@half-built/astro 0.7.0 → 0.9.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 CHANGED
@@ -72,6 +72,27 @@ component that injects `getCollection` and its own gate, the way the
72
72
  blog does. The resolvers live in `lib/drafts.ts` for consumers that
73
73
  want the logic without the components.
74
74
 
75
+ ## Derived excerpts
76
+
77
+ Posts carry no excerpt frontmatter. `lib/excerpt.ts` derives the card
78
+ text from the post body at build time: the opening prose, consecutive
79
+ paragraphs joined by a space, cut at a word boundary within
80
+ `EXCERPT_LIMIT` (200 characters) and always ended with an ellipsis,
81
+ the reader's cue that the post continues. The run stops at the first
82
+ heading, list, blockquote, code fence, or image line, so a card never
83
+ crosses into a later section; components and the lead-break are
84
+ invisible to it. `content/ExcerptStart.astro` on its own line moves
85
+ the start to the paragraph after it, for a post that opens on a TL;DR
86
+ or an aside. `postExcerpt(entry)` is the policy: a published post
87
+ with no prose fails the build naming the slug, a draft warns once per
88
+ build and renders blank. It takes any entry shaped
89
+ `{ body?, data: { slug, draft? } }`, so a `CollectionEntry` passes
90
+ with no cast. A consumer calls `postExcerpt` from every place a
91
+ summary renders (cards, meta description, feed, search index) and
92
+ never stores the result. The blog is the reference consumer and
93
+ derives all four from it. The rule was settled on the blog's 54 posts
94
+ on 2026-09-13 and moved here on 2026-09-14.
95
+
75
96
  ## Live code colors
76
97
 
77
98
  `shiki/code-theme` bakes its amber values into every highlighted span
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@half-built/astro",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Astro components, islands, and pure helpers for the half-built design system.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -3,7 +3,6 @@ import type { SitemapGroup, EcosystemEntry } from "./models";
3
3
  import { BP_PHONE_MAX } from "../scripts/core/breakpoints";
4
4
 
5
5
  interface Props {
6
- siteName: string;
7
6
  legalHolder: string;
8
7
  sitemap: SitemapGroup[];
9
8
  ecosystem: EcosystemEntry[];
@@ -12,7 +11,6 @@ interface Props {
12
11
  }
13
12
 
14
13
  const {
15
- siteName,
16
14
  legalHolder,
17
15
  sitemap,
18
16
  ecosystem,
@@ -28,17 +26,15 @@ const year = new Date().getFullYear();
28
26
  <div class="shell">
29
27
  <div class="bottom-footer-info">
30
28
  <div class="site-info">
31
- {/* Two lines at every width (owner call 2026-09-13): the
32
- copyright over the site name, a real break rather than the
33
- old pipe that retired on phones and left the wrap to do
34
- the separating. The copyright's segments stay unbreakable
35
- so a narrow screen wraps that line only after the year. */}
29
+ {/* The copyright line alone (owner call 2026-09-14). The
30
+ site-name home link that used to sit under it retired.
31
+ The masthead and the Ecosystem group's bold entry already
32
+ say where you are, and the notice is complete without it.
33
+ The segments stay unbreakable so a narrow screen wraps
34
+ only after the year. */}
36
35
  <slot name="site-info">
37
- <span class="site-info-line">
38
- <span class="site-info-seg">Copyright © {year}</span>{" "}
39
- <span class="site-info-seg">{legalHolder}</span>
40
- </span>
41
- <span class="site-info-line"><a href="/">{siteName}</a></span>
36
+ <span class="site-info-seg">Copyright © {year}</span>{" "}
37
+ <span class="site-info-seg">{legalHolder}</span>
42
38
  </slot>
43
39
  </div>
44
40
  </div>
@@ -128,18 +124,9 @@ const year = new Date().getFullYear();
128
124
  font-size: var(--font-size-sm);
129
125
  }
130
126
 
131
- /* The two lines center under each other like the sitemap below
132
- (owner report 2026-08-25). */
127
+ /* Centered like the sitemap below (owner report 2026-08-25). */
133
128
  .site-info { text-align: center; }
134
- .site-info-line { display: block; }
135
-
136
- /* Underlined, unlike the sitemap links below: this one sits inside
137
- running text (the copyright block), where the same ink at 0.8
138
- opacity is not a visible link mark (WCAG 1.4.1, axe
139
- link-in-text-block; the line break is all that frames it). */
140
- .site-info a { text-decoration: underline; opacity: 0.8; transition: var(--transition); color: var(--ink); }
141
129
  .site-info-seg { white-space: nowrap; }
142
- .site-info a:hover { opacity: 1; }
143
130
 
144
131
  /* Sitemap columns under the copyright line, centered like it (owner
145
132
  feedback 2026-08-01). position: relative lifts the content above the
@@ -0,0 +1,12 @@
1
+ ---
2
+ ---
3
+
4
+ {/* Renders nothing. Marks where the derived card text starts (README,
5
+ "Derived excerpts"; owner call 2026-09-13): lib/excerpt.ts takes
6
+ the opening prose from the paragraph after this line instead of
7
+ from the top of the post. Use it when a post opens with a TL;DR or
8
+ an aside the card should jump. A real import rather than a comment
9
+ so the marker is typed, visible in the source, and cannot be
10
+ reflowed away. The note lives in the template because Prettier's
11
+ Astro plugin drops a comment-only frontmatter. Moved from the blog
12
+ 2026-09-14 (owner call). */}
@@ -0,0 +1,198 @@
1
+ /* Card text derived from a post body (owner call 2026-09-13; the rule
2
+ is recorded in the blog's docs/superpowers/specs/2026-09-13-derived-excerpts-design.md
3
+ and summarized in this package's README, "Derived excerpts"). The
4
+ card is the opening prose, consecutive paragraphs joined, cut at a
5
+ word boundary within EXCERPT_LIMIT and always ended with an
6
+ ellipsis, so every listing samples the post's own opening and the
7
+ reader can see it continues. Consumers keep no excerpt frontmatter.
8
+
9
+ Built site-side in the blog first and proved on its 54 posts, then
10
+ moved here on 2026-09-14 (owner call) so BEADZ and later sites get
11
+ the same rule. The blog is the reference consumer. */
12
+
13
+ export const EXCERPT_LIMIT = 200;
14
+
15
+ const MARKER = /^\s*<ExcerptStart\s*\/>\s*$/m;
16
+
17
+ /* Block-level things that are not prose, removed before the body is
18
+ split into paragraphs. Order matters only for the code fence, which
19
+ must go before anything that would look inside it. */
20
+ const IMPORT_LINE = /^import .*$/gm;
21
+ const CODE_FENCE = /```[\s\S]*?```/g;
22
+ const LEAD_BREAK = /<div class="lead-break">[\s\S]*?<\/div>/g;
23
+
24
+ /* A capitalized tag is a component. It is a block only when it
25
+ occupies whole lines: opening tag starts a line, closing tag ends
26
+ one. Non-greedy to the same close tag; the posts do not nest a
27
+ component inside itself. A component inline in a sentence (PostLink,
28
+ WhenPublished around a link) is not matched here and keeps its text
29
+ through flatten; the built-output suite caught the Robot Migrates
30
+ card as the word "In" on 2026-09-13 when this was not so. */
31
+ const COMPONENT_BLOCK =
32
+ /^[ \t]*<([A-Z][A-Za-z0-9]*)\b[^>]*>[\s\S]*?<\/\1>[ \t]*$/gm;
33
+
34
+ const SELF_CLOSING = /^[ \t]*<[A-Za-z][A-Za-z0-9-]*\b[^>]*\/>[ \t]*$/gm;
35
+ /* A lowercase tag opening a line is an html block (figure, div,
36
+ details); it runs to its close. */
37
+ const HTML_BLOCK = /^<([a-z][a-z0-9-]*)\b[^>]*>[\s\S]*?<\/\1>/gm;
38
+ const MDX_EXPRESSION_LINE = /^\s*\{[\s\S]*?\}\s*$/gm;
39
+
40
+ /* Paragraph test: the first non-space character says what the block
41
+ is. A block that starts with a tag is not rejected here; after the
42
+ block-level removals above, a leading tag is an inline component or
43
+ element opening a sentence, and flatten keeps its text. A bare
44
+ triple backtick is the sentinel a code fence leaves behind. */
45
+ const NOT_PROSE = /^(?:#|>|-\s|\*\s|\||!|```|\d+[.)]\s)/;
46
+
47
+ /* A code fence is emptied rather than removed: the sentinel keeps it
48
+ a non-prose block, so it still ends the opening prose (spec rule 1)
49
+ instead of vanishing and letting the run continue past it. */
50
+ function stripBlocks(body: string): string {
51
+ return body
52
+ .replace(CODE_FENCE, "\n\n```\n\n")
53
+ .replace(IMPORT_LINE, "")
54
+ .replace(LEAD_BREAK, "\n\n")
55
+ .replace(COMPONENT_BLOCK, "\n\n")
56
+ .replace(HTML_BLOCK, "\n\n")
57
+ .replace(SELF_CLOSING, "\n\n")
58
+ .replace(MDX_EXPRESSION_LINE, "\n\n");
59
+ }
60
+
61
+ /* The opening prose: consecutive prose blocks from the top, joined by
62
+ a space, until the limit is reached (owner call 2026-09-13, after
63
+ RoverBot's one-line opener made a one-line card; the same afternoon
64
+ the card had been the first paragraph alone). Non-prose blocks
65
+ before any prose are skipped, as before; the first non-prose block
66
+ after prose has started (a heading, a list, a quote, a code fence,
67
+ an image line) ends the run, so a card never crosses into a later
68
+ section. Components and the lead-break were stripped above, so a
69
+ gallery between two intro paragraphs does not end it. A block made
70
+ only of tags (a stray inline component on its own line) flattens to
71
+ nothing and is passed over. */
72
+ function openingProse(body: string): string | null {
73
+ const blocks = stripBlocks(body)
74
+ .split(/\n\s*\n/)
75
+ .map((b) => b.trim())
76
+ .filter((b) => b.length > 0);
77
+
78
+ let taken = "";
79
+
80
+ for (const b of blocks) {
81
+ if (NOT_PROSE.test(b)) {
82
+ if (taken) break;
83
+ continue;
84
+ }
85
+
86
+ const text = flatten(b);
87
+ if (!text) continue;
88
+ taken = taken ? `${taken} ${text}` : text;
89
+ if (taken.length >= EXCERPT_LIMIT) break;
90
+ }
91
+
92
+ return taken || null;
93
+ }
94
+
95
+ /* Inline markdown to plain text. Links keep their text (inline and
96
+ reference style), mdx comments and expressions go, emphasis and code
97
+ markers go, inline html tags go and their text stays, escapes lose
98
+ the backslash, and the block's own line breaks collapse to one
99
+ space. */
100
+ function flatten(block: string): string {
101
+ return block
102
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
103
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
104
+ .replace(/\[([^\]]+)\]\[[^\]]*\]/g, "$1")
105
+ .replace(/\{\/\*[\s\S]*?\*\/\}/g, " ")
106
+ .replace(/\{[^{}\n]*\}/g, " ")
107
+ .replace(/<[^>]+>/g, "")
108
+ .replace(/`([^`]*)`/g, "$1")
109
+ .replace(/(\*\*|__)(.+?)\1/g, "$2")
110
+ .replace(/(^|[^\w\\])[*_](.+?)[*_](?=[^\w]|$)/g, "$1$2")
111
+ .replace(/\\([*_`[\]\\])/g, "$1")
112
+ .replace(/\s+/g, " ")
113
+ .trim();
114
+ }
115
+
116
+ /* The cut (owner call 2026-09-13, replacing the sentence rule the same
117
+ day): whole words while the text stays within the limit, then the
118
+ ellipsis, always. The ellipsis is the reader's cue that the post
119
+ continues, so it goes on even when the whole paragraph fits. A
120
+ period, comma, colon, or semicolon right before it comes off, so the
121
+ card ends "onto Cloudflare Pages…" and not "Pages.…"; a question
122
+ mark or exclamation stays. */
123
+ function cutAtWords(text: string): string {
124
+ let out = text;
125
+
126
+ if (out.length > EXCERPT_LIMIT) {
127
+ /* One past the limit so a word that ends exactly there survives:
128
+ the space after it is what the search finds. */
129
+ const head = out.slice(0, EXCERPT_LIMIT + 1);
130
+ const lastSpace = head.lastIndexOf(" ");
131
+
132
+ out =
133
+ lastSpace > 0 ? head.slice(0, lastSpace) : out.slice(0, EXCERPT_LIMIT);
134
+ }
135
+
136
+ return `${out.replace(/[.,;:]+$/, "").trimEnd()}…`;
137
+ }
138
+
139
+ export function deriveExcerpt(body: string): string | null {
140
+ const marker = MARKER.exec(body);
141
+ const source = marker ? body.slice(marker.index + marker[0].length) : body;
142
+ const text = openingProse(source);
143
+ return text === null ? null : cutAtWords(text);
144
+ }
145
+
146
+ /* Each warning prints once per build. postExcerpt runs once per page
147
+ that lists a post (home, archives, categories, feed, search index),
148
+ so without this a drafts build repeated seven stub warnings fifty
149
+ times and buried anything else in the log (2026-09-13). */
150
+ const warned = new Set<string>();
151
+
152
+ function warnOnce(message: string): void {
153
+ if (warned.has(message)) return;
154
+ warned.add(message);
155
+ console.warn(message);
156
+ }
157
+
158
+ /* Test hook: the set outlives a test, so a suite that asserts a
159
+ warning clears it between cases. */
160
+ export function resetExcerptWarnings(): void {
161
+ warned.clear();
162
+ }
163
+
164
+ /* The consumer's collection entry, structurally: body is the raw MDX
165
+ minus frontmatter as Astro's content layer hands it over, slug names
166
+ the post in a thrown error or a warning, and draft selects the
167
+ policy. A CollectionEntry<"posts"> satisfies this with no cast; the
168
+ same shape lib/drafts.ts uses for Draftable. */
169
+ export interface Excerptable {
170
+ body?: string;
171
+ data: { slug: string; draft?: boolean };
172
+ }
173
+
174
+ /* The one place the publish policy lives (spec, "Consumers"): every
175
+ consumer calls this, never deriveExcerpt directly. A published post
176
+ with no usable opening fails the build; a draft with none warns and
177
+ renders blank, so a SHOW_DRAFTS=1 build still runs and the stub is
178
+ loud without being a wall. */
179
+ export function postExcerpt(post: Excerptable): string {
180
+ const slug = post.data.slug;
181
+ const derived = deriveExcerpt(post.body ?? "");
182
+
183
+ if (derived === null) {
184
+ if (post.data.draft) {
185
+ warnOnce(
186
+ `[excerpt] draft "${slug}" has no prose paragraph; card is blank`,
187
+ );
188
+
189
+ return "";
190
+ }
191
+
192
+ throw new Error(
193
+ `[excerpt] published post "${slug}" has no prose paragraph to derive a card from`,
194
+ );
195
+ }
196
+
197
+ return derived;
198
+ }