@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 +26 -0
- package/package.json +1 -1
- package/src/components/Footer.astro +2 -2
- package/src/scripts/ecosystem.ts +223 -2
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
|
@@ -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
|
) : (
|
package/src/scripts/ecosystem.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|