@half-built/astro 0.9.0 → 0.11.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.
@@ -0,0 +1,462 @@
1
+ /* Popout (spec docs/superpowers/specs/2026-09-26-popout-design.md):
2
+ secondary context for tables and data viz. One island, one real
3
+ <dialog> per page. A click on a trigger fills it from the template
4
+ right after that trigger and opens it anchored below the trigger
5
+ (desktop) or as a modal bottom sheet (phone width, decided at each
6
+ open). Chrome lives in @half-built/css popout.css.
7
+
8
+ A document-wide singleton, like link-tip: root only names the
9
+ document; triggers anywhere in it open the one surface. */
10
+
11
+ import { claim, release, type Island, type IslandHandle } from "./core/island";
12
+ import { docOf, el, iconButton } from "./core/dom";
13
+ import { ICON_X } from "./core/icons";
14
+ import { BP_PHONE_MAX } from "./core/breakpoints";
15
+ import {
16
+ placeTip,
17
+ EDGE,
18
+ type AnchorRect,
19
+ type ViewportSize,
20
+ } from "./core/placement";
21
+
22
+ export type PopoutMode = "anchored" | "sheet";
23
+
24
+ /* The tip edge plus the close box's 17px straddle, so the box never
25
+ lands with its close control off screen. */
26
+ const POPOUT_EDGE = EDGE + 18;
27
+
28
+ /* The label straddles the box's top rule by about 12px; this keeps it
29
+ off the trigger it belongs to. */
30
+ const LABEL_CLEAR = 16;
31
+
32
+ export const SWIPE_CLOSE = 60;
33
+
34
+ export interface PopoutOptions {
35
+ selector?: string;
36
+ edge?: number;
37
+ closeLabel?: string;
38
+ }
39
+
40
+ interface MediaHost {
41
+ matchMedia?: (q: string) => { matches: boolean };
42
+ }
43
+
44
+ /* Widened like link-tip's guard: jsdom has no matchMedia, and the plain
45
+ Window type always declares it. */
46
+ export function popoutMode(win: MediaHost | null): PopoutMode {
47
+ return win?.matchMedia?.(`(max-width: ${BP_PHONE_MAX}px)`).matches
48
+ ? "sheet"
49
+ : "anchored";
50
+ }
51
+
52
+ export function offscreen(r: AnchorRect, vp: ViewportSize): boolean {
53
+ return (
54
+ r.bottom < 0 || r.top >= vp.height || r.right < 0 || r.left >= vp.width
55
+ );
56
+ }
57
+
58
+ export function isSwipeClose(
59
+ startY: number | null,
60
+ endY: number,
61
+ threshold: number = SWIPE_CLOSE,
62
+ ): boolean {
63
+ return startY !== null && endY - startY >= threshold;
64
+ }
65
+
66
+ const FOCUSABLE =
67
+ 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
68
+
69
+ /** The first element after anchor, in document order, that sequential
70
+ focus navigation would reach, ignoring anything inside skip. */
71
+ export function nextFocusableAfter(
72
+ doc: Document,
73
+ anchor: Element,
74
+ skip: Element,
75
+ ): HTMLElement | null {
76
+ for (const node of doc.querySelectorAll<HTMLElement>(FOCUSABLE)) {
77
+ if (skip.contains(node) || anchor.contains(node)) continue;
78
+ if (node.getAttribute("tabindex") === "-1") continue;
79
+ if (node.closest("[hidden], [inert]")) continue;
80
+
81
+ const after =
82
+ anchor.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING;
83
+
84
+ if (after) return node;
85
+ }
86
+
87
+ return null;
88
+ }
89
+
90
+ export const mountPopouts: Island<PopoutOptions> = (
91
+ root,
92
+ options = {},
93
+ ): IslandHandle => {
94
+ const {
95
+ selector = ".popout-trigger",
96
+ edge = POPOUT_EDGE,
97
+ closeLabel = "Close",
98
+ } = options;
99
+
100
+ const doc = docOf(root);
101
+ const html = doc.documentElement;
102
+
103
+ if (!claim(html, "popout")) {
104
+ return {
105
+ destroy(): void {
106
+ /* already claimed elsewhere: nothing here to tear down */
107
+ return;
108
+ },
109
+ };
110
+ }
111
+
112
+ const win = doc.defaultView;
113
+ const media = win as MediaHost | null;
114
+
115
+ const surface = el(doc, "dialog", "popout");
116
+ surface.setAttribute("aria-labelledby", "popout-label");
117
+ surface.setAttribute("aria-describedby", "popout-body");
118
+ surface.tabIndex = -1;
119
+ const label = el(doc, "span", "popout-label boxed-label micro-label");
120
+ label.id = "popout-label";
121
+ const closeBtn = iconButton(doc, "popout-close icon-box", closeLabel, ICON_X);
122
+ const body = el(doc, "div", "popout-body");
123
+ body.id = "popout-body";
124
+ surface.append(label, closeBtn, body);
125
+ doc.body.append(surface);
126
+
127
+ let current: HTMLElement | null = null;
128
+ let mode: PopoutMode = "anchored";
129
+ let frame = 0;
130
+ let swipeStart: number | null = null;
131
+ /* A press is under way between pointerdown and its click (or the
132
+ next key). Focus moves during it, sometimes to nothing (Safari does
133
+ not focus a pressed button); the pointer handlers own that close. */
134
+ let pressing = false;
135
+ /* Sheet only: whether the pointerdown that started the current press
136
+ landed on the dialog element itself (the backdrop), so a press that
137
+ starts inside and is dragged onto the backdrop before release does
138
+ not read as a backdrop click. */
139
+ let pressOnSurface = false;
140
+
141
+ const viewport = (): ViewportSize => ({
142
+ width: html.clientWidth,
143
+ height: html.clientHeight,
144
+ });
145
+
146
+ const triggerFor = (t: EventTarget | null): HTMLElement | null => {
147
+ if (!(t instanceof Element)) return null;
148
+ const hit = t.closest(selector);
149
+ return hit instanceof HTMLElement ? hit : null;
150
+ };
151
+
152
+ /* Every way out lands here, synchronously. dialog.close() queues its
153
+ close event in real browsers, so the listener below only acts when
154
+ the dialog is actually shut: a late event after a reopen is
155
+ ignored.
156
+
157
+ A close the reader asked for (the close box, Escape, the open
158
+ trigger, the backdrop, a swipe) hands focus back to the trigger.
159
+ An automatic one (scroll-away, a breakpoint crossing, focus
160
+ leaving, a press outside) does not: the trigger may be off screen,
161
+ and a focused one reopens on Space against an anchor nobody can
162
+ see. Focus inside the box is dropped before close(), which would
163
+ otherwise restore it to the trigger itself (the dialog's own
164
+ previously-focused-element step; always so for the modal sheet,
165
+ hence the second check after). */
166
+ const finish = (restoreFocus = true): void => {
167
+ const opener = current;
168
+ if (!opener) return;
169
+ current = null;
170
+
171
+ if (frame) {
172
+ win?.cancelAnimationFrame(frame);
173
+ frame = 0;
174
+ }
175
+
176
+ const active = doc.activeElement;
177
+
178
+ if (
179
+ !restoreFocus &&
180
+ active instanceof HTMLElement &&
181
+ surface.contains(active)
182
+ ) {
183
+ active.blur();
184
+ }
185
+
186
+ if (surface.open) surface.close();
187
+ surface.classList.remove("is-anchored", "is-sheet");
188
+ surface.style.left = "";
189
+ surface.style.top = "";
190
+ body.replaceChildren();
191
+ html.classList.remove("popout-open");
192
+ opener.setAttribute("aria-expanded", "false");
193
+ opener.classList.remove("is-open");
194
+
195
+ if (restoreFocus) opener.focus({ preventScroll: true });
196
+ else if (doc.activeElement === opener && active !== opener) opener.blur();
197
+ };
198
+
199
+ const place = (): void => {
200
+ if (!current) return;
201
+ const r = current.getBoundingClientRect();
202
+
203
+ const anchor: AnchorRect = {
204
+ left: r.left,
205
+ right: r.right,
206
+ top: r.top - LABEL_CLEAR,
207
+ bottom: r.bottom + LABEL_CLEAR,
208
+ };
209
+
210
+ const pos = placeTip(
211
+ anchor,
212
+ surface.getBoundingClientRect(),
213
+ viewport(),
214
+ "below",
215
+ "start",
216
+ edge,
217
+ );
218
+
219
+ surface.style.left = `${pos.x}px`;
220
+ surface.style.top = `${pos.y}px`;
221
+ };
222
+
223
+ const open = (trigger: HTMLElement): void => {
224
+ const tpl = trigger.nextElementSibling;
225
+ if (!(tpl instanceof HTMLTemplateElement)) return;
226
+ finish(false);
227
+ current = trigger;
228
+ mode = popoutMode(media);
229
+ label.textContent = trigger.dataset.popoutLabel ?? "";
230
+ body.replaceChildren(tpl.content.cloneNode(true));
231
+ trigger.setAttribute("aria-expanded", "true");
232
+ trigger.classList.add("is-open");
233
+
234
+ if (mode === "sheet") {
235
+ surface.classList.add("is-sheet");
236
+ surface.showModal();
237
+ html.classList.add("popout-open");
238
+ } else {
239
+ surface.classList.add("is-anchored");
240
+ surface.show();
241
+ place();
242
+ }
243
+
244
+ surface.focus({ preventScroll: true });
245
+ };
246
+
247
+ const onClick = (ev: MouseEvent): void => {
248
+ pressing = false;
249
+ const trigger = triggerFor(ev.target);
250
+
251
+ if (trigger) {
252
+ if (trigger === current) finish();
253
+ else open(trigger);
254
+ return;
255
+ }
256
+
257
+ /* The dialog has no padding of its own (popout.css), so a click on
258
+ the element itself is a click on the sheet's backdrop, but only
259
+ when the press that produced this click also started there: a
260
+ press dragged from inside the sheet onto the backdrop must not
261
+ close it. */
262
+ if (ev.target === surface && mode === "sheet" && pressOnSurface) finish();
263
+ pressOnSurface = false;
264
+ };
265
+
266
+ /* Anchored only; the sheet's outside is its backdrop. A press on any
267
+ trigger is left to the click handler, or pressing the open trigger
268
+ would close here and reopen on click. */
269
+ const onPointerDown = (ev: Event): void => {
270
+ pressOnSurface = ev.target === surface;
271
+ pressing = true;
272
+ if (!current || mode !== "anchored") return;
273
+ const t = ev.target;
274
+ if (t instanceof Node && surface.contains(t)) return;
275
+ if (triggerFor(t)) return;
276
+ finish(false);
277
+ };
278
+
279
+ /* A touch pan ends in pointercancel, never a click; the press is over,
280
+ so the flag must not outlive it, or a later null-relatedTarget
281
+ focusout is ignored forever and the box sticks open. pointerup is
282
+ deliberately not one of these: a real tap can put pointerup, a focus
283
+ change, and the click in separate tasks, and clearing the flag at
284
+ pointerup lets the focus change's queued close beat the click, which
285
+ then reopens what the tap meant to close. A press dragged off its
286
+ target and released elsewhere is left holding the flag until the
287
+ next click, keydown, or pointerdown; that fails safe, leaving the
288
+ box open rather than closing and reopening it. */
289
+ const onPointerCancel = (): void => {
290
+ pressing = false;
291
+ };
292
+
293
+ const inside = (t: EventTarget | null): boolean =>
294
+ t instanceof Node &&
295
+ (surface.contains(t) || (current?.contains(t) ?? false));
296
+
297
+ /* Anchored only: the sheet is modal and keeps focus itself. Keyboard
298
+ focus leaving the box and its trigger closes it (the site-header
299
+ flyout's manner). With no destination, focus went to browser chrome
300
+ or another window, or a press is moving it; the check waits for the
301
+ move to settle, leaves a press to the pointer handlers, and keeps
302
+ the box open when focus is still inside it (a window switch leaves
303
+ the document's focus where it was). */
304
+ const onFocusOut = (ev: FocusEvent): void => {
305
+ if (!current || mode !== "anchored") return;
306
+
307
+ if (ev.relatedTarget !== null) {
308
+ if (!inside(ev.relatedTarget)) finish(false);
309
+ return;
310
+ }
311
+
312
+ if (pressing) return;
313
+
314
+ queueMicrotask(() => {
315
+ if (current && mode === "anchored" && !inside(doc.activeElement)) {
316
+ finish(false);
317
+ }
318
+ });
319
+ };
320
+
321
+ const onKey = (ev: KeyboardEvent): void => {
322
+ pressing = false;
323
+ if (ev.key !== "Escape" || !current) return;
324
+ ev.preventDefault();
325
+ finish();
326
+ };
327
+
328
+ /* Anchored only: the box sits last in <body>, so native Tab order
329
+ would carry focus to the footer or out of the page. Shift+Tab off
330
+ the first stop goes back to the trigger; Tab off the last stop
331
+ goes to whatever follows the trigger. */
332
+ const onSurfaceKey = (ev: KeyboardEvent): void => {
333
+ if (ev.key !== "Tab" || !current || mode !== "anchored") return;
334
+ const stops = [...surface.querySelectorAll<HTMLElement>(FOCUSABLE)];
335
+ const first = stops.at(0);
336
+ const last = stops.at(-1);
337
+ const active = doc.activeElement;
338
+
339
+ if (ev.shiftKey && (active === surface || active === first)) {
340
+ ev.preventDefault();
341
+ finish();
342
+ return;
343
+ }
344
+
345
+ if (!ev.shiftKey && (active === last || stops.length === 0)) {
346
+ ev.preventDefault();
347
+ const trigger = current;
348
+ finish(false);
349
+
350
+ /* A candidate that matches FOCUSABLE can still silently refuse
351
+ focus: display:none or visibility:hidden by CSS, inside a
352
+ closed <details>, or inside a disabled <fieldset> all pass the
353
+ selector but leave .focus() a no-op. Walk forward from each
354
+ failed candidate (nextFocusableAfter takes it as the new
355
+ anchor) until one actually takes focus, and fall back to the
356
+ trigger when none does. */
357
+ let anchor: HTMLElement = trigger;
358
+ let candidate = nextFocusableAfter(doc, anchor, surface);
359
+
360
+ while (candidate) {
361
+ candidate.focus();
362
+ if (doc.activeElement === candidate) return;
363
+ anchor = candidate;
364
+ candidate = nextFocusableAfter(doc, anchor, surface);
365
+ }
366
+
367
+ trigger.focus();
368
+ }
369
+ };
370
+
371
+ /* Anchored only: focus leaving the trigger itself for somewhere
372
+ outside both the trigger and the box closes it. Once open, focus
373
+ starts on the surface itself, so under normal tabbing this trigger
374
+ never holds focus while the box is open. This handler is a guard
375
+ for the case where focus reaches the trigger anyway while the box
376
+ is still open (a script, or assistive tech moving focus directly)
377
+ and then leaves for the rest of the page. */
378
+ const onTriggerFocusOut = (ev: FocusEvent): void => {
379
+ if (!current || mode !== "anchored" || ev.target !== current) return;
380
+ if (ev.relatedTarget === null || inside(ev.relatedTarget)) return;
381
+ finish(false);
382
+ };
383
+
384
+ const settle = (): void => {
385
+ frame = 0;
386
+ if (!current) return;
387
+
388
+ if (offscreen(current.getBoundingClientRect(), viewport())) {
389
+ finish(false);
390
+ return;
391
+ }
392
+
393
+ place();
394
+ };
395
+
396
+ const onScroll = (): void => {
397
+ if (!current || mode !== "anchored" || frame) return;
398
+ frame = win?.requestAnimationFrame(settle) ?? 0;
399
+ };
400
+
401
+ const onResize = (): void => {
402
+ if (!current) return;
403
+
404
+ if (popoutMode(media) !== mode) {
405
+ finish(false);
406
+ return;
407
+ }
408
+
409
+ if (mode === "anchored") place();
410
+ };
411
+
412
+ const onClose = (): void => {
413
+ if (!surface.open) finish();
414
+ };
415
+
416
+ const onTouchStart = (ev: TouchEvent): void => {
417
+ swipeStart =
418
+ mode === "sheet" && body.scrollTop === 0
419
+ ? (ev.touches.item(0)?.clientY ?? null)
420
+ : null;
421
+ };
422
+
423
+ const onTouchEnd = (ev: TouchEvent): void => {
424
+ const y = ev.changedTouches.item(0)?.clientY;
425
+ if (y !== undefined && isSwipeClose(swipeStart, y)) finish();
426
+ swipeStart = null;
427
+ };
428
+
429
+ const onCloseBtn = (): void => {
430
+ finish();
431
+ };
432
+
433
+ doc.addEventListener("click", onClick);
434
+ doc.addEventListener("pointerdown", onPointerDown);
435
+ doc.addEventListener("pointercancel", onPointerCancel);
436
+ doc.addEventListener("keydown", onKey);
437
+ doc.addEventListener("focusout", onTriggerFocusOut);
438
+ /* capture: scroll does not bubble from inner scrollers (.table-scroll) */
439
+ doc.addEventListener("scroll", onScroll, true);
440
+ win?.addEventListener("resize", onResize);
441
+ surface.addEventListener("close", onClose);
442
+ surface.addEventListener("focusout", onFocusOut);
443
+ surface.addEventListener("keydown", onSurfaceKey);
444
+ surface.addEventListener("touchstart", onTouchStart, { passive: true });
445
+ surface.addEventListener("touchend", onTouchEnd, { passive: true });
446
+ closeBtn.addEventListener("click", onCloseBtn);
447
+
448
+ return {
449
+ destroy(): void {
450
+ finish(false);
451
+ doc.removeEventListener("click", onClick);
452
+ doc.removeEventListener("pointerdown", onPointerDown);
453
+ doc.removeEventListener("pointercancel", onPointerCancel);
454
+ doc.removeEventListener("keydown", onKey);
455
+ doc.removeEventListener("focusout", onTriggerFocusOut);
456
+ doc.removeEventListener("scroll", onScroll, true);
457
+ win?.removeEventListener("resize", onResize);
458
+ surface.remove();
459
+ release(html, "popout");
460
+ },
461
+ };
462
+ };
@@ -1,8 +1,8 @@
1
1
  import { claim, release, type Island, type IslandHandle } from "./core/island";
2
2
  import { docOf } from "./core/dom";
3
3
 
4
- /* Scroll-to-top floater island (step 9), born from Base.astro's inline
5
- script: shows once the header leaves the viewport. */
4
+ /* Scroll-to-top floater island (step 9), born from the consumer's
5
+ layout's inline script: shows once the header leaves the viewport. */
6
6
 
7
7
  export interface ScrollTopOptions {
8
8
  buttonId?: string;
@@ -24,10 +24,11 @@ export interface SiteHeaderOptions {
24
24
  searchButton?: string;
25
25
  searchWrap?: string;
26
26
  searchField?: string;
27
- /* Defaults to formatHeaderDate, the same function Base.astro uses for
28
- the server render. Its package-time home (staying in src/lib vs.
29
- moving into the library) is an 11.3 decision; this option just
30
- gives a second site a seam to override it without forking. */
27
+ /* Defaults to formatHeaderDate, the same function the consumer's
28
+ layout uses for the server render. Its package-time home (staying
29
+ in src/lib vs. moving into the library) is a settled decision;
30
+ this option just gives a second site a seam to override it without
31
+ forking. */
31
32
  formatDate?: (d: Date) => string;
32
33
  }
33
34
 
@@ -112,8 +113,9 @@ export const mountSiteHeader: Island<SiteHeaderOptions> = (
112
113
 
113
114
  const doc = docOf(root);
114
115
 
115
- // Header date: live like the WordPress original (build-time text is the
116
- // no-JS fallback), same format function as the server render.
116
+ // Header date: live, matching the consumer's original build-time
117
+ // behavior (build-time text is the no-JS fallback), same format
118
+ // function as the server render.
117
119
  const dateBox = doc.getElementById(dateId);
118
120
  if (dateBox) dateBox.textContent = formatDate(new Date());
119
121
 
@@ -15,8 +15,7 @@
15
15
  response (Buttondown's captcha interstitial for a suspicious client,
16
16
  or an error page) falls back to a native form submit so the reader can
17
17
  finish it on Buttondown's own page. Only a thrown fetch (the network
18
- itself failing) writes the couldn't-reach line. Spec:
19
- docs/superpowers/specs/2026-08-16-mailing-list-wiring-design.md
18
+ itself failing) writes the couldn't-reach line.
20
19
 
21
20
  On the island contract (step 9): mount(root, options?) returns a
22
21
  destroy handle, and claim() makes a second mount over an already-wired
@@ -6,9 +6,9 @@
6
6
  theme; this island only wires the button. Shape follows the library's
7
7
  mount(root, options?) contract (step 9): idempotent, and it returns a
8
8
  destroy handle. The storage key is a mount option, not a package
9
- literal (step 11.2); the blog passes it from lib/theme-key. */
9
+ literal (step 11.2); each consumer passes its own storage key. */
10
10
 
11
- import { type Island, type IslandHandle } from "./core/island";
11
+ import { claim, release, type Island, type IslandHandle } from "./core/island";
12
12
  import { docOf } from "./core/dom";
13
13
 
14
14
  export type Theme = "light" | "dark";
@@ -65,19 +65,26 @@ export function apply(doc: Document, theme: Theme): void {
65
65
  else delete doc.documentElement.dataset.theme;
66
66
  }
67
67
 
68
+ /* An action label describes what the button does next ("Switch to dark
69
+ mode"); aria-pressed describes what the button currently is. Carrying
70
+ both means a screen reader in dark mode announces "Switch to light
71
+ mode, pressed", where "pressed" reads as dark mode and the label
72
+ describes turning it off (owner ruling: keep the action label, drop
73
+ aria-pressed). */
68
74
  function reflect(
69
75
  btn: HTMLButtonElement,
70
76
  theme: Theme,
71
77
  labels: Record<Theme, string>,
72
78
  ): void {
73
- btn.setAttribute("aria-pressed", theme === "dark" ? "true" : "false");
74
79
  btn.setAttribute("aria-label", labels[theme]);
75
80
  btn.title = labels[theme];
76
81
  }
77
82
 
78
- /* Every mounted button, so a click on one (the top band's, or the
79
- phone placement beside the search) updates the state of all. */
80
- const mounted = new Set<HTMLButtonElement>();
83
+ /* Each claimed button's own label pair, keyed by element rather than
84
+ read from one mount's closure: a click anywhere reflects every
85
+ claimed button in the document, and a button mounted with its own
86
+ labels keeps them regardless of which mount's listener fired. */
87
+ const labelsOf = new WeakMap<HTMLButtonElement, Record<Theme, string>>();
81
88
 
82
89
  export interface ThemeToggleOptions {
83
90
  selector?: string;
@@ -108,15 +115,19 @@ export const mountThemeToggle: Island<ThemeToggleOptions> = (
108
115
  const handlers: [HTMLButtonElement, () => void][] = [];
109
116
 
110
117
  for (const btn of root.querySelectorAll<HTMLButtonElement>(selector)) {
111
- if (mounted.has(btn)) continue;
112
- mounted.add(btn);
118
+ if (!claim(btn, "theme-toggle")) continue;
119
+ labelsOf.set(btn, labels);
113
120
  reflect(btn, current(doc), labels);
114
121
 
115
122
  const onClick = () => {
116
123
  const next: Theme = current(doc) === "dark" ? "light" : "dark";
117
124
  apply(doc, next);
118
125
  writeStored(storage, storageKey, next);
119
- for (const b of mounted) reflect(b, next, labels);
126
+
127
+ for (const b of doc.querySelectorAll<HTMLButtonElement>(selector)) {
128
+ const bLabels = labelsOf.get(b);
129
+ if (bLabels) reflect(b, next, bLabels);
130
+ }
120
131
  };
121
132
 
122
133
  btn.addEventListener("click", onClick);
@@ -127,7 +138,8 @@ export const mountThemeToggle: Island<ThemeToggleOptions> = (
127
138
  destroy(): void {
128
139
  for (const [btn, onClick] of handlers) {
129
140
  btn.removeEventListener("click", onClick);
130
- mounted.delete(btn);
141
+ labelsOf.delete(btn);
142
+ release(btn, "theme-toggle");
131
143
  }
132
144
  },
133
145
  };
@@ -1,4 +1,5 @@
1
1
  // Custom TextMate theme: amber on the parent theme's dark brown.
2
+ /** @type {import("shiki").ThemeRegistration} */
2
3
  export default {
3
4
  name: "terminal-amber",
4
5
  type: "dark",
@@ -26,7 +27,7 @@ export default {
26
27
  },
27
28
  {
28
29
  scope: ["comment", "punctuation.definition.comment"],
29
- settings: { foreground: "#8a7a63", fontStyle: "italic" },
30
+ settings: { foreground: "#8e7e67", fontStyle: "italic" },
30
31
  },
31
32
  {
32
33
  scope: ["variable", "support.variable"],
@@ -4,18 +4,37 @@
4
4
  // literal colors on every span at build; with this transformer the
5
5
  // build emits var() instead, and the variables resolve to the exact
6
6
  // same hexes until something overrides them, so adopting it changes
7
- // no rendered pixel. Pass it wherever shiki options go: the transformers
7
+ // no rendered pixel. It also encodes ">" in highlighted text (see
8
+ // escapeGt), so Code output passes the tooling html-validate preset.
9
+ // Pass it wherever shiki options go: the transformers
8
10
  // prop of astro:components' Code, or markdown.shikiConfig.transformers
9
11
  // in a consumer's astro config.
10
12
  const HEX_TO_VAR = {
11
13
  "#ffaa3c": "var(--code-token-keyword)",
12
14
  "#ffd18a": "var(--code-token-function)",
13
15
  "#e07c14": "var(--code-token-string)",
14
- "#8a7a63": "var(--code-token-comment)",
16
+ "#8e7e67": "var(--code-token-comment)",
15
17
  "#e8d9c3": "var(--code-fg)",
16
18
  "#1b140c": "var(--code-bg)",
17
19
  };
18
20
 
21
+ // Tags (quoted attribute values kept whole) or runs of text. Shiki's
22
+ // serializer writes "<" in text as "&#x3C;", so any "<" left in the
23
+ // html opens a tag.
24
+ const TOKEN = /<(?:[^>"']|"[^"]*"|'[^']*')*>|[^<]+/g;
25
+
26
+ // Encode ">" in text. codeToHtml (the path astro:components' Code
27
+ // takes) serializes with hast-util-to-html, which escapes only "<" and
28
+ // "&" in text, so a highlighted ">" reaches the page raw and fails
29
+ // html-validate's no-raw-characters in the tooling preset. Markdown
30
+ // code never hits this: it goes through codeToHast, and Astro escapes
31
+ // the text itself.
32
+ function escapeGt(html) {
33
+ return html.replace(TOKEN, (token) =>
34
+ token.startsWith("<") ? token : token.replaceAll(">", "&gt;"),
35
+ );
36
+ }
37
+
19
38
  function swap(node) {
20
39
  const style = node.properties?.style;
21
40
  if (typeof style !== "string") return;
@@ -34,4 +53,7 @@ export default {
34
53
  span(node) {
35
54
  swap(node);
36
55
  },
56
+ postprocess(html) {
57
+ return escapeGt(html);
58
+ },
37
59
  };