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.
- package/.github/workflows/ci.yml +8 -0
- package/Makefile +5 -1
- package/README.md +9 -0
- package/build/routes.ts +27 -10
- package/package.json +2 -1
- package/public/Click.ogg +0 -0
- package/public/addons/rage-volley.txt +1 -1
- package/public/addons/rueg-wa-druid.txt +1 -1
- package/public/addons/rueg-wa-mage.txt +1 -1
- package/public/addons/rueg-wa-paladin.txt +1 -0
- package/public/addons/rueg-wa-priest.txt +1 -1
- package/public/addons/rueg-wa-rueg.txt +1 -1
- package/public/addons/rueg-wa-shaman.txt +1 -1
- package/public/blog/random/branch.md +29 -0
- package/public/blog/random/google.md +25 -0
- package/public/checksums.txt +70 -47
- package/public/documents/drift-report.json +46 -148
- package/public/loading.css +7 -0
- package/scripts/asset-reference.mjs +37 -0
- package/scripts/mutable-assets.mjs +46 -39
- package/scripts/refresh-soundcloud.mjs +152 -414
- package/scripts/verify-policy.mjs +206 -0
- package/scripts/write-dist-checksums.mjs +251 -135
- package/src/content/addons.json +13 -17
- package/src/content/blog.json +5 -26
- package/src/content/characters.json +6 -6
- package/src/content/configs.json +6 -2
- package/src/content/index.ts +105 -12
- package/src/content/pages.json +10 -1
- package/src/content/projects/development.json +9 -1
- package/src/content/projects/doftiles.json +9 -1
- package/src/content/projects/frontend.json +9 -1
- package/src/content/projects/graphbrowser.json +9 -1
- package/src/content/projects/herbstutil.json +93 -4
- package/src/content/projects/lightdni-jssas-toggle.json +9 -1
- package/src/content/projects/pagerts.json +13 -2
- package/src/content/projects/rarebert.json +12 -2
- package/src/content/projects/webuser.json +9 -1
- package/src/content/schema.ts +10 -1
- package/src/content/sections.json +2 -3
- package/src/content/soundcloud.json +204 -72
- package/src/lib/audio.ts +14 -0
- package/src/lib/clippy.ts +1 -7
- package/src/lib/embeds.ts +1 -1
- package/src/lib/musicSchema.ts +1 -0
- package/src/main.tsx +8 -0
- package/src/pages/MusicPage.tsx +0 -1
- package/src/pages/SitemapPage.tsx +1 -1
- package/src/pages/WowPage.tsx +1 -1
- package/src/shell/App.tsx +4 -1
- package/src/styles/extras.css +85 -30
- package/src/styles/layout.css +27 -18
- package/src/styles/window.css +53 -50
- package/src/themes/pageSlide/index.ts +115 -13
- package/src/themes/pageSlide/pageSlide.css +99 -6
- package/src/window/Embed.tsx +3 -1
- package/src/window/MaximizedWindow.tsx +250 -0
- package/src/window/OkButton.tsx +6 -1
- package/src/window/PageWindows.tsx +3 -9
- package/src/window/Window.tsx +129 -53
- package/src/window/model.ts +22 -0
- package/src/window/state.tsx +54 -30
- package/test/asset-reference.test.js +53 -0
- package/test/content.test.js +60 -6
- package/test/mutable-assets.test.js +27 -0
- package/test/verify-policy.test.js +92 -0
- package/tsconfig.node.tsbuildinfo +1 -1
- package/tsconfig.tsbuildinfo +1 -1
- package/xp.css/README.md +10 -0
package/src/window/Window.tsx
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
import { useEffect } from "react";
|
|
2
|
-
import {
|
|
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 +
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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.
|
|
181
|
-
//
|
|
182
|
-
// and
|
|
183
|
-
//
|
|
184
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
//
|
|
200
|
-
//
|
|
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
|
-
|
|
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={() =>
|
|
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
|
-
{
|
|
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
|
|
257
|
-
//
|
|
258
|
-
//
|
|
259
|
-
//
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
}
|
package/src/window/model.ts
CHANGED
|
@@ -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(".");
|
package/src/window/state.tsx
CHANGED
|
@@ -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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
/**
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
|
|
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
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
+
});
|
package/test/content.test.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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) =>
|
|
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(
|
|
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
|
-
|
|
45
|
-
|
|
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
|
+
});
|