@half-built/astro 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ICONS-LICENSE +43 -0
- package/LICENSE +21 -0
- package/README.md +16 -0
- package/package.json +18 -0
- package/src/components/CategoryCard.astro +31 -0
- package/src/components/CornerBadges.astro +40 -0
- package/src/components/Footer.astro +209 -0
- package/src/components/LightboxLink.astro +13 -0
- package/src/components/LinkListWidget.astro +19 -0
- package/src/components/Pagination.astro +72 -0
- package/src/components/PostCard.astro +166 -0
- package/src/components/PostNavigation.astro +61 -0
- package/src/components/Shell.astro +52 -0
- package/src/components/SiteHeader.astro +326 -0
- package/src/components/SmartImage.astro +34 -0
- package/src/components/Subscribe.astro +117 -0
- package/src/components/ThemeToggle.astro +41 -0
- package/src/components/TwoColumn.astro +12 -0
- package/src/components/Widget.astro +41 -0
- package/src/components/content/BlogImage.astro +39 -0
- package/src/components/content/Button.astro +57 -0
- package/src/components/content/Callout.astro +75 -0
- package/src/components/content/CodeBlock.astro +7 -0
- package/src/components/content/Gallery.astro +57 -0
- package/src/components/content/GalleryImage.astro +35 -0
- package/src/components/content/Group.astro +15 -0
- package/src/components/content/MediaText.astro +60 -0
- package/src/components/content/Palette.astro +42 -0
- package/src/components/content/Quote.astro +21 -0
- package/src/components/content/Spacer.astro +5 -0
- package/src/components/content/Step.astro +126 -0
- package/src/components/content/Walkthrough.astro +42 -0
- package/src/components/models.ts +77 -0
- package/src/lib/archive.ts +29 -0
- package/src/lib/drafts.ts +52 -0
- package/src/lib/format-date.ts +9 -0
- package/src/lib/header-date.ts +6 -0
- package/src/lib/ordering.ts +18 -0
- package/src/lib/paginate.ts +15 -0
- package/src/lib/reading-time.ts +4 -0
- package/src/lib/slug.ts +73 -0
- package/src/scripts/code-island.ts +75 -0
- package/src/scripts/core/breakpoints.ts +4 -0
- package/src/scripts/core/dom.ts +31 -0
- package/src/scripts/core/frame-loop.ts +54 -0
- package/src/scripts/core/icons.ts +30 -0
- package/src/scripts/core/island.ts +25 -0
- package/src/scripts/core/storage.ts +44 -0
- package/src/scripts/focus-mode.ts +41 -0
- package/src/scripts/lightbox.ts +446 -0
- package/src/scripts/link-tip.ts +154 -0
- package/src/scripts/path-player-math.ts +34 -0
- package/src/scripts/path-player-paint.ts +154 -0
- package/src/scripts/path-player.ts +341 -0
- package/src/scripts/plate-modal.ts +91 -0
- package/src/scripts/scroll-top.ts +32 -0
- package/src/scripts/site-header.ts +73 -0
- package/src/scripts/subscribe.ts +116 -0
- package/src/scripts/theme-toggle.ts +115 -0
- package/src/shiki/code-theme.mjs +16 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { claim, release, type Island, type IslandHandle } from "./core/island";
|
|
2
|
+
import { formatHeaderDate } from "../lib/header-date";
|
|
3
|
+
import { docOf } from "./core/dom";
|
|
4
|
+
|
|
5
|
+
/* The masthead header island (step 9): the date-box refresh and the
|
|
6
|
+
phone menu toggle, born from SiteHeader.astro's inline script.
|
|
7
|
+
The two halves are unconditional and independent, mirroring the
|
|
8
|
+
original: the date box refreshes on every mount regardless of the
|
|
9
|
+
menu (it is idempotent, same text each time), and the menu wiring
|
|
10
|
+
claims the button separately so a second mount does not double-bind
|
|
11
|
+
its click/pointerdown/keydown handlers. */
|
|
12
|
+
|
|
13
|
+
export interface SiteHeaderOptions {
|
|
14
|
+
dateId?: string;
|
|
15
|
+
menuButton?: string;
|
|
16
|
+
menuId?: string;
|
|
17
|
+
/* Defaults to formatHeaderDate, the same function Base.astro uses for
|
|
18
|
+
the server render. Its package-time home (staying in src/lib vs.
|
|
19
|
+
moving into the library) is an 11.3 decision; this option just
|
|
20
|
+
gives a second site a seam to override it without forking. */
|
|
21
|
+
formatDate?: (d: Date) => string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const mountSiteHeader: Island<SiteHeaderOptions> = (root, options = {}): IslandHandle => {
|
|
25
|
+
const { dateId = "header-date", menuButton = ".menu-toggle", menuId = "primary-menu", formatDate = formatHeaderDate } = options;
|
|
26
|
+
const doc = docOf(root);
|
|
27
|
+
|
|
28
|
+
// Header date: live like the WordPress original (build-time text is the
|
|
29
|
+
// no-JS fallback), same format function as the server render.
|
|
30
|
+
const dateBox = doc.getElementById(dateId);
|
|
31
|
+
if (dateBox) dateBox.textContent = formatDate(new Date());
|
|
32
|
+
|
|
33
|
+
const btn = root.querySelector<HTMLButtonElement>(menuButton);
|
|
34
|
+
const menu = doc.getElementById(menuId);
|
|
35
|
+
let onClick: (() => void) | null = null;
|
|
36
|
+
let onPointerdown: ((ev: PointerEvent) => void) | null = null;
|
|
37
|
+
let onKeydown: ((ev: KeyboardEvent) => void) | null = null;
|
|
38
|
+
|
|
39
|
+
if (btn && claim(btn, "site-header")) {
|
|
40
|
+
const isOpen = (): boolean => menu?.classList.contains("open") ?? false;
|
|
41
|
+
const setOpen = (open: boolean): void => {
|
|
42
|
+
menu?.classList.toggle("open", open);
|
|
43
|
+
btn.setAttribute("aria-expanded", String(open));
|
|
44
|
+
};
|
|
45
|
+
onClick = () => { setOpen(!isOpen()); };
|
|
46
|
+
btn.addEventListener("click", onClick);
|
|
47
|
+
/* A pop-out over the page closes the way the search flyout does: a
|
|
48
|
+
press anywhere outside it, or Escape (which hands focus back to the
|
|
49
|
+
button). */
|
|
50
|
+
onPointerdown = (ev) => {
|
|
51
|
+
if (!isOpen()) return;
|
|
52
|
+
const t = ev.target;
|
|
53
|
+
if (t instanceof Node && (menu?.contains(t) || btn.contains(t))) return;
|
|
54
|
+
setOpen(false);
|
|
55
|
+
};
|
|
56
|
+
onKeydown = (ev) => {
|
|
57
|
+
if (ev.key !== "Escape" || !isOpen()) return;
|
|
58
|
+
setOpen(false);
|
|
59
|
+
btn.focus();
|
|
60
|
+
};
|
|
61
|
+
doc.addEventListener("pointerdown", onPointerdown);
|
|
62
|
+
doc.addEventListener("keydown", onKeydown);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
destroy(): void {
|
|
67
|
+
if (btn && onClick) btn.removeEventListener("click", onClick);
|
|
68
|
+
if (onPointerdown) doc.removeEventListener("pointerdown", onPointerdown);
|
|
69
|
+
if (onKeydown) doc.removeEventListener("keydown", onKeydown);
|
|
70
|
+
if (btn && onClick) release(btn, "site-header");
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/* Subscribe form runtime. The form is a real form (method post, action =
|
|
2
|
+
Buttondown's keyless embed-subscribe endpoint) so it works with no
|
|
3
|
+
JavaScript at all: the browser posts, Buttondown redirects to the
|
|
4
|
+
site's /newsletter/subscribed/ page. With JavaScript, this script takes over the
|
|
5
|
+
submit, posts the same FormData with fetch, and writes the outcome
|
|
6
|
+
into the status line so the reader stays put. There is no server of
|
|
7
|
+
ours in the path and no secret involved.
|
|
8
|
+
|
|
9
|
+
Buttondown answers a successful post with a 302 to our own origin,
|
|
10
|
+
carrying Access-Control-Allow-Origin: * on the redirect response itself
|
|
11
|
+
but not on ours, so a followed fetch cannot read it: the browser throws
|
|
12
|
+
TypeError: Failed to fetch even though the signup went through. The
|
|
13
|
+
fetch below asks for redirect: "manual" instead, which turns that 302
|
|
14
|
+
into a clean opaqueredirect result rather than an error. Any other real
|
|
15
|
+
response (Buttondown's captcha interstitial for a suspicious client,
|
|
16
|
+
or an error page) falls back to a native form submit so the reader can
|
|
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
|
|
20
|
+
|
|
21
|
+
On the island contract (step 9): mount(root, options?) returns a
|
|
22
|
+
destroy handle, and claim() makes a second mount over an already-wired
|
|
23
|
+
form a no-op. */
|
|
24
|
+
|
|
25
|
+
import { claim, release, type Island, type IslandHandle } from "./core/island";
|
|
26
|
+
|
|
27
|
+
export const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
28
|
+
|
|
29
|
+
export const MSG = {
|
|
30
|
+
invalid: "That doesn't look like an email address.",
|
|
31
|
+
pending: "Sending...",
|
|
32
|
+
sent: "Check your inbox. Nothing arrives until you click the confirmation link.",
|
|
33
|
+
failed: "Couldn't reach the list. Try again in a minute.",
|
|
34
|
+
/* Composes the failure line when the form names a public sign-up page.
|
|
35
|
+
A function member so an overridden register controls the whole
|
|
36
|
+
sentence, joiner included (step 9.5; the generalization review found
|
|
37
|
+
the joiner was the one baked phrase left in this script). */
|
|
38
|
+
failedAt: (failed: string, url: string): string => `${failed.replace(/\.$/, "")}, or sign up at ${url}.`,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/* The provider's public sign-up page rides the form's data-public-url
|
|
42
|
+
(config NEWSLETTER via the component's publicUrl prop), so this script names no provider; without the
|
|
43
|
+
attribute the message stays generic. */
|
|
44
|
+
export function failedMessage(form: HTMLFormElement, messages: typeof MSG): string {
|
|
45
|
+
const url = form.dataset.publicUrl;
|
|
46
|
+
return url ? messages.failedAt(messages.failed, url) : messages.failed;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function setStatus(status: HTMLElement, text: string, isError: boolean): void {
|
|
50
|
+
status.textContent = text;
|
|
51
|
+
status.classList.toggle("subscribe-status-err", isError);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function send(form: HTMLFormElement, input: HTMLInputElement, button: HTMLButtonElement | null, status: HTMLElement, messages: typeof MSG): Promise<void> {
|
|
55
|
+
if (button) button.disabled = true;
|
|
56
|
+
setStatus(status, messages.pending, false);
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetch(form.action, { method: "POST", body: new FormData(form), redirect: "manual" });
|
|
59
|
+
if (res.ok || res.type === "opaqueredirect") {
|
|
60
|
+
setStatus(status, messages.sent, false);
|
|
61
|
+
input.value = "";
|
|
62
|
+
if (button) button.disabled = false;
|
|
63
|
+
} else {
|
|
64
|
+
/* A real, readable non-OK response: Buttondown's captcha
|
|
65
|
+
interstitial for a suspicious client, or an error page. A fetch
|
|
66
|
+
cannot complete the captcha; hand off to a native submit so the
|
|
67
|
+
reader finishes it on Buttondown's own page, which redirects back
|
|
68
|
+
to /newsletter/subscribed/. The page is navigating away, so the status line
|
|
69
|
+
is left alone and the button stays disabled. */
|
|
70
|
+
form.submit();
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
setStatus(status, failedMessage(form, messages), true);
|
|
74
|
+
if (button) button.disabled = false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface SubscribeOptions {
|
|
79
|
+
selector?: string;
|
|
80
|
+
messages?: Partial<typeof MSG>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const mountSubscribe: Island<SubscribeOptions> = (root, options = {}): IslandHandle => {
|
|
84
|
+
const { selector = ".subscribe-form", messages: messagesOverride = {} } = options;
|
|
85
|
+
const messages: typeof MSG = { ...MSG, ...messagesOverride };
|
|
86
|
+
const forms = [...root.querySelectorAll<HTMLFormElement>(selector)].filter((form) => claim(form, "subscribe"));
|
|
87
|
+
|
|
88
|
+
const handlers: [HTMLFormElement, (e: Event) => void][] = [];
|
|
89
|
+
for (const form of forms) {
|
|
90
|
+
const onSubmit = (e: Event): void => {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
const input = form.querySelector(".subscribe-email");
|
|
93
|
+
const button = form.querySelector(".subscribe-submit");
|
|
94
|
+
const status = form.parentElement?.querySelector(".subscribe-status");
|
|
95
|
+
if (!(input instanceof HTMLInputElement) || !(status instanceof HTMLElement)) return;
|
|
96
|
+
const value = input.value.trim();
|
|
97
|
+
input.value = value;
|
|
98
|
+
if (!EMAIL.test(value)) {
|
|
99
|
+
setStatus(status, messages.invalid, true);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
void send(form, input, button instanceof HTMLButtonElement ? button : null, status, messages);
|
|
103
|
+
};
|
|
104
|
+
form.addEventListener("submit", onSubmit);
|
|
105
|
+
handlers.push([form, onSubmit]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
destroy(): void {
|
|
110
|
+
for (const [form, onSubmit] of handlers) {
|
|
111
|
+
form.removeEventListener("submit", onSubmit);
|
|
112
|
+
release(form, "subscribe");
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/* Day/night toggle (owner request 2026-08-25): the header button next to
|
|
2
|
+
the date stamp flips data-theme on <html> between light and dark and
|
|
3
|
+
remembers the choice. The first paint is handled by the inline stamp in
|
|
4
|
+
Shell.astro's head, which reads the same storage key (or, with nothing
|
|
5
|
+
stored, the system preference), so there is no flash of the wrong
|
|
6
|
+
theme; this island only wires the button. Shape follows the library's
|
|
7
|
+
mount(root, options?) contract (step 9): idempotent, and it returns a
|
|
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. */
|
|
10
|
+
|
|
11
|
+
import { type Island, type IslandHandle } from "./core/island";
|
|
12
|
+
import { docOf } from "./core/dom";
|
|
13
|
+
|
|
14
|
+
export type Theme = "light" | "dark";
|
|
15
|
+
|
|
16
|
+
const LABEL: Record<Theme, string> = {
|
|
17
|
+
light: "Switch to dark mode",
|
|
18
|
+
dark: "Switch to light mode",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/* Storage can be absent or throwing (private mode, blocked site data);
|
|
22
|
+
every access is guarded and a bad value reads as no choice. With no
|
|
23
|
+
storageKey there is nowhere to read from, so this reads as no choice
|
|
24
|
+
too rather than guessing at a key. */
|
|
25
|
+
export function readStored(storage: Storage | null, storageKey?: string): Theme | null {
|
|
26
|
+
if (!storageKey) return null;
|
|
27
|
+
try {
|
|
28
|
+
const v = storage?.getItem(storageKey);
|
|
29
|
+
return v === "light" || v === "dark" ? v : null;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* With no storageKey, this no-ops: the toggle still flips the theme for
|
|
36
|
+
the page, it just does not persist the choice. That is a safe
|
|
37
|
+
degradation for an adopter who forgot to pass one, not a broken
|
|
38
|
+
toggle. */
|
|
39
|
+
function writeStored(storage: Storage | null, storageKey: string | undefined, theme: Theme): void {
|
|
40
|
+
if (!storageKey) return;
|
|
41
|
+
try {
|
|
42
|
+
storage?.setItem(storageKey, theme);
|
|
43
|
+
} catch {
|
|
44
|
+
/* nothing to do: the choice lives for this page only */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function current(doc: Document): Theme {
|
|
49
|
+
return doc.documentElement.dataset.theme === "dark" ? "dark" : "light";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* Light is the absence of the attribute, so the light theme stays the
|
|
53
|
+
plain :root definition and the dark file only ever adds. */
|
|
54
|
+
export function apply(doc: Document, theme: Theme): void {
|
|
55
|
+
if (theme === "dark") doc.documentElement.dataset.theme = "dark";
|
|
56
|
+
else delete doc.documentElement.dataset.theme;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function reflect(btn: HTMLButtonElement, theme: Theme, labels: Record<Theme, string>): void {
|
|
60
|
+
btn.setAttribute("aria-pressed", theme === "dark" ? "true" : "false");
|
|
61
|
+
btn.setAttribute("aria-label", labels[theme]);
|
|
62
|
+
btn.title = labels[theme];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* Every mounted button, so a click on one (the top band's, or the
|
|
66
|
+
phone placement beside the search) updates the state of all. */
|
|
67
|
+
const mounted = new Set<HTMLButtonElement>();
|
|
68
|
+
|
|
69
|
+
export interface ThemeToggleOptions {
|
|
70
|
+
selector?: string;
|
|
71
|
+
doc?: Document;
|
|
72
|
+
storage?: Storage | null;
|
|
73
|
+
/* No default: the toggle bakes in no brand's key. Pass the site's
|
|
74
|
+
key to persist a choice; omit it and the toggle still works for
|
|
75
|
+
the page, it just forgets on reload. */
|
|
76
|
+
storageKey?: string;
|
|
77
|
+
/* Button aria-label/title text per theme, defaulting to the English
|
|
78
|
+
day/night pair above. */
|
|
79
|
+
labels?: Record<Theme, string>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export const mountThemeToggle: Island<ThemeToggleOptions> = (root, options = {}): IslandHandle => {
|
|
83
|
+
const doc = options.doc ?? docOf(root);
|
|
84
|
+
const { selector = ".theme-toggle", storage = safeStorage(doc), storageKey, labels = LABEL } = options;
|
|
85
|
+
const handlers: [HTMLButtonElement, () => void][] = [];
|
|
86
|
+
for (const btn of root.querySelectorAll<HTMLButtonElement>(selector)) {
|
|
87
|
+
if (mounted.has(btn)) continue;
|
|
88
|
+
mounted.add(btn);
|
|
89
|
+
reflect(btn, current(doc), labels);
|
|
90
|
+
const onClick = () => {
|
|
91
|
+
const next: Theme = current(doc) === "dark" ? "light" : "dark";
|
|
92
|
+
apply(doc, next);
|
|
93
|
+
writeStored(storage, storageKey, next);
|
|
94
|
+
for (const b of mounted) reflect(b, next, labels);
|
|
95
|
+
};
|
|
96
|
+
btn.addEventListener("click", onClick);
|
|
97
|
+
handlers.push([btn, onClick]);
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
destroy(): void {
|
|
101
|
+
for (const [btn, onClick] of handlers) {
|
|
102
|
+
btn.removeEventListener("click", onClick);
|
|
103
|
+
mounted.delete(btn);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
function safeStorage(doc: Document): Storage | null {
|
|
110
|
+
try {
|
|
111
|
+
return doc.defaultView?.localStorage ?? null;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Custom TextMate theme: amber on the parent theme's dark brown.
|
|
2
|
+
export default {
|
|
3
|
+
name: "terminal-amber",
|
|
4
|
+
type: "dark",
|
|
5
|
+
colors: {
|
|
6
|
+
"editor.background": "#1b140c",
|
|
7
|
+
"editor.foreground": "#e8d9c3",
|
|
8
|
+
},
|
|
9
|
+
tokenColors: [
|
|
10
|
+
{ scope: ["keyword", "storage", "keyword.control"], settings: { foreground: "#ffaa3c" } },
|
|
11
|
+
{ scope: ["entity.name.function", "support.function", "entity.name.tag"], settings: { foreground: "#ffd18a" } },
|
|
12
|
+
{ scope: ["string", "constant.numeric", "constant.language", "constant.character"], settings: { foreground: "#e07c14" } },
|
|
13
|
+
{ scope: ["comment", "punctuation.definition.comment"], settings: { foreground: "#8a7a63", fontStyle: "italic" } },
|
|
14
|
+
{ scope: ["variable", "support.variable"], settings: { foreground: "#e8d9c3" } },
|
|
15
|
+
],
|
|
16
|
+
};
|