ui-strings 0.1.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.
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/dist/cli.js +1244 -0
- package/package.json +50 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1244 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join as join2, resolve } from "node:path";
|
|
7
|
+
import { Command } from "commander";
|
|
8
|
+
import pc from "picocolors";
|
|
9
|
+
|
|
10
|
+
// src/extract.ts
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { basename, join, relative, sep } from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
Node,
|
|
15
|
+
Project,
|
|
16
|
+
SyntaxKind,
|
|
17
|
+
ts
|
|
18
|
+
} from "ts-morph";
|
|
19
|
+
|
|
20
|
+
// src/detect.ts
|
|
21
|
+
var COPY_ATTR_PATTERN = /^(aria-(label|description|valuetext|placeholder|roledescription)|placeholder|title|alt|label|legend|summary|description|helperText|errorMessage|emptyText|tooltip|caption|heading|subtitle)$/i;
|
|
22
|
+
var TECHNICAL_ATTRS = new Set([
|
|
23
|
+
"className",
|
|
24
|
+
"class",
|
|
25
|
+
"id",
|
|
26
|
+
"style",
|
|
27
|
+
"href",
|
|
28
|
+
"src",
|
|
29
|
+
"srcSet",
|
|
30
|
+
"srcset",
|
|
31
|
+
"to",
|
|
32
|
+
"path",
|
|
33
|
+
"type",
|
|
34
|
+
"name",
|
|
35
|
+
"key",
|
|
36
|
+
"rel",
|
|
37
|
+
"target",
|
|
38
|
+
"role",
|
|
39
|
+
"htmlFor",
|
|
40
|
+
"for",
|
|
41
|
+
"variant",
|
|
42
|
+
"size",
|
|
43
|
+
"color",
|
|
44
|
+
"width",
|
|
45
|
+
"height",
|
|
46
|
+
"method",
|
|
47
|
+
"action",
|
|
48
|
+
"autoComplete",
|
|
49
|
+
"loading",
|
|
50
|
+
"decoding",
|
|
51
|
+
"lang",
|
|
52
|
+
"dir",
|
|
53
|
+
"form",
|
|
54
|
+
"value",
|
|
55
|
+
"defaultValue"
|
|
56
|
+
]);
|
|
57
|
+
var COPY_KEY_PATTERN = /(label|title|message|description|text|placeholder|error|success|warning|hint|help|caption|heading|subtitle|tooltip|empty|confirm|cancel|body|summary)/i;
|
|
58
|
+
var TECHNICAL_KEYS = new Set([
|
|
59
|
+
"className",
|
|
60
|
+
"id",
|
|
61
|
+
"href",
|
|
62
|
+
"src",
|
|
63
|
+
"key",
|
|
64
|
+
"name",
|
|
65
|
+
"type",
|
|
66
|
+
"variant",
|
|
67
|
+
"value",
|
|
68
|
+
"path",
|
|
69
|
+
"url",
|
|
70
|
+
"icon",
|
|
71
|
+
"color",
|
|
72
|
+
"size",
|
|
73
|
+
"target",
|
|
74
|
+
"rel",
|
|
75
|
+
"method",
|
|
76
|
+
"field",
|
|
77
|
+
"slug",
|
|
78
|
+
"locale",
|
|
79
|
+
"format",
|
|
80
|
+
"testId"
|
|
81
|
+
]);
|
|
82
|
+
var CLASSNAME_CALLEES = new Set([
|
|
83
|
+
"cn",
|
|
84
|
+
"cx",
|
|
85
|
+
"clsx",
|
|
86
|
+
"classnames",
|
|
87
|
+
"classNames",
|
|
88
|
+
"cva",
|
|
89
|
+
"tw",
|
|
90
|
+
"twMerge",
|
|
91
|
+
"twJoin"
|
|
92
|
+
]);
|
|
93
|
+
var URLISH = /^(https?:\/\/|mailto:|tel:|www\.|[./#~@])/;
|
|
94
|
+
var HEX_COLOR = /^#[0-9a-fA-F]{3,8}$/;
|
|
95
|
+
var IDENTIFIER_TOKEN = /^[A-Za-z0-9]+([-_][A-Za-z0-9\[\]%./:#]+)+$/;
|
|
96
|
+
var CONSTANT_TOKEN = /^[A-Z0-9_]+$/;
|
|
97
|
+
var hasNonLatinLetter = (text) => {
|
|
98
|
+
for (const ch of text) {
|
|
99
|
+
const code = ch.codePointAt(0) ?? 0;
|
|
100
|
+
if (code > 767 && /[\p{L}\p{Nl}]/u.test(ch)) {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
};
|
|
106
|
+
var looksLikeEnglishCopy = (text) => {
|
|
107
|
+
const trimmed = text.trim();
|
|
108
|
+
if (!/[A-Za-z]/.test(trimmed)) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
if (URLISH.test(trimmed) || HEX_COLOR.test(trimmed)) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
const words = trimmed.split(/\s+/);
|
|
115
|
+
if (words.length === 1) {
|
|
116
|
+
const word = words[0] ?? "";
|
|
117
|
+
if (IDENTIFIER_TOKEN.test(word) || CONSTANT_TOKEN.test(word)) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
return /^[A-Z][a-z']+([.!?…]|\.\.\.)?$/.test(word) || /[.!?…]$/.test(word);
|
|
121
|
+
}
|
|
122
|
+
const technical = words.filter((word) => /[-_:\[\]{}/\\#@=]|\d/.test(word) && !/^[A-Za-z]+[.,:;!?…]*$/.test(word)).length;
|
|
123
|
+
if (technical / words.length > 0.4) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
const alpha = words.filter((word) => /^[A-Za-z][A-Za-z']*[.,:;!?…]*$/.test(word)).length;
|
|
127
|
+
return alpha >= 2;
|
|
128
|
+
};
|
|
129
|
+
var hasCopyTextEn = (text) => {
|
|
130
|
+
if (hasNonLatinLetter(text)) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
for (const match of text.matchAll(/["'`]([^"'`\n]*)["'`]/g)) {
|
|
134
|
+
if (looksLikeEnglishCopy(match[1] ?? "")) {
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
};
|
|
140
|
+
var withoutPlaceholders = (text) => text.replace(/\{[^{}]*\}/g, "");
|
|
141
|
+
var lexical = (text) => hasNonLatinLetter(text) || looksLikeEnglishCopy(text);
|
|
142
|
+
var isCopyEn = (text, context) => {
|
|
143
|
+
const { kind, attr, callee, key } = context;
|
|
144
|
+
if (hasNonLatinLetter(text)) {
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
if (kind === "jsx-text") {
|
|
148
|
+
return /\p{L}/u.test(withoutPlaceholders(text));
|
|
149
|
+
}
|
|
150
|
+
if (kind === "jsx-attribute") {
|
|
151
|
+
if (attr === undefined) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
if (COPY_ATTR_PATTERN.test(attr)) {
|
|
155
|
+
return /\p{L}/u.test(text);
|
|
156
|
+
}
|
|
157
|
+
if (TECHNICAL_ATTRS.has(attr) || attr.startsWith("data-") || attr.startsWith("on")) {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
return lexical(text);
|
|
161
|
+
}
|
|
162
|
+
if (kind === "object-property" || kind === "metadata") {
|
|
163
|
+
if (key !== undefined) {
|
|
164
|
+
if (COPY_KEY_PATTERN.test(key)) {
|
|
165
|
+
return /\p{L}/u.test(text);
|
|
166
|
+
}
|
|
167
|
+
if (TECHNICAL_KEYS.has(key)) {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return lexical(text);
|
|
172
|
+
}
|
|
173
|
+
if (kind === "call-argument") {
|
|
174
|
+
if (callee !== undefined && CLASSNAME_CALLEES.has(callee)) {
|
|
175
|
+
return false;
|
|
176
|
+
}
|
|
177
|
+
return lexical(text);
|
|
178
|
+
}
|
|
179
|
+
return lexical(text);
|
|
180
|
+
};
|
|
181
|
+
var detector = {
|
|
182
|
+
isCopy: isCopyEn,
|
|
183
|
+
hasCopyText: hasCopyTextEn
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
// src/extract.ts
|
|
187
|
+
var INLINE_TAGS = new Set([
|
|
188
|
+
"b",
|
|
189
|
+
"i",
|
|
190
|
+
"em",
|
|
191
|
+
"strong",
|
|
192
|
+
"span",
|
|
193
|
+
"small",
|
|
194
|
+
"a",
|
|
195
|
+
"code",
|
|
196
|
+
"u",
|
|
197
|
+
"s",
|
|
198
|
+
"mark",
|
|
199
|
+
"sup",
|
|
200
|
+
"sub",
|
|
201
|
+
"abbr",
|
|
202
|
+
"ruby",
|
|
203
|
+
"rt"
|
|
204
|
+
]);
|
|
205
|
+
var BREAK_TAGS = new Set(["br", "wbr"]);
|
|
206
|
+
var INTERACTIVE_KEY_PATTERN = /^(message|error|success|warning|status)|(message|error)$/i;
|
|
207
|
+
var INTERACTIVE_FILE_PATTERN = /(^|\/)(actions|route)\.tsx?$/;
|
|
208
|
+
var collapseWhitespace = (text) => text.replace(/\s+/g, " ").trim();
|
|
209
|
+
var calleeName = (expression) => {
|
|
210
|
+
if (Node.isPropertyAccessExpression(expression)) {
|
|
211
|
+
return expression.getName();
|
|
212
|
+
}
|
|
213
|
+
return collapseWhitespace(expression.getText()).slice(0, 60);
|
|
214
|
+
};
|
|
215
|
+
var isConsoleCall = (expression) => Node.isPropertyAccessExpression(expression) && expression.getExpression().getText() === "console";
|
|
216
|
+
var isModuleSpecifier = (node) => {
|
|
217
|
+
const parent = node.getParent();
|
|
218
|
+
return (Node.isImportDeclaration(parent) || Node.isExportDeclaration(parent)) && parent.getModuleSpecifier() === node;
|
|
219
|
+
};
|
|
220
|
+
var isDirective = (node) => Node.isExpressionStatement(node.getParent());
|
|
221
|
+
var templateToText = (node) => {
|
|
222
|
+
let text = node.getHead().getLiteralText();
|
|
223
|
+
for (const span of node.getTemplateSpans()) {
|
|
224
|
+
text += `{${collapseWhitespace(span.getExpression().getText())}}`;
|
|
225
|
+
text += span.getLiteral().getLiteralText();
|
|
226
|
+
}
|
|
227
|
+
return text;
|
|
228
|
+
};
|
|
229
|
+
var unwrapExpression = (node) => {
|
|
230
|
+
let current = node;
|
|
231
|
+
while (Node.isAsExpression(current) || Node.isSatisfiesExpression(current) || Node.isParenthesizedExpression(current)) {
|
|
232
|
+
current = current.getExpression();
|
|
233
|
+
}
|
|
234
|
+
return current;
|
|
235
|
+
};
|
|
236
|
+
var rootIdentifierOf = (expression) => {
|
|
237
|
+
let current = unwrapExpression(expression);
|
|
238
|
+
while (Node.isCallExpression(current) || Node.isPropertyAccessExpression(current) || Node.isElementAccessExpression(current) || Node.isNonNullExpression(current)) {
|
|
239
|
+
current = unwrapExpression(current.getExpression());
|
|
240
|
+
}
|
|
241
|
+
return Node.isIdentifier(current) ? current : undefined;
|
|
242
|
+
};
|
|
243
|
+
var declKeyOf = (declaration) => `${declaration.getSourceFile().getFilePath()}:${declaration.getPos()}`;
|
|
244
|
+
var declarationOf = (identifier) => {
|
|
245
|
+
const symbol = identifier.getSymbol();
|
|
246
|
+
if (symbol === undefined) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
const aliased = symbol.getAliasedSymbol() ?? symbol;
|
|
250
|
+
return aliased.getDeclarations().find(Node.isVariableDeclaration);
|
|
251
|
+
};
|
|
252
|
+
var tagNameOf = (element) => {
|
|
253
|
+
if (Node.isJsxElement(element)) {
|
|
254
|
+
return element.getOpeningElement().getTagNameNode().getText();
|
|
255
|
+
}
|
|
256
|
+
if (Node.isJsxSelfClosingElement(element)) {
|
|
257
|
+
return element.getTagNameNode().getText();
|
|
258
|
+
}
|
|
259
|
+
return;
|
|
260
|
+
};
|
|
261
|
+
var enclosingTag = (node) => {
|
|
262
|
+
const element = node.getFirstAncestor(Node.isJsxElement);
|
|
263
|
+
return element ? tagNameOf(element) : undefined;
|
|
264
|
+
};
|
|
265
|
+
var returnedJsxTag = (callback) => {
|
|
266
|
+
if (callback === undefined || !Node.isArrowFunction(callback) && !Node.isFunctionExpression(callback)) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const body = callback.getBody();
|
|
270
|
+
const returned = Node.isBlock(body) ? body.getStatements().find(Node.isReturnStatement)?.getExpression() : body;
|
|
271
|
+
return returned ? tagNameOf(unwrapExpression(returned)) : undefined;
|
|
272
|
+
};
|
|
273
|
+
var renderSiteTag = (jsxExpression) => {
|
|
274
|
+
if (!Node.isJsxExpression(jsxExpression)) {
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const expression = jsxExpression.getExpression();
|
|
278
|
+
if (expression !== undefined) {
|
|
279
|
+
const call = unwrapExpression(expression);
|
|
280
|
+
if (Node.isCallExpression(call)) {
|
|
281
|
+
const callee = call.getExpression();
|
|
282
|
+
if (Node.isPropertyAccessExpression(callee) && /^(map|flatMap)$/.test(callee.getName())) {
|
|
283
|
+
const tag = returnedJsxTag(call.getArguments()[0]);
|
|
284
|
+
if (tag !== undefined) {
|
|
285
|
+
return tag;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const owner = jsxExpression.getFirstAncestor((ancestor) => Node.isJsxElement(ancestor) || Node.isJsxSelfClosingElement(ancestor));
|
|
291
|
+
return owner ? tagNameOf(owner) : undefined;
|
|
292
|
+
};
|
|
293
|
+
var containsJsx = (node) => node.getFirstDescendant((descendant) => Node.isJsxElement(descendant) || Node.isJsxSelfClosingElement(descendant) || Node.isJsxFragment(descendant)) !== undefined;
|
|
294
|
+
var joinPieces = (pieces) => pieces.join("").replace(/ {2,}/g, " ").replace(/ *\n */g, `
|
|
295
|
+
`).replace(/^\n+|\n+$/g, "").trim();
|
|
296
|
+
var mergeInline = (element, hasCopy) => {
|
|
297
|
+
const pieces = [];
|
|
298
|
+
for (const child of element.getJsxChildren()) {
|
|
299
|
+
const piece = mergeChild(child, hasCopy);
|
|
300
|
+
if (piece === null) {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
pieces.push(piece);
|
|
304
|
+
}
|
|
305
|
+
const text = joinPieces(pieces);
|
|
306
|
+
return text === "" ? null : text;
|
|
307
|
+
};
|
|
308
|
+
var mergeChild = (child, hasCopy) => {
|
|
309
|
+
if (Node.isJsxText(child)) {
|
|
310
|
+
return child.getLiteralText().replace(/\s+/g, " ");
|
|
311
|
+
}
|
|
312
|
+
if (Node.isJsxSelfClosingElement(child)) {
|
|
313
|
+
const tag = child.getTagNameNode().getText();
|
|
314
|
+
return BREAK_TAGS.has(tag) ? `
|
|
315
|
+
` : null;
|
|
316
|
+
}
|
|
317
|
+
if (Node.isJsxElement(child)) {
|
|
318
|
+
const tag = tagNameOf(child);
|
|
319
|
+
if (tag && INLINE_TAGS.has(tag)) {
|
|
320
|
+
return mergeInline(child, hasCopy);
|
|
321
|
+
}
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
if (Node.isJsxExpression(child)) {
|
|
325
|
+
const expression = child.getExpression();
|
|
326
|
+
if (expression === undefined) {
|
|
327
|
+
return "";
|
|
328
|
+
}
|
|
329
|
+
if (Node.isStringLiteral(expression) || Node.isNoSubstitutionTemplateLiteral(expression)) {
|
|
330
|
+
return expression.getLiteralText();
|
|
331
|
+
}
|
|
332
|
+
if (Node.isTemplateExpression(expression)) {
|
|
333
|
+
return templateToText(expression);
|
|
334
|
+
}
|
|
335
|
+
if (!containsJsx(expression) && !hasCopy(expression.getText())) {
|
|
336
|
+
return `{${collapseWhitespace(expression.getText())}}`;
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
}
|
|
340
|
+
return null;
|
|
341
|
+
};
|
|
342
|
+
var mergeRuns = (container, hasCopy) => {
|
|
343
|
+
const runs = [];
|
|
344
|
+
let nodes = [];
|
|
345
|
+
let pieces = [];
|
|
346
|
+
let anchor;
|
|
347
|
+
const flush = () => {
|
|
348
|
+
const text = joinPieces(pieces);
|
|
349
|
+
if (text !== "" && anchor !== undefined) {
|
|
350
|
+
runs.push({ text, nodes, anchor });
|
|
351
|
+
}
|
|
352
|
+
nodes = [];
|
|
353
|
+
pieces = [];
|
|
354
|
+
anchor = undefined;
|
|
355
|
+
};
|
|
356
|
+
for (const child of container.getJsxChildren()) {
|
|
357
|
+
const piece = mergeChild(child, hasCopy);
|
|
358
|
+
if (piece === null) {
|
|
359
|
+
flush();
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
if (anchor === undefined && piece.trim() !== "") {
|
|
363
|
+
anchor = child;
|
|
364
|
+
}
|
|
365
|
+
nodes.push(child);
|
|
366
|
+
pieces.push(piece);
|
|
367
|
+
}
|
|
368
|
+
flush();
|
|
369
|
+
return runs;
|
|
370
|
+
};
|
|
371
|
+
var classify = (node) => {
|
|
372
|
+
if (node.getKind() === SyntaxKind.JsxText) {
|
|
373
|
+
return { kind: "jsx-text" };
|
|
374
|
+
}
|
|
375
|
+
let current = node.getParent();
|
|
376
|
+
let previous = node;
|
|
377
|
+
while (current) {
|
|
378
|
+
if (Node.isThrowStatement(current)) {
|
|
379
|
+
return { kind: "internal" };
|
|
380
|
+
}
|
|
381
|
+
if (Node.isCallExpression(current)) {
|
|
382
|
+
const expression = current.getExpression();
|
|
383
|
+
if (isConsoleCall(expression)) {
|
|
384
|
+
return { kind: "internal" };
|
|
385
|
+
}
|
|
386
|
+
if (current.getArguments().includes(previous)) {
|
|
387
|
+
return { kind: "call-argument", callee: calleeName(expression) };
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (Node.isNewExpression(current)) {
|
|
391
|
+
const name = current.getExpression().getText();
|
|
392
|
+
if (name.endsWith("Error")) {
|
|
393
|
+
return { kind: "internal" };
|
|
394
|
+
}
|
|
395
|
+
return { kind: "call-argument", callee: `new ${name}` };
|
|
396
|
+
}
|
|
397
|
+
if (Node.isPropertyAssignment(current)) {
|
|
398
|
+
const key = current.getName();
|
|
399
|
+
const declaration = current.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
|
|
400
|
+
if (declaration?.getName() === "metadata") {
|
|
401
|
+
return { kind: "metadata", key };
|
|
402
|
+
}
|
|
403
|
+
return { kind: "object-property", key };
|
|
404
|
+
}
|
|
405
|
+
if (Node.isVariableDeclaration(current) && Node.isArrayLiteralExpression(unwrapExpression(previous))) {
|
|
406
|
+
return { kind: "array-item", key: current.getName() };
|
|
407
|
+
}
|
|
408
|
+
if (Node.isJsxAttribute(current)) {
|
|
409
|
+
return { kind: "jsx-attribute", attr: current.getNameNode().getText() };
|
|
410
|
+
}
|
|
411
|
+
if (Node.isJsxExpression(current)) {
|
|
412
|
+
const parent = current.getParent();
|
|
413
|
+
if (Node.isJsxElement(parent) || Node.isJsxFragment(parent) || Node.isJsxSelfClosingElement(parent)) {
|
|
414
|
+
return { kind: "jsx-text" };
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
if (Node.isArrowFunction(current) || Node.isFunctionDeclaration(current) || Node.isFunctionExpression(current) || Node.isMethodDeclaration(current)) {
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
previous = current;
|
|
421
|
+
current = current.getParent();
|
|
422
|
+
}
|
|
423
|
+
return { kind: "other" };
|
|
424
|
+
};
|
|
425
|
+
var A11Y_ATTRS = new Set(["alt", "title"]);
|
|
426
|
+
var isFunctionBoundary = (node) => Node.isArrowFunction(node) || Node.isFunctionDeclaration(node) || Node.isFunctionExpression(node) || Node.isMethodDeclaration(node);
|
|
427
|
+
var conditionText = (node) => collapseWhitespace(node.getText()).slice(0, 60);
|
|
428
|
+
var conditionOf = (node) => {
|
|
429
|
+
let previous = node;
|
|
430
|
+
let current = node.getParent();
|
|
431
|
+
while (current) {
|
|
432
|
+
if (Node.isConditionalExpression(current)) {
|
|
433
|
+
if (current.getWhenTrue() === previous) {
|
|
434
|
+
return {
|
|
435
|
+
condition: conditionText(current.getCondition()),
|
|
436
|
+
branch: "then"
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
if (current.getWhenFalse() === previous) {
|
|
440
|
+
return {
|
|
441
|
+
condition: conditionText(current.getCondition()),
|
|
442
|
+
branch: "else"
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (Node.isBinaryExpression(current) && current.getRight() === previous) {
|
|
447
|
+
const operator = current.getOperatorToken().getKind();
|
|
448
|
+
if (operator === SyntaxKind.AmpersandAmpersandToken) {
|
|
449
|
+
return { condition: conditionText(current.getLeft()), branch: "then" };
|
|
450
|
+
}
|
|
451
|
+
if (operator === SyntaxKind.BarBarToken || operator === SyntaxKind.QuestionQuestionToken) {
|
|
452
|
+
return { condition: conditionText(current.getLeft()), branch: "else" };
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (isFunctionBoundary(current)) {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
previous = current;
|
|
459
|
+
current = current.getParent();
|
|
460
|
+
}
|
|
461
|
+
return;
|
|
462
|
+
};
|
|
463
|
+
var surfaceFor = (classified, file) => {
|
|
464
|
+
const { kind, attr, key } = classified;
|
|
465
|
+
if (kind === "internal") {
|
|
466
|
+
return "internal";
|
|
467
|
+
}
|
|
468
|
+
if (kind === "metadata") {
|
|
469
|
+
return "meta";
|
|
470
|
+
}
|
|
471
|
+
if (kind === "jsx-attribute" && attr) {
|
|
472
|
+
if (attr.startsWith("aria-") || A11Y_ATTRS.has(attr)) {
|
|
473
|
+
return "a11y";
|
|
474
|
+
}
|
|
475
|
+
if (/error|invalid/i.test(attr)) {
|
|
476
|
+
return "interactive";
|
|
477
|
+
}
|
|
478
|
+
return "visible";
|
|
479
|
+
}
|
|
480
|
+
if (kind === "call-argument") {
|
|
481
|
+
return "interactive";
|
|
482
|
+
}
|
|
483
|
+
if (kind === "object-property") {
|
|
484
|
+
if (key && INTERACTIVE_KEY_PATTERN.test(key) || INTERACTIVE_FILE_PATTERN.test(basename(file)) || file.includes(`${sep}api${sep}`)) {
|
|
485
|
+
return "interactive";
|
|
486
|
+
}
|
|
487
|
+
return "visible";
|
|
488
|
+
}
|
|
489
|
+
return "visible";
|
|
490
|
+
};
|
|
491
|
+
var groupFor = (relativeFile, hasAppDir) => {
|
|
492
|
+
const parts = relativeFile.split(sep);
|
|
493
|
+
const appIndex = parts.indexOf("app");
|
|
494
|
+
if (hasAppDir && parts[0] === "src" && appIndex === 1) {
|
|
495
|
+
const segments = parts.slice(2, -1).filter((segment) => segment !== "_dependencies" && !(segment.startsWith("(") && segment.endsWith(")")));
|
|
496
|
+
return `/${segments.join("/")}`;
|
|
497
|
+
}
|
|
498
|
+
const dirs = parts.slice(0, -1);
|
|
499
|
+
if (dirs[0] === "src") {
|
|
500
|
+
dirs.shift();
|
|
501
|
+
}
|
|
502
|
+
return dirs.slice(0, 2).join("/") || "(root)";
|
|
503
|
+
};
|
|
504
|
+
var DEFAULT_EXCLUDES = [
|
|
505
|
+
"**/*.{test,spec}.{ts,tsx,js,jsx}",
|
|
506
|
+
"**/__tests__/**",
|
|
507
|
+
"**/__mocks__/**"
|
|
508
|
+
];
|
|
509
|
+
var scanProject = (options) => {
|
|
510
|
+
const { projectDir, srcGlob } = options;
|
|
511
|
+
const tsConfigFilePath = join(projectDir, "tsconfig.json");
|
|
512
|
+
const project = existsSync(tsConfigFilePath) ? new Project({ tsConfigFilePath, skipAddingFilesFromTsConfig: true }) : new Project({
|
|
513
|
+
compilerOptions: {
|
|
514
|
+
allowJs: true,
|
|
515
|
+
jsx: ts.JsxEmit.Preserve
|
|
516
|
+
},
|
|
517
|
+
skipAddingFilesFromTsConfig: true
|
|
518
|
+
});
|
|
519
|
+
const excludes = [...DEFAULT_EXCLUDES, ...options.exclude ?? []];
|
|
520
|
+
const sourceFiles = project.addSourceFilesAtPaths([
|
|
521
|
+
`${projectDir}/${srcGlob}`,
|
|
522
|
+
...excludes.map((glob) => `!${projectDir}/${glob}`)
|
|
523
|
+
]);
|
|
524
|
+
const hasAppDir = sourceFiles.some((file) => relative(projectDir, file.getFilePath()).startsWith(`src${sep}app${sep}`));
|
|
525
|
+
const renderSites = new Map;
|
|
526
|
+
for (const sourceFile of sourceFiles) {
|
|
527
|
+
const relativeFile = relative(projectDir, sourceFile.getFilePath());
|
|
528
|
+
sourceFile.forEachDescendant((node) => {
|
|
529
|
+
if (!Node.isJsxExpression(node)) {
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
const expression = node.getExpression();
|
|
533
|
+
if (expression === undefined) {
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
const identifier = rootIdentifierOf(expression);
|
|
537
|
+
if (identifier === undefined) {
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
const declaration = declarationOf(identifier);
|
|
541
|
+
if (declaration === undefined) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const declKey = declKeyOf(declaration);
|
|
545
|
+
if (renderSites.has(declKey)) {
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
const tag = renderSiteTag(node);
|
|
549
|
+
if (tag !== undefined) {
|
|
550
|
+
renderSites.set(declKey, {
|
|
551
|
+
tag,
|
|
552
|
+
usedAt: `${relativeFile}:${node.getStartLineNumber()}`
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
const entries = [];
|
|
558
|
+
for (const sourceFile of sourceFiles) {
|
|
559
|
+
const relativeFile = relative(projectDir, sourceFile.getFilePath());
|
|
560
|
+
const group = groupFor(relativeFile, hasAppDir);
|
|
561
|
+
const consumed = new Set;
|
|
562
|
+
const nodeKey = (node) => `${node.getPos()}:${node.getEnd()}`;
|
|
563
|
+
const push = (text, node, classified) => {
|
|
564
|
+
if (!detector.isCopy(text, classified)) {
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
const declaration = classified.kind === "array-item" ? node.getFirstAncestorByKind(SyntaxKind.VariableDeclaration) : undefined;
|
|
568
|
+
const rendered = declaration ? renderSites.get(declKeyOf(declaration)) : undefined;
|
|
569
|
+
const tag = classified.kind === "jsx-text" ? tagNameOf(node) ?? enclosingTag(node) : rendered?.tag;
|
|
570
|
+
const owner = classified.kind === "jsx-attribute" ? node.getFirstAncestorByKind(SyntaxKind.JsxOpeningElement) ?? node.getFirstAncestorByKind(SyntaxKind.JsxSelfClosingElement) : undefined;
|
|
571
|
+
entries.push({
|
|
572
|
+
text,
|
|
573
|
+
file: relativeFile,
|
|
574
|
+
line: node.getStartLineNumber(),
|
|
575
|
+
group,
|
|
576
|
+
surface: surfaceFor(classified, relativeFile),
|
|
577
|
+
...classified,
|
|
578
|
+
...tag ? { tag } : {},
|
|
579
|
+
...rendered ? { usedAt: rendered.usedAt } : {},
|
|
580
|
+
...owner ? { elementLine: owner.getStartLineNumber() } : {},
|
|
581
|
+
...conditionOf(node) ?? {}
|
|
582
|
+
});
|
|
583
|
+
};
|
|
584
|
+
sourceFile.forEachDescendant((node) => {
|
|
585
|
+
if (consumed.has(nodeKey(node))) {
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (Node.isJsxElement(node) || Node.isJsxFragment(node)) {
|
|
589
|
+
const runs = mergeRuns(node, detector.hasCopyText);
|
|
590
|
+
const coversAll = runs.length === 1 && runs[0]?.nodes.length === node.getJsxChildren().length;
|
|
591
|
+
for (const run of runs) {
|
|
592
|
+
for (const child of run.nodes) {
|
|
593
|
+
consumed.add(nodeKey(child));
|
|
594
|
+
child.forEachDescendant((descendant, traversal) => {
|
|
595
|
+
if (Node.isJsxAttribute(descendant)) {
|
|
596
|
+
traversal.skip();
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
consumed.add(nodeKey(descendant));
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
push(run.text, coversAll ? node : run.anchor, { kind: "jsx-text" });
|
|
603
|
+
}
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (Node.isJsxText(node)) {
|
|
607
|
+
push(collapseWhitespace(node.getLiteralText()), node, classify(node));
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) {
|
|
611
|
+
if (isModuleSpecifier(node) || isDirective(node)) {
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
push(node.getLiteralText(), node, classify(node));
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (Node.isTemplateExpression(node)) {
|
|
618
|
+
push(templateToText(node), node, classify(node));
|
|
619
|
+
}
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
const attrRank = (entry) => {
|
|
623
|
+
if (entry.attr === "label" || entry.attr === "legend") {
|
|
624
|
+
return 0;
|
|
625
|
+
}
|
|
626
|
+
if (entry.attr === "placeholder") {
|
|
627
|
+
return 2;
|
|
628
|
+
}
|
|
629
|
+
return entry.attr ? 3 : 1;
|
|
630
|
+
};
|
|
631
|
+
entries.sort((a, b) => a.group.localeCompare(b.group) || a.file.localeCompare(b.file) || (a.elementLine ?? a.line) - (b.elementLine ?? b.line) || attrRank(a) - attrRank(b) || a.line - b.line);
|
|
632
|
+
return {
|
|
633
|
+
projectDir,
|
|
634
|
+
srcGlob,
|
|
635
|
+
scannedFiles: sourceFiles.length,
|
|
636
|
+
generatedAt: new Date().toISOString(),
|
|
637
|
+
entries
|
|
638
|
+
};
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
// src/report-html.ts
|
|
642
|
+
var toEmbeddedJson = (value) => JSON.stringify(value).replace(/</g, "\\u003c");
|
|
643
|
+
var renderHtml = (result) => {
|
|
644
|
+
const data = toEmbeddedJson(result);
|
|
645
|
+
return `<!doctype html>
|
|
646
|
+
<html lang="en">
|
|
647
|
+
<head>
|
|
648
|
+
<meta charset="utf-8" />
|
|
649
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
650
|
+
<title>UI Strings — ${result.projectDir.split("/").at(-1) ?? ""}</title>
|
|
651
|
+
<style>
|
|
652
|
+
:root {
|
|
653
|
+
--bg: #f6f7f9; --card: #ffffff; --text: #1a2233; --muted: #66708a;
|
|
654
|
+
--border: #e2e6ee; --accent: #2456c8; --accent-soft: #e8eefb;
|
|
655
|
+
--edit: #7a4dbf; --edit-soft: #f2ecfb;
|
|
656
|
+
font-family: "Hiragino Sans", "Noto Sans JP", system-ui, sans-serif;
|
|
657
|
+
}
|
|
658
|
+
* { box-sizing: border-box; }
|
|
659
|
+
body { margin: 0; background: var(--bg); color: var(--text); font-size: 14px; padding-bottom: 70px; }
|
|
660
|
+
header { padding: 20px 24px 0; }
|
|
661
|
+
h1 { margin: 0 0 4px; font-size: 20px; }
|
|
662
|
+
.meta { color: var(--muted); font-size: 12px; }
|
|
663
|
+
.stats { display: flex; flex-wrap: wrap; gap: 10px; padding: 14px 24px; }
|
|
664
|
+
.stat { background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 8px 14px; }
|
|
665
|
+
.stat b { font-size: 18px; }
|
|
666
|
+
.stat span { color: var(--muted); font-size: 11px; display: block; }
|
|
667
|
+
.controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 0 24px 12px; }
|
|
668
|
+
input[type="search"], select {
|
|
669
|
+
padding: 7px 10px; border: 1px solid var(--border); border-radius: 8px;
|
|
670
|
+
background: var(--card); color: var(--text); font-size: 13px;
|
|
671
|
+
}
|
|
672
|
+
input[type="search"] { min-width: 260px; }
|
|
673
|
+
.chip, .viewbtn {
|
|
674
|
+
border: 1px solid var(--border); background: var(--card); border-radius: 999px;
|
|
675
|
+
padding: 5px 12px; cursor: pointer; font-size: 12px; color: var(--muted);
|
|
676
|
+
font-family: ui-monospace, monospace;
|
|
677
|
+
}
|
|
678
|
+
.chip.active, .viewbtn.active { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
|
|
679
|
+
.viewswitch { display: inline-flex; gap: 4px; margin-right: 8px; }
|
|
680
|
+
.viewbtn { font-family: inherit; }
|
|
681
|
+
main { padding: 0 24px 40px; max-width: 1200px; }
|
|
682
|
+
.empty { color: var(--muted); padding: 30px 0; text-align: center; }
|
|
683
|
+
mark { background: #ffe58a; border-radius: 3px; }
|
|
684
|
+
|
|
685
|
+
.ctx {
|
|
686
|
+
font-family: ui-monospace, monospace; font-size: 11px; color: var(--muted);
|
|
687
|
+
background: #f2f4f8; border: 1px solid var(--border); border-radius: 5px;
|
|
688
|
+
padding: 1px 6px; white-space: nowrap;
|
|
689
|
+
}
|
|
690
|
+
.cond {
|
|
691
|
+
font-family: ui-monospace, monospace; font-size: 11px; color: #8a6d1d;
|
|
692
|
+
background: #fdf6e3; border: 1px solid #eadfb8; border-radius: 5px;
|
|
693
|
+
padding: 1px 6px; white-space: nowrap;
|
|
694
|
+
}
|
|
695
|
+
.badge { font-family: ui-monospace, monospace; font-size: 11px; border-radius: 6px; padding: 2px 7px; background: var(--accent-soft); color: var(--accent); }
|
|
696
|
+
.badge.interactive { background: #fff3e0; color: #9a5b00; }
|
|
697
|
+
.badge.a11y { background: #eef2ee; color: #3d6b45; }
|
|
698
|
+
.badge.internal, .badge.meta { background: #f0f0f2; color: #6a6a72; }
|
|
699
|
+
.iconbtn {
|
|
700
|
+
border: none; background: none; cursor: pointer; font-size: 13px; color: var(--muted);
|
|
701
|
+
padding: 1px 4px; border-radius: 5px; visibility: hidden;
|
|
702
|
+
}
|
|
703
|
+
.iconbtn:hover { background: var(--accent-soft); }
|
|
704
|
+
|
|
705
|
+
/* page view */
|
|
706
|
+
.page { margin-top: 22px; border: 1px solid var(--border); border-radius: 12px; background: var(--card); overflow: hidden; }
|
|
707
|
+
.page-bar {
|
|
708
|
+
display: flex; align-items: center; gap: 8px; padding: 8px 14px;
|
|
709
|
+
background: #eef1f6; border-bottom: 1px solid var(--border);
|
|
710
|
+
}
|
|
711
|
+
.page-bar .route { font-family: ui-monospace, monospace; font-size: 12px; color: var(--muted);
|
|
712
|
+
background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 2px 10px; }
|
|
713
|
+
.page-bar .count { margin-left: auto; font-size: 11px; color: var(--muted); }
|
|
714
|
+
.page-body { padding: 14px 20px 12px; }
|
|
715
|
+
.file-divider { display: flex; align-items: center; gap: 8px; margin: 12px 0 6px; color: var(--muted); font-size: 10.5px; font-family: ui-monospace, monospace; }
|
|
716
|
+
.file-divider::before, .file-divider::after { content: ""; flex: 1; border-top: 1px dashed var(--border); }
|
|
717
|
+
.item { display: flex; gap: 8px; align-items: baseline; padding: 3px 4px; border-radius: 6px; }
|
|
718
|
+
.item:hover { background: #fafbfd; }
|
|
719
|
+
.item:hover .iconbtn { visibility: visible; }
|
|
720
|
+
.item .txt { white-space: pre-line; line-height: 1.6; }
|
|
721
|
+
.item.edited .txt { text-decoration: line-through; text-decoration-color: #b3261e88; }
|
|
722
|
+
.item .newtxt { white-space: pre-line; line-height: 1.6; color: var(--edit); font-weight: 600; }
|
|
723
|
+
details { margin: 10px 0 4px; border-top: 1px solid var(--border); }
|
|
724
|
+
summary { cursor: pointer; padding: 8px 0; font-size: 12px; color: var(--muted); font-weight: 600; font-family: ui-monospace, monospace; }
|
|
725
|
+
|
|
726
|
+
/* table view */
|
|
727
|
+
.group { margin-top: 18px; }
|
|
728
|
+
.group > h2 {
|
|
729
|
+
font-size: 14px; margin: 0 0 6px; padding: 6px 10px; background: var(--accent-soft);
|
|
730
|
+
color: var(--accent); border-radius: 8px; display: inline-block;
|
|
731
|
+
}
|
|
732
|
+
.group-count { color: var(--muted); font-weight: normal; margin-left: 6px; }
|
|
733
|
+
table { width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--border); }
|
|
734
|
+
th, td { text-align: left; padding: 6px 10px; border-top: 1px solid var(--border); vertical-align: top; }
|
|
735
|
+
thead th { border-top: none; background: #fafbfd; color: var(--muted); font-size: 11px; font-weight: 600; }
|
|
736
|
+
td.text { width: 40%; white-space: pre-line; }
|
|
737
|
+
td.loc { color: var(--muted); font-size: 12px; white-space: nowrap; font-family: ui-monospace, monospace; }
|
|
738
|
+
td.mono { font-family: ui-monospace, monospace; font-size: 12px; color: var(--muted); white-space: nowrap; }
|
|
739
|
+
td.ops { white-space: nowrap; }
|
|
740
|
+
tr:hover .iconbtn { visibility: visible; }
|
|
741
|
+
.newtxt-cell { color: var(--edit); font-weight: 600; }
|
|
742
|
+
|
|
743
|
+
/* editor */
|
|
744
|
+
.editbox { flex: 1; }
|
|
745
|
+
.editbox textarea, #promptText {
|
|
746
|
+
width: 100%; min-height: 56px; padding: 8px; border: 1px solid var(--edit);
|
|
747
|
+
border-radius: 8px; font: inherit; font-size: 13px;
|
|
748
|
+
}
|
|
749
|
+
.editbox .row { display: flex; gap: 6px; margin-top: 4px; }
|
|
750
|
+
.smallbtn {
|
|
751
|
+
border: 1px solid var(--border); background: var(--card); border-radius: 6px;
|
|
752
|
+
padding: 4px 10px; cursor: pointer; font-size: 12px;
|
|
753
|
+
}
|
|
754
|
+
.smallbtn.primary { background: var(--edit); border-color: var(--edit); color: #fff; }
|
|
755
|
+
|
|
756
|
+
/* bottom bar and modal */
|
|
757
|
+
#fixbar {
|
|
758
|
+
position: fixed; left: 0; right: 0; bottom: 0; display: none; gap: 12px; align-items: center;
|
|
759
|
+
background: var(--edit-soft); border-top: 1px solid var(--edit); padding: 10px 24px; z-index: 10;
|
|
760
|
+
}
|
|
761
|
+
#fixbar.show { display: flex; }
|
|
762
|
+
#fixbar b { color: var(--edit); }
|
|
763
|
+
#modal {
|
|
764
|
+
display: none; position: fixed; inset: 0; background: rgba(20,25,40,0.45); z-index: 20;
|
|
765
|
+
align-items: center; justify-content: center;
|
|
766
|
+
}
|
|
767
|
+
#modal.show { display: flex; }
|
|
768
|
+
#modal .box { background: var(--card); border-radius: 12px; padding: 18px; width: min(760px, 92vw); }
|
|
769
|
+
#modal h2 { margin: 0 0 10px; font-size: 15px; }
|
|
770
|
+
#promptText { min-height: 320px; font-family: ui-monospace, monospace; font-size: 12px; }
|
|
771
|
+
#modal .row { display: flex; gap: 8px; margin-top: 10px; justify-content: flex-end; }
|
|
772
|
+
</style>
|
|
773
|
+
</head>
|
|
774
|
+
<body>
|
|
775
|
+
<header>
|
|
776
|
+
<h1>UI Strings</h1>
|
|
777
|
+
<div class="meta" id="meta"></div>
|
|
778
|
+
</header>
|
|
779
|
+
<div class="stats" id="stats"></div>
|
|
780
|
+
<div class="controls">
|
|
781
|
+
<span class="viewswitch">
|
|
782
|
+
<button class="viewbtn active" id="btnPage">Page view</button>
|
|
783
|
+
<button class="viewbtn" id="btnTable">Table view</button>
|
|
784
|
+
</span>
|
|
785
|
+
<input type="search" id="search" placeholder="Search text or file name…" />
|
|
786
|
+
<select id="groupSelect"><option value="">All groups</option></select>
|
|
787
|
+
<span id="chips"></span>
|
|
788
|
+
</div>
|
|
789
|
+
<main id="list"></main>
|
|
790
|
+
<div id="fixbar">
|
|
791
|
+
<b><span id="editCount"></span> edits</b>
|
|
792
|
+
<button class="smallbtn primary" id="btnCopyAll">Copy fix prompt</button>
|
|
793
|
+
<button class="smallbtn" id="btnGenerate">Preview prompt</button>
|
|
794
|
+
<button class="smallbtn" id="btnClearEdits">Clear</button>
|
|
795
|
+
</div>
|
|
796
|
+
<div id="modal">
|
|
797
|
+
<div class="box">
|
|
798
|
+
<h2>Fix Prompt</h2>
|
|
799
|
+
<textarea id="promptText" readonly></textarea>
|
|
800
|
+
<div class="row">
|
|
801
|
+
<button class="smallbtn primary" id="btnCopyPrompt">Copy</button>
|
|
802
|
+
<button class="smallbtn" id="btnCloseModal">Close</button>
|
|
803
|
+
</div>
|
|
804
|
+
</div>
|
|
805
|
+
</div>
|
|
806
|
+
<script type="application/json" id="data">${data}</script>
|
|
807
|
+
<script>
|
|
808
|
+
const RESULT = JSON.parse(document.getElementById("data").textContent);
|
|
809
|
+
RESULT.entries.forEach((e, i) => { e.id = i; });
|
|
810
|
+
const SURFACES = ["visible", "interactive", "a11y", "meta", "internal"];
|
|
811
|
+
const state = { view: "page", query: "", group: "", surfaces: new Set(), editingId: null };
|
|
812
|
+
|
|
813
|
+
const STORAGE_KEY = "ui-strings-edits:" + RESULT.projectDir;
|
|
814
|
+
const editKey = (e) => e.file + ":" + e.line + ":" + e.text;
|
|
815
|
+
let edits = {};
|
|
816
|
+
try { edits = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"); } catch { edits = {}; }
|
|
817
|
+
const saveEdits = () => localStorage.setItem(STORAGE_KEY, JSON.stringify(edits));
|
|
818
|
+
|
|
819
|
+
const escapeHtml = (s) => s.replace(/[&<>"]/g, (c) => ({"&":"&","<":"<",">":">",'"':"""}[c]));
|
|
820
|
+
const highlight = (s, q) => {
|
|
821
|
+
const escaped = escapeHtml(s);
|
|
822
|
+
if (!q) return escaped;
|
|
823
|
+
const eq = escapeHtml(q).replace(/[.*+?^\${}()|[\\]\\\\]/g, "\\\\$&");
|
|
824
|
+
return escaped.replace(new RegExp(eq, "gi"), (m) => "<mark>" + m + "</mark>");
|
|
825
|
+
};
|
|
826
|
+
const contextOf = (e) =>
|
|
827
|
+
e.kind === "array-item" && e.key ? (e.tag ? e.key + " → <" + e.tag + ">" : e.key) :
|
|
828
|
+
e.attr ? e.attr + "=" : e.callee ? e.callee + "()" : e.key ? e.key + ":" : e.tag ? "<" + e.tag + ">" : "";
|
|
829
|
+
const condOf = (e) => {
|
|
830
|
+
if (!e.condition) return "";
|
|
831
|
+
if (e.branch === "else") {
|
|
832
|
+
return /^[A-Za-z_$][\\w$.]*$/.test(e.condition) ? "!" + e.condition : "!(" + e.condition + ")";
|
|
833
|
+
}
|
|
834
|
+
return e.condition;
|
|
835
|
+
};
|
|
836
|
+
const locOf = (e) => e.file + ":" + e.line;
|
|
837
|
+
|
|
838
|
+
function copyText(text) {
|
|
839
|
+
if (navigator.clipboard) return navigator.clipboard.writeText(text);
|
|
840
|
+
const ta = document.createElement("textarea");
|
|
841
|
+
ta.value = text; document.body.appendChild(ta); ta.select();
|
|
842
|
+
document.execCommand("copy"); ta.remove();
|
|
843
|
+
return Promise.resolve();
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function matches(e) {
|
|
847
|
+
if (e.surface === "internal" && !state.surfaces.has("internal")) return false;
|
|
848
|
+
if (state.surfaces.size && !state.surfaces.has(e.surface)) return false;
|
|
849
|
+
if (state.group && e.group !== state.group) return false;
|
|
850
|
+
if (state.query) {
|
|
851
|
+
const q = state.query.toLowerCase();
|
|
852
|
+
if (!e.text.toLowerCase().includes(q) && !e.file.toLowerCase().includes(q)) return false;
|
|
853
|
+
}
|
|
854
|
+
return true;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function chipsHtml(e) {
|
|
858
|
+
const ctx = contextOf(e);
|
|
859
|
+
const cond = condOf(e);
|
|
860
|
+
const ctxTitle = e.usedAt ? ' title="render: ' + escapeHtml(e.usedAt) + '"' : "";
|
|
861
|
+
return (ctx ? '<span class="ctx"' + ctxTitle + ">" + escapeHtml(ctx) + "</span>" : "") +
|
|
862
|
+
(cond ? '<span class="cond" title="branching condition">' + escapeHtml(cond) + "</span>" : "");
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function editorHtml(e) {
|
|
866
|
+
const current = edits[editKey(e)]?.newText ?? e.text;
|
|
867
|
+
return '<span class="editbox">' +
|
|
868
|
+
'<textarea data-edit-input="' + e.id + '">' + escapeHtml(current) + "</textarea>" +
|
|
869
|
+
'<span class="row">' +
|
|
870
|
+
'<button class="smallbtn primary" data-edit-save="' + e.id + '">Save</button>' +
|
|
871
|
+
'<button class="smallbtn" data-edit-cancel="' + e.id + '">Cancel</button>' +
|
|
872
|
+
(edits[editKey(e)] ? '<button class="smallbtn" data-edit-remove="' + e.id + '">Discard edit</button>' : "") +
|
|
873
|
+
"</span></span>";
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function textHtml(e) {
|
|
877
|
+
const edit = edits[editKey(e)];
|
|
878
|
+
let html = '<span class="txt">' + highlight(e.text, state.query) + "</span>";
|
|
879
|
+
if (edit) html += '<span class="newtxt">→ ' + escapeHtml(edit.newText) + "</span>";
|
|
880
|
+
return html;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function itemButtons(e) {
|
|
884
|
+
return '<button class="iconbtn" data-edit="' + e.id + '" title="Edit text">✎</button>' +
|
|
885
|
+
'<button class="iconbtn" data-copy="' + e.id + '" title="Copy row (TSV)">\uD83D\uDCCB</button>';
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function renderPageView(entries) {
|
|
889
|
+
const groups = new Map();
|
|
890
|
+
for (const e of entries) {
|
|
891
|
+
if (!groups.has(e.group)) groups.set(e.group, []);
|
|
892
|
+
groups.get(e.group).push(e);
|
|
893
|
+
}
|
|
894
|
+
let html = "";
|
|
895
|
+
for (const [group, items] of groups) {
|
|
896
|
+
const visible = items.filter((e) => e.surface === "visible");
|
|
897
|
+
const rest = new Map();
|
|
898
|
+
for (const e of items) {
|
|
899
|
+
if (e.surface === "visible") continue;
|
|
900
|
+
if (!rest.has(e.surface)) rest.set(e.surface, []);
|
|
901
|
+
rest.get(e.surface).push(e);
|
|
902
|
+
}
|
|
903
|
+
html += '<section class="page"><div class="page-bar">' +
|
|
904
|
+
'<span class="route">' + escapeHtml(group) + '</span>' +
|
|
905
|
+
'<span class="count">' + items.length + " items</span></div><div class=\\"page-body\\">";
|
|
906
|
+
let currentFile = "";
|
|
907
|
+
const renderItem = (e) => {
|
|
908
|
+
if (state.editingId === e.id) {
|
|
909
|
+
return '<div class="item">' + chipsHtml(e) + editorHtml(e) + "</div>";
|
|
910
|
+
}
|
|
911
|
+
return '<div class="item' + (edits[editKey(e)] ? " edited" : "") + '" title="' + escapeHtml(locOf(e)) + '">' +
|
|
912
|
+
chipsHtml(e) + textHtml(e) + itemButtons(e) + "</div>";
|
|
913
|
+
};
|
|
914
|
+
for (const e of visible) {
|
|
915
|
+
if (e.file !== currentFile) {
|
|
916
|
+
currentFile = e.file;
|
|
917
|
+
html += '<div class="file-divider">' + escapeHtml(e.file.split("/").pop()) + "</div>";
|
|
918
|
+
}
|
|
919
|
+
html += renderItem(e);
|
|
920
|
+
}
|
|
921
|
+
for (const [surface, list] of rest) {
|
|
922
|
+
html += "<details" + (list.some((e) => state.editingId === e.id) ? " open" : "") +
|
|
923
|
+
"><summary>" + surface + " (" + list.length + ")</summary>";
|
|
924
|
+
for (const e of list) html += renderItem(e);
|
|
925
|
+
html += "</details>";
|
|
926
|
+
}
|
|
927
|
+
html += "</div></section>";
|
|
928
|
+
}
|
|
929
|
+
return html;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function renderTableView(entries) {
|
|
933
|
+
const groups = new Map();
|
|
934
|
+
for (const e of entries) {
|
|
935
|
+
if (!groups.has(e.group)) groups.set(e.group, []);
|
|
936
|
+
groups.get(e.group).push(e);
|
|
937
|
+
}
|
|
938
|
+
let html = "";
|
|
939
|
+
for (const [group, items] of groups) {
|
|
940
|
+
html += '<section class="group"><h2>' + escapeHtml(group) +
|
|
941
|
+
'<span class="group-count">' + items.length + " items</span></h2>";
|
|
942
|
+
html += "<table><thead><tr><th>text</th><th>surface</th><th>kind</th><th>context</th><th>location</th><th></th></tr></thead><tbody>";
|
|
943
|
+
for (const e of items) {
|
|
944
|
+
const edit = edits[editKey(e)];
|
|
945
|
+
const cond = condOf(e);
|
|
946
|
+
const textCell = state.editingId === e.id
|
|
947
|
+
? editorHtml(e)
|
|
948
|
+
: highlight(e.text, state.query) +
|
|
949
|
+
(edit ? '<div class="newtxt-cell">→ ' + escapeHtml(edit.newText) + "</div>" : "");
|
|
950
|
+
html += "<tr><td class=\\"text\\">" + textCell + "</td>" +
|
|
951
|
+
'<td class="mono"><span class="badge ' + e.surface + '">' + e.surface + "</span></td>" +
|
|
952
|
+
'<td class="mono">' + e.kind + "</td>" +
|
|
953
|
+
'<td class="mono"' + (e.usedAt ? ' title="render: ' + escapeHtml(e.usedAt) + '"' : "") + ">" + escapeHtml(contextOf(e)) +
|
|
954
|
+
(cond ? ' <span class="cond">' + escapeHtml(cond) + "</span>" : "") + "</td>" +
|
|
955
|
+
'<td class="loc">' + highlight(locOf(e), state.query) + "</td>" +
|
|
956
|
+
'<td class="ops">' + itemButtons(e) + "</td></tr>";
|
|
957
|
+
}
|
|
958
|
+
html += "</tbody></table></section>";
|
|
959
|
+
}
|
|
960
|
+
return html;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
function render() {
|
|
964
|
+
const entries = RESULT.entries.filter(matches);
|
|
965
|
+
const uniqueTexts = new Set(entries.map((e) => e.text)).size;
|
|
966
|
+
const files = new Set(entries.map((e) => e.file)).size;
|
|
967
|
+
document.getElementById("meta").textContent =
|
|
968
|
+
RESULT.projectDir + " / " + RESULT.srcGlob + " / generated: " + RESULT.generatedAt;
|
|
969
|
+
document.getElementById("stats").innerHTML =
|
|
970
|
+
'<div class="stat"><b>' + entries.length + "</b><span>strings shown</span></div>" +
|
|
971
|
+
'<div class="stat"><b>' + uniqueTexts + "</b><span>unique strings</span></div>" +
|
|
972
|
+
'<div class="stat"><b>' + files + "</b><span>files</span></div>" +
|
|
973
|
+
'<div class="stat"><b>' + RESULT.scannedFiles + "</b><span>files scanned</span></div>";
|
|
974
|
+
const list = document.getElementById("list");
|
|
975
|
+
list.innerHTML = entries.length
|
|
976
|
+
? (state.view === "page" ? renderPageView(entries) : renderTableView(entries))
|
|
977
|
+
: '<div class="empty">No matching strings</div>';
|
|
978
|
+
renderFixbar();
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
function renderFixbar() {
|
|
982
|
+
const count = Object.keys(edits).length;
|
|
983
|
+
const bar = document.getElementById("fixbar");
|
|
984
|
+
bar.classList.toggle("show", count > 0);
|
|
985
|
+
document.getElementById("editCount").textContent = count;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function generatePrompt() {
|
|
989
|
+
const list = Object.values(edits);
|
|
990
|
+
const lines = [];
|
|
991
|
+
lines.push("Update the following UI strings. Target repository: " + RESULT.projectDir);
|
|
992
|
+
lines.push("Change only string literals and JSX text. Do not change logic or markup structure.");
|
|
993
|
+
lines.push('Each location is given as "file path:line number". A merged string may span multiple nodes across <br> or inline elements.');
|
|
994
|
+
lines.push("");
|
|
995
|
+
list.forEach((edit, i) => {
|
|
996
|
+
lines.push((i + 1) + ". " + edit.file + ":" + edit.line);
|
|
997
|
+
lines.push(" Current: " + edit.text.replace(/\\n/g, "\\\\n"));
|
|
998
|
+
lines.push(" Replace with: " + edit.newText.replace(/\\n/g, "\\\\n"));
|
|
999
|
+
});
|
|
1000
|
+
return lines.join("\\n");
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
document.getElementById("list").addEventListener("click", (event) => {
|
|
1004
|
+
const target = event.target.closest("button");
|
|
1005
|
+
if (!target) return;
|
|
1006
|
+
const byId = (value) => RESULT.entries[Number(value)];
|
|
1007
|
+
if (target.dataset.edit !== undefined) {
|
|
1008
|
+
state.editingId = Number(target.dataset.edit);
|
|
1009
|
+
render();
|
|
1010
|
+
const ta = document.querySelector('[data-edit-input="' + state.editingId + '"]');
|
|
1011
|
+
if (ta) { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); }
|
|
1012
|
+
} else if (target.dataset.copy !== undefined) {
|
|
1013
|
+
const e = byId(target.dataset.copy);
|
|
1014
|
+
copyText([e.text, e.surface, e.kind, contextOf(e), locOf(e)].join("\\t"));
|
|
1015
|
+
target.textContent = "✓";
|
|
1016
|
+
setTimeout(() => { target.textContent = "\uD83D\uDCCB"; }, 800);
|
|
1017
|
+
} else if (target.dataset.editSave !== undefined) {
|
|
1018
|
+
const e = byId(target.dataset.editSave);
|
|
1019
|
+
const ta = document.querySelector('[data-edit-input="' + e.id + '"]');
|
|
1020
|
+
const newText = ta.value;
|
|
1021
|
+
if (newText !== e.text && newText.trim() !== "") {
|
|
1022
|
+
edits[editKey(e)] = { file: e.file, line: e.line, text: e.text, newText };
|
|
1023
|
+
} else {
|
|
1024
|
+
delete edits[editKey(e)];
|
|
1025
|
+
}
|
|
1026
|
+
saveEdits();
|
|
1027
|
+
state.editingId = null;
|
|
1028
|
+
render();
|
|
1029
|
+
} else if (target.dataset.editCancel !== undefined) {
|
|
1030
|
+
state.editingId = null;
|
|
1031
|
+
render();
|
|
1032
|
+
} else if (target.dataset.editRemove !== undefined) {
|
|
1033
|
+
const e = byId(target.dataset.editRemove);
|
|
1034
|
+
delete edits[editKey(e)];
|
|
1035
|
+
saveEdits();
|
|
1036
|
+
state.editingId = null;
|
|
1037
|
+
render();
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
|
|
1041
|
+
document.getElementById("btnCopyAll").onclick = (event) => {
|
|
1042
|
+
copyText(generatePrompt());
|
|
1043
|
+
event.target.textContent = "Copied ✓";
|
|
1044
|
+
setTimeout(() => { event.target.textContent = "Copy fix prompt"; }, 1200);
|
|
1045
|
+
};
|
|
1046
|
+
document.getElementById("btnGenerate").onclick = () => {
|
|
1047
|
+
document.getElementById("promptText").value = generatePrompt();
|
|
1048
|
+
document.getElementById("modal").classList.add("show");
|
|
1049
|
+
};
|
|
1050
|
+
document.getElementById("btnCopyPrompt").onclick = (event) => {
|
|
1051
|
+
copyText(document.getElementById("promptText").value);
|
|
1052
|
+
event.target.textContent = "Copied";
|
|
1053
|
+
setTimeout(() => { event.target.textContent = "Copy"; }, 1000);
|
|
1054
|
+
};
|
|
1055
|
+
document.getElementById("btnCloseModal").onclick = () =>
|
|
1056
|
+
document.getElementById("modal").classList.remove("show");
|
|
1057
|
+
document.getElementById("btnClearEdits").onclick = () => {
|
|
1058
|
+
edits = {};
|
|
1059
|
+
saveEdits();
|
|
1060
|
+
render();
|
|
1061
|
+
};
|
|
1062
|
+
|
|
1063
|
+
const surfaceCounts = new Map();
|
|
1064
|
+
for (const e of RESULT.entries) surfaceCounts.set(e.surface, (surfaceCounts.get(e.surface) ?? 0) + 1);
|
|
1065
|
+
const chips = document.getElementById("chips");
|
|
1066
|
+
for (const surface of SURFACES) {
|
|
1067
|
+
const count = surfaceCounts.get(surface) ?? 0;
|
|
1068
|
+
if (!count) continue;
|
|
1069
|
+
const chip = document.createElement("button");
|
|
1070
|
+
chip.className = "chip";
|
|
1071
|
+
chip.textContent = surface + " " + count;
|
|
1072
|
+
chip.onclick = () => {
|
|
1073
|
+
state.surfaces.has(surface) ? state.surfaces.delete(surface) : state.surfaces.add(surface);
|
|
1074
|
+
chip.classList.toggle("active");
|
|
1075
|
+
render();
|
|
1076
|
+
};
|
|
1077
|
+
chips.appendChild(chip);
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const btnPage = document.getElementById("btnPage");
|
|
1081
|
+
const btnTable = document.getElementById("btnTable");
|
|
1082
|
+
btnPage.onclick = () => { state.view = "page"; btnPage.classList.add("active"); btnTable.classList.remove("active"); render(); };
|
|
1083
|
+
btnTable.onclick = () => { state.view = "table"; btnTable.classList.add("active"); btnPage.classList.remove("active"); render(); };
|
|
1084
|
+
|
|
1085
|
+
const groupSelect = document.getElementById("groupSelect");
|
|
1086
|
+
for (const group of [...new Set(RESULT.entries.map((e) => e.group))].sort()) {
|
|
1087
|
+
const option = document.createElement("option");
|
|
1088
|
+
option.value = group;
|
|
1089
|
+
option.textContent = group;
|
|
1090
|
+
groupSelect.appendChild(option);
|
|
1091
|
+
}
|
|
1092
|
+
groupSelect.onchange = () => { state.group = groupSelect.value; render(); };
|
|
1093
|
+
document.getElementById("search").oninput = (event) => {
|
|
1094
|
+
state.query = event.target.value.trim();
|
|
1095
|
+
render();
|
|
1096
|
+
};
|
|
1097
|
+
render();
|
|
1098
|
+
</script>
|
|
1099
|
+
</body>
|
|
1100
|
+
</html>
|
|
1101
|
+
`;
|
|
1102
|
+
};
|
|
1103
|
+
|
|
1104
|
+
// src/report-md.ts
|
|
1105
|
+
var escapeCell = (text) => text.replace(/\|/g, "\\|").replace(/\n/g, "<br>");
|
|
1106
|
+
var countBy = (entries, keyOf) => {
|
|
1107
|
+
const counts = new Map;
|
|
1108
|
+
for (const entry of entries) {
|
|
1109
|
+
const key = keyOf(entry);
|
|
1110
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
1111
|
+
}
|
|
1112
|
+
return counts;
|
|
1113
|
+
};
|
|
1114
|
+
var contextOf = (entry) => {
|
|
1115
|
+
const parts = [];
|
|
1116
|
+
if (entry.kind === "array-item" && entry.key) {
|
|
1117
|
+
parts.push(entry.tag ? `\`${entry.key}\` → \`<${entry.tag}>\`` : `\`${entry.key}\``);
|
|
1118
|
+
} else if (entry.attr) {
|
|
1119
|
+
parts.push(`\`${entry.attr}=\``);
|
|
1120
|
+
} else if (entry.callee) {
|
|
1121
|
+
parts.push(`\`${entry.callee}()\``);
|
|
1122
|
+
} else if (entry.key) {
|
|
1123
|
+
parts.push(`\`${entry.key}:\``);
|
|
1124
|
+
} else if (entry.tag) {
|
|
1125
|
+
parts.push(`\`<${entry.tag}>\``);
|
|
1126
|
+
}
|
|
1127
|
+
if (entry.condition) {
|
|
1128
|
+
const negated = entry.branch === "else" ? `!(${entry.condition})` : entry.condition;
|
|
1129
|
+
parts.push(`cond: \`${negated}\``);
|
|
1130
|
+
}
|
|
1131
|
+
return parts.join(" ");
|
|
1132
|
+
};
|
|
1133
|
+
var renderMarkdown = (result, visibleEntries) => {
|
|
1134
|
+
const lines = [];
|
|
1135
|
+
lines.push("# UI Strings");
|
|
1136
|
+
lines.push("");
|
|
1137
|
+
lines.push(`- Target: \`${result.projectDir}\` (\`${result.srcGlob}\`)`);
|
|
1138
|
+
lines.push(`- Generated: ${result.generatedAt}`);
|
|
1139
|
+
lines.push(`- Strings: **${visibleEntries.length}** / Files scanned: ${result.scannedFiles}`);
|
|
1140
|
+
lines.push("");
|
|
1141
|
+
lines.push("## Count by surface");
|
|
1142
|
+
lines.push("");
|
|
1143
|
+
lines.push("| surface | count |");
|
|
1144
|
+
lines.push("| --- | ---: |");
|
|
1145
|
+
const surfaceCounts = countBy(visibleEntries, (entry) => entry.surface);
|
|
1146
|
+
for (const [surface, count] of [...surfaceCounts.entries()].sort((a, b) => b[1] - a[1])) {
|
|
1147
|
+
lines.push(`| \`${surface}\` | ${count} |`);
|
|
1148
|
+
}
|
|
1149
|
+
lines.push("");
|
|
1150
|
+
lines.push("## Count by kind");
|
|
1151
|
+
lines.push("");
|
|
1152
|
+
lines.push("| kind | count |");
|
|
1153
|
+
lines.push("| --- | ---: |");
|
|
1154
|
+
const kindCounts = countBy(visibleEntries, (entry) => entry.kind);
|
|
1155
|
+
for (const [kind, count] of [...kindCounts.entries()].sort((a, b) => b[1] - a[1])) {
|
|
1156
|
+
lines.push(`| \`${kind}\` | ${count} |`);
|
|
1157
|
+
}
|
|
1158
|
+
lines.push("");
|
|
1159
|
+
lines.push("## Count by group");
|
|
1160
|
+
lines.push("");
|
|
1161
|
+
lines.push("| group | count |");
|
|
1162
|
+
lines.push("| --- | ---: |");
|
|
1163
|
+
const groupCounts = countBy(visibleEntries, (entry) => entry.group);
|
|
1164
|
+
for (const [group, count] of [...groupCounts.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
1165
|
+
lines.push(`| ${group} | ${count} |`);
|
|
1166
|
+
}
|
|
1167
|
+
lines.push("");
|
|
1168
|
+
lines.push("## All strings");
|
|
1169
|
+
let currentGroup = "";
|
|
1170
|
+
let currentFile = "";
|
|
1171
|
+
for (const entry of visibleEntries) {
|
|
1172
|
+
if (entry.group !== currentGroup) {
|
|
1173
|
+
currentGroup = entry.group;
|
|
1174
|
+
currentFile = "";
|
|
1175
|
+
lines.push("");
|
|
1176
|
+
lines.push(`### ${currentGroup}`);
|
|
1177
|
+
}
|
|
1178
|
+
if (entry.file !== currentFile) {
|
|
1179
|
+
currentFile = entry.file;
|
|
1180
|
+
lines.push("");
|
|
1181
|
+
lines.push(`#### ${currentFile}`);
|
|
1182
|
+
lines.push("");
|
|
1183
|
+
lines.push("| line | surface | context | text |");
|
|
1184
|
+
lines.push("| ---: | --- | --- | --- |");
|
|
1185
|
+
}
|
|
1186
|
+
lines.push(`| ${entry.line} | \`${entry.surface}\` | ${contextOf(entry)} | ${escapeCell(entry.text)} |`);
|
|
1187
|
+
}
|
|
1188
|
+
lines.push("");
|
|
1189
|
+
return lines.join(`
|
|
1190
|
+
`);
|
|
1191
|
+
};
|
|
1192
|
+
|
|
1193
|
+
// src/cli.ts
|
|
1194
|
+
var program = new Command;
|
|
1195
|
+
program.name("ui-strings").description("Extract user-reachable UI strings from a React (TS/TSX) codebase and generate reports").version("0.1.0");
|
|
1196
|
+
program.command("scan").argument("[projectDir]", "root directory of the target project", ".").option("--src <glob>", "glob of files to scan", "src/**/*.{ts,tsx,js,jsx}").option("--exclude <globs>", "extra exclude globs (comma-separated; test/spec/__tests__ are always excluded)", "").option("--out <dir>", "output directory", "./ui-strings-report").option("--format <list>", "output formats (json,md,html)", "json,md,html").option("--include-internal", "include console/throw strings in the Markdown listing", false).option("--open", "open the HTML report in a browser after generation", false).action(async (projectDirArg, options) => {
|
|
1197
|
+
const projectDir = resolve(projectDirArg);
|
|
1198
|
+
const formats = new Set(options.format.split(",").map((format) => format.trim()));
|
|
1199
|
+
console.log(pc.dim(`Scanning: ${projectDir}/${options.src}`));
|
|
1200
|
+
const result = scanProject({
|
|
1201
|
+
projectDir,
|
|
1202
|
+
srcGlob: options.src,
|
|
1203
|
+
exclude: options.exclude.split(",").map((glob) => glob.trim()).filter((glob) => glob !== "")
|
|
1204
|
+
});
|
|
1205
|
+
const visibleEntries = options.includeInternal ? result.entries : result.entries.filter((entry) => entry.kind !== "internal");
|
|
1206
|
+
const outDir = resolve(options.out);
|
|
1207
|
+
mkdirSync(outDir, { recursive: true });
|
|
1208
|
+
const written = [];
|
|
1209
|
+
if (formats.has("json")) {
|
|
1210
|
+
const path = join2(outDir, "ui-strings.json");
|
|
1211
|
+
writeFileSync(path, JSON.stringify(result, null, 2));
|
|
1212
|
+
written.push(path);
|
|
1213
|
+
}
|
|
1214
|
+
if (formats.has("md")) {
|
|
1215
|
+
const path = join2(outDir, "ui-strings.md");
|
|
1216
|
+
writeFileSync(path, renderMarkdown(result, visibleEntries));
|
|
1217
|
+
written.push(path);
|
|
1218
|
+
}
|
|
1219
|
+
let htmlPath;
|
|
1220
|
+
if (formats.has("html")) {
|
|
1221
|
+
htmlPath = join2(outDir, "ui-strings.html");
|
|
1222
|
+
writeFileSync(htmlPath, renderHtml(result));
|
|
1223
|
+
written.push(htmlPath);
|
|
1224
|
+
}
|
|
1225
|
+
const surfaceCounts = new Map;
|
|
1226
|
+
for (const entry of result.entries) {
|
|
1227
|
+
surfaceCounts.set(entry.surface, (surfaceCounts.get(entry.surface) ?? 0) + 1);
|
|
1228
|
+
}
|
|
1229
|
+
const internalCount = surfaceCounts.get("internal") ?? 0;
|
|
1230
|
+
console.log();
|
|
1231
|
+
console.log(pc.bold(`${pc.green(String(visibleEntries.length))} strings` + (internalCount > 0 ? pc.dim(` (+${internalCount} internal)`) : "")) + pc.dim(` / ${result.scannedFiles} files scanned`));
|
|
1232
|
+
for (const [surface, count] of [...surfaceCounts.entries()].sort((a, b) => b[1] - a[1])) {
|
|
1233
|
+
console.log(` ${surface.padEnd(12)} ${count}`);
|
|
1234
|
+
}
|
|
1235
|
+
console.log();
|
|
1236
|
+
for (const path of written) {
|
|
1237
|
+
console.log(pc.dim(`→ ${path}`));
|
|
1238
|
+
}
|
|
1239
|
+
if (options.open && htmlPath) {
|
|
1240
|
+
const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
1241
|
+
spawn(opener, [htmlPath], { detached: true, stdio: "ignore" }).unref();
|
|
1242
|
+
}
|
|
1243
|
+
});
|
|
1244
|
+
await program.parseAsync();
|