-
Notifications
You must be signed in to change notification settings - Fork 77
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
12 changed files
with
437 additions
and
20 deletions.
There are no files selected for viewing
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
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 |
---|---|---|
@@ -1,15 +1,12 @@ | ||
{ | ||
"previewText": "Join %{invitedByUsername} on %{company}", | ||
"company": "%{company}", | ||
"logoAlt": "Vercel Logo", | ||
"joinTeamHeading": "Join %{teamName} on %{company}", | ||
"greeting": "Hi %{username},", | ||
"invitationText": | ||
"%{invitedByUsername} (%{email}) has invited you to join the %{teamName} team on %{company}.", | ||
"invitedToAlt": "Invited to", | ||
"joinTeamButton": "Join the team", | ||
"copyUrlText": "Or copy and paste this URL into your browser:", | ||
"footerText": | ||
"This invitation was intended for %{username} (%{ip} from %{location}). If you were not expecting this invitation, you can ignore this email. If you are concerned about your account's safety, please reply to this email to get in touch with us." | ||
} | ||
|
||
"previewText": "Join %{invitedByUsername} on %{company}", | ||
"company": "%{company}", | ||
"logoAlt": "Vercel Logo", | ||
"joinTeamHeading": "Join %{teamName} on %{company}", | ||
"greeting": "Hi %{username},", | ||
"invitationText": "%{invitedByUsername} (%{email}) has invited you to join the %{teamName} team on %{company}.", | ||
"invitedToAlt": "Invited to", | ||
"joinTeamButton": "Join the team", | ||
"copyUrlText": "Or copy and paste this URL into your browser:", | ||
"footerText": "This invitation was intended for %{username} (%{ip} from %{location}). If you were not expecting this invitation, you can ignore this email. If you are concerned about your account's safety, please reply to this email to get in touch with us." | ||
} |
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
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,113 @@ | ||
import fs from "node:fs"; | ||
import { confirm, intro, outro, text } from "@clack/prompts"; | ||
import { glob } from "glob"; | ||
import { parseJS } from "../parsers/js.js"; | ||
import { getConfig, updateConfig } from "../utils.js"; | ||
|
||
export async function extract(update = false) { | ||
intro("Extracting translation keys..."); | ||
|
||
const config = await getConfig(); | ||
|
||
if (!config.extract?.length) { | ||
const shouldContinue = await confirm({ | ||
message: "Would you like to add extract patterns to your config?", | ||
}); | ||
|
||
if (!shouldContinue) { | ||
process.exit(1); | ||
} | ||
|
||
const pattern = await text({ | ||
message: "Where would you like to extract translations from?", | ||
defaultValue: "./src/**/*.{ts,tsx}", | ||
placeholder: "./src/**/*.{ts,tsx}", | ||
}); | ||
|
||
if (typeof pattern === "symbol") { | ||
process.exit(0); | ||
} | ||
|
||
// Add extract pattern and save config | ||
await updateConfig({ | ||
...config, | ||
extract: [pattern], | ||
}); | ||
|
||
outro("Updated config with extract patterns"); | ||
return []; | ||
} | ||
|
||
const extractedKeys = new Set<string>(); | ||
|
||
for (const pattern of config.extract) { | ||
const files = glob.sync(pattern); | ||
|
||
for (const file of files) { | ||
const code = fs.readFileSync(file, "utf8"); | ||
const keys = parseJS(code); | ||
|
||
for (const key of keys) { | ||
extractedKeys.add(key); | ||
} | ||
} | ||
} | ||
|
||
const keys = Array.from(extractedKeys); | ||
|
||
if (update) { | ||
// Get source locale file path from config | ||
const sourceLocale = config.locale.source; | ||
const sourceFile = config.files.json.include[0].replace( | ||
"[locale]", | ||
sourceLocale, | ||
); | ||
|
||
// Read existing translations if any | ||
let translations: Record<string, string> = {}; | ||
if (fs.existsSync(sourceFile)) { | ||
const content = fs.readFileSync(sourceFile, "utf8"); | ||
const ext = sourceFile.split(".").pop()?.toLowerCase(); | ||
|
||
if (ext === "json") { | ||
translations = JSON.parse(content); | ||
} else if (ext === "ts" || ext === "js") { | ||
// For TS/JS files, evaluate the content | ||
const mod = eval(content); | ||
translations = mod.default || mod; | ||
} else if (ext === "md" || ext === "mdx") { | ||
// For MD/MDX files, parse frontmatter | ||
const match = content.match(/^---\n([\s\S]*?)\n---/); | ||
if (match) { | ||
translations = JSON.parse(match[1]); | ||
} | ||
} | ||
} | ||
|
||
// Add new keys with empty translations | ||
for (const key of keys) { | ||
if (!translations[key]) { | ||
translations[key] = ""; | ||
} | ||
} | ||
|
||
// Write back to source file based on extension | ||
const ext = sourceFile.split(".").pop()?.toLowerCase(); | ||
let output = ""; | ||
|
||
if (ext === "json") { | ||
output = JSON.stringify(translations, null, 2); | ||
} else if (ext === "ts") { | ||
output = `export default ${JSON.stringify(translations, null, 2)}`; | ||
} else if (ext === "js") { | ||
output = `module.exports = ${JSON.stringify(translations, null, 2)}`; | ||
} else if (ext === "md" || ext === "mdx") { | ||
output = `---\n${JSON.stringify(translations, null, 2)}\n---\n`; | ||
} | ||
|
||
fs.writeFileSync(sourceFile, output); | ||
outro("Updated source locale file with new keys"); | ||
} | ||
|
||
return keys; | ||
} |
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
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,115 @@ | ||
import * as babelParser from "@babel/parser"; | ||
import type { Node, ObjectExpression, Parser, StringLiteral } from "./types.js"; | ||
|
||
class FunctionCallParser implements Parser { | ||
constructor(private functions: string[]) {} | ||
|
||
parse(node: Node, keys: string[]): void { | ||
if ( | ||
node.type === "CallExpression" && | ||
node.callee?.type === "Identifier" && | ||
this.functions.includes(node.callee.name || "") | ||
) { | ||
const firstArg = node.arguments?.[0] as StringLiteral | undefined; | ||
if (firstArg?.type === "StringLiteral") { | ||
keys.push(firstArg.value); | ||
} | ||
} | ||
} | ||
} | ||
|
||
class IntlFormatParser implements Parser { | ||
parse(node: Node, keys: string[]): void { | ||
if ( | ||
node.type === "CallExpression" && | ||
node.callee?.type === "MemberExpression" && | ||
node.callee.object?.name === "intl" && | ||
node.callee.property?.name === "formatMessage" | ||
) { | ||
const firstArg = node.arguments?.[0] as ObjectExpression | undefined; | ||
if (firstArg?.type === "ObjectExpression") { | ||
const idProperty = firstArg.properties?.find( | ||
(prop) => prop.key?.name === "id", | ||
); | ||
if (idProperty?.value?.type === "StringLiteral") { | ||
keys.push(idProperty.value.value); | ||
} | ||
} | ||
} | ||
} | ||
} | ||
|
||
class I18nParser implements Parser { | ||
parse(node: Node, keys: string[]): void { | ||
if ( | ||
node.type === "CallExpression" && | ||
node.callee?.type === "MemberExpression" && | ||
node.callee.object?.name === "i18n" && | ||
node.callee.property?.name === "t" | ||
) { | ||
const firstArg = node.arguments?.[0] as StringLiteral | undefined; | ||
if (firstArg?.type === "StringLiteral") { | ||
keys.push(firstArg.value); | ||
} | ||
} | ||
} | ||
} | ||
|
||
class JSXComponentParser implements Parser { | ||
constructor(private components: string[]) {} | ||
|
||
parse(node: Node, keys: string[]): void { | ||
if ( | ||
node.type === "JSXOpeningElement" && | ||
this.components.includes(node.name?.name || "") | ||
) { | ||
const idAttr = node.attributes?.find((attr) => attr.name?.name === "id"); | ||
if (idAttr?.value?.type === "StringLiteral") { | ||
keys.push(idAttr.value.value); | ||
} | ||
} | ||
} | ||
} | ||
|
||
export const parseJS = ( | ||
code: string, | ||
functions: string[] = ["t"], | ||
components: string[] = ["FormattedMessage", "Trans"], | ||
): string[] => { | ||
const ast = babelParser.parse(code, { | ||
sourceType: "module", | ||
plugins: ["jsx", "typescript"], | ||
}); | ||
|
||
const keys: string[] = []; | ||
const parsers: Parser[] = [ | ||
new FunctionCallParser(functions), | ||
new IntlFormatParser(), | ||
new I18nParser(), | ||
new JSXComponentParser(components), | ||
]; | ||
const traverseNode = (node: Node) => { | ||
if (!node) return; | ||
|
||
// Apply all parsers | ||
for (const parser of parsers) { | ||
parser.parse(node, keys); | ||
} | ||
|
||
// Recursively traverse child nodes | ||
for (const key in node) { | ||
const value = node[key as keyof Node]; | ||
if (Array.isArray(value)) { | ||
for (const child of value) { | ||
traverseNode(child as Node); | ||
} | ||
} else if (typeof value === "object" && value !== null) { | ||
traverseNode(value as Node); | ||
} | ||
} | ||
}; | ||
|
||
traverseNode(ast as Node); | ||
|
||
return keys; | ||
}; |
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,51 @@ | ||
export interface StringLiteral { | ||
type: "StringLiteral"; | ||
value: string; | ||
} | ||
|
||
export interface ObjectExpression { | ||
type: "ObjectExpression"; | ||
properties: Property[]; | ||
} | ||
|
||
export interface Property { | ||
key?: { | ||
name: string; | ||
}; | ||
value?: StringLiteral; | ||
} | ||
|
||
export interface JSXAttribute { | ||
name?: { | ||
name: string; | ||
}; | ||
value?: StringLiteral; | ||
} | ||
|
||
export type Node = { | ||
type: string; | ||
callee?: { | ||
type: string; | ||
name?: string; | ||
object?: { | ||
name: string; | ||
}; | ||
property?: { | ||
name: string; | ||
}; | ||
}; | ||
arguments?: (StringLiteral | ObjectExpression)[]; | ||
name?: { | ||
name: string; | ||
}; | ||
attributes?: JSXAttribute[]; | ||
value?: string; | ||
key?: { | ||
name: string; | ||
}; | ||
properties?: Property[]; | ||
}; | ||
|
||
export interface Parser { | ||
parse(node: Node, keys: string[]): void; | ||
} |
Oops, something went wrong.