@mark1russell7/site-pages 0.2.7 → 0.2.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.
@@ -0,0 +1,741 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { isAbsolute, join } from "node:path";
4
+ import { parseArgs } from "node:util";
5
+ // src/data/versions.ts
6
+ const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
7
+ /** This function gives the parts of a version, or null when the text is not a SemVer version. */
8
+ function parseVersion(version) {
9
+ const match = SEMVER.exec(version);
10
+ if (!match) return null;
11
+ return {
12
+ major: Number(match[1]),
13
+ minor: Number(match[2]),
14
+ patch: Number(match[3]),
15
+ prerelease: match[4] ? match[4].split(".") : []
16
+ };
17
+ }
18
+ /**
19
+ * This function compares two versions by the SemVer precedence. It gives a negative number when `a`
20
+ * is older, a positive number when `a` is newer, and 0 for the same version. A text that is not a
21
+ * version comes before each version.
22
+ */
23
+ function compareVersions(a, b) {
24
+ const x = parseVersion(a);
25
+ const y = parseVersion(b);
26
+ if (!x || !y) return x ? 1 : y ? -1 : a.localeCompare(b);
27
+ const core = x.major - y.major || x.minor - y.minor || x.patch - y.patch;
28
+ if (core !== 0) return core;
29
+ if (x.prerelease.length === 0 || y.prerelease.length === 0) return y.prerelease.length - x.prerelease.length;
30
+ for (let index = 0; index < Math.max(x.prerelease.length, y.prerelease.length); index++) {
31
+ const p = x.prerelease[index];
32
+ const q = y.prerelease[index];
33
+ if (p === void 0) return -1;
34
+ if (q === void 0) return 1;
35
+ if (p === q) continue;
36
+ const pNumber = /^\d+$/.test(p);
37
+ const qNumber = /^\d+$/.test(q);
38
+ if (pNumber && qNumber) return Number(p) - Number(q);
39
+ if (pNumber !== qNumber) return pNumber ? -1 : 1;
40
+ return p < q ? -1 : 1;
41
+ }
42
+ return 0;
43
+ }
44
+ /**
45
+ * This function gives the kind of a version. The oldest version of the list is "first". The caller
46
+ * tells if the version is the oldest.
47
+ */
48
+ function kindOf(version, oldest) {
49
+ if (oldest) return "first";
50
+ const parsed = parseVersion(version);
51
+ if (!parsed) return "patch";
52
+ if (parsed.major > 0 && parsed.minor === 0 && parsed.patch === 0) return "major";
53
+ return parsed.patch === 0 ? "minor" : "patch";
54
+ }
55
+ /**
56
+ * This function reads the version of a git tag. It knows `1.2.3`, `v1.2.3` and the tags of Changesets:
57
+ * `name@1.2.3` and `@scope/name@1.2.3`. It gives null for another tag.
58
+ */
59
+ function versionOfTag(tag) {
60
+ const match = /^(?:(.+)@)?v?(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.+-]*)?)$/.exec(tag.trim());
61
+ if (!match || match[2] === void 0 || !parseVersion(match[2])) return null;
62
+ return {
63
+ version: match[2],
64
+ package: match[1] ?? null
65
+ };
66
+ }
67
+ // src/data/npm.ts
68
+ /** The public npm registry. */
69
+ const NPM_REGISTRY = "https://registry.npmjs.org";
70
+ const object = (value) => value !== null && typeof value === "object" ? value : {};
71
+ function message(error) {
72
+ if (object(error)["name"] === "TimeoutError") return "npm did not answer in time";
73
+ return error instanceof Error ? error.message : String(error);
74
+ }
75
+ /**
76
+ * This function reads the publication time of each version of the packages from the npm registry.
77
+ * A scope (`@name`) gives each package of the scope. It does not throw. Each failure gives a
78
+ * message in `problems`.
79
+ */
80
+ async function npmPublishTimes(options) {
81
+ const { registry = NPM_REGISTRY, timeoutMs = 15e3 } = options;
82
+ const fetcher = options.fetch ?? globalThis.fetch;
83
+ const problems = [];
84
+ const times = [];
85
+ if (typeof fetcher !== "function") return {
86
+ times,
87
+ problems: ["this Node has no fetch"]
88
+ };
89
+ const get = async (url) => {
90
+ const response = await fetcher(url, {
91
+ headers: { accept: "application/json" },
92
+ signal: AbortSignal.timeout(timeoutMs)
93
+ });
94
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
95
+ return response.json();
96
+ };
97
+ const names = /* @__PURE__ */ new Set();
98
+ for (const entry of options.packages.map((name) => name.trim()).filter(Boolean)) {
99
+ if (!/^@[^/]+$/.test(entry)) {
100
+ names.add(entry);
101
+ continue;
102
+ }
103
+ try {
104
+ const found = object(await get(`${registry}/-/v1/search?text=scope:${encodeURIComponent(entry.slice(1))}&size=250`));
105
+ const objects = Array.isArray(found["objects"]) ? found["objects"] : [];
106
+ const before = names.size;
107
+ for (const item of objects) {
108
+ const name = object(object(item)["package"])["name"];
109
+ if (typeof name === "string" && name.startsWith(`${entry}/`)) names.add(name);
110
+ }
111
+ if (names.size === before) problems.push(`npm has no package in the scope ${entry}`);
112
+ } catch (error) {
113
+ problems.push(`npm did not give the packages of ${entry}: ${message(error)}`);
114
+ }
115
+ }
116
+ const queue = [...names].sort();
117
+ const worker = async () => {
118
+ for (let name = queue.shift(); name !== void 0; name = queue.shift()) try {
119
+ const packument = object(await get(`${registry}/${name.replaceAll("/", "%2f")}`));
120
+ const published = object(packument["versions"]);
121
+ for (const [version, date] of Object.entries(object(packument["time"]))) {
122
+ if (version === "created" || version === "modified" || typeof date !== "string") continue;
123
+ if (!(version in published)) continue;
124
+ times.push({
125
+ name,
126
+ version,
127
+ date
128
+ });
129
+ }
130
+ } catch (error) {
131
+ problems.push(`npm did not give the versions of ${name}: ${message(error)}`);
132
+ }
133
+ };
134
+ await Promise.all(Array.from({ length: 4 }, worker));
135
+ times.sort((a, b) => a.name.localeCompare(b.name) || a.date.localeCompare(b.date));
136
+ return {
137
+ times,
138
+ problems
139
+ };
140
+ }
141
+ // src/data/git.ts
142
+ /**
143
+ * The git source: the tags with their dates, the commit of the build and the GitHub remote. Each
144
+ * function gives an empty result when git or the repository is not available.
145
+ */
146
+ /** This function starts git in a folder and gives its output, or null when git fails. */
147
+ function git(repoDir, ...args) {
148
+ try {
149
+ return execFileSync("git", args, {
150
+ cwd: repoDir,
151
+ encoding: "utf8",
152
+ stdio: [
153
+ "ignore",
154
+ "pipe",
155
+ "ignore"
156
+ ],
157
+ windowsHide: true
158
+ });
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+ /** This function tells if the clone is shallow. A shallow clone has not all the tags and commits. */
164
+ function isShallow(repoDir) {
165
+ return git(repoDir, "rev-parse", "--is-shallow-repository")?.trim() === "true";
166
+ }
167
+ /** This function gives each tag of the repository with its date. An annotated tag has its own date. */
168
+ function gitTagDates(repoDir) {
169
+ const output = git(repoDir, "for-each-ref", "--format=%(refname:strip=2) %(creatordate:iso-strict)", "refs/tags");
170
+ if (!output) return [];
171
+ const tags = [];
172
+ for (const line of output.split(/\r?\n/)) {
173
+ const match = /^(\S+) (\S+)$/.exec(line.trim());
174
+ if (match?.[1] && match[2]) tags.push({
175
+ tag: match[1],
176
+ date: match[2]
177
+ });
178
+ }
179
+ return tags;
180
+ }
181
+ /**
182
+ * This function gives the date of the oldest commit that added a version heading to a changelog,
183
+ * or null. The path of the changelog starts at `repoDir`.
184
+ */
185
+ function headingDate(repoDir, version, file) {
186
+ const dates = (git(repoDir, "log", "--format=%cI", `-S## ${version}`, "--", file) ?? "").trim().split(/\r?\n/).filter(Boolean);
187
+ return dates[dates.length - 1] ?? null;
188
+ }
189
+ /** This function gives the full SHA of `HEAD`, or null. */
190
+ function headCommit(repoDir) {
191
+ const sha = git(repoDir, "rev-parse", "HEAD")?.trim();
192
+ return sha && /^[0-9a-f]{40}$/.test(sha) ? sha : null;
193
+ }
194
+ /** This function gives "owner/name" of the GitHub remote "origin", or null. */
195
+ function originRepository(repoDir) {
196
+ const url = git(repoDir, "remote", "get-url", "origin")?.trim() ?? "";
197
+ const match = /github\.com[:/]([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(url);
198
+ return match ? `${match[1]}/${match[2]}` : null;
199
+ }
200
+ // src/data/kpi.ts
201
+ /**
202
+ * The file `kpi.json` at the root of a site, format `kpi/1`. A reader outside the site takes the measures of the project
203
+ * from this file. Thus a number on a progress page comes from a record and not from a person.
204
+ *
205
+ * The field `at` is the time of the data and not the time of the build. Thus the writer has no default for it.
206
+ * A measure can have its own `at` when its data is from another time than the rest of the file.
207
+ */
208
+ /** The name of the format. A new format gets a new name, so that an old reader refuses it. */
209
+ const KPI_FORMAT = "kpi/1";
210
+ /** The name of the file at the root of the built site. */
211
+ const KPI_FILE = "kpi.json";
212
+ /** The form of a project and a measure. A double hyphen separates fields in the file name of a reading. */
213
+ const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
214
+ /** The form of a time in UTC, with or without seconds. */
215
+ const UTC_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?Z$/;
216
+ /** The form of a full commit. */
217
+ const COMMIT = /^[0-9a-f]{40}$/;
218
+ /** The clock difference between two computers that the check accepts, in milliseconds. */
219
+ const CLOCK_MARGIN = 3e5;
220
+ /** An error in the data of `kpi.json`. The field `problems` has one line for each fault. */
221
+ var KpiError = class extends Error {
222
+ problems;
223
+ constructor(problems) {
224
+ super(`${KPI_FILE} is not correct: ${problems.join(" ")}`);
225
+ this.name = "KpiError";
226
+ this.problems = problems;
227
+ }
228
+ };
229
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
230
+ const isText = (value) => typeof value === "string" && value.trim() !== "";
231
+ /** This function gives the fault of a time of the data, or an empty list. The name tells which field it is. */
232
+ function timeProblems(at, name, now) {
233
+ if (typeof at !== "string" || !UTC_TIME.test(at) || Number.isNaN(Date.parse(at))) return [`${name} is not a time in UTC.`];
234
+ return Date.parse(at) > now + CLOCK_MARGIN ? [`The time of the data ${at} is after the time of the check.`] : [];
235
+ }
236
+ /** This function gives the faults of one measure. */
237
+ function measureProblems(value, index, seen, now) {
238
+ if (!isRecord(value)) return [`Measure ${index} is not an object.`];
239
+ const { id, value: amount, unit, at } = value;
240
+ const problems = [];
241
+ if (typeof id !== "string" || !SLUG.test(id)) problems.push(`Measure ${index} has no id of lowercase words with single hyphens.`);
242
+ else if (seen.has(id)) problems.push(`The id ${id} occurs two times.`);
243
+ else seen.add(id);
244
+ if (typeof amount !== "number" || !Number.isFinite(amount)) problems.push(`Measure ${index} has no finite number as its value.`);
245
+ if (unit !== void 0 && !isText(unit)) problems.push(`Measure ${index} has a unit that is not a text.`);
246
+ if (at !== void 0) problems.push(...timeProblems(at, `The field at of measure ${index}`, now));
247
+ return problems;
248
+ }
249
+ /** This function gives the faults of the list of engines. */
250
+ function engineProblems(value) {
251
+ if (value === void 0) return [];
252
+ if (!Array.isArray(value)) return ["The field engines is not a list."];
253
+ return value.flatMap((engine, index) => isRecord(engine) && isText(engine["name"]) && isText(engine["version"]) ? [] : [`Engine ${index} has no name and version.`]);
254
+ }
255
+ /**
256
+ * This function gives the faults of a parsed `kpi.json`, or an empty list.
257
+ *
258
+ * @param value - The parsed file.
259
+ * @param now - The time of the check, in milliseconds. A time of the data after this time is a fault.
260
+ */
261
+ function kpiProblems(value, now = Date.now()) {
262
+ if (!isRecord(value)) return ["The file is not an object."];
263
+ const problems = [];
264
+ if (value["format"] !== "kpi/1") problems.push(`The format is not ${KPI_FORMAT}.`);
265
+ const { project, at, commit, measures } = value;
266
+ if (typeof project !== "string" || !SLUG.test(project)) problems.push("The project is not lowercase words with single hyphens.");
267
+ problems.push(...timeProblems(at, "The field at", now));
268
+ if (typeof commit !== "string" || !COMMIT.test(commit)) problems.push("The commit is not a full commit.");
269
+ if (!Array.isArray(measures) || measures.length === 0) problems.push("The file has no measures.");
270
+ else {
271
+ const seen = /* @__PURE__ */ new Set();
272
+ problems.push(...measures.flatMap((measure, index) => measureProblems(measure, index, seen, now)));
273
+ }
274
+ problems.push(...engineProblems(value["engines"]));
275
+ return problems;
276
+ }
277
+ /** This function gives a copy of a measure with only its known fields. */
278
+ function cleanMeasure({ id, value, unit, at }) {
279
+ return {
280
+ id,
281
+ value,
282
+ ...unit === void 0 ? {} : { unit },
283
+ ...at === void 0 ? {} : { at }
284
+ };
285
+ }
286
+ /** This function gives a copy of an engine with only its known fields. */
287
+ function cleanEngine({ name, version }) {
288
+ return {
289
+ name,
290
+ version
291
+ };
292
+ }
293
+ /**
294
+ * This function gives the checked content of `kpi.json`. The file has only its known fields, in a fixed order.
295
+ *
296
+ * @throws KpiError when the data is not correct
297
+ */
298
+ function kpiFile(input, now) {
299
+ const problems = kpiProblems({
300
+ format: KPI_FORMAT,
301
+ ...input
302
+ }, now);
303
+ if (problems.length > 0) throw new KpiError(problems);
304
+ const { project, at, commit, measures, engines } = input;
305
+ const file = {
306
+ format: KPI_FORMAT,
307
+ project,
308
+ at,
309
+ commit,
310
+ measures: measures.map(cleanMeasure)
311
+ };
312
+ return engines === void 0 ? file : {
313
+ ...file,
314
+ engines: engines.map(cleanEngine)
315
+ };
316
+ }
317
+ /** This function gives the text of `kpi.json`. */
318
+ function kpiText(file) {
319
+ return `${JSON.stringify(file, null, 2)}\n`;
320
+ }
321
+ /**
322
+ * This function reads the text of a `kpi.json`.
323
+ *
324
+ * @throws KpiError when the text is not correct
325
+ */
326
+ function readKpi(text, now) {
327
+ let value;
328
+ try {
329
+ value = JSON.parse(text);
330
+ } catch (error) {
331
+ throw new KpiError([`The file is not JSON: ${error instanceof Error ? error.message : String(error)}`]);
332
+ }
333
+ const problems = kpiProblems(value, now);
334
+ if (problems.length > 0) throw new KpiError(problems);
335
+ const { project, at, commit, measures, engines } = value;
336
+ return kpiFile(engines === void 0 ? {
337
+ project,
338
+ at,
339
+ commit,
340
+ measures
341
+ } : {
342
+ project,
343
+ at,
344
+ commit,
345
+ measures,
346
+ engines
347
+ }, now);
348
+ }
349
+ /**
350
+ * This function writes `kpi.json` into the built output folder of a site, and gives the path of the file.
351
+ *
352
+ * @throws KpiError when the data is not correct
353
+ */
354
+ function writeKpi(out, input, now) {
355
+ const text = kpiText(kpiFile(input, now));
356
+ mkdirSync(out, { recursive: true });
357
+ const file = join(out, KPI_FILE);
358
+ writeFileSync(file, text);
359
+ return file;
360
+ }
361
+ // src/render/html.ts
362
+ /**
363
+ * The HTML helpers of the pages: the escape functions and a small inline Markdown renderer.
364
+ */
365
+ const ESCAPES = {
366
+ "&": "&amp;",
367
+ "<": "&lt;",
368
+ ">": "&gt;",
369
+ "\"": "&quot;",
370
+ "'": "&#39;"
371
+ };
372
+ /** This function escapes a text for HTML content and for an attribute value in double quotes. */
373
+ function escapeHtml(text) {
374
+ return String(text).replace(/[&<>"']/g, (char) => ESCAPES[char] ?? char);
375
+ }
376
+ /**
377
+ * This function removes the control characters and the spaces (U+0000 to U+0020) at the two ends
378
+ * of a text. A loop does it, because the regular expression `[\u0000- ]+$` backtracks. For a long
379
+ * run of these characters, its time grows with the square of the length.
380
+ */
381
+ function trimControl(text) {
382
+ let start = 0;
383
+ let end = text.length;
384
+ while (start < end && text.charCodeAt(start) <= 32) start++;
385
+ while (end > start && text.charCodeAt(end - 1) <= 32) end--;
386
+ return text.slice(start, end);
387
+ }
388
+ /**
389
+ * This function gives a URL that a link can hold, or null. It accepts `http:`, `https:` and
390
+ * `mailto:` URLs, and relative URLs. It refuses each other scheme, for example `javascript:`.
391
+ *
392
+ * The URL parser of a browser removes each tab and line break, and the control characters and
393
+ * spaces at the two ends. This function removes them before the scheme check, as the browser does.
394
+ * Thus a control character before `javascript:` does not hide the scheme.
395
+ */
396
+ function safeUrl(url) {
397
+ const trimmed = trimControl(url.trim().replace(/[\t\n\r]/g, ""));
398
+ if (/^(?:https?:|mailto:)/i.test(trimmed)) return trimmed;
399
+ if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed) || /^[/\\]{2}/.test(trimmed)) return null;
400
+ return trimmed;
401
+ }
402
+ /** This function gives an `<a>` element, or the escaped text when the URL is not safe. */
403
+ function link(url, html, attributes = "") {
404
+ const href = safeUrl(url);
405
+ return href === null ? html : `<a href="${escapeHtml(href)}"${attributes ? ` ${attributes}` : ""}>${html}</a>`;
406
+ }
407
+ /** The emphasis of a text without code spans or links. The text is already escaped. */
408
+ function emphasis(escaped) {
409
+ return escaped.replace(/\*\*([^*]+?)\*\*/g, "<strong>$1</strong>").replace(/(^|[^\w*])\*([^*\s][^*]*?)\*(?![\w*])/g, "$1<em>$2</em>").replace(/(^|[^\w])_([^_\s][^_]*?)_(?![\w])/g, "$1<em>$2</em>");
410
+ }
411
+ const INLINE = "`([^`]+)`|\\[((?:[^\\]`]|`[^`]*`)+)\\]\\(([^)\\s]+)\\)";
412
+ /**
413
+ * This function renders inline Markdown as HTML: code spans, links, strong text and emphasis. It
414
+ * escapes all other text, so the result is safe.
415
+ */
416
+ function inlineMarkdown(markdown) {
417
+ const text = String(markdown ?? "");
418
+ const pattern = new RegExp(INLINE, "g");
419
+ let html = "";
420
+ let last = 0;
421
+ for (let match = pattern.exec(text); match; match = pattern.exec(text)) {
422
+ html += emphasis(escapeHtml(text.slice(last, match.index)));
423
+ if (match[1] !== void 0) html += `<code>${escapeHtml(match[1])}</code>`;
424
+ else html += link(match[3] ?? "", inlineMarkdown(match[2] ?? ""));
425
+ last = match.index + match[0].length;
426
+ }
427
+ return html + emphasis(escapeHtml(text.slice(last)));
428
+ }
429
+ /** This function gives "1 item" or "2 items". */
430
+ function plural(count, word, many = `${word}s`) {
431
+ return `${count} ${count === 1 ? word : many}`;
432
+ }
433
+ /** This function gives "a", "a and b" or "a, b and c". */
434
+ function listText(parts) {
435
+ if (parts.length <= 1) return parts.join("");
436
+ return `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
437
+ }
438
+ // src/check/freshness.ts
439
+ /**
440
+ * The freshness gate of a measured page. A site keeps a file of data stamps.
441
+ * Each stamp names the page, its runs, their dates and their engine versions.
442
+ * The gate fails on a page with no versions or no run date, and on old data with no newer run in flight.
443
+ * The gate also fails on a run that started behind the engines on npm.
444
+ * A fixed study has no age limit. A page with a longer limit names the reason and the scheduled workflow that meets the limit.
445
+ * This file imports only Node.js and the folder `data`, so `reusable-pages.yml` starts it with no npm release of the package.
446
+ */
447
+ /** The schema of a file of data stamps. */
448
+ const DATA_STAMP_SCHEMA = "data-stamp/1";
449
+ /** The largest age of the data of a page, in days, when no newer run is in flight. */
450
+ const MAX_DATA_AGE_DAYS = 3;
451
+ /** The most minor versions that an engine of a run can be behind npm at the start of the run. */
452
+ const MAX_MINORS_BEHIND = 1;
453
+ /** The longest limit of a page, in days, with no ruling for that site. */
454
+ const MAX_LIMIT_DAYS_WITHOUT_RULING = 14;
455
+ const DAY_MS = 864e5;
456
+ const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
457
+ /** This function gives the faults of one run of a stamp. */
458
+ function runFaults(raw, at) {
459
+ const run = record(raw);
460
+ if (run === null) return [`${at}: a run is not an object.`];
461
+ const faults = [];
462
+ if (typeof run["id"] !== "string" || run["id"] === "") faults.push(`${at}: a run has no id.`);
463
+ const label = `${at}, run ${String(run["id"])}`;
464
+ if (typeof run["url"] !== "string" || !run["url"].startsWith("https://")) faults.push(`${label}: the run has no https link.`);
465
+ if (typeof run["date"] !== "string" || !Number.isFinite(Date.parse(run["date"]))) faults.push(`${label}: the run has no date.`);
466
+ const engines = record(run["engines"]);
467
+ if (engines === null || Object.keys(engines).length === 0) return [...faults, `${label}: the run names no engine versions.`];
468
+ for (const [name, version] of Object.entries(engines)) if (typeof version !== "string" || parseVersion(version) === null) faults.push(`${label}: the version of ${name} is not a version.`);
469
+ return faults;
470
+ }
471
+ /** This function gives the faults of the age limit of one page. */
472
+ function limitFaults(raw, at) {
473
+ const limit = record(raw);
474
+ if (limit === null) return [`${at}: the limit is not an object.`];
475
+ const faults = [];
476
+ const days = limit["days"];
477
+ if (typeof days !== "number" || !Number.isFinite(days) || days <= 0) faults.push(`${at}: the limit has no count of days.`);
478
+ if (typeof limit["reason"] !== "string" || limit["reason"].trim() === "") faults.push(`${at}: the limit has no reason.`);
479
+ if (typeof limit["workflow"] !== "string" || limit["workflow"] === "") faults.push(`${at}: the limit names no workflow.`);
480
+ const ruling = limit["ruling"];
481
+ if (ruling !== void 0 && (typeof ruling !== "string" || !ruling.startsWith("https://"))) faults.push(`${at}: the ruling of the limit has no https link.`);
482
+ if (typeof days === "number" && days > 14 && ruling === void 0) faults.push(`${at}: a limit over 14 days needs a ruling for this site.`);
483
+ return faults;
484
+ }
485
+ /** This function gives the faults of the fields of a page that change the age rule. */
486
+ function ageRuleFaults(page, at) {
487
+ const faults = [];
488
+ const study = page["study"];
489
+ if (study !== void 0 && study !== true) faults.push(`${at}: the field study is not true.`);
490
+ if (study === true && page["limit"] !== void 0) faults.push(`${at}: a fixed study has no limit.`);
491
+ if (study === true && page["inFlight"] !== void 0) faults.push(`${at}: a fixed study has no run in flight.`);
492
+ if (page["limit"] !== void 0) faults.push(...limitFaults(page["limit"], at));
493
+ return faults;
494
+ }
495
+ /**
496
+ * This function reads the content of a file of data stamps. It gives each fault of the content, so a page with no versions
497
+ * or no run date fails here.
498
+ */
499
+ function parseDataStamps(raw) {
500
+ const file = record(raw);
501
+ if (file === null) return {
502
+ ok: false,
503
+ faults: ["The file is not an object."]
504
+ };
505
+ if (file["schema"] !== "data-stamp/1") return {
506
+ ok: false,
507
+ faults: [`The schema is not ${DATA_STAMP_SCHEMA}.`]
508
+ };
509
+ const pages = file["pages"];
510
+ if (!Array.isArray(pages) || pages.length === 0) return {
511
+ ok: false,
512
+ faults: ["The file has no pages."]
513
+ };
514
+ const faults = [];
515
+ for (const item of pages) {
516
+ const page = record(item);
517
+ if (page === null || typeof page["page"] !== "string" || page["page"] === "") {
518
+ faults.push("A stamp has no page.");
519
+ continue;
520
+ }
521
+ const at = page["page"];
522
+ const runs = page["runs"];
523
+ if (!Array.isArray(runs) || runs.length === 0) faults.push(`${at}: the page names no run.`);
524
+ else for (const run of runs) faults.push(...runFaults(run, at));
525
+ if (page["inFlight"] !== void 0) faults.push(...runFaults(page["inFlight"], `${at}, in flight`));
526
+ faults.push(...ageRuleFaults(page, at));
527
+ }
528
+ return faults.length > 0 ? {
529
+ ok: false,
530
+ faults
531
+ } : {
532
+ ok: true,
533
+ stamps: raw
534
+ };
535
+ }
536
+ /** This function gives the count of minor versions from `used` to `latest`. A newer major version counts as no limit. */
537
+ function minorsBehind(used, latest) {
538
+ const a = parseVersion(used);
539
+ const b = parseVersion(latest);
540
+ if (a === null || b === null) return Number.POSITIVE_INFINITY;
541
+ if (a.major !== b.major) return b.major > a.major ? Number.POSITIVE_INFINITY : 0;
542
+ return Math.max(0, b.minor - a.minor);
543
+ }
544
+ /** This function gives the newest stable version of a package on npm at a time, or undefined when npm has none. */
545
+ function latestAt(times, name, date) {
546
+ const limit = Date.parse(date);
547
+ return times.filter((t) => t.name === name && Date.parse(t.date) <= limit && parseVersion(t.version)?.prerelease.length === 0).map((t) => t.version).sort(compareVersions).at(-1);
548
+ }
549
+ const ageInDays = (date, now) => (now - Date.parse(date)) / DAY_MS;
550
+ /** This function gives the values of one field of a cron line, or undefined for a form that it cannot read. */
551
+ function cronValues(field, low, high) {
552
+ const values = /* @__PURE__ */ new Set();
553
+ for (const part of field.split(",")) {
554
+ const match = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/.exec(part);
555
+ if (match === null) return void 0;
556
+ const [, range = "", step = "1"] = match;
557
+ const [from = low, to = from] = range === "*" ? [low, high] : range.split("-").map(Number);
558
+ const by = Number(step);
559
+ if (by <= 0 || from < low || to > high || from > to) return void 0;
560
+ for (let value = from; value <= to; value += by) values.add(value);
561
+ }
562
+ return values;
563
+ }
564
+ /** This function gives the start of each day of a cron line, in a span of two years. It gives undefined for a line that it cannot read. */
565
+ function cronDays(line, from, days) {
566
+ const fields = line.trim().split(/\s+/);
567
+ if (fields.length !== 5) return void 0;
568
+ const [minute = "", hour = "", dayOfMonth = "", month = "", dayOfWeek = ""] = fields;
569
+ const minutes = cronValues(minute, 0, 59);
570
+ const hours = cronValues(hour, 0, 23);
571
+ const monthDays = cronValues(dayOfMonth, 1, 31);
572
+ const months = cronValues(month, 1, 12);
573
+ const weekDays = cronValues(dayOfWeek, 0, 6);
574
+ if (minutes === void 0 || hours === void 0 || monthDays === void 0 || months === void 0 || weekDays === void 0) return void 0;
575
+ const firstTime = (Math.min(...hours) * 60 + Math.min(...minutes)) * 6e4;
576
+ const starts = [];
577
+ for (let day = 0; day < days; day += 1) {
578
+ const time = from + day * DAY_MS;
579
+ const date = new Date(time);
580
+ if (!months.has(date.getUTCMonth() + 1)) continue;
581
+ const onMonthDay = monthDays.has(date.getUTCDate());
582
+ const onWeekDay = weekDays.has(date.getUTCDay());
583
+ if (dayOfMonth !== "*" && dayOfWeek !== "*" ? onMonthDay || onWeekDay : onMonthDay && onWeekDay) starts.push(time + firstTime);
584
+ }
585
+ return starts;
586
+ }
587
+ /** This function gives the cron text of one line of a workflow in a list, or an empty list for another line. */
588
+ function cronLine(line) {
589
+ const text = line.trim().replace(/^- */, "");
590
+ if (!text.startsWith("cron:")) return [];
591
+ return [(text.slice(5).split("#")[0]?.trim() ?? "").replaceAll("\"", "").replaceAll("'", "")];
592
+ }
593
+ /**
594
+ * This function gives the largest gap between the scheduled starts of a workflow, in days. The gap counts from the first start of a day.
595
+ * It gives a text in place of the gap for a workflow with no schedule, and for a line that it cannot read. The text names the line, and the gate fails on it.
596
+ */
597
+ function scheduleGapDays(workflow) {
598
+ const lines = workflow.split("\n").flatMap(cronLine);
599
+ if (lines.length === 0) return "the workflow has no line `cron:`";
600
+ const from = Date.UTC(2027, 0, 1);
601
+ const span = 731;
602
+ const starts = [];
603
+ for (const line of lines) {
604
+ const days = cronDays(line, from, span);
605
+ if (days === void 0) return `the gate cannot read the line \`cron: ${line}\``;
606
+ starts.push(...days);
607
+ }
608
+ if (starts.length < 2) return `the lines \`cron: ${lines.join("`, `cron: ")}\` give fewer than two starts in two years`;
609
+ starts.sort((a, b) => a - b);
610
+ let largest = 0;
611
+ for (let index = 1; index < starts.length; index += 1) largest = Math.max(largest, ((starts[index] ?? 0) - (starts[index - 1] ?? 0)) / DAY_MS);
612
+ return largest;
613
+ }
614
+ /** This function tells if a run uses a newer version of an engine than another run. */
615
+ function hasNewerEngine(run, than) {
616
+ return Object.entries(run.engines).some(([name, version]) => {
617
+ const old = than.engines[name];
618
+ return old !== void 0 && compareVersions(version, old) > 0;
619
+ });
620
+ }
621
+ /**
622
+ * This function applies the rules of the gate to the stamps.
623
+ *
624
+ * - The data of each page is at most `maxAgeDays` old, or a run on newer engines is in flight.
625
+ * - The engines of the newest run are at most `maxMinorsBehind` behind npm at the start of that run.
626
+ * - A run in flight has the same age limit, so an old stamp does not hold the gate open.
627
+ * - A page with its own limit uses that limit, and the schedule of its workflow meets the limit.
628
+ * - A fixed study has no age limit and no limit of the engines, because it tells about the versions that it names.
629
+ */
630
+ function checkFreshness(stamps, options) {
631
+ const { times, maxAgeDays: defaultAge = 3, maxMinorsBehind = 1, schedules = {} } = options;
632
+ const now = Date.parse(options.now);
633
+ const faults = [];
634
+ const notRun = [];
635
+ for (const stamp of stamps.pages) {
636
+ for (const run of [...stamp.runs, ...stamp.inFlight === void 0 ? [] : [stamp.inFlight]]) if (ageInDays(run.date, now) < 0) faults.push(`${stamp.page}: the run ${run.id} has a date after the check.`);
637
+ if (stamp.study === true) continue;
638
+ const maxAgeDays = stamp.limit?.days ?? defaultAge;
639
+ if (stamp.limit !== void 0) {
640
+ const gap = schedules[stamp.limit.workflow];
641
+ if (gap === void 0 || typeof gap === "string") faults.push(`${stamp.page}: the limit of ${maxAgeDays} days has no schedule that the gate can read in ${stamp.limit.workflow}: ${gap ?? "the check has no read of this workflow"}.`);
642
+ else if (gap > maxAgeDays) faults.push(`${stamp.page}: the schedule of ${stamp.limit.workflow} has a gap of ${gap.toFixed(1)} days, over the limit of ${maxAgeDays}.`);
643
+ }
644
+ const flight = stamp.inFlight;
645
+ const flying = flight !== void 0 && ageInDays(flight.date, now) <= maxAgeDays;
646
+ if (flight !== void 0 && !flying) faults.push(`${stamp.page}: the run ${flight.id} is in flight for more than ${maxAgeDays} days.`);
647
+ for (const run of stamp.runs) {
648
+ const age = ageInDays(run.date, now);
649
+ if (age > maxAgeDays && !(flying && hasNewerEngine(flight, run))) faults.push(`${stamp.page}: the run ${run.id} is ${age.toFixed(1)} days old, over the limit of ${maxAgeDays}, and no run on newer engines is in flight.`);
650
+ }
651
+ const newest = flight ?? [...stamp.runs].sort((a, b) => Date.parse(a.date) - Date.parse(b.date)).at(-1);
652
+ if (newest === void 0) continue;
653
+ for (const [name, version] of Object.entries(newest.engines)) {
654
+ const latest = latestAt(times, name, newest.date);
655
+ if (latest === void 0) notRun.push(`${stamp.page}: npm gives no version of ${name} at the start of the run ${newest.id}.`);
656
+ else if (minorsBehind(version, latest) > maxMinorsBehind) faults.push(`${stamp.page}: the run ${newest.id} used ${name} ${version}, and npm had ${latest} at its start. The limit is ${maxMinorsBehind} minor version behind.`);
657
+ }
658
+ }
659
+ return {
660
+ faults,
661
+ notRun
662
+ };
663
+ }
664
+ /** The help of the command `site-pages freshness`. */
665
+ const FRESHNESS_HELP = `Usage: site-pages freshness --stamps <file> [options]
666
+
667
+ Examine the data stamps of the measured pages of a site.
668
+
669
+ --stamps <file> The file of data stamps, schema ${DATA_STAMP_SCHEMA}. (necessary)
670
+ --now <time> The ISO time of the check. The default is the time of the call.
671
+ --help Show this text.
672
+
673
+ The exit code is 0 when the stamps pass, 1 for a fault, and 2 for an incorrect command line.
674
+ `;
675
+ /** This function gives the largest gap of the schedule in a workflow file. It gives a text for a file that it cannot read. */
676
+ function readSchedule(path) {
677
+ try {
678
+ return scheduleGapDays(readFileSync(path, "utf8"));
679
+ } catch (error) {
680
+ return `the file cannot be read: ${error instanceof Error ? error.message : String(error)}`;
681
+ }
682
+ }
683
+ /** This function is the command `site-pages freshness`. The `fetch` option is for a test. */
684
+ async function runFreshnessCli(argv, io, fetcher) {
685
+ let values;
686
+ try {
687
+ ({values} = parseArgs({
688
+ args: [...argv],
689
+ allowPositionals: false,
690
+ options: {
691
+ stamps: { type: "string" },
692
+ now: { type: "string" },
693
+ help: { type: "boolean" }
694
+ }
695
+ }));
696
+ } catch (error) {
697
+ io.stderr(`site-pages freshness: ${error instanceof Error ? error.message : String(error)}\n\n${FRESHNESS_HELP}`);
698
+ return 2;
699
+ }
700
+ if (values.help === true) {
701
+ io.stdout(FRESHNESS_HELP);
702
+ return 0;
703
+ }
704
+ if (values.stamps === void 0) {
705
+ io.stderr(`site-pages freshness: --stamps is necessary.\n\n${FRESHNESS_HELP}`);
706
+ return 2;
707
+ }
708
+ const path = isAbsolute(values.stamps) ? values.stamps : join(io.cwd, values.stamps);
709
+ let raw;
710
+ try {
711
+ raw = JSON.parse(readFileSync(path, "utf8"));
712
+ } catch (error) {
713
+ io.stderr(`site-pages freshness: the file ${values.stamps} cannot be read: ${error instanceof Error ? error.message : String(error)}\n`);
714
+ return 1;
715
+ }
716
+ const parsed = parseDataStamps(raw);
717
+ if (!parsed.ok) {
718
+ for (const fault of parsed.faults) io.stderr(`${fault}\n`);
719
+ return 1;
720
+ }
721
+ const engines = parsed.stamps.pages.filter((p) => p.study !== true).flatMap((p) => [...p.runs, ...p.inFlight === void 0 ? [] : [p.inFlight]]).flatMap((r) => Object.keys(r.engines));
722
+ const schedules = {};
723
+ for (const page of parsed.stamps.pages) if (page.limit !== void 0) schedules[page.limit.workflow] = readSchedule(join(io.cwd, page.limit.workflow));
724
+ const npm = await npmPublishTimes({
725
+ packages: [...new Set(engines)],
726
+ ...fetcher === void 0 ? {} : { fetch: fetcher }
727
+ });
728
+ const result = checkFreshness(parsed.stamps, {
729
+ now: values.now ?? (/* @__PURE__ */ new Date()).toISOString(),
730
+ times: npm.times,
731
+ schedules
732
+ });
733
+ for (const problem of [...npm.problems, ...result.notRun]) io.stdout(`Not run: ${problem}\n`);
734
+ for (const fault of result.faults) io.stderr(`${fault}\n`);
735
+ if (result.faults.length > 0) return 1;
736
+ io.stdout(`The data stamps of ${parsed.stamps.pages.length} pages pass.\n`);
737
+ return 0;
738
+ }
739
+ export { npmPublishTimes as A, writeKpi as C, isShallow as D, headingDate as E, kindOf as M, parseVersion as N, originRepository as O, versionOfTag as P, readKpi as S, headCommit as T, KPI_FORMAT as _, checkFreshness as a, kpiProblems as b, parseDataStamps as c, inlineMarkdown as d, link as f, KPI_FILE as g, safeUrl as h, MAX_MINORS_BEHIND as i, compareVersions as j, NPM_REGISTRY as k, runFreshnessCli as l, plural as m, MAX_DATA_AGE_DAYS as n, latestAt as o, listText as p, MAX_LIMIT_DAYS_WITHOUT_RULING as r, minorsBehind as s, DATA_STAMP_SCHEMA as t, escapeHtml as u, KpiError as v, gitTagDates as w, kpiText as x, kpiFile as y };
740
+
741
+ //# sourceMappingURL=freshness.js.map