-
Notifications
You must be signed in to change notification settings - Fork 86
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
24 changed files
with
965 additions
and
10 deletions.
There are no files selected for viewing
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import { PackageManagerProvider } from "@/components/package-manager-context"; | ||
import { getUpdates } from "@/lib/markdown"; | ||
import type { Metadata } from "next"; | ||
|
||
export const metadata: Metadata = { | ||
title: "Updates", | ||
description: "Latest updates and announcements from Languine", | ||
}; | ||
|
||
interface Update { | ||
slug: string; | ||
frontmatter: { | ||
title: string; | ||
description: string; | ||
date: string; | ||
}; | ||
} | ||
|
||
export default async function Page() { | ||
const updates = await getUpdates(); | ||
|
||
return ( | ||
<PackageManagerProvider> | ||
<div className="container mx-auto max-w-screen-md px-4 py-16 space-y-24"> | ||
{updates} | ||
</div> | ||
</PackageManagerProvider> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,87 @@ | ||
import { waitUntil } from "@vercel/functions"; | ||
import { NextResponse } from "next/server"; | ||
import { | ||
generateKey, | ||
getCacheKey, | ||
getFromCache, | ||
setInCache, | ||
} from "./utils/cache"; | ||
import { verifyApiKeyAndLimits } from "./utils/db"; | ||
import { handleError } from "./utils/errors"; | ||
import { performTranslation, persistTranslation } from "./utils/translation"; | ||
import { translateRequestSchema } from "./utils/validation"; | ||
|
||
export async function POST(request: Request) { | ||
try { | ||
const apiKey = request.headers.get("x-api-key"); | ||
if (!apiKey) { | ||
return NextResponse.json( | ||
{ | ||
success: false, | ||
error: "API key is required. Provide it via x-api-key header", | ||
}, | ||
{ status: 401 }, | ||
); | ||
} | ||
|
||
const body = await request.json(); | ||
const { projectId, sourceLocale, targetLocale, format, sourceText, cache } = | ||
translateRequestSchema.parse(body); | ||
|
||
const org = await verifyApiKeyAndLimits(apiKey, projectId, format); | ||
const key = generateKey(sourceText); | ||
const cacheKey = getCacheKey(projectId, sourceLocale, targetLocale, key); | ||
|
||
// Check cache | ||
if (cache) { | ||
const cachedResult = await getFromCache(cacheKey); | ||
if (cachedResult) { | ||
return NextResponse.json({ | ||
success: true, | ||
translatedText: cachedResult, | ||
cached: true, | ||
}); | ||
} | ||
} | ||
|
||
// Perform translation | ||
const translatedText = await performTranslation(key, sourceText, { | ||
sourceLocale, | ||
targetLocale, | ||
format, | ||
}); | ||
|
||
// Handle persistence in background | ||
const isDocument = format === "md" || format === "mdx"; | ||
|
||
waitUntil( | ||
(async () => { | ||
await persistTranslation( | ||
{ | ||
projectId, | ||
organizationId: org.id, | ||
format, | ||
key, | ||
sourceLocale, | ||
targetLocale, | ||
sourceText, | ||
translatedText, | ||
}, | ||
isDocument, | ||
); | ||
|
||
if (cache) { | ||
await setInCache(cacheKey, translatedText); | ||
} | ||
})(), | ||
); | ||
|
||
return NextResponse.json({ | ||
success: true, | ||
translatedText, | ||
cached: false, | ||
}); | ||
} catch (error) { | ||
return handleError(error); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import { createHash } from "node:crypto"; | ||
import { kv } from "@/lib/kv"; | ||
|
||
// Cache TTL in seconds (8 hours) | ||
export const CACHE_TTL = 60 * 60 * 8; | ||
|
||
export const generateKey = (content: string): string => { | ||
return `api_${createHash("sha256").update(content).digest("hex").slice(0, 8)}`; | ||
}; | ||
|
||
export const getCacheKey = ( | ||
projectId: string, | ||
sourceLocale: string, | ||
targetLocale: string, | ||
key: string, | ||
): string => { | ||
return `translate:${projectId}:${sourceLocale}:${targetLocale}:${key}`; | ||
}; | ||
|
||
export const getFromCache = async ( | ||
cacheKey: string, | ||
): Promise<string | null> => { | ||
return kv.get<string>(cacheKey); | ||
}; | ||
|
||
export const setInCache = async ( | ||
cacheKey: string, | ||
value: string, | ||
ttl: number = CACHE_TTL, | ||
): Promise<void> => { | ||
await kv.set(cacheKey, value, { ex: ttl }); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
import { connectDb } from "@/db"; | ||
import { getOrganizationLimits } from "@/db/queries/organization"; | ||
import { organizations, projects } from "@/db/schema"; | ||
import { TIERS_MAX_DOCUMENTS, TIERS_MAX_KEYS } from "@/lib/tiers"; | ||
import { eq } from "drizzle-orm"; | ||
|
||
export const verifyApiKeyAndLimits = async ( | ||
apiKey: string, | ||
projectId: string, | ||
format?: string, | ||
) => { | ||
if (!apiKey.startsWith("org_")) { | ||
throw new Error("Invalid API key format"); | ||
} | ||
|
||
const db = await connectDb(); | ||
|
||
// Optimize: Single query to get both organization and project | ||
const [org, project] = await Promise.all([ | ||
db | ||
.select() | ||
.from(organizations) | ||
.where(eq(organizations.apiKey, apiKey)) | ||
.limit(1) | ||
.then((rows) => rows[0]), | ||
db | ||
.select() | ||
.from(projects) | ||
.where(eq(projects.id, projectId)) | ||
.limit(1) | ||
.then((rows) => rows[0]), | ||
]); | ||
|
||
if (!org) throw new Error("Invalid API key"); | ||
if (!project || project.organizationId !== org.id) | ||
throw new Error("Project not found or access denied"); | ||
|
||
// Check translation limits | ||
const { totalKeys, totalDocuments } = await getOrganizationLimits(org.id); | ||
const currentKeysLimit = | ||
TIERS_MAX_KEYS[org.tier as keyof typeof TIERS_MAX_KEYS]; | ||
const currentDocsLimit = | ||
TIERS_MAX_DOCUMENTS[org.tier as keyof typeof TIERS_MAX_DOCUMENTS]; | ||
|
||
const isDocument = format === "md" || format === "mdx"; | ||
if (isDocument && totalDocuments >= currentDocsLimit) { | ||
throw new Error("Document limit reached"); | ||
} | ||
|
||
if (!isDocument && totalKeys >= currentKeysLimit) { | ||
throw new Error("Translation key limit reached"); | ||
} | ||
|
||
return org; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
import { NextResponse } from "next/server"; | ||
import { z } from "zod"; | ||
import { getValidationErrorMessage } from "./validation"; | ||
|
||
const errorMessages: Record<string, { status: number; message: string }> = { | ||
"Invalid API key": { | ||
status: 401, | ||
message: "The provided API key is invalid. Please check your credentials.", | ||
}, | ||
"Invalid API key format": { | ||
status: 401, | ||
message: "API key must start with 'org_'. Please check your credentials.", | ||
}, | ||
"Project not found or access denied": { | ||
status: 403, | ||
message: "You don't have access to this project or it doesn't exist.", | ||
}, | ||
"Translation key limit reached": { | ||
status: 403, | ||
message: | ||
"You've reached your translation key limit. Please upgrade your plan to continue.", | ||
}, | ||
"Document limit reached": { | ||
status: 403, | ||
message: | ||
"You've reached your document limit. Please upgrade your plan to continue.", | ||
}, | ||
}; | ||
|
||
export const handleError = (error: unknown) => { | ||
if (error instanceof z.ZodError) { | ||
return NextResponse.json( | ||
{ | ||
success: false, | ||
error: getValidationErrorMessage(error), | ||
}, | ||
{ status: 400 }, | ||
); | ||
} | ||
|
||
if (error instanceof Error) { | ||
const errorInfo = errorMessages[error.message] || { | ||
status: 500, | ||
message: "An unexpected error occurred while processing your request.", | ||
}; | ||
|
||
return NextResponse.json( | ||
{ | ||
success: false, | ||
error: errorInfo.message, | ||
}, | ||
{ status: errorInfo.status }, | ||
); | ||
} | ||
|
||
console.error("Translation error:", error); | ||
return NextResponse.json( | ||
{ | ||
success: false, | ||
error: | ||
"Something went wrong while processing your translation request. Please try again later.", | ||
}, | ||
{ status: 500 }, | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
import { createTranslations } from "@/db/queries/translate"; | ||
import { translateKeys } from "@/jobs/utils/translate"; | ||
|
||
export const performTranslation = async ( | ||
key: string, | ||
sourceText: string, | ||
params: { | ||
sourceLocale: string; | ||
targetLocale: string; | ||
format: string; | ||
}, | ||
): Promise<string> => { | ||
const translatedContent = await translateKeys( | ||
[{ key, sourceText }], | ||
{ | ||
sourceLocale: params.sourceLocale, | ||
targetLocale: params.targetLocale, | ||
sourceFormat: params.format, | ||
}, | ||
1, | ||
); | ||
|
||
return translatedContent[key] || ""; | ||
}; | ||
|
||
export const persistTranslation = async ( | ||
params: { | ||
projectId: string; | ||
organizationId: string; | ||
format: string; | ||
key: string; | ||
sourceLocale: string; | ||
targetLocale: string; | ||
sourceText: string; | ||
translatedText: string; | ||
}, | ||
isDocument: boolean, | ||
): Promise<void> => { | ||
await createTranslations({ | ||
projectId: params.projectId, | ||
organizationId: params.organizationId, | ||
sourceFormat: params.format, | ||
translations: [ | ||
{ | ||
translationKey: params.key, | ||
sourceLanguage: params.sourceLocale, | ||
targetLanguage: params.targetLocale, | ||
sourceText: params.sourceText, | ||
translatedText: params.translatedText, | ||
sourceFile: "api", | ||
sourceType: isDocument ? "document" : "key", | ||
}, | ||
], | ||
}); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
import { z } from "zod"; | ||
|
||
// Supported formats from the codebase | ||
export const FORMAT_ENUM = [ | ||
"string", | ||
"json", | ||
"yaml", | ||
"properties", | ||
"android", | ||
"xcode-strings", | ||
"xcode-stringsdict", | ||
"xcode-xcstrings", | ||
"md", | ||
"mdx", | ||
"html", | ||
"js", | ||
"ts", | ||
"po", | ||
"xliff", | ||
"csv", | ||
"xml", | ||
"arb", | ||
] as const; | ||
|
||
export const translateRequestSchema = z.object({ | ||
projectId: z.string(), | ||
sourceLocale: z.string(), | ||
targetLocale: z.string(), | ||
format: z.enum(FORMAT_ENUM).optional().default("string"), | ||
sourceText: z.string(), | ||
cache: z.boolean().optional().default(true), | ||
}); | ||
|
||
export type TranslateRequest = z.infer<typeof translateRequestSchema>; | ||
|
||
export const getValidationErrorMessage = (error: z.ZodError): string => { | ||
const errors = error.errors.map((err) => { | ||
const field = err.path.join("."); | ||
switch (err.code) { | ||
case "invalid_type": | ||
return `${field} must be a ${err.expected}`; | ||
case "invalid_enum_value": | ||
return `${field} must be one of: ${err.options?.join(", ")}`; | ||
case "invalid_string": | ||
return `${field} is not valid`; | ||
case "too_small": | ||
return `${field} is required`; | ||
default: | ||
return `${field} is invalid`; | ||
} | ||
}); | ||
return errors[0] || "Invalid request format"; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.