@half-built/astro 0.4.2 → 0.6.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/README.md +50 -2
  2. package/package.json +11 -4
  3. package/src/components/CategoryCard.astro +9 -1
  4. package/src/components/CornerBadges.astro +14 -21
  5. package/src/components/Footer.astro +57 -29
  6. package/src/components/Icon.astro +16 -0
  7. package/src/components/LightboxLink.astro +19 -10
  8. package/src/components/LinkListWidget.astro +11 -9
  9. package/src/components/Pagination.astro +51 -28
  10. package/src/components/PostCard.astro +54 -22
  11. package/src/components/PostNavigation.astro +40 -29
  12. package/src/components/Shell.astro +26 -12
  13. package/src/components/SiteHeader.astro +139 -80
  14. package/src/components/SmartImage.astro +48 -17
  15. package/src/components/Subscribe.astro +60 -35
  16. package/src/components/ThemeToggle.astro +28 -15
  17. package/src/components/TwoColumn.astro +5 -5
  18. package/src/components/Widget.astro +10 -7
  19. package/src/components/content/BlogImage.astro +48 -13
  20. package/src/components/content/Button.astro +13 -13
  21. package/src/components/content/Callout.astro +17 -8
  22. package/src/components/content/CodeBlock.astro +6 -1
  23. package/src/components/content/EditorNote.astro +19 -21
  24. package/src/components/content/Gallery.astro +18 -5
  25. package/src/components/content/GalleryImage.astro +26 -7
  26. package/src/components/content/Group.astro +5 -6
  27. package/src/components/content/MediaText.astro +43 -9
  28. package/src/components/content/Palette.astro +37 -21
  29. package/src/components/content/PostLink.astro +15 -0
  30. package/src/components/content/Quote.astro +10 -9
  31. package/src/components/content/Spacer.astro +11 -2
  32. package/src/components/content/Step.astro +34 -15
  33. package/src/components/content/Walkthrough.astro +10 -6
  34. package/src/components/content/WhenPublished.astro +14 -0
  35. package/src/components/models.ts +2 -2
  36. package/src/lib/archive.ts +18 -4
  37. package/src/lib/drafts.ts +16 -5
  38. package/src/lib/format-date.ts +4 -1
  39. package/src/lib/ordering.ts +3 -1
  40. package/src/lib/paginate.ts +5 -1
  41. package/src/lib/slug.ts +30 -7
  42. package/src/scripts/code-island.ts +27 -4
  43. package/src/scripts/core/badges.ts +28 -0
  44. package/src/scripts/core/dom.ts +6 -1
  45. package/src/scripts/core/frame-loop.ts +4 -1
  46. package/src/scripts/core/icons.ts +92 -15
  47. package/src/scripts/ecosystem.ts +72 -12
  48. package/src/scripts/focus-mode.ts +14 -3
  49. package/src/scripts/lightbox.ts +227 -41
  50. package/src/scripts/link-tip.ts +55 -11
  51. package/src/scripts/path-player-math.ts +11 -2
  52. package/src/scripts/path-player-paint.ts +54 -8
  53. package/src/scripts/path-player.ts +131 -29
  54. package/src/scripts/plate-modal.ts +45 -9
  55. package/src/scripts/scroll-top.ts +6 -1
  56. package/src/scripts/site-header.ts +34 -6
  57. package/src/scripts/subscribe.ts +50 -9
  58. package/src/scripts/theme-toggle.ts +32 -5
  59. package/src/shiki/code-theme.mjs +25 -5
  60. package/src/shiki/code-vars.mjs +5 -1
@@ -6,25 +6,102 @@
6
6
  Home: the js package's core (owner decision 1, 2026-08-31); Lucide,
7
7
  ISC license, attribution retained. */
8
8
 
9
+ interface Glyph {
10
+ paths: string;
11
+ fill?: string;
12
+ }
13
+
14
+ /* Every glyph the package draws, keyed by its Lucide name. Components
15
+ render one through Icon.astro; client scripts take the ICON_*
16
+ strings below. Add a glyph here, never as inline <svg> markup in a
17
+ component (test/icons.test.ts guards that). */
18
+ const GLYPHS = {
19
+ x: { paths: '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>' },
20
+ /* Play is the one filled icon: a stroke-only triangle reads as a hollow
21
+ arrow, not a play control. */
22
+ play: {
23
+ paths: '<polygon points="7 4 19 12 7 20 7 4"/>',
24
+ fill: "currentColor",
25
+ },
26
+ /* The AI Art badge (CornerBadges.astro). */
27
+ sparkles: {
28
+ paths:
29
+ '<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/>',
30
+ },
31
+ pause: {
32
+ paths:
33
+ '<line x1="9" x2="9" y1="5" y2="19"/><line x1="15" x2="15" y1="5" y2="19"/>',
34
+ },
35
+ "rotate-ccw": {
36
+ paths:
37
+ '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
38
+ },
39
+ "chevron-left": { paths: '<path d="m15 18-6-6 6-6"/>' },
40
+ /* Pagination's previous and next (Pagination.astro): arrows, not
41
+ chevrons, which read too faint in a 30px box. */
42
+ "arrow-left": { paths: '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>' },
43
+ "arrow-right": { paths: '<path d="M5 12h14"/><path d="m12 5 7 7-7 7"/>' },
44
+ "chevron-right": { paths: '<path d="m9 18 6-6-6-6"/>' },
45
+ /* The to-top link (Shell.astro). */
46
+ "chevron-up": { paths: '<path d="m18 15-6-6-6 6"/>' },
47
+ /* Day/night toggle in the header (owner request 2026-08-25). */
48
+ sun: {
49
+ paths:
50
+ '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
51
+ },
52
+ moon: { paths: '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>' },
53
+ /* The header's search button (SiteHeader.astro) and the post card's
54
+ meta row (PostCard.astro). These replaced Font Awesome glyphs that
55
+ had been inlined in the templates (owner call 2026-09-10), which
56
+ also puts every glyph the package ships under the one
57
+ ICONS-LICENSE. */
58
+ search: {
59
+ paths: '<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>',
60
+ },
61
+ clock: { paths: '<circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>' },
62
+ user: {
63
+ paths:
64
+ '<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
65
+ },
66
+ calendar: {
67
+ paths:
68
+ '<path d="M8 2v3"/><path d="M16 2v3"/><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/>',
69
+ },
70
+ circle: { paths: '<circle cx="12" cy="12" r="10"/>' },
71
+ } as const satisfies Record<string, Glyph>;
72
+
73
+ export type IconName = keyof typeof GLYPHS;
74
+
75
+ export interface IconOptions {
76
+ /* A CSS length, or a pixel count. The default tracks the parent's
77
+ font size. */
78
+ size?: number | string;
79
+ strokeWidth?: number;
80
+ class?: string;
81
+ }
82
+
9
83
  /* 1em sizing: icons track their button's font size instead of a fixed
10
84
  pixel box. */
11
85
  /* pointer-events none: a click on a button must target the button, not
12
86
  the decorative svg. A targeted svg detached by an innerHTML swap
13
87
  mid-bubble made the plate-modal's veil check read a pause click as
14
88
  outside the zone and close the dialog (found 2026-08-23). */
15
- const icon = (paths: string, fill = "none"): string =>
16
- `<svg viewBox="0 0 24 24" width="1em" height="1em" fill="${fill}" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" pointer-events="none" aria-hidden="true">${paths}</svg>`;
89
+ export function iconMarkup(
90
+ name: IconName,
91
+ { size = "1em", strokeWidth = 2.5, class: className }: IconOptions = {},
92
+ ): string {
93
+ const glyph: Glyph = GLYPHS[name];
94
+ const dim = typeof size === "number" ? String(size) : size;
95
+ const cls = className === undefined ? "" : ` class="${className}"`;
96
+ return `<svg${cls} viewBox="0 0 24 24" width="${dim}" height="${dim}" fill="${glyph.fill ?? "none"}" stroke="currentColor" stroke-width="${strokeWidth}" stroke-linecap="round" stroke-linejoin="round" pointer-events="none" aria-hidden="true">${glyph.paths}</svg>`;
97
+ }
17
98
 
18
- export const ICON_X = icon('<path d="M18 6 6 18"/><path d="m6 6 12 12"/>');
19
- /* Play is the one filled icon: a stroke-only triangle reads as a hollow
20
- arrow, not a play control. */
21
- export const ICON_PLAY = icon('<polygon points="7 4 19 12 7 20 7 4"/>', "currentColor");
22
- /* The AI Art badge (CornerBadges.astro). */
23
- export const ICON_SPARKLES = icon('<path d="M9.937 15.5A2 2 0 0 0 8.5 14.063l-6.135-1.582a.5.5 0 0 1 0-.962L8.5 9.936A2 2 0 0 0 9.937 8.5l1.582-6.135a.5.5 0 0 1 .963 0L14.063 8.5A2 2 0 0 0 15.5 9.937l6.135 1.581a.5.5 0 0 1 0 .964L15.5 14.063a2 2 0 0 0-1.437 1.437l-1.582 6.135a.5.5 0 0 1-.963 0z"/><path d="M20 3v4"/><path d="M22 5h-4"/><path d="M4 17v2"/><path d="M5 18H3"/>');
24
- export const ICON_PAUSE = icon('<line x1="9" x2="9" y1="5" y2="19"/><line x1="15" x2="15" y1="5" y2="19"/>');
25
- export const ICON_ROTATE_CCW = icon('<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>');
26
- export const ICON_CHEVRON_LEFT = icon('<path d="m15 18-6-6 6-6"/>');
27
- export const ICON_CHEVRON_RIGHT = icon('<path d="m9 18 6-6-6-6"/>');
28
- /* Day/night toggle in the header (owner request 2026-08-25). */
29
- export const ICON_SUN = icon('<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>');
30
- export const ICON_MOON = icon('<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>');
99
+ export const ICON_X = iconMarkup("x");
100
+ export const ICON_PLAY = iconMarkup("play");
101
+ export const ICON_SPARKLES = iconMarkup("sparkles");
102
+ export const ICON_PAUSE = iconMarkup("pause");
103
+ export const ICON_ROTATE_CCW = iconMarkup("rotate-ccw");
104
+ export const ICON_CHEVRON_LEFT = iconMarkup("chevron-left");
105
+ export const ICON_CHEVRON_RIGHT = iconMarkup("chevron-right");
106
+ export const ICON_SUN = iconMarkup("sun");
107
+ export const ICON_MOON = iconMarkup("moon");
@@ -34,16 +34,20 @@ function isEntry(value: unknown): value is EcosystemDocEntry {
34
34
  if (typeof value !== "object" || value === null) return false;
35
35
  const entry = value as Record<string, unknown>;
36
36
  return (
37
- typeof entry.key === "string" && entry.key !== "" &&
38
- typeof entry.label === "string" && entry.label !== "" &&
37
+ typeof entry.key === "string" &&
38
+ entry.key !== "" &&
39
+ typeof entry.label === "string" &&
40
+ entry.label !== "" &&
39
41
  /* href is checked by scheme, not just type, because it is assigned
40
42
  straight to link.href below. The document and the cached copy
41
43
  are both untrusted input, and a javascript: or data: URL there
42
44
  would run on the consumer's origin when clicked. */
43
45
  (entry.href === null ||
44
46
  (typeof entry.href === "string" && /^https?:\/\//i.test(entry.href))) &&
45
- typeof entry.priority === "number" && Number.isFinite(entry.priority) &&
46
- typeof entry.family === "string" && entry.family !== ""
47
+ typeof entry.priority === "number" &&
48
+ Number.isFinite(entry.priority) &&
49
+ typeof entry.family === "string" &&
50
+ entry.family !== ""
47
51
  );
48
52
  }
49
53
 
@@ -110,7 +114,11 @@ function sleep(ms: number): Promise<void> {
110
114
 
111
115
  async function attemptFetch(endpoint: string): Promise<Attempt> {
112
116
  const controller = new AbortController();
113
- const timer = setTimeout(() => { controller.abort(); }, ATTEMPT_TIMEOUT_MS);
117
+
118
+ const timer = setTimeout(() => {
119
+ controller.abort();
120
+ }, ATTEMPT_TIMEOUT_MS);
121
+
114
122
  /* The timer is cleared once in this single finally, which covers the
115
123
  header fetch and the body read alike: the 3 second budget bounds
116
124
  the whole attempt, not just the response headers, so a stalled
@@ -118,18 +126,25 @@ async function attemptFetch(endpoint: string): Promise<Attempt> {
118
126
  forever. */
119
127
  try {
120
128
  let response: Response;
129
+
121
130
  try {
122
- response = await fetch(endpoint, { signal: controller.signal, credentials: "omit" });
131
+ response = await fetch(endpoint, {
132
+ signal: controller.signal,
133
+ credentials: "omit",
134
+ });
123
135
  } catch {
124
136
  return { kind: "transport", status: null };
125
137
  }
138
+
126
139
  if (!response.ok) return { kind: "transport", status: response.status };
127
140
  let text: string;
141
+
128
142
  try {
129
143
  text = await response.text();
130
144
  } catch {
131
145
  return { kind: "transport", status: null };
132
146
  }
147
+
133
148
  try {
134
149
  return { kind: "body", body: JSON.parse(text) as unknown };
135
150
  } catch {
@@ -141,8 +156,13 @@ async function attemptFetch(endpoint: string): Promise<Attempt> {
141
156
  }
142
157
  }
143
158
 
144
- function readCache(storage: Storage | null, cacheKey: string, now: number): EcosystemDocument | null {
159
+ function readCache(
160
+ storage: Storage | null,
161
+ cacheKey: string,
162
+ now: number,
163
+ ): EcosystemDocument | null {
145
164
  if (!storage) return null;
165
+
146
166
  try {
147
167
  const raw = storage.getItem(cacheKey);
148
168
  if (raw === null) return null;
@@ -158,8 +178,14 @@ function readCache(storage: Storage | null, cacheKey: string, now: number): Ecos
158
178
  }
159
179
  }
160
180
 
161
- function writeCache(storage: Storage | null, cacheKey: string, now: number, document: EcosystemDocument): void {
181
+ function writeCache(
182
+ storage: Storage | null,
183
+ cacheKey: string,
184
+ now: number,
185
+ document: EcosystemDocument,
186
+ ): void {
162
187
  if (!storage) return;
188
+
163
189
  try {
164
190
  storage.setItem(cacheKey, JSON.stringify({ fetchedAt: now, document }));
165
191
  } catch {
@@ -183,17 +209,22 @@ export async function loadDocument(
183
209
  for (const delay of [0, ...retryDelaysMs]) {
184
210
  await sleep(delay);
185
211
  const result = await attemptFetch(endpoint);
212
+
186
213
  if (result.kind === "body") {
187
214
  const document = validateDocument(result.body);
215
+
188
216
  if (document) {
189
217
  writeCache(storage, cacheKey, now, document);
190
218
  return document;
191
219
  }
220
+
192
221
  break;
193
222
  }
223
+
194
224
  if (result.kind === "content") break;
195
225
  if (!retryable(result.status)) break;
196
226
  }
227
+
197
228
  return readCache(storage, cacheKey, now);
198
229
  }
199
230
 
@@ -229,10 +260,15 @@ function scopeOf(list: Element): string | null {
229
260
  for (const { name } of list.attributes) {
230
261
  if (name.startsWith("data-astro-cid-")) return name;
231
262
  }
263
+
232
264
  return null;
233
265
  }
234
266
 
235
- function entryNode(doc: Document, entry: EcosystemDocEntry, selfKey: string): HTMLElement {
267
+ function entryNode(
268
+ doc: Document,
269
+ entry: EcosystemDocEntry,
270
+ selfKey: string,
271
+ ): HTMLElement {
236
272
  if (entry.key === selfKey) {
237
273
  const self = doc.createElement("span");
238
274
  self.className = "footer-sitemap-self";
@@ -243,12 +279,14 @@ function entryNode(doc: Document, entry: EcosystemDocEntry, selfKey: string): HT
243
279
  self.textContent = entry.label;
244
280
  return self;
245
281
  }
282
+
246
283
  if (entry.href !== null) {
247
284
  const link = doc.createElement("a");
248
285
  link.href = entry.href;
249
286
  link.textContent = entry.label;
250
287
  return link;
251
288
  }
289
+
252
290
  const pending = doc.createElement("span");
253
291
  pending.className = "footer-sitemap-pending";
254
292
  pending.textContent = entry.label;
@@ -272,13 +310,31 @@ function safeStorage(view: Window | null): Storage | null {
272
310
  it has no claim() guard, so a caller that mounts it twice on the
273
311
  same document runs the fetch and the swap twice; every known
274
312
  caller mounts it once. */
275
- export async function mountEcosystem(root: Document, opts: EcosystemOptions): Promise<void> {
276
- const { endpoint, selfKey, limit = DEFAULT_LIMIT, retryDelaysMs, cacheKey = CACHE_KEY } = opts;
313
+ export async function mountEcosystem(
314
+ root: Document,
315
+ opts: EcosystemOptions,
316
+ ): Promise<void> {
317
+ const {
318
+ endpoint,
319
+ selfKey,
320
+ limit = DEFAULT_LIMIT,
321
+ retryDelaysMs,
322
+ cacheKey = CACHE_KEY,
323
+ } = opts;
324
+
277
325
  const list = root.querySelector<HTMLElement>("[data-ecosystem]");
278
326
  if (!list) return;
279
327
 
280
328
  const storage = safeStorage(root.defaultView);
281
- const document_ = await loadDocument(endpoint, storage, Date.now(), retryDelaysMs, cacheKey);
329
+
330
+ const document_ = await loadDocument(
331
+ endpoint,
332
+ storage,
333
+ Date.now(),
334
+ retryDelaysMs,
335
+ cacheKey,
336
+ );
337
+
282
338
  if (!document_) return;
283
339
 
284
340
  const entries = sortEntries(document_.entries, selfKey, limit);
@@ -286,15 +342,19 @@ export async function mountEcosystem(root: Document, opts: EcosystemOptions): Pr
286
342
 
287
343
  const scope = scopeOf(list);
288
344
  const fragment = root.createDocumentFragment();
345
+
289
346
  for (const entry of entries) {
290
347
  const item = root.createElement("li");
291
348
  const node = entryNode(root, entry, selfKey);
349
+
292
350
  if (scope !== null) {
293
351
  item.setAttribute(scope, "");
294
352
  node.setAttribute(scope, "");
295
353
  }
354
+
296
355
  item.append(node);
297
356
  fragment.append(item);
298
357
  }
358
+
299
359
  list.replaceChildren(fragment);
300
360
  }
@@ -15,9 +15,13 @@ export interface FocusModeOptions {
15
15
  target?: EventTarget;
16
16
  }
17
17
 
18
- export const mountFocusMode: Island<FocusModeOptions> = (root, options = {}): IslandHandle => {
18
+ export const mountFocusMode: Island<FocusModeOptions> = (
19
+ root,
20
+ options = {},
21
+ ): IslandHandle => {
19
22
  const el = root as HTMLElement;
20
23
  const target = options.target ?? window;
24
+
21
25
  if (!claim(el, "focus-mode")) {
22
26
  return {
23
27
  destroy(): void {
@@ -26,8 +30,15 @@ export const mountFocusMode: Island<FocusModeOptions> = (root, options = {}): Is
26
30
  },
27
31
  };
28
32
  }
29
- const onKeydown = (): void => { el.dataset.focus = "keyboard"; };
30
- const onPointerdown = (): void => { el.dataset.focus = "pointer"; };
33
+
34
+ const onKeydown = (): void => {
35
+ el.dataset.focus = "keyboard";
36
+ };
37
+
38
+ const onPointerdown = (): void => {
39
+ el.dataset.focus = "pointer";
40
+ };
41
+
31
42
  target.addEventListener("keydown", onKeydown, true);
32
43
  target.addEventListener("pointerdown", onPointerdown, true);
33
44