prosebook 0.1.0 → 0.3.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/README.md +311 -57
- package/dist/bun.d.ts +34 -0
- package/dist/bun.d.ts.map +1 -0
- package/dist/bun.js +98 -0
- package/dist/bun.js.map +1 -0
- package/dist/bundle-DboItAre.js +111 -0
- package/dist/bundle-DboItAre.js.map +1 -0
- package/dist/bundle-KD-DiiFa.d.ts +6 -0
- package/dist/bundle-KD-DiiFa.d.ts.map +1 -0
- package/dist/cli.js +51 -56
- package/dist/cli.js.map +1 -1
- package/dist/compile-xF3F4oIp.d.ts +16 -0
- package/dist/compile-xF3F4oIp.d.ts.map +1 -0
- package/dist/{config-CxVMn19f.js → config-BPSmoYel.js} +82 -196
- package/dist/config-BPSmoYel.js.map +1 -0
- package/dist/{config-CF7HziDa.d.ts → config-DdMkKt_N.d.ts} +85 -7
- package/dist/config-DdMkKt_N.d.ts.map +1 -0
- package/dist/config.d.ts +2 -2
- package/dist/config.js +1 -1
- package/dist/{copy-DXnzXsJa.d.ts → copy-B3V_QRkT.d.ts} +20 -7
- package/dist/copy-B3V_QRkT.d.ts.map +1 -0
- package/dist/copy-Bo_FIoLy.js +154 -0
- package/dist/copy-Bo_FIoLy.js.map +1 -0
- package/dist/endpoints-CnUfCkIk.js +96 -0
- package/dist/endpoints-CnUfCkIk.js.map +1 -0
- package/dist/generate-9pR3i5Bj.js +5173 -0
- package/dist/generate-9pR3i5Bj.js.map +1 -0
- package/dist/helpers-BWGcD0kS.js +96 -0
- package/dist/helpers-BWGcD0kS.js.map +1 -0
- package/dist/icu-Dej0mNsF.js +218 -0
- package/dist/icu-Dej0mNsF.js.map +1 -0
- package/dist/index.d.ts +12 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -188
- package/dist/inspector.d.ts +4 -1
- package/dist/inspector.d.ts.map +1 -1
- package/dist/inspector.js +7 -2
- package/dist/inspector.js.map +1 -1
- package/dist/locale-DpQA1pqH.js +70 -0
- package/dist/locale-DpQA1pqH.js.map +1 -0
- package/dist/next-loader.d.ts +11 -0
- package/dist/next-loader.d.ts.map +1 -0
- package/dist/next-loader.js +17 -0
- package/dist/next-loader.js.map +1 -0
- package/dist/next.d.ts +30 -0
- package/dist/next.d.ts.map +1 -0
- package/dist/next.js +160 -0
- package/dist/next.js.map +1 -0
- package/dist/react/index.d.ts +3 -3
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js.map +1 -1
- package/dist/react/server.d.ts +20 -0
- package/dist/react/server.d.ts.map +1 -0
- package/dist/react/server.js +34 -0
- package/dist/react/server.js.map +1 -0
- package/dist/{sarif-PkPvsB07.js → sarif-CWyTAQ0y.js} +103 -87
- package/dist/sarif-CWyTAQ0y.js.map +1 -0
- package/dist/scan-Bx0_yDSi.js +387 -0
- package/dist/scan-Bx0_yDSi.js.map +1 -0
- package/dist/tools.d.ts +83 -64
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +5 -4
- package/dist/vite.d.ts +14 -4
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +60 -114
- package/dist/vite.js.map +1 -1
- package/package.json +37 -4
- package/src/bun.ts +132 -0
- package/src/cli.ts +60 -58
- package/src/index.ts +1 -1
- package/src/inspector.ts +8 -3
- package/src/next-loader.ts +16 -0
- package/src/next.ts +188 -0
- package/src/react/index.tsx +6 -6
- package/src/react/server.ts +37 -0
- package/src/runtime/copy.ts +22 -3
- package/src/runtime/helpers.ts +54 -18
- package/src/runtime/locale.ts +19 -0
- package/src/runtime/types.ts +21 -6
- package/src/tools/bundle.ts +131 -0
- package/src/tools/catalog.ts +2 -2
- package/src/tools/compile.ts +61 -13
- package/src/tools/config.ts +58 -3
- package/src/tools/endpoints.ts +121 -0
- package/src/tools/entry.ts +77 -0
- package/src/tools/generate.ts +32 -32
- package/src/tools/icu.ts +93 -8
- package/src/tools/index.ts +20 -4
- package/src/tools/lint.ts +49 -12
- package/src/tools/lock.ts +7 -0
- package/src/tools/mf1.ts +21 -7
- package/src/tools/mf2.ts +249 -57
- package/src/tools/migrate.ts +3 -2
- package/src/tools/native/emit.ts +87 -0
- package/src/tools/native/index.ts +36 -0
- package/src/tools/native/kotlin.ts +269 -0
- package/src/tools/native/model.ts +280 -0
- package/src/tools/native/number.ts +210 -0
- package/src/tools/native/plurals.ts +103 -0
- package/src/tools/native/rust.ts +367 -0
- package/src/tools/native/swift.ts +326 -0
- package/src/tools/scan.ts +134 -4
- package/src/tools/syntax.ts +3 -3
- package/src/tools/todo.ts +118 -0
- package/src/vite.ts +74 -116
- package/dist/config-CF7HziDa.d.ts.map +0 -1
- package/dist/config-CxVMn19f.js.map +0 -1
- package/dist/copy-DXnzXsJa.d.ts.map +0 -1
- package/dist/helpers-BPuPGjQP.js +0 -61
- package/dist/helpers-BPuPGjQP.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/sarif-PkPvsB07.js.map +0 -1
- package/dist/scan-B0tZ7y-D.js +0 -2447
- package/dist/scan-B0tZ7y-D.js.map +0 -1
package/src/runtime/helpers.ts
CHANGED
|
@@ -1,34 +1,61 @@
|
|
|
1
1
|
import type { Arms, Helpers, Values } from './types.ts';
|
|
2
2
|
|
|
3
|
+
/** Formatters by options object, then by the options arguments gave, as JSON (`''` for none). */
|
|
4
|
+
type Formatters<F> = Map<object | undefined, Map<string, F>>;
|
|
5
|
+
|
|
3
6
|
/**
|
|
4
7
|
* The helpers compiled messages call, for one locale. Formatters are built once per options object:
|
|
5
8
|
* the generator hoists each message's options to a module-level constant, so identity is a stable key.
|
|
9
|
+
* Options an argument gives are built per call, so those are told apart by value.
|
|
10
|
+
*
|
|
11
|
+
* A value missing, or one `Intl` refuses, never throws: `n` and `d` give nothing back, and the message
|
|
12
|
+
* shows the placeholder's fallback through `e`, which hands `report` what went wrong.
|
|
6
13
|
*/
|
|
7
|
-
export function createHelpers(locale: string): Helpers {
|
|
8
|
-
const numbers
|
|
9
|
-
const dates
|
|
10
|
-
|
|
11
|
-
let
|
|
14
|
+
export function createHelpers(locale: string, report?: (error: Error) => void): Helpers {
|
|
15
|
+
const numbers: Formatters<Intl.NumberFormat> = new Map();
|
|
16
|
+
const dates: Formatters<Intl.DateTimeFormat> = new Map();
|
|
17
|
+
const rules: Formatters<Intl.PluralRules>[] = [new Map(), new Map()];
|
|
18
|
+
let failure: unknown;
|
|
19
|
+
|
|
20
|
+
function attempt(value: unknown, format: () => string): string | undefined {
|
|
21
|
+
failure = undefined;
|
|
22
|
+
// `value !== value`: NaN, which `Intl` prints as "NaN".
|
|
23
|
+
if (value === undefined || value === null || value !== value) return undefined;
|
|
24
|
+
try {
|
|
25
|
+
return format();
|
|
26
|
+
} catch (error) {
|
|
27
|
+
failure = error;
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
12
31
|
|
|
13
32
|
return {
|
|
14
33
|
locale,
|
|
15
|
-
n(value, options) {
|
|
16
|
-
|
|
17
|
-
if (format === undefined) numbers.set(options, (format = new Intl.NumberFormat(locale, options)));
|
|
18
|
-
return format.format(value);
|
|
34
|
+
n(value, options, given) {
|
|
35
|
+
return attempt(value, () => formatter(numbers, options, given, (o) => new Intl.NumberFormat(locale, o)).format(value));
|
|
19
36
|
},
|
|
20
|
-
d(value, options) {
|
|
21
|
-
|
|
22
|
-
if (format === undefined) dates.set(options, (format = new Intl.DateTimeFormat(locale, options)));
|
|
23
|
-
return format.format(value);
|
|
37
|
+
d(value, options, given) {
|
|
38
|
+
return attempt(value, () => formatter(dates, options, given, (o) => new Intl.DateTimeFormat(locale, o)).format(value));
|
|
24
39
|
},
|
|
25
|
-
|
|
40
|
+
e(fallback) {
|
|
41
|
+
const why = failure instanceof Error ? `could not be formatted: ${failure.message}` : 'has no value';
|
|
42
|
+
report?.(new Error(`${fallback} ${why}`, { cause: failure }));
|
|
43
|
+
failure = undefined;
|
|
44
|
+
return fallback;
|
|
45
|
+
},
|
|
46
|
+
p(value, offset, isOrdinal, arms, options, given) {
|
|
26
47
|
const exact = arms[`=${value}`];
|
|
27
48
|
if (exact !== undefined) return exact();
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
49
|
+
let category = 'other';
|
|
50
|
+
try {
|
|
51
|
+
const type = isOrdinal ? 'ordinal' : 'cardinal';
|
|
52
|
+
category = formatter(rules[+isOrdinal]!, options, given, (o) => new Intl.PluralRules(locale, { ...o, type })).select(
|
|
53
|
+
value - offset,
|
|
54
|
+
);
|
|
55
|
+
} catch {
|
|
56
|
+
// Options an argument gave that `Intl` refuses: the catch-all arm, as MF2 falls back to `*`.
|
|
57
|
+
}
|
|
58
|
+
return pick(arms, category);
|
|
32
59
|
},
|
|
33
60
|
s(value, arms) {
|
|
34
61
|
return pick(arms, String(value));
|
|
@@ -40,6 +67,15 @@ export function createHelpers(locale: string): Helpers {
|
|
|
40
67
|
};
|
|
41
68
|
}
|
|
42
69
|
|
|
70
|
+
function formatter<O extends object, F>(cache: Formatters<F>, options: O | undefined, given: O | undefined, make: (o?: O) => F): F {
|
|
71
|
+
let byGiven = cache.get(options);
|
|
72
|
+
if (byGiven === undefined) cache.set(options, (byGiven = new Map()));
|
|
73
|
+
const key = given === undefined ? '' : JSON.stringify(given);
|
|
74
|
+
let format = byGiven.get(key);
|
|
75
|
+
if (format === undefined) byGiven.set(key, (format = make(given === undefined ? options : { ...options, ...given })));
|
|
76
|
+
return format;
|
|
77
|
+
}
|
|
78
|
+
|
|
43
79
|
function pick(arms: Arms, arm: string): unknown {
|
|
44
80
|
// ICU requires an `other` arm, and the generator refuses a message without one.
|
|
45
81
|
return (Object.hasOwn(arms, arm) ? arms[arm]! : arms['other']!)();
|
package/src/runtime/locale.ts
CHANGED
|
@@ -44,6 +44,25 @@ export function preferredLocales(): string[] {
|
|
|
44
44
|
return resolved ? [resolved] : [];
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The locales an `Accept-Language` header asks for, most preferred first: ordered by `q`, ties in
|
|
49
|
+
* the header's order. `q=0` means "not this one", and `*` asks for nothing `negotiate`'s fallback
|
|
50
|
+
* doesn't already give, so both are left out, as is a part whose `q` isn't a number.
|
|
51
|
+
*/
|
|
52
|
+
export function acceptedLocales(header: string | null | undefined): string[] {
|
|
53
|
+
const weighed: { tag: string; q: number }[] = [];
|
|
54
|
+
for (const part of (header ?? '').split(',')) {
|
|
55
|
+
const [tag = '', ...params] = part.split(';').map((s) => s.trim());
|
|
56
|
+
if (tag === '' || tag === '*') continue;
|
|
57
|
+
const weight = params.find((p) => /^q\s*=/i.test(p));
|
|
58
|
+
const q = weight === undefined ? 1 : Number(weight.slice(weight.indexOf('=') + 1).trim());
|
|
59
|
+
if (Number.isNaN(q) || q <= 0) continue;
|
|
60
|
+
weighed.push({ tag, q });
|
|
61
|
+
}
|
|
62
|
+
// Array.prototype.sort is stable, so equal weights keep the header's order.
|
|
63
|
+
return weighed.sort((a, b) => b.q - a.q).map((w) => w.tag);
|
|
64
|
+
}
|
|
65
|
+
|
|
47
66
|
function languageOf(tag: string): string {
|
|
48
67
|
return tag.split('-', 1)[0]!.toLowerCase();
|
|
49
68
|
}
|
package/src/runtime/types.ts
CHANGED
|
@@ -42,12 +42,27 @@ export type DefaultTags = Register extends { tags: infer T extends string } ? T
|
|
|
42
42
|
*/
|
|
43
43
|
export interface Helpers {
|
|
44
44
|
readonly locale: string;
|
|
45
|
-
/**
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
45
|
+
/**
|
|
46
|
+
* `{x, number, …}` and the `#` of a plural; `given` holds the options arguments give, in MF2.
|
|
47
|
+
* Nothing where there is no value or `Intl` refuses it, so the message shows `e`'s fallback.
|
|
48
|
+
*/
|
|
49
|
+
n(value: number, options?: Intl.NumberFormatOptions, given?: Intl.NumberFormatOptions): string | undefined;
|
|
50
|
+
/** `{x, date, …}` and `{x, time, …}`, as `n`. */
|
|
51
|
+
d(value: Date | number, options?: Intl.DateTimeFormatOptions, given?: Intl.DateTimeFormatOptions): string | undefined;
|
|
52
|
+
/** A placeholder's fallback, `{x}`, where it has no value or `n` or `d` gave nothing: reports why. */
|
|
53
|
+
e(fallback: string): string;
|
|
54
|
+
/**
|
|
55
|
+
* `{x, plural, …}` and `{x, selectordinal, …}`: picks an arm and renders it. MF2 passes the
|
|
56
|
+
* options its selector formats with, since those decide the category: `1.0` is `other` in English.
|
|
57
|
+
*/
|
|
58
|
+
p(
|
|
59
|
+
value: number,
|
|
60
|
+
offset: number,
|
|
61
|
+
ordinal: boolean,
|
|
62
|
+
arms: Arms,
|
|
63
|
+
options?: Intl.PluralRulesOptions,
|
|
64
|
+
given?: Intl.PluralRulesOptions,
|
|
65
|
+
): unknown;
|
|
51
66
|
/** `{x, select, …}`. */
|
|
52
67
|
s(value: unknown, arms: Arms): unknown;
|
|
53
68
|
/** `<tag>…</tag>`: the tag's function from the values, or the bare children when there is none. */
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the bundler integrations share: which messages a build ships, the reads that would show a key
|
|
3
|
+
* once their message is pruned, and the code a dev server appends so an edit swaps messages in place.
|
|
4
|
+
*
|
|
5
|
+
* Vite sees every module before it writes a chunk, so it prunes after the fact (`vite.ts`). Next's
|
|
6
|
+
* loaders and Bun's `onLoad` hand over a locale module before the code that reads it has been seen,
|
|
7
|
+
* so they read the source under `scan.dirs` first (`scanSources`) and load the locale already pruned.
|
|
8
|
+
*/
|
|
9
|
+
import { existsSync, readFileSync, watch } from 'node:fs';
|
|
10
|
+
import { relative } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
|
|
13
|
+
import { compileLocale, localeModule, type Entry } from './compile.ts';
|
|
14
|
+
import type { ResolvedConfig } from './config.ts';
|
|
15
|
+
import { isReferenced, keyRefs, READERS, sourceFiles, unseenReads, type KeyRefs, type Reads, type UnseenRead } from './scan.ts';
|
|
16
|
+
|
|
17
|
+
/** How a build prunes: `strict` also fails on a read whose key it can't see. */
|
|
18
|
+
export type Prune = 'strict' | 'loose' | false;
|
|
19
|
+
|
|
20
|
+
/** Whether a build keeps a key: some module names it, or `keep` lists it, exactly or as `prefix.*`. */
|
|
21
|
+
export function keeper(refs: KeyRefs, keep: string[] = []): (key: string) => boolean {
|
|
22
|
+
return (key) => isReferenced(key, refs) || keep.some((k) => (k.endsWith('.*') ? key.startsWith(k.slice(0, -1)) : k === key));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The unseen reads that are a copy's: a name only counts once some module binds a reader to it. */
|
|
26
|
+
export function unseenOf(reads: Iterable<Reads>): UnseenRead[] {
|
|
27
|
+
const all = [...reads];
|
|
28
|
+
const readers = new Set([...READERS, ...all.flatMap((r) => [...r.aliases])]);
|
|
29
|
+
return all.flatMap((r) => r.unseen).filter((u) => u.name === 'Text' || readers.has(u.name));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The error a strict build fails with, each read at its path from `root`. */
|
|
33
|
+
export function unseenMessage(unseen: UnseenRead[], root: string): string {
|
|
34
|
+
const at = (file: string) => relative(root, file).replaceAll('\\', '/');
|
|
35
|
+
return [
|
|
36
|
+
`${unseen.length} read(s) whose key the build can't see, so their messages would be pruned and the key shown instead:`,
|
|
37
|
+
...unseen.map((u) => ` ${at(u.file)}:${u.line}:${u.column} ${u.code}`),
|
|
38
|
+
'Write each key at the read, as a literal or `area.${id}`; or, where every key it reads is written elsewhere or listed in `keep`, mark the line `prosebook-ignore`. `prune: "loose"` turns this check off.',
|
|
39
|
+
].join('\n');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SourceScan {
|
|
43
|
+
refs: KeyRefs;
|
|
44
|
+
unseen: UnseenRead[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Every key the app's source names and, with `strict`, its unseen reads. The generated modules are
|
|
49
|
+
* left out, since they name every key.
|
|
50
|
+
*/
|
|
51
|
+
export function scanSources(config: ResolvedConfig, strict: boolean): SourceScan {
|
|
52
|
+
const refs: KeyRefs = { exact: new Set(), prefixes: new Set() };
|
|
53
|
+
const reads: Reads[] = [];
|
|
54
|
+
for (const file of sourceFiles(config.scan.dirs, config.scan.extensions, [config.out])) {
|
|
55
|
+
const code = readFileSync(file, 'utf8');
|
|
56
|
+
keyRefs(code, refs);
|
|
57
|
+
if (strict) reads.push(unseenReads(file, code));
|
|
58
|
+
}
|
|
59
|
+
return { refs, unseen: unseenOf(reads) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Calls `change` once a burst of edits to the catalog or its translations settles. Returns the stop. */
|
|
63
|
+
export function watchCatalog(config: ResolvedConfig, change: () => void): () => void {
|
|
64
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
65
|
+
const watchers = [config.catalog, config.locales]
|
|
66
|
+
.filter((dir) => existsSync(dir))
|
|
67
|
+
.map((dir) =>
|
|
68
|
+
watch(dir, { recursive: true }, (_event, file) => {
|
|
69
|
+
if (file === null || !file.endsWith('.yaml')) return;
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
timer = setTimeout(change, 50);
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
return () => {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
for (const w of watchers) w.close();
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A locale's module holding only the keys `kept`, as JavaScript, for a bundler to load in its place. */
|
|
81
|
+
export function prunedModule(entries: Entry[], kept: (key: string) => boolean): string {
|
|
82
|
+
return localeModule(compileLocale(entries, kept), '// Pruned by prosebook: only the messages the code names.', false);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Appended to a locale module in dev. Accepts its own update and hands the new messages to every
|
|
87
|
+
* copy (`HotHook`); a page loaded before the hooks existed reloads instead.
|
|
88
|
+
*/
|
|
89
|
+
export const hotLocale = (locale: string) => `
|
|
90
|
+
if (import.meta.hot) {
|
|
91
|
+
import.meta.hot.accept((next) => {
|
|
92
|
+
const hot = globalThis.__PROSEBOOK_HOT__;
|
|
93
|
+
if (next === undefined || hot === undefined) return import.meta.hot.invalidate();
|
|
94
|
+
for (const update of hot) update(${JSON.stringify(locale)}, next.default);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
`;
|
|
98
|
+
|
|
99
|
+
/** Appended to the index in dev: its doc comments change with every edit; only a new locale reloads. */
|
|
100
|
+
export const HOT_INDEX = `
|
|
101
|
+
if (import.meta.hot) {
|
|
102
|
+
import.meta.hot.accept((next) => {
|
|
103
|
+
if (next === undefined || next.catalog.locales.join() !== catalog.locales.join()) import.meta.hot.invalidate();
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
`;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A locale module in dev under a bundler that runs it again when it changes, as Turbopack's Fast
|
|
110
|
+
* Refresh does, but has no `import.meta.hot`: each run hands its messages to every copy. The first
|
|
111
|
+
* run finds no copy holding the locale yet, so it changes nothing.
|
|
112
|
+
*/
|
|
113
|
+
export const notifyingLocale = (code: string, locale: string) =>
|
|
114
|
+
`${code.replace(/^export default /m, 'const messages = ')}
|
|
115
|
+
export default messages;
|
|
116
|
+
globalThis.__PROSEBOOK_HOT__?.forEach((update) => update(${JSON.stringify(locale)}, messages));
|
|
117
|
+
`;
|
|
118
|
+
|
|
119
|
+
/** Prepended to the index in dev, so the set every copy adds itself to exists before any copy does. */
|
|
120
|
+
export const HOT_SET = 'globalThis.__PROSEBOOK_HOT__ ??= new Set();\n';
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A module of prosebook's own beside the one at `url`, which passes its `import.meta.url`: the source
|
|
124
|
+
* when run from source, the built file in dist.
|
|
125
|
+
*/
|
|
126
|
+
export const beside = (url: string, name: string) =>
|
|
127
|
+
fileURLToPath(new URL(`./${name}${url.endsWith('.ts') ? '.ts' : '.js'}`, url)).replaceAll('\\', '/');
|
|
128
|
+
|
|
129
|
+
/** Code that starts the inspector (`inspector.ts`, imported from `path`) on the page, its endpoints under `base`. */
|
|
130
|
+
export const installInspector = (path: string, base: string) =>
|
|
131
|
+
`import { install as installInspector } from ${JSON.stringify(path)};\ninstallInspector(${JSON.stringify({ base })});\n`;
|
package/src/tools/catalog.ts
CHANGED
|
@@ -179,7 +179,7 @@ function readEntry(
|
|
|
179
179
|
|
|
180
180
|
let ast: MessageFormatElement[];
|
|
181
181
|
try {
|
|
182
|
-
ast = readMessage(config.messageFormat, text, config.source);
|
|
182
|
+
ast = readMessage(config.messageFormat, text, config.source, config.isolate);
|
|
183
183
|
} catch (e) {
|
|
184
184
|
fail(syntaxError(config, text, e));
|
|
185
185
|
return undefined;
|
|
@@ -260,7 +260,7 @@ function readLocales(
|
|
|
260
260
|
}
|
|
261
261
|
let ast: MessageFormatElement[];
|
|
262
262
|
try {
|
|
263
|
-
ast = readMessage(config.messageFormat, value, locale);
|
|
263
|
+
ast = readMessage(config.messageFormat, value, locale, config.isolate);
|
|
264
264
|
} catch (e) {
|
|
265
265
|
error(syntaxError(config, value, e), key);
|
|
266
266
|
continue;
|
package/src/tools/compile.ts
CHANGED
|
@@ -3,12 +3,26 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A message with no placeholders becomes its string. Any other becomes `(a, h) => …`, where `a` holds
|
|
5
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`.
|
|
6
|
+
* arm, a number is a call to a cached `Intl.NumberFormat`. A placeholder without a value, or with one
|
|
7
|
+
* `Intl` refuses, shows as `{name}` and is reported (`h.e`) rather than throwing, so every such
|
|
8
|
+
* message uses `h`. Formatting options are resolved here and
|
|
7
9
|
* hoisted to module constants, so a skeleton costs nothing at runtime and its formatter is built once.
|
|
10
|
+
* Options an argument gives (MF2's `currency=$code`) follow them as an object built per call, and the
|
|
11
|
+
* runtime keeps a formatter per value.
|
|
8
12
|
*/
|
|
9
13
|
import { isDateElement, isNumberElement, isTimeElement } from '@formatjs/icu-messageformat-parser';
|
|
10
14
|
|
|
11
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
dateOptions,
|
|
17
|
+
numberOptions,
|
|
18
|
+
optionArgsOf,
|
|
19
|
+
placeholderOf,
|
|
20
|
+
selectionOf,
|
|
21
|
+
staticLiteral,
|
|
22
|
+
TYPE,
|
|
23
|
+
type MessageFormatElement,
|
|
24
|
+
type OptionArgs,
|
|
25
|
+
} from './icu.ts';
|
|
12
26
|
|
|
13
27
|
export type Entry = readonly [key: string, ast: MessageFormatElement[]];
|
|
14
28
|
|
|
@@ -35,34 +49,67 @@ function access(name: string): string {
|
|
|
35
49
|
return /^[A-Za-z_$][\w$]*$/.test(name) ? `a.${name}` : `a[${JSON.stringify(name)}]`;
|
|
36
50
|
}
|
|
37
51
|
|
|
38
|
-
|
|
52
|
+
/** The plural a `#` prints the value of. */
|
|
53
|
+
type Pound = { name: string; offset: number } | null;
|
|
54
|
+
|
|
55
|
+
function nodes(elements: MessageFormatElement[], hoisted: Hoisted, pound: Pound): string {
|
|
39
56
|
if (elements.length === 0) return '""';
|
|
40
57
|
const parts = elements.map((el) => node(el, hoisted, pound));
|
|
41
58
|
return parts.length === 1 ? parts[0]! : `[${parts.join(', ')}]`;
|
|
42
59
|
}
|
|
43
60
|
|
|
44
|
-
function arms(options: Record<string, { value: MessageFormatElement[] }>, hoisted: Hoisted, pound:
|
|
61
|
+
function arms(options: Record<string, { value: MessageFormatElement[] }>, hoisted: Hoisted, pound: Pound): string {
|
|
45
62
|
const body = Object.entries(options).map(([arm, option]) => `${JSON.stringify(arm)}: () => ${nodes(option.value, hoisted, pound)}`);
|
|
46
63
|
return `{ ${body.join(', ')} }`;
|
|
47
64
|
}
|
|
48
65
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
66
|
+
/** The hoisted options, then the options arguments give as `{ currency: a.code }`, where there are any. */
|
|
67
|
+
function formatting(options: object | undefined, args: OptionArgs | undefined, hoisted: Hoisted): string {
|
|
68
|
+
const given = Object.entries(args ?? {});
|
|
69
|
+
const fixed = hoisted.arg(options);
|
|
70
|
+
if (given.length === 0) return fixed;
|
|
71
|
+
return `${fixed || ', undefined'}, { ${given.map(([option, { arg }]) => `${option}: ${access(arg)}`).join(', ')} }`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A string literal, its invisible direction marks escaped so the generated code shows them. */
|
|
75
|
+
function text(value: string): string {
|
|
76
|
+
return JSON.stringify(value).replace(/[\u2066-\u2069]/g, (c) => `\\u${c.charCodeAt(0).toString(16)}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A placeholder's value, or its fallback (`{name}`, reported through `h.e`) where it has none or
|
|
81
|
+
* `Intl` refuses it, between the marks isolating its direction where it has them.
|
|
82
|
+
*/
|
|
83
|
+
function placed(el: MessageFormatElement, name: string, value: string): string {
|
|
84
|
+
const { fallback, isolate } = placeholderOf(el, name);
|
|
85
|
+
const shown = `${value} ?? h.e(${text(fallback)})`;
|
|
86
|
+
return isolate === undefined ? shown : `[${text(isolate[0])}, ${shown}, ${text(isolate[1])}]`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function node(el: MessageFormatElement, hoisted: Hoisted, pound: Pound): string {
|
|
90
|
+
const formatted = (helper: string, name: string, options: object | undefined) =>
|
|
91
|
+
placed(el, name, `h.${helper}(${access(name)}${formatting(options, optionArgsOf(el), hoisted)})`);
|
|
92
|
+
if (isNumberElement(el)) return formatted('n', el.value, numberOptions(el));
|
|
93
|
+
if (isDateElement(el) || isTimeElement(el)) return formatted('d', el.value, dateOptions(el));
|
|
52
94
|
switch (el.type) {
|
|
53
95
|
case TYPE.literal:
|
|
54
96
|
return JSON.stringify(el.value);
|
|
55
97
|
case TYPE.argument:
|
|
56
|
-
return access(el.value);
|
|
98
|
+
return placed(el, el.value, access(el.value));
|
|
57
99
|
case TYPE.pound:
|
|
58
|
-
|
|
100
|
+
// `#` is its plural's own value less its offset, formatted as a number.
|
|
101
|
+
if (pound === null) return '"#"';
|
|
102
|
+
return placed(el, pound.name, `h.n(${access(pound.name)}${pound.offset !== 0 ? ` - ${pound.offset}` : ''})`);
|
|
59
103
|
case TYPE.select:
|
|
60
104
|
return `h.s(${access(el.value)}, ${arms(el.options, hoisted, pound)})`;
|
|
61
105
|
case TYPE.plural: {
|
|
62
|
-
//
|
|
106
|
+
// Read from MF2, a plural selects on the number as formatted, and on what `:integer` and `:percent` make of it.
|
|
107
|
+
const selection = selectionOf(el);
|
|
63
108
|
const value = access(el.value);
|
|
64
|
-
const
|
|
65
|
-
|
|
109
|
+
const on = selection?.value === 'round' ? `Math.round(${value})` : selection?.value === 'percent' ? `${value} * 100` : value;
|
|
110
|
+
const rules = selection === undefined ? '' : formatting(selection.options, selection.args, hoisted);
|
|
111
|
+
const own = { name: el.value, offset: el.offset };
|
|
112
|
+
return `h.p(${on}, ${el.offset}, ${el.pluralType === 'ordinal'}, ${arms(el.options, hoisted, own)}${rules})`;
|
|
66
113
|
}
|
|
67
114
|
case TYPE.tag:
|
|
68
115
|
return `h.t(a, ${JSON.stringify(el.value)}, ${nodes(el.children, hoisted, pound)})`;
|
|
@@ -73,7 +120,8 @@ function node(el: MessageFormatElement, hoisted: Hoisted, pound: string | null):
|
|
|
73
120
|
|
|
74
121
|
function message(ast: MessageFormatElement[], hoisted: Hoisted): string {
|
|
75
122
|
const literal = staticLiteral(ast);
|
|
76
|
-
|
|
123
|
+
if (literal !== undefined) return JSON.stringify(literal);
|
|
124
|
+
return `(a, h) => ${nodes(ast, hoisted, null)}`;
|
|
77
125
|
}
|
|
78
126
|
|
|
79
127
|
export interface CompiledLocale {
|
package/src/tools/config.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { isAbsolute, join, resolve } from 'node:path';
|
|
|
7
7
|
import { pathToFileURL } from 'node:url';
|
|
8
8
|
|
|
9
9
|
import { compileRule, CORE_RULES, type CheckRule, type CoreRule, type Plugin } from './lint.ts';
|
|
10
|
+
import type { Isolate } from './mf2.ts';
|
|
10
11
|
import { DEFAULT_KINDS, DEFAULT_SURFACES, STRICT_LISTS, type KindRule, type Severity } from './presets.ts';
|
|
11
12
|
|
|
12
13
|
export type { KindRule, Severity };
|
|
@@ -38,14 +39,39 @@ export interface WordLists {
|
|
|
38
39
|
concepts?: Record<string, string>;
|
|
39
40
|
/** Words that keep their capital inside a sentence-case string: product names, platforms. */
|
|
40
41
|
properNouns?: string[];
|
|
42
|
+
/** What a path through menus or settings is written with, spaced: `›` by default, as in Settings › General. */
|
|
43
|
+
pathSeparator?: string;
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
type RuleSeverities = Partial<Record<CoreRule, Severity>> & Record<string, Severity>;
|
|
47
|
+
|
|
43
48
|
export interface LintConfig extends WordLists {
|
|
44
49
|
/** `recommended` (the default) checks shape; `strict` adds the word lists and rules of a house style guide. */
|
|
45
50
|
extends?: 'recommended' | 'strict' | 'none';
|
|
46
51
|
/** Rules of your own, as `<plugin>/<rule>`. Each takes the severity it gives itself under `extends`. */
|
|
47
52
|
plugins?: Plugin[];
|
|
48
|
-
rules?:
|
|
53
|
+
rules?: RuleSeverities;
|
|
54
|
+
/** Severities for one area's entries (a key's first segment) over `rules`: `{ native: { 'sentence-case': 'off' } }`. */
|
|
55
|
+
areas?: Record<string, RuleSeverities>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Native code generated from the catalog, for the parts of an app outside the browser: a tray, a
|
|
60
|
+
* menu bar, a CLI. One file, holding every locale, with a typed function per message.
|
|
61
|
+
*/
|
|
62
|
+
export interface NativeTarget {
|
|
63
|
+
language: 'rust' | 'swift' | 'kotlin';
|
|
64
|
+
/** The file written. */
|
|
65
|
+
out: string;
|
|
66
|
+
/**
|
|
67
|
+
* Key prefixes the file holds: `['native']` takes every `native.*` key, which it names without the
|
|
68
|
+
* prefix (`native.tray.quit` is `tray_quit`). Every key, named in full, by default.
|
|
69
|
+
*/
|
|
70
|
+
keys?: string[];
|
|
71
|
+
/** The type holding the locales and messages: `Locale` in Rust, `Copy` in Swift and Kotlin. */
|
|
72
|
+
name?: string;
|
|
73
|
+
/** Kotlin's package. */
|
|
74
|
+
package?: string;
|
|
49
75
|
}
|
|
50
76
|
|
|
51
77
|
export interface ProsebookConfig {
|
|
@@ -64,6 +90,13 @@ export interface ProsebookConfig {
|
|
|
64
90
|
* translations alike; `prosebook migrate` rewrites a catalog from one to the other.
|
|
65
91
|
*/
|
|
66
92
|
messageFormat?: 1 | 2;
|
|
93
|
+
/**
|
|
94
|
+
* In MF2, when a placeholder is wrapped in Unicode isolation marks, so a value written the other
|
|
95
|
+
* way (a Latin name in Hebrew) can't reorder the text around it. `'rtl'`, the default: in locales
|
|
96
|
+
* written right to left, and wherever `u:dir` asks. `'always'`: also every string in any locale,
|
|
97
|
+
* as the spec's default has it. `false`: never.
|
|
98
|
+
*/
|
|
99
|
+
isolate?: Isolate;
|
|
67
100
|
/** Locales that ship in the main bundle beside the source, rather than loading when first used. */
|
|
68
101
|
bundled?: string[];
|
|
69
102
|
/** A pseudo-locale generated from the source (accented and lengthened) to find layout bugs and hardcoded text. */
|
|
@@ -81,6 +114,8 @@ export interface ProsebookConfig {
|
|
|
81
114
|
/** Where a string may sit, with what each surface means. */
|
|
82
115
|
surfaces?: Record<string, string>;
|
|
83
116
|
lint?: LintConfig;
|
|
117
|
+
/** Rust, Swift or Kotlin files `generate` writes beside the TypeScript. */
|
|
118
|
+
native?: NativeTarget[];
|
|
84
119
|
/** Where `prosebook scan` looks for hardcoded text and catalog keys. */
|
|
85
120
|
scan?: {
|
|
86
121
|
dirs?: string[];
|
|
@@ -98,6 +133,7 @@ export interface ResolvedConfig {
|
|
|
98
133
|
lock: string;
|
|
99
134
|
source: string;
|
|
100
135
|
messageFormat: 1 | 2;
|
|
136
|
+
isolate: Isolate;
|
|
101
137
|
bundled: string[];
|
|
102
138
|
pseudo: string | false;
|
|
103
139
|
stale: 'use' | 'fallback';
|
|
@@ -106,10 +142,16 @@ export interface ResolvedConfig {
|
|
|
106
142
|
require: ('context' | 'kind' | 'surface')[];
|
|
107
143
|
kinds: Record<string, KindRule>;
|
|
108
144
|
surfaces: Record<string, string>;
|
|
109
|
-
lint: {
|
|
145
|
+
lint: {
|
|
146
|
+
rules: Record<string, { rule: CheckRule; severity: Severity }>;
|
|
147
|
+
areas: Record<string, Record<string, Severity>>;
|
|
148
|
+
} & Required<WordLists>;
|
|
149
|
+
native: (NativeTarget & { name: string })[];
|
|
110
150
|
scan: { dirs: string[]; extensions: string[]; attributes: string[] };
|
|
111
151
|
}
|
|
112
152
|
|
|
153
|
+
const NATIVE_NAMES = { rust: 'Locale', swift: 'Copy', kotlin: 'Copy' };
|
|
154
|
+
|
|
113
155
|
/** Typed config, for `prosebook.config.ts`. */
|
|
114
156
|
export function defineConfig(config: ProsebookConfig): ProsebookConfig {
|
|
115
157
|
return config;
|
|
@@ -141,6 +183,11 @@ export function resolveConfig(root: string, config: ProsebookConfig): ResolvedCo
|
|
|
141
183
|
const lint = config.lint ?? {};
|
|
142
184
|
const base = lint.extends ?? 'recommended';
|
|
143
185
|
const strict = base === 'strict';
|
|
186
|
+
const rules = lintRules(lint);
|
|
187
|
+
for (const [area, severities] of Object.entries(lint.areas ?? {})) {
|
|
188
|
+
for (const name of Object.keys(severities))
|
|
189
|
+
if (!(name in rules)) throw new Error(`lint.areas.${area}.${name} is not a rule: name one of ${Object.keys(rules).join(', ')}`);
|
|
190
|
+
}
|
|
144
191
|
return {
|
|
145
192
|
root,
|
|
146
193
|
catalog: at(config.catalog ?? 'copy/catalog'),
|
|
@@ -149,6 +196,7 @@ export function resolveConfig(root: string, config: ProsebookConfig): ResolvedCo
|
|
|
149
196
|
lock: at(config.lock ?? 'copy/lock.json'),
|
|
150
197
|
source: config.source ?? 'en',
|
|
151
198
|
messageFormat: config.messageFormat ?? 1,
|
|
199
|
+
isolate: config.isolate ?? 'rtl',
|
|
152
200
|
bundled: config.bundled ?? [],
|
|
153
201
|
pseudo: config.pseudo ?? false,
|
|
154
202
|
stale: config.stale ?? 'use',
|
|
@@ -158,12 +206,19 @@ export function resolveConfig(root: string, config: ProsebookConfig): ResolvedCo
|
|
|
158
206
|
kinds: config.kinds ?? DEFAULT_KINDS,
|
|
159
207
|
surfaces: config.surfaces ?? DEFAULT_SURFACES,
|
|
160
208
|
lint: {
|
|
161
|
-
rules
|
|
209
|
+
rules,
|
|
210
|
+
areas: lint.areas ?? {},
|
|
162
211
|
banned: [...(strict ? STRICT_LISTS.banned : []), ...(lint.banned ?? [])],
|
|
163
212
|
spelling: { ...(strict ? STRICT_LISTS.spelling : {}), ...lint.spelling },
|
|
164
213
|
concepts: { ...lint.concepts },
|
|
165
214
|
properNouns: lint.properNouns ?? [],
|
|
215
|
+
pathSeparator: lint.pathSeparator ?? '›',
|
|
166
216
|
},
|
|
217
|
+
native: (config.native ?? []).map((target, i) => {
|
|
218
|
+
if (!(target.language in NATIVE_NAMES))
|
|
219
|
+
throw new Error(`native[${i}].language is "${target.language}": name one of ${Object.keys(NATIVE_NAMES).join(', ')}`);
|
|
220
|
+
return { ...target, out: at(target.out), name: target.name ?? NATIVE_NAMES[target.language] };
|
|
221
|
+
}),
|
|
167
222
|
scan: {
|
|
168
223
|
dirs: (config.scan?.dirs ?? ['src']).map(at),
|
|
169
224
|
extensions: config.scan?.extensions ?? ['.ts', '.tsx', '.js', '.jsx', '.mts', '.mjs'],
|