@cosyte/script-utils 0.1.8 → 0.1.9
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/CHANGELOG.md +9 -0
- package/README.md +99 -3
- package/bin/cosyte-docs-content.js +31 -0
- package/bin/cosyte-phi-scan.js +7 -2
- package/bin/cosyte-ratchet.js +27 -0
- package/bin/script-utils.js +2 -0
- package/changes.d.ts +78 -0
- package/changes.js +269 -0
- package/docs-content.d.ts +182 -0
- package/docs-content.js +833 -0
- package/package.json +28 -5
- package/phi-scan.d.ts +4 -1
- package/phi-scan.js +86 -8
- package/ratchet.d.ts +127 -0
- package/ratchet.js +696 -0
package/docs-content.js
ADDED
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
// The docs-content structure check: the shape every library's `docs-content/` folder takes, read
|
|
2
|
+
// from the folder itself. The standard it checks is documentation/decisions/0006 in cosyte/config.
|
|
3
|
+
//
|
|
4
|
+
// WHAT IT CHECKS. The sidebar's shape (one `docs` sidebar: the overview, the categories in their
|
|
5
|
+
// fixed order, then the two quality pages), that every page is listed exactly once and every listed
|
|
6
|
+
// id is a page, that the required pages exist, the front matter of every page, file names, and that
|
|
7
|
+
// both quality pages carry the section their generated numbers go in.
|
|
8
|
+
//
|
|
9
|
+
// It also reads every link a page makes into the generated reference (`./api/<page>.md#<anchor>`):
|
|
10
|
+
// the page must exist under `api/`, and the anchor must be one a heading on it gets on the docs
|
|
11
|
+
// site. And it reads the "Current numbers" section of both quality pages, which holds only the
|
|
12
|
+
// placeholder until the release fills in the measured numbers.
|
|
13
|
+
//
|
|
14
|
+
// WHAT IT DOES NOT CHECK. Any other link, and images: the shared CI's docs-content link check
|
|
15
|
+
// resolves those. The pages under `api/` themselves: the reference generator writes them and checks
|
|
16
|
+
// them itself; here they are only read for their headings. Whether a sample runs: the sample runner
|
|
17
|
+
// in @cosyte/vitest-config does that. Whether the prose is good, or whether a page sits in the
|
|
18
|
+
// category its content belongs to: nothing here can judge either, so review does.
|
|
19
|
+
//
|
|
20
|
+
// Zero dependencies, like the rest of this package: the front matter is read by hand, for the one
|
|
21
|
+
// form the standard allows (one `key: value` per line), rather than by a YAML parser.
|
|
22
|
+
|
|
23
|
+
import { lstatSync, readFileSync, readdirSync } from "node:fs";
|
|
24
|
+
import { join, resolve } from "node:path";
|
|
25
|
+
|
|
26
|
+
/** The folder this check reads, at the repository root. */
|
|
27
|
+
export const DOCS_DIR = "docs-content";
|
|
28
|
+
|
|
29
|
+
/** The sidebar file inside it. */
|
|
30
|
+
export const SIDEBARS_FILE = "sidebars.json";
|
|
31
|
+
|
|
32
|
+
/** The folder the reference generator owns. Its pages are read only for their headings. */
|
|
33
|
+
export const GENERATED_DIR = "api";
|
|
34
|
+
|
|
35
|
+
/** How a link into the generated reference starts. */
|
|
36
|
+
export const REFERENCE_LINK_PREFIX = `./${GENERATED_DIR}/`;
|
|
37
|
+
|
|
38
|
+
/** The pages every library has, by id. */
|
|
39
|
+
export const REQUIRED_PAGES = Object.freeze([
|
|
40
|
+
"intro",
|
|
41
|
+
"installation",
|
|
42
|
+
"quickstart",
|
|
43
|
+
"troubleshooting",
|
|
44
|
+
"testing-and-quality",
|
|
45
|
+
"performance",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The top-level categories, in the only order they may appear. `first` is the page a category must
|
|
50
|
+
* open with; `required` says whether every library has it.
|
|
51
|
+
*/
|
|
52
|
+
export const CATEGORIES = Object.freeze([
|
|
53
|
+
Object.freeze({ label: "Installation", required: true, first: "installation" }),
|
|
54
|
+
Object.freeze({ label: "Quickstart", required: true, first: "quickstart" }),
|
|
55
|
+
Object.freeze({ label: "Tutorials", required: false, first: null }),
|
|
56
|
+
Object.freeze({ label: "How-to guides", required: true, first: null }),
|
|
57
|
+
Object.freeze({ label: "Explanation", required: true, first: null }),
|
|
58
|
+
Object.freeze({ label: "Reference", required: false, first: null }),
|
|
59
|
+
Object.freeze({ label: "Troubleshooting", required: true, first: "troubleshooting" }),
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
/** The two fixed pages, listed last at the top level of the sidebar, in this order. */
|
|
63
|
+
export const QUALITY_PAGES = Object.freeze(["testing-and-quality", "performance"]);
|
|
64
|
+
|
|
65
|
+
/** The heading of the section each quality page's generated numbers go in. */
|
|
66
|
+
export const NUMBERS_HEADING = "## Current numbers";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* What the "Current numbers" section holds in the repository: this text and nothing else, however
|
|
70
|
+
* its lines wrap. The release replaces the section with the numbers it measured.
|
|
71
|
+
*/
|
|
72
|
+
export const NUMBERS_PLACEHOLDER =
|
|
73
|
+
"The numbers for this section are measured on `main` at each release and filled in when the " +
|
|
74
|
+
"release is built. They are never typed by hand.";
|
|
75
|
+
|
|
76
|
+
/** The label the docs site gives the generated reference. A sidebar never writes it. */
|
|
77
|
+
export const RESERVED_LABEL = "API Reference";
|
|
78
|
+
|
|
79
|
+
/** The front matter keys every page carries. */
|
|
80
|
+
export const FRONT_MATTER_KEYS = Object.freeze(["id", "title", "sidebar_label", "description"]);
|
|
81
|
+
|
|
82
|
+
/** The front matter keys a page must not carry. */
|
|
83
|
+
export const FORBIDDEN_KEYS = Object.freeze(["sidebar_position"]);
|
|
84
|
+
|
|
85
|
+
/** The longest description, in characters: what a search result shows before it cuts the text. */
|
|
86
|
+
export const DESCRIPTION_LIMIT = 160;
|
|
87
|
+
|
|
88
|
+
/** A page's file name, without `.md`: lower-case words joined by single hyphens. */
|
|
89
|
+
export const PAGE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
90
|
+
|
|
91
|
+
/** Every rule a finding can carry, with what it requires. */
|
|
92
|
+
export const DOCS_CONTENT_RULES = Object.freeze({
|
|
93
|
+
sidebar: "sidebars.json holds one `docs` sidebar in the standard shape",
|
|
94
|
+
listing: "every page is listed in the sidebar exactly once, and every listed id is a page",
|
|
95
|
+
"required-page": "the pages every library has exist",
|
|
96
|
+
"front-matter":
|
|
97
|
+
"every page opens with front matter carrying id, title, sidebar_label, description",
|
|
98
|
+
"file-name": "pages are lower-case, hyphenated .md files at the top of docs-content/",
|
|
99
|
+
numbers: `both quality pages carry the "${NUMBERS_HEADING.slice(3)}" section, holding only the placeholder`,
|
|
100
|
+
"reference-link":
|
|
101
|
+
"a link into the generated reference names a page under api/ and, after #, a heading on it",
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
/** Raised when the check cannot read its input; the CLI turns it into exit 2. */
|
|
105
|
+
export class DocsContentRefusal extends Error {}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Read a page's front matter: the block between a first line `---` and the next `---`, one
|
|
109
|
+
* `key: value` per line. A value may be wrapped in double quotes (with `\"` and `\\` escapes) or in
|
|
110
|
+
* single quotes (with `''` for a quote).
|
|
111
|
+
*
|
|
112
|
+
* @param {string} text The page.
|
|
113
|
+
* @returns {{ fields: Map<string, { value: string, line: number }>, errors: { line: number, message: string }[], present: boolean }}
|
|
114
|
+
*/
|
|
115
|
+
export function parseFrontMatter(text) {
|
|
116
|
+
const lines = text.split(/\r?\n/);
|
|
117
|
+
const fields = new Map();
|
|
118
|
+
const errors = [];
|
|
119
|
+
if (lines[0] !== "---") return { fields, errors, present: false };
|
|
120
|
+
const end = lines.indexOf("---", 1);
|
|
121
|
+
if (end === -1) {
|
|
122
|
+
errors.push({ line: 1, message: "the front matter opens with --- and never closes" });
|
|
123
|
+
return { fields, errors, present: true };
|
|
124
|
+
}
|
|
125
|
+
for (let i = 1; i < end; i += 1) {
|
|
126
|
+
const line = lines[i];
|
|
127
|
+
if (line.trim() === "") continue;
|
|
128
|
+
const match = /^([a-z_]+):(?: (.*))?$/.exec(line);
|
|
129
|
+
if (match === null) {
|
|
130
|
+
errors.push({ line: i + 1, message: "is not one `key: value` line" });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const key = match[1];
|
|
134
|
+
const value = unquote(match[2] ?? "");
|
|
135
|
+
if (value === null) {
|
|
136
|
+
errors.push({ line: i + 1, message: `${key}: the quoted value is not closed` });
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (fields.has(key)) {
|
|
140
|
+
errors.push({ line: i + 1, message: `${key} appears twice` });
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
fields.set(key, { value, line: i + 1 });
|
|
144
|
+
}
|
|
145
|
+
return { fields, errors, present: true };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The value of one front matter line, unquoted.
|
|
150
|
+
*
|
|
151
|
+
* @param {string} raw The text after `key: `.
|
|
152
|
+
* @returns {string | null} The value, or null for a quote that is opened and not closed.
|
|
153
|
+
*/
|
|
154
|
+
function unquote(raw) {
|
|
155
|
+
const text = raw.trim();
|
|
156
|
+
if (text.startsWith('"')) {
|
|
157
|
+
if (text.length < 2 || !text.endsWith('"') || text.endsWith('\\"')) return null;
|
|
158
|
+
return text.slice(1, -1).replace(/\\(["\\])/g, "$1");
|
|
159
|
+
}
|
|
160
|
+
if (text.startsWith("'")) {
|
|
161
|
+
if (text.length < 2 || !text.endsWith("'")) return null;
|
|
162
|
+
return text.slice(1, -1).replaceAll("''", "'");
|
|
163
|
+
}
|
|
164
|
+
return text;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The id a sidebar item names, when it is a doc reference.
|
|
169
|
+
*
|
|
170
|
+
* @param {unknown} item A sidebar item.
|
|
171
|
+
* @returns {string | null} The id, or null when the item is not a doc reference.
|
|
172
|
+
*/
|
|
173
|
+
function docId(item) {
|
|
174
|
+
if (typeof item === "string") return item;
|
|
175
|
+
if (isRecord(item) && item.type === "doc" && typeof item.id === "string") return item.id;
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Whether a value is a plain object.
|
|
181
|
+
*
|
|
182
|
+
* @param {unknown} value Anything.
|
|
183
|
+
* @returns {value is Record<string, unknown>} True for a non-null, non-array object.
|
|
184
|
+
*/
|
|
185
|
+
function isRecord(value) {
|
|
186
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Check one category's items: doc references, or a category one level down whose items are doc
|
|
191
|
+
* references. Every id it reaches is pushed onto `ids`.
|
|
192
|
+
*
|
|
193
|
+
* @param {Record<string, unknown>} category The category.
|
|
194
|
+
* @param {string} pointer Where it sits, as a JSON pointer.
|
|
195
|
+
* @param {boolean} nested Whether it is already one level down.
|
|
196
|
+
* @param {string[]} ids Where the ids it reaches are collected.
|
|
197
|
+
* @param {(pointer: string, message: string) => void} report Records a finding.
|
|
198
|
+
*/
|
|
199
|
+
function checkCategory(category, pointer, nested, ids, report) {
|
|
200
|
+
if (category.label === RESERVED_LABEL) {
|
|
201
|
+
report(
|
|
202
|
+
pointer,
|
|
203
|
+
`the "${RESERVED_LABEL}" category is the docs site's own, made from api/: remove it`,
|
|
204
|
+
);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (!Array.isArray(category.items) || category.items.length === 0) {
|
|
208
|
+
report(pointer, `the category "${String(category.label)}" has no items`);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
category.items.forEach((item, index) => {
|
|
212
|
+
const at = `${pointer}/items/${index}`;
|
|
213
|
+
const id = docId(item);
|
|
214
|
+
if (id !== null) {
|
|
215
|
+
ids.push(id);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
if (!nested && isCategoryItem(item)) {
|
|
219
|
+
checkCategory(item, at, true, ids, report);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
report(
|
|
223
|
+
at,
|
|
224
|
+
nested
|
|
225
|
+
? "a category one level down holds doc ids only"
|
|
226
|
+
: "a category holds doc ids, or categories of doc ids one level down",
|
|
227
|
+
);
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Whether a sidebar item is a category with a string label.
|
|
233
|
+
*
|
|
234
|
+
* @param {unknown} item A sidebar item.
|
|
235
|
+
* @returns {item is { type: "category", label: string, items: unknown }} True for a category.
|
|
236
|
+
*/
|
|
237
|
+
function isCategoryItem(item) {
|
|
238
|
+
return isRecord(item) && item.type === "category" && typeof item.label === "string";
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Check the sidebar's shape and collect every id it lists.
|
|
243
|
+
*
|
|
244
|
+
* @param {unknown} sidebars The parsed sidebars.json.
|
|
245
|
+
* @param {(pointer: string, message: string) => void} report Records a finding.
|
|
246
|
+
* @returns {string[]} Every id the sidebar lists, in order, repeats kept.
|
|
247
|
+
*/
|
|
248
|
+
function checkSidebar(sidebars, report) {
|
|
249
|
+
const ids = [];
|
|
250
|
+
if (!isRecord(sidebars) || !Array.isArray(sidebars.docs)) {
|
|
251
|
+
report("", 'sidebars.json is not an object whose "docs" is an array');
|
|
252
|
+
return ids;
|
|
253
|
+
}
|
|
254
|
+
for (const key of Object.keys(sidebars)) {
|
|
255
|
+
if (key !== "docs") report(`/${key}`, `one sidebar, "docs": remove "${key}"`);
|
|
256
|
+
}
|
|
257
|
+
const items = sidebars.docs;
|
|
258
|
+
let index = 0;
|
|
259
|
+
if (docId(items[0]) === "intro") {
|
|
260
|
+
ids.push("intro");
|
|
261
|
+
index = 1;
|
|
262
|
+
} else {
|
|
263
|
+
report("/docs/0", 'the sidebar opens with the overview page, "intro"');
|
|
264
|
+
// Whatever stands in the overview's place is that one finding, not the start of the tail.
|
|
265
|
+
if (!isCategoryItem(items[0])) index = 1;
|
|
266
|
+
}
|
|
267
|
+
let last = -1;
|
|
268
|
+
const seen = new Set();
|
|
269
|
+
for (; isCategoryItem(items[index]); index += 1) {
|
|
270
|
+
const item = items[index];
|
|
271
|
+
const pointer = `/docs/${index}`;
|
|
272
|
+
const position = CATEGORIES.findIndex((category) => category.label === item.label);
|
|
273
|
+
if (position === -1) {
|
|
274
|
+
report(
|
|
275
|
+
pointer,
|
|
276
|
+
`"${item.label}" is not a category of the standard (${CATEGORIES.map((c) => c.label).join(", ")})`,
|
|
277
|
+
);
|
|
278
|
+
} else if (position <= last) {
|
|
279
|
+
report(
|
|
280
|
+
pointer,
|
|
281
|
+
`"${item.label}" is out of order: the categories run ${CATEGORIES.map((c) => c.label).join(", ")}`,
|
|
282
|
+
);
|
|
283
|
+
} else {
|
|
284
|
+
last = position;
|
|
285
|
+
seen.add(item.label);
|
|
286
|
+
const first = CATEGORIES[position].first;
|
|
287
|
+
if (first !== null && Array.isArray(item.items) && docId(item.items[0]) !== first) {
|
|
288
|
+
report(`${pointer}/items/0`, `"${item.label}" opens with the page "${first}"`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
checkCategory(item, pointer, false, ids, report);
|
|
292
|
+
}
|
|
293
|
+
for (const category of CATEGORIES) {
|
|
294
|
+
if (category.required && !seen.has(category.label)) {
|
|
295
|
+
report("/docs", `the category "${category.label}" is missing`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const tail = items.slice(index);
|
|
299
|
+
QUALITY_PAGES.forEach((page, offset) => {
|
|
300
|
+
const id = docId(tail[offset]);
|
|
301
|
+
if (id !== page) {
|
|
302
|
+
report(
|
|
303
|
+
`/docs/${index + offset}`,
|
|
304
|
+
`after the categories, the sidebar lists "${QUALITY_PAGES.join('" then "')}" and nothing else`,
|
|
305
|
+
);
|
|
306
|
+
} else {
|
|
307
|
+
ids.push(id);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
for (let offset = QUALITY_PAGES.length; offset < tail.length; offset += 1) {
|
|
311
|
+
const id = docId(tail[offset]);
|
|
312
|
+
if (id !== null) ids.push(id);
|
|
313
|
+
report(
|
|
314
|
+
`/docs/${index + offset}`,
|
|
315
|
+
`after the categories, the sidebar lists "${QUALITY_PAGES.join('" then "')}" and nothing else`,
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
return ids;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* A heading that ends the "Current numbers" section: one or two `#`, as the docs site reads it.
|
|
323
|
+
* A deeper heading is part of the section.
|
|
324
|
+
*/
|
|
325
|
+
const SECTION_END = /^ {0,3}#{1,2}(?:[ \t]|$)/;
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The text of the section a heading opens, up to the next heading of its level or above, with every
|
|
329
|
+
* run of whitespace read as one space, so a wrapped sentence reads as it does unwrapped.
|
|
330
|
+
*
|
|
331
|
+
* @param {string[]} lines The page's lines.
|
|
332
|
+
* @param {number} at The index of the heading.
|
|
333
|
+
* @returns {string} The section's text.
|
|
334
|
+
*/
|
|
335
|
+
function sectionText(lines, at) {
|
|
336
|
+
const body = lines.slice(at + 1);
|
|
337
|
+
const end = body.findIndex((line) => SECTION_END.test(line));
|
|
338
|
+
if (end !== -1) body.length = end;
|
|
339
|
+
return body.join(" ").replace(/\s+/g, " ").trim();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** A fence opener or closer: up to three spaces, then three or more backticks or tildes. */
|
|
343
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The lines of a page outside fenced code, each with its 1-based line number. A fence closes on a
|
|
347
|
+
* run of its own character at least as long as the one that opened it, with nothing after it; an
|
|
348
|
+
* unclosed fence runs to the end of the page.
|
|
349
|
+
*
|
|
350
|
+
* @param {string} text A page.
|
|
351
|
+
* @returns {{ text: string, line: number }[]} The lines outside code.
|
|
352
|
+
*/
|
|
353
|
+
function proseLines(text) {
|
|
354
|
+
const out = [];
|
|
355
|
+
let fence = null;
|
|
356
|
+
text.split(/\r?\n/).forEach((line, index) => {
|
|
357
|
+
const run = FENCE.exec(line)?.[1];
|
|
358
|
+
if (fence === null) {
|
|
359
|
+
if (run === undefined) out.push({ text: line, line: index + 1 });
|
|
360
|
+
else fence = run;
|
|
361
|
+
} else if (
|
|
362
|
+
run !== undefined &&
|
|
363
|
+
run[0] === fence[0] &&
|
|
364
|
+
run.length >= fence.length &&
|
|
365
|
+
line.trim() === run
|
|
366
|
+
) {
|
|
367
|
+
fence = null;
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* A line with each inline code span turned into one space, so a link written as code is not read as
|
|
375
|
+
* a link, and text on either side of a span is not read as one link. A run of backticks closes on
|
|
376
|
+
* the next run of the same length; an unclosed run is literal.
|
|
377
|
+
*
|
|
378
|
+
* @param {string} line One line.
|
|
379
|
+
* @returns {string} The line, each code span a space.
|
|
380
|
+
*/
|
|
381
|
+
function withoutCodeSpans(line) {
|
|
382
|
+
let out = "";
|
|
383
|
+
let i = 0;
|
|
384
|
+
while (i < line.length) {
|
|
385
|
+
if (line[i] !== "`") {
|
|
386
|
+
out += line[i];
|
|
387
|
+
i += 1;
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
const run = backtickRun(line, i);
|
|
391
|
+
const close = closingRun(line, i + run, run);
|
|
392
|
+
if (close === -1) {
|
|
393
|
+
out += line.slice(i, i + run);
|
|
394
|
+
i += run;
|
|
395
|
+
} else {
|
|
396
|
+
out += " ";
|
|
397
|
+
i = close + run;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return out;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* The length of the run of backticks at a position.
|
|
405
|
+
*
|
|
406
|
+
* @param {string} text Some text.
|
|
407
|
+
* @param {number} at Where the run starts.
|
|
408
|
+
* @returns {number} How many backticks follow, from `at`.
|
|
409
|
+
*/
|
|
410
|
+
function backtickRun(text, at) {
|
|
411
|
+
let end = at;
|
|
412
|
+
while (text[end] === "`") end += 1;
|
|
413
|
+
return end - at;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Where the run of exactly `length` backticks that closes a code span starts.
|
|
418
|
+
*
|
|
419
|
+
* @param {string} text Some text.
|
|
420
|
+
* @param {number} from Where to look from.
|
|
421
|
+
* @param {number} length The opening run's length.
|
|
422
|
+
* @returns {number} The closing run's start, or -1 when there is none.
|
|
423
|
+
*/
|
|
424
|
+
function closingRun(text, from, length) {
|
|
425
|
+
for (let i = from; (i = text.indexOf("`", i)) !== -1;) {
|
|
426
|
+
const run = backtickRun(text, i);
|
|
427
|
+
if (run === length) return i;
|
|
428
|
+
i += run;
|
|
429
|
+
}
|
|
430
|
+
return -1;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** An inline link's destination in the generated reference, read from just after `](`. */
|
|
434
|
+
const REFERENCE_DESTINATION = /^ *<?(\.\/api\/[^\s)>]*)/;
|
|
435
|
+
|
|
436
|
+
/** A link reference definition whose destination is in the generated reference. */
|
|
437
|
+
const REFERENCE_DEFINITION = /^ {0,3}\[[^\]]*\]:[ \t]*<?(\.\/api\/[^\s>]*)/;
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Every link a page makes into the generated reference: inline links and images, `[text](target)`,
|
|
441
|
+
* and link reference definitions, `[label]: target`, outside fenced and inline code.
|
|
442
|
+
*
|
|
443
|
+
* @param {string} text A page.
|
|
444
|
+
* @returns {{ target: string, line: number }[]} Each target that starts `./api/`, with its line.
|
|
445
|
+
*/
|
|
446
|
+
function referenceLinks(text) {
|
|
447
|
+
const links = [];
|
|
448
|
+
for (const { text: raw, line } of proseLines(text)) {
|
|
449
|
+
const prose = withoutCodeSpans(raw);
|
|
450
|
+
const definition = REFERENCE_DEFINITION.exec(prose);
|
|
451
|
+
if (definition !== null) links.push({ target: definition[1], line });
|
|
452
|
+
for (let at = prose.indexOf("]("); at !== -1; at = prose.indexOf("](", at + 2)) {
|
|
453
|
+
// The destination: after the parenthesis, spaces and an opening angle bracket, up to a space,
|
|
454
|
+
// a closing parenthesis or angle bracket, or the end of the line.
|
|
455
|
+
const destination = REFERENCE_DESTINATION.exec(prose.slice(at + 2));
|
|
456
|
+
if (destination !== null) links.push({ target: destination[1], line });
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return links;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** An ATX heading: up to three spaces, one to six `#`, then a space, a tab or the end of the line. */
|
|
463
|
+
const HEADING = /^ {0,3}#{1,6}(?=[ \t]|$)/;
|
|
464
|
+
|
|
465
|
+
/** ASCII punctuation, which a backslash escapes in markdown. */
|
|
466
|
+
const ESCAPABLE = /[!-/:-@[-`{-~]/;
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* What a character the docs site's slugger drops matches: anything that is not a letter, a mark,
|
|
470
|
+
* a decimal or letter number, a connector such as `_`, another alphabetic character (a circled or
|
|
471
|
+
* squared letter is a symbol, but alphabetic), a space or a hyphen. The site's markdown loader
|
|
472
|
+
* slugs headings with github-slugger 1.5.0, whose class was built from Unicode 13.0. Compared over
|
|
473
|
+
* every code point under Unicode 17.0 (Node.js 26), the two agree on every character except 14,802
|
|
474
|
+
* that Unicode assigned in 14.0 to 17.0 (letters, marks, digits and letter numbers): slugger drops
|
|
475
|
+
* them and this keeps them. Every character that slugger keeps, this keeps.
|
|
476
|
+
*/
|
|
477
|
+
const SLUG_DROPS = /[^\p{L}\p{M}\p{Nd}\p{Nl}\p{Pc}\p{Alphabetic} -]/gu;
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* The text of a heading as the docs site reads it before slugging: the closing run of `#` dropped,
|
|
481
|
+
* a backslash escape read as its character, a code span as its content, and a link or an image as
|
|
482
|
+
* its text without its destination.
|
|
483
|
+
*
|
|
484
|
+
* @param {string} raw What follows the heading's `#` run.
|
|
485
|
+
* @returns {string} The heading's text.
|
|
486
|
+
*/
|
|
487
|
+
function headingText(raw) {
|
|
488
|
+
// The closing run of `#` goes with the spaces before it. A `#` the run leaves behind, as in
|
|
489
|
+
// `C#`, is dropped by the slug in any case, and so is every backtick and backslash below: what
|
|
490
|
+
// matters here is only what reaches the slug as a letter, a digit, a space or a hyphen.
|
|
491
|
+
let text = raw.trim();
|
|
492
|
+
while (text.endsWith("#")) text = text.slice(0, -1);
|
|
493
|
+
text = text.trimEnd();
|
|
494
|
+
let out = "";
|
|
495
|
+
let i = 0;
|
|
496
|
+
while (i < text.length) {
|
|
497
|
+
const char = text[i];
|
|
498
|
+
if (char === "\\" && ESCAPABLE.test(text.charAt(i + 1))) {
|
|
499
|
+
out += text[i + 1];
|
|
500
|
+
i += 2;
|
|
501
|
+
} else if (char === "`") {
|
|
502
|
+
const run = backtickRun(text, i);
|
|
503
|
+
const close = closingRun(text, i + run, run);
|
|
504
|
+
// An unclosed run is literal backticks, which the slug drops.
|
|
505
|
+
if (close !== -1) out += text.slice(i + run, close);
|
|
506
|
+
i = close === -1 ? i + run : close + run;
|
|
507
|
+
} else if (text.startsWith("](", i) && text.includes(")", i)) {
|
|
508
|
+
i = text.indexOf(")", i) + 1;
|
|
509
|
+
} else {
|
|
510
|
+
out += char;
|
|
511
|
+
i += 1;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return out;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Every anchor the headings of a page get on the docs site: each heading's text lower-cased, the
|
|
519
|
+
* characters the slugger drops dropped, and each space turned into a hyphen; a slug already taken
|
|
520
|
+
* gets `-1`, `-2` and so on, counted per slug in page order. Headings in fenced code are code.
|
|
521
|
+
*
|
|
522
|
+
* WHAT IT DOES NOT READ, since the reference generator writes none of it: setext headings
|
|
523
|
+
* (underlined with `=` or `-`), an explicit `{#id}`, HTML in a heading, entities such as `&`,
|
|
524
|
+
* and emphasis written with `_`, whose underscores it keeps.
|
|
525
|
+
*
|
|
526
|
+
* @param {string} text A page.
|
|
527
|
+
* @returns {Set<string>} Its anchors.
|
|
528
|
+
*/
|
|
529
|
+
export function headingAnchors(text) {
|
|
530
|
+
const anchors = new Set();
|
|
531
|
+
const counts = new Map();
|
|
532
|
+
for (const { text: line } of proseLines(text)) {
|
|
533
|
+
const heading = HEADING.exec(line);
|
|
534
|
+
if (heading === null) continue;
|
|
535
|
+
const base = headingText(line.slice(heading[0].length))
|
|
536
|
+
.toLowerCase()
|
|
537
|
+
.replace(SLUG_DROPS, "")
|
|
538
|
+
.replaceAll(" ", "-");
|
|
539
|
+
let slug = base;
|
|
540
|
+
while (anchors.has(slug)) {
|
|
541
|
+
const count = (counts.get(base) ?? 0) + 1;
|
|
542
|
+
counts.set(base, count);
|
|
543
|
+
slug = `${base}-${count}`;
|
|
544
|
+
}
|
|
545
|
+
anchors.add(slug);
|
|
546
|
+
}
|
|
547
|
+
return anchors;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* What is wrong with a link into the generated reference, or null when it resolves.
|
|
552
|
+
*
|
|
553
|
+
* @param {string} target The link's target, starting `./api/`.
|
|
554
|
+
* @param {Map<string, Set<string>>} anchors The anchors of each generated page, by path under api/.
|
|
555
|
+
* @returns {string | null} What is wrong.
|
|
556
|
+
*/
|
|
557
|
+
function referenceFault(target, anchors) {
|
|
558
|
+
const hash = target.indexOf("#");
|
|
559
|
+
const path = target.slice(REFERENCE_LINK_PREFIX.length, hash === -1 ? undefined : hash);
|
|
560
|
+
const segments = path.split("/");
|
|
561
|
+
if (!path.endsWith(".md") || segments.some((s) => s === "" || s === "." || s === "..")) {
|
|
562
|
+
return `links ${target}: a link into the reference is ${REFERENCE_LINK_PREFIX}<page>.md, with an optional #<anchor>`;
|
|
563
|
+
}
|
|
564
|
+
const headings = anchors.get(path);
|
|
565
|
+
if (headings === undefined) {
|
|
566
|
+
return `links ${target}, but there is no page ${GENERATED_DIR}/${path}`;
|
|
567
|
+
}
|
|
568
|
+
const anchor = target.slice(hash + 1);
|
|
569
|
+
if (hash === -1 || headings.has(anchor)) return null;
|
|
570
|
+
return `links ${target}, but no heading on ${GENERATED_DIR}/${path} has the anchor "${anchor}"`;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/**
|
|
574
|
+
* Check a docs-content tree that has already been read.
|
|
575
|
+
*
|
|
576
|
+
* @param {{ pages: { name: string, text: string }[], sidebars: string | null, misplaced?: string[], reference?: Map<string, string> }} input
|
|
577
|
+
* `pages` are the `.md` files at the top of docs-content/, by file name without `.md`; `sidebars`
|
|
578
|
+
* is the text of sidebars.json, or null when there is none; `misplaced` are the paths, relative to
|
|
579
|
+
* docs-content/, of markdown that is not a top-level `.md` page and is not under api/;
|
|
580
|
+
* `reference` holds the generated `.md` pages, by their path under api/.
|
|
581
|
+
* @returns {{ file: string, line: number, rule: keyof typeof DOCS_CONTENT_RULES, message: string }[]}
|
|
582
|
+
* Every finding, in a stable order.
|
|
583
|
+
*/
|
|
584
|
+
export function checkDocsContent(input) {
|
|
585
|
+
const findings = [];
|
|
586
|
+
const add = (file, line, rule, message) => findings.push({ file, line, rule, message });
|
|
587
|
+
const names = new Set(input.pages.map((page) => page.name));
|
|
588
|
+
|
|
589
|
+
for (const path of input.misplaced ?? []) {
|
|
590
|
+
add(
|
|
591
|
+
path,
|
|
592
|
+
0,
|
|
593
|
+
"file-name",
|
|
594
|
+
"a page is a .md file at the top of docs-content/: move or rename it",
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
for (const page of input.pages) {
|
|
599
|
+
const file = `${page.name}.md`;
|
|
600
|
+
if (!PAGE_NAME.test(page.name)) {
|
|
601
|
+
add(file, 0, "file-name", "a page name is lower-case words joined by single hyphens");
|
|
602
|
+
}
|
|
603
|
+
const { fields, errors, present } = parseFrontMatter(page.text);
|
|
604
|
+
if (!present) {
|
|
605
|
+
add(file, 1, "front-matter", "the page does not open with front matter (a first line ---)");
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
for (const error of errors) add(file, error.line, "front-matter", error.message);
|
|
609
|
+
for (const key of FRONT_MATTER_KEYS) {
|
|
610
|
+
const field = fields.get(key);
|
|
611
|
+
if (field === undefined) add(file, 1, "front-matter", `${key} is missing`);
|
|
612
|
+
else if (field.value.trim() === "") add(file, field.line, "front-matter", `${key} is empty`);
|
|
613
|
+
}
|
|
614
|
+
for (const key of FORBIDDEN_KEYS) {
|
|
615
|
+
const field = fields.get(key);
|
|
616
|
+
if (field !== undefined) {
|
|
617
|
+
add(file, field.line, "front-matter", `${key}: the sidebar sets the order, remove it`);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
const id = fields.get("id");
|
|
621
|
+
if (id !== undefined && id.value !== page.name) {
|
|
622
|
+
add(
|
|
623
|
+
file,
|
|
624
|
+
id.line,
|
|
625
|
+
"front-matter",
|
|
626
|
+
`id is "${id.value}"; it must be the file name, "${page.name}"`,
|
|
627
|
+
);
|
|
628
|
+
}
|
|
629
|
+
const description = fields.get("description");
|
|
630
|
+
if (description !== undefined && description.value.length > DESCRIPTION_LIMIT) {
|
|
631
|
+
add(
|
|
632
|
+
file,
|
|
633
|
+
description.line,
|
|
634
|
+
"front-matter",
|
|
635
|
+
`description is ${description.value.length} characters; the limit is ${DESCRIPTION_LIMIT}`,
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
for (const page of REQUIRED_PAGES) {
|
|
641
|
+
if (!names.has(page)) add(`${page}.md`, 0, "required-page", "every library has this page");
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
for (const page of input.pages) {
|
|
645
|
+
if (!QUALITY_PAGES.includes(page.name)) continue;
|
|
646
|
+
const lines = page.text.split(/\r?\n/);
|
|
647
|
+
if (!lines.includes(NUMBERS_HEADING)) {
|
|
648
|
+
add(
|
|
649
|
+
`${page.name}.md`,
|
|
650
|
+
0,
|
|
651
|
+
"numbers",
|
|
652
|
+
`the page has no "${NUMBERS_HEADING}" heading: the generated numbers go in that section`,
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
lines.forEach((line, at) => {
|
|
656
|
+
if (line === NUMBERS_HEADING && sectionText(lines, at) !== NUMBERS_PLACEHOLDER) {
|
|
657
|
+
add(
|
|
658
|
+
`${page.name}.md`,
|
|
659
|
+
at + 1,
|
|
660
|
+
"numbers",
|
|
661
|
+
`the section holds only the placeholder until the release fills it in: "${NUMBERS_PLACEHOLDER}"`,
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Each generated page's anchors, read once: a library has a handful of pages and links into them
|
|
668
|
+
// from many.
|
|
669
|
+
const reference = input.reference ?? new Map();
|
|
670
|
+
const anchors = new Map([...reference].map(([path, text]) => [path, headingAnchors(text)]));
|
|
671
|
+
for (const page of input.pages) {
|
|
672
|
+
for (const link of referenceLinks(page.text)) {
|
|
673
|
+
const fault = referenceFault(link.target, anchors);
|
|
674
|
+
if (fault !== null) add(`${page.name}.md`, link.line, "reference-link", fault);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
if (input.sidebars === null) {
|
|
679
|
+
add(SIDEBARS_FILE, 0, "sidebar", "there is no sidebars.json");
|
|
680
|
+
return findings;
|
|
681
|
+
}
|
|
682
|
+
let sidebars;
|
|
683
|
+
try {
|
|
684
|
+
sidebars = JSON.parse(input.sidebars);
|
|
685
|
+
} catch (error) {
|
|
686
|
+
add(SIDEBARS_FILE, 0, "sidebar", `is not JSON: ${error.message}`);
|
|
687
|
+
return findings;
|
|
688
|
+
}
|
|
689
|
+
const ids = checkSidebar(sidebars, (pointer, message) =>
|
|
690
|
+
add(SIDEBARS_FILE, 0, "sidebar", `${pointer === "" ? "" : `at ${pointer}: `}${message}`),
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
const counts = new Map();
|
|
694
|
+
for (const id of ids) counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
695
|
+
for (const [id, count] of counts) {
|
|
696
|
+
if (!names.has(id)) add(SIDEBARS_FILE, 0, "listing", `lists "${id}", which is not a page`);
|
|
697
|
+
else if (count > 1) add(SIDEBARS_FILE, 0, "listing", `lists "${id}" ${count} times`);
|
|
698
|
+
}
|
|
699
|
+
for (const page of input.pages) {
|
|
700
|
+
if (!counts.has(page.name))
|
|
701
|
+
add(`${page.name}.md`, 0, "listing", "the sidebar does not list it");
|
|
702
|
+
}
|
|
703
|
+
return findings;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* Read docs-content/ under a repository root.
|
|
708
|
+
*
|
|
709
|
+
* @param {string} root The repository root.
|
|
710
|
+
* @returns {{ pages: { name: string, text: string }[], sidebars: string | null, misplaced: string[], reference: Map<string, string> }}
|
|
711
|
+
* @throws {DocsContentRefusal} When the folder is missing or a file in it cannot be read.
|
|
712
|
+
*/
|
|
713
|
+
export function readDocsContent(root) {
|
|
714
|
+
const docs = join(root, DOCS_DIR);
|
|
715
|
+
if (!isDirectory(docs)) throw new DocsContentRefusal(`${docs} is not a directory.`);
|
|
716
|
+
const pages = [];
|
|
717
|
+
const misplaced = [];
|
|
718
|
+
const reference = new Map();
|
|
719
|
+
let sidebars = null;
|
|
720
|
+
const generated = `${GENERATED_DIR}/`;
|
|
721
|
+
const walk = (relative) => {
|
|
722
|
+
// Node lists a directory in name order on Linux and macOS (libuv sorts the listing), so the
|
|
723
|
+
// findings come out in one order without sorting here.
|
|
724
|
+
for (const entry of readdirOrRefuse(join(docs, relative))) {
|
|
725
|
+
const path = relative === "" ? entry.name : `${relative}/${entry.name}`;
|
|
726
|
+
if (entry.isDirectory()) {
|
|
727
|
+
walk(path);
|
|
728
|
+
} else if (path.startsWith(generated)) {
|
|
729
|
+
// The generated pages are read for their headings, which links into the reference name.
|
|
730
|
+
if (entry.name.endsWith(".md")) {
|
|
731
|
+
reference.set(path.slice(generated.length), readOrRefuse(join(docs, path), entry));
|
|
732
|
+
}
|
|
733
|
+
} else if (path === SIDEBARS_FILE) {
|
|
734
|
+
sidebars = readOrRefuse(join(docs, path), entry);
|
|
735
|
+
} else if (relative === "" && entry.name.endsWith(".md")) {
|
|
736
|
+
pages.push({ name: entry.name.slice(0, -3), text: readOrRefuse(join(docs, path), entry) });
|
|
737
|
+
} else if (/\.mdx?$/i.test(entry.name)) {
|
|
738
|
+
misplaced.push(path);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
walk("");
|
|
743
|
+
return { pages, sidebars, misplaced, reference };
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Whether a path is a directory, without following a symbolic link.
|
|
748
|
+
*
|
|
749
|
+
* @param {string} path A path.
|
|
750
|
+
* @returns {boolean} True for a directory.
|
|
751
|
+
*/
|
|
752
|
+
function isDirectory(path) {
|
|
753
|
+
return lstatSync(path, { throwIfNoEntry: false })?.isDirectory() === true;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* List a directory, refusing when it cannot be listed.
|
|
758
|
+
*
|
|
759
|
+
* @param {string} directory A directory.
|
|
760
|
+
* @returns {import("node:fs").Dirent[]} Its entries, typed without following a link.
|
|
761
|
+
*/
|
|
762
|
+
function readdirOrRefuse(directory) {
|
|
763
|
+
try {
|
|
764
|
+
return readdirSync(directory, { withFileTypes: true });
|
|
765
|
+
} catch (error) {
|
|
766
|
+
throw new DocsContentRefusal(`${directory} could not be listed (${error.code}).`);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Read a regular file, refusing anything else.
|
|
772
|
+
*
|
|
773
|
+
* @param {string} path The file.
|
|
774
|
+
* @param {import("node:fs").Dirent} entry Its directory entry.
|
|
775
|
+
* @returns {string} Its text.
|
|
776
|
+
*/
|
|
777
|
+
function readOrRefuse(path, entry) {
|
|
778
|
+
if (!entry.isFile()) throw new DocsContentRefusal(`${path} is not a regular file.`);
|
|
779
|
+
try {
|
|
780
|
+
return readFileSync(path, "utf8");
|
|
781
|
+
} catch (error) {
|
|
782
|
+
throw new DocsContentRefusal(`${path} could not be read (${error.code}).`);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* The command line: check docs-content/ in a repository and report.
|
|
788
|
+
*
|
|
789
|
+
* @param {{ argv?: string[], dir?: string, stdout?: { write(text: string): unknown }, stderr?: { write(text: string): unknown } }} [options]
|
|
790
|
+
* @returns {number} 0 clean, 1 a finding, 2 the check could not run.
|
|
791
|
+
*/
|
|
792
|
+
export function runDocsContentCheck(options = {}) {
|
|
793
|
+
const stdout = options.stdout ?? process.stdout;
|
|
794
|
+
const stderr = options.stderr ?? process.stderr;
|
|
795
|
+
let dir = options.dir ?? process.cwd();
|
|
796
|
+
const argv = options.argv ?? [];
|
|
797
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
798
|
+
if (argv[i] === "--dir" && typeof argv[i + 1] === "string") {
|
|
799
|
+
dir = argv[i + 1];
|
|
800
|
+
i += 1;
|
|
801
|
+
} else {
|
|
802
|
+
stderr.write(
|
|
803
|
+
`docs-content: ${JSON.stringify(argv[i])} is not an argument this check accepts (only ` +
|
|
804
|
+
`--dir <repository root>). Nothing was checked.\n`,
|
|
805
|
+
);
|
|
806
|
+
return 2;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
let input;
|
|
810
|
+
try {
|
|
811
|
+
input = readDocsContent(resolve(dir));
|
|
812
|
+
} catch (error) {
|
|
813
|
+
// Every way reading fails is a refusal: the folder, a listing and a file each raise one.
|
|
814
|
+
stderr.write(`docs-content: ${error.message} Nothing was checked.\n`);
|
|
815
|
+
return 2;
|
|
816
|
+
}
|
|
817
|
+
const findings = checkDocsContent(input);
|
|
818
|
+
if (findings.length === 0) {
|
|
819
|
+
stdout.write(`docs-content: OK (${input.pages.length} page(s) checked)\n`);
|
|
820
|
+
return 0;
|
|
821
|
+
}
|
|
822
|
+
stderr.write(
|
|
823
|
+
`docs-content: ${findings.length} finding(s) in ${input.pages.length} page(s).\n` +
|
|
824
|
+
findings
|
|
825
|
+
.map(
|
|
826
|
+
(f) =>
|
|
827
|
+
` ${DOCS_DIR}/${f.file}${f.line > 0 ? `:${f.line}` : ""} [${f.rule}] ${f.message}`,
|
|
828
|
+
)
|
|
829
|
+
.join("\n") +
|
|
830
|
+
"\n",
|
|
831
|
+
);
|
|
832
|
+
return 1;
|
|
833
|
+
}
|