@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.
Files changed (60) hide show
  1. package/ICONS-LICENSE +43 -0
  2. package/LICENSE +21 -0
  3. package/README.md +16 -0
  4. package/package.json +18 -0
  5. package/src/components/CategoryCard.astro +31 -0
  6. package/src/components/CornerBadges.astro +40 -0
  7. package/src/components/Footer.astro +209 -0
  8. package/src/components/LightboxLink.astro +13 -0
  9. package/src/components/LinkListWidget.astro +19 -0
  10. package/src/components/Pagination.astro +72 -0
  11. package/src/components/PostCard.astro +166 -0
  12. package/src/components/PostNavigation.astro +61 -0
  13. package/src/components/Shell.astro +52 -0
  14. package/src/components/SiteHeader.astro +326 -0
  15. package/src/components/SmartImage.astro +34 -0
  16. package/src/components/Subscribe.astro +117 -0
  17. package/src/components/ThemeToggle.astro +41 -0
  18. package/src/components/TwoColumn.astro +12 -0
  19. package/src/components/Widget.astro +41 -0
  20. package/src/components/content/BlogImage.astro +39 -0
  21. package/src/components/content/Button.astro +57 -0
  22. package/src/components/content/Callout.astro +75 -0
  23. package/src/components/content/CodeBlock.astro +7 -0
  24. package/src/components/content/Gallery.astro +57 -0
  25. package/src/components/content/GalleryImage.astro +35 -0
  26. package/src/components/content/Group.astro +15 -0
  27. package/src/components/content/MediaText.astro +60 -0
  28. package/src/components/content/Palette.astro +42 -0
  29. package/src/components/content/Quote.astro +21 -0
  30. package/src/components/content/Spacer.astro +5 -0
  31. package/src/components/content/Step.astro +126 -0
  32. package/src/components/content/Walkthrough.astro +42 -0
  33. package/src/components/models.ts +77 -0
  34. package/src/lib/archive.ts +29 -0
  35. package/src/lib/drafts.ts +52 -0
  36. package/src/lib/format-date.ts +9 -0
  37. package/src/lib/header-date.ts +6 -0
  38. package/src/lib/ordering.ts +18 -0
  39. package/src/lib/paginate.ts +15 -0
  40. package/src/lib/reading-time.ts +4 -0
  41. package/src/lib/slug.ts +73 -0
  42. package/src/scripts/code-island.ts +75 -0
  43. package/src/scripts/core/breakpoints.ts +4 -0
  44. package/src/scripts/core/dom.ts +31 -0
  45. package/src/scripts/core/frame-loop.ts +54 -0
  46. package/src/scripts/core/icons.ts +30 -0
  47. package/src/scripts/core/island.ts +25 -0
  48. package/src/scripts/core/storage.ts +44 -0
  49. package/src/scripts/focus-mode.ts +41 -0
  50. package/src/scripts/lightbox.ts +446 -0
  51. package/src/scripts/link-tip.ts +154 -0
  52. package/src/scripts/path-player-math.ts +34 -0
  53. package/src/scripts/path-player-paint.ts +154 -0
  54. package/src/scripts/path-player.ts +341 -0
  55. package/src/scripts/plate-modal.ts +91 -0
  56. package/src/scripts/scroll-top.ts +32 -0
  57. package/src/scripts/site-header.ts +73 -0
  58. package/src/scripts/subscribe.ts +116 -0
  59. package/src/scripts/theme-toggle.ts +115 -0
  60. 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
+ };