@half-built/astro 0.10.0 → 0.11.1
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 +100 -4
- package/package.json +1 -1
- package/src/components/Footer.astro +9 -0
- package/src/components/LightboxLink.astro +13 -2
- package/src/components/Popout.astro +33 -0
- package/src/components/PostCard.astro +49 -21
- package/src/components/Shell.astro +1 -1
- package/src/components/ThemeToggle.astro +0 -1
- package/src/components/TwoColumn.astro +4 -2
- package/src/components/content/BlogImage.astro +1 -2
- package/src/components/models.ts +11 -10
- package/src/lib/excerpt.ts +2 -2
- package/src/lib/format-date.ts +3 -3
- package/src/lib/header-date.ts +1 -1
- package/src/lib/ordering.ts +5 -4
- package/src/scripts/code-island.ts +49 -18
- package/src/scripts/core/frame-loop.ts +26 -11
- package/src/scripts/core/icons.ts +1 -2
- package/src/scripts/core/placement.ts +67 -0
- package/src/scripts/core/storage.ts +2 -1
- package/src/scripts/ecosystem.ts +18 -10
- package/src/scripts/focus-mode.ts +34 -2
- package/src/scripts/lightbox.ts +69 -17
- package/src/scripts/link-tip.ts +5 -66
- package/src/scripts/path-player.ts +24 -10
- package/src/scripts/plate-modal.ts +17 -6
- package/src/scripts/popout.ts +462 -0
- package/src/scripts/scroll-top.ts +2 -2
- package/src/scripts/site-header.ts +8 -6
- package/src/scripts/subscribe.ts +1 -2
- package/src/scripts/theme-toggle.ts +22 -10
- package/src/shiki/code-theme.mjs +2 -1
- package/src/shiki/code-vars.mjs +24 -2
package/README.md
CHANGED
|
@@ -43,8 +43,9 @@ The two house conventions ship as `GENAI_BADGE` and `DEMO_BADGE`; a
|
|
|
43
43
|
site's view-model lists the badges a post carries, in order, and can
|
|
44
44
|
add its own without a package change. `PostCardModel.badges` carries
|
|
45
45
|
them to the card. The content components (`BlogImage`, `GalleryImage`,
|
|
46
|
-
`MediaText`)
|
|
47
|
-
`badges`
|
|
46
|
+
`MediaText`) take the same `badges` array, for example
|
|
47
|
+
`badges={[GENAI_BADGE]}` with `GENAI_BADGE` imported from
|
|
48
|
+
`@half-built/astro/scripts/core/badges.ts`.
|
|
48
49
|
|
|
49
50
|
## EditorNote
|
|
50
51
|
|
|
@@ -120,7 +121,9 @@ palette override. The variables resolve to the same hexes the theme
|
|
|
120
121
|
bakes, so adopting the transformer changes no rendered pixel on its
|
|
121
122
|
own. Pass it beside the theme: the `transformers` prop of
|
|
122
123
|
`astro:components`' `Code`, or `markdown.shikiConfig.transformers` in
|
|
123
|
-
an Astro config.
|
|
124
|
+
an Astro config. It also encodes the `>` that Shiki's HTML serializer
|
|
125
|
+
leaves raw in `Code` output, so that output passes the tooling
|
|
126
|
+
html-validate preset.
|
|
124
127
|
|
|
125
128
|
## Palette token entries
|
|
126
129
|
|
|
@@ -140,7 +143,7 @@ JSON document, so adding a property to a family of sites does not mean
|
|
|
140
143
|
rebuilding every one of them.
|
|
141
144
|
|
|
142
145
|
```js
|
|
143
|
-
import { mountEcosystem } from "@half-built/astro/scripts/ecosystem";
|
|
146
|
+
import { mountEcosystem } from "@half-built/astro/scripts/ecosystem.ts";
|
|
144
147
|
|
|
145
148
|
void mountEcosystem(document, {
|
|
146
149
|
endpoint: "https://example.com/ecosystem.json",
|
|
@@ -169,6 +172,12 @@ below the last link, so the page scrolls far enough to clear the
|
|
|
169
172
|
cluster. The token is `0px` by default and can be set inside the same
|
|
170
173
|
media block that pins the cluster.
|
|
171
174
|
|
|
175
|
+
## TwoColumn
|
|
176
|
+
|
|
177
|
+
`components/TwoColumn.astro` renders its main column as `<main>`; pass
|
|
178
|
+
`mainTag="div"` for a demo or nested use where the page already has
|
|
179
|
+
its own main.
|
|
180
|
+
|
|
172
181
|
## Search flyout
|
|
173
182
|
|
|
174
183
|
`scripts/site-header` drives the masthead's search flyout as a
|
|
@@ -179,6 +188,93 @@ marks the wrap `data-search-js`; without it, the stylesheet's
|
|
|
179
188
|
focus-within rule opens the flyout on focus alone, so it still works
|
|
180
189
|
with no script.
|
|
181
190
|
|
|
191
|
+
## Lightbox
|
|
192
|
+
|
|
193
|
+
`scripts/lightbox` opens the images the content components link
|
|
194
|
+
through `LightboxLink` (`BlogImage`, `GalleryImage`, `MediaText`,
|
|
195
|
+
`Step`) in a full-window viewer. Links inside one `Gallery` or
|
|
196
|
+
`Walkthrough` form a set with previous and next buttons, thumbnails,
|
|
197
|
+
and the arrow keys.
|
|
198
|
+
|
|
199
|
+
```js
|
|
200
|
+
import "@half-built/css/plate-modal.css";
|
|
201
|
+
import "@half-built/css/lightbox.css";
|
|
202
|
+
import { mountLightbox } from "@half-built/astro/scripts/lightbox.ts";
|
|
203
|
+
|
|
204
|
+
mountLightbox(document);
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The image opens at fit: contained in the window, very tall images fit
|
|
208
|
+
to width, and small images never scale past 100%. The wheel or a
|
|
209
|
+
trackpad zooms under the pointer in proportion to the scroll; a
|
|
210
|
+
sideways scroll does nothing. `+` and `-` step the zoom, `0` returns to fit, and a drag pans. Once
|
|
211
|
+
the image is panned mostly out of view a HOME box appears that also
|
|
212
|
+
returns to fit. Double-click at the fit view
|
|
213
|
+
zooms to 100% under the cursor; anywhere else it returns to fit.
|
|
214
|
+
|
|
215
|
+
A resize or rotation while the viewer is open refits the image to the
|
|
216
|
+
new window. Any zoom and pan the reader had set is discarded, on
|
|
217
|
+
purpose: the old view was measured against a window that no longer
|
|
218
|
+
exists.
|
|
219
|
+
|
|
220
|
+
`mountLightbox` takes options: `selector` (default `a.lightbox-link`)
|
|
221
|
+
picks the links, `groupSelector` names the containers whose links form
|
|
222
|
+
one set, and `labels` renames the viewer's controls for a site that is
|
|
223
|
+
not in English.
|
|
224
|
+
|
|
225
|
+
## Popout
|
|
226
|
+
|
|
227
|
+
`components/Popout.astro` puts a short note behind a table cell: detail
|
|
228
|
+
worth keeping but not worth a column. It renders a trigger button and a
|
|
229
|
+
template holding its slot; `scripts/popout` opens the note. Like
|
|
230
|
+
link-tip, the mounted island is a document-wide singleton: `root` only
|
|
231
|
+
names the document, and triggers anywhere in it share the one surface.
|
|
232
|
+
|
|
233
|
+
```astro
|
|
234
|
+
<Popout label="Sample row · Sample product">
|
|
235
|
+
A short note with a <a href="#">link</a> and <code>code</code>.
|
|
236
|
+
</Popout>
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
```js
|
|
240
|
+
import "@half-built/css/popout.css";
|
|
241
|
+
import { mountPopouts } from "@half-built/astro/scripts/popout.ts";
|
|
242
|
+
|
|
243
|
+
mountPopouts(document);
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
`label` is required. It captions the open note and names the trigger
|
|
247
|
+
for assistive tech (`<trigger>: <label>`, so `Note: <label>` by
|
|
248
|
+
default), so write it to identify the row. `trigger` changes the button
|
|
249
|
+
text from `Note`. The slot takes inline markup: links, code, emphasis.
|
|
250
|
+
|
|
251
|
+
Above the phone breakpoint the note opens below its trigger (above it
|
|
252
|
+
when there is no room below) and stays until Escape, the close box, a
|
|
253
|
+
press outside, or another trigger. It also closes by itself when its
|
|
254
|
+
trigger scrolls out of view or when keyboard focus leaves it. Shift+Tab
|
|
255
|
+
off its first stop returns to the trigger; Tab off its last stop moves
|
|
256
|
+
on to whatever follows the trigger. On a wide touch device a
|
|
257
|
+
drag that starts outside the note closes it. At phone width it opens as
|
|
258
|
+
a modal bottom sheet that closes on the close box, Escape or Back, a
|
|
259
|
+
tap on the backdrop, or a downward swipe. One note is open at a time.
|
|
260
|
+
With JavaScript off the trigger does nothing.
|
|
261
|
+
|
|
262
|
+
A resize or rotation that crosses the phone breakpoint, in either
|
|
263
|
+
direction, closes an open note instead of turning the box into a sheet
|
|
264
|
+
or the sheet into a box. The reader reopens it and it opens in the mode
|
|
265
|
+
for the new width. Focus is not moved back to the trigger, which may
|
|
266
|
+
have scrolled away in the new layout. Resizes that stay on one side of
|
|
267
|
+
the breakpoint keep the note open: the box re-places itself, and the
|
|
268
|
+
sheet rides out the URL bar collapsing and the keyboard opening.
|
|
269
|
+
|
|
270
|
+
`mountPopouts` takes options: `selector` (default `.popout-trigger`)
|
|
271
|
+
picks the triggers, `edge` sets the anchored note's room against the
|
|
272
|
+
viewport edges in px, and `closeLabel` (default `Close`) names the
|
|
273
|
+
close box for a site that is not in English.
|
|
274
|
+
|
|
275
|
+
Not covered yet: structured content (lists, sub-tables, images),
|
|
276
|
+
triggers on chart marks, and hover previews.
|
|
277
|
+
|
|
182
278
|
## Import notes
|
|
183
279
|
|
|
184
280
|
Wildcard subpath imports need explicit file extensions under
|
package/package.json
CHANGED
|
@@ -193,6 +193,15 @@ const year = new Date().getFullYear();
|
|
|
193
193
|
}
|
|
194
194
|
.footer-sitemap-group { text-align: center; width: 100%; }
|
|
195
195
|
|
|
196
|
+
/* 24px tap targets (WCAG 2.2 target size, 2.5.8). The phone lists
|
|
197
|
+
sat at a 23.3px pitch with 16px link boxes. A 24px line on every
|
|
198
|
+
row keeps the rhythm within a pixel, including the rows that are
|
|
199
|
+
plain text (the current site, pending entries), and the
|
|
200
|
+
inline-block link takes the whole line box as its target
|
|
201
|
+
(spec 2026-09-29-wcag-2-2-design.md). */
|
|
202
|
+
.footer-sitemap-group li { margin: 0; line-height: 24px; }
|
|
203
|
+
.footer-sitemap a { display: inline-block; }
|
|
204
|
+
|
|
196
205
|
.footer-sitemap-collapsible summary {
|
|
197
206
|
display: block;
|
|
198
207
|
position: relative;
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
---
|
|
2
|
-
|
|
2
|
+
import type { HTMLAttributes } from "astro/types";
|
|
3
|
+
|
|
4
|
+
type Owned =
|
|
5
|
+
| "href"
|
|
6
|
+
| "aria-label"
|
|
7
|
+
| "data-lb-w"
|
|
8
|
+
| "data-lb-h"
|
|
9
|
+
| "data-lb-caption"
|
|
10
|
+
| "class"
|
|
11
|
+
| "class:list";
|
|
12
|
+
|
|
13
|
+
interface Props extends Omit<HTMLAttributes<"a">, Owned> {
|
|
3
14
|
href: string;
|
|
4
15
|
w: number;
|
|
5
16
|
h: number;
|
|
@@ -11,12 +22,12 @@ const { href, w, h, caption = "", class: className, ...rest } = Astro.props;
|
|
|
11
22
|
---
|
|
12
23
|
|
|
13
24
|
<a
|
|
25
|
+
{...rest}
|
|
14
26
|
class:list={["lightbox-link", className]}
|
|
15
27
|
href={href}
|
|
16
28
|
data-lb-w={w}
|
|
17
29
|
data-lb-h={h}
|
|
18
30
|
data-lb-caption={caption}
|
|
19
31
|
aria-label={`View full-size image${caption ? `: ${caption}` : ""}`}
|
|
20
|
-
{...rest}
|
|
21
32
|
><slot
|
|
22
33
|
/></a>
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { HTMLAttributes } from "astro/types";
|
|
3
|
+
|
|
4
|
+
type Owned =
|
|
5
|
+
| "type"
|
|
6
|
+
| "aria-haspopup"
|
|
7
|
+
| "aria-expanded"
|
|
8
|
+
| "aria-label"
|
|
9
|
+
| "data-popout-label"
|
|
10
|
+
| "class"
|
|
11
|
+
| "class:list";
|
|
12
|
+
|
|
13
|
+
interface Props extends Omit<HTMLAttributes<"button">, Owned> {
|
|
14
|
+
label: string;
|
|
15
|
+
trigger?: string;
|
|
16
|
+
class?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const { label, trigger = "Note", class: className, ...rest } = Astro.props;
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
<button
|
|
23
|
+
{...rest}
|
|
24
|
+
type="button"
|
|
25
|
+
class:list={["popout-trigger", className]}
|
|
26
|
+
aria-haspopup="dialog"
|
|
27
|
+
aria-expanded="false"
|
|
28
|
+
aria-label={`${trigger}: ${label}`}
|
|
29
|
+
data-popout-label={label}
|
|
30
|
+
>
|
|
31
|
+
{trigger}
|
|
32
|
+
</button
|
|
33
|
+
><template class="popout-content"><slot /></template>
|
|
@@ -31,8 +31,8 @@ const Heading: "h2" | "h3" | "h4" = `h${headingLevel}`;
|
|
|
31
31
|
const heroStyle = heroPosition ? `object-position: ${heroPosition}` : undefined;
|
|
32
32
|
---
|
|
33
33
|
|
|
34
|
-
{/* The mapper guarantees an image (
|
|
35
|
-
image), so every card takes the thumbnail treatment. */}
|
|
34
|
+
{/* The mapper guarantees an image (a consumer's own placeholder when no
|
|
35
|
+
featured image), so every card takes the thumbnail treatment. */}
|
|
36
36
|
<article class:list={["has-post-thumbnail", className]}>
|
|
37
37
|
<div class="post-item post-grid">
|
|
38
38
|
<div class="post-item-image">
|
|
@@ -47,12 +47,14 @@ const heroStyle = heroPosition ? `object-position: ${heroPosition}` : undefined;
|
|
|
47
47
|
/>
|
|
48
48
|
</a>
|
|
49
49
|
{draft && <span class="draft-stamp">DRAFT</span>}
|
|
50
|
-
<div class="
|
|
51
|
-
<
|
|
52
|
-
<
|
|
53
|
-
|
|
50
|
+
<div class="card-chips">
|
|
51
|
+
<div class="read-time-comment">
|
|
52
|
+
<span class="reading-time chip">
|
|
53
|
+
<Icon name="clock" size={12} />
|
|
54
|
+
{`${minutes} ${minReadLabel}`}</span>
|
|
55
|
+
</div>
|
|
56
|
+
<CornerBadges badges={badges} />
|
|
54
57
|
</div>
|
|
55
|
-
<CornerBadges badges={badges} />
|
|
56
58
|
</div>
|
|
57
59
|
<div class="post-item-content">
|
|
58
60
|
<div class="entry-cat">
|
|
@@ -114,18 +116,38 @@ const heroStyle = heroPosition ? `object-position: ${heroPosition}` : undefined;
|
|
|
114
116
|
font-size: var(--font-size-xl);
|
|
115
117
|
}
|
|
116
118
|
|
|
117
|
-
/*
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
.
|
|
119
|
+
/* One row along the image's bottom edge: the read-time chip at the
|
|
120
|
+
left, the corner badges pushed right. wrap-reverse keeps the read
|
|
121
|
+
time on the bottom line and lifts the badges to a line above it
|
|
122
|
+
when a narrow card cannot fit both, instead of overlapping (owner
|
|
123
|
+
catch 2026-09-28). pointer-events: none lets a press fall through
|
|
124
|
+
to the thumbnail link. */
|
|
125
|
+
.card-chips {
|
|
124
126
|
position: absolute;
|
|
127
|
+
right: 10px;
|
|
125
128
|
bottom: 10px;
|
|
126
129
|
left: 10px;
|
|
127
130
|
z-index: 1;
|
|
128
131
|
display: flex;
|
|
132
|
+
flex-wrap: wrap-reverse;
|
|
133
|
+
gap: 6px;
|
|
134
|
+
align-items: flex-end;
|
|
135
|
+
pointer-events: none;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/* In the row, CornerBadges gives up its own corner placement. */
|
|
139
|
+
.card-chips :global(.corner-badges) {
|
|
140
|
+
position: static;
|
|
141
|
+
margin-left: auto;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/* Chip look comes from the .chip pattern. display: flex, not a plain
|
|
145
|
+
block: a block wrapper gives the inline-flex chip a text line box
|
|
146
|
+
whose descender gap floats it off the true bottom, so this chip and
|
|
147
|
+
the corner badges (already a flex container) sat at different
|
|
148
|
+
offsets (owner catch 2026-09-06). */
|
|
149
|
+
.read-time-comment {
|
|
150
|
+
display: flex;
|
|
129
151
|
}
|
|
130
152
|
.post-item-content { padding: 15px 0 0; padding-top: 10px; }
|
|
131
153
|
|
|
@@ -135,13 +157,18 @@ const heroStyle = heroPosition ? `object-position: ${heroPosition}` : undefined;
|
|
|
135
157
|
overflow: hidden;
|
|
136
158
|
text-overflow: ellipsis;
|
|
137
159
|
|
|
138
|
-
/*
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
160
|
+
/* Each link's box is padded to 24px (WCAG 2.2 target size, 2.5.8).
|
|
161
|
+
The links' font box already stands about 2px taller than this
|
|
162
|
+
line-height:1 row on each side, and overflow clips at the padding
|
|
163
|
+
box, so the row pads 6px to keep the whole 24px box, and with it
|
|
164
|
+
the inset focus/hover ring, inside the clip. The negative margin
|
|
165
|
+
gives the padding back above; below it gives back 2px less,
|
|
166
|
+
because 24px boxes that touch the title link fail 2.5.8's spacing
|
|
167
|
+
check (spec 2026-09-29-wcag-2-2-design.md, owner-approved nudge:
|
|
168
|
+
the title and everything under it sit 2px lower). The smoke tests
|
|
169
|
+
pin the containment and the 24px height. */
|
|
170
|
+
padding-block: 6px;
|
|
171
|
+
margin-block: -6px -4px;
|
|
145
172
|
color: var(--ink);
|
|
146
173
|
text-transform: uppercase;
|
|
147
174
|
font-weight: bold;
|
|
@@ -151,6 +178,7 @@ const heroStyle = heroPosition ? `object-position: ${heroPosition}` : undefined;
|
|
|
151
178
|
}
|
|
152
179
|
|
|
153
180
|
.entry-cat .post-categories a {
|
|
181
|
+
padding-block: 4px;
|
|
154
182
|
color: inherit;
|
|
155
183
|
text-decoration: none;
|
|
156
184
|
transition: var(--transition);
|
|
@@ -27,7 +27,7 @@ const {
|
|
|
27
27
|
attribute. Kept tiny and dependency-free on purpose.
|
|
28
28
|
The storage key rides define:vars from the required themeStorageKey
|
|
29
29
|
prop (step 11.2), so the shell bakes in no brand of its own; the
|
|
30
|
-
caller (
|
|
30
|
+
caller (the consumer's layout) supplies its own key. */}
|
|
31
31
|
<script is:inline define:vars={{ themeKey: themeStorageKey }}>
|
|
32
32
|
(function () {
|
|
33
33
|
var t = null;
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
interface Props {
|
|
3
3
|
class?: string;
|
|
4
|
+
mainTag?: "main" | "div";
|
|
4
5
|
}
|
|
5
6
|
|
|
6
|
-
const { class: className } = Astro.props;
|
|
7
|
+
const { class: className, mainTag = "main" } = Astro.props;
|
|
8
|
+
const Main = mainTag;
|
|
7
9
|
---
|
|
8
10
|
|
|
9
11
|
<div class:list={["two-column", className]}>
|
|
10
|
-
<
|
|
12
|
+
<Main class="site-main"><slot /></Main>
|
|
11
13
|
<slot name="aside" />
|
|
12
14
|
</div>
|
|
@@ -68,7 +68,6 @@ const {
|
|
|
68
68
|
.blog-image :global(img) { max-width: 100%; height: auto; display: block; margin-inline: auto; }
|
|
69
69
|
.blog-image-narrow :global(.lightbox-link) { max-width: min(495px, 100%); }
|
|
70
70
|
|
|
71
|
-
/* Type from the .caption pattern
|
|
72
|
-
figcaptions are centered. */
|
|
71
|
+
/* Type from the .caption pattern; figcaptions are centered. */
|
|
73
72
|
figcaption { margin-top: 5px; text-align: center; }
|
|
74
73
|
</style>
|
package/src/components/models.ts
CHANGED
|
@@ -32,12 +32,13 @@ export interface LinkListItem {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
/* Chrome view-models (step 9.5, 2026-08-31): the header renders site
|
|
35
|
-
identity it is handed, never
|
|
36
|
-
|
|
37
|
-
site share one definition without the
|
|
38
|
-
SocialItem carries its icon as inline
|
|
39
|
-
|
|
40
|
-
of the package) is a lookup the caller
|
|
35
|
+
identity it is handed, never a consumer's own config. NavItem is the
|
|
36
|
+
shape a consumer's own site navigation config already used; it moved
|
|
37
|
+
here so the component and the site share one definition without the
|
|
38
|
+
component importing site code. SocialItem carries its icon as inline
|
|
39
|
+
SVG markup: a site's own icon registry (including any site-specific
|
|
40
|
+
brand glyphs that stay out of the package) is a lookup the caller
|
|
41
|
+
performs, not the component. */
|
|
41
42
|
export interface NavItem {
|
|
42
43
|
label: string;
|
|
43
44
|
href: string;
|
|
@@ -51,10 +52,10 @@ export interface SocialItem {
|
|
|
51
52
|
icon: string;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
/* Footer view-models (step 9.5). These are the interfaces
|
|
55
|
-
footer-sitemap comment
|
|
56
|
-
schema"; this is that move.
|
|
57
|
-
|
|
55
|
+
/* Footer view-models (step 9.5). These are the interfaces a consumer's
|
|
56
|
+
own footer-sitemap comment called "the future component-library
|
|
57
|
+
schema"; this is that move. A consumer's own footer data stays in its
|
|
58
|
+
own config, not here. */
|
|
58
59
|
export interface SitemapLink {
|
|
59
60
|
label: string;
|
|
60
61
|
href: string;
|
package/src/lib/excerpt.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* Card text derived from a post body (owner call 2026-09-13; the rule
|
|
2
|
-
is
|
|
3
|
-
|
|
2
|
+
was settled at the blog and is summarized in this package's README,
|
|
3
|
+
"Derived excerpts"). The
|
|
4
4
|
card is the opening prose, consecutive paragraphs joined, cut at a
|
|
5
5
|
word boundary within EXCERPT_LIMIT and always ended with an
|
|
6
6
|
ellipsis, so every listing samples the post's own opening and the
|
package/src/lib/format-date.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/* Package lib (step 11.2): the post date formatter, split from
|
|
2
|
-
post-display.ts so the package's formatDate never drags
|
|
3
|
-
placeholder along. Locale parameterized for N sites;
|
|
4
|
-
house default. */
|
|
2
|
+
post-display.ts so the package's formatDate never drags a
|
|
3
|
+
site-specific placeholder along. Locale parameterized for N sites;
|
|
4
|
+
en-US is the house default. */
|
|
5
5
|
export function formatPostDate(d: Date, locale = "en-US"): string {
|
|
6
6
|
return d.toLocaleDateString(locale, {
|
|
7
7
|
year: "numeric",
|
package/src/lib/header-date.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/* The masthead date box: "25, Aug 2026", the live theme's format. Rendered
|
|
2
2
|
at build time as the no-JS fallback (SiteHeader.astro) and refreshed on
|
|
3
|
-
load by
|
|
3
|
+
load by the consumer's layout script, from this one definition. */
|
|
4
4
|
export function formatHeaderDate(now: Date): string {
|
|
5
5
|
return `${now.getDate()}, ${now.toLocaleDateString("en-US", { month: "short" })} ${now.getFullYear()}`;
|
|
6
6
|
}
|
package/src/lib/ordering.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/* Publication-time ordering, pure and framework-free so it is unit-testable.
|
|
2
2
|
Single source of truth for a post's publication instant: `date` is the
|
|
3
|
-
date-only permalink field; `published` carries the full
|
|
4
|
-
|
|
5
|
-
through these instead of picking fields ad hoc:
|
|
6
|
-
the same-day ordering bug shipped twice
|
|
3
|
+
date-only permalink field; `published` carries the full source timestamp
|
|
4
|
+
when a consumer's import knew it. Every consumer (sorting, feeds,
|
|
5
|
+
display) must go through these instead of picking fields ad hoc:
|
|
6
|
+
hand-picked fields are how the same-day ordering bug shipped twice
|
|
7
|
+
(audit A1/A2). */
|
|
7
8
|
|
|
8
9
|
export interface Publishable {
|
|
9
10
|
data: { date: Date; published?: Date };
|
|
@@ -3,8 +3,8 @@ import { docOf } from "./core/dom";
|
|
|
3
3
|
|
|
4
4
|
/* Code island decorator: builds the header bar (filename/language label +
|
|
5
5
|
copy button) above every fenced block in article content. Runs client-side
|
|
6
|
-
from
|
|
7
|
-
under jsdom (a phase-1 carry-over closed 2026-07-28). */
|
|
6
|
+
from the consumer's layout; extracted to a module so the DOM behavior is
|
|
7
|
+
testable under jsdom (a phase-1 carry-over closed 2026-07-28). */
|
|
8
8
|
/* Island contract (step 9): mount(root, options?) returns a destroy handle;
|
|
9
9
|
claim() makes a second mount over the same pre a no-op. */
|
|
10
10
|
export interface CodeIslandOptions {
|
|
@@ -25,18 +25,22 @@ export const mountCodeIslands: Island<CodeIslandOptions> = (
|
|
|
25
25
|
|
|
26
26
|
const doc = docOf(root);
|
|
27
27
|
|
|
28
|
-
/*
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
close over the same
|
|
28
|
+
/* timers holds the two pending setTimeout ids per button: the queued
|
|
29
|
+
status announcement and the label reset, one live each at most (a
|
|
30
|
+
second click before either fires overwrites its id, dropping the
|
|
31
|
+
earlier reference so it can no longer be cleared, which is why doCopy
|
|
32
|
+
clears each before replacing it). A plain mutable box, not a field on
|
|
33
|
+
the mounted entry, so both doCopy and destroy() close over the same
|
|
34
|
+
cells and destroy() can cancel both. */
|
|
34
35
|
const mounted: {
|
|
35
36
|
pre: Element;
|
|
36
37
|
bar: HTMLDivElement;
|
|
37
38
|
btn: HTMLButtonElement;
|
|
38
39
|
onClick: () => void;
|
|
39
|
-
|
|
40
|
+
timers: {
|
|
41
|
+
announce: ReturnType<typeof setTimeout> | undefined;
|
|
42
|
+
reset: ReturnType<typeof setTimeout> | undefined;
|
|
43
|
+
};
|
|
40
44
|
}[] = [];
|
|
41
45
|
|
|
42
46
|
for (const pre of root.querySelectorAll(selector)) {
|
|
@@ -45,6 +49,13 @@ export const mountCodeIslands: Island<CodeIslandOptions> = (
|
|
|
45
49
|
bar.className = "code-island-bar";
|
|
46
50
|
const label = doc.createElement("span");
|
|
47
51
|
|
|
52
|
+
/* Visually the button's own text already shows the outcome, but a
|
|
53
|
+
screen reader is not sat watching the button: role="status" gets
|
|
54
|
+
the outcome announced as a live region without moving focus. */
|
|
55
|
+
const status = doc.createElement("span");
|
|
56
|
+
status.className = "screen-reader-text";
|
|
57
|
+
status.setAttribute("role", "status");
|
|
58
|
+
|
|
48
59
|
const file = pre
|
|
49
60
|
.closest("[data-code-filename]")
|
|
50
61
|
?.getAttribute("data-code-filename");
|
|
@@ -56,22 +67,41 @@ export const mountCodeIslands: Island<CodeIslandOptions> = (
|
|
|
56
67
|
btn.type = "button";
|
|
57
68
|
btn.textContent = copy.copy;
|
|
58
69
|
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
70
|
+
const timers: {
|
|
71
|
+
announce: ReturnType<typeof setTimeout> | undefined;
|
|
72
|
+
reset: ReturnType<typeof setTimeout> | undefined;
|
|
73
|
+
} = { announce: undefined, reset: undefined };
|
|
62
74
|
|
|
63
75
|
const doCopy = async (): Promise<void> => {
|
|
76
|
+
/* Cleared synchronously, before the outcome is known, so a repeat
|
|
77
|
+
copy inside the reset window is a real DOM change rather than
|
|
78
|
+
the same string written over itself. Setting the outcome text
|
|
79
|
+
itself waits one tick (below) so the clear is its own observable
|
|
80
|
+
step; a live region that never changes never gets announced. */
|
|
81
|
+
status.textContent = "";
|
|
82
|
+
|
|
83
|
+
let outcome: string;
|
|
84
|
+
|
|
64
85
|
try {
|
|
65
86
|
await navigator.clipboard.writeText(pre.textContent);
|
|
66
87
|
btn.textContent = copy.copied;
|
|
88
|
+
outcome = copy.copied;
|
|
67
89
|
} catch {
|
|
68
90
|
btn.textContent = copy.failed;
|
|
91
|
+
outcome = copy.failed;
|
|
69
92
|
}
|
|
70
93
|
|
|
71
|
-
clearTimeout(
|
|
94
|
+
clearTimeout(timers.announce);
|
|
95
|
+
|
|
96
|
+
timers.announce = setTimeout(() => {
|
|
97
|
+
status.textContent = outcome;
|
|
98
|
+
}, 0);
|
|
99
|
+
|
|
100
|
+
clearTimeout(timers.reset);
|
|
72
101
|
|
|
73
|
-
|
|
102
|
+
timers.reset = setTimeout(() => {
|
|
74
103
|
btn.textContent = copy.copy;
|
|
104
|
+
status.textContent = "";
|
|
75
105
|
}, resetMs);
|
|
76
106
|
};
|
|
77
107
|
|
|
@@ -80,16 +110,17 @@ export const mountCodeIslands: Island<CodeIslandOptions> = (
|
|
|
80
110
|
};
|
|
81
111
|
|
|
82
112
|
btn.addEventListener("click", onClick);
|
|
83
|
-
bar.append(label, btn);
|
|
113
|
+
bar.append(label, btn, status);
|
|
84
114
|
pre.before(bar);
|
|
85
|
-
mounted.push({ pre, bar, btn, onClick,
|
|
115
|
+
mounted.push({ pre, bar, btn, onClick, timers });
|
|
86
116
|
}
|
|
87
117
|
|
|
88
118
|
return {
|
|
89
119
|
destroy(): void {
|
|
90
|
-
for (const { pre, bar, btn, onClick,
|
|
120
|
+
for (const { pre, bar, btn, onClick, timers } of mounted) {
|
|
91
121
|
btn.removeEventListener("click", onClick);
|
|
92
|
-
clearTimeout(
|
|
122
|
+
clearTimeout(timers.announce);
|
|
123
|
+
clearTimeout(timers.reset);
|
|
93
124
|
bar.remove();
|
|
94
125
|
release(pre, "code");
|
|
95
126
|
}
|