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.
- package/LICENSE +21 -0
- package/README.md +595 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +508 -0
- package/dist/cli.js.map +1 -0
- package/dist/config-CF7HziDa.d.ts +498 -0
- package/dist/config-CF7HziDa.d.ts.map +1 -0
- package/dist/config-CxVMn19f.js +1146 -0
- package/dist/config-CxVMn19f.js.map +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +1 -0
- package/dist/copy-DXnzXsJa.d.ts +162 -0
- package/dist/copy-DXnzXsJa.d.ts.map +1 -0
- package/dist/helpers-BPuPGjQP.js +61 -0
- package/dist/helpers-BPuPGjQP.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +188 -0
- package/dist/index.js.map +1 -0
- package/dist/inspector.d.ts +37 -0
- package/dist/inspector.d.ts.map +1 -0
- package/dist/inspector.js +572 -0
- package/dist/inspector.js.map +1 -0
- package/dist/react/index.d.ts +37 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +86 -0
- package/dist/react/index.js.map +1 -0
- package/dist/sarif-PkPvsB07.js +1033 -0
- package/dist/sarif-PkPvsB07.js.map +1 -0
- package/dist/scan-B0tZ7y-D.js +2447 -0
- package/dist/scan-B0tZ7y-D.js.map +1 -0
- package/dist/tools.d.ts +351 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +4 -0
- package/dist/vite.d.ts +15 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +238 -0
- package/dist/vite.js.map +1 -0
- package/package.json +148 -15
- package/src/cli.ts +528 -0
- package/src/index.ts +31 -0
- package/src/inspector-dialog.ts +450 -0
- package/src/inspector.ts +228 -0
- package/src/react/index.tsx +112 -0
- package/src/runtime/copy.ts +221 -0
- package/src/runtime/helpers.ts +66 -0
- package/src/runtime/locale.ts +49 -0
- package/src/runtime/types.ts +106 -0
- package/src/tools/catalog.ts +449 -0
- package/src/tools/compile.ts +110 -0
- package/src/tools/config.ts +195 -0
- package/src/tools/diff.ts +84 -0
- package/src/tools/entry.ts +325 -0
- package/src/tools/extract.ts +694 -0
- package/src/tools/generate.ts +259 -0
- package/src/tools/icu.ts +182 -0
- package/src/tools/index.ts +78 -0
- package/src/tools/lint.ts +828 -0
- package/src/tools/lock.ts +95 -0
- package/src/tools/mf1.ts +246 -0
- package/src/tools/mf2.ts +752 -0
- package/src/tools/migrate.ts +138 -0
- package/src/tools/presets.ts +159 -0
- package/src/tools/pseudo.ts +40 -0
- package/src/tools/sarif.ts +80 -0
- package/src/tools/scan.ts +173 -0
- package/src/tools/syntax.ts +24 -0
- package/src/tools/types.ts +137 -0
- package/src/vite.ts +252 -0
- package/index.js +0 -1
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shapes the generated catalog and the runtime agree on.
|
|
3
|
+
*
|
|
4
|
+
* A catalog is compiled at build time: every ICU message becomes either a string (no placeholders)
|
|
5
|
+
* or a small function over its arguments and a set of locale-bound helpers. The runtime never parses
|
|
6
|
+
* ICU, so it carries no parser, needs no `eval`, and a message costs a property read or one call.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** What the generator knows about one message: the arguments it takes and the tags it wraps text in. */
|
|
10
|
+
export interface MessageShape {
|
|
11
|
+
args: object;
|
|
12
|
+
tags: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Every key of a catalog, mapped to its shape. The generated `Messages` interface is one of these. */
|
|
16
|
+
export type MessageShapes = { [key: string]: MessageShape };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Augmented by the generated module, so hooks and components that are not handed a catalog
|
|
20
|
+
* (`useCopy()`, `<Text>`) still know its keys:
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* declare module 'prosebook' {
|
|
24
|
+
* interface Register { messages: Messages; locale: Locale }
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* Add `tags: 'b' | 'link'` to say which tags the provider renders by default, so a call site
|
|
29
|
+
* needs to supply only the others.
|
|
30
|
+
*/
|
|
31
|
+
// oxlint-disable-next-line typescript/no-empty-interface -- filled in by declaration merging
|
|
32
|
+
export interface Register {}
|
|
33
|
+
|
|
34
|
+
export type RegisteredMessages = Register extends { messages: infer M extends MessageShapes } ? M : MessageShapes;
|
|
35
|
+
export type RegisteredLocale = Register extends { locale: infer L extends string } ? L : string;
|
|
36
|
+
/** Tags the provider renders unless a call site says otherwise. */
|
|
37
|
+
export type DefaultTags = Register extends { tags: infer T extends string } ? T : never;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The helpers a compiled message calls, bound to the locale its words are in. Names are one letter
|
|
41
|
+
* because they appear once per placeholder in every generated module.
|
|
42
|
+
*/
|
|
43
|
+
export interface Helpers {
|
|
44
|
+
readonly locale: string;
|
|
45
|
+
/** `{x, number, …}` and the `#` of a plural. */
|
|
46
|
+
n(value: number, options?: Intl.NumberFormatOptions): string;
|
|
47
|
+
/** `{x, date, …}` and `{x, time, …}`. */
|
|
48
|
+
d(value: Date | number, options?: Intl.DateTimeFormatOptions): string;
|
|
49
|
+
/** `{x, plural, …}` and `{x, selectordinal, …}`: picks an arm and renders it. */
|
|
50
|
+
p(value: number, offset: number, ordinal: boolean, arms: Arms): unknown;
|
|
51
|
+
/** `{x, select, …}`. */
|
|
52
|
+
s(value: unknown, arms: Arms): unknown;
|
|
53
|
+
/** `<tag>…</tag>`: the tag's function from the values, or the bare children when there is none. */
|
|
54
|
+
t(values: Values, tag: string, children: unknown): unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type Arms = { [arm: string]: () => unknown };
|
|
58
|
+
// `any`, not `unknown`: call sites are checked against the generated `Messages`, so a compiled
|
|
59
|
+
// message can hand its arguments to the helpers without a cast per placeholder.
|
|
60
|
+
// oxlint-disable-next-line typescript/no-explicit-any
|
|
61
|
+
export type Values = { readonly [name: string]: any };
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Told of every message read: its key, the text it came to and the locale it was found in, which is
|
|
65
|
+
* the fallback's when the current locale lacks it. The Vite plugin's dev inspector sets one on
|
|
66
|
+
* `globalThis` to map text on screen back to keys; nothing sets it in a build.
|
|
67
|
+
*/
|
|
68
|
+
export type ReadHook = (key: string, text: string, locale: string) => void;
|
|
69
|
+
|
|
70
|
+
/** Hands a copy a locale's messages, regenerated after a catalog edit, in place of the ones it loaded. */
|
|
71
|
+
export type HotHook = (locale: string, messages: CompiledMessages) => void;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* What the Vite plugin sets on `globalThis` in dev. `__PROSEBOOK_HOT__` is there before the app runs,
|
|
75
|
+
* so each copy adds itself, and the generated modules call every copy when they are updated.
|
|
76
|
+
*/
|
|
77
|
+
export type DevHooks = { __PROSEBOOK_READ__?: ReadHook; __PROSEBOOK_HOT__?: Set<HotHook> };
|
|
78
|
+
|
|
79
|
+
/** One compiled message. */
|
|
80
|
+
export type Compiled = string | ((values: Values, h: Helpers) => unknown);
|
|
81
|
+
|
|
82
|
+
/** One locale's compiled messages: what a generated `<locale>.ts` module exports by default. */
|
|
83
|
+
export type CompiledMessages = { readonly [key: string]: Compiled };
|
|
84
|
+
|
|
85
|
+
/** What a tag's function receives: the pieces it wraps, adjacent text already joined. */
|
|
86
|
+
export type Chunks<N> = (string | N)[];
|
|
87
|
+
export type TagFn<N> = (chunks: Chunks<N>) => N;
|
|
88
|
+
/** A tag renderer for any kind of node, as configured once on a `Copy`. */
|
|
89
|
+
export type AnyTagFn = (chunks: never) => unknown;
|
|
90
|
+
|
|
91
|
+
type NoArgs<T> = {} extends T ? true : false;
|
|
92
|
+
|
|
93
|
+
/** The arguments of a plain read: optional when the message takes none. */
|
|
94
|
+
export type ArgsParam<S extends MessageShape> = NoArgs<S['args']> extends true ? [args?: S['args']] : [args: S['args']];
|
|
95
|
+
|
|
96
|
+
type TagValues<S extends MessageShape, N> = { [T in Exclude<S['tags'], DefaultTags>]: TagFn<N> } & {
|
|
97
|
+
[T in Extract<S['tags'], DefaultTags>]?: TagFn<N>;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** A rich read takes the message's arguments and a function per tag it wraps, beside the defaults. */
|
|
101
|
+
export type RichValues<S extends MessageShape, N> = S['args'] & TagValues<S, N>;
|
|
102
|
+
export type RichParam<S extends MessageShape, N> =
|
|
103
|
+
NoArgs<RichValues<S, N>> extends true ? [values?: RichValues<S, N>] : [values: RichValues<S, N>];
|
|
104
|
+
|
|
105
|
+
/** The keys whose message wraps part of itself in a tag. */
|
|
106
|
+
export type RichKey<M extends MessageShapes> = { [K in keyof M]: [M[K]['tags']] extends [never] ? never : K }[keyof M] & string;
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the catalog and its translations into one checked model.
|
|
3
|
+
*
|
|
4
|
+
* Every message is parsed as ICU here rather than at runtime, which is what lets the generator hand
|
|
5
|
+
* TypeScript exact argument types, compile messages to plain functions, and fail the build where a
|
|
6
|
+
* translation's placeholders drifted from the source instead of dropping a value off the screen.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
9
|
+
import { join, relative, resolve } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { isMap, isScalar, LineCounter, parseDocument, parse as parseYaml, Scalar } from 'yaml';
|
|
12
|
+
|
|
13
|
+
import type { ResolvedConfig } from './config.ts';
|
|
14
|
+
import { argsOf, formatProblems, formatsOf, staticLiteral, tagsOf, type MessageFormatElement } from './icu.ts';
|
|
15
|
+
import { readMessage } from './syntax.ts';
|
|
16
|
+
|
|
17
|
+
export interface Translation {
|
|
18
|
+
text: string;
|
|
19
|
+
ast: MessageFormatElement[];
|
|
20
|
+
file: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Message {
|
|
24
|
+
key: string;
|
|
25
|
+
/** The first segment of the key, which names its file. */
|
|
26
|
+
area: string;
|
|
27
|
+
file: string;
|
|
28
|
+
kind?: string;
|
|
29
|
+
surface?: string;
|
|
30
|
+
/** One sentence telling a translator where the string appears. */
|
|
31
|
+
context?: string;
|
|
32
|
+
/** What each placeholder holds, keyed by argument name. */
|
|
33
|
+
argDocs: Map<string, string>;
|
|
34
|
+
/** Completes something beside it rather than standing as a sentence, so sentence rules do not apply. */
|
|
35
|
+
fragment: boolean;
|
|
36
|
+
/** Lint rules the entry is exempt from, each with the reason. */
|
|
37
|
+
allow: Map<string, string>;
|
|
38
|
+
/** A change someone asked for, left for whoever edits the entry next, and deleted once it is made. */
|
|
39
|
+
comment?: string;
|
|
40
|
+
/** Asked to go: the entry, its translations and the code that reads it. */
|
|
41
|
+
remove: boolean;
|
|
42
|
+
/** The source message, in ICU MessageFormat. */
|
|
43
|
+
text: string;
|
|
44
|
+
ast: MessageFormatElement[];
|
|
45
|
+
/** Argument name to TypeScript type. */
|
|
46
|
+
args: Map<string, string>;
|
|
47
|
+
/** How each formatted argument is styled, for the doc comment at the call site. */
|
|
48
|
+
argFormats: Map<string, string>;
|
|
49
|
+
tags: string[];
|
|
50
|
+
/** Translations by locale. */
|
|
51
|
+
translations: Map<string, Translation>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface Problem {
|
|
55
|
+
severity: 'error' | 'warn';
|
|
56
|
+
/** Relative to the project root; none for a problem that isn't in a file. */
|
|
57
|
+
file?: string;
|
|
58
|
+
/** The entry it is about, in `file`; none for the file as a whole. */
|
|
59
|
+
key?: string;
|
|
60
|
+
message: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `file key`, as the CLI names where a problem is. */
|
|
64
|
+
export function whereOf(problem: Problem): string {
|
|
65
|
+
return [problem.file, problem.key].filter((s) => s !== undefined).join(' ');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface Catalog {
|
|
69
|
+
messages: Message[];
|
|
70
|
+
/** The translated locales found under `locales`, without the source. */
|
|
71
|
+
locales: string[];
|
|
72
|
+
problems: Problem[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class CatalogError extends Error {
|
|
76
|
+
readonly problems: Problem[];
|
|
77
|
+
|
|
78
|
+
constructor(problems: Problem[]) {
|
|
79
|
+
super(`the catalog has ${problems.length} problem(s):\n ${problems.map((p) => `${whereOf(p)}: ${p.message}`).join('\n ')}`);
|
|
80
|
+
this.problems = problems;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A key is dotted lowercase, two segments at least, and its first segment names the file it lives
|
|
86
|
+
* in. Keys are read at every call site, so they say what the string is for: `cart.checkout.title`.
|
|
87
|
+
*/
|
|
88
|
+
export const KEY_PATTERN = /^[a-z0-9]+(?:[-_][a-z0-9]+)*(?:\.[a-z0-9]+(?:[-_][a-z0-9]+)*)+$/;
|
|
89
|
+
|
|
90
|
+
const FIELDS = new Set(['kind', 'surface', 'context', 'args', 'fragment', 'allow', 'comment', 'remove']);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Loads and checks the catalog. Throws a `CatalogError` when anything is an error; warnings ride along.
|
|
94
|
+
* `unsaved` holds file contents by absolute path to read in place of what is on disk, so an edit can be
|
|
95
|
+
* checked before it is written; a translation file in it that isn't on disk yet is read too.
|
|
96
|
+
*/
|
|
97
|
+
export function loadCatalog(config: ResolvedConfig, unsaved?: ReadonlyMap<string, string>): Catalog {
|
|
98
|
+
const read = (path: string) => unsaved?.get(resolve(path)) ?? readFileSync(path, 'utf8');
|
|
99
|
+
const problems: Problem[] = [];
|
|
100
|
+
const error = (message: string, file?: string, key?: string) => problems.push({ severity: 'error', file, key, message });
|
|
101
|
+
const byKey = new Map<string, Message>();
|
|
102
|
+
const rel = (path: string) => relative(config.root, path).replaceAll('\\', '/');
|
|
103
|
+
|
|
104
|
+
if (!existsSync(config.catalog)) {
|
|
105
|
+
error('no catalog folder here: run `prosebook init`, or set `catalog` in the config', rel(config.catalog));
|
|
106
|
+
throw new CatalogError(problems);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const file of yamlFiles(config.catalog)) {
|
|
110
|
+
const area = file.slice(0, -'.yaml'.length);
|
|
111
|
+
const path = join(config.catalog, file);
|
|
112
|
+
const at = rel(path);
|
|
113
|
+
for (const [key, value] of Object.entries(readEntries(read(path), (m) => error(m, at)))) {
|
|
114
|
+
if (!KEY_PATTERN.test(key)) {
|
|
115
|
+
error('a key is dotted lowercase with two segments at least: `area.thing`', at, key);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (key.slice(0, key.indexOf('.')) !== area) {
|
|
119
|
+
error(`a key in ${file} starts with "${area}."`, at, key);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (byKey.has(key)) {
|
|
123
|
+
error('duplicate key', at, key);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
const message = readEntry(key, area, at, value, config, (m) => error(m, at, key));
|
|
127
|
+
if (!message) continue;
|
|
128
|
+
byKey.set(key, message);
|
|
129
|
+
// Asked of whoever edits next, often an agent, which finds them among the warnings of every run.
|
|
130
|
+
const warn = (m: string) => problems.push({ severity: 'warn', file: at, key, message: m });
|
|
131
|
+
if (message.remove) warn('marked for removal: delete the entry, its translations and the code that reads it');
|
|
132
|
+
if (message.comment !== undefined) warn(`a comment to act on, then delete: ${message.comment}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const created = [...(unsaved?.keys() ?? [])].filter((path) => !existsSync(path));
|
|
137
|
+
const locales = readLocales(config, byKey, problems, rel, read, created);
|
|
138
|
+
if (problems.some((p) => p.severity === 'error')) throw new CatalogError(problems);
|
|
139
|
+
return { messages: [...byKey.values()].sort((a, b) => (a.key < b.key ? -1 : 1)), locales, problems };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function readEntry(
|
|
143
|
+
key: string,
|
|
144
|
+
area: string,
|
|
145
|
+
file: string,
|
|
146
|
+
value: unknown,
|
|
147
|
+
config: ResolvedConfig,
|
|
148
|
+
error: (message: string) => void,
|
|
149
|
+
): Message | undefined {
|
|
150
|
+
// `key: message` is the shorthand for an entry with nothing to say about itself.
|
|
151
|
+
const entry = (typeof value === 'string' ? { [config.source]: value } : value) as Record<string, unknown> | null;
|
|
152
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
153
|
+
error(`an entry is a message, or a mapping holding \`${config.source}\` and what the string is`);
|
|
154
|
+
return undefined;
|
|
155
|
+
}
|
|
156
|
+
let ok = true;
|
|
157
|
+
const fail = (message: string) => {
|
|
158
|
+
error(message);
|
|
159
|
+
ok = false;
|
|
160
|
+
};
|
|
161
|
+
for (const field of Object.keys(entry))
|
|
162
|
+
if (!FIELDS.has(field) && field !== config.source) fail(`"${field}" is not a field: ${[...FIELDS, config.source].join(', ')}`);
|
|
163
|
+
|
|
164
|
+
const text = entry[config.source];
|
|
165
|
+
if (typeof text !== 'string' || text === '') {
|
|
166
|
+
fail(`${config.source} is the message, and it is missing`);
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
for (const field of config.require) if (entry[field] === undefined) fail(`${field} is required (\`require\` in the config)`);
|
|
170
|
+
|
|
171
|
+
const kind = optionalString(entry, 'kind', fail);
|
|
172
|
+
if (kind !== undefined && !(kind in config.kinds)) fail(`kind is one of ${Object.keys(config.kinds).join(', ')}`);
|
|
173
|
+
const surface = optionalString(entry, 'surface', fail);
|
|
174
|
+
if (surface !== undefined && !(surface in config.surfaces)) fail(`surface is one of ${Object.keys(config.surfaces).join(', ')}`);
|
|
175
|
+
const context = optionalString(entry, 'context', fail);
|
|
176
|
+
if (entry.fragment !== undefined && typeof entry.fragment !== 'boolean') fail('fragment is true or false');
|
|
177
|
+
const comment = optionalString(entry, 'comment', fail);
|
|
178
|
+
if (entry.remove !== undefined && typeof entry.remove !== 'boolean') fail('remove is true or false');
|
|
179
|
+
|
|
180
|
+
let ast: MessageFormatElement[];
|
|
181
|
+
try {
|
|
182
|
+
ast = readMessage(config.messageFormat, text, config.source);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
fail(syntaxError(config, text, e));
|
|
185
|
+
return undefined;
|
|
186
|
+
}
|
|
187
|
+
for (const problem of formatProblems(ast)) fail(problem);
|
|
188
|
+
const args = argsOf(ast, (name) => fail(`"${name}" is used as two different types`));
|
|
189
|
+
const argDocs = stringMap(entry, 'args', fail);
|
|
190
|
+
// An undocumented placeholder is fine unless context is required; a doc for an argument that no
|
|
191
|
+
// longer exists is always stale.
|
|
192
|
+
if (config.require.includes('context'))
|
|
193
|
+
for (const name of args.keys()) if (!argDocs.has(name)) fail(`args.${name} is missing: say what the placeholder holds`);
|
|
194
|
+
|
|
195
|
+
for (const name of argDocs.keys()) if (!args.has(name)) fail(`args.${name} is not an argument of this message`);
|
|
196
|
+
|
|
197
|
+
if (!ok) return undefined;
|
|
198
|
+
return {
|
|
199
|
+
key,
|
|
200
|
+
area,
|
|
201
|
+
file,
|
|
202
|
+
kind,
|
|
203
|
+
surface,
|
|
204
|
+
context,
|
|
205
|
+
argDocs,
|
|
206
|
+
fragment: entry.fragment === true,
|
|
207
|
+
allow: stringMap(entry, 'allow', fail),
|
|
208
|
+
comment,
|
|
209
|
+
remove: entry.remove === true,
|
|
210
|
+
text,
|
|
211
|
+
ast,
|
|
212
|
+
args,
|
|
213
|
+
argFormats: formatsOf(ast),
|
|
214
|
+
tags: tagsOf(ast),
|
|
215
|
+
translations: new Map(),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function readLocales(
|
|
220
|
+
config: ResolvedConfig,
|
|
221
|
+
byKey: Map<string, Message>,
|
|
222
|
+
problems: Problem[],
|
|
223
|
+
rel: (p: string) => string,
|
|
224
|
+
read: (path: string) => string,
|
|
225
|
+
created: string[],
|
|
226
|
+
): string[] {
|
|
227
|
+
if (!existsSync(config.locales)) return [];
|
|
228
|
+
const files = new Map<string, string[]>();
|
|
229
|
+
for (const name of readdirSync(config.locales).sort()) {
|
|
230
|
+
const path = join(config.locales, name);
|
|
231
|
+
const isDir = statSync(path).isDirectory();
|
|
232
|
+
if (!isDir && !name.endsWith('.yaml')) continue;
|
|
233
|
+
const locale = isDir ? name : name.slice(0, -'.yaml'.length);
|
|
234
|
+
const found = isDir ? yamlFiles(path).map((f) => join(path, f)) : [path];
|
|
235
|
+
files.set(locale, [...(files.get(locale) ?? []), ...found]);
|
|
236
|
+
}
|
|
237
|
+
// A translation file an edit would create, read as if it were there already.
|
|
238
|
+
for (const path of created) {
|
|
239
|
+
const inside = relative(config.locales, path).replaceAll('\\', '/');
|
|
240
|
+
if (inside.startsWith('..') || !inside.endsWith('.yaml')) continue;
|
|
241
|
+
const [first = '', second] = inside.split('/');
|
|
242
|
+
const locale = second === undefined ? first.slice(0, -'.yaml'.length) : first;
|
|
243
|
+
files.set(locale, [...(files.get(locale) ?? []), path]);
|
|
244
|
+
}
|
|
245
|
+
files.delete(config.source);
|
|
246
|
+
|
|
247
|
+
for (const [locale, paths] of files) {
|
|
248
|
+
for (const path of paths) {
|
|
249
|
+
const file = rel(path);
|
|
250
|
+
const error = (message: string, key?: string) => problems.push({ severity: 'error', file, key, message });
|
|
251
|
+
for (const [key, value] of Object.entries(readEntries(read(path), error))) {
|
|
252
|
+
const message = byKey.get(key);
|
|
253
|
+
if (message === undefined) {
|
|
254
|
+
problems.push({ severity: 'warn', file, key, message: 'no such key in the catalog, so this translation is unused' });
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
if (typeof value !== 'string' || value === '') {
|
|
258
|
+
error('a translation is the message, as a string', key);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
let ast: MessageFormatElement[];
|
|
262
|
+
try {
|
|
263
|
+
ast = readMessage(config.messageFormat, value, locale);
|
|
264
|
+
} catch (e) {
|
|
265
|
+
error(syntaxError(config, value, e), key);
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
const drift = driftFrom(message, ast);
|
|
269
|
+
if (drift !== undefined) {
|
|
270
|
+
error(drift, key);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
for (const problem of formatProblems(ast)) error(problem, key);
|
|
274
|
+
message.translations.set(locale, { text: value, ast, file });
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return [...files.keys()];
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** How a translation's placeholders or tags differ from the source, or nothing. */
|
|
282
|
+
function driftFrom(message: Message, ast: MessageFormatElement[]): string | undefined {
|
|
283
|
+
const args = argsOf(ast);
|
|
284
|
+
const missing = [...message.args.keys()].filter((a) => !args.has(a));
|
|
285
|
+
const extra = [...args.keys()].filter((a) => !message.args.has(a));
|
|
286
|
+
const retyped = [...args]
|
|
287
|
+
.filter(([a, type]) => message.args.has(a) && message.args.get(a) !== type && !type.includes('string &'))
|
|
288
|
+
.map(([a]) => a);
|
|
289
|
+
const tags = tagsOf(ast);
|
|
290
|
+
const parts = [
|
|
291
|
+
missing.length > 0 ? `missing {${missing.join('}, {')}}` : '',
|
|
292
|
+
extra.length > 0 ? `unknown {${extra.join('}, {')}}` : '',
|
|
293
|
+
retyped.length > 0 ? `{${retyped.join('}, {')}} used as another type` : '',
|
|
294
|
+
tags.join() === message.tags.join()
|
|
295
|
+
? ''
|
|
296
|
+
: `tags are <${tags.join('>, <') || 'none'}>, the source has <${message.tags.join('>, <') || 'none'}>`,
|
|
297
|
+
].filter(Boolean);
|
|
298
|
+
return parts.length > 0 ? `differs from the source: ${parts.join('; ')}` : undefined;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function syntaxError(config: ResolvedConfig, text: string, e: unknown): string {
|
|
302
|
+
// MF2's reader words its own problems; see mf2.ts.
|
|
303
|
+
if (config.messageFormat === 2) return (e as Error).message;
|
|
304
|
+
return icuError(text, e);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function icuError(text: string, e: unknown): string {
|
|
308
|
+
const hyphenated = /select\s*,/.test(text) ? /[,{]\s*([a-z0-9]+(?:-[a-z0-9]+)+)\s*\{/.exec(text) : null;
|
|
309
|
+
if (hyphenated) return `names a select arm "${hyphenated[1]}": an arm's name takes no hyphen, so write it with an underscore`;
|
|
310
|
+
const reason = (e as Error).message;
|
|
311
|
+
if (/TAG/.test(reason)) return `is not valid ICU (${reason}): a literal < is written '<'`;
|
|
312
|
+
return `is not valid ICU MessageFormat: ${reason}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function yamlFiles(dir: string): string[] {
|
|
316
|
+
return readdirSync(dir)
|
|
317
|
+
.filter((f) => f.endsWith('.yaml'))
|
|
318
|
+
.sort();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The file's entries. YAML 1.1 and 1.2 disagree about bare scalars: 1.1 reads `n`, `on` and `yes` as
|
|
323
|
+
* booleans and `12:30` as a number. Translation tools are often 1.1, so a file that reads differently
|
|
324
|
+
* under each fails here, naming the value to quote, rather than corrupting quietly in the next tool.
|
|
325
|
+
*/
|
|
326
|
+
function readEntries(text: string, error: (message: string) => void): Record<string, unknown> {
|
|
327
|
+
let raw: unknown;
|
|
328
|
+
try {
|
|
329
|
+
raw = parseYaml(text);
|
|
330
|
+
} catch (e) {
|
|
331
|
+
error(`is not valid YAML: ${(e as Error).message}`);
|
|
332
|
+
return {};
|
|
333
|
+
}
|
|
334
|
+
if (raw === null || raw === undefined) return {};
|
|
335
|
+
if (typeof raw !== 'object' || Array.isArray(raw)) {
|
|
336
|
+
error('a catalog file is a mapping of key to entry');
|
|
337
|
+
return {};
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
const found = divergence(raw, parseYaml(text, { version: '1.1' }), '');
|
|
341
|
+
if (found !== undefined) error(found);
|
|
342
|
+
} catch (e) {
|
|
343
|
+
error(`does not parse as YAML 1.1, which another tool may well be: ${(e as Error).message}`);
|
|
344
|
+
}
|
|
345
|
+
return raw as Record<string, unknown>;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function divergence(a: unknown, b: unknown, path: string): string | undefined {
|
|
349
|
+
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') {
|
|
350
|
+
if (Object.is(a, b)) return undefined;
|
|
351
|
+
return `${path} reads as ${JSON.stringify(a)} under YAML 1.2 and ${JSON.stringify(b)} under YAML 1.1: quote it`;
|
|
352
|
+
}
|
|
353
|
+
const left = a as Record<string, unknown>;
|
|
354
|
+
const right = b as Record<string, unknown>;
|
|
355
|
+
for (const key of new Set([...Object.keys(left), ...Object.keys(right)])) {
|
|
356
|
+
const here = path === '' ? key : `${path}.${key}`;
|
|
357
|
+
if (key in left !== key in right) return `${here} is a key under YAML ${key in left ? '1.2' : '1.1'} only: quote it`;
|
|
358
|
+
const found = divergence(left[key], right[key], here);
|
|
359
|
+
if (found !== undefined) return found;
|
|
360
|
+
}
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function optionalString(entry: Record<string, unknown>, field: string, fail: (m: string) => void): string | undefined {
|
|
365
|
+
const value = entry[field];
|
|
366
|
+
if (value === undefined) return undefined;
|
|
367
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
368
|
+
fail(`${field} is a non-empty string`);
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
return value.trim();
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function stringMap(entry: Record<string, unknown>, field: string, fail: (m: string) => void): Map<string, string> {
|
|
375
|
+
const out = new Map<string, string>();
|
|
376
|
+
const value = entry[field];
|
|
377
|
+
if (value === undefined) return out;
|
|
378
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
379
|
+
fail(`${field} is a mapping of name to sentence`);
|
|
380
|
+
return out;
|
|
381
|
+
}
|
|
382
|
+
for (const [name, said] of Object.entries(value)) {
|
|
383
|
+
if (typeof said !== 'string' || said.trim() === '') fail(`${field}.${name} is a sentence`);
|
|
384
|
+
else out.set(name, said.trim());
|
|
385
|
+
}
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export interface EntryLocation {
|
|
390
|
+
/** Absolute. */
|
|
391
|
+
file: string;
|
|
392
|
+
/** From 1. */
|
|
393
|
+
line: number;
|
|
394
|
+
/** From 1. */
|
|
395
|
+
column: number;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Where a message's entry is written: its translation in `locale` when there is one, else the catalog.
|
|
400
|
+
* Read from the file as it is now, so an entry moved since the catalog was loaded is still found.
|
|
401
|
+
*/
|
|
402
|
+
export function entryLocation(config: ResolvedConfig, message: Message, locale?: string): EntryLocation {
|
|
403
|
+
const file = (locale === undefined ? undefined : message.translations.get(locale)?.file) ?? message.file;
|
|
404
|
+
return { file: join(config.root, file), ...entryLocator(config.root)(file, message.key) };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Finds a key's line and column in a catalog or translation file, given relative to `root`. Each file
|
|
409
|
+
* is read once, so a report that places many entries parses each file once. A key it can't find, or no
|
|
410
|
+
* key, is the top of the file.
|
|
411
|
+
*
|
|
412
|
+
* Given an `offset` into the message (under `field`, in an entry that is a mapping), it is that
|
|
413
|
+
* character, when the value is written in the file as it reads up to there: plain, or quoted on one
|
|
414
|
+
* line with nothing escaped before it. Otherwise it is the key.
|
|
415
|
+
*/
|
|
416
|
+
export function entryLocator(root: string): (file: string, key?: string, offset?: number, field?: string) => Omit<EntryLocation, 'file'> {
|
|
417
|
+
const read = new Map<string, { doc: ReturnType<typeof parseDocument>; lines: LineCounter; source: string }>();
|
|
418
|
+
return (file, key, offset, field) => {
|
|
419
|
+
let parsed = read.get(file);
|
|
420
|
+
if (parsed === undefined) {
|
|
421
|
+
const path = join(root, file);
|
|
422
|
+
const lines = new LineCounter();
|
|
423
|
+
const source = existsSync(path) ? readFileSync(path, 'utf8') : '';
|
|
424
|
+
parsed = { doc: parseDocument(source, { lineCounter: lines }), lines, source };
|
|
425
|
+
read.set(file, parsed);
|
|
426
|
+
}
|
|
427
|
+
const { doc, lines, source } = parsed;
|
|
428
|
+
const pair =
|
|
429
|
+
key !== undefined && isMap(doc.contents) ? doc.contents.items.find((p) => isScalar(p.key) && p.key.value === key) : undefined;
|
|
430
|
+
const value = isMap(pair?.value) && field !== undefined ? pair.value.get(field, true) : pair?.value;
|
|
431
|
+
if (offset !== undefined && isScalar(value) && typeof value.value === 'string' && value.range) {
|
|
432
|
+
const quoted = value.type === Scalar.QUOTE_DOUBLE || value.type === Scalar.QUOTE_SINGLE;
|
|
433
|
+
const content = value.range[0] + (quoted ? 1 : 0);
|
|
434
|
+
if (source.slice(content, content + offset) === value.value.slice(0, offset)) {
|
|
435
|
+
const { line, col } = lines.linePos(content + offset);
|
|
436
|
+
return { line, column: col };
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const start = isScalar(pair?.key) ? pair.key.range?.[0] : undefined;
|
|
440
|
+
if (start === undefined) return { line: 1, column: 1 };
|
|
441
|
+
const { line, col } = lines.linePos(start);
|
|
442
|
+
return { line, column: col };
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** The source message as a reader sees it with no placeholders filled: for docs and reports. */
|
|
447
|
+
export function previewOf(message: Message): string {
|
|
448
|
+
return staticLiteral(message.ast) ?? message.text;
|
|
449
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compiles ICU ASTs to JavaScript.
|
|
3
|
+
*
|
|
4
|
+
* A message with no placeholders becomes its string. Any other becomes `(a, h) => …`, where `a` holds
|
|
5
|
+
* the arguments and `h` the locale's helpers (see `runtime/helpers.ts`): a plural is a call picking an
|
|
6
|
+
* arm, a number is a call to a cached `Intl.NumberFormat`. Formatting options are resolved here and
|
|
7
|
+
* hoisted to module constants, so a skeleton costs nothing at runtime and its formatter is built once.
|
|
8
|
+
*/
|
|
9
|
+
import { isDateElement, isNumberElement, isTimeElement } from '@formatjs/icu-messageformat-parser';
|
|
10
|
+
|
|
11
|
+
import { dateOptions, numberOptions, staticLiteral, TYPE, type MessageFormatElement } from './icu.ts';
|
|
12
|
+
|
|
13
|
+
export type Entry = readonly [key: string, ast: MessageFormatElement[]];
|
|
14
|
+
|
|
15
|
+
/** Module-level constants for formatting options, one per distinct object. */
|
|
16
|
+
class Hoisted {
|
|
17
|
+
private readonly byJson = new Map<string, string>();
|
|
18
|
+
|
|
19
|
+
/** `, oN` naming the hoisted options, or nothing when there are none. */
|
|
20
|
+
arg(options: object | undefined): string {
|
|
21
|
+
// `{}` (a bare `:number`) formats as no options do, so it needs no constant.
|
|
22
|
+
if (options === undefined || Object.keys(options).length === 0) return '';
|
|
23
|
+
const json = JSON.stringify(options);
|
|
24
|
+
let name = this.byJson.get(json);
|
|
25
|
+
if (name === undefined) this.byJson.set(json, (name = `o${this.byJson.size}`));
|
|
26
|
+
return `, ${name}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
declarations(): string[] {
|
|
30
|
+
return [...this.byJson].map(([json, name]) => `const ${name} = ${json};`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function access(name: string): string {
|
|
35
|
+
return /^[A-Za-z_$][\w$]*$/.test(name) ? `a.${name}` : `a[${JSON.stringify(name)}]`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function nodes(elements: MessageFormatElement[], hoisted: Hoisted, pound: string | null): string {
|
|
39
|
+
if (elements.length === 0) return '""';
|
|
40
|
+
const parts = elements.map((el) => node(el, hoisted, pound));
|
|
41
|
+
return parts.length === 1 ? parts[0]! : `[${parts.join(', ')}]`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function arms(options: Record<string, { value: MessageFormatElement[] }>, hoisted: Hoisted, pound: string | null): string {
|
|
45
|
+
const body = Object.entries(options).map(([arm, option]) => `${JSON.stringify(arm)}: () => ${nodes(option.value, hoisted, pound)}`);
|
|
46
|
+
return `{ ${body.join(', ')} }`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function node(el: MessageFormatElement, hoisted: Hoisted, pound: string | null): string {
|
|
50
|
+
if (isNumberElement(el)) return `h.n(${access(el.value)}${hoisted.arg(numberOptions(el))})`;
|
|
51
|
+
if (isDateElement(el) || isTimeElement(el)) return `h.d(${access(el.value)}${hoisted.arg(dateOptions(el))})`;
|
|
52
|
+
switch (el.type) {
|
|
53
|
+
case TYPE.literal:
|
|
54
|
+
return JSON.stringify(el.value);
|
|
55
|
+
case TYPE.argument:
|
|
56
|
+
return access(el.value);
|
|
57
|
+
case TYPE.pound:
|
|
58
|
+
return pound ?? '"#"';
|
|
59
|
+
case TYPE.select:
|
|
60
|
+
return `h.s(${access(el.value)}, ${arms(el.options, hoisted, pound)})`;
|
|
61
|
+
case TYPE.plural: {
|
|
62
|
+
// `#` is the plural's own value less its offset, formatted as a number.
|
|
63
|
+
const value = access(el.value);
|
|
64
|
+
const hash = `h.n(${value}${el.offset !== 0 ? ` - ${el.offset}` : ''})`;
|
|
65
|
+
return `h.p(${value}, ${el.offset}, ${el.pluralType === 'ordinal'}, ${arms(el.options, hoisted, hash)})`;
|
|
66
|
+
}
|
|
67
|
+
case TYPE.tag:
|
|
68
|
+
return `h.t(a, ${JSON.stringify(el.value)}, ${nodes(el.children, hoisted, pound)})`;
|
|
69
|
+
default:
|
|
70
|
+
throw new Error(`prosebook: no compiler for ICU element type ${(el as MessageFormatElement).type}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function message(ast: MessageFormatElement[], hoisted: Hoisted): string {
|
|
75
|
+
const literal = staticLiteral(ast);
|
|
76
|
+
return literal === undefined ? `(a, h) => ${nodes(ast, hoisted, null)}` : JSON.stringify(literal);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface CompiledLocale {
|
|
80
|
+
/** `const oN = {…};` lines the messages refer to. */
|
|
81
|
+
declarations: string[];
|
|
82
|
+
/** `"key": message,` lines. */
|
|
83
|
+
rows: string[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** One locale's messages as code, keeping only `keep` when given. */
|
|
87
|
+
export function compileLocale(entries: Iterable<Entry>, keep?: (key: string) => boolean): CompiledLocale {
|
|
88
|
+
const hoisted = new Hoisted();
|
|
89
|
+
const rows: string[] = [];
|
|
90
|
+
for (const [key, ast] of entries) {
|
|
91
|
+
if (keep && !keep(key)) continue;
|
|
92
|
+
rows.push(` ${JSON.stringify(key)}: ${message(ast, hoisted)},`);
|
|
93
|
+
}
|
|
94
|
+
return { declarations: hoisted.declarations(), rows };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The locale as an ES module whose default export is its messages. */
|
|
98
|
+
export function localeModule(compiled: CompiledLocale, banner: string, ts: boolean): string {
|
|
99
|
+
const head = ts ? `${banner}\nimport type { CompiledMessages } from 'prosebook';\n` : `${banner}\n`;
|
|
100
|
+
// `as const` keeps `style: "currency"` a literal, which is what `Intl`'s option types ask for.
|
|
101
|
+
const declarations = ts ? compiled.declarations.map((d) => d.replace(/;$/, ' as const;')) : compiled.declarations;
|
|
102
|
+
const consts = declarations.length > 0 ? `\n${declarations.join('\n')}\n` : '';
|
|
103
|
+
const close = ts ? '} satisfies CompiledMessages;' : '};';
|
|
104
|
+
return `${head}${consts}\nexport default {\n${compiled.rows.join('\n')}\n${close}\n`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The locale as one expression, for a bundler to inline in place of the module. */
|
|
108
|
+
export function localeExpression(compiled: CompiledLocale): string {
|
|
109
|
+
return `(() => {\n${compiled.declarations.join('\n')}\nreturn {\n${compiled.rows.join('\n')}\n};\n})()`;
|
|
110
|
+
}
|