@half-built/astro 0.7.0 → 0.8.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 +21 -0
- package/package.json +1 -1
- package/src/components/content/ExcerptStart.astro +12 -0
- package/src/lib/excerpt.ts +198 -0
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
|
@@ -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
|
+
}
|