@half-built/astro 0.2.0 → 0.3.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 CHANGED
@@ -44,6 +44,32 @@ follows the live cascade with no script, and the hex cell renders
44
44
  empty with a `data-token-hex` attribute for a consumer script to fill
45
45
  from computed styles. Entries with `hex` render exactly as before.
46
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
+
47
73
  ## Import notes
48
74
 
49
75
  Wildcard subpath imports need explicit file extensions under
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@half-built/astro",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "description": "Astro components, islands, and pure helpers for the half-built design system.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -62,11 +62,11 @@ 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 ? (
69
- <span class="footer-sitemap-self">{entry.label}</span>
69
+ <span class="footer-sitemap-self" aria-current="page">{entry.label}</span>
70
70
  ) : entry.href !== null ? (
71
71
  <a href={entry.href}>{entry.label}</a>
72
72
  ) : (
@@ -1,7 +1,8 @@
1
1
  /* The ecosystem island: the footer's Ecosystem column, fetched at
2
2
  runtime from a shared document so adding a property to the family
3
3
  never means rebuilding every site (design record
4
- 2026-09-06-ecosystem-endpoint-design.md in this repo's docs).
4
+ docs/superpowers/specs/2026-09-06-ecosystem-endpoint-design.md in
5
+ this repo).
5
6
 
6
7
  The component keeps rendering its typed props, which are the static
7
8
  baseline. This island replaces that list only on a validated,
@@ -35,7 +36,12 @@ function isEntry(value: unknown): value is EcosystemDocEntry {
35
36
  return (
36
37
  typeof entry.key === "string" && entry.key !== "" &&
37
38
  typeof entry.label === "string" && entry.label !== "" &&
38
- (entry.href === null || typeof entry.href === "string") &&
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))) &&
39
45
  typeof entry.priority === "number" && Number.isFinite(entry.priority) &&
40
46
  typeof entry.family === "string" && entry.family !== ""
41
47
  );
@@ -77,3 +83,218 @@ export function sortEntries(
77
83
  })
78
84
  .slice(0, limit);
79
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
+ /* Astro compiles a component's scoped rules to `.cls[data-astro-cid-x]`,
219
+ and it stamps that attribute at build time on markup it renders. An
220
+ element built here with createElement never gets stamped, so it
221
+ matches none of Footer.astro's scoped rules: the self entry loses its
222
+ bold, a pending entry loses its dimming, and every link falls back to
223
+ the browser's default underline. The server-rendered list is stamped,
224
+ so the fix is to read the attribute off it and carry it onto whatever
225
+ we create. Read rather than hardcoded, because the hash changes
226
+ whenever the component's styles change. Consumers who render the
227
+ footer unscoped simply have nothing to copy, and the loop is a no-op. */
228
+ function scopeOf(list: Element): string | null {
229
+ for (const { name } of list.attributes) {
230
+ if (name.startsWith("data-astro-cid-")) return name;
231
+ }
232
+ return null;
233
+ }
234
+
235
+ function entryNode(doc: Document, entry: EcosystemDocEntry, selfKey: string): HTMLElement {
236
+ if (entry.key === selfKey) {
237
+ const self = doc.createElement("span");
238
+ self.className = "footer-sitemap-self";
239
+ /* The bold is the visual "you are here"; this is the same statement
240
+ for a screen reader, which cannot see weight. Without it the self
241
+ entry is announced exactly like an undeployed one. */
242
+ self.setAttribute("aria-current", "page");
243
+ self.textContent = entry.label;
244
+ return self;
245
+ }
246
+ if (entry.href !== null) {
247
+ const link = doc.createElement("a");
248
+ link.href = entry.href;
249
+ link.textContent = entry.label;
250
+ return link;
251
+ }
252
+ const pending = doc.createElement("span");
253
+ pending.className = "footer-sitemap-pending";
254
+ pending.textContent = entry.label;
255
+ return pending;
256
+ }
257
+
258
+ function safeStorage(view: Window | null): Storage | null {
259
+ try {
260
+ return view?.localStorage ?? null;
261
+ } catch {
262
+ return null;
263
+ }
264
+ }
265
+
266
+ /** Replace the footer's ecosystem list with the shared document's, or
267
+ leave the server-rendered baseline exactly as it is.
268
+
269
+ This deliberately does not follow the package's Island<O> contract
270
+ from core/island.ts: it is async and returns no destroy handle,
271
+ because there is nothing here to tear down. One consequence is that
272
+ it has no claim() guard, so a caller that mounts it twice on the
273
+ same document runs the fetch and the swap twice; every known
274
+ caller mounts it once. */
275
+ export async function mountEcosystem(root: Document, opts: EcosystemOptions): Promise<void> {
276
+ const { endpoint, selfKey, limit = DEFAULT_LIMIT, retryDelaysMs, cacheKey = CACHE_KEY } = opts;
277
+ const list = root.querySelector<HTMLElement>("[data-ecosystem]");
278
+ if (!list) return;
279
+
280
+ const storage = safeStorage(root.defaultView);
281
+ const document_ = await loadDocument(endpoint, storage, Date.now(), retryDelaysMs, cacheKey);
282
+ if (!document_) return;
283
+
284
+ const entries = sortEntries(document_.entries, selfKey, limit);
285
+ if (!entries || entries.length === 0) return;
286
+
287
+ const scope = scopeOf(list);
288
+ const fragment = root.createDocumentFragment();
289
+ for (const entry of entries) {
290
+ const item = root.createElement("li");
291
+ const node = entryNode(root, entry, selfKey);
292
+ if (scope !== null) {
293
+ item.setAttribute(scope, "");
294
+ node.setAttribute(scope, "");
295
+ }
296
+ item.append(node);
297
+ fragment.append(item);
298
+ }
299
+ list.replaceChildren(fragment);
300
+ }