@half-built/astro 0.1.0 → 0.3.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
@@ -14,3 +14,68 @@ is the design record for this package.
14
14
 
15
15
  Default icon glyphs are derived from Lucide (https://lucide.dev), ISC
16
16
  license. See `ICONS-LICENSE`.
17
+
18
+ ## EditorNote
19
+
20
+ `content/EditorNote.astro` is a reminder block for content that must
21
+ not ship: by default it renders only when the consuming build runs in
22
+ dev mode, and a deploy build emits nothing for it. A consumer with a
23
+ wider preview concept (the blog's SHOW_DRAFTS builds, for example)
24
+ passes its own gate through the `shown` prop; the component reads no
25
+ consumer config itself.
26
+
27
+ ## Live code colors
28
+
29
+ `shiki/code-theme` bakes its amber values into every highlighted span
30
+ at build time. `shiki/code-vars` is a Shiki transformer that rewrites
31
+ those baked values to the css package's `--code-token-*` and
32
+ `--code-*` custom properties, so highlighted code follows a runtime
33
+ palette override. The variables resolve to the same hexes the theme
34
+ bakes, so adopting the transformer changes no rendered pixel on its
35
+ own. Pass it beside the theme: the `transformers` prop of
36
+ `astro:components`' `Code`, or `markdown.shikiConfig.transformers` in
37
+ an Astro config.
38
+
39
+ ## Palette token entries
40
+
41
+ A `content/Palette.astro` entry may carry `token` (a custom property
42
+ name) instead of `hex`: the swatch then paints `var(token)` and
43
+ follows the live cascade with no script, and the hex cell renders
44
+ empty with a `data-token-hex` attribute for a consumer script to fill
45
+ from computed styles. Entries with `hex` render exactly as before.
46
+
47
+ ## Ecosystem island
48
+
49
+ `scripts/ecosystem` fills the Footer's Ecosystem column from a shared
50
+ JSON document, so adding a property to a family of sites does not mean
51
+ rebuilding every one of them.
52
+
53
+ ```js
54
+ import { mountEcosystem } from "@half-built/astro/scripts/ecosystem";
55
+
56
+ void mountEcosystem(document, {
57
+ endpoint: "https://example.com/ecosystem.json",
58
+ selfKey: "ui",
59
+ });
60
+ ```
61
+
62
+ The endpoint is a parameter and the package ships no default. `Footer`
63
+ keeps taking `ecosystem` and `ecosystemSelf` as typed props, and those
64
+ props are the static baseline the island replaces. Every failure path
65
+ leaves that baseline standing: no JavaScript, a dead endpoint, a
66
+ malformed payload, or a document that does not contain `selfKey`.
67
+
68
+ The document is `{ version: 1, entries: [...] }` where each entry has
69
+ `key`, `label`, `href` (null renders unlinked), `priority` (ascending,
70
+ 0 highest) and `family`. Entries are sorted with the self entry's own
71
+ family first, then by priority, and capped at `limit`, default 6.
72
+
73
+ ## Import notes
74
+
75
+ Wildcard subpath imports need explicit file extensions under
76
+ TypeScript's bundler mode: `@half-built/astro/lib/slug.ts` and
77
+ `@half-built/astro/components/Shell.astro`, not extensionless forms.
78
+ Vite resolves either; `tsc --noEmit` only accepts the explicit one.
79
+
80
+ The `Masthead.astro` export is an alias for `SiteHeader.astro`, the
81
+ same component under its public name.
package/package.json CHANGED
@@ -1,8 +1,13 @@
1
1
  {
2
2
  "name": "@half-built/astro",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Astro components, islands, and pure helpers for the half-built design system.",
5
5
  "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/curthenrichs/half-built-ui.git",
9
+ "directory": "packages/astro"
10
+ },
6
11
  "type": "module",
7
12
  "files": ["src", "ICONS-LICENSE"],
8
13
  "exports": {
@@ -11,7 +16,8 @@
11
16
  "./content/*": "./src/components/content/*",
12
17
  "./scripts/*": "./src/scripts/*",
13
18
  "./lib/*": "./src/lib/*",
14
- "./shiki/code-theme": "./src/shiki/code-theme.mjs"
19
+ "./shiki/code-theme": "./src/shiki/code-theme.mjs",
20
+ "./shiki/code-vars": "./src/shiki/code-vars.mjs"
15
21
  },
16
22
  "peerDependencies": { "astro": "^5.0.0" },
17
23
  "publishConfig": { "access": "public" }
@@ -62,7 +62,7 @@ const year = new Date().getFullYear();
62
62
  )}
63
63
  <details class="footer-sitemap-group footer-sitemap-collapsible" open>
64
64
  <summary><h2>{ecosystemTitle}</h2></summary>
65
- <ul>
65
+ <ul data-ecosystem>
66
66
  {ecosystem.map((entry) => (
67
67
  <li>
68
68
  {entry.key === ecosystemSelf ? (
@@ -90,12 +90,18 @@ const heroStyle = heroPosition ? `object-position: ${heroPosition}` : undefined;
90
90
  z-index: 2;
91
91
  font-size: var(--font-size-xl);
92
92
  }
93
- /* Chip look comes from the .chip pattern; only placement is contextual. */
93
+ /* Chip look comes from the .chip pattern; only placement is
94
+ contextual. display: flex, not a plain block: a block wrapper
95
+ gives the inline-flex chip a text line box whose descender gap
96
+ floats it off the true bottom, so this chip and the corner badges
97
+ (already a flex container) sat at different offsets (owner catch
98
+ 2026-09-06). */
94
99
  .read-time-comment {
95
100
  position: absolute;
96
101
  bottom: 10px;
97
102
  left: 10px;
98
103
  z-index: 1;
104
+ display: flex;
99
105
  }
100
106
  .post-item-content { padding: 15px 0 0; padding-top: 10px; }
101
107
  .entry-cat .post-categories {
@@ -0,0 +1,57 @@
1
+ ---
2
+ /* Editor note: a reminder in content that renders on the dev server
3
+ and stays out of deploy builds. Moved up from the blog's
4
+ content/EditorNote.astro (owner call 2026-09-06: it is generic and
5
+ should have crossed in the 11.3 extraction; the blog swaps onto this
6
+ copy at its next pin bump). The blog's gate read its own SHOW_DRAFTS
7
+ config, which a package component must not do, so visibility is the
8
+ `shown` prop instead: the default is the consumer build's dev mode,
9
+ and a consumer with a preview concept passes its own wider gate.
10
+ Anatomy follows Callout (box, label straddling the top rule), but the
11
+ rule is dashed and amber: nothing else in the system is dashed, so a
12
+ note cannot be mistaken for content or for a callout. */
13
+
14
+ /* The label always reads "Editor's Note", with the title after a colon
15
+ when one is given (owner call 2026-08-27). */
16
+ interface Props { title?: string; shown?: boolean; class?: string }
17
+ const { title, shown = import.meta.env.MODE === "development", class: className } = Astro.props;
18
+ const label = title ? `Editor's Note: ${title}` : "Editor's Note";
19
+ ---
20
+ {shown && (
21
+ <div class:list={["editor-note", "rule-box", className]} role="note">
22
+ <strong class="editor-note-label boxed-label">{label}</strong>
23
+ <div class="editor-note-body"><slot /></div>
24
+ </div>
25
+ )}
26
+
27
+ <style>
28
+ /* Box from .rule-box; the dashed amber stroke and the margin are here. */
29
+ .editor-note {
30
+ position: relative;
31
+ border-style: dashed;
32
+ border-color: var(--accent-1);
33
+ margin: 30px 0;
34
+ }
35
+ .editor-note-label {
36
+ position: absolute;
37
+ top: 0;
38
+ left: 20px;
39
+ /* Center the label on the 2px top rule, not on the padding-box edge. */
40
+ transform: translateY(calc(-50% - 2px));
41
+ padding: 4px 10px;
42
+ font-size: var(--font-size-xs);
43
+ font-weight: 700;
44
+ text-transform: uppercase;
45
+ letter-spacing: 0.5px;
46
+ line-height: 1;
47
+ color: var(--accent-1-ink);
48
+ border-color: var(--accent-1);
49
+ }
50
+ /* Reads as an annotation, not a paragraph: a step smaller than prose. */
51
+ .editor-note-body {
52
+ position: relative;
53
+ font-size: var(--font-size-sm);
54
+ color: var(--accent-1-ink);
55
+ }
56
+ .editor-note-body > :global(:last-child) { margin-bottom: 0; }
57
+ </style>
@@ -3,8 +3,13 @@
3
3
  page: bordered swatch, role, hex. Hand-authored-page component (the
4
4
  migrator never emits it). Chip color is per-entry data, so it rides an
5
5
  inline background-color, which the html-validate gate whitelists for
6
- exactly this dynamic-value case. */
7
- interface Entry { role: string; hex: string; note?: string }
6
+ exactly this dynamic-value case.
7
+ An entry may carry `token` instead of `hex` (owner ask 2026-09-06,
8
+ the reference site's live token sheet): the chip then paints
9
+ var(token), following the live cascade with no script, and the hex
10
+ cell renders empty with data-token-hex for a consumer script to
11
+ fill from computed styles; the component itself stays inert. */
12
+ interface Entry { role: string; hex?: string; token?: string; note?: string }
8
13
  interface Props { entries: Entry[]; class?: string }
9
14
  const { entries, class: className } = Astro.props;
10
15
  ---
@@ -21,9 +26,9 @@ const { entries, class: className } = Astro.props;
21
26
  <tbody>
22
27
  {entries.map((e) => (
23
28
  <tr>
24
- <td><span class="palette-chip" aria-hidden="true" style={`background-color:${e.hex}`}></span></td>
29
+ <td><span class="palette-chip" aria-hidden="true" style={`background-color:${e.token ? `var(${e.token})` : e.hex}`}></span></td>
25
30
  <td>{e.role}</td>
26
- <td><code>{e.hex}</code></td>
31
+ <td>{e.token ? <code data-token-hex={e.token}></code> : <code>{e.hex}</code>}</td>
27
32
  <td>{e.note}</td>
28
33
  </tr>
29
34
  ))}
@@ -0,0 +1,273 @@
1
+ /* The ecosystem island: the footer's Ecosystem column, fetched at
2
+ runtime from a shared document so adding a property to the family
3
+ never means rebuilding every site (design record
4
+ docs/superpowers/specs/2026-09-06-ecosystem-endpoint-design.md in
5
+ this repo).
6
+
7
+ The component keeps rendering its typed props, which are the static
8
+ baseline. This island replaces that list only on a validated,
9
+ non-empty document that contains the site's own key. Every other
10
+ path leaves the baseline standing, so a reader with JavaScript off,
11
+ a dead endpoint or a malformed payload sees a slightly stale footer
12
+ rather than a blank one.
13
+
14
+ The endpoint is a parameter. This package hardcodes no consumer URL. */
15
+
16
+ export interface EcosystemDocEntry {
17
+ key: string;
18
+ label: string;
19
+ /* null: the property exists but is not deployed. Renders unlinked. */
20
+ href: string | null;
21
+ /* Ascending rank, 0 highest. */
22
+ priority: number;
23
+ family: string;
24
+ }
25
+
26
+ export interface EcosystemDocument {
27
+ version: number;
28
+ entries: EcosystemDocEntry[];
29
+ }
30
+
31
+ const SCHEMA_VERSION = 1;
32
+
33
+ function isEntry(value: unknown): value is EcosystemDocEntry {
34
+ if (typeof value !== "object" || value === null) return false;
35
+ const entry = value as Record<string, unknown>;
36
+ return (
37
+ typeof entry.key === "string" && entry.key !== "" &&
38
+ typeof entry.label === "string" && entry.label !== "" &&
39
+ /* href is checked by scheme, not just type, because it is assigned
40
+ straight to link.href below. The document and the cached copy
41
+ are both untrusted input, and a javascript: or data: URL there
42
+ would run on the consumer's origin when clicked. */
43
+ (entry.href === null ||
44
+ (typeof entry.href === "string" && /^https?:\/\//i.test(entry.href))) &&
45
+ typeof entry.priority === "number" && Number.isFinite(entry.priority) &&
46
+ typeof entry.family === "string" && entry.family !== ""
47
+ );
48
+ }
49
+
50
+ /** The document, or null when anything about it is unusable. */
51
+ export function validateDocument(raw: unknown): EcosystemDocument | null {
52
+ if (typeof raw !== "object" || raw === null) return null;
53
+ const doc = raw as Record<string, unknown>;
54
+ if (doc.version !== SCHEMA_VERSION) return null;
55
+ if (!Array.isArray(doc.entries) || doc.entries.length === 0) return null;
56
+ /* filter, not every: a type predicate narrows the array through
57
+ filter and does not through every, so this is the form that hands
58
+ back EcosystemDocEntry[] instead of any[]. Comparing lengths is
59
+ what makes it a refusal rather than a silent drop of bad rows. */
60
+ const entries = doc.entries.filter(isEntry);
61
+ if (entries.length !== doc.entries.length) return null;
62
+ return { version: SCHEMA_VERSION, entries };
63
+ }
64
+
65
+ /** The self entry's family first, each group by ascending priority and
66
+ then label, capped at limit. Null when selfKey is absent, which is
67
+ the refusal that keeps a site out of a list missing itself. */
68
+ export function sortEntries(
69
+ entries: EcosystemDocEntry[],
70
+ selfKey: string,
71
+ limit: number,
72
+ ): EcosystemDocEntry[] | null {
73
+ const self = entries.find((entry) => entry.key === selfKey);
74
+ if (!self) return null;
75
+ const own = self.family;
76
+ return [...entries]
77
+ .sort((a, b) => {
78
+ const aOwn = a.family === own ? 0 : 1;
79
+ const bOwn = b.family === own ? 0 : 1;
80
+ if (aOwn !== bOwn) return aOwn - bOwn;
81
+ if (a.priority !== b.priority) return a.priority - b.priority;
82
+ return a.label.localeCompare(b.label);
83
+ })
84
+ .slice(0, limit);
85
+ }
86
+
87
+ const CACHE_KEY = "half-built-ecosystem";
88
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
89
+ const ATTEMPT_TIMEOUT_MS = 3000;
90
+ const RETRY_DELAYS_MS = [400, 1200];
91
+ const JITTER_MS = 250;
92
+
93
+ /* A transport failure can differ on a second attempt. A content
94
+ failure cannot, so it is reported separately and never retried. */
95
+ type Attempt =
96
+ | { kind: "body"; body: unknown }
97
+ | { kind: "transport"; status: number | null }
98
+ | { kind: "content" };
99
+
100
+ function retryable(status: number | null): boolean {
101
+ if (status === null) return true;
102
+ if (status === 429) return true;
103
+ return status >= 500 && status <= 599;
104
+ }
105
+
106
+ function sleep(ms: number): Promise<void> {
107
+ const jitter = ms === 0 ? 0 : Math.random() * JITTER_MS;
108
+ return new Promise((resolve) => setTimeout(resolve, ms + jitter));
109
+ }
110
+
111
+ async function attemptFetch(endpoint: string): Promise<Attempt> {
112
+ const controller = new AbortController();
113
+ const timer = setTimeout(() => { controller.abort(); }, ATTEMPT_TIMEOUT_MS);
114
+ /* The timer is cleared once in this single finally, which covers the
115
+ header fetch and the body read alike: the 3 second budget bounds
116
+ the whole attempt, not just the response headers, so a stalled
117
+ body read still aborts instead of leaving the promise pending
118
+ forever. */
119
+ try {
120
+ let response: Response;
121
+ try {
122
+ response = await fetch(endpoint, { signal: controller.signal, credentials: "omit" });
123
+ } catch {
124
+ return { kind: "transport", status: null };
125
+ }
126
+ if (!response.ok) return { kind: "transport", status: response.status };
127
+ let text: string;
128
+ try {
129
+ text = await response.text();
130
+ } catch {
131
+ return { kind: "transport", status: null };
132
+ }
133
+ try {
134
+ return { kind: "body", body: JSON.parse(text) as unknown };
135
+ } catch {
136
+ /* Parsed nothing usable. The same bytes come back next time. */
137
+ return { kind: "content" };
138
+ }
139
+ } finally {
140
+ clearTimeout(timer);
141
+ }
142
+ }
143
+
144
+ function readCache(storage: Storage | null, cacheKey: string, now: number): EcosystemDocument | null {
145
+ if (!storage) return null;
146
+ try {
147
+ const raw = storage.getItem(cacheKey);
148
+ if (raw === null) return null;
149
+ const parsed: unknown = JSON.parse(raw);
150
+ if (typeof parsed !== "object" || parsed === null) return null;
151
+ const record = parsed as Record<string, unknown>;
152
+ if (typeof record.fetchedAt !== "number") return null;
153
+ if (now - record.fetchedAt > CACHE_TTL_MS) return null;
154
+ /* Storage is untrusted input, so it runs the same gate as a fetch. */
155
+ return validateDocument(record.document);
156
+ } catch {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ function writeCache(storage: Storage | null, cacheKey: string, now: number, document: EcosystemDocument): void {
162
+ if (!storage) return;
163
+ try {
164
+ storage.setItem(cacheKey, JSON.stringify({ fetchedAt: now, document }));
165
+ } catch {
166
+ /* A private window or blocked site data. The fetch still stands. */
167
+ }
168
+ }
169
+
170
+ /** Fetch with retry, falling back to the last known good copy. Null
171
+ when neither yields a usable document. */
172
+ export async function loadDocument(
173
+ endpoint: string,
174
+ storage: Storage | null,
175
+ now: number,
176
+ retryDelaysMs: number[] = RETRY_DELAYS_MS,
177
+ cacheKey: string = CACHE_KEY,
178
+ ): Promise<EcosystemDocument | null> {
179
+ /* One attempt up front, then one per configured delay. Iterating the
180
+ delays rather than indexing them keeps this free of the array
181
+ index access that reads as possibly undefined under the strict
182
+ project and as definitely defined under the lint project. */
183
+ for (const delay of [0, ...retryDelaysMs]) {
184
+ await sleep(delay);
185
+ const result = await attemptFetch(endpoint);
186
+ if (result.kind === "body") {
187
+ const document = validateDocument(result.body);
188
+ if (document) {
189
+ writeCache(storage, cacheKey, now, document);
190
+ return document;
191
+ }
192
+ break;
193
+ }
194
+ if (result.kind === "content") break;
195
+ if (!retryable(result.status)) break;
196
+ }
197
+ return readCache(storage, cacheKey, now);
198
+ }
199
+
200
+ const DEFAULT_LIMIT = 6;
201
+
202
+ export interface EcosystemOptions {
203
+ /* The document's URL. This package ships no default: a consumer
204
+ passes its own, so the package reads no consumer configuration. */
205
+ endpoint: string;
206
+ /* The entry this site renders as itself, unlinked and bold. */
207
+ selfKey: string;
208
+ limit?: number;
209
+ retryDelaysMs?: number[];
210
+ /* The storage key is a mount option, not a package literal (same
211
+ convention as theme-toggle.ts's storageKey), so a consumer can
212
+ namespace the cache. Defaults to the current literal, which keeps
213
+ behavior identical and lets two sites on one origin share the
214
+ cached document, which is reasonable since it is not site-specific. */
215
+ cacheKey?: string;
216
+ }
217
+
218
+ function entryNode(doc: Document, entry: EcosystemDocEntry, selfKey: string): HTMLElement {
219
+ if (entry.key === selfKey) {
220
+ const self = doc.createElement("span");
221
+ self.className = "footer-sitemap-self";
222
+ self.textContent = entry.label;
223
+ return self;
224
+ }
225
+ if (entry.href !== null) {
226
+ const link = doc.createElement("a");
227
+ link.href = entry.href;
228
+ link.textContent = entry.label;
229
+ return link;
230
+ }
231
+ const pending = doc.createElement("span");
232
+ pending.className = "footer-sitemap-pending";
233
+ pending.textContent = entry.label;
234
+ return pending;
235
+ }
236
+
237
+ function safeStorage(view: Window | null): Storage | null {
238
+ try {
239
+ return view?.localStorage ?? null;
240
+ } catch {
241
+ return null;
242
+ }
243
+ }
244
+
245
+ /** Replace the footer's ecosystem list with the shared document's, or
246
+ leave the server-rendered baseline exactly as it is.
247
+
248
+ This deliberately does not follow the package's Island<O> contract
249
+ from core/island.ts: it is async and returns no destroy handle,
250
+ because there is nothing here to tear down. One consequence is that
251
+ it has no claim() guard, so a caller that mounts it twice on the
252
+ same document runs the fetch and the swap twice; every known
253
+ caller mounts it once. */
254
+ export async function mountEcosystem(root: Document, opts: EcosystemOptions): Promise<void> {
255
+ const { endpoint, selfKey, limit = DEFAULT_LIMIT, retryDelaysMs, cacheKey = CACHE_KEY } = opts;
256
+ const list = root.querySelector<HTMLElement>("[data-ecosystem]");
257
+ if (!list) return;
258
+
259
+ const storage = safeStorage(root.defaultView);
260
+ const document_ = await loadDocument(endpoint, storage, Date.now(), retryDelaysMs, cacheKey);
261
+ if (!document_) return;
262
+
263
+ const entries = sortEntries(document_.entries, selfKey, limit);
264
+ if (!entries || entries.length === 0) return;
265
+
266
+ const fragment = root.createDocumentFragment();
267
+ for (const entry of entries) {
268
+ const item = root.createElement("li");
269
+ item.append(entryNode(root, entry, selfKey));
270
+ fragment.append(item);
271
+ }
272
+ list.replaceChildren(fragment);
273
+ }
@@ -0,0 +1,33 @@
1
+ // Shiki transformer: rewrite the code theme's baked hex values to the
2
+ // css package's custom properties, so highlighted code follows a live
3
+ // palette override (spec 2026-09-06, live code colors). Shiki inlines
4
+ // literal colors on every span at build; with this transformer the
5
+ // build emits var() instead, and the variables resolve to the exact
6
+ // same hexes until something overrides them, so adopting it changes
7
+ // no rendered pixel. Pass it wherever shiki options go: the transformers
8
+ // prop of astro:components' Code, or markdown.shikiConfig.transformers
9
+ // in a consumer's astro config.
10
+ const HEX_TO_VAR = {
11
+ "#ffaa3c": "var(--code-token-keyword)",
12
+ "#ffd18a": "var(--code-token-function)",
13
+ "#e07c14": "var(--code-token-string)",
14
+ "#8a7a63": "var(--code-token-comment)",
15
+ "#e8d9c3": "var(--code-fg)",
16
+ "#1b140c": "var(--code-bg)",
17
+ };
18
+
19
+ function swap(node) {
20
+ const style = node.properties?.style;
21
+ if (typeof style !== "string") return;
22
+ node.properties.style = style.replace(/#[0-9a-fA-F]{6}/g, (hex) => HEX_TO_VAR[hex.toLowerCase()] ?? hex);
23
+ }
24
+
25
+ export default {
26
+ name: "half-built-code-vars",
27
+ pre(node) {
28
+ swap(node);
29
+ },
30
+ span(node) {
31
+ swap(node);
32
+ },
33
+ };