website-xp-phone 2.2.11 → 2.3.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/.github/workflows/ci.yml +2 -2
- package/README.md +20 -0
- package/build/env.ts +4 -1
- package/build/routeSkeletonsPlugin.ts +44 -2
- package/package.json +8 -5
- package/public/blog/random/hlwm.md +8 -0
- package/public/checksums.txt +63 -63
- package/public/documents/drift-report.json +30 -26
- package/render.yaml +11 -1
- package/scripts/mutable-assets.mjs +20 -22
- package/scripts/refresh-content.mjs +53 -32
- package/scripts/refresh-soundcloud.mjs +175 -136
- package/scripts/write-dist-checksums.mjs +48 -4
- package/src/content/soundcloud.json +39 -39
- package/src/lib/embeds.ts +43 -24
- package/src/lib/markdown.tsx +8 -21
- package/src/lib/rehypeYoutubeEmbed.ts +65 -0
- package/src/lib/sanitizeSchema.ts +32 -0
- package/src/styles/extras.css +7 -0
- package/src/styles/window.css +10 -7
- package/test/deploy-pipeline.test.js +73 -0
- package/test/embeds.test.js +89 -0
- package/test/mutable-assets.test.js +23 -4
- package/test/route-skeletons.test.js +38 -0
- package/test/youtubeEmbed.test.js +157 -0
- package/tsconfig.node.tsbuildinfo +1 -1
- package/tsconfig.tsbuildinfo +1 -1
- package/.opencode/cache/index.akinevz.html +0 -54
- package/public/addons/sidetip-0.1.19.zip +0 -0
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Validates and normalizes the authored JSON content under src/content.
|
|
4
|
+
*
|
|
5
|
+
* Deliberately does NOT regenerate live-data snapshots: soundcloud.json is
|
|
6
|
+
* rewritten by scripts/refresh-soundcloud.mjs (network-dependent), invoked
|
|
7
|
+
* by the `refresh:soundcloud` script. Keeping that out of here means every
|
|
8
|
+
* entry point that runs this script — including a deploy-side build — can
|
|
9
|
+
* only validate content, never re-generate it. Deploy builds must ship the
|
|
10
|
+
* committed snapshot exactly as `build:local` produced it.
|
|
11
|
+
*/
|
|
3
12
|
import { readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
|
4
13
|
import { dirname, resolve } from "node:path";
|
|
5
14
|
import { fileURLToPath } from "node:url";
|
|
@@ -13,52 +22,64 @@ const projectRoot = resolve(__dirname, "..");
|
|
|
13
22
|
* that `verify:policy` (which may run before `checksums`) does not act on a
|
|
14
23
|
* report produced by a previous build. The checksums step rewrites it.
|
|
15
24
|
*/
|
|
16
|
-
const driftReportPath = resolve(
|
|
25
|
+
const driftReportPath = resolve(
|
|
26
|
+
projectRoot,
|
|
27
|
+
"public",
|
|
28
|
+
"documents",
|
|
29
|
+
"drift-report.json",
|
|
30
|
+
);
|
|
17
31
|
|
|
18
32
|
const contentRoot = resolve(projectRoot, "src/content");
|
|
19
33
|
|
|
20
34
|
/** Every structural JSON file under src/content (recursively). */
|
|
21
35
|
async function listContentJson(dir = contentRoot) {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
37
|
+
const nested = await Promise.all(
|
|
38
|
+
entries.map((entry) =>
|
|
39
|
+
entry.isDirectory()
|
|
40
|
+
? listContentJson(resolve(dir, entry.name))
|
|
41
|
+
: entry.name.endsWith(".json")
|
|
42
|
+
? [resolve(dir, entry.name)]
|
|
43
|
+
: [],
|
|
44
|
+
),
|
|
45
|
+
);
|
|
46
|
+
return nested.flat();
|
|
33
47
|
}
|
|
34
48
|
|
|
35
49
|
async function validateLocalContent() {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
50
|
+
for (const inputPath of await listContentJson()) {
|
|
51
|
+
const content = await readFile(inputPath, "utf8");
|
|
52
|
+
JSON.parse(content);
|
|
39
53
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
54
|
+
// Normalize trailing newline so build outputs remain stable.
|
|
55
|
+
await writeFile(inputPath, `${content.trim()}\n`, "utf8");
|
|
56
|
+
process.stdout.write(`Validated local content: ${inputPath}\n`);
|
|
57
|
+
}
|
|
44
58
|
}
|
|
45
59
|
|
|
46
60
|
export async function run() {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
61
|
+
await unlink(driftReportPath).catch((error) => {
|
|
62
|
+
if (!(
|
|
63
|
+
error &&
|
|
64
|
+
typeof error === "object" &&
|
|
65
|
+
"code" in error &&
|
|
66
|
+
error.code === "ENOENT"
|
|
67
|
+
)) {
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
await validateLocalContent();
|
|
54
72
|
}
|
|
55
73
|
|
|
56
74
|
const isDirectExecution =
|
|
57
|
-
|
|
75
|
+
process.argv[1] &&
|
|
76
|
+
fileURLToPath(import.meta.url) === resolve(process.argv[1]);
|
|
58
77
|
|
|
59
78
|
if (isDirectExecution) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
79
|
+
run().catch((error) => {
|
|
80
|
+
process.stderr.write(
|
|
81
|
+
`${error instanceof Error ? error.message : String(error)}\n`,
|
|
82
|
+
);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
});
|
|
64
85
|
}
|
|
@@ -43,7 +43,7 @@ const SOUND_CLOUD_OWNERS = ["akinevz", "akinevz2"];
|
|
|
43
43
|
|
|
44
44
|
/** User-Agent sent for every outbound HTTP request. */
|
|
45
45
|
const USER_AGENT =
|
|
46
|
-
|
|
46
|
+
"Mozilla/5.0 (X11; Linux x86_64; rv:139.0) Gecko/20100101 Firefox/139.0";
|
|
47
47
|
|
|
48
48
|
/** Fetch timeout (ms) for every HTTP request. */
|
|
49
49
|
const FETCH_TIMEOUT_MS = 15_000;
|
|
@@ -63,17 +63,17 @@ const getProfileUrl = (owner) => `https://soundcloud.com/${owner}`;
|
|
|
63
63
|
* (callers decide how to interpret it); throws only on network failure.
|
|
64
64
|
*/
|
|
65
65
|
const fetchWithTimeout = async (url, options = {}) => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
66
|
+
const controller = new AbortController();
|
|
67
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
68
|
+
try {
|
|
69
|
+
return await fetch(url, {
|
|
70
|
+
...options,
|
|
71
|
+
headers: { "User-Agent": USER_AGENT, ...options.headers },
|
|
72
|
+
signal: controller.signal,
|
|
73
|
+
});
|
|
74
|
+
} finally {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
}
|
|
77
77
|
};
|
|
78
78
|
|
|
79
79
|
/**
|
|
@@ -82,21 +82,23 @@ const fetchWithTimeout = async (url, options = {}) => {
|
|
|
82
82
|
* JSON. Throws with the HTTP status on definitive failure.
|
|
83
83
|
*/
|
|
84
84
|
const fetchApiJson = async (url, attempt = 1) => {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
85
|
+
try {
|
|
86
|
+
const response = await fetchWithTimeout(url, {
|
|
87
|
+
headers: { Accept: "application/json" },
|
|
88
|
+
});
|
|
89
|
+
if (!response.ok) {
|
|
90
|
+
throw new Error(`HTTP ${response.status} for ${new URL(url).pathname}`);
|
|
91
|
+
}
|
|
92
|
+
return await response.json();
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (attempt < API_RETRIES) {
|
|
95
|
+
await new Promise((resolveSleep) =>
|
|
96
|
+
setTimeout(resolveSleep, 1_000 * attempt),
|
|
97
|
+
);
|
|
98
|
+
return fetchApiJson(url, attempt + 1);
|
|
99
99
|
}
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
100
102
|
};
|
|
101
103
|
|
|
102
104
|
/**
|
|
@@ -105,46 +107,53 @@ const fetchApiJson = async (url, attempt = 1) => {
|
|
|
105
107
|
* they must never be hard-coded. Throws if no bundle yields one.
|
|
106
108
|
*/
|
|
107
109
|
const resolveClientId = async () => {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
110
|
+
const profileResponse = await fetchWithTimeout(
|
|
111
|
+
getProfileUrl(SOUND_CLOUD_OWNERS[0]),
|
|
112
|
+
);
|
|
113
|
+
if (!profileResponse.ok) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`Could not fetch profile HTML: HTTP ${profileResponse.status}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const html = await profileResponse.text();
|
|
113
119
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
120
|
+
const assetUrls = [
|
|
121
|
+
...new Set(
|
|
122
|
+
[
|
|
123
|
+
...html.matchAll(
|
|
124
|
+
/https:\/\/a-v2\.sndcdn\.com\/assets\/[a-zA-Z0-9._-]+\.js/g,
|
|
119
125
|
),
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
126
|
+
].map((match) => match[0]),
|
|
127
|
+
),
|
|
128
|
+
];
|
|
129
|
+
if (assetUrls.length === 0) {
|
|
130
|
+
throw new Error("No SoundCloud asset bundles found in profile HTML");
|
|
131
|
+
}
|
|
124
132
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
133
|
+
for (const assetUrl of assetUrls) {
|
|
134
|
+
const assetResponse = await fetchWithTimeout(assetUrl);
|
|
135
|
+
if (!assetResponse.ok) continue;
|
|
136
|
+
const js = await assetResponse.text();
|
|
137
|
+
const match = js.match(/client_id:"([a-zA-Z0-9]{20,40})"/);
|
|
138
|
+
if (match) return match[1];
|
|
139
|
+
}
|
|
132
140
|
|
|
133
|
-
|
|
141
|
+
throw new Error("No client_id found in any SoundCloud asset bundle");
|
|
134
142
|
};
|
|
135
143
|
|
|
136
144
|
/**
|
|
137
|
-
* Resolve an owner handle to its
|
|
145
|
+
* Resolve an owner handle to its public api-v2 user object (id plus
|
|
146
|
+
* profile metadata such as the public track_count).
|
|
138
147
|
*/
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
+
const resolveUser = async (owner, clientId) => {
|
|
149
|
+
const resolveUrl = new URL("https://api-v2.soundcloud.com/resolve");
|
|
150
|
+
resolveUrl.searchParams.set("url", getProfileUrl(owner));
|
|
151
|
+
resolveUrl.searchParams.set("client_id", clientId);
|
|
152
|
+
const user = await fetchApiJson(resolveUrl.toString());
|
|
153
|
+
if (typeof user?.id !== "number") {
|
|
154
|
+
throw new Error(`Could not resolve user id for ${owner}`);
|
|
155
|
+
}
|
|
156
|
+
return user;
|
|
148
157
|
};
|
|
149
158
|
|
|
150
159
|
/**
|
|
@@ -153,26 +162,27 @@ const resolveUserId = async (owner, clientId) => {
|
|
|
153
162
|
* re-appended on every page or page 2+ returns HTTP 401.
|
|
154
163
|
*/
|
|
155
164
|
const fetchAllTracks = async (owner, userId, clientId) => {
|
|
156
|
-
|
|
157
|
-
|
|
165
|
+
const tracks = [];
|
|
166
|
+
let href = `https://api-v2.soundcloud.com/users/${userId}/tracks?client_id=${clientId}&limit=200&representation=full`;
|
|
158
167
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
const nextHref = typeof page?.next_href === "string" ? page.next_href : null;
|
|
167
|
-
href = nextHref
|
|
168
|
-
? `${nextHref}${nextHref.includes("?") ? "&" : "?"}client_id=${clientId}`
|
|
169
|
-
: null;
|
|
168
|
+
while (href) {
|
|
169
|
+
const page = await fetchApiJson(href);
|
|
170
|
+
const collection = Array.isArray(page?.collection) ? page.collection : [];
|
|
171
|
+
for (const track of collection) {
|
|
172
|
+
if (typeof track?.permalink_url !== "string") continue;
|
|
173
|
+
tracks.push(track);
|
|
170
174
|
}
|
|
175
|
+
const nextHref =
|
|
176
|
+
typeof page?.next_href === "string" ? page.next_href : null;
|
|
177
|
+
href = nextHref
|
|
178
|
+
? `${nextHref}${nextHref.includes("?") ? "&" : "?"}client_id=${clientId}`
|
|
179
|
+
: null;
|
|
180
|
+
}
|
|
171
181
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
182
|
+
if (tracks.length === 0) {
|
|
183
|
+
throw new Error(`Discography for ${owner} came back empty`);
|
|
184
|
+
}
|
|
185
|
+
return tracks;
|
|
176
186
|
};
|
|
177
187
|
|
|
178
188
|
/**
|
|
@@ -180,87 +190,116 @@ const fetchAllTracks = async (owner, userId, clientId) => {
|
|
|
180
190
|
* URL slug if the title is missing.
|
|
181
191
|
*/
|
|
182
192
|
const toSnapshotTrack = (owner, track) => {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
193
|
+
const url = track.permalink_url;
|
|
194
|
+
const path = `/${url.replace(/^https?:\/\/(www\.)?soundcloud\.com\//, "")}`;
|
|
195
|
+
return {
|
|
196
|
+
owner,
|
|
197
|
+
path,
|
|
198
|
+
title:
|
|
199
|
+
typeof track.title === "string" && track.title.length > 0
|
|
200
|
+
? track.title
|
|
201
|
+
: path.split("/").pop(),
|
|
202
|
+
url,
|
|
203
|
+
};
|
|
194
204
|
};
|
|
195
205
|
|
|
196
206
|
/**
|
|
197
207
|
* Fetch the complete discography for one owner. Returns the snapshot-shaped
|
|
198
208
|
* track list in API order (newest first).
|
|
199
209
|
*/
|
|
210
|
+
/**
|
|
211
|
+
* Fetch the complete discography for one owner. Returns the snapshot-shaped
|
|
212
|
+
* track list in API order (newest first).
|
|
213
|
+
*
|
|
214
|
+
* Integrity: the fetched list must match the profile's PUBLIC track_count —
|
|
215
|
+
* the number any anonymous visitor (and this unauthenticated snapshot) can
|
|
216
|
+
* see. A mismatch means the list came back truncated or the API lagged a
|
|
217
|
+
* fresh upload; retry once, then fail loudly rather than write a silently
|
|
218
|
+
* incomplete discography. (Tracks the owner sees but the public does not —
|
|
219
|
+
* still processing or private — never inflate the public count, so they do
|
|
220
|
+
* not trip this check.)
|
|
221
|
+
*/
|
|
200
222
|
const fetchOwnerDiscography = async (owner, clientId) => {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
223
|
+
const user = await resolveUser(owner, clientId);
|
|
224
|
+
let apiTracks = await fetchAllTracks(owner, user.id, clientId);
|
|
225
|
+
const publicCount =
|
|
226
|
+
typeof user.track_count === "number" ? user.track_count : null;
|
|
227
|
+
if (publicCount !== null && apiTracks.length !== publicCount) {
|
|
228
|
+
apiTracks = await fetchAllTracks(owner, user.id, clientId);
|
|
229
|
+
if (apiTracks.length !== publicCount) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`Discography for ${owner} returned ${apiTracks.length} track(s) ` +
|
|
232
|
+
`but the public profile counts ${publicCount} — refusing to ` +
|
|
233
|
+
`write a truncated snapshot`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return apiTracks.map((track) => toSnapshotTrack(owner, track));
|
|
204
238
|
};
|
|
205
239
|
|
|
206
240
|
export const run = async () => {
|
|
207
|
-
|
|
208
|
-
|
|
241
|
+
const clientId = await resolveClientId();
|
|
242
|
+
process.stdout.write(`Resolved SoundCloud client_id: ${clientId}\n`);
|
|
209
243
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
244
|
+
const discographies = new Map();
|
|
245
|
+
for (const owner of SOUND_CLOUD_OWNERS) {
|
|
246
|
+
discographies.set(owner, await fetchOwnerDiscography(owner, clientId));
|
|
247
|
+
}
|
|
214
248
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
249
|
+
// Flat track list in per-owner API order (newest first). The music page
|
|
250
|
+
// filters this array by `owner`, so per-owner relative order is what the
|
|
251
|
+
// UI displays.
|
|
252
|
+
const tracks = SOUND_CLOUD_OWNERS.flatMap(
|
|
253
|
+
(owner) => discographies.get(owner) ?? [],
|
|
254
|
+
);
|
|
255
|
+
const trackCount = tracks.length;
|
|
220
256
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
};
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
const result = {
|
|
236
|
-
source: getProfileUrl(SOUND_CLOUD_OWNERS[0]),
|
|
237
|
-
generatedAt: new Date().toISOString(),
|
|
238
|
-
asOfUploadingTrackCount: trackCount,
|
|
239
|
-
trackCount,
|
|
240
|
-
tracks,
|
|
241
|
-
profiles,
|
|
257
|
+
// Per-owner sections. The component groups the discography by `owner`,
|
|
258
|
+
// so emitting a `profiles` array ensures every owner gets its own
|
|
259
|
+
// section — not just the first one.
|
|
260
|
+
const profiles = SOUND_CLOUD_OWNERS.map((owner) => {
|
|
261
|
+
const ownerTracks = discographies.get(owner) ?? [];
|
|
262
|
+
return {
|
|
263
|
+
owner,
|
|
264
|
+
source: getProfileUrl(owner),
|
|
265
|
+
profileImageUrl: null,
|
|
266
|
+
trackCount: ownerTracks.length,
|
|
267
|
+
tracks: ownerTracks,
|
|
242
268
|
};
|
|
269
|
+
});
|
|
243
270
|
|
|
244
|
-
|
|
271
|
+
const result = {
|
|
272
|
+
source: getProfileUrl(SOUND_CLOUD_OWNERS[0]),
|
|
273
|
+
generatedAt: new Date().toISOString(),
|
|
274
|
+
asOfUploadingTrackCount: trackCount,
|
|
275
|
+
trackCount,
|
|
276
|
+
tracks,
|
|
277
|
+
profiles,
|
|
278
|
+
};
|
|
245
279
|
|
|
246
|
-
|
|
247
|
-
`Refreshed ${result.tracks.length} SoundCloud track(s) into ${outputFile}\n` +
|
|
248
|
-
profiles
|
|
249
|
-
.map((profile) => ` @${profile.owner}: ${profile.trackCount} track(s)\n`)
|
|
250
|
-
.join(""),
|
|
251
|
-
);
|
|
280
|
+
await writeFile(outputFile, `${JSON.stringify(result, null, 2)}\n`, "utf8");
|
|
252
281
|
|
|
253
|
-
|
|
282
|
+
process.stdout.write(
|
|
283
|
+
`Refreshed ${result.tracks.length} SoundCloud track(s) into ${outputFile}\n` +
|
|
284
|
+
profiles
|
|
285
|
+
.map(
|
|
286
|
+
(profile) => ` @${profile.owner}: ${profile.trackCount} track(s)\n`,
|
|
287
|
+
)
|
|
288
|
+
.join(""),
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
return result;
|
|
254
292
|
};
|
|
255
293
|
|
|
256
294
|
const isDirectExecution =
|
|
257
|
-
|
|
295
|
+
process.argv[1] &&
|
|
296
|
+
fileURLToPath(import.meta.url) === resolve(process.argv[1]);
|
|
258
297
|
|
|
259
298
|
if (isDirectExecution) {
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
299
|
+
run().catch((error) => {
|
|
300
|
+
process.stderr.write(
|
|
301
|
+
`${error instanceof Error ? error.message : String(error)}\n`,
|
|
302
|
+
);
|
|
303
|
+
process.exit(1);
|
|
304
|
+
});
|
|
266
305
|
}
|
|
@@ -39,6 +39,7 @@ const distRoot = resolve(projectRoot, "dist");
|
|
|
39
39
|
const publicRoot = resolve(projectRoot, "public");
|
|
40
40
|
const distChecksumsPath = resolve(distRoot, "checksums.txt");
|
|
41
41
|
const publicChecksumsPath = resolve(publicRoot, "checksums.txt");
|
|
42
|
+
const generatedManifestPath = resolve(distRoot, ".generated.json");
|
|
42
43
|
|
|
43
44
|
/**
|
|
44
45
|
* Sidecar written next to `checksums.txt` capturing any STABLE-invariant
|
|
@@ -191,9 +192,48 @@ async function readRemoteChecksums() {
|
|
|
191
192
|
}
|
|
192
193
|
}
|
|
193
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Read the generated-artifact manifest that routeSkeletonsPlugin emits
|
|
197
|
+
* (`dist/.generated.json`): every webapp-generated path it lists is
|
|
198
|
+
* auto-marked mutable, so new routes and project pages never need a
|
|
199
|
+
* hand-maintained mutable entry.
|
|
200
|
+
*
|
|
201
|
+
* Fail-loud policy: an existing but corrupt manifest throws (a build
|
|
202
|
+
* tooling regression must not silently downgrade the gate); a missing
|
|
203
|
+
* manifest only notes it — pages then fall back to the denylist in
|
|
204
|
+
* mutable-assets.mjs and the live-reconcile in the drift gate.
|
|
205
|
+
*/
|
|
206
|
+
async function readGeneratedPaths() {
|
|
207
|
+
let raw;
|
|
208
|
+
try {
|
|
209
|
+
raw = await readFile(generatedManifestPath, "utf8");
|
|
210
|
+
} catch {
|
|
211
|
+
process.stdout.write(
|
|
212
|
+
`Note: no generated-artifact manifest at dist/.generated.json — ` +
|
|
213
|
+
`generated pages are not auto-marked mutable (run vite build first)\n`,
|
|
214
|
+
);
|
|
215
|
+
return new Set();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const parsed = JSON.parse(raw);
|
|
219
|
+
const list =
|
|
220
|
+
parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
|
|
221
|
+
? parsed.generated
|
|
222
|
+
: null;
|
|
223
|
+
if (!Array.isArray(list) || list.some((p) => typeof p !== "string")) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
`corrupt generated-artifact manifest: ` +
|
|
226
|
+
`${relative(projectRoot, generatedManifestPath)} must be ` +
|
|
227
|
+
`{ "generated": string[] }`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return new Set(list);
|
|
231
|
+
}
|
|
232
|
+
|
|
194
233
|
async function run() {
|
|
195
234
|
const files = await walk(distRoot);
|
|
196
235
|
const existing = await readRemoteChecksums();
|
|
236
|
+
const generatedPaths = await readGeneratedPaths();
|
|
197
237
|
const rows = [];
|
|
198
238
|
let index = 0;
|
|
199
239
|
const driftViolations = [];
|
|
@@ -202,8 +242,9 @@ async function run() {
|
|
|
202
242
|
const liveCounterparts = new Map();
|
|
203
243
|
|
|
204
244
|
for (const filePath of files.sort()) {
|
|
205
|
-
// Skip the manifest file itself
|
|
206
|
-
|
|
245
|
+
// Skip the manifest file itself and the generated-artifact manifest so
|
|
246
|
+
// neither appears in its own diff.
|
|
247
|
+
if (filePath === distChecksumsPath || filePath === generatedManifestPath) {
|
|
207
248
|
continue;
|
|
208
249
|
}
|
|
209
250
|
|
|
@@ -216,11 +257,13 @@ async function run() {
|
|
|
216
257
|
: previous === digest
|
|
217
258
|
? "STABLE"
|
|
218
259
|
: "CHANGED";
|
|
219
|
-
const
|
|
260
|
+
const generated = generatedPaths.has(relPath);
|
|
261
|
+
const mutable = generated || isMutablePath(relPath);
|
|
262
|
+
const tag = generated ? " (generated)" : mutable ? " (mutable)" : "";
|
|
220
263
|
index += 1;
|
|
221
264
|
|
|
222
265
|
process.stdout.write(
|
|
223
|
-
`${String(index).padStart(3, "0")}. [${status}] ${relPath}${
|
|
266
|
+
`${String(index).padStart(3, "0")}. [${status}] ${relPath}${tag}\n` +
|
|
224
267
|
` prev: ${previous ?? "(none)"}\n` +
|
|
225
268
|
` next: ${digest}\n`,
|
|
226
269
|
);
|
|
@@ -266,6 +309,7 @@ async function run() {
|
|
|
266
309
|
baselineUrl: REMOTE_CHECKSUMS_URL,
|
|
267
310
|
generatedAt: new Date().toISOString(),
|
|
268
311
|
mutablePaths: [...MUTABLE_PATH_SET],
|
|
312
|
+
generatedPaths: [...generatedPaths].sort(),
|
|
269
313
|
violationCount: driftViolations.length,
|
|
270
314
|
violations: driftViolations,
|
|
271
315
|
rehashOnlyCount: rehashOnly.length,
|