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.
Files changed (92) hide show
  1. package/README.md +46 -0
  2. package/package.json +73 -0
  3. package/src/build/build-schemas/build-schemas.ts +13 -0
  4. package/src/build/build-schemas/index.ts +1 -0
  5. package/src/build/build-types/build-types.ts +46 -0
  6. package/src/build/build-types/index.ts +1 -0
  7. package/src/build/build-types/output/append-config-block.ts +34 -0
  8. package/src/build/build-types/output/append-footer.ts +5 -0
  9. package/src/build/build-types/output/append-header.ts +11 -0
  10. package/src/build/build-types/output/index.ts +3 -0
  11. package/src/build/build-types/utils/indent.ts +3 -0
  12. package/src/build/build-types/utils/render-infer-node.ts +29 -0
  13. package/src/build/index.ts +3 -0
  14. package/src/build/types.ts +19 -0
  15. package/src/cli/commands/check.ts +50 -0
  16. package/src/cli/commands/generate.ts +61 -0
  17. package/src/cli/index.ts +24 -0
  18. package/src/core/collect-messages/collect-runtime-messages.ts +76 -0
  19. package/src/core/collect-messages/index.ts +1 -0
  20. package/src/core/collect-messages/readers.ts +23 -0
  21. package/src/core/collect-messages/resolve-messages-reader.ts +57 -0
  22. package/src/core/constants/extra-exts.ts +2 -0
  23. package/src/core/constants/generated-files.ts +3 -0
  24. package/src/core/constants/index.ts +7 -0
  25. package/src/core/diagnostics/collect.ts +59 -0
  26. package/src/core/diagnostics/group.ts +41 -0
  27. package/src/core/diagnostics/index.ts +2 -0
  28. package/src/core/diagnostics/messages.ts +51 -0
  29. package/src/core/diagnostics/rules/enforce-missing-replacements.ts +55 -0
  30. package/src/core/diagnostics/rules/enforce-missing-rich.ts +55 -0
  31. package/src/core/diagnostics/rules/key/empty.ts +34 -0
  32. package/src/core/diagnostics/rules/key/index.ts +2 -0
  33. package/src/core/diagnostics/rules/key/not-found.ts +43 -0
  34. package/src/core/diagnostics/rules/replacement/index.ts +3 -0
  35. package/src/core/diagnostics/rules/replacement/missing.ts +48 -0
  36. package/src/core/diagnostics/rules/replacement/not-allowed.ts +43 -0
  37. package/src/core/diagnostics/rules/replacement/unused.ts +48 -0
  38. package/src/core/diagnostics/rules/rich/index.ts +3 -0
  39. package/src/core/diagnostics/rules/rich/missing.ts +48 -0
  40. package/src/core/diagnostics/rules/rich/not-allowed.ts +43 -0
  41. package/src/core/diagnostics/rules/rich/unused.ts +48 -0
  42. package/src/core/diagnostics/types.ts +21 -0
  43. package/src/core/diagnostics/utils/get-schema-node-at-path.ts +29 -0
  44. package/src/core/diagnostics/utils/index-usages-by-key.ts +28 -0
  45. package/src/core/diagnostics/utils/resolve-key-path.ts +5 -0
  46. package/src/core/discover-configs/discover-configs.ts +87 -0
  47. package/src/core/discover-configs/index.ts +1 -0
  48. package/src/core/discover-configs/is-intor-resolved-config.ts +15 -0
  49. package/src/core/extract-usages/README.md +84 -0
  50. package/src/core/extract-usages/collectors/collect-key-usages.ts +40 -0
  51. package/src/core/extract-usages/collectors/collect-pre-keys.ts +58 -0
  52. package/src/core/extract-usages/collectors/collect-replacement-usages.ts +68 -0
  53. package/src/core/extract-usages/collectors/collect-rich-usages.ts +57 -0
  54. package/src/core/extract-usages/collectors/collect-translator-bindings.ts +56 -0
  55. package/src/core/extract-usages/collectors/index.ts +5 -0
  56. package/src/core/extract-usages/collectors/utils/extract-static-object-keys.ts +31 -0
  57. package/src/core/extract-usages/collectors/utils/get-config-key.ts +15 -0
  58. package/src/core/extract-usages/collectors/utils/get-object-arg.ts +42 -0
  59. package/src/core/extract-usages/collectors/utils/is-static-string-literal.ts +26 -0
  60. package/src/core/extract-usages/collectors/utils/walk-translator-bindings.ts +47 -0
  61. package/src/core/extract-usages/collectors/utils/walk-translator-method-calls.ts +42 -0
  62. package/src/core/extract-usages/extract-usages.ts +88 -0
  63. package/src/core/extract-usages/index.ts +13 -0
  64. package/src/core/extract-usages/load-source-files-from-tscofnig.ts +76 -0
  65. package/src/core/extract-usages/translator-registry.ts +20 -0
  66. package/src/core/extract-usages/types.ts +53 -0
  67. package/src/core/index.ts +13 -0
  68. package/src/core/infer-schema/index.ts +3 -0
  69. package/src/core/infer-schema/infer-schemas.ts +16 -0
  70. package/src/core/infer-schema/messages/index.ts +1 -0
  71. package/src/core/infer-schema/messages/infer-messages-schema.ts +64 -0
  72. package/src/core/infer-schema/replacements/extract-interpolation-names.ts +70 -0
  73. package/src/core/infer-schema/replacements/index.ts +1 -0
  74. package/src/core/infer-schema/replacements/infer-replacements-schema.ts +63 -0
  75. package/src/core/infer-schema/rich/index.ts +1 -0
  76. package/src/core/infer-schema/rich/infer-rich-schema.ts +66 -0
  77. package/src/core/infer-schema/types.ts +42 -0
  78. package/src/core/infer-schema/utils/infer-object.ts +32 -0
  79. package/src/core/infer-schema/utils/is-message-object.ts +5 -0
  80. package/src/core/read-generated-schema.ts +42 -0
  81. package/src/core/scan-logger.ts +10 -0
  82. package/src/core/write-generated-files.ts +51 -0
  83. package/src/features/check/check.ts +75 -0
  84. package/src/features/check/index.ts +1 -0
  85. package/src/features/check/print-summary.ts +28 -0
  86. package/src/features/generate/generate.ts +76 -0
  87. package/src/features/generate/index.ts +1 -0
  88. package/src/features/generate/print-configs.ts +8 -0
  89. package/src/features/generate/print-summary.ts +19 -0
  90. package/src/features/index.ts +2 -0
  91. package/src/features/print-title.ts +7 -0
  92. 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,13 @@
1
+ export { extractUsages } from "./extract-usages";
2
+
3
+ export type {
4
+ ExtractedUsages,
5
+ KeyUsage,
6
+ ReplacementUsage,
7
+ RichUsage,
8
+ } from "./types";
9
+
10
+ export type {
11
+ TranslatorFactory,
12
+ TranslatorMethod,
13
+ } from "./translator-registry";
@@ -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,3 @@
1
+ export { inferSchemas } from "./infer-schemas";
2
+ export type { InferNode, InferredSchemas } from "./types";
3
+ export { buildSchemas } from "../../build/build-schemas/build-schemas";
@@ -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
+ }