prosebook 0.0.1 → 0.2.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 +604 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +508 -0
- package/dist/cli.js.map +1 -0
- package/dist/config-D--RITk2.js +1208 -0
- package/dist/config-D--RITk2.js.map +1 -0
- package/dist/config-_4HfnJQu.d.ts +517 -0
- package/dist/config-_4HfnJQu.d.ts.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-CI8HOokm.js +1033 -0
- package/dist/sarif-CI8HOokm.js.map +1 -0
- package/dist/scan-Ch2BfIUD.js +2561 -0
- package/dist/scan-Ch2BfIUD.js.map +1 -0
- package/dist/tools.d.ts +377 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +4 -0
- package/dist/vite.d.ts +24 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +264 -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 +121 -0
- package/src/tools/config.ts +211 -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 +257 -0
- package/src/tools/icu.ts +211 -0
- package/src/tools/index.ts +91 -0
- package/src/tools/lint.ts +865 -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 +303 -0
- package/src/tools/syntax.ts +24 -0
- package/src/tools/types.ts +137 -0
- package/src/vite.ts +296 -0
- package/index.js +0 -1
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns the catalog into the modules an app imports.
|
|
3
|
+
*
|
|
4
|
+
* Generated rather than loaded so that a missing key, a misspelled key or a missing argument is a type
|
|
5
|
+
* error, and so the runtime needs neither a YAML nor an ICU parser. The index carries each key's source
|
|
6
|
+
* text and context as a doc comment, so hovering a key at its call site shows what the screen says.
|
|
7
|
+
*/
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { dirname, join, relative } from 'node:path';
|
|
10
|
+
|
|
11
|
+
import { loadCatalog, type Catalog, type Message } from './catalog.ts';
|
|
12
|
+
import { compileLocale, localeModule, type Entry } from './compile.ts';
|
|
13
|
+
import type { ResolvedConfig } from './config.ts';
|
|
14
|
+
import { readLock, reconcile, serializeLock, type Lock, type StaleTranslation } from './lock.ts';
|
|
15
|
+
import { pseudoAst } from './pseudo.ts';
|
|
16
|
+
import { SYNTAX_NAME } from './syntax.ts';
|
|
17
|
+
|
|
18
|
+
const quote = (s: string) => JSON.stringify(s);
|
|
19
|
+
const prop = (name: string) => (/^[A-Za-z_$][\w$]*$/.test(name) ? name : quote(name));
|
|
20
|
+
|
|
21
|
+
function banner(config: ResolvedConfig): string {
|
|
22
|
+
const from = relative(config.root, config.catalog).replaceAll('\\', '/');
|
|
23
|
+
return `// Generated by prosebook from ${from}. Do not edit: change the catalog and run \`prosebook generate\`.`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Every locale the app can switch to, in order: the source, the translations, the pseudo-locale. */
|
|
27
|
+
export function localesOf(catalog: Catalog, config: ResolvedConfig): string[] {
|
|
28
|
+
return [config.source, ...catalog.locales, ...(config.pseudo !== false ? [config.pseudo] : [])];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Per locale, the messages its module holds. A translated locale holds only what it translates. */
|
|
32
|
+
export function localeEntries(
|
|
33
|
+
catalog: Catalog,
|
|
34
|
+
config: ResolvedConfig,
|
|
35
|
+
stale: Map<string, Map<string, StaleTranslation>>,
|
|
36
|
+
): Map<string, Entry[]> {
|
|
37
|
+
const out = new Map<string, Entry[]>();
|
|
38
|
+
out.set(
|
|
39
|
+
config.source,
|
|
40
|
+
catalog.messages.map((m) => [m.key, m.ast]),
|
|
41
|
+
);
|
|
42
|
+
for (const locale of catalog.locales) {
|
|
43
|
+
const skip = config.stale === 'fallback' ? stale.get(locale) : undefined;
|
|
44
|
+
out.set(
|
|
45
|
+
locale,
|
|
46
|
+
catalog.messages.flatMap((m): Entry[] => {
|
|
47
|
+
const t = m.translations.get(locale);
|
|
48
|
+
return t === undefined || skip?.has(m.key) === true ? [] : [[m.key, t.ast]];
|
|
49
|
+
}),
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
if (config.pseudo !== false) {
|
|
53
|
+
out.set(
|
|
54
|
+
config.pseudo,
|
|
55
|
+
catalog.messages.map((m) => [m.key, pseudoAst(m.ast)]),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function doc(lines: string[], indent: string): string {
|
|
62
|
+
const safe = lines.map((l) => l.replaceAll('*/', '*\\/'));
|
|
63
|
+
if (safe.length === 1) return `${indent}/** ${safe[0]} */\n`;
|
|
64
|
+
return `${indent}/**\n${safe.map((l) => (l === '' ? `${indent} *` : `${indent} * ${l}`)).join('\n')}\n${indent} */\n`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A doc written as a phrase (`the person's name`) reads as a sentence in a doc comment. */
|
|
68
|
+
function sentence(text: string): string {
|
|
69
|
+
const capital = text.charAt(0).toUpperCase() + text.slice(1);
|
|
70
|
+
return /[.!?]$/.test(capital) ? capital : `${capital}.`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function shapeOf(m: Message): string {
|
|
74
|
+
const tags = m.tags.length > 0 ? m.tags.map(quote).join(' | ') : 'never';
|
|
75
|
+
if (m.args.size === 0) return `{ args: {}; tags: ${tags} }`;
|
|
76
|
+
const fields = [...m.args].map(([name, type]) => {
|
|
77
|
+
const held = m.argDocs.get(name);
|
|
78
|
+
const said = [
|
|
79
|
+
held === undefined ? undefined : sentence(held),
|
|
80
|
+
m.argFormats.has(name) ? `Formatted as ${m.argFormats.get(name)}.` : undefined,
|
|
81
|
+
].filter(Boolean);
|
|
82
|
+
return `${said.length > 0 ? doc([said.join(' ')], ' ') : ''} readonly ${prop(name)}: ${type};`;
|
|
83
|
+
});
|
|
84
|
+
return `{\n args: {\n${fields.join('\n')}\n };\n tags: ${tags};\n }`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function typesBlock(catalog: Catalog, locales: string[]): string {
|
|
88
|
+
const rows = catalog.messages.map((m) => {
|
|
89
|
+
// An MF2 message with variants spans lines; each is a line of the doc comment.
|
|
90
|
+
const lines = (m.text.length > 400 ? `${m.text.slice(0, 397)}...` : m.text).split('\n');
|
|
91
|
+
if (m.context) lines.push('', m.context);
|
|
92
|
+
if (m.remove) lines.push('', 'Marked for removal in the catalog.');
|
|
93
|
+
if (m.comment) lines.push('', `Comment: ${m.comment}`);
|
|
94
|
+
return `${doc(lines, ' ')} ${quote(m.key)}: ${shapeOf(m)};`;
|
|
95
|
+
});
|
|
96
|
+
return [
|
|
97
|
+
`export type Locale = ${locales.map(quote).join(' | ')};`,
|
|
98
|
+
'',
|
|
99
|
+
'/** Every message in the catalog: the arguments it takes and the tags it wraps text in. */',
|
|
100
|
+
`export type Messages = {\n${rows.join('\n')}\n};`,
|
|
101
|
+
].join('\n');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const REGISTER = `declare module 'prosebook' {
|
|
105
|
+
interface Register {
|
|
106
|
+
messages: Messages;
|
|
107
|
+
locale: Locale;
|
|
108
|
+
}
|
|
109
|
+
}`;
|
|
110
|
+
|
|
111
|
+
function catalogLiteral(config: ResolvedConfig, locales: string[], ext: string): { imports: string[]; literal: string } {
|
|
112
|
+
const eager = locales.filter((l) => l === config.source || config.bundled.includes(l));
|
|
113
|
+
const lazy = locales.filter((l) => !eager.includes(l));
|
|
114
|
+
const ident = (l: string) => `m_${l.replace(/\W/g, '_')}`;
|
|
115
|
+
return {
|
|
116
|
+
imports: eager.map((l) => `import ${ident(l)} from './${l}${ext}';`),
|
|
117
|
+
literal: [
|
|
118
|
+
'{',
|
|
119
|
+
` source: ${quote(config.source)},`,
|
|
120
|
+
` locales: [${locales.map(quote).join(', ')}],`,
|
|
121
|
+
` messages: { ${eager.map((l) => `${prop(l)}: ${ident(l)}`).join(', ')} },`,
|
|
122
|
+
` loaders: { ${lazy.map((l) => `${prop(l)}: () => import('./${l}${ext}')`).join(', ')} },`,
|
|
123
|
+
'}',
|
|
124
|
+
].join('\n'),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The JSON Schema an editor reads to complete and explain catalog entries. */
|
|
129
|
+
export function catalogSchema(config: ResolvedConfig): object {
|
|
130
|
+
// The shorthand satisfies no required field, so it's offered only where none is.
|
|
131
|
+
const shorthand = { type: 'string', description: `The message, in ${SYNTAX_NAME[config.messageFormat]}, with nothing said about it.` };
|
|
132
|
+
const entry = {
|
|
133
|
+
type: 'object',
|
|
134
|
+
required: [config.source, ...config.require],
|
|
135
|
+
additionalProperties: false,
|
|
136
|
+
properties: {
|
|
137
|
+
[config.source]: { type: 'string', description: `The message, in ${SYNTAX_NAME[config.messageFormat]}.` },
|
|
138
|
+
kind: {
|
|
139
|
+
description: 'What sort of string it is.',
|
|
140
|
+
oneOf: Object.entries(config.kinds).map(([k, r]) => ({ const: k, description: r.doc })),
|
|
141
|
+
},
|
|
142
|
+
surface: {
|
|
143
|
+
description: 'Where the string sits.',
|
|
144
|
+
oneOf: Object.entries(config.surfaces).map(([k, d]) => ({ const: k, description: d })),
|
|
145
|
+
},
|
|
146
|
+
context: { type: 'string', description: 'One sentence telling a translator where the string appears and what it answers to.' },
|
|
147
|
+
args: { type: 'object', description: 'What each placeholder holds.', additionalProperties: { type: 'string' } },
|
|
148
|
+
fragment: { type: 'boolean', description: 'Completes something beside it rather than standing as a sentence.' },
|
|
149
|
+
comment: { type: 'string', description: 'A change asked of whoever edits the entry next. Make it, then delete the comment.' },
|
|
150
|
+
remove: { type: 'boolean', description: 'Asks for the entry to go: delete it, its translations and the code that reads it.' },
|
|
151
|
+
allow: {
|
|
152
|
+
type: 'object',
|
|
153
|
+
description: 'Lint rules this entry is exempt from, each with the reason.',
|
|
154
|
+
propertyNames: { enum: Object.keys(config.lint.rules) },
|
|
155
|
+
additionalProperties: { type: 'string' },
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
return {
|
|
160
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
161
|
+
title: 'prosebook catalog',
|
|
162
|
+
description: 'Generated by `prosebook generate` from the config. Do not edit.',
|
|
163
|
+
type: 'object',
|
|
164
|
+
propertyNames: { pattern: '^[a-z0-9]+(?:[-_][a-z0-9]+)*(?:\\.[a-z0-9]+(?:[-_][a-z0-9]+)*)+$' },
|
|
165
|
+
additionalProperties: config.require.length === 0 ? { oneOf: [shorthand, entry] } : entry,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface Generated {
|
|
170
|
+
catalog: Catalog;
|
|
171
|
+
/** Absolute path to content. */
|
|
172
|
+
files: Map<string, string>;
|
|
173
|
+
lock: Lock;
|
|
174
|
+
stale: Map<string, Map<string, StaleTranslation>>;
|
|
175
|
+
entries: Map<string, Entry[]>;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Everything `generate` writes, in memory. */
|
|
179
|
+
export function render(config: ResolvedConfig, accept?: Map<string, Set<string> | true>): Generated {
|
|
180
|
+
const catalog = loadCatalog(config);
|
|
181
|
+
const { lock, stale } = reconcile(catalog, readLock(config.lock), accept);
|
|
182
|
+
const locales = localesOf(catalog, config);
|
|
183
|
+
const entries = localeEntries(catalog, config, stale);
|
|
184
|
+
const ts = config.language === 'ts';
|
|
185
|
+
const head = banner(config);
|
|
186
|
+
const files = new Map<string, string>();
|
|
187
|
+
|
|
188
|
+
for (const [locale, list] of entries)
|
|
189
|
+
files.set(join(config.out, `${locale}.${ts ? 'ts' : 'js'}`), localeModule(compileLocale(list), head, ts));
|
|
190
|
+
|
|
191
|
+
const { imports, literal } = catalogLiteral(config, locales, config.importExtension);
|
|
192
|
+
const types = typesBlock(catalog, locales);
|
|
193
|
+
if (ts) {
|
|
194
|
+
files.set(
|
|
195
|
+
join(config.out, 'index.ts'),
|
|
196
|
+
[
|
|
197
|
+
head,
|
|
198
|
+
"import { defineCatalog } from 'prosebook';",
|
|
199
|
+
...imports,
|
|
200
|
+
'',
|
|
201
|
+
types,
|
|
202
|
+
'',
|
|
203
|
+
`export const catalog = defineCatalog<Messages, Locale>(${literal});`,
|
|
204
|
+
'',
|
|
205
|
+
REGISTER,
|
|
206
|
+
'',
|
|
207
|
+
].join('\n'),
|
|
208
|
+
);
|
|
209
|
+
} else {
|
|
210
|
+
files.set(
|
|
211
|
+
join(config.out, 'index.js'),
|
|
212
|
+
[head, "import { defineCatalog } from 'prosebook';", ...imports, '', `export const catalog = defineCatalog(${literal});`, ''].join(
|
|
213
|
+
'\n',
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
files.set(
|
|
217
|
+
join(config.out, 'index.d.ts'),
|
|
218
|
+
[
|
|
219
|
+
head,
|
|
220
|
+
"import type { Catalog } from 'prosebook';",
|
|
221
|
+
'',
|
|
222
|
+
types,
|
|
223
|
+
'',
|
|
224
|
+
'export declare const catalog: Catalog<Messages, Locale>;',
|
|
225
|
+
'',
|
|
226
|
+
REGISTER,
|
|
227
|
+
'',
|
|
228
|
+
].join('\n'),
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
files.set(join(config.catalog, 'catalog.schema.json'), `${JSON.stringify(catalogSchema(config), null, 2)}\n`);
|
|
233
|
+
if (Object.keys(lock.translations).length > 0 || existsSync(config.lock)) files.set(config.lock, serializeLock(lock));
|
|
234
|
+
return { catalog, files, lock, stale, entries };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Writes what changed. With `check`, writes nothing and returns what would have changed. */
|
|
238
|
+
export function writeFiles(files: Map<string, string>, check = false): string[] {
|
|
239
|
+
const changed: string[] = [];
|
|
240
|
+
for (const [path, content] of files) {
|
|
241
|
+
const current = existsSync(path) ? readFileSync(path, 'utf8') : undefined;
|
|
242
|
+
if (current === content) continue;
|
|
243
|
+
changed.push(path);
|
|
244
|
+
if (check) continue;
|
|
245
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
246
|
+
writeFileSync(path, content);
|
|
247
|
+
}
|
|
248
|
+
return changed;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function generate(
|
|
252
|
+
config: ResolvedConfig,
|
|
253
|
+
options: { check?: boolean; accept?: Map<string, Set<string> | true> } = {},
|
|
254
|
+
): Generated & { changed: string[] } {
|
|
255
|
+
const generated = render(config, options.accept);
|
|
256
|
+
return { ...generated, changed: writeFiles(generated.files, options.check) };
|
|
257
|
+
}
|
package/src/tools/icu.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an ICU message declares, read off its AST: argument types, tags, and whether each number and
|
|
3
|
+
* date style is one the formatter honors. Shared by the loader, the compiler and the lint.
|
|
4
|
+
*/
|
|
5
|
+
import { isDateElement, isNumberElement, isTimeElement, parse, TYPE } from '@formatjs/icu-messageformat-parser';
|
|
6
|
+
import type { DateElement, MessageFormatElement, NumberElement, TimeElement } from '@formatjs/icu-messageformat-parser';
|
|
7
|
+
|
|
8
|
+
export type { MessageFormatElement };
|
|
9
|
+
export { TYPE };
|
|
10
|
+
|
|
11
|
+
export function parseMessage(text: string): MessageFormatElement[] {
|
|
12
|
+
const ast = parse(text, { shouldParseSkeletons: true, requiresOtherClause: true });
|
|
13
|
+
walk(ast, (el) => {
|
|
14
|
+
if ((isDateElement(el) || isTimeElement(el)) && typeof el.style === 'object' && el.style !== null && el.style.pattern.includes('j'))
|
|
15
|
+
el.style.parsedOptions = localHour(el.style.pattern);
|
|
16
|
+
});
|
|
17
|
+
return ast;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A skeleton's `j` is the hour as the locale writes it, 12 or 24. The parser resolves it only when
|
|
22
|
+
* given a locale and drops it otherwise, so `::yMMMdjmm` showed the minute alone. Read here as an
|
|
23
|
+
* hour with no cycle, which `Intl` gives the cycle of whatever locale formats it: "2:05 PM", "14:05".
|
|
24
|
+
*/
|
|
25
|
+
function localHour(pattern: string): Intl.DateTimeFormatOptions {
|
|
26
|
+
const run = /j+/.exec(pattern)![0];
|
|
27
|
+
const rest = pattern.replaceAll('j', '');
|
|
28
|
+
return { ...(rest === '' ? {} : dateFields(rest)), hour: run.length % 2 === 0 ? '2-digit' : 'numeric' };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What the parser reads from a date skeleton on its own; nothing where it can't parse it. */
|
|
32
|
+
function dateFields(skeleton: string): Intl.DateTimeFormatOptions {
|
|
33
|
+
try {
|
|
34
|
+
const el = parse(`{d, date, ::${skeleton}}`, { shouldParseSkeletons: true })[0];
|
|
35
|
+
return el !== undefined && isDateElement(el) && typeof el.style === 'object' && el.style !== null ? el.style.parsedOptions : {};
|
|
36
|
+
} catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* ICU argument types, as TypeScript. A bare `{x}` is a string: a number spliced in raw is printed by
|
|
43
|
+
* `String()`, which knows no locale, so a quantity says `{x, number}` and the type is what asks for it.
|
|
44
|
+
* A `select` narrows to the arms it names, and still takes any string, since `other` catches the rest.
|
|
45
|
+
*/
|
|
46
|
+
function argType(el: MessageFormatElement): string | undefined {
|
|
47
|
+
switch (el.type) {
|
|
48
|
+
case TYPE.argument:
|
|
49
|
+
return 'string';
|
|
50
|
+
case TYPE.number:
|
|
51
|
+
case TYPE.plural:
|
|
52
|
+
return 'number';
|
|
53
|
+
case TYPE.date:
|
|
54
|
+
case TYPE.time:
|
|
55
|
+
return 'Date | number';
|
|
56
|
+
case TYPE.select: {
|
|
57
|
+
const named = Object.keys(el.options).filter((o) => o !== 'other');
|
|
58
|
+
return named.length > 0 ? `${named.map((o) => JSON.stringify(o)).join(' | ')} | (string & {})` : 'string';
|
|
59
|
+
}
|
|
60
|
+
case TYPE.literal:
|
|
61
|
+
case TYPE.pound:
|
|
62
|
+
case TYPE.tag:
|
|
63
|
+
default:
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Calls `visit` on every element, inside plural and select arms and tags too. */
|
|
69
|
+
export function walk(elements: MessageFormatElement[], visit: (el: MessageFormatElement) => void): void {
|
|
70
|
+
for (const el of elements) {
|
|
71
|
+
visit(el);
|
|
72
|
+
if (el.type === TYPE.select || el.type === TYPE.plural) for (const option of Object.values(el.options)) walk(option.value, visit);
|
|
73
|
+
if (el.type === TYPE.tag) walk(el.children, visit);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function argsOf(elements: MessageFormatElement[], clash: (name: string) => void = () => {}): Map<string, string> {
|
|
78
|
+
const args = new Map<string, string>();
|
|
79
|
+
walk(elements, (el) => {
|
|
80
|
+
const type = argType(el);
|
|
81
|
+
if (type === undefined || !('value' in el) || typeof el.value !== 'string') return;
|
|
82
|
+
const seen = args.get(el.value);
|
|
83
|
+
if (seen !== undefined && seen !== type) clash(el.value);
|
|
84
|
+
else args.set(el.value, type);
|
|
85
|
+
});
|
|
86
|
+
return args;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function tagsOf(elements: MessageFormatElement[]): string[] {
|
|
90
|
+
const tags = new Set<string>();
|
|
91
|
+
walk(elements, (el) => {
|
|
92
|
+
if (el.type === TYPE.tag) tags.add(el.value);
|
|
93
|
+
});
|
|
94
|
+
return [...tags].sort();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A message with no placeholders and no tags renders to one literal, which ships as a plain string. */
|
|
98
|
+
export function staticLiteral(ast: MessageFormatElement[]): string | undefined {
|
|
99
|
+
if (ast.length === 0) return '';
|
|
100
|
+
if (ast.length === 1 && ast[0]!.type === TYPE.literal) return ast[0]!.value;
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const NUMBER_STYLES: Record<string, Intl.NumberFormatOptions> = {
|
|
105
|
+
integer: { maximumFractionDigits: 0 },
|
|
106
|
+
percent: { style: 'percent' },
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const DATE_STYLES = ['short', 'medium', 'long', 'full'] as const;
|
|
110
|
+
|
|
111
|
+
/** The `Intl` options a number element formats with. Throws, with the fix, where the style is wrong. */
|
|
112
|
+
export function numberOptions(el: NumberElement): Intl.NumberFormatOptions | undefined {
|
|
113
|
+
const style = el.style;
|
|
114
|
+
if (style === null || style === undefined) return undefined;
|
|
115
|
+
if (typeof style === 'string') {
|
|
116
|
+
if (style === 'currency') throw new Error(`{${el.value}} needs its currency named: {${el.value}, number, ::currency/EUR}`);
|
|
117
|
+
const named = NUMBER_STYLES[style];
|
|
118
|
+
if (named === undefined) throw new Error(`{${el.value}} asks for number style "${style}": integer, percent, or a ::skeleton`);
|
|
119
|
+
return named;
|
|
120
|
+
}
|
|
121
|
+
const unknown = style.tokens.filter((t) => !stemIsKnown(t.stem, t.options)).map((t) => t.stem);
|
|
122
|
+
if (unknown.length > 0) throw new Error(`{${el.value}} has skeleton stems the formatter drops without a word: ${unknown.join(', ')}`);
|
|
123
|
+
const options = style.parsedOptions as Intl.NumberFormatOptions;
|
|
124
|
+
try {
|
|
125
|
+
new Intl.NumberFormat('en', options).resolvedOptions();
|
|
126
|
+
} catch (e) {
|
|
127
|
+
throw new Error(`{${el.value}} has a skeleton Intl refuses: ${(e as Error).message}`, { cause: e });
|
|
128
|
+
}
|
|
129
|
+
return options;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The `Intl` options a date or time element formats with. Throws, with the fix, where the style is wrong. */
|
|
133
|
+
export function dateOptions(el: DateElement | TimeElement): Intl.DateTimeFormatOptions | undefined {
|
|
134
|
+
const style = el.style;
|
|
135
|
+
const field = el.type === TYPE.time ? 'timeStyle' : 'dateStyle';
|
|
136
|
+
if (style === null || style === undefined) return el.type === TYPE.time ? { timeStyle: 'short' } : undefined;
|
|
137
|
+
if (typeof style === 'string') {
|
|
138
|
+
if (!(DATE_STYLES as readonly string[]).includes(style)) {
|
|
139
|
+
const kind = el.type === TYPE.time ? 'time' : 'date';
|
|
140
|
+
throw new Error(`{${el.value}} asks for ${kind} style "${style}": ${DATE_STYLES.join(', ')}, or a ::skeleton`);
|
|
141
|
+
}
|
|
142
|
+
return { [field]: style };
|
|
143
|
+
}
|
|
144
|
+
// Every field the parser understands sets an option, and one it doesn't is dropped without a word.
|
|
145
|
+
const dropped = (style.pattern.match(/([A-Za-z])*/g) ?? []).filter((f) => !f.startsWith('j') && Object.keys(dateFields(f)).length === 0);
|
|
146
|
+
if (dropped.length > 0) throw new Error(`{${el.value}} has date fields the formatter drops without a word: ${dropped.join(', ')}`);
|
|
147
|
+
if (Object.keys(style.parsedOptions).length === 0)
|
|
148
|
+
throw new Error(`{${el.value}} has a date skeleton the formatter reads nothing from: "${style.pattern}"`);
|
|
149
|
+
try {
|
|
150
|
+
new Intl.DateTimeFormat('en', style.parsedOptions).resolvedOptions();
|
|
151
|
+
} catch (e) {
|
|
152
|
+
throw new Error(`{${el.value}} has a skeleton Intl refuses: ${(e as Error).message}`, { cause: e });
|
|
153
|
+
}
|
|
154
|
+
return style.parsedOptions;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Whether the formatter understands a skeleton stem. One it does not is dropped without a word, so
|
|
159
|
+
* `::compact-shortt` reaches the screen as `5` where `5K` was written. Every stem it understands sets
|
|
160
|
+
* at least one option, so an empty result is the answer.
|
|
161
|
+
*/
|
|
162
|
+
function stemIsKnown(stem: string, options: readonly string[]): boolean {
|
|
163
|
+
try {
|
|
164
|
+
const el = parse(`{n, number, ::${[stem, ...options].join('/')}}`, { shouldParseSkeletons: true })[0];
|
|
165
|
+
if (el === undefined || !isNumberElement(el) || typeof el.style !== 'object' || el.style === null) return false;
|
|
166
|
+
return Object.keys(el.style.parsedOptions).length > 0;
|
|
167
|
+
} catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Every format problem in a message, as sentences. */
|
|
173
|
+
export function formatProblems(ast: MessageFormatElement[]): string[] {
|
|
174
|
+
const found: string[] = [];
|
|
175
|
+
walk(ast, (el) => {
|
|
176
|
+
try {
|
|
177
|
+
if (isNumberElement(el)) numberOptions(el);
|
|
178
|
+
if (isDateElement(el) || isTimeElement(el)) dateOptions(el);
|
|
179
|
+
} catch (e) {
|
|
180
|
+
found.push((e as Error).message);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
return found;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** How each number or date argument is formatted, for the generated doc comment: `date medium, time short`. */
|
|
187
|
+
export function formatsOf(ast: MessageFormatElement[]): Map<string, string> {
|
|
188
|
+
const formats = new Map<string, string[]>();
|
|
189
|
+
walk(ast, (el) => {
|
|
190
|
+
if (!isNumberElement(el) && !isDateElement(el) && !isTimeElement(el)) return;
|
|
191
|
+
const style = el.style;
|
|
192
|
+
if (style === null || style === undefined) return;
|
|
193
|
+
const kind = el.type === TYPE.number ? 'number' : el.type === TYPE.date ? 'date' : 'time';
|
|
194
|
+
let said: string;
|
|
195
|
+
if (typeof style === 'string') {
|
|
196
|
+
said = `${kind} \`${style}\``;
|
|
197
|
+
} else if ('mf2' in style) {
|
|
198
|
+
// Read from MF2: the function as written.
|
|
199
|
+
const { name, options } = (style as { mf2: { name: string; options: Record<string, string> } }).mf2;
|
|
200
|
+
said = `\`${[`:${name}`, ...Object.entries(options).map(([k, v]) => `${k}=${v}`)].join(' ')}\``;
|
|
201
|
+
} else if ('tokens' in style) {
|
|
202
|
+
said = `${kind} \`::${style.tokens.map((t) => [t.stem, ...t.options].join('/')).join(' ')}\``;
|
|
203
|
+
} else {
|
|
204
|
+
said = `${kind} \`::${style.pattern}\``;
|
|
205
|
+
}
|
|
206
|
+
const list = formats.get(el.value) ?? [];
|
|
207
|
+
if (!list.includes(said)) list.push(said);
|
|
208
|
+
formats.set(el.value, list);
|
|
209
|
+
});
|
|
210
|
+
return new Map([...formats].map(([name, list]) => [name, list.join(', ')]));
|
|
211
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `prosebook/tools`: the build-time half, for scripts and other bundler integrations. The CLI and the
|
|
3
|
+
* Vite plugin are written on these.
|
|
4
|
+
*/
|
|
5
|
+
export {
|
|
6
|
+
CatalogError,
|
|
7
|
+
entryLocation,
|
|
8
|
+
KEY_PATTERN,
|
|
9
|
+
loadCatalog,
|
|
10
|
+
previewOf,
|
|
11
|
+
whereOf,
|
|
12
|
+
type Catalog,
|
|
13
|
+
type EntryLocation,
|
|
14
|
+
type Message,
|
|
15
|
+
type Problem,
|
|
16
|
+
type Translation,
|
|
17
|
+
} from './catalog.ts';
|
|
18
|
+
export { compileLocale, localeExpression, localeModule, type CompiledLocale, type Entry } from './compile.ts';
|
|
19
|
+
export {
|
|
20
|
+
applyExtract,
|
|
21
|
+
areaOf,
|
|
22
|
+
draftPlurals,
|
|
23
|
+
planExtract,
|
|
24
|
+
slugOf,
|
|
25
|
+
type Extraction,
|
|
26
|
+
type ExtractPlan,
|
|
27
|
+
type FilePlan,
|
|
28
|
+
type NewEntry,
|
|
29
|
+
} from './extract.ts';
|
|
30
|
+
export {
|
|
31
|
+
applyFixes,
|
|
32
|
+
editEntry,
|
|
33
|
+
readEntry,
|
|
34
|
+
type EntryEdit,
|
|
35
|
+
type EntryFields,
|
|
36
|
+
type EntryFinding,
|
|
37
|
+
type EntryView,
|
|
38
|
+
type EntryYaml,
|
|
39
|
+
} from './entry.ts';
|
|
40
|
+
export { defineConfig, loadConfig, resolveConfig, type ProsebookConfig, type ResolvedConfig } from './config.ts';
|
|
41
|
+
export { catalogSchema, generate, localeEntries, localesOf, render, writeFiles, type Generated } from './generate.ts';
|
|
42
|
+
export {
|
|
43
|
+
applyEdits,
|
|
44
|
+
compileRule,
|
|
45
|
+
CORE_RULES,
|
|
46
|
+
definePlugin,
|
|
47
|
+
HOLE,
|
|
48
|
+
lint,
|
|
49
|
+
phrase,
|
|
50
|
+
sentences,
|
|
51
|
+
singularOf,
|
|
52
|
+
words,
|
|
53
|
+
type CapitalizationRule,
|
|
54
|
+
type CheckRule,
|
|
55
|
+
type ConditionalRule,
|
|
56
|
+
type CoreRule,
|
|
57
|
+
type Edit,
|
|
58
|
+
type ExistenceRule,
|
|
59
|
+
type Finding,
|
|
60
|
+
type OccurrenceRule,
|
|
61
|
+
type Plugin,
|
|
62
|
+
type Readings,
|
|
63
|
+
type Report,
|
|
64
|
+
type Rule,
|
|
65
|
+
type RuleContext,
|
|
66
|
+
type SubstitutionRule,
|
|
67
|
+
} from './lint.ts';
|
|
68
|
+
export { formatDiff, wordDiff, type DiffPart } from './diff.ts';
|
|
69
|
+
export { emptyLock, hash, readLock, reconcile, serializeLock, type Lock, type StaleTranslation } from './lock.ts';
|
|
70
|
+
export { DEFAULT_KINDS, DEFAULT_SURFACES, STRICT_LISTS, type KindRule, type Severity } from './presets.ts';
|
|
71
|
+
export { pseudoAst, pseudoText } from './pseudo.ts';
|
|
72
|
+
export { intlDate, intlNumber, parseMf2, toMf2, type Mf2Function } from './mf2.ts';
|
|
73
|
+
export { toMf1 } from './mf1.ts';
|
|
74
|
+
export { applyMigrate, planMigrate, type MigratePlan } from './migrate.ts';
|
|
75
|
+
export { readMessage, SYNTAX_NAME, writeMessage, type MessageFormatVersion } from './syntax.ts';
|
|
76
|
+
export { syntaxKind, typescriptOracle, type Span, type TypeOracle, type ValueKind } from './types.ts';
|
|
77
|
+
export { SarifReport, type SarifResult, type SarifTool } from './sarif.ts';
|
|
78
|
+
export {
|
|
79
|
+
hardcodedText,
|
|
80
|
+
isReferenced,
|
|
81
|
+
keyRefs,
|
|
82
|
+
looksLikeCode,
|
|
83
|
+
READERS,
|
|
84
|
+
scan,
|
|
85
|
+
sourceFiles,
|
|
86
|
+
unseenReads,
|
|
87
|
+
type Hardcoded,
|
|
88
|
+
type KeyRefs,
|
|
89
|
+
type Reads,
|
|
90
|
+
type UnseenRead,
|
|
91
|
+
} from './scan.ts';
|