website-xp-phone 2.2.1 → 2.2.11

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.
Files changed (69) hide show
  1. package/.github/workflows/ci.yml +8 -0
  2. package/Makefile +5 -1
  3. package/README.md +9 -0
  4. package/build/routes.ts +27 -10
  5. package/package.json +2 -1
  6. package/public/Click.ogg +0 -0
  7. package/public/addons/rage-volley.txt +1 -1
  8. package/public/addons/rueg-wa-druid.txt +1 -1
  9. package/public/addons/rueg-wa-mage.txt +1 -1
  10. package/public/addons/rueg-wa-paladin.txt +1 -0
  11. package/public/addons/rueg-wa-priest.txt +1 -1
  12. package/public/addons/rueg-wa-rueg.txt +1 -1
  13. package/public/addons/rueg-wa-shaman.txt +1 -1
  14. package/public/blog/random/branch.md +29 -0
  15. package/public/blog/random/google.md +25 -0
  16. package/public/checksums.txt +70 -47
  17. package/public/documents/drift-report.json +46 -148
  18. package/public/loading.css +7 -0
  19. package/scripts/asset-reference.mjs +37 -0
  20. package/scripts/mutable-assets.mjs +46 -39
  21. package/scripts/refresh-soundcloud.mjs +152 -414
  22. package/scripts/verify-policy.mjs +206 -0
  23. package/scripts/write-dist-checksums.mjs +251 -135
  24. package/src/content/addons.json +13 -17
  25. package/src/content/blog.json +5 -26
  26. package/src/content/characters.json +6 -6
  27. package/src/content/configs.json +6 -2
  28. package/src/content/index.ts +105 -12
  29. package/src/content/pages.json +10 -1
  30. package/src/content/projects/development.json +9 -1
  31. package/src/content/projects/doftiles.json +9 -1
  32. package/src/content/projects/frontend.json +9 -1
  33. package/src/content/projects/graphbrowser.json +9 -1
  34. package/src/content/projects/herbstutil.json +93 -4
  35. package/src/content/projects/lightdni-jssas-toggle.json +9 -1
  36. package/src/content/projects/pagerts.json +13 -2
  37. package/src/content/projects/rarebert.json +12 -2
  38. package/src/content/projects/webuser.json +9 -1
  39. package/src/content/schema.ts +10 -1
  40. package/src/content/sections.json +2 -3
  41. package/src/content/soundcloud.json +204 -72
  42. package/src/lib/audio.ts +14 -0
  43. package/src/lib/clippy.ts +1 -7
  44. package/src/lib/embeds.ts +1 -1
  45. package/src/lib/musicSchema.ts +1 -0
  46. package/src/main.tsx +8 -0
  47. package/src/pages/MusicPage.tsx +0 -1
  48. package/src/pages/SitemapPage.tsx +1 -1
  49. package/src/pages/WowPage.tsx +1 -1
  50. package/src/shell/App.tsx +4 -1
  51. package/src/styles/extras.css +85 -30
  52. package/src/styles/layout.css +27 -18
  53. package/src/styles/window.css +53 -50
  54. package/src/themes/pageSlide/index.ts +115 -13
  55. package/src/themes/pageSlide/pageSlide.css +99 -6
  56. package/src/window/Embed.tsx +3 -1
  57. package/src/window/MaximizedWindow.tsx +250 -0
  58. package/src/window/OkButton.tsx +6 -1
  59. package/src/window/PageWindows.tsx +3 -9
  60. package/src/window/Window.tsx +129 -53
  61. package/src/window/model.ts +22 -0
  62. package/src/window/state.tsx +54 -30
  63. package/test/asset-reference.test.js +53 -0
  64. package/test/content.test.js +60 -6
  65. package/test/mutable-assets.test.js +27 -0
  66. package/test/verify-policy.test.js +92 -0
  67. package/tsconfig.node.tsbuildinfo +1 -1
  68. package/tsconfig.tsbuildinfo +1 -1
  69. package/xp.css/README.md +10 -0
@@ -1,11 +1,12 @@
1
- import { useEffect } from "react";
2
- import { createPortal } from "react-dom";
3
- import { playLayeredAudio } from "@/lib/audio";
1
+ import { useEffect, useRef } from "react";
2
+ import { playLayeredAudio, playMinimizeClick } from "@/lib/audio";
4
3
  import { getClippyState } from "@/lib/clippy";
5
4
  import { Md } from "@/lib/markdown";
5
+ import { suppressNextSlideClick } from "@/themes/pageSlide";
6
6
  import { CopyAddon } from "./CopyAddon";
7
7
  import { BannerView, EmbedView } from "./Embed";
8
8
  import { OkButton } from "./OkButton";
9
+ import { MaximizedWindow, useMaximizedController } from "./MaximizedWindow";
9
10
  import { ResourceEmbed } from "./ResourceEmbed";
10
11
  import { TitleBar } from "./TitleBar";
11
12
  import type { WindowChild, WindowNode } from "./model";
@@ -14,7 +15,6 @@ import { dataTheme, forcesExpanded } from "./themes";
14
15
  import { hasTheme } from "./model";
15
16
 
16
17
  const CLOSE_SOUND = "/crunchy_kick.ogg";
17
- const MINIMIZE_SOUND = "/Click.ogg";
18
18
 
19
19
  function Children({ items }: { items: WindowChild[] }) {
20
20
  return (
@@ -63,6 +63,13 @@ function PermalinkButton({ node }: { node: WindowNode }) {
63
63
  return <button onClick={onClick}>Permalink</button>;
64
64
  }
65
65
 
66
+ /** The maximized rail's dismiss button for linked windows: Cancel closes
67
+ * the fullscreen view without touching OK's link (the dialog OK/Cancel
68
+ * pair — OK confirms through the link, Cancel backs out). */
69
+ function CancelButton({ onClose }: { onClose: () => void }) {
70
+ return <button onClick={onClose}>Cancel</button>;
71
+ }
72
+
66
73
  function Body({
67
74
  node,
68
75
  expanded,
@@ -97,16 +104,19 @@ function Body({
97
104
  );
98
105
 
99
106
  // Maximized: the rail sits BELOW the content in normal flow — content on
100
- // top, then OK + Permalink. margin-top: auto only pushes the rail to the
101
- // bottom edge when the window is taller than its content, so nothing ever
102
- // scrolls underneath it. OK keeps its regular function (navigate when
103
- // linked) and closes the fullscreen view otherwise (via onOk).
107
+ // top, then OK (+ Cancel for linked windows) + Permalink. Dialog rules:
108
+ // OK keeps its native meaning — follow the link on linked windows,
109
+ // dismiss on link-less ones — while Cancel is the explicit dismiss
110
+ // affordance that leaves the link untouched. margin-top: auto only
111
+ // pushes the rail to the bottom edge when the window is taller than
112
+ // its content, so nothing ever scrolls underneath it.
104
113
  if (maximized) {
105
114
  return (
106
115
  <div className="maximized-content-col">
107
116
  {content}
108
117
  <div className="ok-row ok-row-bottom">
109
118
  <OkButton node={node} onOk={node.link ? undefined : onOk} />
119
+ {node.link && onOk && <CancelButton onClose={onOk} />}
110
120
  <PermalinkButton node={node} />
111
121
  </div>
112
122
  </div>
@@ -139,21 +149,37 @@ export function Window({ node }: { node: WindowNode }) {
139
149
  const leaves = node.depth === 0 && onLeave;
140
150
  const flags = flagsOf(node.id);
141
151
  const maximized = Boolean(flags.maximized);
142
-
143
- /** Exiting the fullscreen view: click sound + restore into the page. */
144
- const exitFullscreen = () => {
145
- playLayeredAudio(MINIMIZE_SOUND);
146
- patch(node.id, { maximized: false });
147
- };
152
+ const windowRef = useRef<HTMLDivElement | null>(null);
153
+ const maximizedCtl = useMaximizedController(
154
+ node.id,
155
+ patch,
156
+ flagsOf,
157
+ windowRef,
158
+ );
148
159
 
149
160
  useEffect(() => {
150
161
  if (!maximized) return;
151
162
  const onKey = (event: KeyboardEvent) => {
152
- if (event.key === "Escape") exitFullscreen();
163
+ // Escape restores silently (no sound, no slide) like every other
164
+ // non-minimize exit — no other key does.
165
+ if (event.key === "Escape") maximizedCtl.dismiss();
153
166
  };
154
167
  document.addEventListener("keydown", onKey, true);
155
168
  return () => document.removeEventListener("keydown", onKey, true);
156
- }, [maximized, node.id, patch]);
169
+ }, [maximized, node.id, patch, maximizedCtl]);
170
+
171
+ // Mobile: the maximized window's hash entry is a history PUSH, so the
172
+ // OS back button removes it — the hash leaves this window first, which
173
+ // dismisses the overlay (instead of ever leaving the page).
174
+ useEffect(() => {
175
+ if (!maximized) return;
176
+ if (!window.matchMedia("(max-width: 768px)").matches) return;
177
+ const onHash = () => {
178
+ if (location.hash !== `#${node.id}`) maximizedCtl.dismiss();
179
+ };
180
+ window.addEventListener("hashchange", onHash);
181
+ return () => window.removeEventListener("hashchange", onHash);
182
+ }, [maximized, node.id, maximizedCtl]);
157
183
 
158
184
  if (flags.closed) return null;
159
185
 
@@ -177,40 +203,52 @@ export function Window({ node }: { node: WindowNode }) {
177
203
  const expanded =
178
204
  forcesExpanded(node) || Boolean(flags.expanded || node.embed) || maximized;
179
205
 
180
- // Minimize and Close share the same window behaviour. Close consults the
181
- // window-close-attempt event first: only refusals play the crunchy kick
182
- // and keep the window open. Leaving the fullscreen view is the minimize
183
- // sound + restore-into-the-page for every exit (title bar, Escape, or
184
- // OK); the ✕ close from fullscreen restores silently. Outside of
185
- // fullscreen, minimize keeps its page-exit/collapse roles.
206
+ // Minimize and Close share the same window behaviour. Sounds are pinned
207
+ // to their moments: the minimize click belongs to the title-bar
208
+ // minimize and the ✕ cross on a MAXIMIZED window, the crunchy kick to
209
+ // the ✕ close on an inline window, navigation gets its own click
210
+ // (pageSlide). Escape and OK stay silent.
186
211
  const minimize = () => {
187
212
  if (maximized) {
188
- exitFullscreen();
213
+ // The title-bar minimize is the ONLY dismissal that plays the
214
+ // click + slide-off (Escape, OK, ✕ and the backdrop restore
215
+ // silently).
216
+ playMinimizeClick();
217
+ maximizedCtl.dismissWithSlide();
218
+ return;
219
+ }
220
+ if (leaves) {
221
+ // Navigation is the fold here: pageSlide animates every departing
222
+ // window (and plays the minimize click). Don't fold twice.
223
+ leaves();
189
224
  return;
190
225
  }
191
- if (leaves) leaves();
192
- else patch(node.id, { expanded: false, maximized: false });
226
+ // Inline windows collapse to their title bar — silently, with no
227
+ // depart animation (the minimize click and the slide-off belong to
228
+ // the title-bar minimize on a maximized window, and to navigation).
229
+ patch(node.id, { expanded: false, maximized: false });
193
230
  };
194
231
 
195
232
  const close = () => {
196
- // Windows themed "funny" ARE the joke — pressing their ✕ always
197
- // crunches, even when the close goes through normally.
233
+ if (maximized) {
234
+ // ✕ on a maximized window = a minimize: restores into the page
235
+ // flow with the minimize click — no crunchy kick, no slide.
236
+ playMinimizeClick();
237
+ maximizedCtl.dismiss();
238
+ return;
239
+ }
240
+ // The crunchy kick backs every inline ✕ press: refused closes
241
+ // (Clippy veto, "funny" windows) and successful ones alike.
242
+ playLayeredAudio(CLOSE_SOUND);
198
243
  const funnyClose = hasTheme(node, "funny");
199
- // The crunchy kick plays only when a window refuses to close (Clippy
200
- // saying no); successful closes pass silently and then
201
- // park/restore/leave as before.
244
+ // Refusal keeps the window open (see window-close-attempt below);
245
+ // successful closings park/restore/leave after the crunch.
202
246
  const refused = !document.dispatchEvent(
203
247
  new CustomEvent("window-close-attempt", { cancelable: true }),
204
248
  );
205
- if (funnyClose || refused) playLayeredAudio(CLOSE_SOUND);
206
- // "funny" windows refuse outright: the kick plays, the window stays.
249
+ // "funny" windows refuse outright: the kick played, the window stays.
207
250
  if (funnyClose) return;
208
251
  if (!refused) {
209
- if (maximized) {
210
- // ✕ from fullscreen: restore into the page, no sound.
211
- patch(node.id, { maximized: false });
212
- return;
213
- }
214
252
  // Easter egg: while Clippy is on screen, ✕ parks the window instead
215
253
  // of leaving the page — the mascot's unhide list offers it back
216
254
  // (restore). Never on leave-on-close pages (Projects), where ✕ means
@@ -219,14 +257,19 @@ export function Window({ node }: { node: WindowNode }) {
219
257
  patch(node.id, { closed: true });
220
258
  return;
221
259
  }
260
+ if (leaves) {
261
+ // ✕ on a page-exit window navigates with the kick as its sound:
262
+ // the navigation fold the close triggers stays silent.
263
+ suppressNextSlideClick();
264
+ }
222
265
  minimize();
223
266
  return;
224
267
  }
225
- // (Refusals and funny windows played the crunchy kick above.)
226
268
  };
227
269
 
228
270
  const window_ = (
229
271
  <div
272
+ ref={windowRef}
230
273
  id={maximized ? undefined : node.id}
231
274
  className={[
232
275
  "window",
@@ -242,10 +285,38 @@ export function Window({ node }: { node: WindowNode }) {
242
285
  node={node}
243
286
  maximized={maximized}
244
287
  onMinimize={minimize}
245
- onMaximize={() => patch(node.id, { maximized: !maximized })}
288
+ onMaximize={() => {
289
+ if (!maximized) {
290
+ maximizedCtl.maximize();
291
+ return;
292
+ }
293
+ // Restore button: instant dismiss (silent, no slide) + hash
294
+ // hand-off.
295
+ maximizedCtl.dismiss();
296
+ }}
246
297
  onClose={close}
247
298
  />
248
- {!flags.minimized && (
299
+ {flags.minimized ? (
300
+ // Minimized windows keep an OK row as their only interior
301
+ // affordance: pressing it restores and reveals the window.
302
+ <div className="window-body window-minimized-body">
303
+ <div className="ok-row ok-row-bottom">
304
+ <button
305
+ onClick={() => {
306
+ patch(node.id, { minimized: false, expanded: true });
307
+ requestAnimationFrame(() =>
308
+ windowRef.current?.scrollIntoView({
309
+ behavior: "smooth",
310
+ block: "start",
311
+ }),
312
+ );
313
+ }}
314
+ >
315
+ OK
316
+ </button>
317
+ </div>
318
+ </div>
319
+ ) : (
249
320
  <div
250
321
  className={`window-body${maximized ? " maximized-window-body" : ""}`}
251
322
  >
@@ -253,23 +324,28 @@ export function Window({ node }: { node: WindowNode }) {
253
324
  node={node}
254
325
  expanded={expanded}
255
326
  maximized={maximized}
256
- // OK restores the window into the page when it has no link to
257
- // follow; linked windows keep their navigate-on-OK behaviour.
258
- // Deliberately silent: the minimize click (Click.ogg) is for the
259
- // title bar / Escape paths only.
260
- onOk={
261
- maximized ? () => patch(node.id, { maximized: false }) : undefined
262
- }
327
+ // OK is the dismiss affordance in fullscreen: silent close,
328
+ // no slide — the departing animation is the minimize's alone.
329
+ // OK on a maximized window: dismiss AND reveal its content
330
+ // (the returned window opens along with its direct children).
331
+ onOk={maximized ? () => maximizedCtl.dismissRevealed() : undefined}
263
332
  />
264
333
  </div>
265
334
  )}
266
335
  </div>
267
336
  );
268
337
 
269
- return maximized
270
- ? createPortal(
271
- <div className="maximized-backdrop">{window_}</div>,
272
- document.body,
273
- )
274
- : window_;
338
+ // The maximized chrome wraps the window in EVERY state: the window
339
+ // element stays in the last, stable child slot so its DOM identity
340
+ // survives maximize/dismiss round-trips, while the dimmed layer and
341
+ // the reserved flow slot appear only while lifted.
342
+ return (
343
+ <MaximizedWindow
344
+ maximized={maximized}
345
+ controller={maximizedCtl}
346
+ nodeId={node.id}
347
+ onOutsideClick={maximizedCtl.dismiss}
348
+ windowEl={window_}
349
+ />
350
+ );
275
351
  }
@@ -50,6 +50,11 @@ function buildNode(section: Section, id: string, depth: number): WindowNode {
50
50
  children.push({ kind: "element", element: item });
51
51
  } else if ("banner" in item) {
52
52
  children.push({ kind: "banner", banner: item.banner });
53
+ } else if ("include" in item) {
54
+ // Directives are resolved at content load (src/content/index.ts);
55
+ // reaching the model means an unknown/broken include slipped
56
+ // through — fail loudly instead of rendering an opaque object.
57
+ throw new Error(`Unresolved include directive: ${String(item.include)}`);
53
58
  } else if (isEmbed(item)) {
54
59
  children.push({ kind: "embed", embed: item });
55
60
  } else {
@@ -92,6 +97,23 @@ export function flatten(nodes: WindowNode[]): WindowNode[] {
92
97
  ]);
93
98
  }
94
99
 
100
+ /** Every descendant window id beneath a node (the node itself excluded) —
101
+ * used to reveal a whole subtree when arriving via a hash href. */
102
+ export function descendantIds(windows: WindowNode[], rootId: string): string[] {
103
+ const root = windows.find((w) => w.id === rootId);
104
+ const acc: string[] = [];
105
+ const walk = (node: WindowNode) => {
106
+ for (const child of node.children) {
107
+ if (child.kind === "window") {
108
+ acc.push(child.node.id);
109
+ walk(child.node);
110
+ }
111
+ }
112
+ };
113
+ if (root) walk(root);
114
+ return acc;
115
+ }
116
+
95
117
  /** "0.1.2" -> ["0", "0.1", "0.1.2"] */
96
118
  export function lineage(id: string): string[] {
97
119
  const parts = id.split(".");
@@ -7,7 +7,7 @@ import {
7
7
  type ReactNode,
8
8
  } from "react";
9
9
  import { clearHidden, publishHidden } from "./hidden";
10
- import { flatten, lineage, type WindowNode } from "./model";
10
+ import { descendantIds, flatten, lineage, type WindowNode } from "./model";
11
11
 
12
12
  export type PermalinkMode = "hash" | "post";
13
13
 
@@ -51,6 +51,27 @@ type WindowStore = {
51
51
 
52
52
  const Ctx = createContext<WindowStore | null>(null);
53
53
 
54
+ /** Seeds the store synchronously from the URL: if the location names a
55
+ * target window (?post=slug / #tree-index), it starts maximized with the
56
+ * whole subtree expanded on the FIRST render. usePermalinkTarget's
57
+ * post-paint effect normally applies these flags a frame later, but then
58
+ * the page first paints the un-maximized main DOM and scrolls — the
59
+ * maximized element pops into view over a polluted first frame. Rendering
60
+ * the flags from the start shows the maximized overlay immediately, with
61
+ * nothing below it ever committed. */
62
+ function initialFlags(roots: WindowNode[]): State {
63
+ const windows = flatten(roots);
64
+ const target = findTarget(windows);
65
+ if (!target) return {};
66
+ const state: State = {
67
+ [target.id]: { maximized: true, minimized: false, expanded: true },
68
+ };
69
+ for (const id of descendantIds(windows, target.id)) {
70
+ state[id] = { expanded: true, minimized: false };
71
+ }
72
+ return state;
73
+ }
74
+
54
75
  export function WindowProvider({
55
76
  roots,
56
77
  permalinkMode = "hash",
@@ -62,7 +83,7 @@ export function WindowProvider({
62
83
  onLeave?: (() => void) | undefined;
63
84
  children: ReactNode;
64
85
  }) {
65
- const [state, dispatch] = useReducer(reducer, {});
86
+ const [state, dispatch] = useReducer(reducer, [], () => initialFlags(roots));
66
87
  const windows = useMemo(() => flatten(roots), [roots]);
67
88
 
68
89
  const store = useMemo<WindowStore>(() => {
@@ -119,50 +140,53 @@ export const toSlug = (heading: string): string =>
119
140
  .replace(/[^\p{L}\p{N}]+/gu, "-")
120
141
  .replace(/^-+|-+$/g, "");
121
142
 
122
- /** Blog posts are addressed as ?post=slug, everything else by #tree-index. */
143
+ /** Blog posts are addressed as ?post=slug, everything else by #tree-index.
144
+ * The depth-0 root is excluded from ?post= addressing: findTarget's
145
+ * slug lookup only dives under the root, so emitting the slug for it
146
+ * generates a dead permalink from the very rail that shows the button. */
123
147
  export function permalinkFor(node: WindowNode, mode: PermalinkMode): string {
124
148
  const { origin, pathname } = window.location;
125
- if (mode === "post" && node.heading) {
149
+ if (mode === "post" && node.depth > 0 && node.heading) {
126
150
  return `${origin}${pathname}?post=${encodeURIComponent(toSlug(node.heading))}`;
127
151
  }
128
152
  return `${origin}${pathname}#${node.id}`;
129
153
  }
130
154
 
131
- function findTarget(windows: WindowNode[], mode: PermalinkMode) {
132
- if (mode === "post") {
133
- const slug = new URLSearchParams(location.search).get("post");
134
- return slug
135
- ? windows.find(
136
- (w) => w.depth > 0 && w.heading && toSlug(w.heading) === slug,
137
- )
138
- : undefined;
155
+ /** The window named by the URL. BOTH link shapes land on any page:
156
+ * ?post=slug (the blog's permalink mode) addresses a post by heading, and
157
+ * #tree-index addresses any window by id. ?post wins when both are set
158
+ * (they could name different windows); the hash is the fallback. */
159
+ function findTarget(windows: WindowNode[]) {
160
+ const slug = new URLSearchParams(location.search).get("post");
161
+ if (slug) {
162
+ const post = windows.find(
163
+ (w) => w.depth > 0 && w.heading && toSlug(w.heading) === slug,
164
+ );
165
+ if (post) return post;
139
166
  }
140
167
  const hash = location.hash.slice(1);
141
168
  return windows.find((w) => w.id === hash);
142
169
  }
143
170
 
144
- /** Opens the window named by the URL, once per page load. Hash targets
145
- * (cross-page links and permalinks) open their section maximized in the
146
- * foreground; blog ?post= targets keep the expand-and-scroll behaviour. */
147
- export function usePermalinkTarget(mode: PermalinkMode) {
171
+ /** Confirms the window named by the URL, once per page load. The store is
172
+ * already seeded synchronously with the target maximized (initialFlags)
173
+ * so the FIRST paint shows the fullscreen view with no inline flash;
174
+ * this effect only re-applies the identical, idempotent patches. Both
175
+ * link shapes arrive the same way: the target opens maximized in the
176
+ * foreground with its whole subtree expanded. */
177
+ export function usePermalinkTarget() {
148
178
  const { windows, expand, patch } = useWindowStore();
149
179
  useEffect(() => {
150
- const target = findTarget(windows, mode);
180
+ const target = findTarget(windows);
151
181
  if (!target) return;
152
- if (mode === "hash") {
153
- expand(target.id);
154
- patch(target.id, { maximized: true });
155
- return;
156
- }
157
182
  expand(target.id);
158
- const timer = setTimeout(
159
- () =>
160
- document
161
- .getElementById(target.id)
162
- ?.scrollIntoView({ behavior: "smooth" }),
163
- 100,
164
- );
165
- return () => clearTimeout(timer);
183
+ // Arriving at a permalink reveals the target's whole subtree, not
184
+ // just its own section (descendants expand, stale minimized flags
185
+ // clear; closed/parked windows stay parked), then lifts it.
186
+ for (const id of descendantIds(windows, target.id)) {
187
+ patch(id, { expanded: true, minimized: false });
188
+ }
189
+ patch(target.id, { maximized: true, minimized: false });
166
190
  }, []);
167
191
  }
168
192
 
@@ -0,0 +1,53 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ canonicalizeAssetRefs,
5
+ isCanonicalizablePath,
6
+ } from "../scripts/asset-reference.mjs";
7
+
8
+ test("strips Vite content hashes from hashed asset references", () => {
9
+ const build = (hashes) => `<html><head>
10
+ <script type="module" crossorigin src="/assets/index-${hashes.index}.js"></script>
11
+ <link rel="modulepreload" crossorigin href="/assets/markdown-${hashes.markdown}.js">
12
+ <link rel="stylesheet" crossorigin href="/assets/index-${hashes.index}.css">
13
+ </head></html>`;
14
+
15
+ // Two builds whose bundles rehashed but whose authored content is identical
16
+ // must canonicalize to the same text.
17
+ const first = build({ index: "C-jayB4T", markdown: "4zwTcHc2" });
18
+ const second = build({ index: "z86-hiKg", markdown: "DKJpDlSZ" });
19
+ assert.equal(canonicalizeAssetRefs(first), canonicalizeAssetRefs(second));
20
+ assert.notEqual(first, second);
21
+ });
22
+
23
+ test("hashes in asset names are stripped, the extension is kept", () => {
24
+ assert.equal(
25
+ canonicalizeAssetRefs('href="/assets/chunk-Ab12Cd34.js"'),
26
+ 'href="/assets/chunk.js"',
27
+ );
28
+ assert.equal(
29
+ canonicalizeAssetRefs('href="/assets/index-Ab12Cd34.css"'),
30
+ 'href="/assets/index.css"',
31
+ );
32
+ });
33
+
34
+ test("non-hashed and non-asset references are left untouched", () => {
35
+ const page = `<a href="/blog/random/google.md">doc</a>
36
+ <link rel="canonical" href="https://akinevz.com/configs.html">
37
+ <img src="/blog/P5kKjYA.png">
38
+ <script src="https://example.com/lib.js"></script>`;
39
+ assert.equal(canonicalizeAssetRefs(page), page);
40
+ });
41
+
42
+ test("hyphenated asset names canonicalize correctly", () => {
43
+ assert.equal(
44
+ canonicalizeAssetRefs('src="/assets/react-core-9sdj916r.js"'),
45
+ 'src="/assets/react-core.js"',
46
+ );
47
+ });
48
+
49
+ test("only HTML paths are canonicalized for drift comparison", () => {
50
+ assert.equal(isCanonicalizablePath("configs/index.html"), true);
51
+ assert.equal(isCanonicalizablePath("blog/random/google.md"), false);
52
+ assert.equal(isCanonicalizablePath("assets/index-Ab12Cd34.js"), false);
53
+ });
@@ -20,28 +20,82 @@ test("route paths are unique and a /404 route exists", () => {
20
20
  test("page aliases target an existing route (+optional section anchor)", () => {
21
21
  const paths = new Set(pages.map((r) => r.path));
22
22
  const anchors = new Set(
23
- readFileSync("src/content/addons.json", "utf8").match(/"anchor"\s*:\s*"([^"]+)"/g) ?? [],
23
+ readFileSync("src/content/addons.json", "utf8").match(
24
+ /"anchor"\s*:\s*"([^"]+)"/g,
25
+ ) ?? [],
24
26
  );
25
27
  for (const route of pages) {
26
28
  if (!route.alias) continue;
27
29
  const [target, hash] = route.alias.split("#");
28
- assert.ok(paths.has(target), `${route.path}: alias target ${target} exists`);
30
+ assert.ok(
31
+ paths.has(target),
32
+ `${route.path}: alias target ${target} exists`,
33
+ );
29
34
  if (hash && target === "/wow" && hash === "addons")
30
35
  assert.ok(anchors.size > 0, "wow#addons anchor exists in addons.json");
31
36
  }
32
37
  });
33
38
 
39
+ test("include directives target a registered content file", async () => {
40
+ const { readdirSync } = await import("node:fs");
41
+ const indexTs = readFileSync("src/content/index.ts", "utf8");
42
+ const jsonFiles = readdirSync("src/content").filter((f) =>
43
+ f.endsWith(".json"),
44
+ );
45
+ const directives = jsonFiles.flatMap(
46
+ (file) =>
47
+ readFileSync(`src/content/${file}`, "utf8").match(
48
+ /"include"\s*:\s*"([^"]+)"/g,
49
+ ) ?? [],
50
+ );
51
+ for (const directive of directives) {
52
+ const name = directive.match(/"include"\s*:\s*"([^"]+)"/)[1];
53
+ const target = `src/content/${name}.json`;
54
+ assert.ok(
55
+ readFileSync(target, "utf8").length > 0,
56
+ `include target ${target} exists`,
57
+ );
58
+ assert.match(
59
+ indexTs,
60
+ new RegExp(`\\b${name}\\b`),
61
+ `${name} registered in includeRegistry`,
62
+ );
63
+ }
64
+ });
65
+
34
66
  test("every project document is complete and routable", async () => {
35
67
  const { readdirSync } = await import("node:fs");
36
- const files = readdirSync("src/content/projects").filter((f) => f.endsWith(".json"));
68
+ const files = readdirSync("src/content/projects").filter((f) =>
69
+ f.endsWith(".json"),
70
+ );
37
71
  assert.ok(files.length > 0);
38
72
  for (const file of files) {
39
- const project = JSON.parse(readFileSync(`src/content/projects/${file}`, "utf8"));
73
+ const project = JSON.parse(
74
+ readFileSync(`src/content/projects/${file}`, "utf8"),
75
+ );
40
76
  for (const key of ["slug", "title", "description", "github"]) {
41
77
  assert.equal(typeof project[key], "string", `${file}: ${key}`);
42
78
  }
43
79
  assert.ok(project.content, `${file}: content`);
44
- const path = project.path ?? `/projects/${project.slug}`;
45
- assert.ok(!pages.some((r) => r.path === path), `${file}: path clashes with pages.json`);
80
+ assert.equal(
81
+ project.path,
82
+ undefined,
83
+ `${file}: standardised under /projects/${project.slug}`,
84
+ );
85
+ const path = `/projects/${project.slug}`;
86
+ assert.ok(
87
+ !pages.some((r) => r.path === path),
88
+ `${file}: path clashes with pages.json`,
89
+ );
90
+ }
91
+ // every project is also reachable as /<slug> via a generated alias route
92
+ const slugs = files.map(
93
+ (f) => JSON.parse(readFileSync(`src/content/projects/${f}`, "utf8")).slug,
94
+ );
95
+ for (const slug of slugs) {
96
+ assert.ok(
97
+ !pages.some((r) => r.path === `/${slug}`),
98
+ `${slug}: short-hand must not clash with pages.json`,
99
+ );
46
100
  }
47
101
  });
@@ -0,0 +1,27 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { isMutablePath } from "../scripts/mutable-assets.mjs";
4
+
5
+ test("blog post artifacts are mutable, so new posts are never drift", () => {
6
+ // posts at the blog root and in subdirectories
7
+ assert.equal(isMutablePath("blog/new-post.md"), true);
8
+ assert.equal(isMutablePath("blog/random/google.md"), true);
9
+ // post attachments
10
+ assert.equal(isMutablePath("blog/P5kKjYA.png"), true);
11
+ assert.equal(isMutablePath("blog/exhausted.txt"), true);
12
+ // non-ASCII post filenames survive path checks
13
+ assert.equal(isMutablePath("blog/рунисч-глоссарий.md"), true);
14
+ // embedded post documents
15
+ assert.equal(isMutablePath("stories/1.md"), true);
16
+ });
17
+
18
+ test("blog pages remain mutable", () => {
19
+ assert.equal(isMutablePath("blog.html"), true);
20
+ assert.equal(isMutablePath("blog/index.html"), true);
21
+ });
22
+
23
+ test("stable non-blog artifacts are not mutable", () => {
24
+ assert.equal(isMutablePath("configs.html"), false);
25
+ assert.equal(isMutablePath("spinner.svg"), false);
26
+ assert.equal(isMutablePath("assets/index-Ab12Cd34.css"), false);
27
+ });