prosebook 0.1.0 → 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/README.md +41 -32
- package/dist/cli.js +3 -3
- package/dist/{config-CxVMn19f.js → config-D--RITk2.js} +76 -14
- package/dist/config-D--RITk2.js.map +1 -0
- package/dist/{config-CF7HziDa.d.ts → config-_4HfnJQu.d.ts} +25 -6
- package/dist/config-_4HfnJQu.d.ts.map +1 -0
- package/dist/config.d.ts +1 -1
- package/dist/config.js +1 -1
- package/dist/{sarif-PkPvsB07.js → sarif-CI8HOokm.js} +3 -3
- package/dist/{sarif-PkPvsB07.js.map → sarif-CI8HOokm.js.map} +1 -1
- package/dist/{scan-B0tZ7y-D.js → scan-Ch2BfIUD.js} +178 -64
- package/dist/scan-Ch2BfIUD.js.map +1 -0
- package/dist/tools.d.ts +28 -2
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +4 -4
- package/dist/vite.d.ts +13 -4
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +47 -21
- package/dist/vite.js.map +1 -1
- package/package.json +1 -1
- package/src/tools/compile.ts +14 -3
- package/src/tools/config.ts +19 -3
- package/src/tools/generate.ts +30 -32
- package/src/tools/icu.ts +30 -1
- package/src/tools/index.ts +14 -1
- package/src/tools/lint.ts +49 -12
- package/src/tools/scan.ts +134 -4
- package/src/vite.ts +62 -18
- package/dist/config-CF7HziDa.d.ts.map +0 -1
- package/dist/config-CxVMn19f.js.map +0 -1
- package/dist/scan-B0tZ7y-D.js.map +0 -1
package/dist/vite.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { C as
|
|
2
|
-
import { r as loadConfig } from "./config-
|
|
3
|
-
import { resolve } from "node:path";
|
|
1
|
+
import { A as entryLocation, C as editEntry, E as localeExpression, O as CatalogError, T as compileLocale, c as unseenReads, i as keyRefs, r as isReferenced, t as READERS, u as generate, w as readEntry } from "./scan-Ch2BfIUD.js";
|
|
2
|
+
import { r as loadConfig } from "./config-D--RITk2.js";
|
|
3
|
+
import { relative, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
// src/vite.ts
|
|
6
6
|
/**
|
|
@@ -19,6 +19,7 @@ import { fileURLToPath } from "node:url";
|
|
|
19
19
|
* whichever Vite already launches.
|
|
20
20
|
*/
|
|
21
21
|
const PLACEHOLDER = /__PROSEBOOK_LOCALE_(\d+)__/g;
|
|
22
|
+
const SCRIPT = /\.[cm]?[jt]sx?$/;
|
|
22
23
|
const DEV_ID = "\0prosebook/dev";
|
|
23
24
|
const hotLocale = (locale) => `
|
|
24
25
|
if (import.meta.hot) {
|
|
@@ -38,15 +39,19 @@ if (import.meta.hot) {
|
|
|
38
39
|
`;
|
|
39
40
|
const normalize = (path) => resolve(path).replaceAll("\\", "/");
|
|
40
41
|
const inspectorPath = normalize(fileURLToPath(new URL(`./inspector${import.meta.url.endsWith(".ts") ? ".ts" : ".js"}`, import.meta.url)));
|
|
42
|
+
const ownDir = normalize(fileURLToPath(new URL(".", import.meta.url)));
|
|
41
43
|
function prosebook(options = {}) {
|
|
42
44
|
let config;
|
|
43
45
|
let generated;
|
|
44
46
|
let building = false;
|
|
45
47
|
let base = "/";
|
|
48
|
+
let viteRoot = "";
|
|
46
49
|
let refs = {
|
|
47
50
|
exact: /* @__PURE__ */ new Set(),
|
|
48
51
|
prefixes: /* @__PURE__ */ new Set()
|
|
49
52
|
};
|
|
53
|
+
const reads = /* @__PURE__ */ new Map();
|
|
54
|
+
const prune = options.prune ?? "strict";
|
|
50
55
|
const placeholders = [];
|
|
51
56
|
const shipped = {
|
|
52
57
|
total: 0,
|
|
@@ -71,7 +76,8 @@ function prosebook(options = {}) {
|
|
|
71
76
|
async configResolved(resolved) {
|
|
72
77
|
building = resolved.command === "build";
|
|
73
78
|
base = resolved.base;
|
|
74
|
-
|
|
79
|
+
viteRoot = resolved.root;
|
|
80
|
+
config = await loadConfig(options.root === void 0 ? resolved.root : resolve(resolved.root, options.root), options.config);
|
|
75
81
|
},
|
|
76
82
|
transformIndexHtml() {
|
|
77
83
|
if (building) return [];
|
|
@@ -97,6 +103,7 @@ function prosebook(options = {}) {
|
|
|
97
103
|
exact: /* @__PURE__ */ new Set(),
|
|
98
104
|
prefixes: /* @__PURE__ */ new Set()
|
|
99
105
|
};
|
|
106
|
+
reads.clear();
|
|
100
107
|
placeholders.length = 0;
|
|
101
108
|
shipped.total = shipped.kept = 0;
|
|
102
109
|
},
|
|
@@ -108,7 +115,7 @@ function prosebook(options = {}) {
|
|
|
108
115
|
if (!path.endsWith(".yaml") || !dirs.some((d) => path.startsWith(`${d}/`))) return;
|
|
109
116
|
try {
|
|
110
117
|
generated = generate(config);
|
|
111
|
-
server.config.logger.info(`prosebook: regenerated from ${
|
|
118
|
+
server.config.logger.info(`prosebook: regenerated from ${relative(config.root, path).replaceAll("\\", "/")}`, { timestamp: true });
|
|
112
119
|
} catch (e) {
|
|
113
120
|
server.config.logger.error(describe(e), { timestamp: true });
|
|
114
121
|
server.ws.send({
|
|
@@ -189,28 +196,47 @@ function prosebook(options = {}) {
|
|
|
189
196
|
if (options.inspect === false) return hot;
|
|
190
197
|
return `import { install } from ${JSON.stringify(inspectorPath)};\n${hot}install(${JSON.stringify({ base })});\n`;
|
|
191
198
|
}
|
|
192
|
-
if (!building ||
|
|
199
|
+
if (!building || prune === false || generated === void 0) return null;
|
|
193
200
|
const locale = localeOf(id);
|
|
194
201
|
if (locale === void 0) return null;
|
|
195
202
|
placeholders.push(locale);
|
|
196
203
|
return `export default __PROSEBOOK_LOCALE_${placeholders.length - 1}__;\n`;
|
|
197
204
|
},
|
|
198
|
-
transform
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
205
|
+
transform: {
|
|
206
|
+
order: "pre",
|
|
207
|
+
handler(code, id) {
|
|
208
|
+
if (!building) {
|
|
209
|
+
const locale = localeOf(id);
|
|
210
|
+
if (locale !== void 0) return {
|
|
211
|
+
code: code + hotLocale(locale),
|
|
212
|
+
map: null
|
|
213
|
+
};
|
|
214
|
+
return isIndex(id) ? {
|
|
215
|
+
code: code + HOT_INDEX,
|
|
216
|
+
map: null
|
|
217
|
+
} : null;
|
|
218
|
+
}
|
|
219
|
+
if (prune === false || id.includes("/node_modules/") || id.startsWith("\0")) return null;
|
|
220
|
+
if (localeOf(id) !== void 0 || isIndex(id)) return null;
|
|
221
|
+
keyRefs(code, refs);
|
|
222
|
+
const path = id.split("?", 1)[0];
|
|
223
|
+
if (prune === "strict" && SCRIPT.test(path) && !normalize(path).startsWith(`${ownDir}/`)) reads.set(id, unseenReads(path, code));
|
|
224
|
+
return null;
|
|
209
225
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
226
|
+
},
|
|
227
|
+
buildEnd(error) {
|
|
228
|
+
if (error !== void 0 || prune !== "strict") return;
|
|
229
|
+
const live = new Set(this.getModuleIds());
|
|
230
|
+
const all = [...reads].filter(([id]) => live.has(id)).map(([, r]) => r);
|
|
231
|
+
const readers = /* @__PURE__ */ new Set([...READERS, ...all.flatMap((r) => [...r.aliases])]);
|
|
232
|
+
const unseen = all.flatMap((r) => r.unseen).filter((u) => u.name === "Text" || readers.has(u.name));
|
|
233
|
+
if (unseen.length === 0) return;
|
|
234
|
+
const at = (file) => relative(viteRoot, file).replaceAll("\\", "/");
|
|
235
|
+
this.error([
|
|
236
|
+
`${unseen.length} read(s) whose key the build can't see, so their messages would be pruned and the key shown instead:`,
|
|
237
|
+
...unseen.map((u) => ` ${at(u.file)}:${u.line}:${u.column} ${u.code}`),
|
|
238
|
+
"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."
|
|
239
|
+
].join("\n"));
|
|
214
240
|
},
|
|
215
241
|
renderChunk(code) {
|
|
216
242
|
if (!code.includes("__PROSEBOOK_LOCALE_") || generated === void 0) return null;
|
package/dist/vite.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"vite.js","names":[],"sources":["../src/vite.ts"],"sourcesContent":["/**\n * `prosebook/vite`: the catalog as part of the build.\n *\n * Generates on start and again whenever a catalog or translation file changes, so dev never runs on\n * stale modules and a broken entry shows in the error overlay. A build ships only the messages the\n * code names: each locale module is swapped for its messages less the keys no module references\n * (see `tools/scan.ts` for how a reference is found, and why it errs towards keeping).\n *\n * In dev an edit doesn't reload the page: each generated locale module accepts its own update and\n * hands the new messages to every copy (`HotHook`), which re-renders in the locale and state it was in.\n * The index accepts its own too, since its doc comments change with the source text, and reloads the\n * page only when a locale was added or removed. The page also gets the inspector (`inspector.ts`), and\n * the server opens the entry it asks for through Vite's own `/__open-in-editor`, so the editor is\n * whichever Vite already launches.\n */\nimport { resolve } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nimport type { Plugin } from 'vite';\n\nimport { CatalogError, entryLocation } from './tools/catalog.ts';\nimport { compileLocale, localeExpression } from './tools/compile.ts';\nimport { loadConfig, type ProsebookConfig, type ResolvedConfig } from './tools/config.ts';\nimport { editEntry, readEntry, type EntryEdit } from './tools/entry.ts';\nimport { generate, type Generated } from './tools/generate.ts';\nimport { isReferenced, keyRefs, type KeyRefs } from './tools/scan.ts';\n\nexport interface ProsebookPluginOptions {\n /** The config, inline. Without it, `prosebook.config.*` at the Vite root is read. */\n config?: ProsebookConfig;\n /** Ship only the messages the code names. On by default. */\n prune?: boolean;\n /** Keys a build always keeps, exactly or as `prefix.*`: for keys built at runtime from data. */\n keep?: string[];\n /** In dev, Alt-click a string on the page to open its entry in the editor. On by default. */\n inspect?: boolean;\n}\n\nconst PLACEHOLDER = /__PROSEBOOK_LOCALE_(\\d+)__/g;\nconst DEV_ID = '\\0prosebook/dev';\n\nconst hotLocale = (locale: string) => `\nif (import.meta.hot) {\n import.meta.hot.accept((next) => {\n const hot = globalThis.__PROSEBOOK_HOT__;\n if (next === undefined || hot === undefined) return import.meta.hot.invalidate();\n for (const update of hot) update(${JSON.stringify(locale)}, next.default);\n });\n}\n`;\n\nconst HOT_INDEX = `\nif (import.meta.hot) {\n import.meta.hot.accept((next) => {\n if (next === undefined || next.catalog.locales.join() !== catalog.locales.join()) import.meta.hot.invalidate();\n });\n}\n`;\n\nconst normalize = (path: string) => resolve(path).replaceAll('\\\\', '/');\n\n// Beside this module: the source when run from source, the built file in dist.\nconst inspectorPath = normalize(fileURLToPath(new URL(`./inspector${import.meta.url.endsWith('.ts') ? '.ts' : '.js'}`, import.meta.url)));\n\nexport function prosebook(options: ProsebookPluginOptions = {}): Plugin {\n let config: ResolvedConfig;\n let generated: Generated | undefined;\n let building = false;\n let base = '/';\n let refs: KeyRefs = { exact: new Set(), prefixes: new Set() };\n const placeholders: string[] = [];\n const shipped = { total: 0, kept: 0 };\n\n const describe = (e: unknown) => (e instanceof CatalogError ? e.message : e instanceof Error ? (e.stack ?? e.message) : String(e));\n\n const localeOf = (id: string): string | undefined => {\n const path = normalize(id.split('?', 1)[0]!);\n const out = normalize(config.out);\n if (!path.startsWith(`${out}/`)) return undefined;\n const name = path.slice(out.length + 1).replace(/\\.[jt]s$/, '');\n return generated?.entries.has(name) === true ? name : undefined;\n };\n\n const isIndex = (id: string) => {\n const path = normalize(id.split('?', 1)[0]!);\n const out = normalize(config.out);\n return path === `${out}/index.ts` || path === `${out}/index.js`;\n };\n\n const kept = (key: string) =>\n isReferenced(key, refs) || (options.keep ?? []).some((k) => (k.endsWith('.*') ? key.startsWith(k.slice(0, -1)) : k === key));\n\n return {\n name: 'prosebook',\n\n async configResolved(resolved) {\n building = resolved.command === 'build';\n base = resolved.base;\n config = await loadConfig(resolved.root, options.config);\n },\n\n // First in <head>, so the hot hooks are there before any copy is created.\n transformIndexHtml() {\n if (building) return [];\n return [{ tag: 'script', attrs: { type: 'module', src: `${base}@id/__x00__prosebook/dev` }, injectTo: 'head-prepend' }];\n },\n\n resolveId(id) {\n return id === DEV_ID ? id : null;\n },\n\n buildStart() {\n try {\n generated = generate(config);\n } catch (e) {\n this.error(describe(e));\n }\n refs = { exact: new Set(), prefixes: new Set() };\n placeholders.length = 0;\n shipped.total = shipped.kept = 0;\n },\n\n configureServer(server) {\n const dirs = [normalize(config.catalog), normalize(config.locales)];\n server.watcher.add(dirs);\n const onChange = (file: string) => {\n const path = normalize(file);\n if (!path.endsWith('.yaml') || !dirs.some((d) => path.startsWith(`${d}/`))) return;\n try {\n generated = generate(config);\n server.config.logger.info(`prosebook: regenerated from ${path.slice(normalize(config.root).length + 1)}`, { timestamp: true });\n } catch (e) {\n server.config.logger.error(describe(e), { timestamp: true });\n server.ws.send({ type: 'error', err: { message: describe(e), stack: '', plugin: 'prosebook' } });\n }\n };\n server.watcher.on('change', onChange);\n server.watcher.on('add', onChange);\n server.watcher.on('unlink', onChange);\n if (options.inspect === false) return;\n\n // `?key=cart.items&locale=de`: the translation's entry, or the catalog's with `catalog` or when\n // the locale has none. Handed on to Vite's `/__open-in-editor`, which comes later in the stack.\n server.middlewares.use((req, res, next) => {\n const url = new URL(req.url ?? '/', 'http://localhost');\n if (url.pathname !== `${base}__prosebook/open`) return next();\n const key = url.searchParams.get('key') ?? '';\n const message = generated?.catalog.messages.find((m) => m.key === key);\n if (message === undefined) {\n res.statusCode = 404;\n res.end(`no entry for \"${key}\" in the catalog: has it been renamed since the page loaded?`);\n return;\n }\n const locale = url.searchParams.has('catalog') ? undefined : (url.searchParams.get('locale') ?? undefined);\n const { file, line, column } = entryLocation(config, message, locale);\n req.url = `${base}__open-in-editor?file=${encodeURIComponent(`${file}:${line}:${column}`)}`;\n next();\n });\n\n // The inspector's dialog: GET `?key=…` reads an entry in every locale, POST `{ key, edit, write }`\n // applies an edit (`tools/entry.ts`). A POST writes files, so only the page itself may send one:\n // same origin, and JSON, which a form on another site can't send without asking first.\n server.middlewares.use((req, res, next) => {\n const url = new URL(req.url ?? '/', 'http://localhost');\n if (url.pathname !== `${base}__prosebook/entry`) return next();\n const reply = (status: number, body: unknown) => {\n res.statusCode = status;\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify(body));\n };\n const fail = (e: unknown) =>\n reply(e instanceof CatalogError ? 409 : 400, {\n problems: e instanceof CatalogError ? e.problems : [{ severity: 'error', message: e instanceof Error ? e.message : String(e) }],\n });\n if (req.method === 'GET') {\n try {\n reply(200, readEntry(config, url.searchParams.get('key') ?? ''));\n } catch (e) {\n fail(e);\n }\n return;\n }\n const origin = req.headers.origin;\n const sameOrigin = origin === undefined || new URL(origin).host === req.headers.host;\n if (req.method !== 'POST' || !sameOrigin || req.headers['content-type']?.startsWith('application/json') !== true) {\n reply(403, { problems: [{ severity: 'error', message: 'edits come from the page, as JSON' }] });\n return;\n }\n let body = '';\n req.setEncoding('utf8');\n req.on('data', (chunk: string) => (body += chunk));\n req.on('end', () => {\n try {\n const { key, edit, write } = JSON.parse(body) as { key: string; edit: EntryEdit; write?: boolean };\n const result = editEntry(config, key, edit, write === true);\n reply(result.problems.length > 0 ? 422 : 200, result);\n } catch (e) {\n fail(e);\n }\n });\n });\n },\n\n load(id) {\n if (id === DEV_ID) {\n const hot = 'globalThis.__PROSEBOOK_HOT__ ??= new Set();\\n';\n if (options.inspect === false) return hot;\n return `import { install } from ${JSON.stringify(inspectorPath)};\\n${hot}install(${JSON.stringify({ base })});\\n`;\n }\n if (!building || options.prune === false || generated === undefined) return null;\n const locale = localeOf(id);\n if (locale === undefined) return null;\n placeholders.push(locale);\n return `export default __PROSEBOOK_LOCALE_${placeholders.length - 1}__;\\n`;\n },\n\n transform(code, id) {\n if (!building) {\n const locale = localeOf(id);\n if (locale !== undefined) return { code: code + hotLocale(locale), map: null };\n return isIndex(id) ? { code: code + HOT_INDEX, map: null } : null;\n }\n if (options.prune === false || id.includes('/node_modules/') || id.startsWith('\\0')) return null;\n if (localeOf(id) !== undefined) return null;\n keyRefs(code, refs);\n return null;\n },\n\n renderChunk(code) {\n if (!code.includes('__PROSEBOOK_LOCALE_') || generated === undefined) return null;\n const entries = generated.entries;\n const replaced = code.replace(PLACEHOLDER, (_match, index: string) => {\n const locale = placeholders[Number(index)]!;\n const list = entries.get(locale) ?? [];\n const compiled = compileLocale(list, kept);\n shipped.total += list.length;\n shipped.kept += compiled.rows.length;\n return localeExpression(compiled);\n });\n return { code: replaced, map: null };\n },\n\n generateBundle() {\n if (shipped.total === 0) return;\n this.info(\n `shipped ${shipped.kept} of ${shipped.total} messages across ${placeholders.length} locale(s); pruned ${shipped.total - shipped.kept} no code names`,\n );\n },\n };\n}\n\nexport default prosebook;\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"vite.js","names":[],"sources":["../src/vite.ts"],"sourcesContent":["/**\n * `prosebook/vite`: the catalog as part of the build.\n *\n * Generates on start and again whenever a catalog or translation file changes, so dev never runs on\n * stale modules and a broken entry shows in the error overlay. A build ships only the messages the\n * code names: each locale module is swapped for its messages less the keys no module references\n * (see `tools/scan.ts` for how a reference is found, and why it errs towards keeping).\n *\n * In dev an edit doesn't reload the page: each generated locale module accepts its own update and\n * hands the new messages to every copy (`HotHook`), which re-renders in the locale and state it was in.\n * The index accepts its own too, since its doc comments change with the source text, and reloads the\n * page only when a locale was added or removed. The page also gets the inspector (`inspector.ts`), and\n * the server opens the entry it asks for through Vite's own `/__open-in-editor`, so the editor is\n * whichever Vite already launches.\n */\nimport { relative, resolve } from 'node:path';\nimport { fileURLToPath } from 'node:url';\n\nimport type { Plugin } from 'vite';\n\nimport { CatalogError, entryLocation } from './tools/catalog.ts';\nimport { compileLocale, localeExpression } from './tools/compile.ts';\nimport { loadConfig, type ProsebookConfig, type ResolvedConfig } from './tools/config.ts';\nimport { editEntry, readEntry, type EntryEdit } from './tools/entry.ts';\nimport { generate, type Generated } from './tools/generate.ts';\nimport { isReferenced, keyRefs, READERS, unseenReads, type KeyRefs, type Reads } from './tools/scan.ts';\n\nexport interface ProsebookPluginOptions {\n /** The config, inline. Without it, `prosebook.config.*` at `root` is read. */\n config?: ProsebookConfig;\n /**\n * The folder the config's paths resolve from, and where its file is found: the Vite root when\n * unset. Apps that share one catalog name its folder, so each build generates the same files.\n */\n root?: string;\n /**\n * Ship only the messages the code names. `strict`, the default, also fails the build on a read\n * whose key it can't see, such as `t(item.key)`, which would show the key where its message was\n * pruned. `loose` prunes without that check; `false` ships every message.\n */\n prune?: 'strict' | 'loose' | false;\n /** Keys a build always keeps, exactly or as `prefix.*`: for keys built at runtime from data. */\n keep?: string[];\n /** In dev, Alt-click a string on the page to open its entry in the editor. On by default. */\n inspect?: boolean;\n}\n\nconst PLACEHOLDER = /__PROSEBOOK_LOCALE_(\\d+)__/g;\nconst SCRIPT = /\\.[cm]?[jt]sx?$/;\nconst DEV_ID = '\\0prosebook/dev';\n\nconst hotLocale = (locale: string) => `\nif (import.meta.hot) {\n import.meta.hot.accept((next) => {\n const hot = globalThis.__PROSEBOOK_HOT__;\n if (next === undefined || hot === undefined) return import.meta.hot.invalidate();\n for (const update of hot) update(${JSON.stringify(locale)}, next.default);\n });\n}\n`;\n\nconst HOT_INDEX = `\nif (import.meta.hot) {\n import.meta.hot.accept((next) => {\n if (next === undefined || next.catalog.locales.join() !== catalog.locales.join()) import.meta.hot.invalidate();\n });\n}\n`;\n\nconst normalize = (path: string) => resolve(path).replaceAll('\\\\', '/');\n\n// Beside this module: the source when run from source, the built file in dist.\nconst inspectorPath = normalize(fileURLToPath(new URL(`./inspector${import.meta.url.endsWith('.ts') ? '.ts' : '.js'}`, import.meta.url)));\n// prosebook's own modules, which read keys handed to them: `<Text>` calls `rich(props.id)`.\nconst ownDir = normalize(fileURLToPath(new URL('.', import.meta.url)));\n\nexport function prosebook(options: ProsebookPluginOptions = {}): Plugin {\n let config: ResolvedConfig;\n let generated: Generated | undefined;\n let building = false;\n let base = '/';\n let viteRoot = '';\n let refs: KeyRefs = { exact: new Set(), prefixes: new Set() };\n const reads = new Map<string, Reads>();\n const prune = options.prune ?? 'strict';\n const placeholders: string[] = [];\n const shipped = { total: 0, kept: 0 };\n\n const describe = (e: unknown) => (e instanceof CatalogError ? e.message : e instanceof Error ? (e.stack ?? e.message) : String(e));\n\n const localeOf = (id: string): string | undefined => {\n const path = normalize(id.split('?', 1)[0]!);\n const out = normalize(config.out);\n if (!path.startsWith(`${out}/`)) return undefined;\n const name = path.slice(out.length + 1).replace(/\\.[jt]s$/, '');\n return generated?.entries.has(name) === true ? name : undefined;\n };\n\n const isIndex = (id: string) => {\n const path = normalize(id.split('?', 1)[0]!);\n const out = normalize(config.out);\n return path === `${out}/index.ts` || path === `${out}/index.js`;\n };\n\n const kept = (key: string) =>\n isReferenced(key, refs) || (options.keep ?? []).some((k) => (k.endsWith('.*') ? key.startsWith(k.slice(0, -1)) : k === key));\n\n return {\n name: 'prosebook',\n\n async configResolved(resolved) {\n building = resolved.command === 'build';\n base = resolved.base;\n viteRoot = resolved.root;\n config = await loadConfig(options.root === undefined ? resolved.root : resolve(resolved.root, options.root), options.config);\n },\n\n // First in <head>, so the hot hooks are there before any copy is created.\n transformIndexHtml() {\n if (building) return [];\n return [{ tag: 'script', attrs: { type: 'module', src: `${base}@id/__x00__prosebook/dev` }, injectTo: 'head-prepend' }];\n },\n\n resolveId(id) {\n return id === DEV_ID ? id : null;\n },\n\n buildStart() {\n try {\n generated = generate(config);\n } catch (e) {\n this.error(describe(e));\n }\n refs = { exact: new Set(), prefixes: new Set() };\n reads.clear();\n placeholders.length = 0;\n shipped.total = shipped.kept = 0;\n },\n\n configureServer(server) {\n const dirs = [normalize(config.catalog), normalize(config.locales)];\n server.watcher.add(dirs);\n const onChange = (file: string) => {\n const path = normalize(file);\n if (!path.endsWith('.yaml') || !dirs.some((d) => path.startsWith(`${d}/`))) return;\n try {\n generated = generate(config);\n server.config.logger.info(`prosebook: regenerated from ${relative(config.root, path).replaceAll('\\\\', '/')}`, {\n timestamp: true,\n });\n } catch (e) {\n server.config.logger.error(describe(e), { timestamp: true });\n server.ws.send({ type: 'error', err: { message: describe(e), stack: '', plugin: 'prosebook' } });\n }\n };\n server.watcher.on('change', onChange);\n server.watcher.on('add', onChange);\n server.watcher.on('unlink', onChange);\n if (options.inspect === false) return;\n\n // `?key=cart.items&locale=de`: the translation's entry, or the catalog's with `catalog` or when\n // the locale has none. Handed on to Vite's `/__open-in-editor`, which comes later in the stack.\n server.middlewares.use((req, res, next) => {\n const url = new URL(req.url ?? '/', 'http://localhost');\n if (url.pathname !== `${base}__prosebook/open`) return next();\n const key = url.searchParams.get('key') ?? '';\n const message = generated?.catalog.messages.find((m) => m.key === key);\n if (message === undefined) {\n res.statusCode = 404;\n res.end(`no entry for \"${key}\" in the catalog: has it been renamed since the page loaded?`);\n return;\n }\n const locale = url.searchParams.has('catalog') ? undefined : (url.searchParams.get('locale') ?? undefined);\n const { file, line, column } = entryLocation(config, message, locale);\n req.url = `${base}__open-in-editor?file=${encodeURIComponent(`${file}:${line}:${column}`)}`;\n next();\n });\n\n // The inspector's dialog: GET `?key=…` reads an entry in every locale, POST `{ key, edit, write }`\n // applies an edit (`tools/entry.ts`). A POST writes files, so only the page itself may send one:\n // same origin, and JSON, which a form on another site can't send without asking first.\n server.middlewares.use((req, res, next) => {\n const url = new URL(req.url ?? '/', 'http://localhost');\n if (url.pathname !== `${base}__prosebook/entry`) return next();\n const reply = (status: number, body: unknown) => {\n res.statusCode = status;\n res.setHeader('Content-Type', 'application/json');\n res.end(JSON.stringify(body));\n };\n const fail = (e: unknown) =>\n reply(e instanceof CatalogError ? 409 : 400, {\n problems: e instanceof CatalogError ? e.problems : [{ severity: 'error', message: e instanceof Error ? e.message : String(e) }],\n });\n if (req.method === 'GET') {\n try {\n reply(200, readEntry(config, url.searchParams.get('key') ?? ''));\n } catch (e) {\n fail(e);\n }\n return;\n }\n const origin = req.headers.origin;\n const sameOrigin = origin === undefined || new URL(origin).host === req.headers.host;\n if (req.method !== 'POST' || !sameOrigin || req.headers['content-type']?.startsWith('application/json') !== true) {\n reply(403, { problems: [{ severity: 'error', message: 'edits come from the page, as JSON' }] });\n return;\n }\n let body = '';\n req.setEncoding('utf8');\n req.on('data', (chunk: string) => (body += chunk));\n req.on('end', () => {\n try {\n const { key, edit, write } = JSON.parse(body) as { key: string; edit: EntryEdit; write?: boolean };\n const result = editEntry(config, key, edit, write === true);\n reply(result.problems.length > 0 ? 422 : 200, result);\n } catch (e) {\n fail(e);\n }\n });\n });\n },\n\n load(id) {\n if (id === DEV_ID) {\n const hot = 'globalThis.__PROSEBOOK_HOT__ ??= new Set();\\n';\n if (options.inspect === false) return hot;\n return `import { install } from ${JSON.stringify(inspectorPath)};\\n${hot}install(${JSON.stringify({ base })});\\n`;\n }\n if (!building || prune === false || generated === undefined) return null;\n const locale = localeOf(id);\n if (locale === undefined) return null;\n placeholders.push(locale);\n return `export default __PROSEBOOK_LOCALE_${placeholders.length - 1}__;\\n`;\n },\n\n // Before other plugins, so a read is found in the source as written: `<Text id>` still JSX, and\n // the line it is reported at, and its `prosebook-ignore`, where the author wrote them.\n transform: {\n order: 'pre',\n handler(code, id) {\n if (!building) {\n const locale = localeOf(id);\n if (locale !== undefined) return { code: code + hotLocale(locale), map: null };\n return isIndex(id) ? { code: code + HOT_INDEX, map: null } : null;\n }\n if (prune === false || id.includes('/node_modules/') || id.startsWith('\\0')) return null;\n // The index's `Messages` type names every key, and is read here before its types are stripped.\n if (localeOf(id) !== undefined || isIndex(id)) return null;\n keyRefs(code, refs);\n const path = id.split('?', 1)[0]!;\n if (prune === 'strict' && SCRIPT.test(path) && !normalize(path).startsWith(`${ownDir}/`)) reads.set(id, unseenReads(path, code));\n return null;\n },\n },\n\n buildEnd(error) {\n if (error !== undefined || prune !== 'strict') return;\n const live = new Set(this.getModuleIds());\n const all = [...reads].filter(([id]) => live.has(id)).map(([, r]) => r);\n const readers = new Set([...READERS, ...all.flatMap((r) => [...r.aliases])]);\n const unseen = all.flatMap((r) => r.unseen).filter((u) => u.name === 'Text' || readers.has(u.name));\n if (unseen.length === 0) return;\n const at = (file: string) => relative(viteRoot, file).replaceAll('\\\\', '/');\n this.error(\n [\n `${unseen.length} read(s) whose key the build can't see, so their messages would be pruned and the key shown instead:`,\n ...unseen.map((u) => ` ${at(u.file)}:${u.line}:${u.column} ${u.code}`),\n '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.',\n ].join('\\n'),\n );\n },\n\n renderChunk(code) {\n if (!code.includes('__PROSEBOOK_LOCALE_') || generated === undefined) return null;\n const entries = generated.entries;\n const replaced = code.replace(PLACEHOLDER, (_match, index: string) => {\n const locale = placeholders[Number(index)]!;\n const list = entries.get(locale) ?? [];\n const compiled = compileLocale(list, kept);\n shipped.total += list.length;\n shipped.kept += compiled.rows.length;\n return localeExpression(compiled);\n });\n return { code: replaced, map: null };\n },\n\n generateBundle() {\n if (shipped.total === 0) return;\n this.info(\n `shipped ${shipped.kept} of ${shipped.total} messages across ${placeholders.length} locale(s); pruned ${shipped.total - shipped.kept} no code names`,\n );\n },\n };\n}\n\nexport default prosebook;\n"],"mappings":"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"}
|
package/package.json
CHANGED
package/src/tools/compile.ts
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
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`. One that calls no helper is `(a) => …`, so
|
|
7
|
+
* the generated TypeScript passes `noUnusedParameters`. Formatting options are resolved here and
|
|
7
8
|
* hoisted to module constants, so a skeleton costs nothing at runtime and its formatter is built once.
|
|
8
9
|
*/
|
|
9
10
|
import { isDateElement, isNumberElement, isTimeElement } from '@formatjs/icu-messageformat-parser';
|
|
10
11
|
|
|
11
|
-
import { dateOptions, numberOptions, staticLiteral, TYPE, type MessageFormatElement } from './icu.ts';
|
|
12
|
+
import { dateOptions, numberOptions, staticLiteral, TYPE, walk, type MessageFormatElement } from './icu.ts';
|
|
12
13
|
|
|
13
14
|
export type Entry = readonly [key: string, ast: MessageFormatElement[]];
|
|
14
15
|
|
|
@@ -71,9 +72,19 @@ function node(el: MessageFormatElement, hoisted: Hoisted, pound: string | null):
|
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
/** Whether the message calls a helper: everything but literals and plain placeholders does. */
|
|
76
|
+
function usesHelpers(ast: MessageFormatElement[]): boolean {
|
|
77
|
+
let uses = false;
|
|
78
|
+
walk(ast, (el) => {
|
|
79
|
+
if (el.type !== TYPE.literal && el.type !== TYPE.argument) uses = true;
|
|
80
|
+
});
|
|
81
|
+
return uses;
|
|
82
|
+
}
|
|
83
|
+
|
|
74
84
|
function message(ast: MessageFormatElement[], hoisted: Hoisted): string {
|
|
75
85
|
const literal = staticLiteral(ast);
|
|
76
|
-
|
|
86
|
+
if (literal !== undefined) return JSON.stringify(literal);
|
|
87
|
+
return `(${usesHelpers(ast) ? 'a, h' : 'a'}) => ${nodes(ast, hoisted, null)}`;
|
|
77
88
|
}
|
|
78
89
|
|
|
79
90
|
export interface CompiledLocale {
|
package/src/tools/config.ts
CHANGED
|
@@ -38,14 +38,20 @@ export interface WordLists {
|
|
|
38
38
|
concepts?: Record<string, string>;
|
|
39
39
|
/** Words that keep their capital inside a sentence-case string: product names, platforms. */
|
|
40
40
|
properNouns?: string[];
|
|
41
|
+
/** What a path through menus or settings is written with, spaced: `›` by default, as in Settings › General. */
|
|
42
|
+
pathSeparator?: string;
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
type RuleSeverities = Partial<Record<CoreRule, Severity>> & Record<string, Severity>;
|
|
46
|
+
|
|
43
47
|
export interface LintConfig extends WordLists {
|
|
44
48
|
/** `recommended` (the default) checks shape; `strict` adds the word lists and rules of a house style guide. */
|
|
45
49
|
extends?: 'recommended' | 'strict' | 'none';
|
|
46
50
|
/** Rules of your own, as `<plugin>/<rule>`. Each takes the severity it gives itself under `extends`. */
|
|
47
51
|
plugins?: Plugin[];
|
|
48
|
-
rules?:
|
|
52
|
+
rules?: RuleSeverities;
|
|
53
|
+
/** Severities for one area's entries (a key's first segment) over `rules`: `{ native: { 'sentence-case': 'off' } }`. */
|
|
54
|
+
areas?: Record<string, RuleSeverities>;
|
|
49
55
|
}
|
|
50
56
|
|
|
51
57
|
export interface ProsebookConfig {
|
|
@@ -106,7 +112,10 @@ export interface ResolvedConfig {
|
|
|
106
112
|
require: ('context' | 'kind' | 'surface')[];
|
|
107
113
|
kinds: Record<string, KindRule>;
|
|
108
114
|
surfaces: Record<string, string>;
|
|
109
|
-
lint: {
|
|
115
|
+
lint: {
|
|
116
|
+
rules: Record<string, { rule: CheckRule; severity: Severity }>;
|
|
117
|
+
areas: Record<string, Record<string, Severity>>;
|
|
118
|
+
} & Required<WordLists>;
|
|
110
119
|
scan: { dirs: string[]; extensions: string[]; attributes: string[] };
|
|
111
120
|
}
|
|
112
121
|
|
|
@@ -141,6 +150,11 @@ export function resolveConfig(root: string, config: ProsebookConfig): ResolvedCo
|
|
|
141
150
|
const lint = config.lint ?? {};
|
|
142
151
|
const base = lint.extends ?? 'recommended';
|
|
143
152
|
const strict = base === 'strict';
|
|
153
|
+
const rules = lintRules(lint);
|
|
154
|
+
for (const [area, severities] of Object.entries(lint.areas ?? {})) {
|
|
155
|
+
for (const name of Object.keys(severities))
|
|
156
|
+
if (!(name in rules)) throw new Error(`lint.areas.${area}.${name} is not a rule: name one of ${Object.keys(rules).join(', ')}`);
|
|
157
|
+
}
|
|
144
158
|
return {
|
|
145
159
|
root,
|
|
146
160
|
catalog: at(config.catalog ?? 'copy/catalog'),
|
|
@@ -158,11 +172,13 @@ export function resolveConfig(root: string, config: ProsebookConfig): ResolvedCo
|
|
|
158
172
|
kinds: config.kinds ?? DEFAULT_KINDS,
|
|
159
173
|
surfaces: config.surfaces ?? DEFAULT_SURFACES,
|
|
160
174
|
lint: {
|
|
161
|
-
rules
|
|
175
|
+
rules,
|
|
176
|
+
areas: lint.areas ?? {},
|
|
162
177
|
banned: [...(strict ? STRICT_LISTS.banned : []), ...(lint.banned ?? [])],
|
|
163
178
|
spelling: { ...(strict ? STRICT_LISTS.spelling : {}), ...lint.spelling },
|
|
164
179
|
concepts: { ...lint.concepts },
|
|
165
180
|
properNouns: lint.properNouns ?? [],
|
|
181
|
+
pathSeparator: lint.pathSeparator ?? '›',
|
|
166
182
|
},
|
|
167
183
|
scan: {
|
|
168
184
|
dirs: (config.scan?.dirs ?? ['src']).map(at),
|
package/src/tools/generate.ts
CHANGED
|
@@ -127,44 +127,42 @@ function catalogLiteral(config: ResolvedConfig, locales: string[], ext: string):
|
|
|
127
127
|
|
|
128
128
|
/** The JSON Schema an editor reads to complete and explain catalog entries. */
|
|
129
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
|
+
};
|
|
130
159
|
return {
|
|
131
160
|
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
132
161
|
title: 'prosebook catalog',
|
|
133
162
|
description: 'Generated by `prosebook generate` from the config. Do not edit.',
|
|
134
163
|
type: 'object',
|
|
135
164
|
propertyNames: { pattern: '^[a-z0-9]+(?:[-_][a-z0-9]+)*(?:\\.[a-z0-9]+(?:[-_][a-z0-9]+)*)+$' },
|
|
136
|
-
additionalProperties: {
|
|
137
|
-
oneOf: [
|
|
138
|
-
{ type: 'string', description: `The message, in ${SYNTAX_NAME[config.messageFormat]}, with nothing said about it.` },
|
|
139
|
-
{
|
|
140
|
-
type: 'object',
|
|
141
|
-
required: [config.source, ...config.require],
|
|
142
|
-
additionalProperties: false,
|
|
143
|
-
properties: {
|
|
144
|
-
[config.source]: { type: 'string', description: `The message, in ${SYNTAX_NAME[config.messageFormat]}.` },
|
|
145
|
-
kind: {
|
|
146
|
-
description: 'What sort of string it is.',
|
|
147
|
-
oneOf: Object.entries(config.kinds).map(([k, r]) => ({ const: k, description: r.doc })),
|
|
148
|
-
},
|
|
149
|
-
surface: {
|
|
150
|
-
description: 'Where the string sits.',
|
|
151
|
-
oneOf: Object.entries(config.surfaces).map(([k, d]) => ({ const: k, description: d })),
|
|
152
|
-
},
|
|
153
|
-
context: { type: 'string', description: 'One sentence telling a translator where the string appears and what it answers to.' },
|
|
154
|
-
args: { type: 'object', description: 'What each placeholder holds.', additionalProperties: { type: 'string' } },
|
|
155
|
-
fragment: { type: 'boolean', description: 'Completes something beside it rather than standing as a sentence.' },
|
|
156
|
-
comment: { type: 'string', description: 'A change asked of whoever edits the entry next. Make it, then delete the comment.' },
|
|
157
|
-
remove: { type: 'boolean', description: 'Asks for the entry to go: delete it, its translations and the code that reads it.' },
|
|
158
|
-
allow: {
|
|
159
|
-
type: 'object',
|
|
160
|
-
description: 'Lint rules this entry is exempt from, each with the reason.',
|
|
161
|
-
propertyNames: { enum: Object.keys(config.lint.rules) },
|
|
162
|
-
additionalProperties: { type: 'string' },
|
|
163
|
-
},
|
|
164
|
-
},
|
|
165
|
-
},
|
|
166
|
-
],
|
|
167
|
-
},
|
|
165
|
+
additionalProperties: config.require.length === 0 ? { oneOf: [shorthand, entry] } : entry,
|
|
168
166
|
};
|
|
169
167
|
}
|
|
170
168
|
|
package/src/tools/icu.ts
CHANGED
|
@@ -9,7 +9,33 @@ export type { MessageFormatElement };
|
|
|
9
9
|
export { TYPE };
|
|
10
10
|
|
|
11
11
|
export function parseMessage(text: string): MessageFormatElement[] {
|
|
12
|
-
|
|
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
|
+
}
|
|
13
39
|
}
|
|
14
40
|
|
|
15
41
|
/**
|
|
@@ -115,6 +141,9 @@ export function dateOptions(el: DateElement | TimeElement): Intl.DateTimeFormatO
|
|
|
115
141
|
}
|
|
116
142
|
return { [field]: style };
|
|
117
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(', ')}`);
|
|
118
147
|
if (Object.keys(style.parsedOptions).length === 0)
|
|
119
148
|
throw new Error(`{${el.value}} has a date skeleton the formatter reads nothing from: "${style.pattern}"`);
|
|
120
149
|
try {
|
package/src/tools/index.ts
CHANGED
|
@@ -75,4 +75,17 @@ export { applyMigrate, planMigrate, type MigratePlan } from './migrate.ts';
|
|
|
75
75
|
export { readMessage, SYNTAX_NAME, writeMessage, type MessageFormatVersion } from './syntax.ts';
|
|
76
76
|
export { syntaxKind, typescriptOracle, type Span, type TypeOracle, type ValueKind } from './types.ts';
|
|
77
77
|
export { SarifReport, type SarifResult, type SarifTool } from './sarif.ts';
|
|
78
|
-
export {
|
|
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';
|
package/src/tools/lint.ts
CHANGED
|
@@ -37,11 +37,12 @@ interface RuleBase {
|
|
|
37
37
|
/** Its severity under `extends: 'strict'`; `error` when unset. */
|
|
38
38
|
strict?: Severity;
|
|
39
39
|
/**
|
|
40
|
-
* Only entries of these kinds,
|
|
41
|
-
*
|
|
42
|
-
* `
|
|
40
|
+
* Only entries of these kinds, on these surfaces, or in these areas (a key's first segment); an
|
|
41
|
+
* entry that says no kind or surface is skipped by a rule that names some. Only the source unless
|
|
42
|
+
* `locale` says otherwise, since most writing rules are written for one language: `all` reads
|
|
43
|
+
* every locale, and a list those languages and their regions (`de` covers `de-CH`).
|
|
43
44
|
*/
|
|
44
|
-
on?: { kind?: string[]; surface?: string[]; locale?: 'all' | string[] };
|
|
45
|
+
on?: { kind?: string[]; surface?: string[]; area?: string[]; locale?: 'all' | string[] };
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
/** A rule written as code. */
|
|
@@ -273,10 +274,14 @@ const NOT_PLURAL = new Set([
|
|
|
273
274
|
'series',
|
|
274
275
|
]);
|
|
275
276
|
|
|
276
|
-
/**
|
|
277
|
+
/**
|
|
278
|
+
* The singular of an English plural noun, or nothing where it doesn't look like one. A word with no
|
|
279
|
+
* vowel is a unit or an abbreviation (`kbps`, `fps`, `hrs`), which reads the same after 1.
|
|
280
|
+
*/
|
|
277
281
|
export function singularOf(word: string): string | undefined {
|
|
278
282
|
const lower = word.toLowerCase();
|
|
279
283
|
if (word.length < 3 || !lower.endsWith('s') || lower.endsWith('ss') || NOT_PLURAL.has(lower)) return undefined;
|
|
284
|
+
if (!/[aeiouy]/.test(lower)) return undefined;
|
|
280
285
|
if (/[^aeiou]ies$/.test(lower)) return `${word.slice(0, -3)}y`;
|
|
281
286
|
if (/(?:s|x|z|ch|sh)es$/.test(lower)) return word.slice(0, -2);
|
|
282
287
|
return word.slice(0, -1);
|
|
@@ -323,12 +328,14 @@ function eachRun(
|
|
|
323
328
|
const COUNT_NAME = /^(?:n|num|count|total|amount|quantity)$|(?:count|total)$/i;
|
|
324
329
|
|
|
325
330
|
/**
|
|
326
|
-
* Whether an arm ends with a stop; nothing for an arm with no words
|
|
327
|
-
*
|
|
331
|
+
* Whether an arm ends with a stop; nothing for an arm with no words, or one that trails off in an
|
|
332
|
+
* ellipsis (`Saving…`, `Save as…`), which is neither. Which stop is the arm's own: a question in
|
|
333
|
+
* `one` and a second sentence after it in `other` both end well.
|
|
328
334
|
*/
|
|
329
335
|
function endsWithStop(arm: MessageFormatElement[]): boolean | undefined {
|
|
330
336
|
const text = prose(renderAs(arm, 'other')).trim();
|
|
331
|
-
|
|
337
|
+
if (text.replaceAll(HOLE, '').trim() === '' || /(?:…|\.{3})["')”»」』]*$/u.test(text)) return undefined;
|
|
338
|
+
return hasStop(text);
|
|
332
339
|
}
|
|
333
340
|
|
|
334
341
|
/** Ends with a stop in any script (`。`, `؟`, `;` as Greek writes `?`), though not a comma. */
|
|
@@ -336,6 +343,17 @@ const hasStop = (text: string) => /(?![,,、،])[\p{Terminal_Punctuation}…][
|
|
|
336
343
|
|
|
337
344
|
const CURRENCY_BEFORE = /[$€£¥₹]\s?$/;
|
|
338
345
|
|
|
346
|
+
/**
|
|
347
|
+
* The ways a path through menus is written, the separator in a group: an arrow or `›` however
|
|
348
|
+
* spaced, and `>` or `/` only spaced between words or placeholders, with a capital after a slash,
|
|
349
|
+
* since "2 > 1" and "and / or" are no paths. The match takes the spaces around, so the fix spaces
|
|
350
|
+
* the separator.
|
|
351
|
+
*/
|
|
352
|
+
const PATH_SEPARATORS = new RegExp(
|
|
353
|
+
String.raw`\s*(→|->|›)\s*|(?<=[\p{L}${HOLE}])\s+(>)\s+(?=[\p{L}${HOLE}])|(?<=\p{L})\s+(\/)\s+(?=\p{Lu})`,
|
|
354
|
+
'gu',
|
|
355
|
+
);
|
|
356
|
+
|
|
339
357
|
const OFF_SCREEN = new Set(['aria']);
|
|
340
358
|
|
|
341
359
|
const an = (word = 'string') => `${/^[aeiou]/i.test(word) ? 'an' : 'a'} ${word}`;
|
|
@@ -447,6 +465,19 @@ export const CORE_RULES = {
|
|
|
447
465
|
return match === undefined ? [] : [{ message: 'no em-dash: a comma, a period or a colon does the job', match }];
|
|
448
466
|
},
|
|
449
467
|
},
|
|
468
|
+
path: {
|
|
469
|
+
description: 'A path through menus or settings is written with `lint.pathSeparator`, spaced: Settings › General.',
|
|
470
|
+
check: ({ text, config }) => {
|
|
471
|
+
const use = config.lint.pathSeparator;
|
|
472
|
+
for (const v of text.variants) {
|
|
473
|
+
for (const m of v.matchAll(PATH_SEPARATORS)) {
|
|
474
|
+
if ((m[1] ?? m[2] ?? m[3]) === use) continue;
|
|
475
|
+
return [{ message: `a path is written with a spaced ${use}`, match: m[0], fix: ` ${use} ` }];
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
return [];
|
|
479
|
+
},
|
|
480
|
+
},
|
|
450
481
|
contractions: {
|
|
451
482
|
description: '`could not`, `is not` and `cannot` are written as contractions.',
|
|
452
483
|
check: ({ text }) =>
|
|
@@ -704,14 +735,19 @@ const readsLocale = (locales: 'all' | string[] | undefined, locale: string, sour
|
|
|
704
735
|
locales.some((l) => locale.toLowerCase() === l.toLowerCase() || locale.toLowerCase().startsWith(`${l.toLowerCase()}-`));
|
|
705
736
|
|
|
706
737
|
function applies(rule: RuleBase, message: Message, locale: string, sourceLocale: string): boolean {
|
|
707
|
-
const { kind, surface, locale: locales } = rule.on ?? {};
|
|
738
|
+
const { kind, surface, area, locale: locales } = rule.on ?? {};
|
|
708
739
|
return (
|
|
709
740
|
readsLocale(locales, locale, sourceLocale) &&
|
|
710
741
|
(kind === undefined || (message.kind !== undefined && kind.includes(message.kind))) &&
|
|
711
|
-
(surface === undefined || (message.surface !== undefined && surface.includes(message.surface)))
|
|
742
|
+
(surface === undefined || (message.surface !== undefined && surface.includes(message.surface))) &&
|
|
743
|
+
(area === undefined || area.includes(message.area))
|
|
712
744
|
);
|
|
713
745
|
}
|
|
714
746
|
|
|
747
|
+
/** A rule's severity for an entry: its area's, where `lint.areas` sets one. */
|
|
748
|
+
export const severityOf = (config: ResolvedConfig, name: string, message: Message): Severity | undefined =>
|
|
749
|
+
config.lint.areas[message.area]?.[name] ?? config.lint.rules[name]?.severity;
|
|
750
|
+
|
|
715
751
|
interface Literal {
|
|
716
752
|
start: number;
|
|
717
753
|
value: string;
|
|
@@ -784,7 +820,8 @@ export function lint(messages: Message[], config: ResolvedConfig): Finding[] {
|
|
|
784
820
|
const text = locale === config.source ? source : readings(ast);
|
|
785
821
|
const context: RuleContext = { message, locale, ast, kind, text, source, config };
|
|
786
822
|
let literals: Literal[] | undefined;
|
|
787
|
-
for (const [name, { rule
|
|
823
|
+
for (const [name, { rule }] of rules) {
|
|
824
|
+
const severity = severityOf(config, name, message)!;
|
|
788
825
|
if (severity === 'off' || !applies(rule, message, locale, config.source)) continue;
|
|
789
826
|
let said: (string | Report)[];
|
|
790
827
|
try {
|
|
@@ -814,7 +851,7 @@ export function lint(messages: Message[], config: ResolvedConfig): Finding[] {
|
|
|
814
851
|
const rule = config.lint.rules[name];
|
|
815
852
|
if (rule === undefined) {
|
|
816
853
|
findings.push({ ...at, message: `allow.${name} is not a rule: ${rules.map(([n]) => n).join(', ')}` });
|
|
817
|
-
} else if (
|
|
854
|
+
} else if (severityOf(config, name, message) === 'off') {
|
|
818
855
|
continue;
|
|
819
856
|
} else if (!fired.has(name)) {
|
|
820
857
|
findings.push({ ...at, message: `allow.${name} exempts the entry from a rule it passes: remove it` });
|