Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -350,9 +350,29 @@ solidTranslate({
batchSize: 50, // Keys per API call (default: 50)
autoExtract: true, // Auto-extract <T> and msg() strings (default: false)
include: ["src/**/*.tsx"], // Files to scan for extraction
extractImportSources: ["@/i18n"], // Extra module specifiers whose msg/<T> imports count as markers (optional)
})
```

### What extraction considers a marker

Extraction only honors `msg()` calls and `<T>`/`<Plural>` elements whose
identifier actually refers to solid-translate:

- Imported bindings must come from `"solid-translate"` or an accepted
re-export wrapper. By default any specifier whose final path segment is
`solid-translate` or `i18n` (e.g. `@/i18n`, `../lib/i18n`) is accepted;
set `extractImportSources` (plugin config) / `"extractImportSources"`
(CLI config) to an explicit list to override the `i18n` heuristic
(`"solid-translate"` itself is always accepted).
- Aliased imports work: `import { msg as m } from "solid-translate"`
extracts `m("...")`.
- Locally bound identifiers are never extracted — a callback parameter
named `msg`, a local `const msg = ...`, or a local component named `T`
will not pollute the catalog or emit warnings.
- Identifiers with no binding at all are still treated as markers by name,
so snippet-style sources keep working.

## CLI

For translating locale files, JSON, Markdown, and MDX outside of the Vite build.
Expand Down
3 changes: 3 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ async function runExtract() {
code,
relative(root, file),
warnings,
{ importSources: config.extractImportSources },
);
for (const entry of extracted) {
strings[entry.key] = entry.source;
Expand Down Expand Up @@ -298,6 +299,8 @@ async function runCheck(jsonOutput: boolean) {
const entries = extractStringsFromSource(
code,
relative(root, file),
undefined,
{ importSources: config.extractImportSources },
);
for (const entry of entries) {
extracted[entry.key] = entry.source;
Expand Down
233 changes: 230 additions & 3 deletions src/extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,21 @@ export interface ExtractWarning {
message: string;
}

/** Options controlling how extraction markers are recognized */
export interface ExtractOptions {
/**
* Module specifiers accepted as sources of the extraction markers
* (`msg`, `<T>`, `<Plural>`, ...). When a file imports a marker
* identifier, it is only treated as an extraction marker if the import
* comes from one of these specifiers (exact match). `"solid-translate"`
* is always accepted. When omitted, any specifier whose final path
* segment is `solid-translate` or `i18n` (optionally with an extension,
* e.g. `"@/i18n"`, `"../i18n.ts"`) is accepted — covering host-app
* re-export wrappers.
*/
importSources?: string[];
}

// Minimal structural type for Babel AST nodes — avoids a hard dependency on
// @babel/types (we only walk, never construct).
interface Node {
Expand Down Expand Up @@ -65,6 +80,17 @@ interface Node {
* whole `<T>` is skipped with a warning. Wrap such values in `<Var>` to
* make them extractable.
*
* Markers are only honored when the identifier actually refers to
* solid-translate:
* - If the file imports the identifier, the import must come from an
* accepted specifier (see {@link ExtractOptions.importSources});
* aliased imports (`import { msg as m }`) are resolved to their
* imported name.
* - A local binding (function param, `const msg = ...`, a local component
* named `T`, ...) shadows the marker and is never extracted.
* - An identifier with no binding at all is treated as a marker by name
* (backwards compatible with snippet-style sources).
*
* Pass a `warnings` array to collect unextractable shapes (dynamic
* `msg()` arguments, spread props on `<T>`, non-literal `<Plural>`
* forms, ...).
Expand All @@ -73,6 +99,7 @@ export function extractStringsFromSource(
code: string,
filePath: string,
warnings?: ExtractWarning[],
options?: ExtractOptions,
): ExtractedString[] {
const results: ExtractedString[] = [];
const seen = new Set<string>();
Expand All @@ -99,32 +126,232 @@ export function extractStringsFromSource(
results.push(entry);
};

const bindings = collectModuleBindings(ast);
const shadowStack: Set<string>[] = [];

/**
* Resolve a local identifier to the marker it refers to, or null when it
* is bound to something other than a solid-translate import.
*/
const resolveMarker = (localName: string): string | null => {
for (let i = shadowStack.length - 1; i >= 0; i--) {
if (shadowStack[i]!.has(localName)) return null;
}
const imported = bindings.imports.get(localName);
if (imported) {
if (!isAcceptedImportSource(imported.source, options?.importSources)) {
return null;
}
return MARKER_NAMES.has(imported.imported) ? imported.imported : null;
}
if (bindings.moduleLocals.has(localName)) return null;
// Unbound identifier: assume ambient marker (backwards compatible)
return MARKER_NAMES.has(localName) ? localName : null;
};

const visit = (node: Node) => {
const scopeBindings = collectScopeBindings(node);
if (scopeBindings) shadowStack.push(scopeBindings);

if (node.type === "JSXElement") {
const name = jsxName(node);
if (name === "T") {
const marker = name ? resolveMarker(name) : null;
if (marker === "T") {
const entry = processT(node, filePath, warn);
if (entry) push(entry);
} else if (name === "Plural") {
} else if (marker === "Plural") {
for (const entry of processPlural(node, filePath, warn)) {
push(entry);
}
}
} else if (
node.type === "CallExpression" &&
node.callee?.type === "Identifier" &&
node.callee.name === "msg"
resolveMarker(node.callee.name) === "msg"
) {
const entry = processMsg(node, filePath, warn);
if (entry) push(entry);
}
walkChildren(node, visit);

if (scopeBindings) shadowStack.pop();
};

visit(ast);
return results;
}

// ---------------------------------------------------------------------------
// Marker binding resolution
// ---------------------------------------------------------------------------

/** Identifiers solid-translate exports that act as extraction markers */
const MARKER_NAMES = new Set([
"msg",
"T",
"Var",
"Num",
"Currency",
"DateTime",
"Plural",
]);

/**
* Default accepted import specifiers: `solid-translate` itself, or any
* path whose final segment is `solid-translate` or `i18n` (host-app
* re-export wrappers like `@/i18n`, `~/lib/i18n`, `./i18n.ts`).
*/
const DEFAULT_IMPORT_SOURCE_RE =
/(^|\/)(solid-translate|i18n)(\.[cm]?[jt]sx?)?$/;

function isAcceptedImportSource(
source: string,
importSources?: string[],
): boolean {
if (source === "solid-translate") return true;
if (importSources) return importSources.includes(source);
return DEFAULT_IMPORT_SOURCE_RE.test(source);
}

interface ModuleBindings {
/** local name → imported name + module specifier */
imports: Map<string, { imported: string; source: string }>;
/** Marker-named identifiers declared at module level (non-import) */
moduleLocals: Set<string>;
}

/** Collect import bindings and module-level declarations of marker names */
function collectModuleBindings(ast: Node): ModuleBindings {
const imports = new Map<string, { imported: string; source: string }>();
const moduleLocals = new Set<string>();

const body: Node[] = ast.program?.body ?? [];
for (const stmt of body) {
if (stmt.type === "ImportDeclaration") {
const source = String(stmt.source?.value ?? "");
for (const spec of (stmt.specifiers ?? []) as Node[]) {
const local = spec.local?.name;
if (typeof local !== "string") continue;
if (spec.type === "ImportSpecifier") {
const imported =
spec.imported?.type === "Identifier"
? spec.imported.name
: String(spec.imported?.value ?? "");
imports.set(local, { imported, source });
} else {
// Default / namespace imports never bind a marker directly
imports.set(local, { imported: "*", source });
}
}
} else {
collectDeclaredNames(stmt, moduleLocals);
}
}

return { imports, moduleLocals };
}

/** Collect marker-named identifiers a statement declares (top level) */
function collectDeclaredNames(stmt: Node, into: Set<string>) {
if (
stmt.type === "ExportNamedDeclaration" ||
stmt.type === "ExportDefaultDeclaration"
) {
if (stmt.declaration) collectDeclaredNames(stmt.declaration as Node, into);
return;
}
if (stmt.type === "VariableDeclaration") {
for (const decl of (stmt.declarations ?? []) as Node[]) {
if (decl.id) collectPatternNames(decl.id as Node, into);
}
return;
}
if (
(stmt.type === "FunctionDeclaration" ||
stmt.type === "ClassDeclaration" ||
stmt.type === "TSEnumDeclaration") &&
stmt.id?.type === "Identifier"
) {
addMarkerName(stmt.id.name, into);
}
}

/** Collect identifiers bound by a destructuring/param pattern */
function collectPatternNames(pattern: Node, into: Set<string>) {
switch (pattern.type) {
case "Identifier":
addMarkerName(pattern.name, into);
break;
case "AssignmentPattern":
collectPatternNames(pattern.left as Node, into);
break;
case "RestElement":
collectPatternNames(pattern.argument as Node, into);
break;
case "ObjectPattern":
for (const prop of (pattern.properties ?? []) as Node[]) {
if (prop.type === "ObjectProperty") {
collectPatternNames(prop.value as Node, into);
} else if (prop.type === "RestElement") {
collectPatternNames(prop.argument as Node, into);
}
}
break;
case "ArrayPattern":
for (const el of (pattern.elements ?? []) as (Node | null)[]) {
if (el) collectPatternNames(el, into);
}
break;
}
}

function addMarkerName(name: unknown, into: Set<string>) {
if (typeof name === "string" && MARKER_NAMES.has(name)) into.add(name);
}

const FUNCTION_TYPES = new Set([
"ArrowFunctionExpression",
"FunctionExpression",
"FunctionDeclaration",
"ObjectMethod",
"ClassMethod",
"ClassPrivateMethod",
]);

/**
* Marker-named identifiers a node binds for its subtree (function params,
* block-level declarations, catch params, for-loop bindings). Returns null
* when the node introduces no relevant bindings — most nodes — so the
* shadow stack stays shallow.
*/
function collectScopeBindings(node: Node): Set<string> | null {
const bound = new Set<string>();

if (FUNCTION_TYPES.has(node.type)) {
if (node.id?.type === "Identifier") addMarkerName(node.id.name, bound);
for (const param of (node.params ?? []) as Node[]) {
collectPatternNames(param, bound);
}
} else if (node.type === "CatchClause" && node.param) {
collectPatternNames(node.param as Node, bound);
} else if (node.type === "BlockStatement") {
for (const stmt of (node.body ?? []) as Node[]) {
collectDeclaredNames(stmt, bound);
}
} else if (
node.type === "ForStatement" ||
node.type === "ForOfStatement" ||
node.type === "ForInStatement"
) {
const init = node.init ?? node.left;
if (init?.type === "VariableDeclaration") {
collectDeclaredNames(init as Node, bound);
}
}

return bound.size > 0 ? bound : null;
}

// ---------------------------------------------------------------------------
// AST walking
// ---------------------------------------------------------------------------
Expand Down
14 changes: 14 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ export interface SolidTranslatePluginConfig {
* Default: `["src/**\/*.tsx", "src/**\/*.ts", "src/**\/*.jsx"]`
*/
include?: string[];
/**
* Module specifiers accepted as sources of the extraction markers
* (`msg`, `<T>`, `<Plural>`, ...). `"solid-translate"` is always
* accepted. When omitted, any specifier whose final path segment is
* `solid-translate` or `i18n` (e.g. `"@/i18n"`) is accepted.
*/
extractImportSources?: string[];
}

/** A flat dictionary mapping keys to translated strings */
Expand Down Expand Up @@ -99,4 +106,11 @@ export interface CLIConfig {
};
/** Glob patterns for source files to scan */
include?: string[];
/**
* Module specifiers accepted as sources of the extraction markers
* (`msg`, `<T>`, `<Plural>`, ...). `"solid-translate"` is always
* accepted. When omitted, any specifier whose final path segment is
* `solid-translate` or `i18n` (e.g. `"@/i18n"`) is accepted.
*/
extractImportSources?: string[];
}
9 changes: 8 additions & 1 deletion src/vite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export function solidTranslate(config: SolidTranslatePluginConfig): Plugin {
translate = true,
autoExtract = false,
include = ["src/**/*.tsx", "src/**/*.ts", "src/**/*.jsx"],
extractImportSources,
} = config;

let root: string;
Expand Down Expand Up @@ -90,7 +91,11 @@ export function solidTranslate(config: SolidTranslatePluginConfig): Plugin {
// Auto-extraction: scan source files for <T> and msg() strings
let contexts: Record<string, string> = {};
if (autoExtract) {
const extracted = await autoExtractStrings(root, include);
const extracted = await autoExtractStrings(
root,
include,
extractImportSources,
);
contexts = extracted.contexts;

// Merge into source locale file
Expand Down Expand Up @@ -294,6 +299,7 @@ export default solidTranslate;
async function autoExtractStrings(
root: string,
patterns: string[],
importSources?: string[],
): Promise<{ strings: Record<string, string>; contexts: Record<string, string> }> {
const strings: Record<string, string> = {};
const contexts: Record<string, string> = {};
Expand All @@ -311,6 +317,7 @@ async function autoExtractStrings(
code,
relative(root, file),
warnings,
{ importSources },
);
for (const entry of extracted) {
strings[entry.key] = entry.source;
Expand Down
Loading
Loading