intor-cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -0
- package/package.json +73 -0
- package/src/build/build-schemas/build-schemas.ts +13 -0
- package/src/build/build-schemas/index.ts +1 -0
- package/src/build/build-types/build-types.ts +46 -0
- package/src/build/build-types/index.ts +1 -0
- package/src/build/build-types/output/append-config-block.ts +34 -0
- package/src/build/build-types/output/append-footer.ts +5 -0
- package/src/build/build-types/output/append-header.ts +11 -0
- package/src/build/build-types/output/index.ts +3 -0
- package/src/build/build-types/utils/indent.ts +3 -0
- package/src/build/build-types/utils/render-infer-node.ts +29 -0
- package/src/build/index.ts +3 -0
- package/src/build/types.ts +19 -0
- package/src/cli/commands/check.ts +50 -0
- package/src/cli/commands/generate.ts +61 -0
- package/src/cli/index.ts +24 -0
- package/src/core/collect-messages/collect-runtime-messages.ts +76 -0
- package/src/core/collect-messages/index.ts +1 -0
- package/src/core/collect-messages/readers.ts +23 -0
- package/src/core/collect-messages/resolve-messages-reader.ts +57 -0
- package/src/core/constants/extra-exts.ts +2 -0
- package/src/core/constants/generated-files.ts +3 -0
- package/src/core/constants/index.ts +7 -0
- package/src/core/diagnostics/collect.ts +59 -0
- package/src/core/diagnostics/group.ts +41 -0
- package/src/core/diagnostics/index.ts +2 -0
- package/src/core/diagnostics/messages.ts +51 -0
- package/src/core/diagnostics/rules/enforce-missing-replacements.ts +55 -0
- package/src/core/diagnostics/rules/enforce-missing-rich.ts +55 -0
- package/src/core/diagnostics/rules/key/empty.ts +34 -0
- package/src/core/diagnostics/rules/key/index.ts +2 -0
- package/src/core/diagnostics/rules/key/not-found.ts +43 -0
- package/src/core/diagnostics/rules/replacement/index.ts +3 -0
- package/src/core/diagnostics/rules/replacement/missing.ts +48 -0
- package/src/core/diagnostics/rules/replacement/not-allowed.ts +43 -0
- package/src/core/diagnostics/rules/replacement/unused.ts +48 -0
- package/src/core/diagnostics/rules/rich/index.ts +3 -0
- package/src/core/diagnostics/rules/rich/missing.ts +48 -0
- package/src/core/diagnostics/rules/rich/not-allowed.ts +43 -0
- package/src/core/diagnostics/rules/rich/unused.ts +48 -0
- package/src/core/diagnostics/types.ts +21 -0
- package/src/core/diagnostics/utils/get-schema-node-at-path.ts +29 -0
- package/src/core/diagnostics/utils/index-usages-by-key.ts +28 -0
- package/src/core/diagnostics/utils/resolve-key-path.ts +5 -0
- package/src/core/discover-configs/discover-configs.ts +87 -0
- package/src/core/discover-configs/index.ts +1 -0
- package/src/core/discover-configs/is-intor-resolved-config.ts +15 -0
- package/src/core/extract-usages/README.md +84 -0
- package/src/core/extract-usages/collectors/collect-key-usages.ts +40 -0
- package/src/core/extract-usages/collectors/collect-pre-keys.ts +58 -0
- package/src/core/extract-usages/collectors/collect-replacement-usages.ts +68 -0
- package/src/core/extract-usages/collectors/collect-rich-usages.ts +57 -0
- package/src/core/extract-usages/collectors/collect-translator-bindings.ts +56 -0
- package/src/core/extract-usages/collectors/index.ts +5 -0
- package/src/core/extract-usages/collectors/utils/extract-static-object-keys.ts +31 -0
- package/src/core/extract-usages/collectors/utils/get-config-key.ts +15 -0
- package/src/core/extract-usages/collectors/utils/get-object-arg.ts +42 -0
- package/src/core/extract-usages/collectors/utils/is-static-string-literal.ts +26 -0
- package/src/core/extract-usages/collectors/utils/walk-translator-bindings.ts +47 -0
- package/src/core/extract-usages/collectors/utils/walk-translator-method-calls.ts +42 -0
- package/src/core/extract-usages/extract-usages.ts +88 -0
- package/src/core/extract-usages/index.ts +13 -0
- package/src/core/extract-usages/load-source-files-from-tscofnig.ts +76 -0
- package/src/core/extract-usages/translator-registry.ts +20 -0
- package/src/core/extract-usages/types.ts +53 -0
- package/src/core/index.ts +13 -0
- package/src/core/infer-schema/index.ts +3 -0
- package/src/core/infer-schema/infer-schemas.ts +16 -0
- package/src/core/infer-schema/messages/index.ts +1 -0
- package/src/core/infer-schema/messages/infer-messages-schema.ts +64 -0
- package/src/core/infer-schema/replacements/extract-interpolation-names.ts +70 -0
- package/src/core/infer-schema/replacements/index.ts +1 -0
- package/src/core/infer-schema/replacements/infer-replacements-schema.ts +63 -0
- package/src/core/infer-schema/rich/index.ts +1 -0
- package/src/core/infer-schema/rich/infer-rich-schema.ts +66 -0
- package/src/core/infer-schema/types.ts +42 -0
- package/src/core/infer-schema/utils/infer-object.ts +32 -0
- package/src/core/infer-schema/utils/is-message-object.ts +5 -0
- package/src/core/read-generated-schema.ts +42 -0
- package/src/core/scan-logger.ts +10 -0
- package/src/core/write-generated-files.ts +51 -0
- package/src/features/check/check.ts +75 -0
- package/src/features/check/index.ts +1 -0
- package/src/features/check/print-summary.ts +28 -0
- package/src/features/generate/generate.ts +76 -0
- package/src/features/generate/index.ts +1 -0
- package/src/features/generate/print-configs.ts +8 -0
- package/src/features/generate/print-summary.ts +19 -0
- package/src/features/index.ts +2 -0
- package/src/features/print-title.ts +7 -0
- package/src/features/spinner.ts +3 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CallExpression,
|
|
3
|
+
ObjectBindingPattern,
|
|
4
|
+
SourceFile,
|
|
5
|
+
} from "ts-morph";
|
|
6
|
+
import { SyntaxKind } from "ts-morph";
|
|
7
|
+
|
|
8
|
+
export interface DestructuredVariableCallContext {
|
|
9
|
+
sourceFile: SourceFile;
|
|
10
|
+
call: CallExpression;
|
|
11
|
+
binding: ObjectBindingPattern;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Walk through destructured variable declarations initialized by a call.
|
|
16
|
+
*/
|
|
17
|
+
export function walkTranslatorBindings(
|
|
18
|
+
sourceFile: SourceFile,
|
|
19
|
+
visitor: (ctx: DestructuredVariableCallContext) => void,
|
|
20
|
+
): void {
|
|
21
|
+
sourceFile.forEachDescendant((node) => {
|
|
22
|
+
// Only handle variable declarations (e.g. `const x = ...`)
|
|
23
|
+
if (!node.isKind(SyntaxKind.VariableDeclaration)) return;
|
|
24
|
+
|
|
25
|
+
// Ensure the declaration has an initializer (skip `let x;`)
|
|
26
|
+
const initializer = node.getInitializer();
|
|
27
|
+
if (!initializer) return;
|
|
28
|
+
|
|
29
|
+
// Unwrap awaited initializers (e.g. `await foo()` -> `foo()`)
|
|
30
|
+
const call = initializer.isKind(SyntaxKind.AwaitExpression)
|
|
31
|
+
? initializer.getExpression()
|
|
32
|
+
: initializer;
|
|
33
|
+
if (!call.isKind(SyntaxKind.CallExpression)) return;
|
|
34
|
+
|
|
35
|
+
// Only support object destructuring bindings
|
|
36
|
+
// Supported: `const { t } = useTranslator()`
|
|
37
|
+
// Ignored: `const translator = useTranslator()` (no destructuring)
|
|
38
|
+
const binding = node.getNameNode();
|
|
39
|
+
if (!binding.isKind(SyntaxKind.ObjectBindingPattern)) return;
|
|
40
|
+
|
|
41
|
+
visitor({
|
|
42
|
+
sourceFile,
|
|
43
|
+
call,
|
|
44
|
+
binding,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { TranslatorBinding, TranslatorBindingMap } from "../../types";
|
|
2
|
+
import type { SourceFile, CallExpression } from "ts-morph";
|
|
3
|
+
import { SyntaxKind } from "ts-morph";
|
|
4
|
+
|
|
5
|
+
interface TranslatorCallContext {
|
|
6
|
+
sourceFile: SourceFile;
|
|
7
|
+
translatorUsage: TranslatorBinding;
|
|
8
|
+
call: CallExpression;
|
|
9
|
+
localName: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Walk through all static translator method calls within a source file.
|
|
14
|
+
*/
|
|
15
|
+
export function walkTranslatorMethodCalls(
|
|
16
|
+
sourceFile: SourceFile,
|
|
17
|
+
translatorBindingMap: TranslatorBindingMap,
|
|
18
|
+
visitor: (ctx: TranslatorCallContext) => void,
|
|
19
|
+
): void {
|
|
20
|
+
sourceFile.forEachDescendant((node) => {
|
|
21
|
+
// Only care about call expressions (e.g. `t(...)`)
|
|
22
|
+
if (!node.isKind(SyntaxKind.CallExpression)) return;
|
|
23
|
+
|
|
24
|
+
// Only support direct identifier calls.
|
|
25
|
+
// Supported: `t("key")`
|
|
26
|
+
// Ignored: `translator.t("key")`, `getTranslator().t("key")`
|
|
27
|
+
const expr = node.getExpression();
|
|
28
|
+
if (!expr.isKind(SyntaxKind.Identifier)) return;
|
|
29
|
+
|
|
30
|
+
// Match against known translator bindings
|
|
31
|
+
const localName = expr.getText();
|
|
32
|
+
const translatorUsage = translatorBindingMap.get(localName);
|
|
33
|
+
if (!translatorUsage) return;
|
|
34
|
+
|
|
35
|
+
visitor({
|
|
36
|
+
sourceFile,
|
|
37
|
+
translatorUsage,
|
|
38
|
+
call: node,
|
|
39
|
+
localName,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ExtractedUsages } from "./types";
|
|
2
|
+
import pc from "picocolors";
|
|
3
|
+
import { createLogger } from "../scan-logger";
|
|
4
|
+
import {
|
|
5
|
+
collectTranslatorBindings,
|
|
6
|
+
collectKeyUsages,
|
|
7
|
+
collectReplacementUsages,
|
|
8
|
+
collectRichUsages,
|
|
9
|
+
collectPreKeys,
|
|
10
|
+
} from "./collectors";
|
|
11
|
+
import { loadSourceFilesFromTsconfig } from "./load-source-files-from-tscofnig";
|
|
12
|
+
|
|
13
|
+
export interface ExtractUsagesOptions {
|
|
14
|
+
tsconfigPath?: string;
|
|
15
|
+
debug?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Extract all static translator usages from a TypeScript project.
|
|
20
|
+
*/
|
|
21
|
+
export function extractUsages(options?: ExtractUsagesOptions): ExtractedUsages {
|
|
22
|
+
const { tsconfigPath = "tsconfig.json", debug = false } = options || {};
|
|
23
|
+
const log = createLogger(debug);
|
|
24
|
+
|
|
25
|
+
const result: ExtractedUsages = {
|
|
26
|
+
keys: [],
|
|
27
|
+
replacements: [],
|
|
28
|
+
rich: [],
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
let scannedFiles = 0;
|
|
32
|
+
let matchedFiles = 0;
|
|
33
|
+
|
|
34
|
+
const sourceFiles = loadSourceFilesFromTsconfig(tsconfigPath, debug);
|
|
35
|
+
|
|
36
|
+
// Process each source file independently
|
|
37
|
+
for (const sourceFile of sourceFiles) {
|
|
38
|
+
scannedFiles++;
|
|
39
|
+
log("scan", sourceFile.getFilePath());
|
|
40
|
+
|
|
41
|
+
// -----------------------------------------------------------------------
|
|
42
|
+
// Translator binding
|
|
43
|
+
// -----------------------------------------------------------------------
|
|
44
|
+
const translatorBindingMap = collectTranslatorBindings(sourceFile);
|
|
45
|
+
if (translatorBindingMap.size === 0) continue;
|
|
46
|
+
matchedFiles++;
|
|
47
|
+
|
|
48
|
+
// -----------------------------------------------------------------------
|
|
49
|
+
// Key usages
|
|
50
|
+
// -----------------------------------------------------------------------
|
|
51
|
+
const keyUsages = collectKeyUsages(sourceFile, translatorBindingMap);
|
|
52
|
+
|
|
53
|
+
// -----------------------------------------------------------------------
|
|
54
|
+
// Replacement usages
|
|
55
|
+
// -----------------------------------------------------------------------
|
|
56
|
+
const replacementUsages = collectReplacementUsages(
|
|
57
|
+
sourceFile,
|
|
58
|
+
translatorBindingMap,
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
// -----------------------------------------------------------------------
|
|
62
|
+
// Rich usages
|
|
63
|
+
// -----------------------------------------------------------------------
|
|
64
|
+
const richUsages = collectRichUsages(sourceFile, translatorBindingMap);
|
|
65
|
+
|
|
66
|
+
// -----------------------------------------------------------------------
|
|
67
|
+
// PreKey values
|
|
68
|
+
// -----------------------------------------------------------------------
|
|
69
|
+
const preKeyMap = collectPreKeys(sourceFile, translatorBindingMap);
|
|
70
|
+
|
|
71
|
+
// Attach preKey to all usages that support it
|
|
72
|
+
for (const usage of [...keyUsages, ...replacementUsages, ...richUsages]) {
|
|
73
|
+
usage.preKey = preKeyMap.get(usage.localName);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
result.keys.push(...keyUsages);
|
|
77
|
+
result.replacements.push(...replacementUsages);
|
|
78
|
+
result.rich.push(...richUsages);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (debug) {
|
|
82
|
+
console.log(
|
|
83
|
+
pc.dim(` › Scanned ${scannedFiles} files, matched ${matchedFiles} \n`),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import { Project, type SourceFile } from "ts-morph";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Load source files from a tsconfig.
|
|
8
|
+
*
|
|
9
|
+
* Strategy:
|
|
10
|
+
* 1. Try loading source files from the given tsconfig directly.
|
|
11
|
+
* 2. If no source files are found, follow project references (one level).
|
|
12
|
+
* 3. Return the collected source files for further static analysis.
|
|
13
|
+
*
|
|
14
|
+
* Notes:
|
|
15
|
+
* - This is designed to support Vite-style tsconfig setups
|
|
16
|
+
* where the root tsconfig only contains references.
|
|
17
|
+
* - References are followed non-recursively on purpose.
|
|
18
|
+
*/
|
|
19
|
+
export function loadSourceFilesFromTsconfig(
|
|
20
|
+
tsconfigPath: string,
|
|
21
|
+
debug: boolean,
|
|
22
|
+
): SourceFile[] {
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// 1. Try loading source files directly from the given tsconfig
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
const project = new Project({ tsConfigFilePath: tsconfigPath });
|
|
27
|
+
const files = project.getSourceFiles();
|
|
28
|
+
if (files.length > 0) return files;
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// 2. No source files found → attempt to follow project references
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
const configDir = path.dirname(tsconfigPath);
|
|
34
|
+
const rawConfig = JSON.parse(fs.readFileSync(tsconfigPath, "utf8"));
|
|
35
|
+
const references: { path: string }[] = rawConfig.references ?? [];
|
|
36
|
+
|
|
37
|
+
if (references.length === 0) return [];
|
|
38
|
+
|
|
39
|
+
if (debug) {
|
|
40
|
+
console.log(
|
|
41
|
+
pc.dim(" (info)") +
|
|
42
|
+
pc.gray(" no source files found, following project references"),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// 3. Load source files from each referenced tsconfig
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
const collected: SourceFile[] = [];
|
|
50
|
+
|
|
51
|
+
for (const ref of references) {
|
|
52
|
+
const refPath = path.resolve(configDir, ref.path);
|
|
53
|
+
|
|
54
|
+
// Skip missing referenced tsconfig files
|
|
55
|
+
if (!fs.existsSync(refPath)) {
|
|
56
|
+
if (debug) {
|
|
57
|
+
console.log(
|
|
58
|
+
pc.dim(" (warn)") +
|
|
59
|
+
pc.gray(` referenced tsconfig not found: ${refPath}`),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (debug) {
|
|
66
|
+
console.log(
|
|
67
|
+
pc.dim(" (ref)") + pc.gray(` ${path.relative(process.cwd(), refPath)}`),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const refProject = new Project({ tsConfigFilePath: refPath });
|
|
72
|
+
collected.push(...refProject.getSourceFiles());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return collected;
|
|
76
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registry of supported Intor translator factories and methods for static key extraction.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
// Factories
|
|
6
|
+
export const TRANSLATOR_FACTORY = {
|
|
7
|
+
getTranslator: "getTranslator",
|
|
8
|
+
useTranslator: "useTranslator",
|
|
9
|
+
} as const;
|
|
10
|
+
export const TRANSLATOR_FACTORIES = Object.values(TRANSLATOR_FACTORY);
|
|
11
|
+
export type TranslatorFactory = (typeof TRANSLATOR_FACTORIES)[number];
|
|
12
|
+
|
|
13
|
+
// Methods
|
|
14
|
+
export const TRANSLATOR_METHOD = {
|
|
15
|
+
hasKey: "hasKey",
|
|
16
|
+
t: "t",
|
|
17
|
+
tRich: "tRich",
|
|
18
|
+
} as const;
|
|
19
|
+
export const TRANSLATOR_METHODS = Object.values(TRANSLATOR_METHOD);
|
|
20
|
+
export type TranslatorMethod = (typeof TRANSLATOR_METHODS)[number];
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TranslatorFactory,
|
|
3
|
+
TranslatorMethod,
|
|
4
|
+
} from "./translator-registry";
|
|
5
|
+
|
|
6
|
+
/** Describes a local translator binding resolved from a factory and method. */
|
|
7
|
+
export interface TranslatorBinding {
|
|
8
|
+
configKey?: string;
|
|
9
|
+
factory: TranslatorFactory;
|
|
10
|
+
method: TranslatorMethod;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Map of local binding names to their translator bindings. */
|
|
14
|
+
export type TranslatorBindingMap = Map<string, TranslatorBinding>;
|
|
15
|
+
|
|
16
|
+
/** Map of local binding names to their resolved preKey context. */
|
|
17
|
+
export type PreKeyMap = Map<string, string>;
|
|
18
|
+
|
|
19
|
+
interface SourceLocation {
|
|
20
|
+
file: string;
|
|
21
|
+
line: number;
|
|
22
|
+
column: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Static translation key usage extracted from a source file. */
|
|
26
|
+
export interface KeyUsage extends TranslatorBinding, SourceLocation {
|
|
27
|
+
localName: string; // local binding name (e.g. `t`, `hasKey`)
|
|
28
|
+
key: string;
|
|
29
|
+
preKey?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Static replacement usage extracted from a translation call. */
|
|
33
|
+
export interface ReplacementUsage extends TranslatorBinding, SourceLocation {
|
|
34
|
+
localName: string;
|
|
35
|
+
key: string;
|
|
36
|
+
replacements: string[];
|
|
37
|
+
preKey?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Static rich-tag usage extracted from a rich translation call. */
|
|
41
|
+
export interface RichUsage extends TranslatorBinding, SourceLocation {
|
|
42
|
+
localName: string;
|
|
43
|
+
key: string;
|
|
44
|
+
rich: string[];
|
|
45
|
+
preKey?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Aggregated static translator usages extracted from a project. */
|
|
49
|
+
export interface ExtractedUsages {
|
|
50
|
+
keys: KeyUsage[];
|
|
51
|
+
replacements: ReplacementUsage[];
|
|
52
|
+
rich: RichUsage[];
|
|
53
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { discoverConfigs } from "./discover-configs";
|
|
2
|
+
export { collectRuntimeMessages } from "./collect-messages";
|
|
3
|
+
export { inferSchemas, type InferredSchemas } from "./infer-schema";
|
|
4
|
+
export { extractUsages } from "./extract-usages";
|
|
5
|
+
export { collectDiagnostics, groupDiagnostics } from "./diagnostics";
|
|
6
|
+
|
|
7
|
+
export { writeGeneratedFiles } from "./write-generated-files";
|
|
8
|
+
export {
|
|
9
|
+
readGeneratedSchema,
|
|
10
|
+
type ReadGeneratedSchemaOptions,
|
|
11
|
+
} from "./read-generated-schema";
|
|
12
|
+
|
|
13
|
+
export { EXTRA_EXTS, type ExtraExt } from "./constants";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { InferredSchemas } from "./types";
|
|
2
|
+
import type { MessageObject } from "intor";
|
|
3
|
+
import { inferMessagesSchema } from "./messages";
|
|
4
|
+
import { inferReplacementsSchema } from "./replacements";
|
|
5
|
+
import { inferRichSchema } from "./rich";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Infer all semantic schemas from messages.
|
|
9
|
+
*/
|
|
10
|
+
export function inferSchemas(messages: MessageObject): InferredSchemas {
|
|
11
|
+
return {
|
|
12
|
+
messagesSchema: inferMessagesSchema(messages),
|
|
13
|
+
replacementsSchema: inferReplacementsSchema(messages),
|
|
14
|
+
richSchema: inferRichSchema(messages),
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { inferMessagesSchema } from "./infer-messages-schema";
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { InferNode } from "../types";
|
|
2
|
+
import type { MessageObject, MessageValue } from "intor";
|
|
3
|
+
import { inferObject } from "../utils/infer-object";
|
|
4
|
+
import { isMessageObject } from "../utils/is-message-object";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Infer message value types from a message object.
|
|
8
|
+
*
|
|
9
|
+
* Traverses message values and derives a semantic schema tree.
|
|
10
|
+
* Rendering to TypeScript type string should be done in a later stage.
|
|
11
|
+
*/
|
|
12
|
+
export function inferMessagesSchema(messages: MessageObject): InferNode {
|
|
13
|
+
if (!isMessageObject(messages) || Object.keys(messages).length === 0) {
|
|
14
|
+
return { kind: "none" };
|
|
15
|
+
}
|
|
16
|
+
return inferValue(messages);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Infer a semantic node from a single message value.
|
|
21
|
+
*
|
|
22
|
+
* - Primitive values → primitive node
|
|
23
|
+
* - Arrays → array node (first-element policy)
|
|
24
|
+
* - Objects → object node (recursive, empty pruned)
|
|
25
|
+
* - Unsupported → none
|
|
26
|
+
*/
|
|
27
|
+
function inferValue(value: MessageValue): InferNode {
|
|
28
|
+
// ----------------------------------------------------------------------
|
|
29
|
+
// Primitive values
|
|
30
|
+
// ----------------------------------------------------------------------
|
|
31
|
+
if (typeof value === "string") return { kind: "primitive", type: "string" };
|
|
32
|
+
if (typeof value === "number") return { kind: "primitive", type: "number" };
|
|
33
|
+
if (typeof value === "boolean") return { kind: "primitive", type: "boolean" };
|
|
34
|
+
if (value === null) return { kind: "primitive", type: "null" };
|
|
35
|
+
|
|
36
|
+
// ----------------------------------------------------------------------
|
|
37
|
+
// Array values
|
|
38
|
+
// ----------------------------------------------------------------------
|
|
39
|
+
if (Array.isArray(value)) {
|
|
40
|
+
// empty array → unknown element
|
|
41
|
+
if (value.length === 0) {
|
|
42
|
+
return { kind: "array", element: { kind: "none" } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { kind: "array", element: inferValue(value[0]) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ----------------------------------------------------------------------
|
|
49
|
+
// Object values
|
|
50
|
+
// ----------------------------------------------------------------------
|
|
51
|
+
if (isMessageObject(value)) {
|
|
52
|
+
const result = inferObject(value, inferValue);
|
|
53
|
+
|
|
54
|
+
// empty object → fallback record
|
|
55
|
+
if (result.kind === "none") {
|
|
56
|
+
return { kind: "record" };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Fallback
|
|
63
|
+
return { kind: "none" };
|
|
64
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract interpolation argument names from a message string.
|
|
3
|
+
*
|
|
4
|
+
* Policy:
|
|
5
|
+
* - Only the outermost `{ ... }` blocks are considered
|
|
6
|
+
* - Nested `{}` are treated as part of the same block
|
|
7
|
+
* - For each block, only the identifier before the first `,` is extracted
|
|
8
|
+
* - Unmatched `{` or `}` are ignored
|
|
9
|
+
* - No ICU syntax parsing or semantic validation is performed
|
|
10
|
+
*
|
|
11
|
+
* This function is syntax-aware but not syntax-validating.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* extractInterpolationNames("Hello, {name}.")
|
|
16
|
+
* // => ["name"]
|
|
17
|
+
*
|
|
18
|
+
* extractInterpolationNames(
|
|
19
|
+
* "{name} has {count, plural, =0 {no messages} one {1 message} other {# messages}}."
|
|
20
|
+
* )
|
|
21
|
+
* // => ["name", "count"]
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
export function extractInterpolationNames(message: string): string[] {
|
|
25
|
+
const result = new Set<string>();
|
|
26
|
+
|
|
27
|
+
let depth = 0;
|
|
28
|
+
let start = -1;
|
|
29
|
+
|
|
30
|
+
for (let i = 0; i < message.length; i++) {
|
|
31
|
+
const char = message[i];
|
|
32
|
+
|
|
33
|
+
if (char === "{") {
|
|
34
|
+
if (depth === 0) {
|
|
35
|
+
start = i + 1;
|
|
36
|
+
}
|
|
37
|
+
depth++;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (char === "}") {
|
|
42
|
+
depth--;
|
|
43
|
+
|
|
44
|
+
// Unbalanced closing brace → reset state
|
|
45
|
+
if (depth < 0) {
|
|
46
|
+
depth = 0;
|
|
47
|
+
start = -1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (depth === 0 && start !== -1) {
|
|
52
|
+
const raw = message.slice(start, i).trim();
|
|
53
|
+
|
|
54
|
+
// ICU invariant:
|
|
55
|
+
// argument name is always the first segment
|
|
56
|
+
const name = raw.split(",", 1)[0].trim();
|
|
57
|
+
|
|
58
|
+
if (name) {
|
|
59
|
+
result.add(name);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
start = -1;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return [...result];
|
|
70
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { inferReplacementsSchema } from "./infer-replacements-schema";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { InferNode } from "../types";
|
|
2
|
+
import type { MessageObject, MessageValue } from "intor";
|
|
3
|
+
import { inferObject } from "../utils/infer-object";
|
|
4
|
+
import { isMessageObject } from "../utils/is-message-object";
|
|
5
|
+
import { extractInterpolationNames } from "./extract-interpolation-names";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Infer interpolation replacement schema from a message object.
|
|
9
|
+
*
|
|
10
|
+
* Traverses message values and extracts `{...}` interpolation names
|
|
11
|
+
* into a semantic schema tree.
|
|
12
|
+
*/
|
|
13
|
+
export function inferReplacementsSchema(messages: MessageObject): InferNode {
|
|
14
|
+
if (!isMessageObject(messages) || Object.keys(messages).length === 0) {
|
|
15
|
+
return { kind: "none" };
|
|
16
|
+
}
|
|
17
|
+
return inferValue(messages);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Infer replacement information from a single message value.
|
|
22
|
+
*
|
|
23
|
+
* - Strings are scanned for `{...}` interpolations
|
|
24
|
+
* - Objects are traversed recursively
|
|
25
|
+
* - Arrays and unsupported values are ignored
|
|
26
|
+
*/
|
|
27
|
+
function inferValue(value: MessageValue): InferNode {
|
|
28
|
+
// ----------------------------------------------------------------------
|
|
29
|
+
// String values (replacement source)
|
|
30
|
+
// ----------------------------------------------------------------------
|
|
31
|
+
if (typeof value === "string") {
|
|
32
|
+
const names = extractInterpolationNames(value);
|
|
33
|
+
|
|
34
|
+
// No replacements found
|
|
35
|
+
if (names.length === 0) {
|
|
36
|
+
return { kind: "none" };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const properties: Record<string, InferNode> = {};
|
|
40
|
+
for (const name of names) {
|
|
41
|
+
properties[name] = { kind: "primitive", type: "string" };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { kind: "object", properties };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ----------------------------------------------------------------------
|
|
48
|
+
// Array values (semantically irrelevant for replacements)
|
|
49
|
+
// ----------------------------------------------------------------------
|
|
50
|
+
if (Array.isArray(value)) {
|
|
51
|
+
return { kind: "none" };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ----------------------------------------------------------------------
|
|
55
|
+
// Object values (delegate aggregation & pruning)
|
|
56
|
+
// ----------------------------------------------------------------------
|
|
57
|
+
if (isMessageObject(value)) {
|
|
58
|
+
return inferObject(value, inferValue);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Fallback
|
|
62
|
+
return { kind: "none" };
|
|
63
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { inferRichSchema } from "./infer-rich-schema";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { InferNode } from "../types";
|
|
2
|
+
import type { MessageObject, MessageValue } from "intor";
|
|
3
|
+
import { tokenize, type Token } from "intor";
|
|
4
|
+
import { inferObject } from "../utils/infer-object";
|
|
5
|
+
import { isMessageObject } from "../utils/is-message-object";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Infer rich tag schema from a message object.
|
|
9
|
+
*
|
|
10
|
+
* Traverses message values and extracts rich tag names
|
|
11
|
+
* into a semantic schema tree.
|
|
12
|
+
*/
|
|
13
|
+
export function inferRichSchema(messages: MessageObject): InferNode {
|
|
14
|
+
if (!isMessageObject(messages) || Object.keys(messages).length === 0) {
|
|
15
|
+
return { kind: "none" };
|
|
16
|
+
}
|
|
17
|
+
return inferValue(messages);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Infer rich tag information from a single message value.
|
|
22
|
+
*
|
|
23
|
+
* - Strings are tokenized and analyzed for rich tags
|
|
24
|
+
* - Objects are traversed recursively
|
|
25
|
+
* - Arrays and unsupported values are ignored
|
|
26
|
+
*/
|
|
27
|
+
function inferValue(value: MessageValue): InferNode {
|
|
28
|
+
// ----------------------------------------------------------------------
|
|
29
|
+
// String values (rich source)
|
|
30
|
+
// ----------------------------------------------------------------------
|
|
31
|
+
if (typeof value === "string") {
|
|
32
|
+
const tokens: Token[] = tokenize(value);
|
|
33
|
+
|
|
34
|
+
// Collect unique rich tag names
|
|
35
|
+
const properties: Record<string, InferNode> = {};
|
|
36
|
+
|
|
37
|
+
for (const token of tokens) {
|
|
38
|
+
if (token.type !== "tag-open") continue;
|
|
39
|
+
properties[token.name] = { kind: "none" };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// No rich tags found
|
|
43
|
+
if (Object.keys(properties).length === 0) {
|
|
44
|
+
return { kind: "none" };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { kind: "object", properties };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ----------------------------------------------------------------------
|
|
51
|
+
// Array values (semantically irrelevant for rich tags)
|
|
52
|
+
// ----------------------------------------------------------------------
|
|
53
|
+
if (Array.isArray(value)) {
|
|
54
|
+
return { kind: "none" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ----------------------------------------------------------------------
|
|
58
|
+
// Object values (delegate aggregation & pruning)
|
|
59
|
+
// ----------------------------------------------------------------------
|
|
60
|
+
if (isMessageObject(value)) {
|
|
61
|
+
return inferObject(value, inferValue);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Fallback
|
|
65
|
+
return { kind: "none" };
|
|
66
|
+
}
|