@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 +65 -0
- package/package.json +8 -2
- package/src/components/Footer.astro +1 -1
- package/src/components/PostCard.astro +7 -1
- package/src/components/content/EditorNote.astro +57 -0
- package/src/components/content/Palette.astro +9 -4
- package/src/scripts/ecosystem.ts +273 -0
- package/src/shiki/code-vars.mjs +33 -0
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
+
};
|