@mark1russell7/site-pages 0.0.0-bootstrap.0 → 0.1.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 +281 -1
- package/build/build-vAps0mRE.js +2267 -0
- package/build/build-vAps0mRE.js.map +1 -0
- package/build/cli.d.ts +1 -0
- package/build/cli.js +18 -0
- package/build/cli.js.map +1 -0
- package/build/index.d.ts +737 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +1 -0
- package/package.json +53 -4
|
@@ -0,0 +1,2267 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, posix, resolve } from "node:path";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
//#region 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
|
+
//#endregion
|
|
68
|
+
//#region src/data/changelog.ts
|
|
69
|
+
const VERSION_HEADING = /^##\s+\[?v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\]?(?:\s.*)?$/;
|
|
70
|
+
const COMMIT_ENTRY = /^([0-9a-f]{7,40}):\s+([\s\S]*)$/;
|
|
71
|
+
const DEPENDENCY_ENTRY = /^(?:Updated dependencies\b|@?[\w.-]+(?:\/[\w.-]+)?@\S+\s*$)/;
|
|
72
|
+
const GENERATED_NOTE = /\s+by\s+@[\w-]+(?:\[bot\])?\s+in\s+(https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/(\d+))\s*$/;
|
|
73
|
+
const FIRST_CONTRIBUTION = /\bmade their first contribution\b/;
|
|
74
|
+
/**
|
|
75
|
+
* This function gives the first sentence of a text. A full stop, a question mark or an exclamation
|
|
76
|
+
* mark ends the sentence when a space and no lowercase letter follow it.
|
|
77
|
+
*
|
|
78
|
+
* @remarks A mark in a code span does not end the sentence. A text that ends with a colon (a list
|
|
79
|
+
* follows) ends with a full stop.
|
|
80
|
+
*/
|
|
81
|
+
function firstSentence(text) {
|
|
82
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
83
|
+
let code = false;
|
|
84
|
+
for (let index = 0; index < clean.length; index++) {
|
|
85
|
+
const char = clean[index];
|
|
86
|
+
if (char === "`") {
|
|
87
|
+
code = !code;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (code || char !== "." && char !== "?" && char !== "!") continue;
|
|
91
|
+
const next = clean[index + 1];
|
|
92
|
+
if (next === void 0) break;
|
|
93
|
+
if (next === " " && !/^[a-z]/.test(clean.slice(index + 2))) return clean.slice(0, index + 1);
|
|
94
|
+
}
|
|
95
|
+
return clean.replace(/:$/, ".");
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* This function makes one entry from the lines of a list item. The text is the first paragraph: the
|
|
99
|
+
* lines before an empty line or a nested list. It gives null for an entry that is not a change.
|
|
100
|
+
*/
|
|
101
|
+
function entryOf(lines) {
|
|
102
|
+
const paragraph = [];
|
|
103
|
+
for (const [index, line] of lines.entries()) {
|
|
104
|
+
if (index > 0 && (line.trim() === "" || /^\s+[-*+]\s/.test(line))) break;
|
|
105
|
+
paragraph.push(line.trim());
|
|
106
|
+
}
|
|
107
|
+
const text = paragraph.join(" ");
|
|
108
|
+
const commit = COMMIT_ENTRY.exec(text);
|
|
109
|
+
let body = (commit ? commit[2] : text) ?? "";
|
|
110
|
+
if (body.trim() === "" || DEPENDENCY_ENTRY.test(body.trim()) || FIRST_CONTRIBUTION.test(body)) return null;
|
|
111
|
+
body = body.replace(GENERATED_NOTE, (_match, url, number) => ` ([#${number}](${url}))`);
|
|
112
|
+
return {
|
|
113
|
+
commit: commit?.[1] ?? null,
|
|
114
|
+
text: firstSentence(body)
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* This function gives the entries of each version of a changelog, in the sequence of the file.
|
|
119
|
+
*
|
|
120
|
+
* @remarks An entry is a line that starts with "- " and the indented lines under it. An entry for a
|
|
121
|
+
* new dependency version is not a change.
|
|
122
|
+
*/
|
|
123
|
+
function parseChangelog(markdown) {
|
|
124
|
+
const sections = [];
|
|
125
|
+
let section = null;
|
|
126
|
+
let entry = null;
|
|
127
|
+
const close = () => {
|
|
128
|
+
if (entry && section) section.raw.push(entry);
|
|
129
|
+
entry = null;
|
|
130
|
+
};
|
|
131
|
+
for (const line of markdown.split(/\r?\n/)) {
|
|
132
|
+
const heading = VERSION_HEADING.exec(line);
|
|
133
|
+
if (heading?.[1] !== void 0) {
|
|
134
|
+
close();
|
|
135
|
+
section = {
|
|
136
|
+
version: heading[1],
|
|
137
|
+
heading: line.replace(/^##\s+/, "").trim(),
|
|
138
|
+
raw: []
|
|
139
|
+
};
|
|
140
|
+
sections.push(section);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (section === null) continue;
|
|
144
|
+
if (line.startsWith("- ")) {
|
|
145
|
+
close();
|
|
146
|
+
entry = [line.slice(2)];
|
|
147
|
+
} else if (entry && (line.trim() === "" || /^\s/.test(line))) entry.push(line);
|
|
148
|
+
else {
|
|
149
|
+
close();
|
|
150
|
+
if (/^#{1,2}\s/.test(line)) section = null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
close();
|
|
154
|
+
return sections.map(({ version, heading, raw }) => ({
|
|
155
|
+
version,
|
|
156
|
+
heading,
|
|
157
|
+
entries: raw.map(entryOf).filter((change) => change !== null)
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* This function gives the changes of the text of a GitHub release: one change for each top-level
|
|
162
|
+
* list item. Fenced code is not read.
|
|
163
|
+
*/
|
|
164
|
+
function releaseBodyEntries(body) {
|
|
165
|
+
const entries = [];
|
|
166
|
+
let entry = null;
|
|
167
|
+
let fence = null;
|
|
168
|
+
const close = () => {
|
|
169
|
+
const change = entry ? entryOf(entry) : null;
|
|
170
|
+
if (change) entries.push(change);
|
|
171
|
+
entry = null;
|
|
172
|
+
};
|
|
173
|
+
for (const line of body.split(/\r?\n/)) {
|
|
174
|
+
const fenceMark = /^\s*(`{3,}|~{3,})/.exec(line)?.[1];
|
|
175
|
+
if (fenceMark !== void 0) {
|
|
176
|
+
close();
|
|
177
|
+
if (fence === null) fence = fenceMark[0] ?? null;
|
|
178
|
+
else if (fenceMark[0] === fence) fence = null;
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
if (fence !== null) continue;
|
|
182
|
+
const item = /^[-*+]\s+(.*)$/.exec(line);
|
|
183
|
+
if (item?.[1] !== void 0) {
|
|
184
|
+
close();
|
|
185
|
+
entry = [item[1]];
|
|
186
|
+
} else if (entry && (line.trim() === "" || /^\s/.test(line))) entry.push(line);
|
|
187
|
+
else close();
|
|
188
|
+
}
|
|
189
|
+
close();
|
|
190
|
+
return entries;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* This function gives the anchor of a heading on GitHub: lowercase, no punctuation, and a hyphen
|
|
194
|
+
* for each space. The anchor of "0.13.2" is "0132".
|
|
195
|
+
*/
|
|
196
|
+
function headingAnchor(heading) {
|
|
197
|
+
return heading.toLowerCase().trim().replace(/[^\p{L}\p{N}\s_-]/gu, "").replace(/\s/g, "-");
|
|
198
|
+
}
|
|
199
|
+
//#endregion
|
|
200
|
+
//#region src/data/github.ts
|
|
201
|
+
/** The GraphQL endpoint of GitHub. */
|
|
202
|
+
const GITHUB_GRAPHQL = "https://api.github.com/graphql";
|
|
203
|
+
/** The REST endpoint of GitHub. */
|
|
204
|
+
const GITHUB_API = "https://api.github.com";
|
|
205
|
+
const USER_AGENT = "site-pages";
|
|
206
|
+
const OPEN_WORK_QUERY = `query OpenWork($owner: String!, $repo: String!, $first: Int!) {
|
|
207
|
+
repository(owner: $owner, name: $repo) {
|
|
208
|
+
pullRequests(states: OPEN, first: $first, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
209
|
+
totalCount
|
|
210
|
+
nodes {
|
|
211
|
+
number title url isDraft createdAt updatedAt headRefName
|
|
212
|
+
author { login }
|
|
213
|
+
labels(first: 10) { nodes { name } }
|
|
214
|
+
commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
issues(states: OPEN, first: $first, orderBy: {field: UPDATED_AT, direction: DESC}) {
|
|
218
|
+
totalCount
|
|
219
|
+
nodes {
|
|
220
|
+
number title url createdAt updatedAt
|
|
221
|
+
author { login }
|
|
222
|
+
labels(first: 10) { nodes { name } }
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}`;
|
|
227
|
+
/** This function gives the combined check state of a commit: success, failure, pending or none. */
|
|
228
|
+
function checkStateOf(rollup) {
|
|
229
|
+
switch (rollup?.state) {
|
|
230
|
+
case "SUCCESS": return "success";
|
|
231
|
+
case "FAILURE":
|
|
232
|
+
case "ERROR": return "failure";
|
|
233
|
+
case "PENDING":
|
|
234
|
+
case "EXPECTED": return "pending";
|
|
235
|
+
default: return "none";
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const object$1 = (value) => value !== null && typeof value === "object" ? value : {};
|
|
239
|
+
const list = (value) => Array.isArray(value) ? value : [];
|
|
240
|
+
const text = (value, fallback = "") => typeof value === "string" ? value : fallback;
|
|
241
|
+
const labelsOf = (node) => list(object$1(node["labels"])["nodes"]).map((label) => object$1(label)["name"]).filter((name) => typeof name === "string");
|
|
242
|
+
const authorOf = (node) => text(object$1(node["author"])["login"], "ghost");
|
|
243
|
+
function failure(error, timeoutMs) {
|
|
244
|
+
if (object$1(error)["name"] === "TimeoutError") return {
|
|
245
|
+
available: false,
|
|
246
|
+
reason: `GitHub did not answer in ${timeoutMs / 1e3} s`
|
|
247
|
+
};
|
|
248
|
+
return {
|
|
249
|
+
available: false,
|
|
250
|
+
reason: `the request failed: ${error instanceof Error ? error.message : String(error)}`
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
function precheck(options) {
|
|
254
|
+
if (!options.owner || !options.repo) return {
|
|
255
|
+
available: false,
|
|
256
|
+
reason: "no repository was given"
|
|
257
|
+
};
|
|
258
|
+
if (!options.token) return {
|
|
259
|
+
available: false,
|
|
260
|
+
reason: "the build had no GitHub token"
|
|
261
|
+
};
|
|
262
|
+
if (typeof (options.fetch ?? globalThis.fetch) !== "function") return {
|
|
263
|
+
available: false,
|
|
264
|
+
reason: "this Node has no fetch"
|
|
265
|
+
};
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* This function reads the open pull requests and issues of a repository with one GraphQL request,
|
|
270
|
+
* the most recently updated first. It gives `{ available: false, reason }` without a token or after
|
|
271
|
+
* an error. It does not throw.
|
|
272
|
+
*/
|
|
273
|
+
async function githubOpenWork(options) {
|
|
274
|
+
const stop = precheck(options);
|
|
275
|
+
if (stop) return stop;
|
|
276
|
+
const { owner, repo, token, endpoint = GITHUB_GRAPHQL, first = 50, timeoutMs = 15e3 } = options;
|
|
277
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
278
|
+
try {
|
|
279
|
+
const response = await fetcher(endpoint, {
|
|
280
|
+
method: "POST",
|
|
281
|
+
headers: {
|
|
282
|
+
authorization: `bearer ${token}`,
|
|
283
|
+
"content-type": "application/json",
|
|
284
|
+
"user-agent": USER_AGENT
|
|
285
|
+
},
|
|
286
|
+
body: JSON.stringify({
|
|
287
|
+
query: OPEN_WORK_QUERY,
|
|
288
|
+
variables: {
|
|
289
|
+
owner,
|
|
290
|
+
repo,
|
|
291
|
+
first
|
|
292
|
+
}
|
|
293
|
+
}),
|
|
294
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
295
|
+
});
|
|
296
|
+
if (!response.ok) return {
|
|
297
|
+
available: false,
|
|
298
|
+
reason: `GitHub answered with HTTP ${response.status}`
|
|
299
|
+
};
|
|
300
|
+
const body = object$1(await response.json());
|
|
301
|
+
const errors = list(body["errors"]);
|
|
302
|
+
if (errors.length > 0) return {
|
|
303
|
+
available: false,
|
|
304
|
+
reason: `GitHub gave an error: ${text(object$1(errors[0])["message"], "no message")}`
|
|
305
|
+
};
|
|
306
|
+
const repository = object$1(body["data"])["repository"];
|
|
307
|
+
if (repository === null || typeof repository !== "object") return {
|
|
308
|
+
available: false,
|
|
309
|
+
reason: `GitHub has no repository ${owner}/${repo}`
|
|
310
|
+
};
|
|
311
|
+
const pulls = object$1(repository["pullRequests"]);
|
|
312
|
+
const open = object$1(repository["issues"]);
|
|
313
|
+
const pullRequests = list(pulls["nodes"]).filter((node) => node !== null && typeof node === "object").map((value) => {
|
|
314
|
+
const node = object$1(value);
|
|
315
|
+
const lastCommit = object$1(object$1(list(object$1(node["commits"])["nodes"])[0])["commit"]);
|
|
316
|
+
return {
|
|
317
|
+
number: Number(node["number"]),
|
|
318
|
+
title: text(node["title"]),
|
|
319
|
+
url: text(node["url"]),
|
|
320
|
+
author: authorOf(node),
|
|
321
|
+
branch: text(node["headRefName"]),
|
|
322
|
+
draft: node["isDraft"] === true,
|
|
323
|
+
createdAt: text(node["createdAt"]),
|
|
324
|
+
updatedAt: text(node["updatedAt"]),
|
|
325
|
+
labels: labelsOf(node),
|
|
326
|
+
checks: checkStateOf(object$1(lastCommit["statusCheckRollup"]))
|
|
327
|
+
};
|
|
328
|
+
});
|
|
329
|
+
const issues = list(open["nodes"]).filter((node) => node !== null && typeof node === "object").map((value) => {
|
|
330
|
+
const node = object$1(value);
|
|
331
|
+
return {
|
|
332
|
+
number: Number(node["number"]),
|
|
333
|
+
title: text(node["title"]),
|
|
334
|
+
url: text(node["url"]),
|
|
335
|
+
author: authorOf(node),
|
|
336
|
+
createdAt: text(node["createdAt"]),
|
|
337
|
+
updatedAt: text(node["updatedAt"]),
|
|
338
|
+
labels: labelsOf(node)
|
|
339
|
+
};
|
|
340
|
+
});
|
|
341
|
+
const total = (value, fallback) => typeof value === "number" ? value : fallback;
|
|
342
|
+
return {
|
|
343
|
+
available: true,
|
|
344
|
+
pullRequests,
|
|
345
|
+
issues,
|
|
346
|
+
totals: {
|
|
347
|
+
pullRequests: total(pulls["totalCount"], pullRequests.length),
|
|
348
|
+
issues: total(open["totalCount"], issues.length)
|
|
349
|
+
}
|
|
350
|
+
};
|
|
351
|
+
} catch (error) {
|
|
352
|
+
return failure(error, timeoutMs);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/** This function gives the URL of the next page from a `Link` header of GitHub, or null. */
|
|
356
|
+
function nextPage(link) {
|
|
357
|
+
if (!link) return null;
|
|
358
|
+
for (const part of link.split(",")) {
|
|
359
|
+
const match = /<([^>]+)>\s*;\s*rel="next"/.exec(part);
|
|
360
|
+
if (match?.[1]) return match[1];
|
|
361
|
+
}
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* This function reads the published releases of a repository with the REST API, the newest first.
|
|
366
|
+
* It skips the drafts. It gives `{ available: false, reason }` without a token or after an error.
|
|
367
|
+
* It does not throw.
|
|
368
|
+
*
|
|
369
|
+
* @remarks The function reads 100 releases on each page, and at most `maxPages` pages (default 10).
|
|
370
|
+
*/
|
|
371
|
+
async function githubReleases(options) {
|
|
372
|
+
const stop = precheck(options);
|
|
373
|
+
if (stop) return stop;
|
|
374
|
+
const { owner, repo, token, endpoint = GITHUB_API, maxPages = 10, timeoutMs = 15e3 } = options;
|
|
375
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
376
|
+
const releases = [];
|
|
377
|
+
let url = `${endpoint}/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/releases?per_page=100`;
|
|
378
|
+
try {
|
|
379
|
+
for (let page = 0; url !== null && page < maxPages; page++) {
|
|
380
|
+
const response = await fetcher(url, {
|
|
381
|
+
headers: {
|
|
382
|
+
accept: "application/vnd.github+json",
|
|
383
|
+
authorization: `Bearer ${token}`,
|
|
384
|
+
"user-agent": USER_AGENT,
|
|
385
|
+
"x-github-api-version": "2022-11-28"
|
|
386
|
+
},
|
|
387
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
388
|
+
});
|
|
389
|
+
if (response.status === 404) return {
|
|
390
|
+
available: false,
|
|
391
|
+
reason: `GitHub has no repository ${owner}/${repo}`
|
|
392
|
+
};
|
|
393
|
+
if (!response.ok) return {
|
|
394
|
+
available: false,
|
|
395
|
+
reason: `GitHub answered with HTTP ${response.status}`
|
|
396
|
+
};
|
|
397
|
+
const body = await response.json();
|
|
398
|
+
if (!Array.isArray(body)) return {
|
|
399
|
+
available: false,
|
|
400
|
+
reason: "GitHub gave no list of releases"
|
|
401
|
+
};
|
|
402
|
+
for (const value of body) {
|
|
403
|
+
const release = object$1(value);
|
|
404
|
+
const tag = release["tag_name"];
|
|
405
|
+
if (release["draft"] === true || typeof tag !== "string") continue;
|
|
406
|
+
releases.push({
|
|
407
|
+
tag,
|
|
408
|
+
name: text(release["name"]) || tag,
|
|
409
|
+
body: text(release["body"]),
|
|
410
|
+
publishedAt: text(release["published_at"]) || text(release["created_at"]),
|
|
411
|
+
url: text(release["html_url"]),
|
|
412
|
+
prerelease: release["prerelease"] === true
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
url = nextPage(response.headers.get("link"));
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
available: true,
|
|
419
|
+
releases
|
|
420
|
+
};
|
|
421
|
+
} catch (error) {
|
|
422
|
+
return failure(error, timeoutMs);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
//#endregion
|
|
426
|
+
//#region src/data/npm.ts
|
|
427
|
+
/** The public npm registry. */
|
|
428
|
+
const NPM_REGISTRY = "https://registry.npmjs.org";
|
|
429
|
+
const object = (value) => value !== null && typeof value === "object" ? value : {};
|
|
430
|
+
function message(error) {
|
|
431
|
+
if (object(error)["name"] === "TimeoutError") return "npm did not answer in time";
|
|
432
|
+
return error instanceof Error ? error.message : String(error);
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* This function reads the publication time of each version of the packages from the npm registry.
|
|
436
|
+
* A scope (`@name`) gives each package of the scope. It does not throw. Each failure gives a
|
|
437
|
+
* message in `problems`.
|
|
438
|
+
*/
|
|
439
|
+
async function npmPublishTimes(options) {
|
|
440
|
+
const { registry = NPM_REGISTRY, timeoutMs = 15e3 } = options;
|
|
441
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
442
|
+
const problems = [];
|
|
443
|
+
const times = [];
|
|
444
|
+
if (typeof fetcher !== "function") return {
|
|
445
|
+
times,
|
|
446
|
+
problems: ["this Node has no fetch"]
|
|
447
|
+
};
|
|
448
|
+
const get = async (url) => {
|
|
449
|
+
const response = await fetcher(url, {
|
|
450
|
+
headers: { accept: "application/json" },
|
|
451
|
+
signal: AbortSignal.timeout(timeoutMs)
|
|
452
|
+
});
|
|
453
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
454
|
+
return response.json();
|
|
455
|
+
};
|
|
456
|
+
const names = /* @__PURE__ */ new Set();
|
|
457
|
+
for (const entry of options.packages.map((name) => name.trim()).filter(Boolean)) {
|
|
458
|
+
if (!/^@[^/]+$/.test(entry)) {
|
|
459
|
+
names.add(entry);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
try {
|
|
463
|
+
const found = object(await get(`${registry}/-/v1/search?text=scope:${encodeURIComponent(entry.slice(1))}&size=250`));
|
|
464
|
+
const objects = Array.isArray(found["objects"]) ? found["objects"] : [];
|
|
465
|
+
const before = names.size;
|
|
466
|
+
for (const item of objects) {
|
|
467
|
+
const name = object(object(item)["package"])["name"];
|
|
468
|
+
if (typeof name === "string" && name.startsWith(`${entry}/`)) names.add(name);
|
|
469
|
+
}
|
|
470
|
+
if (names.size === before) problems.push(`npm has no package in the scope ${entry}`);
|
|
471
|
+
} catch (error) {
|
|
472
|
+
problems.push(`npm did not give the packages of ${entry}: ${message(error)}`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const queue = [...names].sort();
|
|
476
|
+
const worker = async () => {
|
|
477
|
+
for (let name = queue.shift(); name !== void 0; name = queue.shift()) try {
|
|
478
|
+
const packument = object(await get(`${registry}/${name.replace("/", "%2f")}`));
|
|
479
|
+
const published = object(packument["versions"]);
|
|
480
|
+
for (const [version, date] of Object.entries(object(packument["time"]))) {
|
|
481
|
+
if (version === "created" || version === "modified" || typeof date !== "string") continue;
|
|
482
|
+
if (!(version in published)) continue;
|
|
483
|
+
times.push({
|
|
484
|
+
name,
|
|
485
|
+
version,
|
|
486
|
+
date
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
} catch (error) {
|
|
490
|
+
problems.push(`npm did not give the versions of ${name}: ${message(error)}`);
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
await Promise.all(Array.from({ length: 4 }, worker));
|
|
494
|
+
times.sort((a, b) => a.name.localeCompare(b.name) || a.date.localeCompare(b.date));
|
|
495
|
+
return {
|
|
496
|
+
times,
|
|
497
|
+
problems
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
//#endregion
|
|
501
|
+
//#region src/data/git.ts
|
|
502
|
+
/**
|
|
503
|
+
* The git source: the tags with their dates, the commit of the build and the GitHub remote. Each
|
|
504
|
+
* function gives an empty result when git or the repository is not available.
|
|
505
|
+
*/
|
|
506
|
+
/** This function starts git in a folder and gives its output, or null when git fails. */
|
|
507
|
+
function git(repoDir, ...args) {
|
|
508
|
+
try {
|
|
509
|
+
return execFileSync("git", args, {
|
|
510
|
+
cwd: repoDir,
|
|
511
|
+
encoding: "utf8",
|
|
512
|
+
stdio: [
|
|
513
|
+
"ignore",
|
|
514
|
+
"pipe",
|
|
515
|
+
"ignore"
|
|
516
|
+
],
|
|
517
|
+
windowsHide: true
|
|
518
|
+
});
|
|
519
|
+
} catch {
|
|
520
|
+
return null;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
/** This function tells if the clone is shallow. A shallow clone has not all the tags and commits. */
|
|
524
|
+
function isShallow(repoDir) {
|
|
525
|
+
return git(repoDir, "rev-parse", "--is-shallow-repository")?.trim() === "true";
|
|
526
|
+
}
|
|
527
|
+
/** This function gives each tag of the repository with its date. An annotated tag has its own date. */
|
|
528
|
+
function gitTagDates(repoDir) {
|
|
529
|
+
const output = git(repoDir, "for-each-ref", "--format=%(refname:strip=2) %(creatordate:iso-strict)", "refs/tags");
|
|
530
|
+
if (!output) return [];
|
|
531
|
+
const tags = [];
|
|
532
|
+
for (const line of output.split(/\r?\n/)) {
|
|
533
|
+
const match = /^(\S+) (\S+)$/.exec(line.trim());
|
|
534
|
+
if (match?.[1] && match[2]) tags.push({
|
|
535
|
+
tag: match[1],
|
|
536
|
+
date: match[2]
|
|
537
|
+
});
|
|
538
|
+
}
|
|
539
|
+
return tags;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* This function gives the date of the oldest commit that added a version heading to a changelog,
|
|
543
|
+
* or null. The path of the changelog starts at `repoDir`.
|
|
544
|
+
*/
|
|
545
|
+
function headingDate(repoDir, version, file) {
|
|
546
|
+
const dates = (git(repoDir, "log", "--format=%cI", `-S## ${version}`, "--", file) ?? "").trim().split(/\r?\n/).filter(Boolean);
|
|
547
|
+
return dates[dates.length - 1] ?? null;
|
|
548
|
+
}
|
|
549
|
+
/** This function gives the full SHA of `HEAD`, or null. */
|
|
550
|
+
function headCommit(repoDir) {
|
|
551
|
+
const sha = git(repoDir, "rev-parse", "HEAD")?.trim();
|
|
552
|
+
return sha && /^[0-9a-f]{40}$/.test(sha) ? sha : null;
|
|
553
|
+
}
|
|
554
|
+
/** This function gives "owner/name" of the GitHub remote "origin", or null. */
|
|
555
|
+
function originRepository(repoDir) {
|
|
556
|
+
const url = git(repoDir, "remote", "get-url", "origin")?.trim() ?? "";
|
|
557
|
+
const match = /github\.com[:/]([^/]+)\/(.+?)(?:\.git)?\/?$/.exec(url);
|
|
558
|
+
return match ? `${match[1]}/${match[2]}` : null;
|
|
559
|
+
}
|
|
560
|
+
//#endregion
|
|
561
|
+
//#region src/data/glob.ts
|
|
562
|
+
/**
|
|
563
|
+
* A small glob for the changelog files. The syntax has `*` and `?` in one path segment, and `**` for
|
|
564
|
+
* zero or more folders.
|
|
565
|
+
*/
|
|
566
|
+
function segmentPattern(segment) {
|
|
567
|
+
const source = segment.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]");
|
|
568
|
+
return new RegExp(`^${source}$`);
|
|
569
|
+
}
|
|
570
|
+
const SKIP = /* @__PURE__ */ new Set(["node_modules", ".git"]);
|
|
571
|
+
/**
|
|
572
|
+
* This function gives the files under `root` that match a pattern, as paths with `/` that start at
|
|
573
|
+
* `root`, sorted. It does not go into `node_modules` or `.git`.
|
|
574
|
+
*/
|
|
575
|
+
function globFiles(root, pattern) {
|
|
576
|
+
const segments = pattern.replace(/\\/g, "/").replace(/^\.\//, "").split("/").filter(Boolean);
|
|
577
|
+
const found = /* @__PURE__ */ new Set();
|
|
578
|
+
const walk = (dir, prefix, index) => {
|
|
579
|
+
const segment = segments[index];
|
|
580
|
+
if (segment === void 0) return;
|
|
581
|
+
const last = index === segments.length - 1;
|
|
582
|
+
if (segment === "**") {
|
|
583
|
+
walk(dir, prefix, index + 1);
|
|
584
|
+
for (const entry of safeList(dir)) if (entry.isDirectory() && !SKIP.has(entry.name)) walk(join(dir, entry.name), `${prefix}${entry.name}/`, index);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (!/[*?]/.test(segment)) {
|
|
588
|
+
const path = join(dir, segment);
|
|
589
|
+
if (!existsSync(path)) return;
|
|
590
|
+
if (last) {
|
|
591
|
+
if (statSync(path).isFile()) found.add(`${prefix}${segment}`);
|
|
592
|
+
} else if (statSync(path).isDirectory()) walk(path, `${prefix}${segment}/`, index + 1);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
const matcher = segmentPattern(segment);
|
|
596
|
+
for (const entry of safeList(dir)) {
|
|
597
|
+
if (!matcher.test(entry.name) || SKIP.has(entry.name)) continue;
|
|
598
|
+
if (last && entry.isFile()) found.add(`${prefix}${entry.name}`);
|
|
599
|
+
else if (!last && entry.isDirectory()) walk(join(dir, entry.name), `${prefix}${entry.name}/`, index + 1);
|
|
600
|
+
}
|
|
601
|
+
};
|
|
602
|
+
walk(root, "", 0);
|
|
603
|
+
return [...found].sort();
|
|
604
|
+
}
|
|
605
|
+
function safeList(dir) {
|
|
606
|
+
try {
|
|
607
|
+
return readdirSync(dir, { withFileTypes: true });
|
|
608
|
+
} catch {
|
|
609
|
+
return [];
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
//#endregion
|
|
613
|
+
//#region src/data/releases.ts
|
|
614
|
+
/**
|
|
615
|
+
* The release data: one entry for each version. The sources are the changelogs of Changesets, the
|
|
616
|
+
* GitHub releases, the git tags and the publication times on npm.
|
|
617
|
+
*/
|
|
618
|
+
/** An error in the release sources, for example a group without a changelog. */
|
|
619
|
+
var ReleaseDataError = class extends Error {};
|
|
620
|
+
function addChange(changes, entry, packageName) {
|
|
621
|
+
const key = entry.commit ?? `text:${entry.text}`;
|
|
622
|
+
const change = changes.get(key) ?? {
|
|
623
|
+
commit: entry.commit,
|
|
624
|
+
text: entry.text,
|
|
625
|
+
packages: []
|
|
626
|
+
};
|
|
627
|
+
if (packageName && !change.packages.includes(packageName)) change.packages.push(packageName);
|
|
628
|
+
changes.set(key, change);
|
|
629
|
+
}
|
|
630
|
+
function sortedChanges(changes) {
|
|
631
|
+
return [...changes?.values() ?? []].map((change) => ({
|
|
632
|
+
...change,
|
|
633
|
+
packages: [...change.packages].sort()
|
|
634
|
+
})).sort((a, b) => b.packages.length - a.packages.length || a.text.localeCompare(b.text));
|
|
635
|
+
}
|
|
636
|
+
/** The earliest of some ISO dates, or null. */
|
|
637
|
+
function earliest(dates) {
|
|
638
|
+
let best = null;
|
|
639
|
+
for (const date of dates) {
|
|
640
|
+
if (!date || !Number.isFinite(Date.parse(date))) continue;
|
|
641
|
+
if (best === null || Date.parse(date) < Date.parse(best)) best = date;
|
|
642
|
+
}
|
|
643
|
+
return best;
|
|
644
|
+
}
|
|
645
|
+
/** The URL path of a git ref: each character that a URL cannot hold is encoded, and "@" and "/" stay. */
|
|
646
|
+
function refPath(ref) {
|
|
647
|
+
return encodeURI(ref).replace(/#/g, "%23").replace(/\?/g, "%3F");
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* This function merges the release sources into one entry for each version, the newest first.
|
|
651
|
+
*
|
|
652
|
+
* - The versions: the changelog of `group`, or else each changelog version and each GitHub release.
|
|
653
|
+
* - The date: the git tag, else the GitHub release, else npm, else the commit of the changelog heading.
|
|
654
|
+
* - The changes: the changelog entries, else the list items of the GitHub release text.
|
|
655
|
+
*
|
|
656
|
+
* @throws ReleaseDataError when `group` has no changelog and other changelogs exist
|
|
657
|
+
*/
|
|
658
|
+
function mergeReleases(sources) {
|
|
659
|
+
const { group, repository, ref = "HEAD", warn = () => {} } = sources;
|
|
660
|
+
const changelogs = sources.changelogs ?? [];
|
|
661
|
+
const inGroup = (packageName) => !group || packageName === null || packageName === group;
|
|
662
|
+
const parsed = changelogs.map((source) => ({
|
|
663
|
+
source,
|
|
664
|
+
sections: parseChangelog(source.markdown)
|
|
665
|
+
}));
|
|
666
|
+
const groupChangelog = group ? parsed.find((entry) => entry.source.name === group) : void 0;
|
|
667
|
+
if (group && parsed.length > 0 && !groupChangelog) throw new ReleaseDataError(`no changelog of ${group}`);
|
|
668
|
+
const githubByVersion = /* @__PURE__ */ new Map();
|
|
669
|
+
for (const release of sources.githubReleases ?? []) {
|
|
670
|
+
const tag = versionOfTag(release.tag);
|
|
671
|
+
if (!tag) continue;
|
|
672
|
+
const list = githubByVersion.get(tag.version) ?? [];
|
|
673
|
+
list.push({
|
|
674
|
+
release,
|
|
675
|
+
package: tag.package
|
|
676
|
+
});
|
|
677
|
+
githubByVersion.set(tag.version, list);
|
|
678
|
+
}
|
|
679
|
+
const versions = /* @__PURE__ */ new Set();
|
|
680
|
+
if (groupChangelog) for (const section of groupChangelog.sections) versions.add(section.version);
|
|
681
|
+
else {
|
|
682
|
+
for (const { sections } of parsed) for (const section of sections) versions.add(section.version);
|
|
683
|
+
for (const [version, list] of githubByVersion) if (list.some((entry) => inGroup(entry.package))) versions.add(version);
|
|
684
|
+
}
|
|
685
|
+
const ordered = [...versions].sort((a, b) => compareVersions(b, a));
|
|
686
|
+
if (ordered.length === 0) return [];
|
|
687
|
+
const changelogChanges = /* @__PURE__ */ new Map();
|
|
688
|
+
const changelogOf = /* @__PURE__ */ new Map();
|
|
689
|
+
const groupFirst = groupChangelog ? [groupChangelog, ...parsed.filter((entry) => entry !== groupChangelog)] : parsed;
|
|
690
|
+
for (const { source, sections } of groupFirst) for (const section of sections) {
|
|
691
|
+
if (!versions.has(section.version)) continue;
|
|
692
|
+
if (!changelogOf.has(section.version)) changelogOf.set(section.version, {
|
|
693
|
+
source,
|
|
694
|
+
heading: section.heading
|
|
695
|
+
});
|
|
696
|
+
const changes = changelogChanges.get(section.version) ?? /* @__PURE__ */ new Map();
|
|
697
|
+
for (const entry of section.entries) addChange(changes, entry, source.name);
|
|
698
|
+
changelogChanges.set(section.version, changes);
|
|
699
|
+
}
|
|
700
|
+
const tagsByVersion = /* @__PURE__ */ new Map();
|
|
701
|
+
for (const tag of sources.tags ?? []) {
|
|
702
|
+
const parsedTag = versionOfTag(tag.tag);
|
|
703
|
+
if (!parsedTag || !inGroup(parsedTag.package)) continue;
|
|
704
|
+
const list = tagsByVersion.get(parsedTag.version) ?? [];
|
|
705
|
+
list.push(tag);
|
|
706
|
+
tagsByVersion.set(parsedTag.version, list);
|
|
707
|
+
}
|
|
708
|
+
const npmByVersion = /* @__PURE__ */ new Map();
|
|
709
|
+
for (const time of sources.npm ?? []) {
|
|
710
|
+
const list = npmByVersion.get(time.version) ?? [];
|
|
711
|
+
list.push(time);
|
|
712
|
+
npmByVersion.set(time.version, list);
|
|
713
|
+
}
|
|
714
|
+
const dates = /* @__PURE__ */ new Map();
|
|
715
|
+
const missing = [];
|
|
716
|
+
for (const version of ordered) {
|
|
717
|
+
const github = (githubByVersion.get(version) ?? []).filter((entry) => inGroup(entry.package));
|
|
718
|
+
const npm = npmByVersion.get(version) ?? [];
|
|
719
|
+
const npmGroup = group ? npm.filter((time) => time.name === group) : [];
|
|
720
|
+
const changelog = changelogOf.get(version);
|
|
721
|
+
const date = earliest((tagsByVersion.get(version) ?? []).map((tag) => tag.date)) ?? earliest(github.map((entry) => entry.release.publishedAt)) ?? earliest((npmGroup.length > 0 ? npmGroup : npm).map((time) => time.date)) ?? (changelog && sources.headingDate ? sources.headingDate(version, changelog.source.file) : null);
|
|
722
|
+
if (date) dates.set(version, date);
|
|
723
|
+
else missing.push(version);
|
|
724
|
+
}
|
|
725
|
+
if (missing.length > 0) {
|
|
726
|
+
warn(`no source gives a date for ${missing.join(", ")}. The page shows an approximate date.`);
|
|
727
|
+
let newer = sources.now ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
728
|
+
for (const version of ordered) {
|
|
729
|
+
const date = dates.get(version);
|
|
730
|
+
if (date) newer = date;
|
|
731
|
+
else dates.set(version, newer);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
const oldest = ordered[ordered.length - 1];
|
|
735
|
+
return ordered.map((version) => {
|
|
736
|
+
const github = githubByVersion.get(version) ?? [];
|
|
737
|
+
let changes = changelogChanges.get(version);
|
|
738
|
+
if (!changes) {
|
|
739
|
+
changes = /* @__PURE__ */ new Map();
|
|
740
|
+
for (const { release, package: packageName } of github) for (const entry of releaseBodyEntries(release.body)) addChange(changes, entry, packageName ?? (group || null));
|
|
741
|
+
}
|
|
742
|
+
const links = [];
|
|
743
|
+
const githubRelease = github.find((entry) => entry.package === group) ?? github.find((entry) => entry.package === null) ?? github[0];
|
|
744
|
+
if (githubRelease?.release.url) links.push({
|
|
745
|
+
label: "GitHub release",
|
|
746
|
+
url: githubRelease.release.url
|
|
747
|
+
});
|
|
748
|
+
else if (repository) {
|
|
749
|
+
const tag = (tagsByVersion.get(version) ?? [])[0];
|
|
750
|
+
if (tag) links.push({
|
|
751
|
+
label: "Tag",
|
|
752
|
+
url: `https://github.com/${repository}/releases/tag/${refPath(tag.tag)}`
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
const changelog = changelogOf.get(version);
|
|
756
|
+
if (changelog && repository) links.push({
|
|
757
|
+
label: "Changelog",
|
|
758
|
+
url: `https://github.com/${repository}/blob/${refPath(ref)}/${changelog.source.file}#${headingAnchor(changelog.heading)}`
|
|
759
|
+
});
|
|
760
|
+
const npm = npmByVersion.get(version) ?? [];
|
|
761
|
+
const npmPackage = npm.find((time) => time.name === group) ?? npm[0];
|
|
762
|
+
if (npmPackage) links.push({
|
|
763
|
+
label: "npm",
|
|
764
|
+
url: `https://www.npmjs.com/package/${npmPackage.name}/v/${version}`
|
|
765
|
+
});
|
|
766
|
+
return {
|
|
767
|
+
version,
|
|
768
|
+
date: dates.get(version) ?? sources.now ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
769
|
+
kind: kindOf(version, version === oldest),
|
|
770
|
+
changes: sortedChanges(changes),
|
|
771
|
+
links
|
|
772
|
+
};
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
/** The default patterns of the changelog files: the first pattern that finds a file wins. */
|
|
776
|
+
const DEFAULT_CHANGELOGS = ["packages/*/CHANGELOG.md", "CHANGELOG.md"];
|
|
777
|
+
/**
|
|
778
|
+
* This function reads the changelog files of a repository. The npm name of each changelog comes from
|
|
779
|
+
* the `package.json` next to it, else from its folder.
|
|
780
|
+
*/
|
|
781
|
+
function readChangelogs(repoDir, pattern) {
|
|
782
|
+
const patterns = pattern ? [pattern] : DEFAULT_CHANGELOGS;
|
|
783
|
+
for (const candidate of patterns) {
|
|
784
|
+
const files = globFiles(repoDir, candidate);
|
|
785
|
+
if (files.length === 0) continue;
|
|
786
|
+
return files.map((file) => {
|
|
787
|
+
const path = join(repoDir, ...file.split("/"));
|
|
788
|
+
const manifest = join(dirname(path), "package.json");
|
|
789
|
+
let name = basename(dirname(path));
|
|
790
|
+
if (existsSync(manifest)) try {
|
|
791
|
+
const parsed = JSON.parse(readFileSync(manifest, "utf8"));
|
|
792
|
+
if (typeof parsed.name === "string" && parsed.name) name = parsed.name;
|
|
793
|
+
} catch {}
|
|
794
|
+
return {
|
|
795
|
+
name,
|
|
796
|
+
file,
|
|
797
|
+
markdown: readFileSync(path, "utf8")
|
|
798
|
+
};
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
return [];
|
|
802
|
+
}
|
|
803
|
+
/**
|
|
804
|
+
* This function reads all the release sources of a repository and merges them. A failure of GitHub
|
|
805
|
+
* or npm does not stop it, but gives a warning.
|
|
806
|
+
*
|
|
807
|
+
* @throws ReleaseDataError when `group` has no changelog and other changelogs exist
|
|
808
|
+
*/
|
|
809
|
+
async function readReleases(options) {
|
|
810
|
+
const { repoDir, repository, warn = () => {} } = options;
|
|
811
|
+
const changelogs = readChangelogs(repoDir, options.changesets);
|
|
812
|
+
const tags = gitTagDates(repoDir);
|
|
813
|
+
if (isShallow(repoDir)) warn("the clone is shallow, so the release dates are not exact. Check out the full history (fetch-depth 0).");
|
|
814
|
+
let releases = [];
|
|
815
|
+
const [owner = "", repo = ""] = (repository ?? "").split("/");
|
|
816
|
+
if (owner && repo) {
|
|
817
|
+
const result = await githubReleases({
|
|
818
|
+
owner,
|
|
819
|
+
repo,
|
|
820
|
+
token: options.token,
|
|
821
|
+
...options.fetch ? { fetch: options.fetch } : {}
|
|
822
|
+
});
|
|
823
|
+
if (result.available) releases = result.releases;
|
|
824
|
+
else warn(`the releases page has no GitHub releases: ${result.reason}.`);
|
|
825
|
+
}
|
|
826
|
+
let npm = [];
|
|
827
|
+
if (options.packages && options.packages.length > 0) {
|
|
828
|
+
const result = await npmPublishTimes({
|
|
829
|
+
packages: options.packages,
|
|
830
|
+
...options.fetch ? { fetch: options.fetch } : {}
|
|
831
|
+
});
|
|
832
|
+
npm = result.times;
|
|
833
|
+
for (const problem of result.problems) warn(problem);
|
|
834
|
+
}
|
|
835
|
+
return mergeReleases({
|
|
836
|
+
changelogs,
|
|
837
|
+
githubReleases: releases,
|
|
838
|
+
tags,
|
|
839
|
+
npm,
|
|
840
|
+
group: options.group,
|
|
841
|
+
repository,
|
|
842
|
+
ref: options.ref,
|
|
843
|
+
headingDate: (version, file) => headingDate(repoDir, version, file),
|
|
844
|
+
now: options.now,
|
|
845
|
+
warn
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
//#endregion
|
|
849
|
+
//#region src/data/status.ts
|
|
850
|
+
/**
|
|
851
|
+
* The status data: the open work of a roadmap file, and the open pull requests and issues of a
|
|
852
|
+
* GitHub repository. The build reads both, so the page shows the state at the time of the build.
|
|
853
|
+
*
|
|
854
|
+
* @remarks The roadmap parser reads each Markdown table with an `ID` column and a `Status` column.
|
|
855
|
+
* It is strict: a row whose cells do not match the header stops the build with the file and the line.
|
|
856
|
+
*/
|
|
857
|
+
/** The classes of a status, in the sequence of the board. The open work comes first. */
|
|
858
|
+
const STATUS_CLASSES = [
|
|
859
|
+
"waiting",
|
|
860
|
+
"blocked",
|
|
861
|
+
"progress",
|
|
862
|
+
"open",
|
|
863
|
+
"later",
|
|
864
|
+
"done"
|
|
865
|
+
];
|
|
866
|
+
/** The open classes, in the sequence of the board. */
|
|
867
|
+
const OPEN_CLASSES = [
|
|
868
|
+
"waiting",
|
|
869
|
+
"blocked",
|
|
870
|
+
"progress",
|
|
871
|
+
"open",
|
|
872
|
+
"later"
|
|
873
|
+
];
|
|
874
|
+
/** An error in the roadmap file, with the file and the line. */
|
|
875
|
+
var RoadmapParseError = class extends Error {};
|
|
876
|
+
const STATUS_KEYWORDS = [
|
|
877
|
+
["open", /\bnot\s+(?:yet\s+)?(?:started|done|fixed|merged|shipped|released)\b|\bto[ -]?do\b/],
|
|
878
|
+
["done", /\b(?:done|shipped|merged|fixed|released|complete|completed|closed)\b/],
|
|
879
|
+
["progress", /\bin[ -]progress\b|\b(?:active|ongoing|started|wip)\b/],
|
|
880
|
+
["waiting", /\b(?:waiting|waits|awaiting|needs)\b/],
|
|
881
|
+
["blocked", /\bblocked\b/],
|
|
882
|
+
["later", /\b(?:later|deferred|postponed|someday)\b/]
|
|
883
|
+
];
|
|
884
|
+
/**
|
|
885
|
+
* This function gives the class of a free-text status: done, progress, waiting, blocked, later or
|
|
886
|
+
* open. The first keyword of the text decides. A text without a keyword ("next", "open") is open.
|
|
887
|
+
*/
|
|
888
|
+
function statusClassOf(text) {
|
|
889
|
+
const lower = String(text ?? "").toLowerCase();
|
|
890
|
+
let best = {
|
|
891
|
+
index: Infinity,
|
|
892
|
+
value: "open"
|
|
893
|
+
};
|
|
894
|
+
for (const [value, pattern] of STATUS_KEYWORDS) {
|
|
895
|
+
const match = pattern.exec(lower);
|
|
896
|
+
if (match && match.index < best.index) best = {
|
|
897
|
+
index: match.index,
|
|
898
|
+
value
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
return best.value;
|
|
902
|
+
}
|
|
903
|
+
/** This function gives the cells of a table row. A "|" in a code span or after "\" is text. */
|
|
904
|
+
function splitRow(line) {
|
|
905
|
+
const cells = [];
|
|
906
|
+
let cell = "";
|
|
907
|
+
let code = false;
|
|
908
|
+
const text = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
|
909
|
+
for (let index = 0; index < text.length; index++) {
|
|
910
|
+
const char = text[index];
|
|
911
|
+
if (char === "\\" && text[index + 1] === "|") {
|
|
912
|
+
cell += "|";
|
|
913
|
+
index++;
|
|
914
|
+
} else if (char === "`") {
|
|
915
|
+
code = !code;
|
|
916
|
+
cell += char;
|
|
917
|
+
} else if (char === "|" && !code) {
|
|
918
|
+
cells.push(cell.trim());
|
|
919
|
+
cell = "";
|
|
920
|
+
} else cell += char;
|
|
921
|
+
}
|
|
922
|
+
cells.push(cell.trim());
|
|
923
|
+
return cells;
|
|
924
|
+
}
|
|
925
|
+
/** This function gives the plain text of inline Markdown: no code marks, no emphasis, the text of each link. */
|
|
926
|
+
function plainText(markdown) {
|
|
927
|
+
return String(markdown ?? "").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[`*_]/g, "").replace(/\s+/g, " ").trim();
|
|
928
|
+
}
|
|
929
|
+
/** A cell with no value: empty, or a dash. */
|
|
930
|
+
function isEmpty(cell) {
|
|
931
|
+
return cell === void 0 || /^\s*(?:[-–—]|n\/a)?\s*$/i.test(cell);
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* This function gives the tables of a Markdown file. Each table has its header names in lowercase
|
|
935
|
+
* plain text, the nearest heading above it, the line of its header and its rows. Fenced code is not read.
|
|
936
|
+
*/
|
|
937
|
+
function markdownTables(markdown) {
|
|
938
|
+
const lines = String(markdown).split(/\r?\n/);
|
|
939
|
+
const tables = [];
|
|
940
|
+
let section = "";
|
|
941
|
+
let fence = null;
|
|
942
|
+
for (let index = 0; index < lines.length; index++) {
|
|
943
|
+
const line = lines[index] ?? "";
|
|
944
|
+
const fenceMark = /^\s*(`{3,}|~{3,})/.exec(line)?.[1];
|
|
945
|
+
if (fenceMark !== void 0) {
|
|
946
|
+
if (fence === null) fence = fenceMark[0] ?? null;
|
|
947
|
+
else if (fenceMark[0] === fence) fence = null;
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
if (fence !== null) continue;
|
|
951
|
+
const heading = /^#{1,6}\s+(.*?)\s*#*\s*$/.exec(line);
|
|
952
|
+
if (heading) {
|
|
953
|
+
section = plainText(heading[1]);
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
if (!line.trim().startsWith("|")) continue;
|
|
957
|
+
const separator = lines[index + 1] ?? "";
|
|
958
|
+
if (!/^\s*\|?\s*:?-{3,}/.test(separator)) continue;
|
|
959
|
+
const header = splitRow(line).map((name) => plainText(name).toLowerCase());
|
|
960
|
+
const rows = [];
|
|
961
|
+
let next = index + 2;
|
|
962
|
+
for (; next < lines.length && (lines[next] ?? "").trim().startsWith("|"); next++) rows.push({
|
|
963
|
+
line: next + 1,
|
|
964
|
+
cells: splitRow(lines[next] ?? "")
|
|
965
|
+
});
|
|
966
|
+
tables.push({
|
|
967
|
+
section,
|
|
968
|
+
line: index + 1,
|
|
969
|
+
header,
|
|
970
|
+
rows
|
|
971
|
+
});
|
|
972
|
+
index = next - 1;
|
|
973
|
+
}
|
|
974
|
+
return tables;
|
|
975
|
+
}
|
|
976
|
+
/** The columns whose cells hold the links of an item. */
|
|
977
|
+
const LINK_COLUMNS = [
|
|
978
|
+
"where",
|
|
979
|
+
"commit",
|
|
980
|
+
"commits",
|
|
981
|
+
"link",
|
|
982
|
+
"links",
|
|
983
|
+
"pr",
|
|
984
|
+
"prs"
|
|
985
|
+
];
|
|
986
|
+
/** Words before a reference that do not name a repository. */
|
|
987
|
+
const NOT_REPOSITORIES = new Set("a an and also at by commit commits done fixed for from in into issue issues merged of on or plus pr prs run see the then to via with".split(" "));
|
|
988
|
+
/** The URL of a link target of the roadmap file: an absolute URL stays, and a relative one goes to GitHub. */
|
|
989
|
+
function linkUrl(target, { file = "", repository = "", ref = "HEAD" }) {
|
|
990
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(target)) return target;
|
|
991
|
+
if (!repository) return null;
|
|
992
|
+
const [path = "", anchor = ""] = target.split("#", 2);
|
|
993
|
+
const resolved = path === "" ? file : posix.normalize(posix.join(posix.dirname(file), path));
|
|
994
|
+
if (resolved.startsWith("..")) return null;
|
|
995
|
+
return `https://github.com/${repository}/blob/${ref}/${resolved}${anchor ? `#${anchor}` : ""}`;
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* This function gives the links of a cell: each Markdown link, then each bare reference in the rest
|
|
999
|
+
* of the text. A reference is a pull request ("PR #12", "#12") or a commit hash in a code span.
|
|
1000
|
+
*
|
|
1001
|
+
* @remarks A repository name right before a reference ("systems PR #16") applies to it and to the
|
|
1002
|
+
* next references of the cell. Without a name, a reference belongs to `repository`.
|
|
1003
|
+
*/
|
|
1004
|
+
function linksOf(cell, options = {}) {
|
|
1005
|
+
const { repository = "" } = options;
|
|
1006
|
+
const links = [];
|
|
1007
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1008
|
+
const add = (label, url) => {
|
|
1009
|
+
if (!url || seen.has(url)) return;
|
|
1010
|
+
seen.add(url);
|
|
1011
|
+
links.push({
|
|
1012
|
+
label,
|
|
1013
|
+
url
|
|
1014
|
+
});
|
|
1015
|
+
};
|
|
1016
|
+
const rest = String(cell ?? "").replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_match, label, target) => {
|
|
1017
|
+
add(plainText(label), linkUrl(target, options));
|
|
1018
|
+
return " ";
|
|
1019
|
+
});
|
|
1020
|
+
if (!repository) return links;
|
|
1021
|
+
const [owner, defaultRepo] = repository.split("/");
|
|
1022
|
+
let current = defaultRepo;
|
|
1023
|
+
let pulls = false;
|
|
1024
|
+
const reference = /\bPRs?\s*#(\d+)|(?<![\w/&])#(\d+)\b|`([0-9a-f]{7,40})`/g;
|
|
1025
|
+
for (let match = reference.exec(rest); match; match = reference.exec(rest)) {
|
|
1026
|
+
const word = /([A-Za-z][\w.-]*)\s+$/.exec(rest.slice(0, match.index))?.[1];
|
|
1027
|
+
if (word && !NOT_REPOSITORIES.has(word.toLowerCase())) current = word;
|
|
1028
|
+
const prefix = current === defaultRepo ? "" : `${current} `;
|
|
1029
|
+
if (match[1] !== void 0) pulls = true;
|
|
1030
|
+
const number = match[1] ?? match[2];
|
|
1031
|
+
if (number !== void 0) add(`${prefix}#${number}`, `https://github.com/${owner}/${current}/${pulls ? "pull" : "issues"}/${number}`);
|
|
1032
|
+
else if (match[3] !== void 0) add(`${prefix}${match[3].slice(0, 7)}`, `https://github.com/${owner}/${current}/commit/${match[3]}`);
|
|
1033
|
+
}
|
|
1034
|
+
return links;
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* This function gives the items of a roadmap file: one item for each row of each table with an `ID`
|
|
1038
|
+
* column and a `Status` column. The header names match without case.
|
|
1039
|
+
*
|
|
1040
|
+
* @remarks The title comes from `Item`, else from `Title`. `Owner`, `Area` and `Needs` are optional.
|
|
1041
|
+
* `Where`, `Commit` and `Links` give the links.
|
|
1042
|
+
* @throws RoadmapParseError when the cells of a row do not match the header
|
|
1043
|
+
*/
|
|
1044
|
+
function roadmapItems(markdown, options = {}) {
|
|
1045
|
+
const { file = "ROADMAP.md" } = options;
|
|
1046
|
+
const items = [];
|
|
1047
|
+
for (const table of markdownTables(markdown)) {
|
|
1048
|
+
const column = (...names) => names.map((name) => table.header.indexOf(name)).find((index) => index >= 0) ?? -1;
|
|
1049
|
+
const id = column("id");
|
|
1050
|
+
const status = column("status");
|
|
1051
|
+
if (id < 0 || status < 0) continue;
|
|
1052
|
+
const title = column("item", "title");
|
|
1053
|
+
const owner = column("owner");
|
|
1054
|
+
const area = column("area");
|
|
1055
|
+
const needs = column("needs");
|
|
1056
|
+
const linkColumns = LINK_COLUMNS.map((name) => table.header.indexOf(name)).filter((index) => index >= 0);
|
|
1057
|
+
for (const row of table.rows) {
|
|
1058
|
+
if (row.cells.length !== table.header.length) throw new RoadmapParseError(`${file}:${row.line}: the row has ${row.cells.length} cells, and the header has ${table.header.length}`);
|
|
1059
|
+
const idText = plainText(row.cells[id]);
|
|
1060
|
+
if (isEmpty(idText)) continue;
|
|
1061
|
+
const statusText = row.cells[status] ?? "";
|
|
1062
|
+
const item = {
|
|
1063
|
+
id: idText,
|
|
1064
|
+
title: title >= 0 ? row.cells[title] ?? "" : "",
|
|
1065
|
+
status: statusText,
|
|
1066
|
+
statusClass: statusClassOf(plainText(statusText)),
|
|
1067
|
+
section: table.section,
|
|
1068
|
+
line: row.line,
|
|
1069
|
+
links: linkColumns.flatMap((index) => linksOf(row.cells[index], {
|
|
1070
|
+
...options,
|
|
1071
|
+
file
|
|
1072
|
+
}))
|
|
1073
|
+
};
|
|
1074
|
+
const optional = (index) => index >= 0 && !isEmpty(row.cells[index]) ? row.cells[index] : void 0;
|
|
1075
|
+
const ownerText = optional(owner);
|
|
1076
|
+
const areaText = optional(area);
|
|
1077
|
+
const needsText = optional(needs);
|
|
1078
|
+
if (ownerText !== void 0) item.owner = ownerText;
|
|
1079
|
+
if (areaText !== void 0) item.area = areaText;
|
|
1080
|
+
if (needsText !== void 0) item.needs = needsText;
|
|
1081
|
+
items.push(item);
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
return items;
|
|
1085
|
+
}
|
|
1086
|
+
/** This function puts the roadmap items and the GitHub part into the shape of `status.json`. */
|
|
1087
|
+
function buildStatusBoard(input) {
|
|
1088
|
+
const { items, github, roadmap, repository, commit = null, generatedAt = (/* @__PURE__ */ new Date()).toISOString() } = input;
|
|
1089
|
+
return {
|
|
1090
|
+
generatedAt,
|
|
1091
|
+
commit,
|
|
1092
|
+
source: {
|
|
1093
|
+
roadmap,
|
|
1094
|
+
github: repository
|
|
1095
|
+
},
|
|
1096
|
+
items,
|
|
1097
|
+
pullRequests: github.available ? github.pullRequests : [],
|
|
1098
|
+
issues: github.available ? github.issues : [],
|
|
1099
|
+
github: github.available ? {
|
|
1100
|
+
available: true,
|
|
1101
|
+
totals: github.totals
|
|
1102
|
+
} : {
|
|
1103
|
+
available: false,
|
|
1104
|
+
reason: github.reason
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* This function reads the board of a repository on disk: the roadmap file, and the open work on GitHub.
|
|
1110
|
+
*
|
|
1111
|
+
* @throws RoadmapParseError when the roadmap has an incorrect row, or no table with an ID and a Status column
|
|
1112
|
+
*/
|
|
1113
|
+
async function readStatusBoard(options) {
|
|
1114
|
+
const { repoDir, roadmap = null, owner, repo, token, commit = null } = options;
|
|
1115
|
+
const repository = `${owner}/${repo}`;
|
|
1116
|
+
let items = [];
|
|
1117
|
+
if (roadmap) {
|
|
1118
|
+
items = roadmapItems(readFileSync(join(repoDir, ...roadmap.split("/")), "utf8"), {
|
|
1119
|
+
file: roadmap,
|
|
1120
|
+
repository,
|
|
1121
|
+
ref: commit ?? "HEAD"
|
|
1122
|
+
});
|
|
1123
|
+
if (items.length === 0) throw new RoadmapParseError(`${roadmap}: no table with an ID column and a Status column`);
|
|
1124
|
+
}
|
|
1125
|
+
const github = await githubOpenWork({
|
|
1126
|
+
owner,
|
|
1127
|
+
repo,
|
|
1128
|
+
token,
|
|
1129
|
+
...options.fetch ? { fetch: options.fetch } : {}
|
|
1130
|
+
});
|
|
1131
|
+
return buildStatusBoard({
|
|
1132
|
+
items,
|
|
1133
|
+
github,
|
|
1134
|
+
roadmap: roadmap || null,
|
|
1135
|
+
repository,
|
|
1136
|
+
commit,
|
|
1137
|
+
...options.generatedAt ? { generatedAt: options.generatedAt } : {}
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
//#endregion
|
|
1141
|
+
//#region src/render/html.ts
|
|
1142
|
+
/**
|
|
1143
|
+
* The HTML helpers of the pages: the escape functions and a small inline Markdown renderer.
|
|
1144
|
+
*/
|
|
1145
|
+
const ESCAPES = {
|
|
1146
|
+
"&": "&",
|
|
1147
|
+
"<": "<",
|
|
1148
|
+
">": ">",
|
|
1149
|
+
"\"": """,
|
|
1150
|
+
"'": "'"
|
|
1151
|
+
};
|
|
1152
|
+
/** This function escapes a text for HTML content and for an attribute value in double quotes. */
|
|
1153
|
+
function escapeHtml(text) {
|
|
1154
|
+
return String(text).replace(/[&<>"']/g, (char) => ESCAPES[char] ?? char);
|
|
1155
|
+
}
|
|
1156
|
+
/**
|
|
1157
|
+
* This function gives a URL that a link can hold, or null. It accepts `http:`, `https:` and
|
|
1158
|
+
* `mailto:` URLs, and relative URLs. It refuses each other scheme, for example `javascript:`.
|
|
1159
|
+
*/
|
|
1160
|
+
function safeUrl(url) {
|
|
1161
|
+
const trimmed = url.trim();
|
|
1162
|
+
if (/^(?:https?:|mailto:)/i.test(trimmed)) return trimmed;
|
|
1163
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed) || trimmed.startsWith("//")) return null;
|
|
1164
|
+
return trimmed;
|
|
1165
|
+
}
|
|
1166
|
+
/** This function gives an `<a>` element, or the escaped text when the URL is not safe. */
|
|
1167
|
+
function link(url, html, attributes = "") {
|
|
1168
|
+
const href = safeUrl(url);
|
|
1169
|
+
return href === null ? html : `<a href="${escapeHtml(href)}"${attributes ? ` ${attributes}` : ""}>${html}</a>`;
|
|
1170
|
+
}
|
|
1171
|
+
/** The emphasis of a text without code spans or links. The text is already escaped. */
|
|
1172
|
+
function emphasis(escaped) {
|
|
1173
|
+
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>");
|
|
1174
|
+
}
|
|
1175
|
+
const INLINE = "`([^`]+)`|\\[((?:[^\\]`]|`[^`]*`)+)\\]\\(([^)\\s]+)\\)";
|
|
1176
|
+
/**
|
|
1177
|
+
* This function renders inline Markdown as HTML: code spans, links, strong text and emphasis. It
|
|
1178
|
+
* escapes all other text, so the result is safe.
|
|
1179
|
+
*/
|
|
1180
|
+
function inlineMarkdown(markdown) {
|
|
1181
|
+
const text = String(markdown ?? "");
|
|
1182
|
+
const pattern = new RegExp(INLINE, "g");
|
|
1183
|
+
let html = "";
|
|
1184
|
+
let last = 0;
|
|
1185
|
+
for (let match = pattern.exec(text); match; match = pattern.exec(text)) {
|
|
1186
|
+
html += emphasis(escapeHtml(text.slice(last, match.index)));
|
|
1187
|
+
if (match[1] !== void 0) html += `<code>${escapeHtml(match[1])}</code>`;
|
|
1188
|
+
else html += link(match[3] ?? "", inlineMarkdown(match[2] ?? ""));
|
|
1189
|
+
last = match.index + match[0].length;
|
|
1190
|
+
}
|
|
1191
|
+
return html + emphasis(escapeHtml(text.slice(last)));
|
|
1192
|
+
}
|
|
1193
|
+
/** This function gives "1 item" or "2 items". */
|
|
1194
|
+
function plural(count, word, many = `${word}s`) {
|
|
1195
|
+
return `${count} ${count === 1 ? word : many}`;
|
|
1196
|
+
}
|
|
1197
|
+
/** This function gives "a", "a and b" or "a, b and c". */
|
|
1198
|
+
function listText(parts) {
|
|
1199
|
+
if (parts.length <= 1) return parts.join("");
|
|
1200
|
+
return `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
|
|
1201
|
+
}
|
|
1202
|
+
//#endregion
|
|
1203
|
+
//#region src/render/dates.ts
|
|
1204
|
+
/**
|
|
1205
|
+
* The dates of the pages. The pages show each date in UTC, so the build and the browser agree.
|
|
1206
|
+
*/
|
|
1207
|
+
/** The milliseconds of one day. */
|
|
1208
|
+
const DAY_MS = 864e5;
|
|
1209
|
+
const MONTHS = [
|
|
1210
|
+
"Jan",
|
|
1211
|
+
"Feb",
|
|
1212
|
+
"Mar",
|
|
1213
|
+
"Apr",
|
|
1214
|
+
"May",
|
|
1215
|
+
"Jun",
|
|
1216
|
+
"Jul",
|
|
1217
|
+
"Aug",
|
|
1218
|
+
"Sep",
|
|
1219
|
+
"Oct",
|
|
1220
|
+
"Nov",
|
|
1221
|
+
"Dec"
|
|
1222
|
+
];
|
|
1223
|
+
/** This function gives the start of the UTC day of a time. */
|
|
1224
|
+
function utcDay(time) {
|
|
1225
|
+
return Math.floor(time / DAY_MS) * DAY_MS;
|
|
1226
|
+
}
|
|
1227
|
+
/** This function gives "Oct 18": the UTC day of a time or of an ISO date. */
|
|
1228
|
+
function shortDate(date) {
|
|
1229
|
+
const day = new Date(date);
|
|
1230
|
+
return `${MONTHS[day.getUTCMonth()]} ${day.getUTCDate()}`;
|
|
1231
|
+
}
|
|
1232
|
+
/** This function gives "Oct 18, 2026, 19:34 UTC", or "an unknown time" for a date that is not valid. */
|
|
1233
|
+
function longDate(date) {
|
|
1234
|
+
const day = new Date(date);
|
|
1235
|
+
if (Number.isNaN(day.getTime())) return "an unknown time";
|
|
1236
|
+
const time = `${String(day.getUTCHours()).padStart(2, "0")}:${String(day.getUTCMinutes()).padStart(2, "0")}`;
|
|
1237
|
+
return `${shortDate(date)}, ${day.getUTCFullYear()}, ${time} UTC`;
|
|
1238
|
+
}
|
|
1239
|
+
/** This function gives "Oct 13–15", or "Sep 30–Oct 2" over the end of a month. */
|
|
1240
|
+
function dayRange(start, end) {
|
|
1241
|
+
if (utcDay(start) === utcDay(end)) return shortDate(start);
|
|
1242
|
+
const first = new Date(start);
|
|
1243
|
+
const last = new Date(end);
|
|
1244
|
+
return first.getUTCMonth() === last.getUTCMonth() ? `${shortDate(start)}–${last.getUTCDate()}` : `${shortDate(start)}–${shortDate(end)}`;
|
|
1245
|
+
}
|
|
1246
|
+
//#endregion
|
|
1247
|
+
//#region src/render/theme.ts
|
|
1248
|
+
/**
|
|
1249
|
+
* The page shell and the CSS of the standalone pages. The CSS uses variables (`--sp-bg`, `--sp-fg`,
|
|
1250
|
+
* `--sp-accent` and the others), so a site can change the colors and the fonts with `extraCss`.
|
|
1251
|
+
*/
|
|
1252
|
+
const DARK = `color-scheme:dark;--sp-bg:#0e1220;--sp-surface:#151a2b;--sp-sunken:#1c2236;--sp-fg:#e6e9f3;--sp-fg-2:#b3bad0;
|
|
1253
|
+
--sp-muted:#949cb3;--sp-rule:#2b3350;--sp-rule-strong:#46507a;--sp-accent:#93abff;--sp-accent-strong:#bccbff;--sp-accent-wash:#1f2a4f;
|
|
1254
|
+
--sp-good-ink:#4cc94c;--sp-warn-ink:#fab219;--sp-bad-ink:#ff8a8a;--sp-grid:#232a40;--sp-axis:#5d6683;`;
|
|
1255
|
+
/** The default CSS of the pages: the variables, the light and dark themes, and the parts of both pages. */
|
|
1256
|
+
const BASE_CSS = `
|
|
1257
|
+
:root{color-scheme:light;--sp-bg:#f5f6f9;--sp-surface:#ffffff;--sp-sunken:#eef1f6;--sp-fg:#151c2e;--sp-fg-2:#454f67;
|
|
1258
|
+
--sp-muted:#5f6880;--sp-rule:#d6dbe5;--sp-rule-strong:#aeb6c7;--sp-accent:#2346c4;--sp-accent-strong:#172f8f;--sp-accent-wash:#e7ecfd;
|
|
1259
|
+
--sp-good:#0ca30c;--sp-good-ink:#006300;--sp-warn:#fab219;--sp-warn-ink:#7a5200;--sp-bad:#d03b3b;--sp-bad-ink:#a3262a;
|
|
1260
|
+
--sp-neutral:#8a91a3;--sp-grid:#e6e9f0;--sp-axis:#9aa1b3;
|
|
1261
|
+
--sp-font:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
|
1262
|
+
--sp-mono:ui-monospace,"Cascadia Mono","SFMono-Regular",Menlo,Consolas,monospace;--sp-radius:6px;--sp-width:64rem}
|
|
1263
|
+
:root[data-theme="dark"]{${DARK}}
|
|
1264
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){${DARK}}}
|
|
1265
|
+
*,*::before,*::after{box-sizing:border-box}
|
|
1266
|
+
[hidden]{display:none!important}
|
|
1267
|
+
html{-webkit-text-size-adjust:100%;text-size-adjust:100%}
|
|
1268
|
+
body{margin:0;background:var(--sp-bg);color:var(--sp-fg);font-family:var(--sp-font);font-size:1rem;line-height:1.55}
|
|
1269
|
+
a{color:var(--sp-accent);text-underline-offset:2px}
|
|
1270
|
+
a:hover{color:var(--sp-accent-strong)}
|
|
1271
|
+
:focus-visible{outline:2px solid var(--sp-accent);outline-offset:2px}
|
|
1272
|
+
code{font-family:var(--sp-mono);font-size:.9em;background:var(--sp-sunken);border-radius:3px;padding:0 .25em;overflow-wrap:anywhere}
|
|
1273
|
+
h1{font-size:2rem;line-height:1.2;margin:.5rem 0 .75rem}
|
|
1274
|
+
h2{font-size:1.35rem;line-height:1.3;margin:0 0 .5rem}
|
|
1275
|
+
h3{font-size:1.1rem;line-height:1.3}
|
|
1276
|
+
p{margin:0 0 .75rem}
|
|
1277
|
+
button{font:inherit;color:inherit}
|
|
1278
|
+
.sp-skip{position:absolute;left:-9999px;top:0}
|
|
1279
|
+
.sp-skip:focus{left:16px;top:8px;z-index:10;background:var(--sp-surface);padding:.25rem .5rem}
|
|
1280
|
+
.sp-header{background:var(--sp-surface);border-bottom:1px solid var(--sp-rule)}
|
|
1281
|
+
.sp-header nav{max-width:var(--sp-width);margin:0 auto;padding:.6rem 16px;display:flex;flex-wrap:wrap;align-items:baseline;gap:.25rem 1.25rem}
|
|
1282
|
+
.sp-home{margin-right:auto;font-weight:700;color:var(--sp-fg);text-decoration:none;overflow-wrap:anywhere}
|
|
1283
|
+
.sp-header a[aria-current="page"]{color:var(--sp-fg);font-weight:600;text-decoration:none}
|
|
1284
|
+
.sp-main{max-width:var(--sp-width);margin:0 auto;padding:16px;min-width:0;overflow-wrap:break-word}
|
|
1285
|
+
.sp-footer{max-width:var(--sp-width);margin:2rem auto 0;padding:16px;border-top:1px solid var(--sp-rule);color:var(--sp-muted);font-size:.875rem}
|
|
1286
|
+
.sp-lead{font-size:1.2rem;max-width:70ch}
|
|
1287
|
+
.sp-small{font-size:.875rem}
|
|
1288
|
+
.sp-muted{color:var(--sp-muted)}
|
|
1289
|
+
.sp-panel{background:var(--sp-surface);border:1px solid var(--sp-rule);border-radius:var(--sp-radius);padding:16px;min-width:0}
|
|
1290
|
+
.sp-pill{display:inline-block;border:1px solid var(--sp-rule-strong);border-radius:999px;padding:0 .5rem;font-size:.8125rem;line-height:1.5;color:var(--sp-fg-2);white-space:nowrap}
|
|
1291
|
+
.sp-pill.sp-accent{border-color:var(--sp-accent);background:var(--sp-accent-wash);color:var(--sp-accent-strong)}
|
|
1292
|
+
.sp-pill.sp-good{border-color:var(--sp-good);color:var(--sp-good-ink)}
|
|
1293
|
+
.sp-pill.sp-strong{border-color:var(--sp-accent-strong);background:var(--sp-accent-strong);color:var(--sp-surface);font-weight:700}
|
|
1294
|
+
.sp-chip{display:inline-flex;align-items:center;gap:.3rem;background:var(--sp-surface);border:1px solid var(--sp-rule-strong);border-radius:999px;padding:.1rem .7rem;font-size:.875rem;color:var(--sp-fg-2);cursor:pointer}
|
|
1295
|
+
.sp-chip:hover{border-color:var(--sp-accent)}
|
|
1296
|
+
.sp-chip[aria-pressed="true"]{background:var(--sp-accent-wash);border-color:var(--sp-tone,var(--sp-accent));box-shadow:inset 0 0 0 1px var(--sp-tone,var(--sp-accent));color:var(--sp-fg)}
|
|
1297
|
+
.sp-count{font-family:var(--sp-mono);font-size:.8125rem;color:var(--sp-muted)}
|
|
1298
|
+
.sp-ghost{background:none;border:0;padding:.15rem 0;color:var(--sp-accent);text-decoration:underline;text-underline-offset:2px;cursor:pointer;font-size:.875rem}
|
|
1299
|
+
.sp-callout{background:var(--sp-sunken);border-left:3px solid var(--sp-rule-strong);border-radius:var(--sp-radius);padding:.6rem .8rem}
|
|
1300
|
+
.sp-visually-hidden{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}
|
|
1301
|
+
.sp-section{margin-top:2rem}
|
|
1302
|
+
|
|
1303
|
+
/* The releases page */
|
|
1304
|
+
.sp-legend{display:flex;flex-wrap:wrap;gap:.25rem 1rem;margin:0 0 .5rem;padding:0;list-style:none;color:var(--sp-fg-2);font-size:.875rem}
|
|
1305
|
+
.sp-legend li{display:inline-flex;align-items:center;gap:.25rem}
|
|
1306
|
+
.sp-legend svg{flex:none;overflow:visible}
|
|
1307
|
+
.sp-timeline-box{overflow-x:auto;overflow-y:hidden;max-width:100%;overscroll-behavior-x:contain}
|
|
1308
|
+
.sp-timeline-box svg{display:block;overflow:visible;max-width:none}
|
|
1309
|
+
.sp-timeline-wide{display:none}
|
|
1310
|
+
@media (min-width:1000px){.sp-timeline-narrow{display:none}.sp-timeline-wide{display:block}}
|
|
1311
|
+
.sp-gap{fill:var(--sp-sunken)}
|
|
1312
|
+
.sp-grid{stroke:var(--sp-grid);stroke-width:1}
|
|
1313
|
+
.sp-axis{stroke:var(--sp-axis);stroke-width:1}
|
|
1314
|
+
.sp-day{fill:var(--sp-fg-2);font-family:var(--sp-font);font-size:12px;font-weight:600}
|
|
1315
|
+
.sp-day-count{fill:var(--sp-muted);font-family:var(--sp-font);font-size:11px}
|
|
1316
|
+
.sp-mark{cursor:pointer;outline:none}
|
|
1317
|
+
.sp-hit{fill:transparent}
|
|
1318
|
+
.sp-dot{fill:var(--sp-accent);stroke:var(--sp-surface);stroke-width:1.5}
|
|
1319
|
+
.sp-ring{fill:var(--sp-surface);stroke:var(--sp-accent);stroke-width:2}
|
|
1320
|
+
.sp-kind-first .sp-dot{fill:var(--sp-good)}
|
|
1321
|
+
.sp-kind-major .sp-dot{fill:var(--sp-accent-strong)}
|
|
1322
|
+
.sp-halo{fill:none;stroke:var(--sp-accent);stroke-width:2;pointer-events:none;display:none}
|
|
1323
|
+
.sp-mark.sp-selected .sp-halo{display:inline}
|
|
1324
|
+
.sp-label{fill:var(--sp-fg-2);font-family:var(--sp-mono);font-size:12px;paint-order:stroke;stroke:var(--sp-surface);stroke-width:3px;stroke-linejoin:round}
|
|
1325
|
+
.sp-mark:hover .sp-label,.sp-mark.sp-selected .sp-label{fill:var(--sp-accent-strong)}
|
|
1326
|
+
.sp-mark.sp-selected .sp-label{font-weight:700}
|
|
1327
|
+
.sp-mark:focus-visible .sp-hit{stroke:var(--sp-accent);stroke-width:2}
|
|
1328
|
+
.sp-release{margin:.75rem 0 1rem}
|
|
1329
|
+
.sp-release-head{display:flex;flex-wrap:wrap;align-items:baseline;gap:.25rem .75rem;margin-bottom:.5rem}
|
|
1330
|
+
.sp-release-head h2{margin:0;font-family:var(--sp-mono);font-size:1.5rem}
|
|
1331
|
+
.sp-release-links{display:inline-flex;flex-wrap:wrap;gap:.25rem .75rem;margin-left:auto}
|
|
1332
|
+
.sp-changes{list-style:none;margin:.5rem 0 0;padding:0}
|
|
1333
|
+
.sp-change{padding:.5rem 0;border-top:1px solid var(--sp-rule);min-width:0}
|
|
1334
|
+
.sp-change p{margin:0 0 .25rem;overflow-wrap:anywhere}
|
|
1335
|
+
.sp-change-meta{display:flex;flex-wrap:wrap;align-items:center;gap:.25rem .75rem}
|
|
1336
|
+
.sp-commit code{font-size:.8125rem}
|
|
1337
|
+
.sp-chips{display:inline-flex;flex-wrap:wrap;align-items:center;gap:.25rem}
|
|
1338
|
+
.sp-chips .sp-ghost{font-size:.8125rem}
|
|
1339
|
+
@media (max-width:560px){.sp-release-links{margin-left:0;width:100%}}
|
|
1340
|
+
|
|
1341
|
+
/* The status page: each class has a tone, and the page always shows the tone with an icon and a label */
|
|
1342
|
+
.sp-c-waiting{--sp-tone:var(--sp-warn);--sp-ink:var(--sp-warn-ink)}
|
|
1343
|
+
.sp-c-blocked{--sp-tone:var(--sp-bad);--sp-ink:var(--sp-bad-ink)}
|
|
1344
|
+
.sp-c-progress{--sp-tone:var(--sp-accent);--sp-ink:var(--sp-accent-strong)}
|
|
1345
|
+
.sp-c-open{--sp-tone:var(--sp-neutral);--sp-ink:var(--sp-fg-2)}
|
|
1346
|
+
.sp-c-later{--sp-tone:var(--sp-rule-strong);--sp-ink:var(--sp-muted)}
|
|
1347
|
+
.sp-c-done{--sp-tone:var(--sp-good);--sp-ink:var(--sp-good-ink)}
|
|
1348
|
+
.sp-meter{display:flex;gap:2px;height:12px;border-radius:6px;overflow:hidden;background:var(--sp-rule);margin-top:.75rem}
|
|
1349
|
+
.sp-meter span{display:block;min-width:4px;background:var(--sp-tone)}
|
|
1350
|
+
.sp-meter-legend{display:flex;flex-wrap:wrap;gap:.25rem 1rem;list-style:none;margin:.5rem 0 1rem;padding:0;color:var(--sp-fg-2);font-size:.875rem}
|
|
1351
|
+
.sp-meter-legend li{display:inline-flex;align-items:center;gap:.25rem}
|
|
1352
|
+
.sp-meter-legend strong{font-family:var(--sp-mono);color:var(--sp-ink)}
|
|
1353
|
+
.sp-swatch{display:inline-block;width:12px;height:10px;border-radius:2px;background:var(--sp-tone)}
|
|
1354
|
+
.sp-filters{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem;margin-bottom:.5rem}
|
|
1355
|
+
.sp-filters>span{display:inline-flex;flex-wrap:wrap;gap:.25rem}
|
|
1356
|
+
.sp-group{margin-top:1.5rem}
|
|
1357
|
+
.sp-group h3{display:flex;align-items:center;gap:.5rem;margin:0;padding-bottom:.25rem;color:var(--sp-ink);border-bottom:2px solid var(--sp-tone)}
|
|
1358
|
+
.sp-group-count{font-family:var(--sp-mono);font-size:.875rem;font-weight:600;color:var(--sp-fg-2);background:var(--sp-sunken);border-radius:999px;padding:0 .5rem}
|
|
1359
|
+
.sp-table-box{max-width:100%;overflow-x:auto}
|
|
1360
|
+
table.sp-table{width:100%;border-collapse:collapse;font-size:.9rem}
|
|
1361
|
+
.sp-table th,.sp-table td{text-align:left;vertical-align:top;padding:.5rem;border-bottom:1px solid var(--sp-rule)}
|
|
1362
|
+
.sp-table thead th{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:var(--sp-muted)}
|
|
1363
|
+
.sp-table th[scope="row"]{font-family:var(--sp-mono);font-weight:600;white-space:nowrap;border-left:3px solid var(--sp-tone)}
|
|
1364
|
+
.sp-item{min-width:16rem;overflow-wrap:anywhere}
|
|
1365
|
+
.sp-item-meta{display:block;margin-top:2px}
|
|
1366
|
+
.sp-status{color:var(--sp-ink);overflow-wrap:anywhere}
|
|
1367
|
+
.sp-links{display:inline-flex;flex-wrap:wrap;gap:2px .5rem;overflow-wrap:anywhere}
|
|
1368
|
+
.sp-done{margin-top:1rem}
|
|
1369
|
+
.sp-done>summary{cursor:pointer;color:var(--sp-good-ink)}
|
|
1370
|
+
@media (max-width:760px){
|
|
1371
|
+
.sp-table thead{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}
|
|
1372
|
+
.sp-table,.sp-table tbody,.sp-table tr,.sp-table th,.sp-table td{display:block;width:100%}
|
|
1373
|
+
.sp-table tr{border:1px solid var(--sp-rule);border-left:4px solid var(--sp-tone);border-radius:var(--sp-radius);padding:.5rem .75rem;margin-top:.5rem;background:var(--sp-surface)}
|
|
1374
|
+
.sp-table th,.sp-table td{border:0;padding:2px 0}
|
|
1375
|
+
.sp-table th[scope="row"]{border-left:0}
|
|
1376
|
+
.sp-table td{display:grid;grid-template-columns:4.5rem minmax(0,1fr);align-items:baseline;gap:.5rem}
|
|
1377
|
+
.sp-table td::before{content:attr(data-label);font-size:.75rem;color:var(--sp-muted)}
|
|
1378
|
+
.sp-table td.sp-item{display:block;min-width:0}
|
|
1379
|
+
.sp-table td.sp-item::before{content:none}
|
|
1380
|
+
}
|
|
1381
|
+
.sp-cards{list-style:none;margin:.75rem 0 0;padding:0;display:flex;flex-direction:column;gap:.5rem}
|
|
1382
|
+
.sp-card{background:var(--sp-surface);border:1px solid var(--sp-rule);border-radius:var(--sp-radius);padding:.5rem .75rem;min-width:0}
|
|
1383
|
+
.sp-card-title{display:block;font-weight:600;overflow-wrap:anywhere}
|
|
1384
|
+
.sp-number{font-family:var(--sp-mono);font-weight:400;color:var(--sp-muted)}
|
|
1385
|
+
.sp-card-meta{display:flex;flex-wrap:wrap;align-items:center;gap:.25rem .75rem;margin-top:.25rem;color:var(--sp-fg-2);font-size:.875rem}
|
|
1386
|
+
.sp-branch{font-size:.8125rem;min-width:0}
|
|
1387
|
+
.sp-check{display:inline-flex;align-items:center;gap:.25rem;white-space:nowrap}
|
|
1388
|
+
.sp-dot-state{display:inline-block;width:9px;height:9px;border-radius:50%;background:var(--sp-neutral)}
|
|
1389
|
+
.sp-check-success .sp-dot-state{background:var(--sp-good)}
|
|
1390
|
+
.sp-check-failure .sp-dot-state{background:var(--sp-bad)}
|
|
1391
|
+
.sp-check-pending .sp-dot-state{background:var(--sp-warn)}
|
|
1392
|
+
.sp-check-none .sp-dot-state{background:transparent;border:1.5px solid var(--sp-neutral)}
|
|
1393
|
+
.sp-labels{display:inline-flex;flex-wrap:wrap;gap:4px}
|
|
1394
|
+
@media (prefers-reduced-motion:reduce){*{scroll-behavior:auto!important}}
|
|
1395
|
+
`;
|
|
1396
|
+
const NO_SCRIPT_CSS = `.sp-release[hidden],.sp-extra[hidden]{display:block!important}li.sp-extra[hidden]{display:list-item!important}
|
|
1397
|
+
.sp-chips>.sp-extra[hidden]{display:inline-block!important}[data-sp-js]{display:none!important}`;
|
|
1398
|
+
/** This function gives the name of the repository: "client" for "mark1russell7/client". */
|
|
1399
|
+
function repositoryName(repository) {
|
|
1400
|
+
return repository.split("/").pop() || repository;
|
|
1401
|
+
}
|
|
1402
|
+
/** This function makes the complete HTML document of a page. */
|
|
1403
|
+
function pageShell(parts, options) {
|
|
1404
|
+
const base = options.base.endsWith("/") ? options.base : `${options.base}/`;
|
|
1405
|
+
const name = options.siteName || repositoryName(options.repository);
|
|
1406
|
+
const nav = (page, label) => `<a href="${escapeHtml(`${base}${page}/`)}"${parts.current === page ? " aria-current=\"page\"" : ""}>${label}</a>`;
|
|
1407
|
+
const commit = options.commit ? ` from ${link(`https://github.com/${options.repository}/commit/${options.commit}`, `<code>${escapeHtml(options.commit.slice(0, 7))}</code>`)}` : "";
|
|
1408
|
+
const data = `${base}data/${parts.current}.json`;
|
|
1409
|
+
return `<!doctype html>
|
|
1410
|
+
<html lang="en">
|
|
1411
|
+
<head>
|
|
1412
|
+
<meta charset="utf-8">
|
|
1413
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1414
|
+
<meta name="color-scheme" content="light dark">
|
|
1415
|
+
<title>${escapeHtml(`${parts.title} · ${name}`)}</title>
|
|
1416
|
+
<meta name="description" content="${escapeHtml(parts.description)}">
|
|
1417
|
+
<style>${BASE_CSS}${options.extraCss ? `\n${options.extraCss}` : ""}</style>
|
|
1418
|
+
<noscript><style>${NO_SCRIPT_CSS}</style></noscript>
|
|
1419
|
+
</head>
|
|
1420
|
+
<body>
|
|
1421
|
+
<a class="sp-skip" href="#main">Skip to the content</a>
|
|
1422
|
+
<header class="sp-header"><nav aria-label="Site"><a class="sp-home" href="${escapeHtml(base)}">${escapeHtml(name)}</a>${nav("releases", "Releases")}${nav("status", "Status")}</nav></header>
|
|
1423
|
+
<main id="main" class="sp-main">
|
|
1424
|
+
${parts.main}
|
|
1425
|
+
</main>
|
|
1426
|
+
<footer class="sp-footer"><p>The build made this page at ${escapeHtml(longDate(options.generatedAt))}${commit}. The data is in <a href="${escapeHtml(data)}"><code>${escapeHtml(`data/${parts.current}.json`)}</code></a>.</p></footer>
|
|
1427
|
+
<script type="module">${parts.script}<\/script>
|
|
1428
|
+
</body>
|
|
1429
|
+
</html>
|
|
1430
|
+
`;
|
|
1431
|
+
}
|
|
1432
|
+
//#endregion
|
|
1433
|
+
//#region src/render/timeline.ts
|
|
1434
|
+
/**
|
|
1435
|
+
* The layout of the release timeline: one mark for each version on a time axis.
|
|
1436
|
+
*
|
|
1437
|
+
* @remarks The axis has one band for each day with a release. A run of days without a release
|
|
1438
|
+
* becomes one narrow band, so the busy days get the width. In a day band, the time is linear. Marks
|
|
1439
|
+
* that are near each other go into lanes, so that no two labels overlap.
|
|
1440
|
+
*/
|
|
1441
|
+
/** This function counts the versions and the kinds. The first release counts as a version only. */
|
|
1442
|
+
function releaseSummary(releases) {
|
|
1443
|
+
const times = releases.map((release) => Date.parse(release.date)).filter(Number.isFinite);
|
|
1444
|
+
const count = (kind) => releases.filter((release) => release.kind === kind).length;
|
|
1445
|
+
return {
|
|
1446
|
+
versions: releases.length,
|
|
1447
|
+
major: count("major"),
|
|
1448
|
+
minor: count("minor"),
|
|
1449
|
+
patch: count("patch"),
|
|
1450
|
+
first: times.length > 0 ? new Date(Math.min(...times)).toISOString() : null,
|
|
1451
|
+
last: times.length > 0 ? new Date(Math.max(...times)).toISOString() : null
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
/** This function sorts the versions from the oldest to the newest: the sequence of the marks and of the arrow keys. */
|
|
1455
|
+
function oldestFirst(releases) {
|
|
1456
|
+
return [...releases].sort((a, b) => Date.parse(a.date) - Date.parse(b.date) || compareVersions(a.version, b.version));
|
|
1457
|
+
}
|
|
1458
|
+
/** This function gives the y of the center line of a lane. */
|
|
1459
|
+
function laneCenter(layout, lane) {
|
|
1460
|
+
return layout.plotBottom - (lane + .5) * layout.laneHeight;
|
|
1461
|
+
}
|
|
1462
|
+
/** This function calculates the layout of the timeline at a width. */
|
|
1463
|
+
function timelineLayout(releases, options) {
|
|
1464
|
+
const { width, padX = 30, laneHeight = 38, charWidth = 7.4, gap = 8, emptyWeight = .6 } = options;
|
|
1465
|
+
const ordered = oldestFirst(releases).filter((release) => Number.isFinite(Date.parse(release.date)));
|
|
1466
|
+
const first = ordered[0];
|
|
1467
|
+
const last = ordered[ordered.length - 1];
|
|
1468
|
+
if (!first || !last) return {
|
|
1469
|
+
width,
|
|
1470
|
+
height: 6 + laneHeight + 46,
|
|
1471
|
+
plotTop: 6,
|
|
1472
|
+
plotBottom: 6 + laneHeight,
|
|
1473
|
+
laneHeight,
|
|
1474
|
+
lanes: 0,
|
|
1475
|
+
bands: [],
|
|
1476
|
+
marks: []
|
|
1477
|
+
};
|
|
1478
|
+
const counts = /* @__PURE__ */ new Map();
|
|
1479
|
+
for (const release of ordered) {
|
|
1480
|
+
const day = utcDay(Date.parse(release.date));
|
|
1481
|
+
counts.set(day, (counts.get(day) ?? 0) + 1);
|
|
1482
|
+
}
|
|
1483
|
+
const raw = [];
|
|
1484
|
+
for (let day = utcDay(Date.parse(first.date)); day <= utcDay(Date.parse(last.date)); day += DAY_MS) {
|
|
1485
|
+
const count = counts.get(day) ?? 0;
|
|
1486
|
+
const previous = raw[raw.length - 1];
|
|
1487
|
+
if (count === 0 && previous?.empty) previous.end = day + DAY_MS;
|
|
1488
|
+
else raw.push({
|
|
1489
|
+
start: day,
|
|
1490
|
+
end: day + DAY_MS,
|
|
1491
|
+
count,
|
|
1492
|
+
empty: count === 0,
|
|
1493
|
+
weight: count === 0 ? emptyWeight : 1
|
|
1494
|
+
});
|
|
1495
|
+
}
|
|
1496
|
+
const unit = (width - 2 * padX) / raw.reduce((sum, band) => sum + band.weight, 0);
|
|
1497
|
+
let cursor = padX;
|
|
1498
|
+
const bands = raw.map((band) => {
|
|
1499
|
+
const x0 = cursor;
|
|
1500
|
+
cursor += band.weight * unit;
|
|
1501
|
+
return {
|
|
1502
|
+
start: band.start,
|
|
1503
|
+
end: band.end,
|
|
1504
|
+
x0,
|
|
1505
|
+
x1: cursor,
|
|
1506
|
+
label: dayRange(band.start, band.end - 1),
|
|
1507
|
+
count: band.count,
|
|
1508
|
+
empty: band.empty
|
|
1509
|
+
};
|
|
1510
|
+
});
|
|
1511
|
+
const xOf = (time) => {
|
|
1512
|
+
const band = bands.find((candidate) => time >= candidate.start && time < candidate.end) ?? bands[bands.length - 1];
|
|
1513
|
+
if (!band) return padX;
|
|
1514
|
+
return band.x0 + (time - band.start) / (band.end - band.start) * (band.x1 - band.x0);
|
|
1515
|
+
};
|
|
1516
|
+
const laneEnds = [];
|
|
1517
|
+
const marks = ordered.map((release) => {
|
|
1518
|
+
const time = Date.parse(release.date);
|
|
1519
|
+
const x = xOf(time);
|
|
1520
|
+
const half = Math.max(release.version.length * charWidth, 24) / 2;
|
|
1521
|
+
let lane = laneEnds.findIndex((end) => end + gap <= x - half);
|
|
1522
|
+
if (lane === -1) {
|
|
1523
|
+
lane = laneEnds.length;
|
|
1524
|
+
laneEnds.push(0);
|
|
1525
|
+
}
|
|
1526
|
+
laneEnds[lane] = x + half;
|
|
1527
|
+
return {
|
|
1528
|
+
release,
|
|
1529
|
+
time,
|
|
1530
|
+
x,
|
|
1531
|
+
lane,
|
|
1532
|
+
half
|
|
1533
|
+
};
|
|
1534
|
+
});
|
|
1535
|
+
const lanes = laneEnds.length;
|
|
1536
|
+
const plotBottom = 6 + lanes * laneHeight;
|
|
1537
|
+
return {
|
|
1538
|
+
width,
|
|
1539
|
+
height: plotBottom + 46,
|
|
1540
|
+
plotTop: 6,
|
|
1541
|
+
plotBottom,
|
|
1542
|
+
laneHeight,
|
|
1543
|
+
lanes,
|
|
1544
|
+
bands,
|
|
1545
|
+
marks
|
|
1546
|
+
};
|
|
1547
|
+
}
|
|
1548
|
+
//#endregion
|
|
1549
|
+
//#region src/render/releases.ts
|
|
1550
|
+
/** The changes and the package chips that the page shows before "Show all". */
|
|
1551
|
+
const FIRST_CHANGES = 8;
|
|
1552
|
+
const FIRST_CHIPS = 4;
|
|
1553
|
+
/** The y of the mark below the center line of its lane. The label is above the line. */
|
|
1554
|
+
const MARK_Y = 9;
|
|
1555
|
+
/** The widths of the two timelines: the narrow one for a phone or a tablet, the wide one for a desktop. */
|
|
1556
|
+
const NARROW_WIDTH = 640;
|
|
1557
|
+
const WIDE_WIDTH = 920;
|
|
1558
|
+
const KIND_INFO = {
|
|
1559
|
+
first: {
|
|
1560
|
+
label: "First release",
|
|
1561
|
+
word: "first release",
|
|
1562
|
+
pill: "sp-pill sp-good",
|
|
1563
|
+
legend: "The first release"
|
|
1564
|
+
},
|
|
1565
|
+
major: {
|
|
1566
|
+
label: "MAJOR",
|
|
1567
|
+
word: "major",
|
|
1568
|
+
pill: "sp-pill sp-strong",
|
|
1569
|
+
legend: "MAJOR: a change that breaks the API"
|
|
1570
|
+
},
|
|
1571
|
+
minor: {
|
|
1572
|
+
label: "MINOR",
|
|
1573
|
+
word: "minor",
|
|
1574
|
+
pill: "sp-pill sp-accent",
|
|
1575
|
+
legend: "MINOR: new features (before 1.0.0, also a change of the API)"
|
|
1576
|
+
},
|
|
1577
|
+
patch: {
|
|
1578
|
+
label: "PATCH",
|
|
1579
|
+
word: "patch",
|
|
1580
|
+
pill: "sp-pill",
|
|
1581
|
+
legend: "PATCH: fixes and small additions"
|
|
1582
|
+
}
|
|
1583
|
+
};
|
|
1584
|
+
/** This function gives "Oct 18, 2026" for an ISO date. */
|
|
1585
|
+
function dayWithYear(date) {
|
|
1586
|
+
return `${shortDate(date)}, ${new Date(date).getUTCFullYear()}`;
|
|
1587
|
+
}
|
|
1588
|
+
/**
|
|
1589
|
+
* This function gives the lead line of the releases page, for example "23 versions from Oct 12 to
|
|
1590
|
+
* Oct 18, 2026. 7 MINOR releases and 15 PATCH releases came after the first release."
|
|
1591
|
+
*/
|
|
1592
|
+
function releasesLead(releases) {
|
|
1593
|
+
const summary = releaseSummary(releases);
|
|
1594
|
+
if (!summary.first || !summary.last) return "The repository has no release yet.";
|
|
1595
|
+
const sameYear = new Date(summary.first).getUTCFullYear() === new Date(summary.last).getUTCFullYear();
|
|
1596
|
+
const from = sameYear ? shortDate(summary.first) : dayWithYear(summary.first);
|
|
1597
|
+
const range = shortDate(summary.first) === shortDate(summary.last) && sameYear ? `on ${dayWithYear(summary.last)}` : `from ${from} to ${dayWithYear(summary.last)}`;
|
|
1598
|
+
if (summary.versions === 1) return `1 version, the first release, ${range}.`;
|
|
1599
|
+
const kinds = [
|
|
1600
|
+
summary.major > 0 ? plural(summary.major, "MAJOR release") : "",
|
|
1601
|
+
summary.minor > 0 ? plural(summary.minor, "MINOR release") : "",
|
|
1602
|
+
summary.patch > 0 ? plural(summary.patch, "PATCH release") : ""
|
|
1603
|
+
].filter(Boolean);
|
|
1604
|
+
return `${plural(summary.versions, "version")} ${range}. ${listText(kinds)} came after the first release.`;
|
|
1605
|
+
}
|
|
1606
|
+
/** The shape of a mark. The first release is a diamond, a MAJOR is a large dot, a MINOR is a dot and a PATCH is a ring. */
|
|
1607
|
+
function markShape(kind, y) {
|
|
1608
|
+
if (kind === "first") return `<rect class="sp-dot" x="-5.5" y="${y - 5.5}" width="11" height="11" transform="rotate(45 0 ${y})"/>`;
|
|
1609
|
+
if (kind === "major") return `<circle class="sp-dot" cy="${y}" r="8"/>`;
|
|
1610
|
+
if (kind === "minor") return `<circle class="sp-dot" cy="${y}" r="6.5"/>`;
|
|
1611
|
+
return `<circle class="sp-ring" cy="${y}" r="4.5"/>`;
|
|
1612
|
+
}
|
|
1613
|
+
function legend(releases) {
|
|
1614
|
+
return `<ul class="sp-legend">${[
|
|
1615
|
+
"major",
|
|
1616
|
+
"minor",
|
|
1617
|
+
"patch",
|
|
1618
|
+
"first"
|
|
1619
|
+
].filter((kind) => releases.some((release) => release.kind === kind)).map((kind) => `<li><svg width="18" height="18" viewBox="-9 -9 18 18" aria-hidden="true"><g class="sp-kind-${kind}">${markShape(kind, 0)}</g></svg>${escapeHtml(KIND_INFO[kind].legend)}</li>`).join("")}</ul>`;
|
|
1620
|
+
}
|
|
1621
|
+
const fixed = (value) => value.toFixed(1).replace(/\.0$/, "");
|
|
1622
|
+
function timelineSvg(releases, width, selected) {
|
|
1623
|
+
const layout = timelineLayout(releases, { width });
|
|
1624
|
+
const { plotTop, plotBottom } = layout;
|
|
1625
|
+
const parts = [];
|
|
1626
|
+
for (const band of layout.bands) {
|
|
1627
|
+
const middle = (band.x0 + band.x1) / 2;
|
|
1628
|
+
const room = band.x1 - band.x0;
|
|
1629
|
+
const count = band.empty ? "no release" : plural(band.count, "release");
|
|
1630
|
+
parts.push(`<g${band.empty ? " class=\"sp-empty-band\"" : ""}>`);
|
|
1631
|
+
if (band.empty) parts.push(`<rect class="sp-gap" x="${fixed(band.x0)}" y="${plotTop}" width="${fixed(room)}" height="${plotBottom - plotTop}"/>`);
|
|
1632
|
+
parts.push(`<line class="sp-grid" x1="${fixed(band.x0)}" x2="${fixed(band.x0)}" y1="${plotTop}" y2="${plotBottom + 6}"/>`);
|
|
1633
|
+
if (room >= band.label.length * 7 + 6) parts.push(`<text class="sp-day" x="${fixed(middle)}" y="${plotBottom + 21}" text-anchor="middle">${escapeHtml(band.label)}</text>`);
|
|
1634
|
+
if (room >= count.length * 6 + 6) parts.push(`<text class="sp-day-count" x="${fixed(middle)}" y="${plotBottom + 38}" text-anchor="middle">${escapeHtml(count)}</text>`);
|
|
1635
|
+
parts.push("</g>");
|
|
1636
|
+
}
|
|
1637
|
+
const lastBand = layout.bands[layout.bands.length - 1];
|
|
1638
|
+
if (lastBand) parts.push(`<line class="sp-grid" x1="${fixed(lastBand.x1)}" x2="${fixed(lastBand.x1)}" y1="${plotTop}" y2="${plotBottom + 6}"/>`);
|
|
1639
|
+
parts.push(`<line class="sp-axis" x1="${fixed(layout.bands[0]?.x0 ?? 0)}" x2="${fixed(lastBand?.x1 ?? width)}" y1="${plotBottom}" y2="${plotBottom}"/>`);
|
|
1640
|
+
const axis = `<g aria-hidden="true">${parts.join("")}</g>`;
|
|
1641
|
+
const marks = layout.marks.map((mark) => {
|
|
1642
|
+
const { release } = mark;
|
|
1643
|
+
const on = release.version === selected;
|
|
1644
|
+
const label = `${release.version}, ${KIND_INFO[release.kind].word}, ${shortDate(release.date)}, ${plural(release.changes.length, "change")}`;
|
|
1645
|
+
return `<g class="sp-mark sp-kind-${release.kind}${on ? " sp-selected" : ""}" data-sp-mark data-version="${escapeHtml(release.version)}" data-x="${fixed(mark.x)}" data-half="${fixed(mark.half)}" transform="translate(${fixed(mark.x)} ${fixed(laneCenter(layout, mark.lane))})" role="button" tabindex="${on ? 0 : -1}" aria-pressed="${on}" aria-label="${escapeHtml(label)}"><rect class="sp-hit" x="${fixed(-mark.half - 4)}" y="${fixed(-layout.laneHeight / 2 + 1)}" width="${fixed(mark.half * 2 + 8)}" height="${layout.laneHeight - 2}" rx="4"/><circle class="sp-halo" cy="${MARK_Y}" r="11"/>${markShape(release.kind, MARK_Y)}<text class="sp-label" y="-3" text-anchor="middle">${escapeHtml(release.version)}</text></g>`;
|
|
1646
|
+
});
|
|
1647
|
+
const name = `The release timeline, ${plural(layout.marks.length, "version")}. The arrow keys move between the versions.`;
|
|
1648
|
+
return `<svg class="sp-timeline" width="${layout.width}" height="${layout.height}" viewBox="0 0 ${layout.width} ${layout.height}" role="group" aria-label="${escapeHtml(name)}">${axis}${marks.join("")}</svg>`;
|
|
1649
|
+
}
|
|
1650
|
+
/** The common scope of all the package names ("@procedures/"), or "" when the names have no common scope. */
|
|
1651
|
+
function commonScope(releases) {
|
|
1652
|
+
const names = new Set(releases.flatMap((release) => release.changes.flatMap((change) => change.packages)));
|
|
1653
|
+
const scopes = new Set([...names].map((name) => /^(@[^/]+\/)/.exec(name)?.[1] ?? ""));
|
|
1654
|
+
const [scope] = scopes;
|
|
1655
|
+
return scopes.size === 1 && scope ? scope : "";
|
|
1656
|
+
}
|
|
1657
|
+
function toggleButton(ids, more, less, hidden = "") {
|
|
1658
|
+
return `<button type="button" class="sp-ghost" data-sp-js data-sp-toggle aria-controls="${ids}" aria-expanded="false" data-more="${escapeHtml(more)}" data-less="${escapeHtml(less)}"><span data-sp-label>${escapeHtml(more)}</span>${hidden}</button>`;
|
|
1659
|
+
}
|
|
1660
|
+
function chips(packages, scope, ids) {
|
|
1661
|
+
if (packages.length === 0) return "";
|
|
1662
|
+
const id = `k${ids.next++}`;
|
|
1663
|
+
const items = packages.map((name, index) => {
|
|
1664
|
+
const short = scope && name.startsWith(scope) ? name.slice(scope.length) : name;
|
|
1665
|
+
const extra = index >= FIRST_CHIPS;
|
|
1666
|
+
return `<span class="sp-pill${extra ? " sp-extra" : ""}" title="${escapeHtml(name)}"${extra ? " hidden" : ""}>${escapeHtml(short)}</span>`;
|
|
1667
|
+
});
|
|
1668
|
+
const more = packages.length - FIRST_CHIPS;
|
|
1669
|
+
const button = more > 0 ? toggleButton(id, `+${more} more`, "Fewer", "<span class=\"sp-visually-hidden\"> packages</span>") : "";
|
|
1670
|
+
return `<span class="sp-chips" id="${id}">${items.join("")}${button}</span>`;
|
|
1671
|
+
}
|
|
1672
|
+
function changeItem(change, index, repository, scope, ids) {
|
|
1673
|
+
const extra = index >= FIRST_CHANGES;
|
|
1674
|
+
const commit = change.commit ? link(`https://github.com/${repository}/commit/${change.commit}`, `<code>${escapeHtml(change.commit)}</code>`, `class="sp-commit" title="${escapeHtml(`Commit ${change.commit} on GitHub`)}"`) : "";
|
|
1675
|
+
const meta = commit || change.packages.length > 0 ? `<div class="sp-change-meta">${commit}${chips(change.packages, scope, ids)}</div>` : "";
|
|
1676
|
+
return `<li class="sp-change${extra ? " sp-extra" : ""}"${extra ? " hidden" : ""}><p>${inlineMarkdown(change.text)}</p>${meta}</li>`;
|
|
1677
|
+
}
|
|
1678
|
+
function details(release, index, selected, repository, scope, ids) {
|
|
1679
|
+
const info = KIND_INFO[release.kind];
|
|
1680
|
+
const links = release.links.map((each) => link(each.url, escapeHtml(each.label))).join("");
|
|
1681
|
+
const head = `<div class="sp-release-head"><h2 id="h${index}">${escapeHtml(release.version)}</h2><span class="${info.pill}">${escapeHtml(info.label)}</span><time class="sp-small sp-muted" datetime="${escapeHtml(release.date)}">${escapeHtml(longDate(release.date))}</time>` + (links ? `<span class="sp-release-links sp-small">${links}</span>` : "") + "</div>";
|
|
1682
|
+
let body;
|
|
1683
|
+
if (release.changes.length === 0) body = "<p class=\"sp-small sp-muted\">The sources have no change entry for this version.</p>";
|
|
1684
|
+
else {
|
|
1685
|
+
const packages = new Set(release.changes.flatMap((change) => change.packages)).size;
|
|
1686
|
+
const count = packages > 0 ? `${plural(release.changes.length, "change")} in ${plural(packages, "package")}. The changes with the most packages come first.` : `${plural(release.changes.length, "change")}.`;
|
|
1687
|
+
const listId = `c${index}`;
|
|
1688
|
+
const items = release.changes.map((change, position) => changeItem(change, position, repository, scope, ids));
|
|
1689
|
+
const all = release.changes.length > FIRST_CHANGES ? toggleButton(listId, `Show all ${release.changes.length} changes`, "Show fewer changes") : "";
|
|
1690
|
+
body = `<p class="sp-small sp-muted">${escapeHtml(count)}</p><ul class="sp-changes" id="${listId}">${items.join("")}</ul>${all}`;
|
|
1691
|
+
}
|
|
1692
|
+
return `<section class="sp-panel sp-release" id="v${escapeHtml(release.version)}" data-sp-release data-version="${escapeHtml(release.version)}" aria-labelledby="h${index}"${selected ? "" : " hidden"}>${head}${body}</section>`;
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* The inline module of the releases page. It selects a version on a click, with the arrow keys,
|
|
1696
|
+
* Home, End, Enter and Space. It also opens the long lists and keeps the version in the URL.
|
|
1697
|
+
*/
|
|
1698
|
+
const RELEASES_SCRIPT = String.raw`
|
|
1699
|
+
const boxes = Array.from(document.querySelectorAll("[data-sp-timeline]"));
|
|
1700
|
+
const panels = new Map(Array.from(document.querySelectorAll("[data-sp-release]"), (panel) => [panel.dataset.version, panel]));
|
|
1701
|
+
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
1702
|
+
let selected = (document.querySelector("[data-sp-release]:not([hidden])") || {}).dataset?.version;
|
|
1703
|
+
const markIn = (box, version) => Array.from(box.querySelectorAll("[data-sp-mark]")).find((mark) => mark.dataset.version === version);
|
|
1704
|
+
function reveal(box, version, smooth) {
|
|
1705
|
+
const mark = markIn(box, version);
|
|
1706
|
+
if (!mark || box.scrollWidth <= box.clientWidth) return;
|
|
1707
|
+
const x = Number(mark.dataset.x);
|
|
1708
|
+
const margin = Number(mark.dataset.half) + 16;
|
|
1709
|
+
if (x - margin >= box.scrollLeft && x + margin <= box.scrollLeft + box.clientWidth) return;
|
|
1710
|
+
box.scrollTo({ left: x - box.clientWidth / 2, behavior: smooth && !reduced ? "smooth" : "auto" });
|
|
1711
|
+
}
|
|
1712
|
+
function select(version, box) {
|
|
1713
|
+
const panel = panels.get(version);
|
|
1714
|
+
if (!panel) return;
|
|
1715
|
+
selected = version;
|
|
1716
|
+
for (const each of boxes) {
|
|
1717
|
+
for (const mark of each.querySelectorAll("[data-sp-mark]")) {
|
|
1718
|
+
const on = mark.dataset.version === version;
|
|
1719
|
+
mark.classList.toggle("sp-selected", on);
|
|
1720
|
+
mark.setAttribute("aria-pressed", String(on));
|
|
1721
|
+
mark.setAttribute("tabindex", on ? "0" : "-1");
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
for (const [key, each] of panels) each.hidden = key !== version;
|
|
1725
|
+
if (box) {
|
|
1726
|
+
const mark = markIn(box, version);
|
|
1727
|
+
if (mark) mark.focus({ preventScroll: true });
|
|
1728
|
+
reveal(box, version, true);
|
|
1729
|
+
}
|
|
1730
|
+
try {
|
|
1731
|
+
history.replaceState(null, "", "#" + encodeURIComponent(panel.id));
|
|
1732
|
+
} catch {}
|
|
1733
|
+
}
|
|
1734
|
+
for (const box of boxes) {
|
|
1735
|
+
box.addEventListener("click", (event) => {
|
|
1736
|
+
const mark = event.target.closest("[data-sp-mark]");
|
|
1737
|
+
if (mark) select(mark.dataset.version, box);
|
|
1738
|
+
});
|
|
1739
|
+
box.addEventListener("keydown", (event) => {
|
|
1740
|
+
const mark = event.target.closest("[data-sp-mark]");
|
|
1741
|
+
if (!mark || event.altKey || event.ctrlKey || event.metaKey) return;
|
|
1742
|
+
const marks = Array.from(box.querySelectorAll("[data-sp-mark]"));
|
|
1743
|
+
const position = marks.indexOf(mark);
|
|
1744
|
+
const targets = { ArrowLeft: position - 1, ArrowRight: position + 1, Home: 0, End: marks.length - 1, Enter: position, " ": position };
|
|
1745
|
+
if (!(event.key in targets)) return;
|
|
1746
|
+
event.preventDefault();
|
|
1747
|
+
const next = marks[Math.max(0, Math.min(marks.length - 1, targets[event.key]))];
|
|
1748
|
+
if (next) select(next.dataset.version, box);
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
document.addEventListener("click", (event) => {
|
|
1752
|
+
const button = event.target.closest("[data-sp-toggle]");
|
|
1753
|
+
const list = button && document.getElementById(button.getAttribute("aria-controls"));
|
|
1754
|
+
if (!list) return;
|
|
1755
|
+
const open = button.getAttribute("aria-expanded") !== "true";
|
|
1756
|
+
for (const extra of list.querySelectorAll(":scope > .sp-extra")) extra.hidden = !open;
|
|
1757
|
+
button.setAttribute("aria-expanded", String(open));
|
|
1758
|
+
const label = button.querySelector("[data-sp-label]");
|
|
1759
|
+
if (label) label.textContent = open ? button.dataset.less : button.dataset.more;
|
|
1760
|
+
});
|
|
1761
|
+
const hashed = decodeURIComponent(location.hash.slice(1));
|
|
1762
|
+
for (const [version, panel] of panels) if (panel.id === hashed) select(version, null);
|
|
1763
|
+
const revealAll = () => { for (const box of boxes) reveal(box, selected, false); };
|
|
1764
|
+
revealAll();
|
|
1765
|
+
window.addEventListener("resize", revealAll);
|
|
1766
|
+
`;
|
|
1767
|
+
/** This function renders the complete releases page from the data of `releases.json`. */
|
|
1768
|
+
function renderReleasesPage(releases, options) {
|
|
1769
|
+
const newest = releases[0];
|
|
1770
|
+
const ordered = oldestFirst(releases);
|
|
1771
|
+
const scope = commonScope(releases);
|
|
1772
|
+
const ids = { next: 0 };
|
|
1773
|
+
const lead = `<p class="sp-lead">${escapeHtml(releasesLead(releases))}</p>`;
|
|
1774
|
+
let content = "";
|
|
1775
|
+
if (newest) content = `<div class="sp-panel"><p class="sp-small sp-muted">Each mark is one version, on a time axis in UTC. Select a mark to show its changes.</p>` + legend(releases) + `<div class="sp-timeline-box sp-timeline-narrow" data-sp-timeline>${timelineSvg(ordered, NARROW_WIDTH, newest.version)}</div><div class="sp-timeline-box sp-timeline-wide" data-sp-timeline>${timelineSvg(ordered, WIDE_WIDTH, newest.version)}</div></div>` + releases.map((release, index) => details(release, index, index === 0, options.repository, scope, ids)).join("");
|
|
1776
|
+
return pageShell({
|
|
1777
|
+
title: "Releases",
|
|
1778
|
+
description: releasesLead(releases),
|
|
1779
|
+
current: "releases",
|
|
1780
|
+
main: `<h1>Releases</h1>${lead}${content}<p class="sp-small sp-muted">Source: the <code>CHANGELOG.md</code> files, the git tags, the GitHub releases and npm, read when the site was built.</p>`,
|
|
1781
|
+
script: RELEASES_SCRIPT
|
|
1782
|
+
}, options);
|
|
1783
|
+
}
|
|
1784
|
+
//#endregion
|
|
1785
|
+
//#region src/render/status.ts
|
|
1786
|
+
/**
|
|
1787
|
+
* The standalone status page: the open work of the roadmap file, the open pull requests and the
|
|
1788
|
+
* open issues. The build renders the HTML, and one small inline module adds the filters.
|
|
1789
|
+
*/
|
|
1790
|
+
/** The label, the icon and the meaning of each class. The page always shows a color with an icon and a label. */
|
|
1791
|
+
const CLASS_INFO = {
|
|
1792
|
+
waiting: {
|
|
1793
|
+
label: "Waiting",
|
|
1794
|
+
icon: "◷",
|
|
1795
|
+
meaning: "It waits for a person or a decision"
|
|
1796
|
+
},
|
|
1797
|
+
blocked: {
|
|
1798
|
+
label: "Blocked",
|
|
1799
|
+
icon: "⊘",
|
|
1800
|
+
meaning: "Other work must finish first"
|
|
1801
|
+
},
|
|
1802
|
+
progress: {
|
|
1803
|
+
label: "In progress",
|
|
1804
|
+
icon: "◐",
|
|
1805
|
+
meaning: "Work started"
|
|
1806
|
+
},
|
|
1807
|
+
open: {
|
|
1808
|
+
label: "Open",
|
|
1809
|
+
icon: "○",
|
|
1810
|
+
meaning: "Nobody started it"
|
|
1811
|
+
},
|
|
1812
|
+
later: {
|
|
1813
|
+
label: "Later",
|
|
1814
|
+
icon: "◌",
|
|
1815
|
+
meaning: "It is for later"
|
|
1816
|
+
},
|
|
1817
|
+
done: {
|
|
1818
|
+
label: "Done",
|
|
1819
|
+
icon: "✓",
|
|
1820
|
+
meaning: "It is finished"
|
|
1821
|
+
}
|
|
1822
|
+
};
|
|
1823
|
+
const CHECK_LABEL = {
|
|
1824
|
+
success: "Checks pass",
|
|
1825
|
+
failure: "Checks fail",
|
|
1826
|
+
pending: "Checks run",
|
|
1827
|
+
none: "No checks"
|
|
1828
|
+
};
|
|
1829
|
+
/**
|
|
1830
|
+
* This function gives the lead line of the status page, for example "12 open roadmap items, 9 open
|
|
1831
|
+
* pull requests and 0 open issues, read at Oct 19, 2026, 14:05 UTC from abc1234."
|
|
1832
|
+
*/
|
|
1833
|
+
function statusLead(data) {
|
|
1834
|
+
const parts = [];
|
|
1835
|
+
if (data.source.roadmap) parts.push(plural(data.items.filter((item) => item.statusClass !== "done").length, "open roadmap item"));
|
|
1836
|
+
if (data.github.available) {
|
|
1837
|
+
parts.push(plural(data.github.totals?.pullRequests ?? data.pullRequests.length, "open pull request"));
|
|
1838
|
+
parts.push(plural(data.github.totals?.issues ?? data.issues.length, "open issue"));
|
|
1839
|
+
}
|
|
1840
|
+
const commit = data.commit ? data.commit.slice(0, 7) : null;
|
|
1841
|
+
const read = `read at ${longDate(data.generatedAt)}${commit ? ` from ${commit}` : ""}`;
|
|
1842
|
+
const line = parts.length > 0 ? `${listText(parts)}, ${read}.` : `Read at ${read.slice(8)}.`;
|
|
1843
|
+
return data.github.available ? line : `${line} The build could not read GitHub.`;
|
|
1844
|
+
}
|
|
1845
|
+
/** This function gives the part of a URL for a text: "The server stack (R37)" gives "the-server-stack-r37". */
|
|
1846
|
+
function slugOf(text) {
|
|
1847
|
+
return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "other";
|
|
1848
|
+
}
|
|
1849
|
+
/** This function gives the sections of the items, in the sequence of the file. */
|
|
1850
|
+
function sectionsOf(items) {
|
|
1851
|
+
const sections = /* @__PURE__ */ new Map();
|
|
1852
|
+
for (const item of items) {
|
|
1853
|
+
const slug = slugOf(item.section);
|
|
1854
|
+
const section = sections.get(slug) ?? {
|
|
1855
|
+
slug,
|
|
1856
|
+
label: item.section || "Other",
|
|
1857
|
+
open: 0,
|
|
1858
|
+
done: 0
|
|
1859
|
+
};
|
|
1860
|
+
if (item.statusClass === "done") section.done++;
|
|
1861
|
+
else section.open++;
|
|
1862
|
+
sections.set(slug, section);
|
|
1863
|
+
}
|
|
1864
|
+
return [...sections.values()];
|
|
1865
|
+
}
|
|
1866
|
+
/** This function gives the groups of the open items in the sequence of `OPEN_CLASSES`, without the empty groups. */
|
|
1867
|
+
function openGroups(items) {
|
|
1868
|
+
return OPEN_CLASSES.map((statusClass) => ({
|
|
1869
|
+
statusClass,
|
|
1870
|
+
items: items.filter((item) => item.statusClass === statusClass)
|
|
1871
|
+
})).filter((group) => group.items.length > 0);
|
|
1872
|
+
}
|
|
1873
|
+
/** This function counts the items of each class. */
|
|
1874
|
+
function classCounts(items) {
|
|
1875
|
+
const counts = {
|
|
1876
|
+
waiting: 0,
|
|
1877
|
+
blocked: 0,
|
|
1878
|
+
progress: 0,
|
|
1879
|
+
open: 0,
|
|
1880
|
+
later: 0,
|
|
1881
|
+
done: 0
|
|
1882
|
+
};
|
|
1883
|
+
for (const item of items) counts[item.statusClass]++;
|
|
1884
|
+
return counts;
|
|
1885
|
+
}
|
|
1886
|
+
function roadmapUrl(data) {
|
|
1887
|
+
return `https://github.com/${data.source.github}/blob/${data.commit ?? "HEAD"}/${data.source.roadmap ?? ""}`;
|
|
1888
|
+
}
|
|
1889
|
+
function meter(items) {
|
|
1890
|
+
const counts = classCounts(items);
|
|
1891
|
+
const present = ["done", ...OPEN_CLASSES].filter((value) => counts[value] > 0);
|
|
1892
|
+
const total = Math.max(1, items.length);
|
|
1893
|
+
const summary = present.map((value) => `${counts[value]} ${CLASS_INFO[value].label.toLowerCase()}`).join(", ");
|
|
1894
|
+
const parts = present.map((value) => `<span class="sp-c-${value}" style="flex-grow:${(counts[value] / total).toFixed(4)}"></span>`);
|
|
1895
|
+
const legend = present.map((value) => `<li class="sp-c-${value}"><span class="sp-swatch"></span>${CLASS_INFO[value].label} <strong>${counts[value]}</strong></li>`);
|
|
1896
|
+
return `<div class="sp-meter" role="img" aria-label="${escapeHtml(`${plural(items.length, "item")} of the roadmap: ${summary}`)}">${parts.join("")}</div><ul class="sp-meter-legend" aria-hidden="true">${legend.join("")}</ul>`;
|
|
1897
|
+
}
|
|
1898
|
+
function itemTable(items, data, columns) {
|
|
1899
|
+
const fileUrl = roadmapUrl(data);
|
|
1900
|
+
const none = "<span class=\"sp-muted\">—</span>";
|
|
1901
|
+
const rows = items.map((item) => {
|
|
1902
|
+
const meta = [
|
|
1903
|
+
escapeHtml(item.section),
|
|
1904
|
+
item.area ? inlineMarkdown(item.area) : "",
|
|
1905
|
+
item.needs ? `needs ${inlineMarkdown(item.needs)}` : ""
|
|
1906
|
+
].filter(Boolean).join(" · ");
|
|
1907
|
+
const links = item.links.length > 0 ? `<span class="sp-links">${item.links.map((each) => link(each.url, escapeHtml(each.label))).join("")}</span>` : none;
|
|
1908
|
+
return `<tr class="sp-c-${item.statusClass}" data-section="${escapeHtml(slugOf(item.section))}"><th scope="row">${link(`${fileUrl}?plain=1#L${item.line}`, escapeHtml(item.id), `title="${escapeHtml(`The row of ${item.id} in the roadmap file`)}"`)}</th><td data-label="Item" class="sp-item"><span>${item.title ? inlineMarkdown(item.title) : "<span class=\"sp-muted\">No title</span>"}</span><span class="sp-item-meta sp-small sp-muted">${meta}</span></td>` + (columns.owner ? `<td data-label="Owner">${item.owner ? `<span>${inlineMarkdown(item.owner)}</span>` : none}</td>` : "") + `<td data-label="Status"><span class="sp-status"><span aria-hidden="true">${CLASS_INFO[item.statusClass].icon}</span> ${inlineMarkdown(item.status || CLASS_INFO[item.statusClass].label)}</span></td>` + (columns.links ? `<td data-label="Links">${links}</td>` : "") + "</tr>";
|
|
1909
|
+
});
|
|
1910
|
+
return `<div class="sp-table-box"><table class="sp-table">${"<thead><tr><th scope=\"col\">ID</th><th scope=\"col\">Item</th>" + (columns.owner ? "<th scope=\"col\">Owner</th>" : "") + "<th scope=\"col\">Status</th>" + (columns.links ? "<th scope=\"col\">Links</th>" : "") + "</tr></thead>"}<tbody>${rows.join("")}</tbody></table></div>`;
|
|
1911
|
+
}
|
|
1912
|
+
function openWork(data) {
|
|
1913
|
+
const items = data.items;
|
|
1914
|
+
const open = items.filter((item) => item.statusClass !== "done");
|
|
1915
|
+
const done = items.filter((item) => item.statusClass === "done");
|
|
1916
|
+
const counts = classCounts(items);
|
|
1917
|
+
const columns = {
|
|
1918
|
+
owner: items.some((item) => item.owner),
|
|
1919
|
+
links: items.some((item) => item.links.length > 0)
|
|
1920
|
+
};
|
|
1921
|
+
const file = (data.source.roadmap ?? "").split("/").pop() ?? "";
|
|
1922
|
+
const classChips = OPEN_CLASSES.map((value) => `<button type="button" class="sp-chip sp-c-${value}" data-sp-class="${value}" aria-pressed="false" title="${escapeHtml(CLASS_INFO[value].meaning)}"><span aria-hidden="true">${CLASS_INFO[value].icon}</span> ${CLASS_INFO[value].label} <span class="sp-count">${counts[value]}</span></button>`);
|
|
1923
|
+
const sectionChips = sectionsOf(items).filter((section) => section.open > 0).map((section) => `<button type="button" class="sp-chip" data-sp-section="${escapeHtml(section.slug)}" aria-pressed="false">${escapeHtml(section.label)} <span class="sp-count">${section.open}</span></button>`);
|
|
1924
|
+
const filters = `<div class="sp-filters" role="group" aria-label="Filters of the open work" data-sp-js><span role="group" aria-label="Status of the items">${classChips.join("")}</span>` + (sectionChips.length > 1 ? `<span role="group" aria-label="Section of the roadmap">${sectionChips.join("")}</span>` : "") + `<button type="button" class="sp-ghost" data-sp-clear hidden>Clear the filters</button></div>`;
|
|
1925
|
+
const label = plural(open.length, "open item");
|
|
1926
|
+
const groups = openGroups(items).map((group) => `<section class="sp-group sp-c-${group.statusClass}" data-sp-group data-class="${group.statusClass}" aria-labelledby="group-${group.statusClass}"><h3 id="group-${group.statusClass}"><span aria-hidden="true">${CLASS_INFO[group.statusClass].icon}</span> ${CLASS_INFO[group.statusClass].label} <span class="sp-group-count" data-sp-group-count>${group.items.length}</span></h3>${itemTable(group.items, data, columns)}</section>`);
|
|
1927
|
+
const list = open.length === 0 ? "<p class=\"sp-callout\">The roadmap has no open item.</p>" : `<p class="sp-small sp-muted" aria-live="polite" data-sp-shown data-label="${escapeHtml(label)}">${open.length} of ${escapeHtml(label)}</p>` + groups.join("") + "<p class=\"sp-callout\" data-sp-empty hidden>No open item matches the filters.</p>";
|
|
1928
|
+
const doneText = done.length === 1 ? "1 item is done." : `${done.length} items are done.`;
|
|
1929
|
+
const doneList = done.length === 0 ? `<p class="sp-done sp-small"><span aria-hidden="true">✓</span> No item is done.</p>` : `<details class="sp-done sp-c-done" data-sp-done><summary><span aria-hidden="true">✓</span> ${doneText} <span data-sp-done-label>Show the done items</span></summary><section class="sp-group sp-c-done" data-sp-group data-class="done" aria-label="The done items">${itemTable(done, data, columns)}<p class="sp-callout" data-sp-done-empty hidden>No done item matches the filters.</p></section></details>`;
|
|
1930
|
+
return `<section class="sp-section" aria-labelledby="open-work" data-sp-board><h2 id="open-work">Open work</h2><p class="sp-small sp-muted">Each row of a table of ${link(roadmapUrl(data), `<code>${escapeHtml(file)}</code>`)} with an ID and a status that is not done. The build reads the file, so this list follows each commit.</p>` + meter(items) + (open.length > 0 ? filters : "") + list + doneList + "</section>";
|
|
1931
|
+
}
|
|
1932
|
+
function when(date) {
|
|
1933
|
+
const text = escapeHtml(longDate(date));
|
|
1934
|
+
return `<time datetime="${escapeHtml(date)}" title="${text}" data-sp-rel>${text}</time>`;
|
|
1935
|
+
}
|
|
1936
|
+
function labels(names) {
|
|
1937
|
+
return names.length === 0 ? "" : `<span class="sp-labels">${names.map((name) => `<span class="sp-pill">${escapeHtml(name)}</span>`).join("")}</span>`;
|
|
1938
|
+
}
|
|
1939
|
+
function githubMissing(data) {
|
|
1940
|
+
return `<p class="sp-callout">The build could not read GitHub${data.github.reason ? ` (${escapeHtml(data.github.reason)})` : ""}. Refer to ${link(`https://github.com/${data.source.github}`, escapeHtml(data.source.github))} on GitHub.</p>`;
|
|
1941
|
+
}
|
|
1942
|
+
function pullRequests(data) {
|
|
1943
|
+
const total = data.github.totals?.pullRequests ?? data.pullRequests.length;
|
|
1944
|
+
let body;
|
|
1945
|
+
if (!data.github.available) body = githubMissing(data);
|
|
1946
|
+
else if (data.pullRequests.length === 0) body = `<p class="sp-callout">No pull request is open. Refer to ${link(`https://github.com/${data.source.github}/pulls?q=is%3Apr`, "all the pull requests")}.</p>`;
|
|
1947
|
+
else {
|
|
1948
|
+
const count = total > data.pullRequests.length ? `The ${data.pullRequests.length} most recently updated of ${plural(total, "open pull request")}.` : `${plural(total, "open pull request")}, the most recently updated first.`;
|
|
1949
|
+
const cards = data.pullRequests.map((pull) => `<li class="sp-card">${link(pull.url, `<span class="sp-number">#${pull.number}</span> ${escapeHtml(pull.title)}`, "class=\"sp-card-title\"")}<span class="sp-card-meta"><span class="sp-pill${pull.draft ? "" : " sp-accent"}">${pull.draft ? "Draft" : "Ready"}</span><span class="sp-check sp-check-${pull.checks}"><span class="sp-dot-state" aria-hidden="true"></span>${CHECK_LABEL[pull.checks]}</span><span>by ${escapeHtml(pull.author)}</span><code class="sp-branch">${escapeHtml(pull.branch)}</code><span class="sp-muted">updated ${when(pull.updatedAt)}</span>${labels(pull.labels)}</span></li>`);
|
|
1950
|
+
body = `<p class="sp-small sp-muted">${escapeHtml(count)}</p><ul class="sp-cards">${cards.join("")}</ul>`;
|
|
1951
|
+
}
|
|
1952
|
+
return `<section class="sp-section" aria-labelledby="pull-requests"><h2 id="pull-requests">Pull requests</h2>${body}</section>`;
|
|
1953
|
+
}
|
|
1954
|
+
function issues(data) {
|
|
1955
|
+
const total = data.github.totals?.issues ?? data.issues.length;
|
|
1956
|
+
let body;
|
|
1957
|
+
if (!data.github.available) body = githubMissing(data);
|
|
1958
|
+
else if (data.issues.length === 0) body = `<p class="sp-callout">No issue is open. To report a problem, ${link(`https://github.com/${data.source.github}/issues/new`, "open an issue")}.</p>`;
|
|
1959
|
+
else {
|
|
1960
|
+
const count = total > data.issues.length ? `The ${data.issues.length} most recently updated of ${plural(total, "open issue")}.` : `${plural(total, "open issue")}, the most recently updated first.`;
|
|
1961
|
+
const cards = data.issues.map((issue) => `<li class="sp-card">${link(issue.url, `<span class="sp-number">#${issue.number}</span> ${escapeHtml(issue.title)}`, "class=\"sp-card-title\"")}<span class="sp-card-meta"><span>by ${escapeHtml(issue.author)}</span><span class="sp-muted">updated ${when(issue.updatedAt)}</span>${labels(issue.labels)}</span></li>`);
|
|
1962
|
+
body = `<p class="sp-small sp-muted">${escapeHtml(count)}</p><ul class="sp-cards">${cards.join("")}</ul>`;
|
|
1963
|
+
}
|
|
1964
|
+
return `<section class="sp-section" aria-labelledby="issues"><h2 id="issues">Issues</h2>${body}</section>`;
|
|
1965
|
+
}
|
|
1966
|
+
/**
|
|
1967
|
+
* The inline module of the status page. It filters the open work by class and by section, keeps the
|
|
1968
|
+
* filters in the URL, and shows the age of each GitHub time.
|
|
1969
|
+
*/
|
|
1970
|
+
const STATUS_SCRIPT = String.raw`
|
|
1971
|
+
function relativeTime(then, now) {
|
|
1972
|
+
const seconds = Math.round((now - then) / 1000);
|
|
1973
|
+
if (!Number.isFinite(seconds)) return "at an unknown time";
|
|
1974
|
+
if (seconds < 60) return "just now";
|
|
1975
|
+
const steps = [[60, "minute"], [3600, "hour"], [86400, "day"], [2592000, "month"], [31536000, "year"]];
|
|
1976
|
+
let unit = steps[0];
|
|
1977
|
+
for (const step of steps) if (seconds >= step[0]) unit = step;
|
|
1978
|
+
const count = Math.floor(seconds / unit[0]);
|
|
1979
|
+
return count + " " + unit[1] + (count === 1 ? "" : "s") + " ago";
|
|
1980
|
+
}
|
|
1981
|
+
function listOf(text) {
|
|
1982
|
+
return (text || "").split(",").filter((part) => part !== "");
|
|
1983
|
+
}
|
|
1984
|
+
` + String.raw`
|
|
1985
|
+
const now = Date.now();
|
|
1986
|
+
for (const time of document.querySelectorAll("time[data-sp-rel]")) {
|
|
1987
|
+
time.textContent = relativeTime(Date.parse(time.dateTime), now) + " (" + time.title + ")";
|
|
1988
|
+
}
|
|
1989
|
+
const board = document.querySelector("[data-sp-board]");
|
|
1990
|
+
if (board) {
|
|
1991
|
+
const classChips = Array.from(board.querySelectorAll("[data-sp-class]"));
|
|
1992
|
+
const sectionChips = Array.from(board.querySelectorAll("[data-sp-section]"));
|
|
1993
|
+
const groups = Array.from(board.querySelectorAll("[data-sp-group]"));
|
|
1994
|
+
const shown = board.querySelector("[data-sp-shown]");
|
|
1995
|
+
const empty = board.querySelector("[data-sp-empty]");
|
|
1996
|
+
const clear = board.querySelector("[data-sp-clear]");
|
|
1997
|
+
const done = board.querySelector("[data-sp-done]");
|
|
1998
|
+
const query = new URLSearchParams(location.search);
|
|
1999
|
+
const state = {
|
|
2000
|
+
classes: listOf(query.get("status")).filter((value) => classChips.some((chip) => chip.dataset.spClass === value)),
|
|
2001
|
+
sections: listOf(query.get("section")),
|
|
2002
|
+
};
|
|
2003
|
+
if (done && query.get("done") === "1") done.open = true;
|
|
2004
|
+
const apply = (write) => {
|
|
2005
|
+
for (const chip of classChips) chip.setAttribute("aria-pressed", String(state.classes.includes(chip.dataset.spClass)));
|
|
2006
|
+
for (const chip of sectionChips) chip.setAttribute("aria-pressed", String(state.sections.includes(chip.dataset.spSection)));
|
|
2007
|
+
let count = 0;
|
|
2008
|
+
for (const group of groups) {
|
|
2009
|
+
const isDone = group.dataset.class === "done";
|
|
2010
|
+
const classOn = isDone || state.classes.length === 0 || state.classes.includes(group.dataset.class);
|
|
2011
|
+
let visible = 0;
|
|
2012
|
+
for (const row of group.querySelectorAll("tr[data-section]")) {
|
|
2013
|
+
const on = classOn && (state.sections.length === 0 || state.sections.includes(row.dataset.section));
|
|
2014
|
+
row.hidden = !on;
|
|
2015
|
+
if (on) visible++;
|
|
2016
|
+
}
|
|
2017
|
+
if (isDone) {
|
|
2018
|
+
const none = group.querySelector("[data-sp-done-empty]");
|
|
2019
|
+
if (none) none.hidden = visible > 0;
|
|
2020
|
+
} else {
|
|
2021
|
+
group.hidden = visible === 0;
|
|
2022
|
+
const badge = group.querySelector("[data-sp-group-count]");
|
|
2023
|
+
if (badge) badge.textContent = String(visible);
|
|
2024
|
+
count += visible;
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
if (shown) shown.textContent = count + " of " + shown.dataset.label;
|
|
2028
|
+
if (empty) empty.hidden = count > 0;
|
|
2029
|
+
if (clear) clear.hidden = state.classes.length === 0 && state.sections.length === 0;
|
|
2030
|
+
if (done) {
|
|
2031
|
+
const label = done.querySelector("[data-sp-done-label]");
|
|
2032
|
+
if (label) label.textContent = done.open ? "Hide the done items" : "Show the done items";
|
|
2033
|
+
}
|
|
2034
|
+
if (!write) return;
|
|
2035
|
+
const next = new URLSearchParams(location.search);
|
|
2036
|
+
const put = (key, value) => (value ? next.set(key, value) : next.delete(key));
|
|
2037
|
+
put("status", state.classes.join(","));
|
|
2038
|
+
put("section", state.sections.join(","));
|
|
2039
|
+
put("done", done && done.open ? "1" : "");
|
|
2040
|
+
const search = next.toString();
|
|
2041
|
+
try {
|
|
2042
|
+
history.replaceState(null, "", location.pathname + (search ? "?" + search : "") + location.hash);
|
|
2043
|
+
} catch {}
|
|
2044
|
+
};
|
|
2045
|
+
const toggle = (list, value) => (list.includes(value) ? list.filter((each) => each !== value) : list.concat(value));
|
|
2046
|
+
board.addEventListener("click", (event) => {
|
|
2047
|
+
const chip = event.target.closest("button");
|
|
2048
|
+
if (!chip) return;
|
|
2049
|
+
if (chip.dataset.spClass) state.classes = toggle(state.classes, chip.dataset.spClass);
|
|
2050
|
+
else if (chip.dataset.spSection) state.sections = toggle(state.sections, chip.dataset.spSection);
|
|
2051
|
+
else if (chip.hasAttribute("data-sp-clear")) {
|
|
2052
|
+
state.classes = [];
|
|
2053
|
+
state.sections = [];
|
|
2054
|
+
} else return;
|
|
2055
|
+
apply(true);
|
|
2056
|
+
});
|
|
2057
|
+
if (done) done.addEventListener("toggle", () => apply(true));
|
|
2058
|
+
apply(false);
|
|
2059
|
+
}
|
|
2060
|
+
`;
|
|
2061
|
+
/** This function renders the complete status page from the data of `status.json`. */
|
|
2062
|
+
function renderStatusPage(data, options) {
|
|
2063
|
+
const lead = statusLead(data);
|
|
2064
|
+
return pageShell({
|
|
2065
|
+
title: "Status",
|
|
2066
|
+
description: lead,
|
|
2067
|
+
current: "status",
|
|
2068
|
+
main: `<h1>Status</h1><p class="sp-lead">${escapeHtml(lead)}</p><p class="sp-small sp-muted">The build reads the roadmap file and GitHub again after each commit and on a schedule.</p>` + (data.source.roadmap ? openWork(data) : "") + pullRequests(data) + issues(data),
|
|
2069
|
+
script: STATUS_SCRIPT
|
|
2070
|
+
}, options);
|
|
2071
|
+
}
|
|
2072
|
+
//#endregion
|
|
2073
|
+
//#region src/build.ts
|
|
2074
|
+
/**
|
|
2075
|
+
* The `site-pages build` command. It reads the sources. Then it writes the JSON data and the two
|
|
2076
|
+
* standalone pages into the built output folder of a site.
|
|
2077
|
+
*/
|
|
2078
|
+
/** An error in the command line. The CLI shows the help and exits with code 2. */
|
|
2079
|
+
var UsageError = class extends Error {};
|
|
2080
|
+
function write(file, content, files) {
|
|
2081
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
2082
|
+
writeFileSync(file, content);
|
|
2083
|
+
files.push(file);
|
|
2084
|
+
}
|
|
2085
|
+
/**
|
|
2086
|
+
* This function reads the sources and writes `data/releases.json`, `data/status.json`,
|
|
2087
|
+
* `releases/index.html` and `status/index.html` into the output folder.
|
|
2088
|
+
*
|
|
2089
|
+
* @throws ReleaseDataError or RoadmapParseError when a source on disk is not correct
|
|
2090
|
+
*/
|
|
2091
|
+
async function build(options) {
|
|
2092
|
+
const { repoDir, repository, warn = () => {} } = options;
|
|
2093
|
+
const now = options.now ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
2094
|
+
const commit = options.commit === void 0 ? headCommit(repoDir) : options.commit;
|
|
2095
|
+
const [owner = "", repo = ""] = repository.split("/");
|
|
2096
|
+
const out = isAbsolute(options.out) ? options.out : resolve(repoDir, options.out);
|
|
2097
|
+
const fetcher = options.fetch ? { fetch: options.fetch } : {};
|
|
2098
|
+
const releases = await readReleases({
|
|
2099
|
+
repoDir,
|
|
2100
|
+
repository,
|
|
2101
|
+
token: options.token,
|
|
2102
|
+
changesets: options.changesets,
|
|
2103
|
+
packages: options.packages,
|
|
2104
|
+
group: options.group,
|
|
2105
|
+
ref: commit ?? void 0,
|
|
2106
|
+
now,
|
|
2107
|
+
warn,
|
|
2108
|
+
...fetcher
|
|
2109
|
+
});
|
|
2110
|
+
const status = await readStatusBoard({
|
|
2111
|
+
repoDir,
|
|
2112
|
+
roadmap: options.roadmap ?? null,
|
|
2113
|
+
owner,
|
|
2114
|
+
repo,
|
|
2115
|
+
token: options.token,
|
|
2116
|
+
commit,
|
|
2117
|
+
generatedAt: now,
|
|
2118
|
+
...fetcher
|
|
2119
|
+
});
|
|
2120
|
+
if (!status.github.available) warn(`the status page has no GitHub data: ${status.github.reason ?? "no reason"}.`);
|
|
2121
|
+
const files = [];
|
|
2122
|
+
write(join(out, "data", "releases.json"), `${JSON.stringify(releases, null, 2)}\n`, files);
|
|
2123
|
+
write(join(out, "data", "status.json"), `${JSON.stringify(status, null, 2)}\n`, files);
|
|
2124
|
+
if (!options.dataOnly) {
|
|
2125
|
+
const page = {
|
|
2126
|
+
repository,
|
|
2127
|
+
base: options.base,
|
|
2128
|
+
siteName: options.siteName,
|
|
2129
|
+
extraCss: options.css ? readFileSync(isAbsolute(options.css) ? options.css : resolve(repoDir, options.css), "utf8") : void 0,
|
|
2130
|
+
generatedAt: now,
|
|
2131
|
+
commit
|
|
2132
|
+
};
|
|
2133
|
+
write(join(out, "releases", "index.html"), renderReleasesPage(releases, page), files);
|
|
2134
|
+
write(join(out, "status", "index.html"), renderStatusPage(status, page), files);
|
|
2135
|
+
}
|
|
2136
|
+
return {
|
|
2137
|
+
files,
|
|
2138
|
+
releases,
|
|
2139
|
+
status
|
|
2140
|
+
};
|
|
2141
|
+
}
|
|
2142
|
+
/** The help of the CLI. */
|
|
2143
|
+
const HELP = `Usage: site-pages build --out <dir> [options]
|
|
2144
|
+
|
|
2145
|
+
Writes <out>/data/releases.json, <out>/data/status.json, <out>/releases/index.html
|
|
2146
|
+
and <out>/status/index.html. Start it in the repository, after the build of the site.
|
|
2147
|
+
|
|
2148
|
+
Options:
|
|
2149
|
+
--out <dir> The built output folder of the site (necessary)
|
|
2150
|
+
--repo <owner/name> The GitHub repository (default: $GITHUB_REPOSITORY, else the remote "origin")
|
|
2151
|
+
--base </name/> The path of the site home (default: "/<name>/", or "/" for <owner>.github.io)
|
|
2152
|
+
--roadmap <file> The roadmap file (default: none, the status page has only the GitHub part)
|
|
2153
|
+
--packages <names> npm names or a @scope, separated by commas, for the npm dates
|
|
2154
|
+
--changesets <glob> The changelog files (default: packages/*/CHANGELOG.md, else CHANGELOG.md)
|
|
2155
|
+
--group <name> The npm name of the package whose changelog gives the versions
|
|
2156
|
+
--token <token> A GitHub token with read access (default: $GITHUB_TOKEN)
|
|
2157
|
+
--css <file> A CSS file for the pages, after the default CSS
|
|
2158
|
+
--site-name <name> The name of the site in the header (default: the repository name)
|
|
2159
|
+
--data-only Write only the JSON data, not the pages
|
|
2160
|
+
-h, --help Show this help
|
|
2161
|
+
`;
|
|
2162
|
+
/**
|
|
2163
|
+
* This function reads the command line into the options of `build`.
|
|
2164
|
+
*
|
|
2165
|
+
* @throws UsageError when the command line is not correct
|
|
2166
|
+
*/
|
|
2167
|
+
function parseBuildArgs(argv, io) {
|
|
2168
|
+
let parsed;
|
|
2169
|
+
try {
|
|
2170
|
+
parsed = parseCommandLine(argv);
|
|
2171
|
+
} catch (error) {
|
|
2172
|
+
throw new UsageError(error instanceof Error ? error.message : String(error));
|
|
2173
|
+
}
|
|
2174
|
+
const { values, positionals } = parsed;
|
|
2175
|
+
if (values.help) return "help";
|
|
2176
|
+
if (positionals[0] !== "build" || positionals.length > 1) throw new UsageError(positionals.length === 0 ? "no command was given" : `unknown command: ${positionals.join(" ")}`);
|
|
2177
|
+
if (!values.out) throw new UsageError("--out is necessary");
|
|
2178
|
+
const repository = values.repo || io.env["GITHUB_REPOSITORY"] || originRepository(io.cwd) || "";
|
|
2179
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(repository)) throw new UsageError(repository ? `--repo must be "owner/name", not "${repository}"` : "give --repo <owner/name> or set GITHUB_REPOSITORY");
|
|
2180
|
+
const name = repository.split("/")[1] ?? "";
|
|
2181
|
+
let base = values.base ?? (/\.github\.io$/i.test(name) ? "/" : `/${name}/`);
|
|
2182
|
+
if (!base.startsWith("/")) base = `/${base}`;
|
|
2183
|
+
if (!base.endsWith("/")) base = `${base}/`;
|
|
2184
|
+
const packages = (values.packages ?? []).flatMap((entry) => entry.split(",")).map((entry) => entry.trim()).filter(Boolean);
|
|
2185
|
+
const changesets = values.changesets;
|
|
2186
|
+
return {
|
|
2187
|
+
repoDir: io.cwd,
|
|
2188
|
+
repository,
|
|
2189
|
+
out: values.out,
|
|
2190
|
+
base,
|
|
2191
|
+
roadmap: values.roadmap,
|
|
2192
|
+
packages,
|
|
2193
|
+
changesets,
|
|
2194
|
+
group: values.group,
|
|
2195
|
+
token: values.token || io.env["GITHUB_TOKEN"] || void 0,
|
|
2196
|
+
css: values.css,
|
|
2197
|
+
siteName: values["site-name"],
|
|
2198
|
+
dataOnly: values["data-only"] === true
|
|
2199
|
+
};
|
|
2200
|
+
}
|
|
2201
|
+
function parseCommandLine(argv) {
|
|
2202
|
+
return parseArgs({
|
|
2203
|
+
args: [...argv],
|
|
2204
|
+
allowPositionals: true,
|
|
2205
|
+
strict: true,
|
|
2206
|
+
options: {
|
|
2207
|
+
out: { type: "string" },
|
|
2208
|
+
repo: { type: "string" },
|
|
2209
|
+
base: { type: "string" },
|
|
2210
|
+
roadmap: { type: "string" },
|
|
2211
|
+
packages: {
|
|
2212
|
+
type: "string",
|
|
2213
|
+
multiple: true
|
|
2214
|
+
},
|
|
2215
|
+
changesets: { type: "string" },
|
|
2216
|
+
group: { type: "string" },
|
|
2217
|
+
token: { type: "string" },
|
|
2218
|
+
css: { type: "string" },
|
|
2219
|
+
"site-name": { type: "string" },
|
|
2220
|
+
"data-only": { type: "boolean" },
|
|
2221
|
+
help: {
|
|
2222
|
+
type: "boolean",
|
|
2223
|
+
short: "h"
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
});
|
|
2227
|
+
}
|
|
2228
|
+
/**
|
|
2229
|
+
* This function starts the CLI and gives the exit code. The code is 0 after a build, 1 when a
|
|
2230
|
+
* source is not correct, and 2 for an incorrect command line.
|
|
2231
|
+
*/
|
|
2232
|
+
async function runCli(argv, io) {
|
|
2233
|
+
let options;
|
|
2234
|
+
try {
|
|
2235
|
+
options = parseBuildArgs(argv, io);
|
|
2236
|
+
} catch (error) {
|
|
2237
|
+
if (!(error instanceof UsageError)) throw error;
|
|
2238
|
+
io.stderr(`site-pages: ${error.message}\n\n${HELP}`);
|
|
2239
|
+
return 2;
|
|
2240
|
+
}
|
|
2241
|
+
if (options === "help") {
|
|
2242
|
+
io.stdout(HELP);
|
|
2243
|
+
return 0;
|
|
2244
|
+
}
|
|
2245
|
+
try {
|
|
2246
|
+
const result = await build({
|
|
2247
|
+
...options,
|
|
2248
|
+
warn: (message) => io.stderr(`site-pages: ${message}\n`),
|
|
2249
|
+
...io.fetch ? { fetch: io.fetch } : {},
|
|
2250
|
+
...io.now ? { now: io.now } : {}
|
|
2251
|
+
});
|
|
2252
|
+
const open = result.status.items.filter((item) => item.statusClass !== "done").length;
|
|
2253
|
+
const github = result.status.github.available ? `${plural(result.status.pullRequests.length, "open pull request")} and ${plural(result.status.issues.length, "open issue")}` : "no GitHub data";
|
|
2254
|
+
io.stdout(`site-pages: ${plural(result.releases.length, "release")}, ${plural(result.status.items.length, "roadmap item")} (${open} open), ${github}.\n` + result.files.map((file) => ` ${file}\n`).join(""));
|
|
2255
|
+
return 0;
|
|
2256
|
+
} catch (error) {
|
|
2257
|
+
if (error instanceof ReleaseDataError || error instanceof RoadmapParseError || error?.code === "ENOENT") {
|
|
2258
|
+
io.stderr(`site-pages: ${error.message}\n`);
|
|
2259
|
+
return 1;
|
|
2260
|
+
}
|
|
2261
|
+
throw error;
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
//#endregion
|
|
2265
|
+
export { GITHUB_API as $, RoadmapParseError as A, DEFAULT_CHANGELOGS as B, longDate as C, inlineMarkdown as D, escapeHtml as E, plainText as F, globFiles as G, mergeReleases as H, readStatusBoard as I, headingDate as J, gitTagDates as K, roadmapItems as L, buildStatusBoard as M, linksOf as N, safeUrl as O, markdownTables as P, npmPublishTimes as Q, splitRow as R, dayRange as S, utcDay as T, readChangelogs as U, ReleaseDataError as V, readReleases as W, originRepository as X, isShallow as Y, NPM_REGISTRY as Z, oldestFirst as _, runCli as a, headingAnchor as at, BASE_CSS as b, openGroups as c, compareVersions as ct, slugOf as d, versionOfTag as dt, GITHUB_GRAPHQL as et, statusLead as f, laneCenter as g, renderReleasesPage as h, parseBuildArgs as i, firstSentence as it, STATUS_CLASSES as j, OPEN_CLASSES as k, renderStatusPage as l, kindOf as lt, releasesLead as m, UsageError as n, githubOpenWork as nt, STATUS_SCRIPT as o, parseChangelog as ot, RELEASES_SCRIPT as p, headCommit as q, build as r, githubReleases as rt, classCounts as s, releaseBodyEntries as st, HELP as t, checkStateOf as tt, sectionsOf as u, parseVersion as ut, releaseSummary as v, shortDate as w, pageShell as x, timelineLayout as y, statusClassOf as z };
|
|
2266
|
+
|
|
2267
|
+
//# sourceMappingURL=build-vAps0mRE.js.map
|