prosebook 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +604 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +508 -0
- package/dist/cli.js.map +1 -0
- package/dist/config-D--RITk2.js +1208 -0
- package/dist/config-D--RITk2.js.map +1 -0
- package/dist/config-_4HfnJQu.d.ts +517 -0
- package/dist/config-_4HfnJQu.d.ts.map +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +1 -0
- package/dist/copy-DXnzXsJa.d.ts +162 -0
- package/dist/copy-DXnzXsJa.d.ts.map +1 -0
- package/dist/helpers-BPuPGjQP.js +61 -0
- package/dist/helpers-BPuPGjQP.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +188 -0
- package/dist/index.js.map +1 -0
- package/dist/inspector.d.ts +37 -0
- package/dist/inspector.d.ts.map +1 -0
- package/dist/inspector.js +572 -0
- package/dist/inspector.js.map +1 -0
- package/dist/react/index.d.ts +37 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +86 -0
- package/dist/react/index.js.map +1 -0
- package/dist/sarif-CI8HOokm.js +1033 -0
- package/dist/sarif-CI8HOokm.js.map +1 -0
- package/dist/scan-Ch2BfIUD.js +2561 -0
- package/dist/scan-Ch2BfIUD.js.map +1 -0
- package/dist/tools.d.ts +377 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +4 -0
- package/dist/vite.d.ts +24 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +264 -0
- package/dist/vite.js.map +1 -0
- package/package.json +148 -15
- package/src/cli.ts +528 -0
- package/src/index.ts +31 -0
- package/src/inspector-dialog.ts +450 -0
- package/src/inspector.ts +228 -0
- package/src/react/index.tsx +112 -0
- package/src/runtime/copy.ts +221 -0
- package/src/runtime/helpers.ts +66 -0
- package/src/runtime/locale.ts +49 -0
- package/src/runtime/types.ts +106 -0
- package/src/tools/catalog.ts +449 -0
- package/src/tools/compile.ts +121 -0
- package/src/tools/config.ts +211 -0
- package/src/tools/diff.ts +84 -0
- package/src/tools/entry.ts +325 -0
- package/src/tools/extract.ts +694 -0
- package/src/tools/generate.ts +257 -0
- package/src/tools/icu.ts +211 -0
- package/src/tools/index.ts +91 -0
- package/src/tools/lint.ts +865 -0
- package/src/tools/lock.ts +95 -0
- package/src/tools/mf1.ts +246 -0
- package/src/tools/mf2.ts +752 -0
- package/src/tools/migrate.ts +138 -0
- package/src/tools/presets.ts +159 -0
- package/src/tools/pseudo.ts +40 -0
- package/src/tools/sarif.ts +80 -0
- package/src/tools/scan.ts +303 -0
- package/src/tools/syntax.ts +24 -0
- package/src/tools/types.ts +137 -0
- package/src/vite.ts +296 -0
- package/index.js +0 -1
|
@@ -0,0 +1,1208 @@
|
|
|
1
|
+
import { isAbsolute, join, resolve } from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { TYPE as TYPE$1, isDateElement, isNumberElement, isTimeElement, parse } from "@formatjs/icu-messageformat-parser";
|
|
5
|
+
// src/tools/icu.ts
|
|
6
|
+
/**
|
|
7
|
+
* What an ICU message declares, read off its AST: argument types, tags, and whether each number and
|
|
8
|
+
* date style is one the formatter honors. Shared by the loader, the compiler and the lint.
|
|
9
|
+
*/
|
|
10
|
+
function parseMessage(text) {
|
|
11
|
+
const ast = parse(text, {
|
|
12
|
+
shouldParseSkeletons: true,
|
|
13
|
+
requiresOtherClause: true
|
|
14
|
+
});
|
|
15
|
+
walk(ast, (el) => {
|
|
16
|
+
if ((isDateElement(el) || isTimeElement(el)) && typeof el.style === "object" && el.style !== null && el.style.pattern.includes("j")) el.style.parsedOptions = localHour(el.style.pattern);
|
|
17
|
+
});
|
|
18
|
+
return ast;
|
|
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) {
|
|
26
|
+
const run = /j+/.exec(pattern)[0];
|
|
27
|
+
const rest = pattern.replaceAll("j", "");
|
|
28
|
+
return {
|
|
29
|
+
...rest === "" ? {} : dateFields(rest),
|
|
30
|
+
hour: run.length % 2 === 0 ? "2-digit" : "numeric"
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** What the parser reads from a date skeleton on its own; nothing where it can't parse it. */
|
|
34
|
+
function dateFields(skeleton) {
|
|
35
|
+
try {
|
|
36
|
+
const el = parse(`{d, date, ::${skeleton}}`, { shouldParseSkeletons: true })[0];
|
|
37
|
+
return el !== void 0 && isDateElement(el) && typeof el.style === "object" && el.style !== null ? el.style.parsedOptions : {};
|
|
38
|
+
} catch {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* ICU argument types, as TypeScript. A bare `{x}` is a string: a number spliced in raw is printed by
|
|
44
|
+
* `String()`, which knows no locale, so a quantity says `{x, number}` and the type is what asks for it.
|
|
45
|
+
* A `select` narrows to the arms it names, and still takes any string, since `other` catches the rest.
|
|
46
|
+
*/
|
|
47
|
+
function argType(el) {
|
|
48
|
+
switch (el.type) {
|
|
49
|
+
case TYPE$1.argument: return "string";
|
|
50
|
+
case TYPE$1.number:
|
|
51
|
+
case TYPE$1.plural: return "number";
|
|
52
|
+
case TYPE$1.date:
|
|
53
|
+
case TYPE$1.time: return "Date | number";
|
|
54
|
+
case TYPE$1.select: {
|
|
55
|
+
const named = Object.keys(el.options).filter((o) => o !== "other");
|
|
56
|
+
return named.length > 0 ? `${named.map((o) => JSON.stringify(o)).join(" | ")} | (string & {})` : "string";
|
|
57
|
+
}
|
|
58
|
+
case TYPE$1.literal:
|
|
59
|
+
case TYPE$1.pound:
|
|
60
|
+
case TYPE$1.tag:
|
|
61
|
+
default: return;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Calls `visit` on every element, inside plural and select arms and tags too. */
|
|
65
|
+
function walk(elements, visit) {
|
|
66
|
+
for (const el of elements) {
|
|
67
|
+
visit(el);
|
|
68
|
+
if (el.type === TYPE$1.select || el.type === TYPE$1.plural) for (const option of Object.values(el.options)) walk(option.value, visit);
|
|
69
|
+
if (el.type === TYPE$1.tag) walk(el.children, visit);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function argsOf(elements, clash = () => {}) {
|
|
73
|
+
const args = /* @__PURE__ */ new Map();
|
|
74
|
+
walk(elements, (el) => {
|
|
75
|
+
const type = argType(el);
|
|
76
|
+
if (type === void 0 || !("value" in el) || typeof el.value !== "string") return;
|
|
77
|
+
const seen = args.get(el.value);
|
|
78
|
+
if (seen !== void 0 && seen !== type) clash(el.value);
|
|
79
|
+
else args.set(el.value, type);
|
|
80
|
+
});
|
|
81
|
+
return args;
|
|
82
|
+
}
|
|
83
|
+
function tagsOf(elements) {
|
|
84
|
+
const tags = /* @__PURE__ */ new Set();
|
|
85
|
+
walk(elements, (el) => {
|
|
86
|
+
if (el.type === TYPE$1.tag) tags.add(el.value);
|
|
87
|
+
});
|
|
88
|
+
return [...tags].sort();
|
|
89
|
+
}
|
|
90
|
+
/** A message with no placeholders and no tags renders to one literal, which ships as a plain string. */
|
|
91
|
+
function staticLiteral(ast) {
|
|
92
|
+
if (ast.length === 0) return "";
|
|
93
|
+
if (ast.length === 1 && ast[0].type === TYPE$1.literal) return ast[0].value;
|
|
94
|
+
}
|
|
95
|
+
const NUMBER_STYLES = {
|
|
96
|
+
integer: { maximumFractionDigits: 0 },
|
|
97
|
+
percent: { style: "percent" }
|
|
98
|
+
};
|
|
99
|
+
const DATE_STYLES = [
|
|
100
|
+
"short",
|
|
101
|
+
"medium",
|
|
102
|
+
"long",
|
|
103
|
+
"full"
|
|
104
|
+
];
|
|
105
|
+
/** The `Intl` options a number element formats with. Throws, with the fix, where the style is wrong. */
|
|
106
|
+
function numberOptions(el) {
|
|
107
|
+
const style = el.style;
|
|
108
|
+
if (style === null || style === void 0) return void 0;
|
|
109
|
+
if (typeof style === "string") {
|
|
110
|
+
if (style === "currency") throw new Error(`{${el.value}} needs its currency named: {${el.value}, number, ::currency/EUR}`);
|
|
111
|
+
const named = NUMBER_STYLES[style];
|
|
112
|
+
if (named === void 0) throw new Error(`{${el.value}} asks for number style "${style}": integer, percent, or a ::skeleton`);
|
|
113
|
+
return named;
|
|
114
|
+
}
|
|
115
|
+
const unknown = style.tokens.filter((t) => !stemIsKnown(t.stem, t.options)).map((t) => t.stem);
|
|
116
|
+
if (unknown.length > 0) throw new Error(`{${el.value}} has skeleton stems the formatter drops without a word: ${unknown.join(", ")}`);
|
|
117
|
+
const options = style.parsedOptions;
|
|
118
|
+
try {
|
|
119
|
+
new Intl.NumberFormat("en", options).resolvedOptions();
|
|
120
|
+
} catch (e) {
|
|
121
|
+
throw new Error(`{${el.value}} has a skeleton Intl refuses: ${e.message}`, { cause: e });
|
|
122
|
+
}
|
|
123
|
+
return options;
|
|
124
|
+
}
|
|
125
|
+
/** The `Intl` options a date or time element formats with. Throws, with the fix, where the style is wrong. */
|
|
126
|
+
function dateOptions(el) {
|
|
127
|
+
const style = el.style;
|
|
128
|
+
const field = el.type === TYPE$1.time ? "timeStyle" : "dateStyle";
|
|
129
|
+
if (style === null || style === void 0) return el.type === TYPE$1.time ? { timeStyle: "short" } : void 0;
|
|
130
|
+
if (typeof style === "string") {
|
|
131
|
+
if (!DATE_STYLES.includes(style)) {
|
|
132
|
+
const kind = el.type === TYPE$1.time ? "time" : "date";
|
|
133
|
+
throw new Error(`{${el.value}} asks for ${kind} style "${style}": ${DATE_STYLES.join(", ")}, or a ::skeleton`);
|
|
134
|
+
}
|
|
135
|
+
return { [field]: style };
|
|
136
|
+
}
|
|
137
|
+
const dropped = (style.pattern.match(/([A-Za-z])*/g) ?? []).filter((f) => !f.startsWith("j") && Object.keys(dateFields(f)).length === 0);
|
|
138
|
+
if (dropped.length > 0) throw new Error(`{${el.value}} has date fields the formatter drops without a word: ${dropped.join(", ")}`);
|
|
139
|
+
if (Object.keys(style.parsedOptions).length === 0) throw new Error(`{${el.value}} has a date skeleton the formatter reads nothing from: "${style.pattern}"`);
|
|
140
|
+
try {
|
|
141
|
+
new Intl.DateTimeFormat("en", style.parsedOptions).resolvedOptions();
|
|
142
|
+
} catch (e) {
|
|
143
|
+
throw new Error(`{${el.value}} has a skeleton Intl refuses: ${e.message}`, { cause: e });
|
|
144
|
+
}
|
|
145
|
+
return style.parsedOptions;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Whether the formatter understands a skeleton stem. One it does not is dropped without a word, so
|
|
149
|
+
* `::compact-shortt` reaches the screen as `5` where `5K` was written. Every stem it understands sets
|
|
150
|
+
* at least one option, so an empty result is the answer.
|
|
151
|
+
*/
|
|
152
|
+
function stemIsKnown(stem, options) {
|
|
153
|
+
try {
|
|
154
|
+
const el = parse(`{n, number, ::${[stem, ...options].join("/")}}`, { shouldParseSkeletons: true })[0];
|
|
155
|
+
if (el === void 0 || !isNumberElement(el) || typeof el.style !== "object" || el.style === null) return false;
|
|
156
|
+
return Object.keys(el.style.parsedOptions).length > 0;
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
/** Every format problem in a message, as sentences. */
|
|
162
|
+
function formatProblems(ast) {
|
|
163
|
+
const found = [];
|
|
164
|
+
walk(ast, (el) => {
|
|
165
|
+
try {
|
|
166
|
+
if (isNumberElement(el)) numberOptions(el);
|
|
167
|
+
if (isDateElement(el) || isTimeElement(el)) dateOptions(el);
|
|
168
|
+
} catch (e) {
|
|
169
|
+
found.push(e.message);
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
return found;
|
|
173
|
+
}
|
|
174
|
+
/** How each number or date argument is formatted, for the generated doc comment: `date medium, time short`. */
|
|
175
|
+
function formatsOf(ast) {
|
|
176
|
+
const formats = /* @__PURE__ */ new Map();
|
|
177
|
+
walk(ast, (el) => {
|
|
178
|
+
if (!isNumberElement(el) && !isDateElement(el) && !isTimeElement(el)) return;
|
|
179
|
+
const style = el.style;
|
|
180
|
+
if (style === null || style === void 0) return;
|
|
181
|
+
const kind = el.type === TYPE$1.number ? "number" : el.type === TYPE$1.date ? "date" : "time";
|
|
182
|
+
let said;
|
|
183
|
+
if (typeof style === "string") said = `${kind} \`${style}\``;
|
|
184
|
+
else if ("mf2" in style) {
|
|
185
|
+
const { name, options } = style.mf2;
|
|
186
|
+
said = `\`${[`:${name}`, ...Object.entries(options).map(([k, v]) => `${k}=${v}`)].join(" ")}\``;
|
|
187
|
+
} else if ("tokens" in style) said = `${kind} \`::${style.tokens.map((t) => [t.stem, ...t.options].join("/")).join(" ")}\``;
|
|
188
|
+
else said = `${kind} \`::${style.pattern}\``;
|
|
189
|
+
const list = formats.get(el.value) ?? [];
|
|
190
|
+
if (!list.includes(said)) list.push(said);
|
|
191
|
+
formats.set(el.value, list);
|
|
192
|
+
});
|
|
193
|
+
return new Map([...formats].map(([name, list]) => [name, list.join(", ")]));
|
|
194
|
+
}
|
|
195
|
+
// src/tools/lint.ts
|
|
196
|
+
/** Typed plugin, for `lint.plugins`. */
|
|
197
|
+
function definePlugin(plugin) {
|
|
198
|
+
return plugin;
|
|
199
|
+
}
|
|
200
|
+
/** Stands in for a placeholder in a reading, which no prose rule reads. */
|
|
201
|
+
const HOLE = "";
|
|
202
|
+
/** Tags whose content is code or a literal the reader types, not prose. */
|
|
203
|
+
const VERBATIM_TAGS = /* @__PURE__ */ new Set([
|
|
204
|
+
"code",
|
|
205
|
+
"mono",
|
|
206
|
+
"kbd",
|
|
207
|
+
"keycap",
|
|
208
|
+
"nums"
|
|
209
|
+
]);
|
|
210
|
+
function renderAs(elements, arm) {
|
|
211
|
+
let out = "";
|
|
212
|
+
for (const el of elements) if (el.type === TYPE$1.literal) out += el.value;
|
|
213
|
+
else if (el.type === TYPE$1.tag) out += VERBATIM_TAGS.has(el.value) ? "" : renderAs(el.children, arm);
|
|
214
|
+
else if (el.type === TYPE$1.plural || el.type === TYPE$1.select) {
|
|
215
|
+
const option = arm === null ? void 0 : el.options[arm] ?? el.options["other"];
|
|
216
|
+
out += option === void 0 ? "" : renderAs(option.value, arm);
|
|
217
|
+
} else out += "";
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
function readings(message) {
|
|
221
|
+
const literal = staticLiteral(message);
|
|
222
|
+
const ast = literal === void 0 ? message : [{
|
|
223
|
+
type: TYPE$1.literal,
|
|
224
|
+
value: literal
|
|
225
|
+
}];
|
|
226
|
+
const arms = /* @__PURE__ */ new Set();
|
|
227
|
+
const collect = (els) => {
|
|
228
|
+
for (const el of els) {
|
|
229
|
+
if (el.type === TYPE$1.plural || el.type === TYPE$1.select) for (const [name, option] of Object.entries(el.options)) {
|
|
230
|
+
arms.add(name);
|
|
231
|
+
collect(option.value);
|
|
232
|
+
}
|
|
233
|
+
if (el.type === TYPE$1.tag) collect(el.children);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
collect(ast);
|
|
237
|
+
const primary = renderAs(ast, "other");
|
|
238
|
+
return {
|
|
239
|
+
primary,
|
|
240
|
+
variants: [.../* @__PURE__ */ new Set([primary, ...[...arms].map((arm) => renderAs(ast, arm))])].map((v) => v.replace(/\u2019/g, "'")),
|
|
241
|
+
skeleton: renderAs(ast, null)
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/** What a reader sees end to end, less a placeholder appended at the very end. */
|
|
245
|
+
const prose = (text) => text.replace(new RegExp(`[\\s]+$`), "");
|
|
246
|
+
const FREE = new Set("a an the this that these those it its them your and or to of for by in on at from with".split(" "));
|
|
247
|
+
const tokens = (text) => text.split(/\s+/).filter((w) => /[\p{L}\p{N}]/u.test(w) || w.includes(""));
|
|
248
|
+
/** The words a budget counts: articles, short prepositions and asides in parentheses are free. */
|
|
249
|
+
function words(text) {
|
|
250
|
+
return tokens(text.replace(/\s*\([^)]*\)/g, "")).filter((w) => !FREE.has(w.toLowerCase().replace(/[^\p{L}]/gu, "")));
|
|
251
|
+
}
|
|
252
|
+
const ABBREVIATIONS = /\b(?:e\.g|i\.e|vs|etc)\.$/i;
|
|
253
|
+
function sentences(text) {
|
|
254
|
+
let count = 1;
|
|
255
|
+
const boundary = /[.!?…]+["')\]]*\s+(?=\S)/g;
|
|
256
|
+
for (let m = boundary.exec(text); m !== null; m = boundary.exec(text)) if (!ABBREVIATIONS.test(text.slice(0, m.index + m[0].trimEnd().length))) count += 1;
|
|
257
|
+
return count;
|
|
258
|
+
}
|
|
259
|
+
/** A word or phrase on its own, not inside a longer word or a hyphenated compound. */
|
|
260
|
+
function phrase(p) {
|
|
261
|
+
const escaped = p.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/ /g, "\\s+");
|
|
262
|
+
return new RegExp(`(?<![\\p{L}\\p{N}'-])${escaped}(?![\\p{L}\\p{N}'-])`, "iu");
|
|
263
|
+
}
|
|
264
|
+
/** The words and phrases of `list` that some reading carries. */
|
|
265
|
+
const listed = (variants, list) => list.filter((w) => variants.some((v) => phrase(w).test(v)));
|
|
266
|
+
const unquoted = (token) => token.replace(/^[("'“‘]+|[)"'”’.,:;!?]+$/g, "");
|
|
267
|
+
function capitalizedWord(text, proper) {
|
|
268
|
+
const parts = text.split(/\s+/).filter(Boolean);
|
|
269
|
+
for (let i = 1; i < parts.length; i++) {
|
|
270
|
+
const word = unquoted(parts[i]);
|
|
271
|
+
if (!/^\p{Lu}\p{Ll}/u.test(word) || proper.has(word)) continue;
|
|
272
|
+
if (/[.:?!]["')]*$/.test(parts[i - 1])) continue;
|
|
273
|
+
return word;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
/** Words a count is followed by that are not plural nouns, though they end in s. */
|
|
277
|
+
const NOT_PLURAL = /* @__PURE__ */ new Set([
|
|
278
|
+
"is",
|
|
279
|
+
"was",
|
|
280
|
+
"has",
|
|
281
|
+
"does",
|
|
282
|
+
"this",
|
|
283
|
+
"its",
|
|
284
|
+
"plus",
|
|
285
|
+
"minus",
|
|
286
|
+
"less",
|
|
287
|
+
"yes",
|
|
288
|
+
"us",
|
|
289
|
+
"as",
|
|
290
|
+
"bus",
|
|
291
|
+
"gas",
|
|
292
|
+
"news",
|
|
293
|
+
"series"
|
|
294
|
+
]);
|
|
295
|
+
/**
|
|
296
|
+
* The singular of an English plural noun, or nothing where it doesn't look like one. A word with no
|
|
297
|
+
* vowel is a unit or an abbreviation (`kbps`, `fps`, `hrs`), which reads the same after 1.
|
|
298
|
+
*/
|
|
299
|
+
function singularOf(word) {
|
|
300
|
+
const lower = word.toLowerCase();
|
|
301
|
+
if (word.length < 3 || !lower.endsWith("s") || lower.endsWith("ss") || NOT_PLURAL.has(lower)) return void 0;
|
|
302
|
+
if (!/[aeiouy]/.test(lower)) return void 0;
|
|
303
|
+
if (/[^aeiou]ies$/.test(lower)) return `${word.slice(0, -3)}y`;
|
|
304
|
+
if (/(?:s|x|z|ch|sh)es$/.test(lower)) return word.slice(0, -2);
|
|
305
|
+
return word.slice(0, -1);
|
|
306
|
+
}
|
|
307
|
+
const MINOR = new Set("a an the and but or nor for so yet as at by in of off on per to up via with".split(" "));
|
|
308
|
+
/** The first word out of `style` case, less those in `keep`. */
|
|
309
|
+
function outOfCase(text, style, keep) {
|
|
310
|
+
if (style === "sentence") return capitalizedWord(text, keep);
|
|
311
|
+
return text.split(/\s+/).map(unquoted).filter((w) => /\p{L}/u.test(w) && !keep.has(w)).find((w, i) => {
|
|
312
|
+
if (style === "lower") return /\p{Lu}/u.test(w);
|
|
313
|
+
if (style === "upper") return /\p{Ll}/u.test(w);
|
|
314
|
+
return /^\p{Ll}/u.test(w) && (i === 0 || !MINOR.has(w));
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
const CONTRACTIONS = [
|
|
318
|
+
[/\bcannot\b/i, "can't"],
|
|
319
|
+
[/\bcan not\b/i, "can't"],
|
|
320
|
+
[/\bwill not\b/i, "won't"],
|
|
321
|
+
[/\b(could|would|should|is|are|was|were|do|does|did|has|have|had|must|need) not\b/i, "n't"]
|
|
322
|
+
];
|
|
323
|
+
/** Calls `visit` on every run of elements that sit side by side, with the arguments a plural around it counts. */
|
|
324
|
+
function eachRun(elements, visit, counted = /* @__PURE__ */ new Set()) {
|
|
325
|
+
visit(elements, counted);
|
|
326
|
+
for (const el of elements) {
|
|
327
|
+
if (el.type === TYPE$1.plural) for (const o of Object.values(el.options)) eachRun(o.value, visit, /* @__PURE__ */ new Set([...counted, el.value]));
|
|
328
|
+
if (el.type === TYPE$1.select) for (const o of Object.values(el.options)) eachRun(o.value, visit, counted);
|
|
329
|
+
if (el.type === TYPE$1.tag) eachRun(el.children, visit, counted);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
/** A plain placeholder named as a count. A number placeholder is one whatever its name. */
|
|
333
|
+
const COUNT_NAME = /^(?:n|num|count|total|amount|quantity)$|(?:count|total)$/i;
|
|
334
|
+
/**
|
|
335
|
+
* Whether an arm ends with a stop; nothing for an arm with no words, or one that trails off in an
|
|
336
|
+
* ellipsis (`Saving…`, `Save as…`), which is neither. Which stop is the arm's own: a question in
|
|
337
|
+
* `one` and a second sentence after it in `other` both end well.
|
|
338
|
+
*/
|
|
339
|
+
function endsWithStop(arm) {
|
|
340
|
+
const text = prose(renderAs(arm, "other")).trim();
|
|
341
|
+
if (text.replaceAll("", "").trim() === "" || /(?:…|\.{3})["')”»」』]*$/u.test(text)) return void 0;
|
|
342
|
+
return hasStop(text);
|
|
343
|
+
}
|
|
344
|
+
/** Ends with a stop in any script (`。`, `؟`, `;` as Greek writes `?`), though not a comma. */
|
|
345
|
+
const hasStop = (text) => /(?![,,、،])[\p{Terminal_Punctuation}…]["')”»」』]*$/u.test(prose(text).trim());
|
|
346
|
+
const CURRENCY_BEFORE = /[$€£¥₹]\s?$/;
|
|
347
|
+
/**
|
|
348
|
+
* The ways a path through menus is written, the separator in a group: an arrow or `›` however
|
|
349
|
+
* spaced, and `>` or `/` only spaced between words or placeholders, with a capital after a slash,
|
|
350
|
+
* since "2 > 1" and "and / or" are no paths. The match takes the spaces around, so the fix spaces
|
|
351
|
+
* the separator.
|
|
352
|
+
*/
|
|
353
|
+
const PATH_SEPARATORS = new RegExp(String.raw`\s*(→|->|›)\s*|(?<=[\p{L}${""}])\s+(>)\s+(?=[\p{L}${""}])|(?<=\p{L})\s+(\/)\s+(?=\p{Lu})`, "gu");
|
|
354
|
+
const OFF_SCREEN = /* @__PURE__ */ new Set(["aria"]);
|
|
355
|
+
const an = (word = "string") => `${/^[aeiou]/i.test(word) ? "an" : "a"} ${word}`;
|
|
356
|
+
const CORE_RULES = {
|
|
357
|
+
whitespace: {
|
|
358
|
+
description: "No leading, trailing or doubled spaces.",
|
|
359
|
+
recommended: "error",
|
|
360
|
+
on: { locale: "all" },
|
|
361
|
+
check: ({ text }) => {
|
|
362
|
+
const message = "no leading, trailing or doubled spaces";
|
|
363
|
+
if (text.variants.some((v) => v.includes(" "))) return [{
|
|
364
|
+
message,
|
|
365
|
+
match: " ",
|
|
366
|
+
fix: " "
|
|
367
|
+
}];
|
|
368
|
+
return text.variants.some((v) => /^\s|\s$/.test(v.replaceAll("", "x"))) ? [message] : [];
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
"word-budget": {
|
|
372
|
+
description: "A kind with `maxWords` stays within it. Articles, short prepositions and asides in parentheses don't count.",
|
|
373
|
+
recommended: "warn",
|
|
374
|
+
check: ({ message: m, kind, text }) => {
|
|
375
|
+
if (kind?.maxWords === void 0 || OFF_SCREEN.has(m.surface ?? "")) return [];
|
|
376
|
+
const n = words(text.primary).length;
|
|
377
|
+
return n > kind.maxWords ? [`${an(m.kind)} is ${kind.maxWords} words at most, and this is ${n}`] : [];
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
"sentence-budget": {
|
|
381
|
+
description: "A kind with `maxSentences` stays within it.",
|
|
382
|
+
recommended: "warn",
|
|
383
|
+
check: ({ message: m, kind, text }) => {
|
|
384
|
+
const most = (m.surface !== void 0 ? kind?.maxSentencesOn?.[m.surface] : void 0) ?? kind?.maxSentences;
|
|
385
|
+
if (most === void 0) return [];
|
|
386
|
+
const n = sentences(prose(text.primary));
|
|
387
|
+
return n > most ? [`${an(m.kind)}${kind?.maxSentencesOn?.[m.surface ?? ""] !== void 0 ? ` on ${m.surface}` : ""} is ${most} sentence(s) at most, and this is ${n}`] : [];
|
|
388
|
+
}
|
|
389
|
+
},
|
|
390
|
+
period: {
|
|
391
|
+
description: "A kind with `period: always` (or on a listed surface) ends with a period, unless the entry is a fragment.",
|
|
392
|
+
recommended: "warn",
|
|
393
|
+
check: ({ message: m, kind, text }) => {
|
|
394
|
+
const rule = kind?.period;
|
|
395
|
+
return (rule === "always" || Array.isArray(rule) && rule.includes(m.surface ?? "")) && !m.fragment && !/[.?!…:]["')”]*$/.test(prose(text.primary)) ? [`${an(m.kind)} ends with a period, or is marked fragment: true`] : [];
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
"no-period": {
|
|
399
|
+
description: "A kind with `period: never` ends without one.",
|
|
400
|
+
recommended: "warn",
|
|
401
|
+
check: ({ message: m, kind, text }) => kind?.period === "never" && /[^.]\.$/.test(prose(text.primary)) ? [`${an(m.kind)} ends without a period`] : []
|
|
402
|
+
},
|
|
403
|
+
"bare-name": {
|
|
404
|
+
description: "A kind with `bare: true` carries no explanation after a dash or a colon.",
|
|
405
|
+
recommended: "warn",
|
|
406
|
+
check: ({ message: m, kind, text }) => kind?.bare === true && new RegExp(`\\s[—–-]\\s|\\p{L}:\\s(?!)`, "u").test(text.skeleton) ? [`${an(m.kind)} carries no explanation after a dash or a colon`] : []
|
|
407
|
+
},
|
|
408
|
+
"sentence-case": {
|
|
409
|
+
description: "A kind with `sentenceCase: true` capitalizes only its first word and proper nouns.",
|
|
410
|
+
check: ({ message: m, kind, text, config }) => {
|
|
411
|
+
if (kind?.sentenceCase !== true) return [];
|
|
412
|
+
const word = outOfCase(text.primary, "sentence", new Set(config.lint.properNouns));
|
|
413
|
+
if (word === void 0) return [];
|
|
414
|
+
return [{
|
|
415
|
+
message: `"${word}" is capitalized in a sentence-case ${m.kind}: add it to lint.properNouns if it is a name`,
|
|
416
|
+
match: word
|
|
417
|
+
}];
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
"banned-word": {
|
|
421
|
+
description: "A word or phrase from the banned lists.",
|
|
422
|
+
recommended: "warn",
|
|
423
|
+
check: ({ text, config }) => config.lint.banned.flatMap(({ words: list, why }) => listed(text.variants, list).map((w) => ({
|
|
424
|
+
message: `"${w}" ${why}`,
|
|
425
|
+
match: w
|
|
426
|
+
})))
|
|
427
|
+
},
|
|
428
|
+
spelling: {
|
|
429
|
+
description: "A spelling the product writes another way.",
|
|
430
|
+
recommended: "warn",
|
|
431
|
+
check: ({ text, config: { lint } }) => listed(text.variants, Object.keys(lint.spelling)).map((w) => ({
|
|
432
|
+
message: `"${w}" is spelled "${lint.spelling[w]}"`,
|
|
433
|
+
match: w,
|
|
434
|
+
fix: lint.spelling[w]
|
|
435
|
+
}))
|
|
436
|
+
},
|
|
437
|
+
"one-word": {
|
|
438
|
+
description: "A near-synonym for a concept the product has one word for.",
|
|
439
|
+
recommended: "warn",
|
|
440
|
+
check: ({ text, config: { lint } }) => listed(text.variants, Object.keys(lint.concepts)).map((w) => ({
|
|
441
|
+
message: `"${w}" is "${lint.concepts[w]}" everywhere in the product`,
|
|
442
|
+
match: w,
|
|
443
|
+
fix: lint.concepts[w]
|
|
444
|
+
}))
|
|
445
|
+
},
|
|
446
|
+
"no-em-dash": {
|
|
447
|
+
description: "No em-dashes, and no spaced hyphen standing in for one.",
|
|
448
|
+
check: ({ text }) => {
|
|
449
|
+
const match = text.variants.map((v) => /—|\s[–-]\s/.exec(v)?.[0]).find((m) => m !== void 0);
|
|
450
|
+
return match === void 0 ? [] : [{
|
|
451
|
+
message: "no em-dash: a comma, a period or a colon does the job",
|
|
452
|
+
match
|
|
453
|
+
}];
|
|
454
|
+
}
|
|
455
|
+
},
|
|
456
|
+
path: {
|
|
457
|
+
description: "A path through menus or settings is written with `lint.pathSeparator`, spaced: Settings › General.",
|
|
458
|
+
check: ({ text, config }) => {
|
|
459
|
+
const use = config.lint.pathSeparator;
|
|
460
|
+
for (const v of text.variants) for (const m of v.matchAll(PATH_SEPARATORS)) {
|
|
461
|
+
if ((m[1] ?? m[2] ?? m[3]) === use) continue;
|
|
462
|
+
return [{
|
|
463
|
+
message: `a path is written with a spaced ${use}`,
|
|
464
|
+
match: m[0],
|
|
465
|
+
fix: ` ${use} `
|
|
466
|
+
}];
|
|
467
|
+
}
|
|
468
|
+
return [];
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
contractions: {
|
|
472
|
+
description: "`could not`, `is not` and `cannot` are written as contractions.",
|
|
473
|
+
check: ({ text }) => CONTRACTIONS.flatMap(([pattern, short]) => {
|
|
474
|
+
const found = text.variants.map((v) => pattern.exec(v)).find((f) => f !== null);
|
|
475
|
+
if (found === void 0 || found === null) return [];
|
|
476
|
+
const fix = short === "n't" ? `${found[1]}n't` : short;
|
|
477
|
+
return [{
|
|
478
|
+
message: `"${found[0]}" is written as a contraction (${short})`,
|
|
479
|
+
match: found[0],
|
|
480
|
+
fix
|
|
481
|
+
}];
|
|
482
|
+
})
|
|
483
|
+
},
|
|
484
|
+
ellipsis: {
|
|
485
|
+
description: "An ellipsis is one character, `…`, not three periods.",
|
|
486
|
+
on: { locale: "all" },
|
|
487
|
+
check: ({ text }) => text.variants.some((v) => v.includes("...")) ? [{
|
|
488
|
+
message: "an ellipsis is one character: …",
|
|
489
|
+
match: "...",
|
|
490
|
+
fix: "…"
|
|
491
|
+
}] : []
|
|
492
|
+
},
|
|
493
|
+
"plural-count": {
|
|
494
|
+
description: "A count followed by a plural noun, `{count} files`, is a plural, or it reads \"1 files\". English.",
|
|
495
|
+
recommended: "warn",
|
|
496
|
+
check: ({ ast }) => {
|
|
497
|
+
const said = [];
|
|
498
|
+
eachRun(ast, (run, counted) => run.forEach((el, i) => {
|
|
499
|
+
if (el.type !== TYPE$1.number && el.type !== TYPE$1.argument) return;
|
|
500
|
+
if (counted.has(el.value) || el.type === TYPE$1.argument && !COUNT_NAME.test(el.value)) return;
|
|
501
|
+
const next = run[i + 1];
|
|
502
|
+
const word = next?.type === TYPE$1.literal ? /^\s+(\p{L}+)(?![\p{L}'])/u.exec(next.value)?.[1] : void 0;
|
|
503
|
+
const one = word === void 0 ? void 0 : singularOf(word);
|
|
504
|
+
if (word !== void 0 && one !== void 0) said.push({
|
|
505
|
+
message: `${el.value} is followed by "${word}", which reads "1 ${word}": make it a plural with a "# ${one}" arm`,
|
|
506
|
+
match: word
|
|
507
|
+
});
|
|
508
|
+
}));
|
|
509
|
+
return said;
|
|
510
|
+
}
|
|
511
|
+
},
|
|
512
|
+
"plural-one": {
|
|
513
|
+
description: "A plural's `one` arm says something other than `other`, and says `#` rather than `1`.",
|
|
514
|
+
recommended: "warn",
|
|
515
|
+
on: { locale: "all" },
|
|
516
|
+
check: ({ ast, locale, config }) => {
|
|
517
|
+
const said = [];
|
|
518
|
+
walk(ast, (el) => {
|
|
519
|
+
if (el.type !== TYPE$1.plural || el.pluralType !== "cardinal") return;
|
|
520
|
+
const { one, other } = el.options;
|
|
521
|
+
if (one === void 0) return;
|
|
522
|
+
if (locale === config.source && other !== void 0 && renderAs(one.value, "other") === renderAs(other.value, "other")) said.push({ message: `the one and other arms of ${el.value} say the same: write the singular in one` });
|
|
523
|
+
let literalOne = false;
|
|
524
|
+
walk(one.value, (inner) => {
|
|
525
|
+
if (inner.type === TYPE$1.literal && /(?<![\d.,])1(?![\d.,])/.test(inner.value)) literalOne = true;
|
|
526
|
+
});
|
|
527
|
+
if (!literalOne) return;
|
|
528
|
+
const message = `the one arm of ${el.value} writes 1, which one is not in every language: write #, or make the arm =1`;
|
|
529
|
+
said.push({
|
|
530
|
+
message,
|
|
531
|
+
match: "1"
|
|
532
|
+
});
|
|
533
|
+
});
|
|
534
|
+
return said;
|
|
535
|
+
}
|
|
536
|
+
},
|
|
537
|
+
"arm-endings": {
|
|
538
|
+
description: "The arms of a plural or select end alike: all with a stop, or all without one.",
|
|
539
|
+
recommended: "warn",
|
|
540
|
+
on: { locale: "all" },
|
|
541
|
+
check: ({ ast }) => {
|
|
542
|
+
const said = [];
|
|
543
|
+
walk(ast, (el) => {
|
|
544
|
+
if (el.type !== TYPE$1.plural && el.type !== TYPE$1.select) return;
|
|
545
|
+
if (new Set(Object.values(el.options).flatMap((o) => endsWithStop(o.value) ?? [])).size > 1) said.push(`some arms of ${el.value} end with a stop and some without: end them alike`);
|
|
546
|
+
});
|
|
547
|
+
return said;
|
|
548
|
+
}
|
|
549
|
+
},
|
|
550
|
+
"formatted-by-hand": {
|
|
551
|
+
description: "A currency symbol or `%` typed beside a placeholder, or digits grouped by hand, which only suit one locale.",
|
|
552
|
+
recommended: "warn",
|
|
553
|
+
on: { locale: "all" },
|
|
554
|
+
check: ({ ast, text }) => {
|
|
555
|
+
const said = /* @__PURE__ */ new Map();
|
|
556
|
+
const say = (message, match) => said.set(message, {
|
|
557
|
+
message,
|
|
558
|
+
match
|
|
559
|
+
});
|
|
560
|
+
eachRun(ast, (run) => run.forEach((el, i) => {
|
|
561
|
+
if (el.type !== TYPE$1.number && el.type !== TYPE$1.argument) return;
|
|
562
|
+
const before = run[i - 1];
|
|
563
|
+
const after = run[i + 1];
|
|
564
|
+
const symbol = before?.type === TYPE$1.literal ? CURRENCY_BEFORE.exec(before.value)?.[0].trim() : void 0;
|
|
565
|
+
if (symbol !== void 0) say(`"${symbol}" is typed before ${el.value}: format it as a currency, which puts the symbol where the locale does`, symbol);
|
|
566
|
+
if (after?.type === TYPE$1.literal && /^\s?%/.test(after.value)) say(`"%" is typed after ${el.value}: format it as a percent, which takes the fraction (0.5 for 50%)`, "%");
|
|
567
|
+
}));
|
|
568
|
+
const grouped = text.variants.map((v) => /\d{1,3}(?:,\d{3})+(?!\d)/.exec(v)?.[0]).find((g) => g !== void 0);
|
|
569
|
+
if (grouped !== void 0) say(`"${grouped}" groups its digits the English way: make it a placeholder formatted as a number`, grouped);
|
|
570
|
+
return [...said.values()];
|
|
571
|
+
}
|
|
572
|
+
},
|
|
573
|
+
"source-ending": {
|
|
574
|
+
description: "A translation ends with a stop where its source does, and without one where it does not.",
|
|
575
|
+
recommended: "warn",
|
|
576
|
+
on: { locale: "all" },
|
|
577
|
+
check: ({ text, source }) => {
|
|
578
|
+
const ends = hasStop(text.primary);
|
|
579
|
+
if (ends === hasStop(source.primary)) return [];
|
|
580
|
+
return [ends ? "ends with a stop, and the source does not: drop it" : "the source ends with a stop, and this does not: end it as the source does"];
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
const fill = (message, values) => message.replace(/\{(\w+)\}/g, (all, name) => values[name] === void 0 ? all : String(values[name]));
|
|
585
|
+
const graphemes = new Intl.Segmenter();
|
|
586
|
+
const COUNTERS = {
|
|
587
|
+
words: (text) => tokens(text).length,
|
|
588
|
+
sentences: (text) => sentences(prose(text)),
|
|
589
|
+
characters: (text) => [...graphemes.segment(text.replaceAll("", ""))].length
|
|
590
|
+
};
|
|
591
|
+
/** A rule as code, checked once when the config loads: a JSON config is not type-checked. */
|
|
592
|
+
function compileRule(name, rule) {
|
|
593
|
+
const fail = (problem) => {
|
|
594
|
+
throw new Error(`lint rule ${name} ${problem}`);
|
|
595
|
+
};
|
|
596
|
+
const pattern = (source, field, ignoreCase) => {
|
|
597
|
+
if (typeof source !== "string") return fail(`${field} is not a string: write the pattern as one`);
|
|
598
|
+
try {
|
|
599
|
+
return new RegExp(source, ignoreCase === true ? "iu" : "u");
|
|
600
|
+
} catch (e) {
|
|
601
|
+
return fail(`${field} is not a regular expression: ${e instanceof Error ? e.message : String(e)}`);
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
const said = (message) => typeof message === "string" && message !== "" ? message : fail("has no message: say what to write instead");
|
|
605
|
+
if (typeof rule.description !== "string" || rule.description === "") fail("has no description: say what it holds a string to");
|
|
606
|
+
if ("check" in rule) return typeof rule.check === "function" ? rule : fail("has a check that is not a function");
|
|
607
|
+
const { description, recommended, strict, on } = rule;
|
|
608
|
+
const base = {
|
|
609
|
+
description,
|
|
610
|
+
recommended,
|
|
611
|
+
strict,
|
|
612
|
+
on
|
|
613
|
+
};
|
|
614
|
+
switch (rule.type) {
|
|
615
|
+
case "existence": {
|
|
616
|
+
const list = rule.words ?? [];
|
|
617
|
+
const patterns = (rule.patterns ?? []).map((p, i) => pattern(p, `patterns[${i}]`, rule.ignoreCase));
|
|
618
|
+
if (list.length + patterns.length === 0) fail("has no words or patterns: list what a string never says");
|
|
619
|
+
const message = said(rule.message);
|
|
620
|
+
return {
|
|
621
|
+
...base,
|
|
622
|
+
check: ({ text }) => {
|
|
623
|
+
const matched = patterns.flatMap((p) => text.variants.map((v) => p.exec(v)?.[0]).find((m) => m !== void 0) ?? []);
|
|
624
|
+
return [.../* @__PURE__ */ new Set([...listed(text.variants, list), ...matched])].map((match) => ({
|
|
625
|
+
message: fill(message, { match }),
|
|
626
|
+
match
|
|
627
|
+
}));
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
case "substitution": {
|
|
632
|
+
const swap = rule.swap ?? {};
|
|
633
|
+
if (Object.keys(swap).length === 0) fail("has nothing in swap: map a word to the one written instead");
|
|
634
|
+
const message = rule.message === void 0 ? "\"{match}\" is written \"{use}\"" : said(rule.message);
|
|
635
|
+
return {
|
|
636
|
+
...base,
|
|
637
|
+
check: ({ text }) => listed(text.variants, Object.keys(swap)).map((match) => ({
|
|
638
|
+
message: fill(message, {
|
|
639
|
+
match,
|
|
640
|
+
use: swap[match]
|
|
641
|
+
}),
|
|
642
|
+
match,
|
|
643
|
+
fix: swap[match]
|
|
644
|
+
}))
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
case "occurrence": {
|
|
648
|
+
const { count: unit, max, min, relative } = rule;
|
|
649
|
+
const counter = Object.hasOwn(COUNTERS, unit) ? COUNTERS[unit] : fail(`counts ${unit}: count words, sentences or characters`);
|
|
650
|
+
if (max === void 0 && min === void 0) fail("has no max or min: give it one");
|
|
651
|
+
if (rule.message !== void 0) said(rule.message);
|
|
652
|
+
return {
|
|
653
|
+
...base,
|
|
654
|
+
check: ({ text, source }) => {
|
|
655
|
+
const count = counter(text.primary);
|
|
656
|
+
const of = relative === true ? counter(source.primary) : 1;
|
|
657
|
+
const over = max !== void 0 && count > max * of;
|
|
658
|
+
const under = min !== void 0 && count < min * of;
|
|
659
|
+
if (!over && !under) return [];
|
|
660
|
+
const limit = over && max !== void 0 ? Math.floor(max * of) : Math.ceil((min ?? 0) * of);
|
|
661
|
+
const bound = over ? "at most" : "at least";
|
|
662
|
+
const fallback = relative === true ? `{limit} ${unit} ${bound} ({${over ? "max" : "min"}} times the source's {source}), and this is {count}` : `{${over ? "max" : "min"}} ${unit} ${bound}, and this is {count}`;
|
|
663
|
+
return [fill(rule.message ?? fallback, {
|
|
664
|
+
count,
|
|
665
|
+
max,
|
|
666
|
+
min,
|
|
667
|
+
source: relative === true ? of : void 0,
|
|
668
|
+
limit
|
|
669
|
+
})];
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
case "capitalization": {
|
|
674
|
+
const style = rule.case;
|
|
675
|
+
if (![
|
|
676
|
+
"sentence",
|
|
677
|
+
"title",
|
|
678
|
+
"lower",
|
|
679
|
+
"upper"
|
|
680
|
+
].includes(style)) fail(`has case ${style}: use sentence, title, lower or upper`);
|
|
681
|
+
const message = rule.message === void 0 ? `"{match}" is out of ${style} case: change it, or add it to exceptions if it is a name` : said(rule.message);
|
|
682
|
+
const exceptions = rule.exceptions ?? [];
|
|
683
|
+
return {
|
|
684
|
+
...base,
|
|
685
|
+
check: ({ text, config }) => {
|
|
686
|
+
const match = outOfCase(text.primary, style, /* @__PURE__ */ new Set([...config.lint.properNouns, ...exceptions]));
|
|
687
|
+
return match === void 0 ? [] : [{
|
|
688
|
+
message: fill(message, { match }),
|
|
689
|
+
match
|
|
690
|
+
}];
|
|
691
|
+
}
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
case "conditional": {
|
|
695
|
+
const when = rule.when === void 0 ? void 0 : pattern(rule.when, "when", rule.ignoreCase);
|
|
696
|
+
const required = pattern(rule.require, "require", rule.ignoreCase);
|
|
697
|
+
const message = said(rule.message);
|
|
698
|
+
return {
|
|
699
|
+
...base,
|
|
700
|
+
check: ({ text }) => {
|
|
701
|
+
for (const v of text.variants) {
|
|
702
|
+
const match = when === void 0 ? "" : when.exec(v)?.[0];
|
|
703
|
+
if (match !== void 0 && !required.test(v)) return [fill(message, { match })];
|
|
704
|
+
}
|
|
705
|
+
return [];
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
default: return fail(`has type ${String(rule.type)}: use existence, substitution, occurrence, capitalization or conditional, or give it a check`);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
/** A reason is a sentence a reviewer can check, not a shrug. */
|
|
713
|
+
function reasonProblem(why) {
|
|
714
|
+
if (why.split(/\s+/).length < 4) return "is four words at least: say why the rule is wrong here";
|
|
715
|
+
if (/^(?:intentional|needed|required|ok|fine|false positive|by design|legacy|todo)\b/i.test(why)) return "names the reason, not that there is one";
|
|
716
|
+
}
|
|
717
|
+
/** A locale is one a rule reads when it names its language, or the locale and a region of it. */
|
|
718
|
+
const readsLocale = (locales, locale, sourceLocale) => locales === void 0 ? locale === sourceLocale : locales === "all" || locales.some((l) => locale.toLowerCase() === l.toLowerCase() || locale.toLowerCase().startsWith(`${l.toLowerCase()}-`));
|
|
719
|
+
function applies(rule, message, locale, sourceLocale) {
|
|
720
|
+
const { kind, surface, area, locale: locales } = rule.on ?? {};
|
|
721
|
+
return readsLocale(locales, locale, sourceLocale) && (kind === void 0 || message.kind !== void 0 && kind.includes(message.kind)) && (surface === void 0 || message.surface !== void 0 && surface.includes(message.surface)) && (area === void 0 || area.includes(message.area));
|
|
722
|
+
}
|
|
723
|
+
/** A rule's severity for an entry: its area's, where `lint.areas` sets one. */
|
|
724
|
+
const severityOf = (config, name, message) => config.lint.areas[message.area]?.[name] ?? config.lint.rules[name]?.severity;
|
|
725
|
+
/**
|
|
726
|
+
* Where the message's literals are written in its text, each looked for after the one before. A
|
|
727
|
+
* literal escaped in the text (`''`, `\{`) isn't written as read, so it has no place, and nor does a
|
|
728
|
+
* finding in it.
|
|
729
|
+
*/
|
|
730
|
+
function literalsIn(ast, text) {
|
|
731
|
+
const found = [];
|
|
732
|
+
let from = 0;
|
|
733
|
+
walk(ast, (el) => {
|
|
734
|
+
if (el.type !== TYPE$1.literal || el.value === "") return;
|
|
735
|
+
const next = text.indexOf(el.value, from);
|
|
736
|
+
const start = next === -1 ? text.indexOf(el.value) : next;
|
|
737
|
+
if (start === -1) return;
|
|
738
|
+
found.push({
|
|
739
|
+
start,
|
|
740
|
+
value: el.value
|
|
741
|
+
});
|
|
742
|
+
from = start + el.value.length;
|
|
743
|
+
});
|
|
744
|
+
return found;
|
|
745
|
+
}
|
|
746
|
+
/** Every place `match` stands in the literals, in any case, whole where it starts or ends with a letter. */
|
|
747
|
+
function placesOf(match, literals) {
|
|
748
|
+
const body = match.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/ /g, "\\s+").replace(/'/g, "['’]");
|
|
749
|
+
const before = /^[\p{L}\p{N}]/u.test(match) ? "(?<![\\p{L}\\p{N}])" : "";
|
|
750
|
+
const after = /[\p{L}\p{N}]$/u.test(match) ? "(?![\\p{L}\\p{N}])" : "";
|
|
751
|
+
const pattern = new RegExp(`${before}${body}${after}`, "giu");
|
|
752
|
+
const places = /* @__PURE__ */ new Map();
|
|
753
|
+
for (const { start, value } of literals) for (const m of value.matchAll(pattern)) places.set(start + m.index, start + m.index + m[0].length);
|
|
754
|
+
return [...places].sort(([a], [b]) => a - b).map(([start, end]) => ({
|
|
755
|
+
start,
|
|
756
|
+
end
|
|
757
|
+
}));
|
|
758
|
+
}
|
|
759
|
+
/** A fix in the case of what it replaces: "Colour" becomes "Color". */
|
|
760
|
+
const inCaseOf = (found, fix) => /^\p{Lu}/u.test(found) && /^\p{Ll}/u.test(fix) ? fix.charAt(0).toUpperCase() + fix.slice(1) : fix;
|
|
761
|
+
/** `text` with the edits made, leaving out any that overlaps one before it. */
|
|
762
|
+
function applyEdits(text, edits) {
|
|
763
|
+
let out = "";
|
|
764
|
+
let at = 0;
|
|
765
|
+
for (const edit of [...edits].sort((a, b) => a.start - b.start)) {
|
|
766
|
+
if (edit.start < at) continue;
|
|
767
|
+
out += text.slice(at, edit.start) + edit.text;
|
|
768
|
+
at = edit.end;
|
|
769
|
+
}
|
|
770
|
+
return out + text.slice(at);
|
|
771
|
+
}
|
|
772
|
+
function lint(messages, config) {
|
|
773
|
+
const findings = [];
|
|
774
|
+
const rules = Object.entries(config.lint.rules);
|
|
775
|
+
for (const message of messages) {
|
|
776
|
+
const kind = message.kind === void 0 ? void 0 : config.kinds[message.kind];
|
|
777
|
+
const source = readings(message.ast);
|
|
778
|
+
const locales = [{
|
|
779
|
+
locale: config.source,
|
|
780
|
+
ast: message.ast,
|
|
781
|
+
written: message.text,
|
|
782
|
+
file: message.file
|
|
783
|
+
}, ...[...message.translations].map(([locale, t]) => ({
|
|
784
|
+
locale,
|
|
785
|
+
ast: t.ast,
|
|
786
|
+
written: t.text,
|
|
787
|
+
file: t.file
|
|
788
|
+
}))];
|
|
789
|
+
const fired = /* @__PURE__ */ new Set();
|
|
790
|
+
for (const { locale, ast, written, file } of locales) {
|
|
791
|
+
const context = {
|
|
792
|
+
message,
|
|
793
|
+
locale,
|
|
794
|
+
ast,
|
|
795
|
+
kind,
|
|
796
|
+
text: locale === config.source ? source : readings(ast),
|
|
797
|
+
source,
|
|
798
|
+
config
|
|
799
|
+
};
|
|
800
|
+
let literals;
|
|
801
|
+
for (const [name, { rule }] of rules) {
|
|
802
|
+
const severity = severityOf(config, name, message);
|
|
803
|
+
if (severity === "off" || !applies(rule, message, locale, config.source)) continue;
|
|
804
|
+
let said;
|
|
805
|
+
try {
|
|
806
|
+
said = rule.check(context);
|
|
807
|
+
} catch (cause) {
|
|
808
|
+
throw new Error(`lint rule ${name} failed on ${message.key} in ${locale}: fix or turn off the rule`, { cause });
|
|
809
|
+
}
|
|
810
|
+
if (said.length === 0) continue;
|
|
811
|
+
fired.add(name);
|
|
812
|
+
if (message.allow.has(name)) continue;
|
|
813
|
+
for (const s of said) {
|
|
814
|
+
const { message: text, match, fix } = typeof s === "string" ? { message: s } : s;
|
|
815
|
+
const finding = {
|
|
816
|
+
key: message.key,
|
|
817
|
+
file,
|
|
818
|
+
locale,
|
|
819
|
+
rule: name,
|
|
820
|
+
severity,
|
|
821
|
+
message: text
|
|
822
|
+
};
|
|
823
|
+
if (match !== void 0 && match !== "") {
|
|
824
|
+
literals ??= literalsIn(ast, written);
|
|
825
|
+
const places = placesOf(match, literals);
|
|
826
|
+
if (places.length > 0) finding.at = places[0];
|
|
827
|
+
if (places.length > 0 && fix !== void 0) finding.fix = places.map((p) => ({
|
|
828
|
+
...p,
|
|
829
|
+
text: inCaseOf(written.slice(p.start, p.end), fix)
|
|
830
|
+
}));
|
|
831
|
+
}
|
|
832
|
+
findings.push(finding);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
for (const [name, why] of message.allow) {
|
|
837
|
+
const at = {
|
|
838
|
+
key: message.key,
|
|
839
|
+
file: message.file,
|
|
840
|
+
locale: config.source,
|
|
841
|
+
rule: "allow",
|
|
842
|
+
severity: "error"
|
|
843
|
+
};
|
|
844
|
+
if (config.lint.rules[name] === void 0) findings.push({
|
|
845
|
+
...at,
|
|
846
|
+
message: `allow.${name} is not a rule: ${rules.map(([n]) => n).join(", ")}`
|
|
847
|
+
});
|
|
848
|
+
else if (severityOf(config, name, message) === "off") continue;
|
|
849
|
+
else if (!fired.has(name)) findings.push({
|
|
850
|
+
...at,
|
|
851
|
+
message: `allow.${name} exempts the entry from a rule it passes: remove it`
|
|
852
|
+
});
|
|
853
|
+
else {
|
|
854
|
+
const problem = reasonProblem(why);
|
|
855
|
+
if (problem !== void 0) findings.push({
|
|
856
|
+
...at,
|
|
857
|
+
message: `allow.${name} ${problem}`
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
return findings;
|
|
863
|
+
}
|
|
864
|
+
// src/tools/presets.ts
|
|
865
|
+
const DEFAULT_KINDS = {
|
|
866
|
+
name: {
|
|
867
|
+
doc: "Identifies a thing so it can be found again. One to four words, a noun phrase, no period.",
|
|
868
|
+
maxWords: 4,
|
|
869
|
+
period: "never",
|
|
870
|
+
sentenceCase: true,
|
|
871
|
+
bare: true
|
|
872
|
+
},
|
|
873
|
+
action: {
|
|
874
|
+
doc: "Says what pressing it does. One to three words, verb first, no period.",
|
|
875
|
+
maxWords: 3,
|
|
876
|
+
period: "never",
|
|
877
|
+
sentenceCase: true
|
|
878
|
+
},
|
|
879
|
+
description: {
|
|
880
|
+
doc: "Adds only what the name cannot carry. One sentence, two at most, with a period.",
|
|
881
|
+
maxSentences: 2,
|
|
882
|
+
period: "always"
|
|
883
|
+
},
|
|
884
|
+
state: {
|
|
885
|
+
doc: "Reports what is true right now. One sentence, present tense; on an error or empty state, one more saying what to do.",
|
|
886
|
+
maxSentences: 1,
|
|
887
|
+
maxSentencesOn: {
|
|
888
|
+
error: 2,
|
|
889
|
+
empty: 2,
|
|
890
|
+
hint: 2,
|
|
891
|
+
status: 2
|
|
892
|
+
},
|
|
893
|
+
period: [
|
|
894
|
+
"hint",
|
|
895
|
+
"status",
|
|
896
|
+
"error",
|
|
897
|
+
"empty",
|
|
898
|
+
"confirm"
|
|
899
|
+
]
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
const DEFAULT_SURFACES = {
|
|
903
|
+
action: "The text of a button or a menu entry.",
|
|
904
|
+
aria: "Reached only by a screen reader, so nothing on screen carries it.",
|
|
905
|
+
choice: "One option among a set offered together.",
|
|
906
|
+
confirm: "The question of a confirmation, or the verb that answers it.",
|
|
907
|
+
empty: "Fills a list or a panel that has nothing in it.",
|
|
908
|
+
error: "Says what failed.",
|
|
909
|
+
heading: "Heads a section, group, dialog or list.",
|
|
910
|
+
hint: "A subordinate line under the thing it qualifies.",
|
|
911
|
+
label: "Names a control, a field, a section or a destination.",
|
|
912
|
+
placeholder: "Sits inside an empty field, and goes when typing starts.",
|
|
913
|
+
status: "Reports what is true right now, on a badge or beside a row.",
|
|
914
|
+
title: "The title of a page, window or document.",
|
|
915
|
+
toast: "A notification that comes and goes.",
|
|
916
|
+
tooltip: "Appears on hover."
|
|
917
|
+
};
|
|
918
|
+
/** The word lists of the strict preset: filler, marketing and reassurance, and American spelling. */
|
|
919
|
+
const STRICT_LISTS = {
|
|
920
|
+
banned: [
|
|
921
|
+
{
|
|
922
|
+
words: [
|
|
923
|
+
"seamless",
|
|
924
|
+
"seamlessly",
|
|
925
|
+
"effortless",
|
|
926
|
+
"effortlessly",
|
|
927
|
+
"hassle-free",
|
|
928
|
+
"frictionless"
|
|
929
|
+
],
|
|
930
|
+
why: "promises a feeling rather than saying what happens"
|
|
931
|
+
},
|
|
932
|
+
{
|
|
933
|
+
words: [
|
|
934
|
+
"leverage",
|
|
935
|
+
"leverages",
|
|
936
|
+
"leveraging",
|
|
937
|
+
"utilize",
|
|
938
|
+
"utilizes",
|
|
939
|
+
"utilizing",
|
|
940
|
+
"harness",
|
|
941
|
+
"take advantage of"
|
|
942
|
+
],
|
|
943
|
+
why: "is a long way to say use"
|
|
944
|
+
},
|
|
945
|
+
{
|
|
946
|
+
words: [
|
|
947
|
+
"delve",
|
|
948
|
+
"dive into",
|
|
949
|
+
"deep dive",
|
|
950
|
+
"embark",
|
|
951
|
+
"journey",
|
|
952
|
+
"tapestry",
|
|
953
|
+
"realm",
|
|
954
|
+
"landscape",
|
|
955
|
+
"testament",
|
|
956
|
+
"synergy",
|
|
957
|
+
"paradigm",
|
|
958
|
+
"holistic"
|
|
959
|
+
],
|
|
960
|
+
why: "is filler, not a thing on the screen"
|
|
961
|
+
},
|
|
962
|
+
{
|
|
963
|
+
words: [
|
|
964
|
+
"robust",
|
|
965
|
+
"powerful",
|
|
966
|
+
"cutting-edge",
|
|
967
|
+
"state-of-the-art",
|
|
968
|
+
"best-in-class",
|
|
969
|
+
"world-class",
|
|
970
|
+
"next-generation",
|
|
971
|
+
"revolutionary",
|
|
972
|
+
"game-changing"
|
|
973
|
+
],
|
|
974
|
+
why: "is marketing: say what it does"
|
|
975
|
+
},
|
|
976
|
+
{
|
|
977
|
+
words: [
|
|
978
|
+
"streamline",
|
|
979
|
+
"streamlined",
|
|
980
|
+
"empower",
|
|
981
|
+
"unlock",
|
|
982
|
+
"unleash",
|
|
983
|
+
"elevate",
|
|
984
|
+
"supercharge",
|
|
985
|
+
"boost",
|
|
986
|
+
"enhance",
|
|
987
|
+
"enhanced"
|
|
988
|
+
],
|
|
989
|
+
why: "claims an improvement without naming it"
|
|
990
|
+
},
|
|
991
|
+
{
|
|
992
|
+
words: [
|
|
993
|
+
"meticulous",
|
|
994
|
+
"crucial",
|
|
995
|
+
"pivotal",
|
|
996
|
+
"vital",
|
|
997
|
+
"paramount"
|
|
998
|
+
],
|
|
999
|
+
why: "tells the reader how to feel instead of what it is"
|
|
1000
|
+
},
|
|
1001
|
+
{
|
|
1002
|
+
words: [
|
|
1003
|
+
"comprehensive",
|
|
1004
|
+
"myriad",
|
|
1005
|
+
"plethora",
|
|
1006
|
+
"a variety of",
|
|
1007
|
+
"a wide range of"
|
|
1008
|
+
],
|
|
1009
|
+
why: "is padding: name the things, or say how many"
|
|
1010
|
+
},
|
|
1011
|
+
{
|
|
1012
|
+
words: [
|
|
1013
|
+
"furthermore",
|
|
1014
|
+
"moreover",
|
|
1015
|
+
"additionally",
|
|
1016
|
+
"in order to",
|
|
1017
|
+
"please note",
|
|
1018
|
+
"note that",
|
|
1019
|
+
"keep in mind",
|
|
1020
|
+
"rest assured"
|
|
1021
|
+
],
|
|
1022
|
+
why: "is a connective a short string has no room for"
|
|
1023
|
+
},
|
|
1024
|
+
{
|
|
1025
|
+
words: [
|
|
1026
|
+
"intuitive",
|
|
1027
|
+
"sleek",
|
|
1028
|
+
"stunning",
|
|
1029
|
+
"beautiful",
|
|
1030
|
+
"delightful",
|
|
1031
|
+
"magical"
|
|
1032
|
+
],
|
|
1033
|
+
why: "grades the product instead of describing it"
|
|
1034
|
+
},
|
|
1035
|
+
{
|
|
1036
|
+
words: [
|
|
1037
|
+
"simply",
|
|
1038
|
+
"just",
|
|
1039
|
+
"easily",
|
|
1040
|
+
"easy",
|
|
1041
|
+
"quickly"
|
|
1042
|
+
],
|
|
1043
|
+
why: "says it is easy, which helps nobody it is not easy for"
|
|
1044
|
+
},
|
|
1045
|
+
{
|
|
1046
|
+
words: [
|
|
1047
|
+
"so you can",
|
|
1048
|
+
"allows you to",
|
|
1049
|
+
"lets you",
|
|
1050
|
+
"enables you to",
|
|
1051
|
+
"don't worry"
|
|
1052
|
+
],
|
|
1053
|
+
why: "justifies or reassures: state the fact"
|
|
1054
|
+
},
|
|
1055
|
+
{
|
|
1056
|
+
words: [
|
|
1057
|
+
"please",
|
|
1058
|
+
"oops",
|
|
1059
|
+
"whoops",
|
|
1060
|
+
"sorry"
|
|
1061
|
+
],
|
|
1062
|
+
why: "pleads or apologizes: say what failed and what to do"
|
|
1063
|
+
},
|
|
1064
|
+
{
|
|
1065
|
+
words: [
|
|
1066
|
+
"etc",
|
|
1067
|
+
"and more",
|
|
1068
|
+
"and so on"
|
|
1069
|
+
],
|
|
1070
|
+
why: "trails off: name the things or stop"
|
|
1071
|
+
}
|
|
1072
|
+
],
|
|
1073
|
+
spelling: {
|
|
1074
|
+
colour: "color",
|
|
1075
|
+
colours: "colors",
|
|
1076
|
+
behaviour: "behavior",
|
|
1077
|
+
favourite: "favorite",
|
|
1078
|
+
cancelled: "canceled",
|
|
1079
|
+
cancelling: "canceling",
|
|
1080
|
+
labelled: "labeled",
|
|
1081
|
+
centre: "center",
|
|
1082
|
+
licence: "license",
|
|
1083
|
+
grey: "gray",
|
|
1084
|
+
organise: "organize",
|
|
1085
|
+
recognise: "recognize",
|
|
1086
|
+
customise: "customize",
|
|
1087
|
+
optimise: "optimize",
|
|
1088
|
+
minimise: "minimize",
|
|
1089
|
+
maximise: "maximize",
|
|
1090
|
+
synchronise: "synchronize",
|
|
1091
|
+
authorise: "authorize",
|
|
1092
|
+
initialise: "initialize",
|
|
1093
|
+
analyse: "analyze",
|
|
1094
|
+
catalogue: "catalog",
|
|
1095
|
+
dialogue: "dialog",
|
|
1096
|
+
artefact: "artifact",
|
|
1097
|
+
judgement: "judgment",
|
|
1098
|
+
whilst: "while",
|
|
1099
|
+
amongst: "among"
|
|
1100
|
+
}
|
|
1101
|
+
};
|
|
1102
|
+
// src/tools/config.ts
|
|
1103
|
+
/**
|
|
1104
|
+
* `prosebook/config`: where the catalog lives, what an entry may say about itself, and how strictly
|
|
1105
|
+
* its words are checked. Written as `prosebook.config.ts` (or `.js`, `.mjs`, `.json`) at the project root.
|
|
1106
|
+
*/
|
|
1107
|
+
/** Typed config, for `prosebook.config.ts`. */
|
|
1108
|
+
function defineConfig(config) {
|
|
1109
|
+
return config;
|
|
1110
|
+
}
|
|
1111
|
+
const CONFIG_FILES = [
|
|
1112
|
+
"prosebook.config.ts",
|
|
1113
|
+
"prosebook.config.mts",
|
|
1114
|
+
"prosebook.config.js",
|
|
1115
|
+
"prosebook.config.mjs",
|
|
1116
|
+
"prosebook.config.json"
|
|
1117
|
+
];
|
|
1118
|
+
/** Finds and reads the config at `root`, or the defaults when there is none. */
|
|
1119
|
+
async function loadConfig(root, inline) {
|
|
1120
|
+
if (inline) return resolveConfig(root, inline);
|
|
1121
|
+
for (const name of CONFIG_FILES) {
|
|
1122
|
+
const path = join(root, name);
|
|
1123
|
+
if (!existsSync(path)) continue;
|
|
1124
|
+
if (name.endsWith(".json")) return resolveConfig(root, JSON.parse(readFileSync(path, "utf8")));
|
|
1125
|
+
return resolveConfig(root, (await import(pathToFileURL(path).href)).default ?? {});
|
|
1126
|
+
}
|
|
1127
|
+
return resolveConfig(root, {});
|
|
1128
|
+
}
|
|
1129
|
+
function resolveConfig(root, config) {
|
|
1130
|
+
const at = (path) => isAbsolute(path) ? path : resolve(root, path);
|
|
1131
|
+
const lint = config.lint ?? {};
|
|
1132
|
+
const strict = (lint.extends ?? "recommended") === "strict";
|
|
1133
|
+
const rules = lintRules(lint);
|
|
1134
|
+
for (const [area, severities] of Object.entries(lint.areas ?? {})) for (const name of Object.keys(severities)) if (!(name in rules)) throw new Error(`lint.areas.${area}.${name} is not a rule: name one of ${Object.keys(rules).join(", ")}`);
|
|
1135
|
+
return {
|
|
1136
|
+
root,
|
|
1137
|
+
catalog: at(config.catalog ?? "copy/catalog"),
|
|
1138
|
+
locales: at(config.locales ?? "copy/locales"),
|
|
1139
|
+
out: at(config.out ?? "src/copy"),
|
|
1140
|
+
lock: at(config.lock ?? "copy/lock.json"),
|
|
1141
|
+
source: config.source ?? "en",
|
|
1142
|
+
messageFormat: config.messageFormat ?? 1,
|
|
1143
|
+
bundled: config.bundled ?? [],
|
|
1144
|
+
pseudo: config.pseudo ?? false,
|
|
1145
|
+
stale: config.stale ?? "use",
|
|
1146
|
+
language: config.language ?? "ts",
|
|
1147
|
+
importExtension: config.importExtension ?? ".js",
|
|
1148
|
+
require: config.require ?? ["context"],
|
|
1149
|
+
kinds: config.kinds ?? DEFAULT_KINDS,
|
|
1150
|
+
surfaces: config.surfaces ?? DEFAULT_SURFACES,
|
|
1151
|
+
lint: {
|
|
1152
|
+
rules,
|
|
1153
|
+
areas: lint.areas ?? {},
|
|
1154
|
+
banned: [...strict ? STRICT_LISTS.banned : [], ...lint.banned ?? []],
|
|
1155
|
+
spelling: {
|
|
1156
|
+
...strict ? STRICT_LISTS.spelling : {},
|
|
1157
|
+
...lint.spelling
|
|
1158
|
+
},
|
|
1159
|
+
concepts: { ...lint.concepts },
|
|
1160
|
+
properNouns: lint.properNouns ?? [],
|
|
1161
|
+
pathSeparator: lint.pathSeparator ?? "›"
|
|
1162
|
+
},
|
|
1163
|
+
scan: {
|
|
1164
|
+
dirs: (config.scan?.dirs ?? ["src"]).map(at),
|
|
1165
|
+
extensions: config.scan?.extensions ?? [
|
|
1166
|
+
".ts",
|
|
1167
|
+
".tsx",
|
|
1168
|
+
".js",
|
|
1169
|
+
".jsx",
|
|
1170
|
+
".mts",
|
|
1171
|
+
".mjs"
|
|
1172
|
+
],
|
|
1173
|
+
attributes: config.scan?.attributes ?? [
|
|
1174
|
+
"alt",
|
|
1175
|
+
"title",
|
|
1176
|
+
"placeholder",
|
|
1177
|
+
"label",
|
|
1178
|
+
"aria-label",
|
|
1179
|
+
"aria-description",
|
|
1180
|
+
"aria-placeholder"
|
|
1181
|
+
]
|
|
1182
|
+
}
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
function lintRules(lint) {
|
|
1186
|
+
const base = lint.extends ?? "recommended";
|
|
1187
|
+
const all = Object.entries(CORE_RULES);
|
|
1188
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1189
|
+
for (const plugin of lint.plugins ?? []) {
|
|
1190
|
+
if (!/^[a-z][a-z0-9-]*$/i.test(plugin.name)) throw new Error(`lint.plugins: "${plugin.name}" is not a plugin name: use letters, digits and dashes`);
|
|
1191
|
+
if (seen.has(plugin.name)) throw new Error(`lint.plugins: "${plugin.name}" is added twice: remove one`);
|
|
1192
|
+
seen.add(plugin.name);
|
|
1193
|
+
for (const [name, rule] of Object.entries(plugin.rules)) {
|
|
1194
|
+
const full = `${plugin.name}/${name}`;
|
|
1195
|
+
all.push([full, compileRule(full, rule)]);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
const preset = (rule) => base === "none" ? "off" : base === "strict" ? rule.strict ?? "error" : rule.recommended ?? "off";
|
|
1199
|
+
const rules = Object.fromEntries(all.map(([name, rule]) => [name, {
|
|
1200
|
+
rule,
|
|
1201
|
+
severity: lint.rules?.[name] ?? preset(rule)
|
|
1202
|
+
}]));
|
|
1203
|
+
for (const name of Object.keys(lint.rules ?? {})) if (!(name in rules)) throw new Error(`lint.rules.${name} is not a rule: name one of ${Object.keys(rules).join(", ")}`);
|
|
1204
|
+
return rules;
|
|
1205
|
+
}
|
|
1206
|
+
export { numberOptions as C, walk as D, tagsOf as E, formatsOf as S, staticLiteral as T, words as _, DEFAULT_KINDS as a, dateOptions as b, CORE_RULES as c, compileRule as d, definePlugin as f, singularOf as g, sentences as h, resolveConfig as i, HOLE as l, phrase as m, defineConfig as n, DEFAULT_SURFACES as o, lint as p, loadConfig as r, STRICT_LISTS as s, CONFIG_FILES as t, applyEdits as u, TYPE$1 as v, parseMessage as w, formatProblems as x, argsOf as y };
|
|
1207
|
+
|
|
1208
|
+
//# sourceMappingURL=config-D--RITk2.js.map
|