@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
|
@@ -1,25 +1,31 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
2
|
+
interface Entry {
|
|
3
|
+
role: string;
|
|
4
|
+
hex?: string;
|
|
5
|
+
token?: string;
|
|
6
|
+
note?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
entries: Entry[];
|
|
11
|
+
class?: string;
|
|
12
|
+
label?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
14
15
|
const { entries, class: className, label = "Palette" } = Astro.props;
|
|
15
16
|
---
|
|
17
|
+
|
|
16
18
|
{/* The scroll box is a tab stop with a name: below the column's width
|
|
17
19
|
the table scrolls sideways, and a scrolling region a keyboard cannot
|
|
18
20
|
reach fails WCAG 2.1.1 (axe scrollable-region-focusable). A named
|
|
19
21
|
<section> is the region landmark natively; a div with role=region
|
|
20
22
|
fails html-validate's prefer-native-element (the blog's gate caught
|
|
21
23
|
it at the 0.4.0 pin bump). */}
|
|
22
|
-
<section
|
|
24
|
+
<section
|
|
25
|
+
class:list={["table-scroll", className]}
|
|
26
|
+
tabindex="0"
|
|
27
|
+
aria-label={label}
|
|
28
|
+
>
|
|
23
29
|
<table class="palette">
|
|
24
30
|
<thead>
|
|
25
31
|
<tr>
|
|
@@ -30,14 +36,24 @@ const { entries, class: className, label = "Palette" } = Astro.props;
|
|
|
30
36
|
</tr>
|
|
31
37
|
</thead>
|
|
32
38
|
<tbody>
|
|
33
|
-
{entries.map((e) =>
|
|
34
|
-
<
|
|
35
|
-
<
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
{entries.map((e) => <tr>
|
|
40
|
+
<td>
|
|
41
|
+
<span
|
|
42
|
+
class="palette-chip"
|
|
43
|
+
aria-hidden="true"
|
|
44
|
+
style={`background-color:${e.token ? `var(${e.token})` : e.hex}`}
|
|
45
|
+
></span>
|
|
46
|
+
</td>
|
|
47
|
+
<td>{e.role}</td>
|
|
48
|
+
<td>
|
|
49
|
+
{e.token ? (
|
|
50
|
+
<code data-token-hex={e.token}></code>
|
|
51
|
+
) : (
|
|
52
|
+
<code>{e.hex}</code>
|
|
53
|
+
)}
|
|
54
|
+
</td>
|
|
55
|
+
<td>{e.note}</td>
|
|
56
|
+
</tr>)}
|
|
41
57
|
</tbody>
|
|
42
58
|
</table>
|
|
43
59
|
</section>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { resolvePostHref, type Linkable } from "../../lib/drafts";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
slug: string;
|
|
6
|
+
posts: Linkable[];
|
|
7
|
+
tip?: string;
|
|
8
|
+
class?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const { slug, posts, tip, class: className } = Astro.props;
|
|
12
|
+
const href = resolvePostHref(slug, posts);
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
<a href={href} data-tooltip={tip} class={className}><slot /></a>
|
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
already the site's separator (post titles). */
|
|
9
|
-
interface Props { by?: string; when?: string; class?: string }
|
|
2
|
+
interface Props {
|
|
3
|
+
by?: string;
|
|
4
|
+
when?: string;
|
|
5
|
+
class?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
10
8
|
const { by, when, class: className } = Astro.props;
|
|
11
9
|
const attribution = [by, when].filter(Boolean).join(", ");
|
|
12
10
|
---
|
|
11
|
+
|
|
13
12
|
<blockquote class:list={[className]}>
|
|
14
13
|
<slot />
|
|
15
|
-
{attribution && <footer class="quote-attribution caption"
|
|
14
|
+
{attribution && <footer class="quote-attribution caption">
|
|
15
|
+
~ {attribution}
|
|
16
|
+
</footer>}
|
|
16
17
|
</blockquote>
|
|
17
18
|
|
|
18
19
|
<style>
|
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
---
|
|
2
|
-
interface Props {
|
|
2
|
+
interface Props {
|
|
3
|
+
size?: number;
|
|
4
|
+
class?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
3
7
|
const { size = 40, class: className } = Astro.props;
|
|
4
8
|
---
|
|
5
|
-
|
|
9
|
+
|
|
10
|
+
<div
|
|
11
|
+
aria-hidden="true"
|
|
12
|
+
style={`height:${size}px`}
|
|
13
|
+
class:list={["spacer", className]}
|
|
14
|
+
></div>
|
|
@@ -3,36 +3,49 @@ import type { ImageMetadata } from "astro";
|
|
|
3
3
|
import SmartImage from "../SmartImage.astro";
|
|
4
4
|
import LightboxLink from "../LightboxLink.astro";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
type Props = ({ src: ImageMetadata; images?: never } | { src?: never; images: Photo[] }) & {
|
|
6
|
+
interface Photo {
|
|
7
|
+
src: ImageMetadata;
|
|
8
|
+
alt?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type Props = (
|
|
12
|
+
{ src: ImageMetadata; images?: never } | { src?: never; images: Photo[] }
|
|
13
|
+
) & {
|
|
15
14
|
alt?: string;
|
|
16
15
|
class?: string;
|
|
17
16
|
};
|
|
17
|
+
|
|
18
18
|
const { src, alt = "", images, class: className } = Astro.props;
|
|
19
19
|
|
|
20
20
|
const hasSingle = src !== undefined;
|
|
21
21
|
const hasMany = images !== undefined && images.length > 0;
|
|
22
|
+
|
|
22
23
|
if (hasSingle === hasMany) {
|
|
23
|
-
throw new Error(
|
|
24
|
+
throw new Error(
|
|
25
|
+
"Step: pass exactly one of `src` (with `alt`) or a non-empty `images` array",
|
|
26
|
+
);
|
|
24
27
|
}
|
|
28
|
+
|
|
25
29
|
const photos: Photo[] = hasMany ? images : [{ src: src as ImageMetadata, alt }];
|
|
26
30
|
---
|
|
31
|
+
|
|
27
32
|
<li class:list={["step", className]}>
|
|
28
33
|
<div class="step-media">
|
|
29
34
|
{photos.map((p) => {
|
|
30
35
|
const a = p.alt ?? "";
|
|
31
|
-
return
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
return <LightboxLink
|
|
37
|
+
href={p.src.src}
|
|
38
|
+
w={p.src.width}
|
|
39
|
+
h={p.src.height}
|
|
40
|
+
caption={a}
|
|
41
|
+
>
|
|
42
|
+
<SmartImage
|
|
43
|
+
src={p.src}
|
|
44
|
+
alt={a}
|
|
45
|
+
widths={[200, Math.min(p.src.width, 400)]}
|
|
46
|
+
sizes="200px"
|
|
47
|
+
/>
|
|
48
|
+
</LightboxLink>;
|
|
36
49
|
})}
|
|
37
50
|
</div>
|
|
38
51
|
<div class="step-body"><slot /></div>
|
|
@@ -44,11 +57,13 @@ const photos: Photo[] = hasMany ? images : [{ src: src as ImageMetadata, alt }];
|
|
|
44
57
|
grid-template-columns: auto 1fr;
|
|
45
58
|
gap: 20px;
|
|
46
59
|
align-items: start;
|
|
60
|
+
|
|
47
61
|
/* prose.css pads every .prose li; rows space themselves via
|
|
48
62
|
the Walkthrough gap instead. */
|
|
49
63
|
padding: 0;
|
|
50
64
|
counter-increment: walkthrough-step;
|
|
51
65
|
}
|
|
66
|
+
|
|
52
67
|
/* Photos sit in a row, one 200px track each, so a two-photo step's cell
|
|
53
68
|
is 410px and its prose starts further right (owner pick M3). The row
|
|
54
69
|
does not wrap, so two photos is the practical cap for the 990px
|
|
@@ -61,8 +76,10 @@ const photos: Photo[] = hasMany ? images : [{ src: src as ImageMetadata, alt }];
|
|
|
61
76
|
gap: 10px;
|
|
62
77
|
line-height: 0;
|
|
63
78
|
}
|
|
79
|
+
|
|
64
80
|
/* The 2px outline comes from .prose img in prose.css. */
|
|
65
81
|
.step-media :global(img) { width: 100%; height: auto; }
|
|
82
|
+
|
|
66
83
|
/* The body is 1fr of the layout's column; nothing here narrows it. */
|
|
67
84
|
.step-body { min-width: 0; }
|
|
68
85
|
.step-body :global(p:last-child) { margin-bottom: 0; }
|
|
@@ -78,6 +95,7 @@ const photos: Photo[] = hasMany ? images : [{ src: src as ImageMetadata, alt }];
|
|
|
78
95
|
/* stylelint-disable-next-line no-duplicate-selectors -- split from the
|
|
79
96
|
grid rule above so the contract stays grouped with its own comment. */
|
|
80
97
|
.step-media { margin: var(--step-media-margin, 0); }
|
|
98
|
+
|
|
81
99
|
.step-media::before {
|
|
82
100
|
content: counter(walkthrough-step);
|
|
83
101
|
display: var(--step-chip-display, none);
|
|
@@ -115,6 +133,7 @@ const photos: Photo[] = hasMany ? images : [{ src: src as ImageMetadata, alt }];
|
|
|
115
133
|
of a multi-photo step stacking too. Same breakpoint as MediaText. */
|
|
116
134
|
@media (max-width: 600px) {
|
|
117
135
|
.step { grid-template-columns: 1fr; gap: 10px; }
|
|
136
|
+
|
|
118
137
|
.step-media {
|
|
119
138
|
grid-auto-flow: row;
|
|
120
139
|
grid-auto-columns: auto;
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
interface Props {
|
|
3
|
+
numbered?: boolean;
|
|
4
|
+
class?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
7
|
const { numbered = false, class: className } = Astro.props;
|
|
8
8
|
---
|
|
9
|
+
|
|
9
10
|
{/* list-style: none drops the implicit list role in Safari/VoiceOver, so
|
|
10
11
|
role="list" restores it. html-validate's no-redundant-role and
|
|
11
12
|
prefer-native-element both flag that as unnecessary on a native <ol>,
|
|
12
13
|
which is true generically but wrong for this specific case; disabled
|
|
13
14
|
surgically for this element only. */}
|
|
14
15
|
<!-- [html-validate-disable-next no-redundant-role, prefer-native-element] -->
|
|
15
|
-
<ol
|
|
16
|
+
<ol
|
|
17
|
+
class:list={["walkthrough", numbered && "walkthrough-numbered", className]}
|
|
18
|
+
role="list"
|
|
19
|
+
>
|
|
16
20
|
<slot />
|
|
17
21
|
</ol>
|
|
18
22
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { forwardLinkVisible, type ForwardLinkable } from "../../lib/drafts";
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
slug: string;
|
|
6
|
+
posts: ForwardLinkable[];
|
|
7
|
+
showDrafts?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { slug, posts, showDrafts = false } = Astro.props;
|
|
11
|
+
const visible = forwardLinkVisible(slug, posts, showDrafts);
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
{visible && <slot />}
|
package/src/components/models.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
CollectionEntry mapping that produces them is src/lib/view-models.ts,
|
|
5
5
|
which stays site-side. */
|
|
6
6
|
import type { ImageMetadata } from "astro";
|
|
7
|
+
import type { Badge } from "../scripts/core/badges";
|
|
7
8
|
|
|
8
9
|
export interface PostCardModel {
|
|
9
10
|
href: string;
|
|
@@ -15,8 +16,7 @@ export interface PostCardModel {
|
|
|
15
16
|
hero: ImageMetadata;
|
|
16
17
|
heroPosition?: string;
|
|
17
18
|
draft?: boolean;
|
|
18
|
-
|
|
19
|
-
demo?: boolean;
|
|
19
|
+
badges?: Badge[];
|
|
20
20
|
categories: { name: string; href: string }[];
|
|
21
21
|
}
|
|
22
22
|
|
package/src/lib/archive.ts
CHANGED
|
@@ -1,28 +1,42 @@
|
|
|
1
1
|
import { slugifyCategory } from "./slug";
|
|
2
2
|
|
|
3
|
-
interface PostLike {
|
|
3
|
+
interface PostLike {
|
|
4
|
+
data: { date: Date; categories: string[] };
|
|
5
|
+
}
|
|
4
6
|
|
|
5
7
|
export function groupByMonth(posts: PostLike[], locale = "en-US") {
|
|
6
8
|
const map = new Map<string, number>();
|
|
9
|
+
|
|
7
10
|
for (const p of posts) {
|
|
8
11
|
const d = p.data.date;
|
|
9
12
|
const key = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}`;
|
|
10
13
|
map.set(key, (map.get(key) ?? 0) + 1);
|
|
11
14
|
}
|
|
15
|
+
|
|
12
16
|
return [...map.entries()]
|
|
13
17
|
.sort((a, b) => (a[0] < b[0] ? 1 : -1))
|
|
14
18
|
.map(([key, count]) => {
|
|
15
19
|
const [year, month] = key.split("-");
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
|
|
21
|
+
const label = new Date(
|
|
22
|
+
Date.UTC(Number(year), Number(month) - 1),
|
|
23
|
+
).toLocaleDateString(locale, {
|
|
24
|
+
month: "long",
|
|
25
|
+
year: "numeric",
|
|
26
|
+
timeZone: "UTC",
|
|
18
27
|
});
|
|
28
|
+
|
|
19
29
|
return { year, month, label, count };
|
|
20
30
|
});
|
|
21
31
|
}
|
|
22
32
|
|
|
23
33
|
export function countByCategory(posts: PostLike[]) {
|
|
24
34
|
const map = new Map<string, number>();
|
|
25
|
-
|
|
35
|
+
|
|
36
|
+
for (const p of posts) {
|
|
37
|
+
for (const c of p.data.categories) map.set(c, (map.get(c) ?? 0) + 1);
|
|
38
|
+
}
|
|
39
|
+
|
|
26
40
|
return [...map.entries()]
|
|
27
41
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
|
28
42
|
.map(([name, count]) => ({ name, slug: slugifyCategory(name), count }));
|
package/src/lib/drafts.ts
CHANGED
|
@@ -6,9 +6,14 @@
|
|
|
6
6
|
this filter; a call site reaching for getCollection("posts") directly
|
|
7
7
|
would leak drafts into whatever it renders. */
|
|
8
8
|
|
|
9
|
-
export interface Draftable {
|
|
9
|
+
export interface Draftable {
|
|
10
|
+
data: { draft?: boolean };
|
|
11
|
+
}
|
|
10
12
|
|
|
11
|
-
export function visiblePosts<T extends Draftable>(
|
|
13
|
+
export function visiblePosts<T extends Draftable>(
|
|
14
|
+
posts: T[],
|
|
15
|
+
showDrafts: boolean,
|
|
16
|
+
): T[] {
|
|
12
17
|
return showDrafts ? posts : posts.filter((p) => !p.data.draft);
|
|
13
18
|
}
|
|
14
19
|
|
|
@@ -21,10 +26,14 @@ export function visiblePosts<T extends Draftable>(posts: T[], showDrafts: boolea
|
|
|
21
26
|
a build error rather than a silently hidden paragraph: a typo must not
|
|
22
27
|
look like "still a draft". */
|
|
23
28
|
|
|
24
|
-
export interface ForwardLinkable extends Draftable {
|
|
29
|
+
export interface ForwardLinkable extends Draftable {
|
|
30
|
+
data: { draft?: boolean; slug: string };
|
|
31
|
+
}
|
|
25
32
|
|
|
26
33
|
export function forwardLinkVisible(
|
|
27
|
-
slug: string,
|
|
34
|
+
slug: string,
|
|
35
|
+
posts: ForwardLinkable[],
|
|
36
|
+
showDrafts: boolean,
|
|
28
37
|
): boolean {
|
|
29
38
|
const target = posts.find((p) => p.data.slug === slug);
|
|
30
39
|
if (!target) throw new Error(`WhenPublished: no post has slug "${slug}"`);
|
|
@@ -43,7 +52,9 @@ export function forwardLinkVisible(
|
|
|
43
52
|
|
|
44
53
|
import { postPath } from "./slug";
|
|
45
54
|
|
|
46
|
-
export interface Linkable {
|
|
55
|
+
export interface Linkable {
|
|
56
|
+
data: { slug: string; date: Date };
|
|
57
|
+
}
|
|
47
58
|
|
|
48
59
|
export function resolvePostHref(slug: string, posts: Linkable[]): string {
|
|
49
60
|
const target = posts.find((p) => p.data.slug === slug);
|
package/src/lib/format-date.ts
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
house default. */
|
|
5
5
|
export function formatPostDate(d: Date, locale = "en-US"): string {
|
|
6
6
|
return d.toLocaleDateString(locale, {
|
|
7
|
-
year: "numeric",
|
|
7
|
+
year: "numeric",
|
|
8
|
+
month: "long",
|
|
9
|
+
day: "numeric",
|
|
10
|
+
timeZone: "UTC",
|
|
8
11
|
});
|
|
9
12
|
}
|
package/src/lib/ordering.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
through these instead of picking fields ad hoc: hand-picked fields are how
|
|
6
6
|
the same-day ordering bug shipped twice (audit A1/A2). */
|
|
7
7
|
|
|
8
|
-
export interface Publishable {
|
|
8
|
+
export interface Publishable {
|
|
9
|
+
data: { date: Date; published?: Date };
|
|
10
|
+
}
|
|
9
11
|
|
|
10
12
|
export function publishedAt(p: Publishable): Date {
|
|
11
13
|
return p.data.published ?? p.data.date;
|
package/src/lib/paginate.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
export function paginatePosts<T>(items: T[], size: number): T[][] {
|
|
2
2
|
if (items.length === 0) return [[]];
|
|
3
3
|
const pages: T[][] = [];
|
|
4
|
-
|
|
4
|
+
|
|
5
|
+
for (let i = 0; i < items.length; i += size) {
|
|
6
|
+
pages.push(items.slice(i, i + size));
|
|
7
|
+
}
|
|
8
|
+
|
|
5
9
|
return pages;
|
|
6
10
|
}
|
|
7
11
|
|
package/src/lib/slug.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
export function slugifyCategory(name: string): string {
|
|
2
|
-
return name
|
|
2
|
+
return name
|
|
3
|
+
.toLowerCase()
|
|
4
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
5
|
+
.replace(/^-|-$/g, "");
|
|
3
6
|
}
|
|
4
7
|
|
|
5
8
|
/* The WP-compatible date permalink is the library's documented opinion
|
|
@@ -22,8 +25,12 @@ export function categoryPath(name: string, base = "/category"): string {
|
|
|
22
25
|
/* Canonical href for a static page. One level of nesting exists today
|
|
23
26
|
(children of policies); parent chains deeper than one are not modeled
|
|
24
27
|
anywhere, so this stays flat on purpose. */
|
|
25
|
-
export function pagePath(p: {
|
|
26
|
-
|
|
28
|
+
export function pagePath(p: {
|
|
29
|
+
data: { slug: string; parent?: string };
|
|
30
|
+
}): string {
|
|
31
|
+
return p.data.parent
|
|
32
|
+
? `/${p.data.parent}/${p.data.slug}/`
|
|
33
|
+
: `/${p.data.slug}/`;
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
/* Canonical lookup key for an internal href: root-relative, fragment
|
|
@@ -31,14 +38,19 @@ export function pagePath(p: { data: { slug: string; parent?: string } }): string
|
|
|
31
38
|
site's own origins reduces to its path; anything else external is
|
|
32
39
|
null, as is a file-ish path (a dot in the last segment, /feed.xml),
|
|
33
40
|
which names a document with no page title to reveal. */
|
|
34
|
-
export function internalHrefKey(
|
|
41
|
+
export function internalHrefKey(
|
|
42
|
+
href: string,
|
|
43
|
+
origins: readonly string[] = [],
|
|
44
|
+
): string | null {
|
|
35
45
|
let h = href;
|
|
46
|
+
|
|
36
47
|
for (const o of origins) {
|
|
37
48
|
if (h === o || h.startsWith(`${o}/`)) {
|
|
38
49
|
h = h.slice(o.length) || "/";
|
|
39
50
|
break;
|
|
40
51
|
}
|
|
41
52
|
}
|
|
53
|
+
|
|
42
54
|
if (!h.startsWith("/") || h.startsWith("//")) return null;
|
|
43
55
|
h = h.replace(/[#?].*$/, "");
|
|
44
56
|
if (h === "") return "/";
|
|
@@ -50,7 +62,12 @@ export function internalHrefKey(href: string, origins: readonly string[] = []):
|
|
|
50
62
|
/* Top-level path segments owned by real routes. A page slugged one of these
|
|
51
63
|
(or a bare year) would shadow the archive, pagination, category, or search
|
|
52
64
|
trees under the [...slug] catch-all without any build error. */
|
|
53
|
-
export const RESERVED_PAGE_SLUGS = new Set([
|
|
65
|
+
export const RESERVED_PAGE_SLUGS = new Set([
|
|
66
|
+
"page",
|
|
67
|
+
"category",
|
|
68
|
+
"search",
|
|
69
|
+
"404",
|
|
70
|
+
]);
|
|
54
71
|
|
|
55
72
|
/* Build-time guard for the [...slug] catch-all: rejects reserved top-level
|
|
56
73
|
slugs and parent references that resolve to nothing (a typo'd parent would
|
|
@@ -67,7 +84,13 @@ export function assertPageRoutable(
|
|
|
67
84
|
`Page "${p.data.slug}" declares parent "${p.data.parent}", which matches no page slug`,
|
|
68
85
|
);
|
|
69
86
|
}
|
|
70
|
-
|
|
71
|
-
|
|
87
|
+
|
|
88
|
+
if (
|
|
89
|
+
!p.data.parent &&
|
|
90
|
+
(reserved.has(p.data.slug) || /^\d{4}$/.test(p.data.slug))
|
|
91
|
+
) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Page slug "${p.data.slug}" collides with a reserved route`,
|
|
94
|
+
);
|
|
72
95
|
}
|
|
73
96
|
}
|
|
@@ -13,34 +13,53 @@ export interface CodeIslandOptions {
|
|
|
13
13
|
resetMs?: number;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
export const mountCodeIslands: Island<CodeIslandOptions> = (
|
|
16
|
+
export const mountCodeIslands: Island<CodeIslandOptions> = (
|
|
17
|
+
root,
|
|
18
|
+
options = {},
|
|
19
|
+
): IslandHandle => {
|
|
17
20
|
const {
|
|
18
21
|
selector = ".prose pre.astro-code",
|
|
19
22
|
copy = { copy: "COPY", copied: "COPIED", failed: "FAILED" },
|
|
20
23
|
resetMs = 1500,
|
|
21
24
|
} = options;
|
|
25
|
+
|
|
22
26
|
const doc = docOf(root);
|
|
27
|
+
|
|
23
28
|
/* resetTimer holds the copy-reset setTimeout id, one live per button at
|
|
24
29
|
most (a second click before the first reset overwrites it, dropping
|
|
25
30
|
the earlier timer's reference so it can no longer be cleared, which
|
|
26
31
|
is why doCopy clears the box before replacing it). A plain mutable
|
|
27
32
|
box, not a field on the mounted entry, so both doCopy and destroy()
|
|
28
33
|
close over the same cell. */
|
|
29
|
-
const mounted: {
|
|
34
|
+
const mounted: {
|
|
35
|
+
pre: Element;
|
|
36
|
+
bar: HTMLDivElement;
|
|
37
|
+
btn: HTMLButtonElement;
|
|
38
|
+
onClick: () => void;
|
|
39
|
+
resetTimer: { id: ReturnType<typeof setTimeout> | undefined };
|
|
40
|
+
}[] = [];
|
|
30
41
|
|
|
31
42
|
for (const pre of root.querySelectorAll(selector)) {
|
|
32
43
|
if (!claim(pre, "code")) continue;
|
|
33
44
|
const bar = doc.createElement("div");
|
|
34
45
|
bar.className = "code-island-bar";
|
|
35
46
|
const label = doc.createElement("span");
|
|
36
|
-
|
|
47
|
+
|
|
48
|
+
const file = pre
|
|
49
|
+
.closest("[data-code-filename]")
|
|
50
|
+
?.getAttribute("data-code-filename");
|
|
51
|
+
|
|
37
52
|
const lang = pre.getAttribute("data-language") ?? "";
|
|
38
53
|
label.textContent = [file, lang].filter(Boolean).join(" · ");
|
|
39
54
|
const btn = doc.createElement("button");
|
|
40
55
|
btn.className = "code-copy";
|
|
41
56
|
btn.type = "button";
|
|
42
57
|
btn.textContent = copy.copy;
|
|
43
|
-
|
|
58
|
+
|
|
59
|
+
const resetTimer: { id: ReturnType<typeof setTimeout> | undefined } = {
|
|
60
|
+
id: undefined,
|
|
61
|
+
};
|
|
62
|
+
|
|
44
63
|
const doCopy = async (): Promise<void> => {
|
|
45
64
|
try {
|
|
46
65
|
await navigator.clipboard.writeText(pre.textContent);
|
|
@@ -48,14 +67,18 @@ export const mountCodeIslands: Island<CodeIslandOptions> = (root, options = {}):
|
|
|
48
67
|
} catch {
|
|
49
68
|
btn.textContent = copy.failed;
|
|
50
69
|
}
|
|
70
|
+
|
|
51
71
|
clearTimeout(resetTimer.id);
|
|
72
|
+
|
|
52
73
|
resetTimer.id = setTimeout(() => {
|
|
53
74
|
btn.textContent = copy.copy;
|
|
54
75
|
}, resetMs);
|
|
55
76
|
};
|
|
77
|
+
|
|
56
78
|
const onClick = (): void => {
|
|
57
79
|
void doCopy();
|
|
58
80
|
};
|
|
81
|
+
|
|
59
82
|
btn.addEventListener("click", onClick);
|
|
60
83
|
bar.append(label, btn);
|
|
61
84
|
pre.before(bar);
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/* A corner badge is data the caller hands to CornerBadges.astro: a key
|
|
2
|
+
(which becomes the chip's badge-<key> class, the styling hook), the
|
|
3
|
+
label, and a glyph from the icon registry. No lookup table in the
|
|
4
|
+
package (owner call 2026-09-10): a site's view-model lists the
|
|
5
|
+
badges a post carries, in the order it wants them, and can invent one
|
|
6
|
+
without a package release. The two house conventions ship as
|
|
7
|
+
constants so every site spells them the same way. */
|
|
8
|
+
import type { IconName } from "./icons";
|
|
9
|
+
|
|
10
|
+
export interface Badge {
|
|
11
|
+
key: string;
|
|
12
|
+
label: string;
|
|
13
|
+
icon: IconName;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/* The pixels were generated (a hero, a content image). */
|
|
17
|
+
export const GENAI_BADGE: Badge = {
|
|
18
|
+
key: "genai",
|
|
19
|
+
label: "AI Art",
|
|
20
|
+
icon: "sparkles",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/* The post carries an interactive demo. */
|
|
24
|
+
export const DEMO_BADGE: Badge = {
|
|
25
|
+
key: "demo",
|
|
26
|
+
label: "Demo",
|
|
27
|
+
icon: "play",
|
|
28
|
+
};
|
package/src/scripts/core/dom.ts
CHANGED
|
@@ -14,7 +14,12 @@ export function el<K extends keyof HTMLElementTagNameMap>(
|
|
|
14
14
|
return node;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export function iconButton(
|
|
17
|
+
export function iconButton(
|
|
18
|
+
doc: Document,
|
|
19
|
+
className: string,
|
|
20
|
+
label: string,
|
|
21
|
+
svg: string,
|
|
22
|
+
): HTMLButtonElement {
|
|
18
23
|
const btn = doc.createElement("button");
|
|
19
24
|
btn.type = "button";
|
|
20
25
|
btn.className = className;
|
|
@@ -23,7 +23,10 @@ export function createFrameLoop(
|
|
|
23
23
|
|
|
24
24
|
function tick(now: number): void {
|
|
25
25
|
if (!live) return;
|
|
26
|
-
|
|
26
|
+
|
|
27
|
+
const elapsed = Math.max(0, (now - last) / 1000);
|
|
28
|
+
const dt = last === 0 ? firstDt : Math.min(clamp, elapsed);
|
|
29
|
+
|
|
27
30
|
last = now;
|
|
28
31
|
cb(dt);
|
|
29
32
|
// cb may have called stop() reentrantly; a fresh function body reads live without stale narrowing.
|