prosebook 0.0.1 → 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.
Files changed (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +595 -0
  3. package/dist/cli.d.ts +1 -0
  4. package/dist/cli.js +508 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/config-CF7HziDa.d.ts +498 -0
  7. package/dist/config-CF7HziDa.d.ts.map +1 -0
  8. package/dist/config-CxVMn19f.js +1146 -0
  9. package/dist/config-CxVMn19f.js.map +1 -0
  10. package/dist/config.d.ts +2 -0
  11. package/dist/config.js +1 -0
  12. package/dist/copy-DXnzXsJa.d.ts +162 -0
  13. package/dist/copy-DXnzXsJa.d.ts.map +1 -0
  14. package/dist/helpers-BPuPGjQP.js +61 -0
  15. package/dist/helpers-BPuPGjQP.js.map +1 -0
  16. package/dist/index.d.ts +24 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +188 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/inspector.d.ts +37 -0
  21. package/dist/inspector.d.ts.map +1 -0
  22. package/dist/inspector.js +572 -0
  23. package/dist/inspector.js.map +1 -0
  24. package/dist/react/index.d.ts +37 -0
  25. package/dist/react/index.d.ts.map +1 -0
  26. package/dist/react/index.js +86 -0
  27. package/dist/react/index.js.map +1 -0
  28. package/dist/sarif-PkPvsB07.js +1033 -0
  29. package/dist/sarif-PkPvsB07.js.map +1 -0
  30. package/dist/scan-B0tZ7y-D.js +2447 -0
  31. package/dist/scan-B0tZ7y-D.js.map +1 -0
  32. package/dist/tools.d.ts +351 -0
  33. package/dist/tools.d.ts.map +1 -0
  34. package/dist/tools.js +4 -0
  35. package/dist/vite.d.ts +15 -0
  36. package/dist/vite.d.ts.map +1 -0
  37. package/dist/vite.js +238 -0
  38. package/dist/vite.js.map +1 -0
  39. package/package.json +148 -15
  40. package/src/cli.ts +528 -0
  41. package/src/index.ts +31 -0
  42. package/src/inspector-dialog.ts +450 -0
  43. package/src/inspector.ts +228 -0
  44. package/src/react/index.tsx +112 -0
  45. package/src/runtime/copy.ts +221 -0
  46. package/src/runtime/helpers.ts +66 -0
  47. package/src/runtime/locale.ts +49 -0
  48. package/src/runtime/types.ts +106 -0
  49. package/src/tools/catalog.ts +449 -0
  50. package/src/tools/compile.ts +110 -0
  51. package/src/tools/config.ts +195 -0
  52. package/src/tools/diff.ts +84 -0
  53. package/src/tools/entry.ts +325 -0
  54. package/src/tools/extract.ts +694 -0
  55. package/src/tools/generate.ts +259 -0
  56. package/src/tools/icu.ts +182 -0
  57. package/src/tools/index.ts +78 -0
  58. package/src/tools/lint.ts +828 -0
  59. package/src/tools/lock.ts +95 -0
  60. package/src/tools/mf1.ts +246 -0
  61. package/src/tools/mf2.ts +752 -0
  62. package/src/tools/migrate.ts +138 -0
  63. package/src/tools/presets.ts +159 -0
  64. package/src/tools/pseudo.ts +40 -0
  65. package/src/tools/sarif.ts +80 -0
  66. package/src/tools/scan.ts +173 -0
  67. package/src/tools/syntax.ts +24 -0
  68. package/src/tools/types.ts +137 -0
  69. package/src/vite.ts +252 -0
  70. package/index.js +0 -1
@@ -0,0 +1,138 @@
1
+ /**
2
+ * `prosebook migrate`: a catalog and its translations rewritten from one syntax to the other. Each
3
+ * message is written in the target syntax, read back, and rendered both ways over sample values, so
4
+ * one that can't be written, or would come out different, is reported before anything changes.
5
+ */
6
+ import { readFileSync, writeFileSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+
9
+ import { isMap, parseDocument, Scalar } from 'yaml';
10
+
11
+ import { createHelpers, flatten } from '#src/runtime/helpers.ts';
12
+ import type { CompiledMessages } from '#src/runtime/types.ts';
13
+
14
+ import { loadCatalog } from './catalog.ts';
15
+ import { compileLocale, localeExpression } from './compile.ts';
16
+ import type { ResolvedConfig } from './config.ts';
17
+ import { argsOf, tagsOf, type MessageFormatElement } from './icu.ts';
18
+ import { readMessage, writeMessage, type MessageFormatVersion } from './syntax.ts';
19
+
20
+ interface Located {
21
+ key: string;
22
+ locale: string;
23
+ /** The file, relative to the project root. */
24
+ file: string;
25
+ }
26
+
27
+ export interface MigratePlan {
28
+ to: MessageFormatVersion;
29
+ rewrites: (Located & { before: string; after: string })[];
30
+ /** Messages the target syntax can't say, and why. Nothing is written while there are any. */
31
+ blocked: (Located & { reason: string })[];
32
+ /** Messages that would render differently, with the first values that show it. */
33
+ changed: (Located & { values: string; before: string; after: string })[];
34
+ }
35
+
36
+ export function planMigrate(config: ResolvedConfig, to: MessageFormatVersion): MigratePlan {
37
+ const catalog = loadCatalog(config);
38
+ const plan: MigratePlan = { to, rewrites: [], blocked: [], changed: [] };
39
+ for (const m of catalog.messages) {
40
+ const versions = [
41
+ { locale: config.source, text: m.text, ast: m.ast, file: m.file },
42
+ ...[...m.translations].map(([locale, t]) => ({ locale, text: t.text, ast: t.ast, file: t.file })),
43
+ ];
44
+ for (const { locale, text, ast, file } of versions) {
45
+ const at = { key: m.key, locale, file };
46
+ let after: string;
47
+ let back: MessageFormatElement[];
48
+ try {
49
+ after = writeMessage(to, ast, locale);
50
+ back = readMessage(to, after, locale);
51
+ } catch (e) {
52
+ plan.blocked.push({ ...at, reason: (e as Error).message });
53
+ continue;
54
+ }
55
+ const difference = firstDifference(ast, back, locale);
56
+ if (difference !== undefined) plan.changed.push({ ...at, ...difference });
57
+ if (after !== text) plan.rewrites.push({ ...at, before: text, after });
58
+ }
59
+ }
60
+ return plan;
61
+ }
62
+
63
+ /** Writes the plan's rewrites into the YAML files, keeping their comments and layout. Returns the files written. */
64
+ export function applyMigrate(config: ResolvedConfig, plan: MigratePlan): string[] {
65
+ if (plan.blocked.length > 0) throw new Error('prosebook: some messages have no equivalent in the target syntax; nothing was written');
66
+ const byFile = Map.groupBy(plan.rewrites, (r) => r.file);
67
+ for (const [file, rewrites] of byFile) {
68
+ const path = join(config.root, file);
69
+ // As YAML 1.1, so a new value that 1.1 would read differently, like `n`, is quoted.
70
+ const doc = parseDocument(readFileSync(path, 'utf8'), { version: '1.1' });
71
+ for (const r of rewrites) {
72
+ const value = doc.createNode(r.after);
73
+ if (r.after.includes('\n')) value.type = Scalar.BLOCK_LITERAL;
74
+ const entry = doc.get(r.key, true);
75
+ if (isMap(entry)) entry.set(config.source, value);
76
+ else doc.set(r.key, value);
77
+ }
78
+ writeFileSync(path, doc.toString({ lineWidth: 0 }));
79
+ }
80
+ return [...byFile.keys()];
81
+ }
82
+
83
+ const NUMBERS = [0, 1, 2, 3, 5, 11, 21, 101, 1.5, 1234.5];
84
+ const DATE = new Date(Date.UTC(2026, 0, 5, 14, 30, 15));
85
+
86
+ function samplesOf(type: string): unknown[] {
87
+ if (type === 'number') return NUMBERS;
88
+ if (type === 'Date | number') return [DATE];
89
+ const named = [...type.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => JSON.parse(`"${m[1]}"`) as string);
90
+ return named.length > 0 ? [...named, 'zzz'] : ['Ada'];
91
+ }
92
+
93
+ /** Every combination of sample values, or each argument varied alone where there are too many. */
94
+ function* samples(a: MessageFormatElement[], b: MessageFormatElement[]): Generator<Record<string, unknown>> {
95
+ const types = new Map<string, Set<unknown>>();
96
+ for (const [name, type] of [...argsOf(a), ...argsOf(b)]) {
97
+ const set = types.get(name) ?? new Set();
98
+ for (const value of samplesOf(type)) set.add(value);
99
+ types.set(name, set);
100
+ }
101
+ const tags = Object.fromEntries(
102
+ [...new Set([...tagsOf(a), ...tagsOf(b)])].map((t) => [t, (c: unknown[]) => `<${t}>${c.join('')}</${t}>`]),
103
+ );
104
+ const names = [...types.keys()];
105
+ const lists = names.map((n) => [...types.get(n)!]);
106
+ if (lists.reduce((n, l) => n * l.length, 1) <= 1000) {
107
+ let combos: unknown[][] = [[]];
108
+ for (const list of lists) combos = combos.flatMap((c) => list.map((v) => [...c, v]));
109
+ for (const combo of combos) yield { ...tags, ...Object.fromEntries(names.map((n, i) => [n, combo[i]])) };
110
+ return;
111
+ }
112
+ const base = Object.fromEntries(names.map((n, i) => [n, lists[i]![0]]));
113
+ for (const [i, n] of names.entries()) for (const value of lists[i]!) yield { ...tags, ...base, [n]: value };
114
+ }
115
+
116
+ function renderer(ast: MessageFormatElement[], locale: string): (values: Record<string, unknown>) => string {
117
+ // oxlint-disable-next-line no-new-func, typescript/no-implied-eval -- evaluating our own compiled message, as the bundle would
118
+ const messages = new Function(`return ${localeExpression(compileLocale([['m', ast]]))};`)() as CompiledMessages;
119
+ const message = messages.m!;
120
+ const h = createHelpers(locale);
121
+ return (values) => (typeof message === 'string' ? message : flatten(message(values, h)).join(''));
122
+ }
123
+
124
+ function firstDifference(a: MessageFormatElement[], b: MessageFormatElement[], locale: string) {
125
+ const before = renderer(a, locale);
126
+ const after = renderer(b, locale);
127
+ for (const values of samples(a, b)) {
128
+ const x = before(values);
129
+ const y = after(values);
130
+ if (x !== y) {
131
+ const shown = Object.entries(values)
132
+ .filter(([, v]) => typeof v !== 'function')
133
+ .map(([k, v]) => `${k}: ${v instanceof Date ? v.toISOString() : JSON.stringify(v)}`);
134
+ return { values: `{ ${shown.join(', ')} }`, before: x, after: y };
135
+ }
136
+ }
137
+ return undefined;
138
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The defaults: four kinds of string, the surfaces they sit on, and the word lists of the strict lint preset.
3
+ *
4
+ * The kinds are the four any UI writing guide comes down to. A name identifies, an action says what
5
+ * pressing it does, a description adds what the name cannot carry, a state reports what is true.
6
+ * Each carries a budget and a grammar, which is what makes a mechanical check of the words possible.
7
+ */
8
+ export type Severity = 'error' | 'warn' | 'off';
9
+
10
+ export interface KindRule {
11
+ /** What the kind is for, shown when an editor completes it. */
12
+ doc: string;
13
+ /** The most words it may have. Articles, short prepositions and asides in parentheses are free. */
14
+ maxWords?: number;
15
+ /** The most sentences it may have, and per surface where that differs. */
16
+ maxSentences?: number;
17
+ maxSentencesOn?: Record<string, number>;
18
+ /** Whether it ends with a period: always, never, or only on the surfaces listed. */
19
+ period?: 'always' | 'never' | string[];
20
+ /** Capitalizes only its first word and proper nouns. */
21
+ sentenceCase?: boolean;
22
+ /** Carries no explanation after a dash or colon: `Quick capture`, not `Quick capture: choose an area`. */
23
+ bare?: boolean;
24
+ }
25
+
26
+ export const DEFAULT_KINDS: Record<string, KindRule> = {
27
+ name: {
28
+ doc: 'Identifies a thing so it can be found again. One to four words, a noun phrase, no period.',
29
+ maxWords: 4,
30
+ period: 'never',
31
+ sentenceCase: true,
32
+ bare: true,
33
+ },
34
+ action: {
35
+ doc: 'Says what pressing it does. One to three words, verb first, no period.',
36
+ maxWords: 3,
37
+ period: 'never',
38
+ sentenceCase: true,
39
+ },
40
+ description: {
41
+ doc: 'Adds only what the name cannot carry. One sentence, two at most, with a period.',
42
+ maxSentences: 2,
43
+ period: 'always',
44
+ },
45
+ state: {
46
+ doc: 'Reports what is true right now. One sentence, present tense; on an error or empty state, one more saying what to do.',
47
+ maxSentences: 1,
48
+ maxSentencesOn: { error: 2, empty: 2, hint: 2, status: 2 },
49
+ period: ['hint', 'status', 'error', 'empty', 'confirm'],
50
+ },
51
+ };
52
+
53
+ export const DEFAULT_SURFACES: Record<string, string> = {
54
+ action: 'The text of a button or a menu entry.',
55
+ aria: 'Reached only by a screen reader, so nothing on screen carries it.',
56
+ choice: 'One option among a set offered together.',
57
+ confirm: 'The question of a confirmation, or the verb that answers it.',
58
+ empty: 'Fills a list or a panel that has nothing in it.',
59
+ error: 'Says what failed.',
60
+ heading: 'Heads a section, group, dialog or list.',
61
+ hint: 'A subordinate line under the thing it qualifies.',
62
+ label: 'Names a control, a field, a section or a destination.',
63
+ placeholder: 'Sits inside an empty field, and goes when typing starts.',
64
+ status: 'Reports what is true right now, on a badge or beside a row.',
65
+ title: 'The title of a page, window or document.',
66
+ toast: 'A notification that comes and goes.',
67
+ tooltip: 'Appears on hover.',
68
+ };
69
+
70
+ /** The word lists of the strict preset: filler, marketing and reassurance, and American spelling. */
71
+ export const STRICT_LISTS = {
72
+ banned: [
73
+ {
74
+ words: ['seamless', 'seamlessly', 'effortless', 'effortlessly', 'hassle-free', 'frictionless'],
75
+ why: 'promises a feeling rather than saying what happens',
76
+ },
77
+ {
78
+ words: ['leverage', 'leverages', 'leveraging', 'utilize', 'utilizes', 'utilizing', 'harness', 'take advantage of'],
79
+ why: 'is a long way to say use',
80
+ },
81
+ {
82
+ words: [
83
+ 'delve',
84
+ 'dive into',
85
+ 'deep dive',
86
+ 'embark',
87
+ 'journey',
88
+ 'tapestry',
89
+ 'realm',
90
+ 'landscape',
91
+ 'testament',
92
+ 'synergy',
93
+ 'paradigm',
94
+ 'holistic',
95
+ ],
96
+ why: 'is filler, not a thing on the screen',
97
+ },
98
+ {
99
+ words: [
100
+ 'robust',
101
+ 'powerful',
102
+ 'cutting-edge',
103
+ 'state-of-the-art',
104
+ 'best-in-class',
105
+ 'world-class',
106
+ 'next-generation',
107
+ 'revolutionary',
108
+ 'game-changing',
109
+ ],
110
+ why: 'is marketing: say what it does',
111
+ },
112
+ {
113
+ words: ['streamline', 'streamlined', 'empower', 'unlock', 'unleash', 'elevate', 'supercharge', 'boost', 'enhance', 'enhanced'],
114
+ why: 'claims an improvement without naming it',
115
+ },
116
+ { words: ['meticulous', 'crucial', 'pivotal', 'vital', 'paramount'], why: 'tells the reader how to feel instead of what it is' },
117
+ {
118
+ words: ['comprehensive', 'myriad', 'plethora', 'a variety of', 'a wide range of'],
119
+ why: 'is padding: name the things, or say how many',
120
+ },
121
+ {
122
+ words: ['furthermore', 'moreover', 'additionally', 'in order to', 'please note', 'note that', 'keep in mind', 'rest assured'],
123
+ why: 'is a connective a short string has no room for',
124
+ },
125
+ { words: ['intuitive', 'sleek', 'stunning', 'beautiful', 'delightful', 'magical'], why: 'grades the product instead of describing it' },
126
+ { words: ['simply', 'just', 'easily', 'easy', 'quickly'], why: 'says it is easy, which helps nobody it is not easy for' },
127
+ { words: ['so you can', 'allows you to', 'lets you', 'enables you to', "don't worry"], why: 'justifies or reassures: state the fact' },
128
+ { words: ['please', 'oops', 'whoops', 'sorry'], why: 'pleads or apologizes: say what failed and what to do' },
129
+ { words: ['etc', 'and more', 'and so on'], why: 'trails off: name the things or stop' },
130
+ ],
131
+ spelling: {
132
+ colour: 'color',
133
+ colours: 'colors',
134
+ behaviour: 'behavior',
135
+ favourite: 'favorite',
136
+ cancelled: 'canceled',
137
+ cancelling: 'canceling',
138
+ labelled: 'labeled',
139
+ centre: 'center',
140
+ licence: 'license',
141
+ grey: 'gray',
142
+ organise: 'organize',
143
+ recognise: 'recognize',
144
+ customise: 'customize',
145
+ optimise: 'optimize',
146
+ minimise: 'minimize',
147
+ maximise: 'maximize',
148
+ synchronise: 'synchronize',
149
+ authorise: 'authorize',
150
+ initialise: 'initialize',
151
+ analyse: 'analyze',
152
+ catalogue: 'catalog',
153
+ dialogue: 'dialog',
154
+ artefact: 'artifact',
155
+ judgement: 'judgment',
156
+ whilst: 'while',
157
+ amongst: 'among',
158
+ } as Record<string, string>,
159
+ };
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Pseudo-localization: the source with every letter accented, vowels doubled for the length a
3
+ * translation usually adds, and brackets around the whole. A screen in the pseudo-locale shows at a
4
+ * glance which text is hardcoded (it is plain), which is clipped (a bracket is missing), and which
5
+ * splices fragments together (brackets inside brackets).
6
+ */
7
+ import { TYPE, type MessageFormatElement } from './icu.ts';
8
+
9
+ const PLAIN = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
10
+ const ACCENTED = Array.from('áƀçðéƒĝĥíĵķļɱñóþǫŕšţúṽŵẋýžÁƁÇÐÉƑĜĤÍĴĶĻṀÑÓÞǪŔŠŢÚṼŴẊÝŽ');
11
+
12
+ export function pseudoText(text: string): string {
13
+ return text.replace(/[A-Za-z]/g, (c) => {
14
+ const accented = ACCENTED[PLAIN.indexOf(c)]!;
15
+ return /[aeiou]/i.test(c) ? accented.repeat(2) : accented;
16
+ });
17
+ }
18
+
19
+ function transform(elements: MessageFormatElement[]): MessageFormatElement[] {
20
+ return elements.map((el) => {
21
+ if (el.type === TYPE.literal) return { ...el, value: pseudoText(el.value) };
22
+ if (el.type === TYPE.tag) return { ...el, children: transform(el.children) };
23
+ if (el.type === TYPE.select || el.type === TYPE.plural) {
24
+ const options = Object.fromEntries(Object.entries(el.options).map(([arm, o]) => [arm, { ...o, value: transform(o.value) }]));
25
+ return { ...el, options };
26
+ }
27
+ return el;
28
+ });
29
+ }
30
+
31
+ export function pseudoAst(ast: MessageFormatElement[]): MessageFormatElement[] {
32
+ const out: MessageFormatElement[] = [];
33
+ // Adjacent literals are joined, so a message with no placeholders still compiles to a plain string.
34
+ for (const el of [{ type: TYPE.literal, value: '⟦' }, ...transform(ast), { type: TYPE.literal, value: '⟧' }] as MessageFormatElement[]) {
35
+ const last = out.at(-1);
36
+ if (el.type === TYPE.literal && last?.type === TYPE.literal) out[out.length - 1] = { ...last, value: last.value + el.value };
37
+ else out.push(el);
38
+ }
39
+ return out;
40
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Findings as SARIF 2.1.0, the static-analysis format code scanning reads (GitHub's among them), so
3
+ * `lint`, `scan`, `status` and `check` can annotate a pull request on the line the string is written.
4
+ *
5
+ * Paths are relative to the project root under `%SRCROOT%`, which the log maps to the root itself:
6
+ * a project that isn't at the top of its repository still resolves to the right files.
7
+ */
8
+ import { pathToFileURL } from 'node:url';
9
+
10
+ export interface SarifResult {
11
+ /** A rule the report describes, or one of a lint plugin's. */
12
+ rule: string;
13
+ level: 'error' | 'warning';
14
+ message: string;
15
+ /** Relative to the project root, with forward slashes; none for a problem that isn't in a file. */
16
+ file?: string;
17
+ /** From 1; none for the file as a whole. */
18
+ line?: number;
19
+ column?: number;
20
+ }
21
+
22
+ export interface SarifTool {
23
+ name: string;
24
+ version: string;
25
+ informationUri: string;
26
+ }
27
+
28
+ /** Collects what a command found, then writes it as one SARIF run. */
29
+ export class SarifReport {
30
+ readonly results: SarifResult[] = [];
31
+ private readonly rules = new Map<string, string | undefined>();
32
+
33
+ /** Describes a rule in one sentence; the first description given for an id stands. */
34
+ rule(id: string, description?: string): void {
35
+ if (this.rules.get(id) === undefined) this.rules.set(id, description);
36
+ }
37
+
38
+ add(result: SarifResult): void {
39
+ if (!this.rules.has(result.rule)) this.rules.set(result.rule, undefined);
40
+ this.results.push(result);
41
+ }
42
+
43
+ toJSON(root: string, tool: SarifTool): object {
44
+ const ids = [...this.rules.keys()];
45
+ return {
46
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json',
47
+ version: '2.1.0',
48
+ runs: [
49
+ {
50
+ tool: {
51
+ driver: {
52
+ ...tool,
53
+ rules: ids.map((id) => {
54
+ const description = this.rules.get(id);
55
+ return description === undefined ? { id } : { id, shortDescription: { text: description } };
56
+ }),
57
+ },
58
+ },
59
+ originalUriBaseIds: { '%SRCROOT%': { uri: pathToFileURL(root).href.replace(/\/?$/, '/') } },
60
+ results: this.results.map((r) => ({
61
+ ruleId: r.rule,
62
+ ruleIndex: ids.indexOf(r.rule),
63
+ level: r.level,
64
+ message: { text: r.message },
65
+ locations: r.file === undefined ? [] : [location(r.file, r.line, r.column)],
66
+ })),
67
+ },
68
+ ],
69
+ };
70
+ }
71
+ }
72
+
73
+ function location(file: string, line: number | undefined, column: number | undefined): object {
74
+ return {
75
+ physicalLocation: {
76
+ artifactLocation: { uri: encodeURI(file), uriBaseId: '%SRCROOT%' },
77
+ ...(line === undefined ? {} : { region: { startLine: line, startColumn: column ?? 1 } }),
78
+ },
79
+ };
80
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * What the code says: text a person reads that is still written in a component, and which catalog
3
+ * keys the code names.
4
+ *
5
+ * Key references are found by reading string literals, not by tracing calls, so a key kept in an
6
+ * array or a map still counts, and `nav.${id}` or `'nav.' + id` keeps every key under `nav.`. That
7
+ * errs towards keeping a key, which is the safe direction both for `unused` and for pruning a bundle.
8
+ */
9
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
10
+ import { extname, join, relative } from 'node:path';
11
+
12
+ import { parseSync } from 'oxc-parser';
13
+
14
+ const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', 'out', 'coverage', '.git']);
15
+
16
+ export function sourceFiles(dirs: string[], extensions: string[], skip: string[] = []): string[] {
17
+ const files: string[] = [];
18
+ const visit = (dir: string) => {
19
+ let names: string[];
20
+ try {
21
+ names = readdirSync(dir);
22
+ } catch {
23
+ return;
24
+ }
25
+ for (const name of names) {
26
+ const path = join(dir, name);
27
+ if (skip.some((s) => path === s || path.startsWith(`${s}/`) || path.startsWith(`${s}\\`))) continue;
28
+ if (statSync(path).isDirectory()) {
29
+ if (!SKIP_DIRS.has(name) && !name.startsWith('.')) visit(path);
30
+ } else if (extensions.includes(extname(name)) && !name.endsWith('.d.ts')) {
31
+ files.push(path);
32
+ }
33
+ }
34
+ };
35
+ for (const dir of dirs) visit(dir);
36
+ return files.sort();
37
+ }
38
+
39
+ export interface KeyRefs {
40
+ exact: Set<string>;
41
+ /** `nav.` from `nav.${id}` or `'nav.' + id`: every key under it may be read. */
42
+ prefixes: Set<string>;
43
+ }
44
+
45
+ const LITERAL_KEY = /(['"`])([a-z0-9][\w-]*(?:\.[\w-]+)+)\1/g;
46
+ const KEY_PREFIX = /(['"`])([a-z0-9][\w-]*(?:\.[\w-]+)*\.)(?:\1|\$\{)/g;
47
+
48
+ export function keyRefs(code: string, into: KeyRefs = { exact: new Set(), prefixes: new Set() }): KeyRefs {
49
+ for (const m of code.matchAll(LITERAL_KEY)) into.exact.add(m[2]!);
50
+ for (const m of code.matchAll(KEY_PREFIX)) into.prefixes.add(m[2]!);
51
+ return into;
52
+ }
53
+
54
+ export function isReferenced(key: string, refs: KeyRefs): boolean {
55
+ if (refs.exact.has(key)) return true;
56
+ for (const prefix of refs.prefixes) if (key.startsWith(prefix)) return true;
57
+ return false;
58
+ }
59
+
60
+ export interface Hardcoded {
61
+ file: string;
62
+ line: number;
63
+ column: number;
64
+ text: string;
65
+ /** Where it sits: `text`, or the attribute it is the value of. */
66
+ where: string;
67
+ }
68
+
69
+ type Node = { type: string; start: number; end: number; [field: string]: unknown };
70
+
71
+ /**
72
+ * Text that reads as code rather than prose: one token holding a path, a call, an identifier with an
73
+ * underscore or a dotted name, as in `form.json()` or `src/index.ts`. Trailing punctuation doesn't
74
+ * count, so `Done.` is still prose.
75
+ */
76
+ export function looksLikeCode(text: string): boolean {
77
+ const token = text.trim().replace(/[.!?:,…]+$/, '');
78
+ return token !== '' && !/\s/.test(token) && /[/()_=<>{}[\].]|::/.test(token);
79
+ }
80
+
81
+ const NON_PROSE_ELEMENTS = new Set(['code', 'pre', 'kbd', 'samp', 'script', 'style', 'svg', 'path']);
82
+ const LETTER = /\p{L}/u;
83
+
84
+ /** Literal text in JSX that a person reads, where it should be a catalog entry. */
85
+ export function hardcodedText(file: string, code: string, attributes: string[]): Hardcoded[] {
86
+ const { program } = parseSync(file, code);
87
+ const found: Hardcoded[] = [];
88
+ const lines = code.split('\n');
89
+ const lineStarts: number[] = [];
90
+ let offset = 0;
91
+ for (const line of lines) {
92
+ lineStarts.push(offset);
93
+ offset += line.length + 1;
94
+ }
95
+ const position = (at: number) => {
96
+ let lo = 0;
97
+ let hi = lineStarts.length - 1;
98
+ while (lo < hi) {
99
+ const mid = (lo + hi + 1) >> 1;
100
+ if (lineStarts[mid]! <= at) lo = mid;
101
+ else hi = mid - 1;
102
+ }
103
+ return { line: lo + 1, column: at - lineStarts[lo]! + 1 };
104
+ };
105
+ const ignored = (line: number) =>
106
+ (lines[line - 1] ?? '').includes('prosebook-ignore') || (lines[line - 2] ?? '').includes('prosebook-ignore');
107
+ const report = (node: Node, text: string, where: string) => {
108
+ const trimmed = text.replace(/\s+/g, ' ').trim();
109
+ if (!LETTER.test(trimmed) || looksLikeCode(trimmed)) return;
110
+ const at = position(node.start + Math.max(0, code.slice(node.start, node.end).search(/\S/)));
111
+ if (ignored(at.line)) return;
112
+ found.push({ file, ...at, text: trimmed, where });
113
+ };
114
+
115
+ const visit = (node: Node, inProse: boolean) => {
116
+ let prose = inProse;
117
+ if (node.type === 'JSXElement') {
118
+ const opening = node.openingElement as Node & { name: Node & { name?: string } };
119
+ if (opening.name.type === 'JSXIdentifier' && NON_PROSE_ELEMENTS.has(opening.name.name ?? '')) prose = false;
120
+ }
121
+ if (node.type === 'JSXText' && prose) report(node, node.value as string, 'text');
122
+ if (node.type === 'JSXExpressionContainer' && prose) {
123
+ const expr = node.expression as Node;
124
+ if (expr.type === 'Literal' && typeof expr.value === 'string') report(expr, expr.value, 'text');
125
+ if (expr.type === 'TemplateLiteral' && (expr.expressions as Node[]).length === 0)
126
+ report(expr, String((expr.quasis as { value: { cooked: string } }[])[0]?.value.cooked), 'text');
127
+ }
128
+ if (node.type === 'JSXAttribute') {
129
+ const name = node.name as Node & { name?: string };
130
+ const value = node.value as Node | null;
131
+ const attr = name.type === 'JSXIdentifier' ? (name.name ?? '') : '';
132
+ if (attributes.includes(attr) && value !== null) {
133
+ if (value.type === 'Literal' && typeof value.value === 'string') report(value, value.value, attr);
134
+ if (value.type === 'JSXExpressionContainer') {
135
+ const expr = value.expression as Node;
136
+ if (expr.type === 'Literal' && typeof expr.value === 'string') report(expr, expr.value, attr);
137
+ }
138
+ }
139
+ return;
140
+ }
141
+ for (const value of Object.values(node)) {
142
+ if (Array.isArray(value)) {
143
+ for (const child of value) if (isNode(child)) visit(child, prose);
144
+ } else if (isNode(value)) {
145
+ visit(value, prose);
146
+ }
147
+ }
148
+ };
149
+ visit(program as unknown as Node, true);
150
+ return found;
151
+ }
152
+
153
+ function isNode(value: unknown): value is Node {
154
+ return typeof value === 'object' && value !== null && typeof (value as Node).type === 'string';
155
+ }
156
+
157
+ export interface ScanResult {
158
+ hardcoded: Hardcoded[];
159
+ refs: KeyRefs;
160
+ files: number;
161
+ }
162
+
163
+ export function scan(dirs: string[], extensions: string[], attributes: string[], root: string, skip: string[] = []): ScanResult {
164
+ const refs: KeyRefs = { exact: new Set(), prefixes: new Set() };
165
+ const hardcoded: Hardcoded[] = [];
166
+ const files = sourceFiles(dirs, extensions, skip);
167
+ for (const path of files) {
168
+ const code = readFileSync(path, 'utf8');
169
+ keyRefs(code, refs);
170
+ if (/\.[jt]sx$/.test(path)) hardcoded.push(...hardcodedText(relative(root, path).replaceAll('\\', '/'), code, attributes));
171
+ }
172
+ return { hardcoded, refs, files: files.length };
173
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The two syntaxes a catalog can be written in, behind one pair of calls: read a message into the
3
+ * element tree, and write a tree back out. Everything past reading works on the tree alone.
4
+ */
5
+ import { parseMessage, type MessageFormatElement } from './icu.ts';
6
+ import { toMf1 } from './mf1.ts';
7
+ import { parseMf2, toMf2 } from './mf2.ts';
8
+
9
+ export type MessageFormatVersion = 1 | 2;
10
+
11
+ export const SYNTAX_NAME: Record<MessageFormatVersion, string> = { 1: 'ICU MessageFormat', 2: 'MessageFormat 2' };
12
+
13
+ /**
14
+ * A message as the element tree. MF2 is read per locale: which variants a value can reach depends
15
+ * on the locale's plural rules (see `matchTree` in mf2.ts).
16
+ */
17
+ export function readMessage(version: MessageFormatVersion, text: string, locale: string): MessageFormatElement[] {
18
+ return version === 2 ? parseMf2(text, locale) : parseMessage(text);
19
+ }
20
+
21
+ /** A tree as text in the given syntax. Throws, saying what the syntax can't express, where it can't. */
22
+ export function writeMessage(version: MessageFormatVersion, ast: MessageFormatElement[], locale: string): string {
23
+ return version === 2 ? toMf2(ast, locale) : toMf1(ast);
24
+ }