@cosmicdrift/kumiko-cli 0.356.0 → 0.357.0

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.
@@ -0,0 +1,280 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+
4
+ export type UiStringSources = {
5
+ repoDir: string;
6
+ locales: readonly string[];
7
+ globs: readonly string[];
8
+ packages: ReadonlyArray<{
9
+ package: string;
10
+ files: readonly string[];
11
+ locale?: string;
12
+ }>;
13
+ };
14
+
15
+ const PLACEHOLDER = "\u0001";
16
+ const QUOTES = /[„“”"'’‘‚«»‹›`]/g;
17
+ const WORD_CHAR = "\\p{L}\\p{N}";
18
+
19
+ function unescapeLiteral(raw: string): string {
20
+ return raw.replace(
21
+ /\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[\s\S])/g,
22
+ (_, code: string) => {
23
+ if (code.startsWith("u{"))
24
+ return String.fromCodePoint(Number.parseInt(code.slice(2, -1), 16));
25
+ if (code.startsWith("u") && code.length === 5)
26
+ return String.fromCharCode(Number.parseInt(code.slice(1), 16));
27
+ if (code.startsWith("x") && code.length === 3)
28
+ return String.fromCharCode(Number.parseInt(code.slice(1), 16));
29
+ if (code === "n") return "\n";
30
+ if (code === "t") return "\t";
31
+ return code;
32
+ },
33
+ );
34
+ }
35
+
36
+ export function readStringLiteral(
37
+ source: string,
38
+ start: number,
39
+ ): { value: string; end: number } | undefined {
40
+ const quote = source[start];
41
+ if (quote !== '"' && quote !== "'" && quote !== "`") return undefined;
42
+ let index = start + 1;
43
+ while (index < source.length) {
44
+ const char = source[index];
45
+ if (char === "\\") {
46
+ index += 2;
47
+ continue;
48
+ }
49
+ if (quote === "`" && char === "$" && source[index + 1] === "{") return undefined;
50
+ if (char === quote) {
51
+ return {
52
+ value: unescapeLiteral(source.slice(start + 1, index)),
53
+ end: index + 1,
54
+ };
55
+ }
56
+ if (char === "\n" && quote !== "`") return undefined;
57
+ index++;
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ export type UiStringMap = Map<string, string[]>;
63
+
64
+ function push(map: UiStringMap, locale: string, value: string): void {
65
+ const list = map.get(locale);
66
+ if (list) list.push(value);
67
+ else map.set(locale, [value]);
68
+ }
69
+
70
+ export function extractUiStrings(
71
+ source: string,
72
+ locales: readonly string[],
73
+ flatLocale?: string,
74
+ ): UiStringMap {
75
+ const result: UiStringMap = new Map();
76
+ const alternatives = locales.map((locale) => locale.replace(/[^\w-]/g, "")).join("|");
77
+ if (!alternatives) return result;
78
+
79
+ for (const match of source.matchAll(new RegExp(`\\b(${alternatives})\\s*:\\s*(?=["'\`])`, "g"))) {
80
+ const locale = match[1];
81
+ const literal = locale ? readStringLiteral(source, match.index + match[0].length) : undefined;
82
+ if (locale && literal) push(result, locale, literal.value);
83
+ }
84
+
85
+ const blocks = [
86
+ ...source.matchAll(new RegExp(`^[ \\t]*["']?(${alternatives})["']?\\s*:\\s*\\{`, "gm")),
87
+ ].map((match) => ({ at: match.index, locale: match[1] }));
88
+ if (flatLocale === undefined && blocks.length === 0) return result;
89
+ for (const match of source.matchAll(/(?:["'][^"'\n]+["']|[A-Za-z_$][\w$]*)\s*:\s*(?=["'`])/g)) {
90
+ const valueStart = match.index + match[0].length;
91
+ const block = [...blocks].reverse().find((candidate) => candidate.at < match.index);
92
+ const locale = flatLocale ?? block?.locale;
93
+ const literal = locale ? readStringLiteral(source, valueStart) : undefined;
94
+ if (locale && literal) push(result, locale, literal.value);
95
+ }
96
+ return result;
97
+ }
98
+
99
+ export function normalizeUiTerm(text: string): string {
100
+ return text
101
+ .replace(/\{[^}]*\}/g, PLACEHOLDER)
102
+ .replace(QUOTES, "")
103
+ .replace(/\s+/g, " ")
104
+ .toLowerCase()
105
+ .replace(
106
+ new RegExp(`^[^${WORD_CHAR}${PLACEHOLDER}]+|[^${WORD_CHAR}${PLACEHOLDER}]+$`, "gu"),
107
+ "",
108
+ );
109
+ }
110
+
111
+ function escapeRegex(value: string): string {
112
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
113
+ }
114
+
115
+ function hasLiteralWordsAroundPlaceholder(value: string): boolean {
116
+ return (
117
+ value.includes(PLACEHOLDER) &&
118
+ value.replace(new RegExp(`[^${WORD_CHAR}]`, "gu"), "").length >= 3
119
+ );
120
+ }
121
+
122
+ type LocaleIndex = {
123
+ exact: Set<string>;
124
+ haystack: string;
125
+ wildcards: RegExp[];
126
+ };
127
+ export type UiIndex = { locales: ReadonlyMap<string, LocaleIndex> };
128
+
129
+ export function buildUiIndex(strings: UiStringMap): UiIndex {
130
+ const locales = new Map<string, LocaleIndex>();
131
+ for (const [locale, values] of strings) {
132
+ const normalized = [...new Set(values.map(normalizeUiTerm).filter(Boolean))];
133
+ locales.set(locale, {
134
+ exact: new Set(normalized),
135
+ haystack: normalized.join("\n"),
136
+ wildcards: normalized
137
+ .filter(hasLiteralWordsAroundPlaceholder)
138
+ .map(
139
+ (value) => new RegExp(`^${value.split(PLACEHOLDER).map(escapeRegex).join(".+")}$`, "u"),
140
+ ),
141
+ });
142
+ }
143
+ return { locales };
144
+ }
145
+
146
+ export function hasUiStrings(index: UiIndex, locale: string): boolean {
147
+ return (index.locales.get(locale)?.exact.size ?? 0) > 0;
148
+ }
149
+
150
+ export function uiIndexContains(index: UiIndex, locale: string, term: string): boolean {
151
+ const localeIndex = index.locales.get(locale);
152
+ const normalized = normalizeUiTerm(term);
153
+ if (!localeIndex || !normalized) return true;
154
+ if (localeIndex.exact.has(normalized)) return true;
155
+ if (localeIndex.wildcards.some((pattern) => pattern.test(normalized))) return true;
156
+ const wholeWord = new RegExp(
157
+ `(?<![${WORD_CHAR}])${escapeRegex(normalized)}(?![${WORD_CHAR}])`,
158
+ "u",
159
+ );
160
+ return wholeWord.test(localeIndex.haystack);
161
+ }
162
+
163
+ export function extractBoldTerms(markdown: string): string[] {
164
+ const withoutCode = markdown.replace(/```[\s\S]*?```/g, "");
165
+ const terms = new Set<string>();
166
+ for (const match of withoutCode.matchAll(/(?<!\*)\*\*(?!\*)((?:[^*\n]|\n(?!\n))+?)\*\*(?!\*)/g)) {
167
+ const term = (match[1] ?? "").replace(/`/g, "").replace(/\s+/g, " ").trim();
168
+ if (/[\p{L}\p{N}]/u.test(term)) terms.add(term);
169
+ }
170
+ return [...terms];
171
+ }
172
+
173
+ export function findMissingUiTerms(
174
+ markdown: string,
175
+ locale: string,
176
+ index: UiIndex,
177
+ ignore: readonly string[],
178
+ ): string[] {
179
+ if (!hasUiStrings(index, locale)) return [];
180
+ const ignored = new Set(ignore.map(normalizeUiTerm));
181
+ return extractBoldTerms(markdown).filter(
182
+ (term) => !ignored.has(normalizeUiTerm(term)) && !uiIndexContains(index, locale, term),
183
+ );
184
+ }
185
+
186
+ function packageDirectory(packageName: string, fromDir: string): string | undefined {
187
+ try {
188
+ // Bare-name resolution fails for packages without a "." export (bundled-features).
189
+ let current = dirname(Bun.resolveSync(`${packageName}/package.json`, fromDir));
190
+ while (current !== dirname(current)) {
191
+ const manifest = join(current, "package.json");
192
+ if (existsSync(manifest) && JSON.parse(readFileSync(manifest, "utf8")).name === packageName) {
193
+ return current;
194
+ }
195
+ current = dirname(current);
196
+ }
197
+ } catch {
198
+ return undefined;
199
+ }
200
+ return undefined;
201
+ }
202
+
203
+ function scanFiles(cwd: string, patterns: readonly string[]): string[] {
204
+ const files = new Set<string>();
205
+ for (const pattern of patterns) {
206
+ for (const file of new Bun.Glob(pattern).scanSync({
207
+ cwd,
208
+ onlyFiles: true,
209
+ })) {
210
+ // A pattern that names __tests__ opts in (the framework's en catalog
211
+ // snapshot is the only English source for its UI strings).
212
+ const testsAllowed = pattern.includes("__tests__");
213
+ if (
214
+ !/(^|\/)node_modules\//.test(file) &&
215
+ (testsAllowed || !/(^|\/)__tests__\//.test(file)) &&
216
+ !/\.test\.tsx?$/.test(file)
217
+ ) {
218
+ files.add(join(cwd, file));
219
+ }
220
+ }
221
+ }
222
+ return [...files].sort();
223
+ }
224
+
225
+ export function extractYamlStrings(source: string): string[] {
226
+ const values: string[] = [];
227
+ for (const line of source.split(/\r?\n/)) {
228
+ const match = line.match(/^\s*(?:-\s+)?(?:[\w-]+:\s+)?(["']?)(.+?)\1\s*$/);
229
+ const value = match?.[2];
230
+ if (!value || /^[\w-]+:$/.test(value) || /^[|>][+-]?$/.test(value) || value.startsWith("#"))
231
+ continue;
232
+ values.push(value);
233
+ }
234
+ return values;
235
+ }
236
+
237
+ export function collectYamlUiStrings(
238
+ repoDir: string,
239
+ byLocale: Readonly<Record<string, readonly string[]>>,
240
+ ): UiStringMap {
241
+ const merged: UiStringMap = new Map();
242
+ for (const [locale, globs] of Object.entries(byLocale)) {
243
+ for (const file of scanFiles(repoDir, globs)) {
244
+ const source = readFileSync(file, "utf8");
245
+ // Non-YAML files (.md, .astro) are raw haystacks, no markup parsing.
246
+ const values = /\.ya?ml$/.test(file) ? extractYamlStrings(source) : [source];
247
+ for (const value of values) push(merged, locale, value);
248
+ }
249
+ }
250
+ return merged;
251
+ }
252
+
253
+ export function collectSourceUiStrings(
254
+ repoDir: string,
255
+ globs: readonly string[],
256
+ locales: readonly string[],
257
+ ): UiStringMap {
258
+ const merged: UiStringMap = new Map();
259
+ for (const file of scanFiles(repoDir, globs)) {
260
+ const found = extractUiStrings(readFileSync(file, "utf8"), locales);
261
+ for (const [locale, values] of found) for (const value of values) push(merged, locale, value);
262
+ }
263
+ return merged;
264
+ }
265
+
266
+ export function collectUiStrings(sources: UiStringSources): UiStringMap {
267
+ const merged: UiStringMap = new Map();
268
+ const absorb = (files: string[], flatLocale?: string) => {
269
+ for (const file of files) {
270
+ const found = extractUiStrings(readFileSync(file, "utf8"), sources.locales, flatLocale);
271
+ for (const [locale, values] of found) for (const value of values) push(merged, locale, value);
272
+ }
273
+ };
274
+ absorb(scanFiles(sources.repoDir, sources.globs));
275
+ for (const entry of sources.packages) {
276
+ const directory = packageDirectory(entry.package, sources.repoDir);
277
+ if (directory) absorb(scanFiles(directory, entry.files), entry.locale);
278
+ }
279
+ return merged;
280
+ }
@@ -1,6 +1,7 @@
1
1
  const APP_PEER_PACKAGES = [
2
2
  "@cosmicdrift/kumiko-framework",
3
3
  "@cosmicdrift/kumiko-bundled-features",
4
+ "@cosmicdrift/kumiko-headless",
4
5
  ] as const;
5
6
 
6
7
  // The runtime packages are optional peers (so `new app` runs without them); a