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/dist/vite.js CHANGED
@@ -1,6 +1,6 @@
1
- import { C as compileLocale, E as CatalogError, O as entryLocation, S as readEntry, c as generate, n as isReferenced, r as keyRefs, w as localeExpression, x as editEntry } from "./scan-B0tZ7y-D.js";
2
- import { r as loadConfig } from "./config-CxVMn19f.js";
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
- config = await loadConfig(resolved.root, options.config);
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 ${path.slice(normalize(config.root).length + 1)}`, { timestamp: true });
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 || options.prune === false || generated === void 0) return null;
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(code, id) {
199
- if (!building) {
200
- const locale = localeOf(id);
201
- if (locale !== void 0) return {
202
- code: code + hotLocale(locale),
203
- map: null
204
- };
205
- return isIndex(id) ? {
206
- code: code + HOT_INDEX,
207
- map: null
208
- } : null;
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
- if (options.prune === false || id.includes("/node_modules/") || id.startsWith("\0")) return null;
211
- if (localeOf(id) !== void 0) return null;
212
- keyRefs(code, refs);
213
- return null;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prosebook",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Every string your app shows, in one typed catalog: ICU messages compiled to plain functions, for React and any JavaScript.",
5
5
  "keywords": [
6
6
  "i18n",
@@ -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`. Formatting options are resolved here and
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
- return literal === undefined ? `(a, h) => ${nodes(ast, hoisted, null)}` : JSON.stringify(literal);
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 {
@@ -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?: Partial<Record<CoreRule, Severity>> & Record<string, Severity>;
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: { rules: Record<string, { rule: CheckRule; severity: Severity }> } & Required<WordLists>;
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: lintRules(lint),
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),
@@ -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
- return parse(text, { shouldParseSkeletons: true, requiresOtherClause: true });
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 {
@@ -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 { hardcodedText, isReferenced, keyRefs, looksLikeCode, scan, sourceFiles, type Hardcoded, type KeyRefs } from './scan.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';
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, or on these surfaces; an entry that says neither is skipped. Only
41
- * the source unless `locale` says otherwise, since most writing rules are written for one language:
42
- * `all` reads every locale, and a list those languages and their regions (`de` covers `de-CH`).
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
- /** The singular of an English plural noun, or nothing where it doesn't look like one. */
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. Which stop is the arm's own: a
327
- * question in `one` and a second sentence after it in `other` both end well.
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
- return text.replaceAll(HOLE, '').trim() === '' ? undefined : hasStop(text);
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, severity }] of rules) {
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 (rule.severity === 'off') {
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` });