@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.
- package/README.md +50 -2
- package/package.json +11 -4
- package/src/components/CategoryCard.astro +9 -1
- package/src/components/CornerBadges.astro +14 -21
- package/src/components/Footer.astro +57 -29
- package/src/components/Icon.astro +16 -0
- package/src/components/LightboxLink.astro +19 -10
- package/src/components/LinkListWidget.astro +11 -9
- package/src/components/Pagination.astro +51 -28
- package/src/components/PostCard.astro +54 -22
- package/src/components/PostNavigation.astro +40 -29
- package/src/components/Shell.astro +26 -12
- package/src/components/SiteHeader.astro +139 -80
- package/src/components/SmartImage.astro +48 -17
- package/src/components/Subscribe.astro +60 -35
- package/src/components/ThemeToggle.astro +28 -15
- package/src/components/TwoColumn.astro +5 -5
- package/src/components/Widget.astro +10 -7
- package/src/components/content/BlogImage.astro +48 -13
- package/src/components/content/Button.astro +13 -13
- package/src/components/content/Callout.astro +17 -8
- package/src/components/content/CodeBlock.astro +6 -1
- package/src/components/content/EditorNote.astro +19 -21
- package/src/components/content/Gallery.astro +18 -5
- package/src/components/content/GalleryImage.astro +26 -7
- package/src/components/content/Group.astro +5 -6
- package/src/components/content/MediaText.astro +43 -9
- package/src/components/content/Palette.astro +37 -21
- package/src/components/content/PostLink.astro +15 -0
- package/src/components/content/Quote.astro +10 -9
- package/src/components/content/Spacer.astro +11 -2
- package/src/components/content/Step.astro +34 -15
- package/src/components/content/Walkthrough.astro +10 -6
- package/src/components/content/WhenPublished.astro +14 -0
- package/src/components/models.ts +2 -2
- package/src/lib/archive.ts +18 -4
- package/src/lib/drafts.ts +16 -5
- package/src/lib/format-date.ts +4 -1
- package/src/lib/ordering.ts +3 -1
- package/src/lib/paginate.ts +5 -1
- package/src/lib/slug.ts +30 -7
- package/src/scripts/code-island.ts +27 -4
- package/src/scripts/core/badges.ts +28 -0
- package/src/scripts/core/dom.ts +6 -1
- package/src/scripts/core/frame-loop.ts +4 -1
- package/src/scripts/core/icons.ts +92 -15
- package/src/scripts/ecosystem.ts +72 -12
- package/src/scripts/focus-mode.ts +14 -3
- package/src/scripts/lightbox.ts +227 -41
- package/src/scripts/link-tip.ts +55 -11
- package/src/scripts/path-player-math.ts +11 -2
- package/src/scripts/path-player-paint.ts +54 -8
- package/src/scripts/path-player.ts +131 -29
- package/src/scripts/plate-modal.ts +45 -9
- package/src/scripts/scroll-top.ts +6 -1
- package/src/scripts/site-header.ts +34 -6
- package/src/scripts/subscribe.ts +50 -9
- package/src/scripts/theme-toggle.ts +32 -5
- package/src/shiki/code-theme.mjs +25 -5
- 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
|
-
|
|
16
|
-
|
|
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 =
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
export const
|
|
22
|
-
|
|
23
|
-
export const
|
|
24
|
-
export const
|
|
25
|
-
export const
|
|
26
|
-
export const
|
|
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");
|
package/src/scripts/ecosystem.ts
CHANGED
|
@@ -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" &&
|
|
38
|
-
|
|
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" &&
|
|
46
|
-
|
|
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
|
-
|
|
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, {
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
276
|
-
|
|
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
|
-
|
|
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> = (
|
|
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
|
-
|
|
30
|
-
const
|
|
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
|
|