@orbytes/astrolab 0.3.0 → 0.4.0-next.2
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 +261 -116
- package/bin/pin-gallery.mjs +53 -19
- package/defaults.mjs +72 -20
- package/dist/core/virtual-module/virtual-routes.js +12 -1
- package/docs/PIN-CONTRACT.md +84 -10
- package/docs/PIN.md +117 -37
- package/index.d.ts +41 -23
- package/index.mjs +46 -91
- package/package.json +7 -3
- package/src/Home.astro +166 -264
- package/src/LabHead.astro +37 -1047
- package/src/chrome/ActionsMenu.astro +97 -0
- package/src/chrome/ComponentCard.astro +76 -0
- package/src/chrome/Icon.astro +21 -0
- package/src/chrome/LICENSE-icons +43 -0
- package/src/chrome/Nav.astro +131 -0
- package/src/chrome/Panel.astro +117 -0
- package/src/chrome/Properties.astro +104 -0
- package/src/chrome/SectionsTree.astro +128 -0
- package/src/chrome/Shell.astro +120 -0
- package/src/chrome/Sprite.astro +23 -0
- package/src/chrome/StoryView.astro +192 -0
- package/src/chrome/Tree.astro +86 -0
- package/src/chrome/ViewportControls.astro +173 -0
- package/src/chrome/ViewportStage.astro +55 -0
- package/src/chrome/fonts/OFL.txt +93 -0
- package/src/chrome/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/src/chrome/icons.ts +68 -0
- package/src/chrome/marks-client.ts +75 -0
- package/src/chrome/model.ts +156 -0
- package/src/chrome/navbar-client.ts +324 -0
- package/src/chrome/params-client.ts +434 -0
- package/src/chrome/pins-data.ts +63 -0
- package/src/chrome/shell-client.ts +468 -0
- package/src/chrome/site-data.ts +230 -0
- package/src/chrome/trees.ts +257 -0
- package/src/chrome/viewport-client.ts +405 -0
- package/src/chrome/views/Assets.astro +125 -0
- package/src/chrome/views/Pages.astro +214 -0
- package/src/chrome/views/Placeholder.astro +37 -0
- package/src/chrome/views/Tasks.astro +79 -0
- package/src/core/LICENSE-astrobook +21 -0
- package/src/core/lib/components/home.astro +4 -2
- package/src/core/lib/pages/story.astro +12 -10
- package/src/core/utils/kebab-case.ts +2 -2
- package/src/core/virtual-module/virtual-routes.ts +20 -4
- package/src/pin/board.mjs +414 -190
- package/src/pin/index.mjs +67 -22
- package/src/pin/tickets.mjs +6 -5
- package/src/pin/toolbar.js +82 -4
- package/src/shell/Browse.astro +131 -353
- package/src/shell/Viewport.astro +22 -1315
- package/src/shell/lab-index.ts +28 -18
- package/src/shell/lab-params.ts +113 -6
- package/src/shell/live-files.mjs +212 -10
- package/src/shell/marks.mjs +17 -41
- package/src/ui/components/app.astro +5 -7
- package/src/ui/components/preview-layout.astro +17 -0
- package/src/ui/components/theme-script.astro +17 -5
- package/src/ui/lab.css +3754 -371
- package/virtual.d.ts +13 -4
- package/bin/lab-cull.mjs +0 -401
- package/src/shell/CardGrid.astro +0 -297
- package/src/ui/components/build-path.ts +0 -13
- package/src/ui/components/build-tree.ts +0 -108
- package/src/ui/components/collapse-duration.ts +0 -28
- package/src/ui/components/compress-terms.ts +0 -10
- package/src/ui/components/dashboard-layout.astro +0 -39
- package/src/ui/components/home.astro +0 -65
- package/src/ui/components/layout.astro +0 -110
- package/src/ui/components/sidebar-button-fullscreen.astro +0 -38
- package/src/ui/components/sidebar-button-search.astro +0 -23
- package/src/ui/components/sidebar-button-theme.astro +0 -9
- package/src/ui/components/sidebar-button.astro +0 -24
- package/src/ui/components/sidebar-resize-handle.astro +0 -74
- package/src/ui/components/sidebar-search-panel.astro +0 -41
- package/src/ui/components/sidebar-search-script.ts +0 -103
- package/src/ui/components/sidebar-title.astro +0 -17
- package/src/ui/components/sidebar-tree-node.astro +0 -143
- package/src/ui/components/sidebar-tree.astro +0 -84
- package/src/ui/components/sidebar.astro +0 -29
- package/src/ui/components/theme-toggle.astro +0 -63
package/src/shell/lab-index.ts
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
// survives a multi-page site.
|
|
14
14
|
// 5. <directory>/responsive.json, through marks.mjs — the hand-set responsive marks. The one
|
|
15
15
|
// thing here that is ticked rather than derived, because approval cannot be read from code.
|
|
16
|
-
// 6. The stories files themselves — the component they render, the leading
|
|
17
|
-
//
|
|
16
|
+
// 6. The stories files themselves, read as text — the component they render, the leading
|
|
17
|
+
// comment (summary) and the default export's optional `meta.tags`.
|
|
18
18
|
//
|
|
19
19
|
// Classification is by Astrobook `directory` (relative to its `directory` option):
|
|
20
20
|
// <tier>/<Section>/<Version>/… tier = first segment; for the tier holding the RESPONSIVE role
|
|
@@ -75,13 +75,14 @@ export interface LabItem {
|
|
|
75
75
|
livePage: string | null;
|
|
76
76
|
/** every page that mounts it — one entry on a normal site, more for shared chrome */
|
|
77
77
|
liveOn: LiveMount[];
|
|
78
|
-
/** section versions only (tier
|
|
78
|
+
/** section versions only (the tier holding the responsive role); null everywhere else */
|
|
79
79
|
responsive: { done: boolean; approved: boolean } | null;
|
|
80
80
|
/** the same story on the deployed lab; null when no `stagingUrl` is configured */
|
|
81
81
|
stagingUrl: string | null;
|
|
82
82
|
/** vscode://file/<absolute path> to the component; null in a build — the link is dev-only */
|
|
83
83
|
editorUrl: string | null;
|
|
84
|
-
/** repo-relative files
|
|
84
|
+
/** repo-relative files that import this item's componentFile and are live themselves (a page
|
|
85
|
+
* mounts them, whatever their tier or folder) */
|
|
85
86
|
usedBy: string[];
|
|
86
87
|
tags: string[];
|
|
87
88
|
summary: string | null;
|
|
@@ -108,17 +109,26 @@ export interface LabIndex {
|
|
|
108
109
|
live: { file: string; page: string; slot: number; of: number }[];
|
|
109
110
|
}
|
|
110
111
|
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
// read.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
112
|
+
// The optional `meta.tags` on a story module's default export — Astrobook ignores unknown keys, so
|
|
113
|
+
// `export default { component, meta: { tags: ["cta", "v2"] } }` is legal and this is where it is
|
|
114
|
+
// read.
|
|
115
|
+
//
|
|
116
|
+
// READ AS TEXT, NOT IMPORTED (2026-09-24, ASTROL-12). This used to be an EAGER
|
|
117
|
+
// `import.meta.glob("/src/**/*.stories.ts")`, and importing a stories file imports the component it
|
|
118
|
+
// renders — so every chrome page's module graph held every section, and Astro put every section's
|
|
119
|
+
// scoped stylesheet on every chrome page (18 of them on the lab home). The tags are string
|
|
120
|
+
// literals in the file, so a parse of its text gives the same answer and imports nothing. A tag
|
|
121
|
+
// built at runtime is not seen, which a tag never needs to be.
|
|
122
|
+
const tagsOf = (rootDir: string, moduleFile: string): string[] => {
|
|
123
|
+
let source = "";
|
|
124
|
+
try {
|
|
125
|
+
source = readFileSync(path.join(rootDir, moduleFile), "utf8");
|
|
126
|
+
} catch {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
const list = source.match(/\bmeta\s*:\s*\{[^}]*?\btags\s*:\s*\[([^\]]*)\]/);
|
|
130
|
+
if (!list) return [];
|
|
131
|
+
return [...list[1]!.matchAll(/["'`]([^"'`\n]+)["'`]/g)].map((m) => m[1]!);
|
|
122
132
|
};
|
|
123
133
|
|
|
124
134
|
// First sentence of the leading `//` comment block — the stories files already open with the
|
|
@@ -140,7 +150,7 @@ const summaryOf = (rootDir: string, moduleFile: string): string | null => {
|
|
|
140
150
|
const classify = (directory: string) => {
|
|
141
151
|
const parts = directory ? directory.split("/") : [];
|
|
142
152
|
const tier = parts[0] ?? "";
|
|
143
|
-
const sectional = tier ===
|
|
153
|
+
const sectional = labConfig.sectionsTier !== null && tier === labConfig.sectionsTier;
|
|
144
154
|
return {
|
|
145
155
|
tier,
|
|
146
156
|
section: sectional && parts[1] ? parts[1] : null,
|
|
@@ -200,7 +210,7 @@ export function buildLabIndex(): LabIndex {
|
|
|
200
210
|
const liveOn = (componentFile ? mountsByFile.get(componentFile) : undefined) ?? [];
|
|
201
211
|
const primary = liveOn[0];
|
|
202
212
|
const { tier, section, version } = classify(mod.directory);
|
|
203
|
-
const tags = tagsOf(moduleFile);
|
|
213
|
+
const tags = tagsOf(rootDir, moduleFile);
|
|
204
214
|
const summary = summaryOf(rootDir, moduleFile);
|
|
205
215
|
const responsive = RESPONSIVE_DIR && moduleFile.startsWith(RESPONSIVE_DIR)
|
|
206
216
|
? { done: doneMarks.has(moduleFile), approved: approvedMarks.has(moduleFile) }
|
|
@@ -320,7 +330,7 @@ export const itemsUnder = (index: LabIndex, folderPath: string): LabItem[] =>
|
|
|
320
330
|
/**
|
|
321
331
|
* The live pill's text. A single-page site reads exactly as it always did — `live · slot 3 of 12`
|
|
322
332
|
* — and only a page that is not the home page names itself: `live · /about, slot 3`.
|
|
323
|
-
*
|
|
333
|
+
* Every live pill in the chrome is built here; nothing else formats one.
|
|
324
334
|
*/
|
|
325
335
|
export const livePillText = (item: LabItem): string | null => {
|
|
326
336
|
if (!item.live) return null;
|
package/src/shell/lab-params.ts
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
at all.
|
|
11
11
|
|
|
12
12
|
So: a component that has something to tune DECLARES it here and renders nothing. The panel is
|
|
13
|
-
drawn by the
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
drawn by the lab's chrome (../chrome/params-client.ts) as a drawer on the right edge of the
|
|
14
|
+
component page's canvas — and only there. The same story at <subpath>/stories/<id> (the bare
|
|
15
|
+
render) shows no panel at all, because nothing in this module touches the DOM.
|
|
16
16
|
|
|
17
17
|
HOW IT FITS TOGETHER. This module runs INSIDE the story document (the iframe). It keeps the
|
|
18
18
|
registered groups on `window.__labParams` and announces each one with a `lab:params` event on
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
with the restored values, so `apply` must tolerate being called before the thing it configures
|
|
35
35
|
exists. Then:
|
|
36
36
|
|
|
37
|
-
import { registerLabParams } from "@orbytes/astrolab/params";
|
|
37
|
+
import { LAB_UNITS, parseLabLength, registerLabParams } from "@orbytes/astrolab/params";
|
|
38
38
|
|
|
39
39
|
const shader = document.querySelector<HTMLCanvasElement>(".hero__shader");
|
|
40
40
|
let uniforms: Uniforms | null = null; // filled in below; apply() tolerates null
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
note: "Uniforms are read every frame; nothing here is on the live page.",
|
|
47
47
|
controls: [
|
|
48
48
|
{ kind: "range", id: "speed", label: "Speed", min: 0, max: 4, step: 0.05, value: 1, unit: "×" },
|
|
49
|
+
{ kind: "number", id: "warp", label: "Warp", value: 24, unit: "px", units: LAB_UNITS.size },
|
|
49
50
|
{ kind: "toggle", id: "grain", label: "Grain", value: true },
|
|
50
51
|
{ kind: "select", id: "blend", label: "Blend", value: "screen",
|
|
51
52
|
options: [{ value: "screen", label: "screen" }, { value: "add", label: "add" }] },
|
|
@@ -54,6 +55,7 @@
|
|
|
54
55
|
apply(values) {
|
|
55
56
|
if (!uniforms) return;
|
|
56
57
|
uniforms.speed = Number(values.speed);
|
|
58
|
+
uniforms.warp = parseLabLength(values.warp)?.value ?? 24; // "24px" → 24
|
|
57
59
|
uniforms.grain = Boolean(values.grain);
|
|
58
60
|
uniforms.blend = String(values.blend);
|
|
59
61
|
uniforms.tint = String(values.tint);
|
|
@@ -66,14 +68,55 @@
|
|
|
66
68
|
|
|
67
69
|
That is all. Reset, Copy settings, the collapse chevron, the readouts, the units and the
|
|
68
70
|
storage are the panel's job, not the component's.
|
|
71
|
+
|
|
72
|
+
---------------------------------------------------------------------------------------------
|
|
73
|
+
SLIDER OR NUMBER? Decided 2026-09-24: a parameter that is a SIZE — a font size, a tracking, a
|
|
74
|
+
line height, a width, an offset, a blur radius — is never a slider. It is `kind: "number"`: a
|
|
75
|
+
field to type the value into, joined to a select of the units it can be expressed in (72 px,
|
|
76
|
+
4.5 rem, 12.5 vw). Unless it names its own `units`, it offers px · rem · em · vw · vh · %.
|
|
77
|
+
Sliders stay for unitless tuning values (speed, grain, scale, opacity).
|
|
78
|
+
|
|
79
|
+
A number control hands `apply` ONE CSS-READY STRING — the number and the unit joined, "72px",
|
|
80
|
+
"12.5rem", or "1.05" when the unit is "" — so it drops straight into a style:
|
|
81
|
+
|
|
82
|
+
el.style.setProperty("--headline-size", String(values.headlineSize)); // "72px"
|
|
83
|
+
|
|
84
|
+
and `parseLabLength` takes it apart again when the number itself is wanted (a shader uniform,
|
|
85
|
+
a calculation). Changing the unit keeps the number and changes what it means; the panel never
|
|
86
|
+
converts between units, because px → rem depends on a root size it cannot know.
|
|
87
|
+
|
|
88
|
+
{ kind: "number", id: "headlineSize", label: "Headline size", value: 72, unit: "px",
|
|
89
|
+
units: LAB_UNITS.size },
|
|
90
|
+
{ kind: "number", id: "tracking", label: "Tracking", value: -0.02, unit: "em",
|
|
91
|
+
units: LAB_UNITS.tracking, step: 0.01 },
|
|
92
|
+
{ kind: "number", id: "lineHeight", label: "Line height", value: 1.05, unit: "",
|
|
93
|
+
units: LAB_UNITS.lineHeight, step: 0.01 },
|
|
69
94
|
============================================================================================= */
|
|
70
95
|
|
|
71
96
|
export type LabParamValue = number | boolean | string;
|
|
72
97
|
export type LabParamValues = Record<string, LabParamValue>;
|
|
73
98
|
|
|
99
|
+
/**
|
|
100
|
+
* The unit sets a number control usually offers — pass one as `units`, or any list of your own.
|
|
101
|
+
* "" is "no unit" (the drawer shows it as "—"), which is what a line height is at its most
|
|
102
|
+
* useful. Figma writes tracking as a percentage; that is em × 100, so Figma's −2% is −0.02em.
|
|
103
|
+
*/
|
|
104
|
+
export const LAB_UNITS = {
|
|
105
|
+
/** Font sizes, widths, offsets, radii — and the set a number control gets when it names none. */
|
|
106
|
+
size: ["px", "rem", "em", "vw", "vh", "%"],
|
|
107
|
+
/** Letter spacing. */
|
|
108
|
+
tracking: ["em", "px"],
|
|
109
|
+
/** Line height: unitless first, because a unitless line height scales with the font. */
|
|
110
|
+
lineHeight: ["", "px"],
|
|
111
|
+
} as const satisfies Record<string, readonly string[]>;
|
|
112
|
+
|
|
74
113
|
/** One row of the panel. `id` is the key in `values`; `note` is shown on hover. */
|
|
75
114
|
export type LabParamControl =
|
|
76
115
|
| {
|
|
116
|
+
/**
|
|
117
|
+
* A slider, for a UNITLESS tuning value — speed, grain, scale. The value is a number and
|
|
118
|
+
* `unit` is only the readout's suffix ("×"). A size is a `number` control instead.
|
|
119
|
+
*/
|
|
77
120
|
kind: "range";
|
|
78
121
|
id: string;
|
|
79
122
|
label: string;
|
|
@@ -93,7 +136,59 @@ export type LabParamControl =
|
|
|
93
136
|
options: { value: string; label: string }[];
|
|
94
137
|
note?: string;
|
|
95
138
|
}
|
|
96
|
-
| { kind: "color"; id: string; label: string; value: string; note?: string }
|
|
139
|
+
| { kind: "color"; id: string; label: string; value: string; note?: string }
|
|
140
|
+
| {
|
|
141
|
+
/**
|
|
142
|
+
* A SIZE — a number field joined to a unit select (72 px · 4.5 rem · 12.5 vw).
|
|
143
|
+
*
|
|
144
|
+
* The value in `values` is ONE CSS-ready STRING, the number and unit joined: "72px",
|
|
145
|
+
* "12.5rem", "-0.02em", or "1.05" when the unit is "". Put it straight into a style, or
|
|
146
|
+
* take it apart with `parseLabLength`. Changing the unit keeps the number; nothing is
|
|
147
|
+
* converted.
|
|
148
|
+
*/
|
|
149
|
+
kind: "number";
|
|
150
|
+
id: string;
|
|
151
|
+
label: string;
|
|
152
|
+
/** The default number, bare: 72. */
|
|
153
|
+
value: number;
|
|
154
|
+
/** The default unit — "px", "em", or "" for none. Added to `units` if it is missing. */
|
|
155
|
+
unit: string;
|
|
156
|
+
/**
|
|
157
|
+
* What the unit select offers, in order. Defaults to `LAB_UNITS.size` (px · rem · em · vw ·
|
|
158
|
+
* vh · %); a tracking wants `LAB_UNITS.tracking`, a line height `LAB_UNITS.lineHeight`.
|
|
159
|
+
* A list of one shows the unit without a choice.
|
|
160
|
+
*/
|
|
161
|
+
units?: readonly string[];
|
|
162
|
+
/** What ↑ / ↓ add. Defaults to 1. */
|
|
163
|
+
step?: number;
|
|
164
|
+
/** Optional bounds, applied to typed and stored values alike. */
|
|
165
|
+
min?: number;
|
|
166
|
+
max?: number;
|
|
167
|
+
note?: string;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
/** The units a number control offers: its declared `units`, with its default `unit` guaranteed. */
|
|
171
|
+
export const labUnitsOf = (control: Extract<LabParamControl, { kind: "number" }>): string[] => {
|
|
172
|
+
const units = [...(control.units ?? LAB_UNITS.size)];
|
|
173
|
+
return units.includes(control.unit) ? units : [control.unit, ...units];
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* "72px" → { value: 72, unit: "px" }; "1.05" → { value: 1.05, unit: "" }. Null for anything that
|
|
178
|
+
* is not one number followed by an optional unit — so a value from a number control always
|
|
179
|
+
* parses, and a stray string never quietly becomes 0.
|
|
180
|
+
*/
|
|
181
|
+
export const parseLabLength = (raw: unknown): { value: number; unit: string } | null => {
|
|
182
|
+
if (typeof raw === "number") return Number.isFinite(raw) ? { value: raw, unit: "" } : null;
|
|
183
|
+
if (typeof raw !== "string") return null;
|
|
184
|
+
const match = /^\s*([-+]?(?:\d+\.?\d*|\.\d+)(?:e[-+]?\d+)?)\s*([a-z%]*)\s*$/i.exec(raw);
|
|
185
|
+
if (!match) return null;
|
|
186
|
+
const value = Number(match[1]);
|
|
187
|
+
return Number.isFinite(value) ? { value, unit: match[2]!.toLowerCase() } : null;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
/** The inverse: 72 and "px" → "72px". `String()` already turns −0 into "0". */
|
|
191
|
+
export const formatLabLength = (value: number, unit: string): string => `${String(value)}${unit}`;
|
|
97
192
|
|
|
98
193
|
export interface LabParamGroup {
|
|
99
194
|
/** Stable, and the localStorage key suffix. One group per thing being tuned. */
|
|
@@ -180,14 +275,26 @@ const coerce = (control: LabParamControl, raw: unknown): LabParamValue | undefin
|
|
|
180
275
|
return typeof raw === "string" && control.options.some((o) => o.value === raw) ? raw : undefined;
|
|
181
276
|
case "color":
|
|
182
277
|
return typeof raw === "string" && /^#[0-9a-f]{3,8}$/i.test(raw) ? raw : undefined;
|
|
278
|
+
case "number": {
|
|
279
|
+
// A bare number is what a slider saved. Reading it in the default unit is what lets a size
|
|
280
|
+
// that used to be a `range` become a `number` without losing the tuning saved for it.
|
|
281
|
+
const parsed = typeof raw === "number" ? parseLabLength(`${raw}${control.unit}`) : parseLabLength(raw);
|
|
282
|
+
if (!parsed || !labUnitsOf(control).includes(parsed.unit)) return undefined;
|
|
283
|
+
const value = Math.min(control.max ?? Infinity, Math.max(control.min ?? -Infinity, parsed.value));
|
|
284
|
+
return formatLabLength(value, parsed.unit);
|
|
285
|
+
}
|
|
183
286
|
}
|
|
184
287
|
};
|
|
185
288
|
|
|
289
|
+
/** A control's declared default, as `values` holds it. */
|
|
290
|
+
const defaultOf = (control: LabParamControl): LabParamValue =>
|
|
291
|
+
control.kind === "number" ? formatLabLength(control.value, control.unit) : control.value;
|
|
292
|
+
|
|
186
293
|
export function registerLabParams(group: LabParamGroup): LabParamHandle {
|
|
187
294
|
const storageKey = group.storageKey ?? `${STORE_PREFIX}${group.id}`;
|
|
188
295
|
|
|
189
296
|
const defaults: LabParamValues = {};
|
|
190
|
-
for (const control of group.controls) defaults[control.id] = control
|
|
297
|
+
for (const control of group.controls) defaults[control.id] = defaultOf(control);
|
|
191
298
|
|
|
192
299
|
const values: LabParamValues = { ...defaults };
|
|
193
300
|
const stored = readStore(storageKey);
|
package/src/shell/live-files.mjs
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// Which lab components are LIVE on the site — derived from the pages under src/pages/, never declared.
|
|
2
2
|
//
|
|
3
|
-
// Dependency-free Node (fs + path only) on purpose: it is imported by
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// One parser, so the three can never disagree about what "live" means.
|
|
3
|
+
// Dependency-free Node (fs + path only) on purpose: it is imported by the build-time index
|
|
4
|
+
// (./lab-index.ts) and by the chrome's page views (../chrome/site-data.ts,
|
|
5
|
+
// ../chrome/views/Pages.astro). One parser, so they can never disagree about what "live" means.
|
|
7
6
|
//
|
|
8
7
|
// The rule: a component file is live when a page imports it AND mounts it as a tag. Slot is its
|
|
9
8
|
// 1-based position among the mounted tags in that page's document order (the Footer sits outside
|
|
@@ -15,7 +14,7 @@
|
|
|
15
14
|
// home-page-only reader reports nothing, or the wrong slot, for everything that is not on the
|
|
16
15
|
// home page. Pages come home first, then alphabetically, so a component mounted on
|
|
17
16
|
// several pages reports the home page's slot as its primary one.
|
|
18
|
-
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
17
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
19
18
|
import path from "node:path";
|
|
20
19
|
|
|
21
20
|
export const PAGES_DIR = "src/pages";
|
|
@@ -37,9 +36,212 @@ export const templateOf = (source) => {
|
|
|
37
36
|
return m ? m[1] : source;
|
|
38
37
|
};
|
|
39
38
|
|
|
39
|
+
// A page may mount a section through a tsconfig path alias (`@/lab/sections/…`) or a root-relative
|
|
40
|
+
// path (`/src/lab/sections/…`) as well as a relative one, and Vite resolves all three. Following only
|
|
41
|
+
// the relative kind reported such a section as not live (ASTROL-23). The alias reading below was
|
|
42
|
+
// written for the cull (ASTROL-21), which was removed before it merged.
|
|
43
|
+
|
|
44
|
+
/** tsconfig.json's dialect: JSON with comments and trailing commas. @param {string} file */
|
|
45
|
+
const readJsonc = (file) => {
|
|
46
|
+
const src = readFileSync(file, "utf8").replace(/^\uFEFF/, "");
|
|
47
|
+
let out = "";
|
|
48
|
+
for (let i = 0; i < src.length; i++) {
|
|
49
|
+
const ch = src[i];
|
|
50
|
+
if (ch === '"') {
|
|
51
|
+
let j = i + 1;
|
|
52
|
+
while (j < src.length && src[j] !== '"' && src[j] !== "\n") j += src[j] === "\\" ? 2 : 1;
|
|
53
|
+
out += src.slice(i, j + 1);
|
|
54
|
+
i = j;
|
|
55
|
+
} else if (ch === "/" && src[i + 1] === "/") {
|
|
56
|
+
while (i + 1 < src.length && src[i + 1] !== "\n") i++;
|
|
57
|
+
} else if (ch === "/" && src[i + 1] === "*") {
|
|
58
|
+
const close = src.indexOf("*/", i + 2);
|
|
59
|
+
i = close < 0 ? src.length : close + 1;
|
|
60
|
+
} else out += ch;
|
|
61
|
+
}
|
|
62
|
+
return JSON.parse(out.replace(/,(\s*[}\]])/g, "$1"));
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** @param {string} specifier */
|
|
66
|
+
const packageNameOf = (specifier) =>
|
|
67
|
+
specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0];
|
|
68
|
+
|
|
69
|
+
/** The installed package's directory, looking up from fromDir. @param {string} fromDir @param {string} name */
|
|
70
|
+
function packageDir(fromDir, name) {
|
|
71
|
+
for (let dir = fromDir; ; dir = path.dirname(dir)) {
|
|
72
|
+
if (existsSync(path.join(dir, "node_modules", name))) return path.join(dir, "node_modules", name);
|
|
73
|
+
if (path.dirname(dir) === dir) return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** An `exports` value → the path it names under the conditions TypeScript reads a config with. @param {unknown} value @returns {string | null} */
|
|
78
|
+
const exportTarget = (value) => {
|
|
79
|
+
if (typeof value === "string") return value;
|
|
80
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
81
|
+
for (const [condition, inner] of Object.entries(value)) {
|
|
82
|
+
if (!["node", "require", "types", "default"].includes(condition)) continue;
|
|
83
|
+
const hit = exportTarget(inner);
|
|
84
|
+
if (hit) return hit;
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The file an `extends` entry names: a path from the extending config's folder, or a package
|
|
91
|
+
* found from that folder — through the package's `exports` map (or its `tsconfig` field, for a
|
|
92
|
+
* bare name) when one matches, otherwise the path as written, with `.json` or `/tsconfig.json`.
|
|
93
|
+
* @param {string} dir folder of the config doing the extending @param {string} name
|
|
94
|
+
* @returns {string | null}
|
|
95
|
+
*/
|
|
96
|
+
function extendsTarget(dir, name) {
|
|
97
|
+
/** @param {string} base */
|
|
98
|
+
const asFile = (base) =>
|
|
99
|
+
[base, `${base}.json`, path.join(base, "tsconfig.json")].find((c) => existsSync(c) && statSync(c).isFile()) ?? null;
|
|
100
|
+
if (name.startsWith(".") || path.isAbsolute(name)) return asFile(path.resolve(dir, name));
|
|
101
|
+
const pkgName = packageNameOf(name);
|
|
102
|
+
const pkg = packageDir(dir, pkgName);
|
|
103
|
+
if (!pkg) return null;
|
|
104
|
+
const subpath = name.slice(pkgName.length);
|
|
105
|
+
const manifestFile = path.join(pkg, "package.json");
|
|
106
|
+
const manifest = existsSync(manifestFile) ? readJsonc(manifestFile) : {};
|
|
107
|
+
const { exports } = manifest;
|
|
108
|
+
if (exports !== undefined && exports !== null) {
|
|
109
|
+
const bySubpath =
|
|
110
|
+
typeof exports === "object" && !Array.isArray(exports) && Object.keys(exports).some((k) => k.startsWith("."))
|
|
111
|
+
? exports
|
|
112
|
+
: { ".": exports };
|
|
113
|
+
/** @type {[string, unknown][]} */
|
|
114
|
+
const rules = Object.entries(bySubpath).map(([key, value]) => [key, [exportTarget(value)].filter(Boolean)]);
|
|
115
|
+
const mapped = aliasTargets({ rules }, `.${subpath}`)?.[0];
|
|
116
|
+
const file = mapped && asFile(path.join(pkg, mapped));
|
|
117
|
+
if (file) return file;
|
|
118
|
+
}
|
|
119
|
+
if (!subpath && typeof manifest.tsconfig === "string") return asFile(path.join(pkg, manifest.tsconfig));
|
|
120
|
+
return asFile(path.join(pkg, subpath));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* tsconfig.json's `compilerOptions.paths`, following `extends` (a relative path or a package, one
|
|
125
|
+
* or several), the child overriding the parent as TypeScript does. `base` is what the targets are
|
|
126
|
+
* relative to: `baseUrl` when set, otherwise the directory of the config that declared `paths`.
|
|
127
|
+
* `${configDir}` in either means the site's own tsconfig folder, wherever it was written.
|
|
128
|
+
* Throws when the config cannot be read: every alias would silently stop resolving, and a section
|
|
129
|
+
* mounted through one would lose its live pill with nothing to say why.
|
|
130
|
+
* @param {string} rootDir absolute repo root
|
|
131
|
+
* @returns {{ base: string; rules: [string, unknown][] }}
|
|
132
|
+
*/
|
|
133
|
+
export function readAliases(rootDir) {
|
|
134
|
+
const file = path.join(rootDir, "tsconfig.json");
|
|
135
|
+
if (!existsSync(file)) return { base: rootDir, rules: [] };
|
|
136
|
+
/** @param {string} value */
|
|
137
|
+
const withConfigDir = (value) => value.replaceAll("${configDir}", path.dirname(file));
|
|
138
|
+
/** @type {string | null} */
|
|
139
|
+
let baseUrl = null;
|
|
140
|
+
/** @type {Record<string, unknown> | null} */
|
|
141
|
+
let paths = null;
|
|
142
|
+
/** @type {string | null} */
|
|
143
|
+
let pathsDir = null;
|
|
144
|
+
const seen = new Set();
|
|
145
|
+
/** @param {string} configFile */
|
|
146
|
+
const load = (configFile) => {
|
|
147
|
+
if (seen.has(configFile)) return;
|
|
148
|
+
seen.add(configFile);
|
|
149
|
+
const config = readJsonc(configFile);
|
|
150
|
+
const dir = path.dirname(configFile);
|
|
151
|
+
for (const parent of config.extends === undefined ? [] : [].concat(config.extends)) {
|
|
152
|
+
const name = String(parent);
|
|
153
|
+
const target = extendsTarget(dir, name);
|
|
154
|
+
if (!target) throw new Error(`its "extends": "${name}" resolves to no file`);
|
|
155
|
+
load(target);
|
|
156
|
+
}
|
|
157
|
+
const options = config.compilerOptions ?? {};
|
|
158
|
+
if (typeof options.baseUrl === "string") baseUrl = path.resolve(dir, withConfigDir(options.baseUrl));
|
|
159
|
+
if (options.paths && typeof options.paths === "object") {
|
|
160
|
+
paths = options.paths;
|
|
161
|
+
pathsDir = dir;
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
try {
|
|
165
|
+
load(file);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`astrolab: ${toRepoRelative(rootDir, file)} could not be read (${error instanceof Error ? error.message : error}), ` +
|
|
169
|
+
"so no path alias can be followed and a section a page mounts through one would not show as live",
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
const rules = Object.entries(paths ?? {}).map(([key, targets]) =>
|
|
173
|
+
/** @type {[string, unknown]} */ ([key, Array.isArray(targets) ? targets.map((t) => withConfigDir(String(t))) : targets]),
|
|
174
|
+
);
|
|
175
|
+
return { base: baseUrl ?? pathsDir ?? rootDir, rules };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The targets `specifier` maps to under the tsconfig aliases, TypeScript's way: an exact key wins,
|
|
180
|
+
* then the wildcard key with the longest prefix. Null when no key matches.
|
|
181
|
+
* @param {{ rules: [string, unknown][] }} aliases @param {string} specifier
|
|
182
|
+
* @returns {string[] | null}
|
|
183
|
+
*/
|
|
184
|
+
export function aliasTargets(aliases, specifier) {
|
|
185
|
+
/** @type {{ prefix: string; targets: string[] } | null} */
|
|
186
|
+
let best = null;
|
|
187
|
+
for (const [key, targets] of aliases.rules) {
|
|
188
|
+
if (!Array.isArray(targets)) continue;
|
|
189
|
+
const star = key.indexOf("*");
|
|
190
|
+
if (star < 0) {
|
|
191
|
+
if (key === specifier) return targets.map(String);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
const prefix = key.slice(0, star);
|
|
195
|
+
const suffix = key.slice(star + 1);
|
|
196
|
+
if (specifier.length < prefix.length + suffix.length) continue;
|
|
197
|
+
if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) continue;
|
|
198
|
+
if (best && prefix.length <= best.prefix.length) continue;
|
|
199
|
+
const middle = specifier.slice(prefix.length, specifier.length - suffix.length);
|
|
200
|
+
best = { prefix, targets: targets.map((t) => String(t).replace("*", middle)) };
|
|
201
|
+
}
|
|
202
|
+
return best ? best.targets : null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** An absolute path → the file it names, the way the bundler would try it, or null. @param {string} base */
|
|
206
|
+
export function fileAt(base) {
|
|
207
|
+
const candidates = [base];
|
|
208
|
+
for (const ext of [".ts", ".mts", ".tsx", ".mjs", ".js", ".jsx", ".astro", ".mdx"]) candidates.push(base + ext);
|
|
209
|
+
if (base.endsWith(".js")) candidates.push(base.slice(0, -3) + ".ts", base.slice(0, -3) + ".tsx");
|
|
210
|
+
if (base.endsWith(".mjs")) candidates.push(base.slice(0, -4) + ".mts");
|
|
211
|
+
for (const index of ["index.ts", "index.mts", "index.mjs", "index.js"]) candidates.push(path.join(base, index));
|
|
212
|
+
for (const c of candidates) if (existsSync(c) && statSync(c).isFile()) return c;
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The absolute file an import specifier names, or null when it names none in this repo (a
|
|
218
|
+
* package, a virtual module, a `#` subpath import, an alias with no file behind it).
|
|
219
|
+
* A relative specifier that matches no file keeps its plain resolved path, as it always has.
|
|
220
|
+
* @param {string} rootDir absolute repo root
|
|
221
|
+
* @param {string} fromAbs absolute path of the importing file
|
|
222
|
+
* @param {string} specifier
|
|
223
|
+
* @param {{ base: string; rules: [string, unknown][] }} [aliases] from readAliases, when reading many
|
|
224
|
+
* @returns {string | null}
|
|
225
|
+
*/
|
|
226
|
+
export function resolveImport(rootDir, fromAbs, specifier, aliases = readAliases(rootDir)) {
|
|
227
|
+
if (specifier.startsWith("#")) return null;
|
|
228
|
+
const clean = specifier.replace(/[?#].*$/, "");
|
|
229
|
+
if (clean === "." || clean === ".." || clean.startsWith("./") || clean.startsWith("../")) {
|
|
230
|
+
const base = path.resolve(path.dirname(fromAbs), clean);
|
|
231
|
+
return fileAt(base) ?? base;
|
|
232
|
+
}
|
|
233
|
+
if (clean.startsWith("/")) return fileAt(path.join(rootDir, clean)) ?? fileAt(clean);
|
|
234
|
+
for (const target of aliasTargets(aliases, clean) ?? []) {
|
|
235
|
+
const file = fileAt(path.resolve(aliases.base, target));
|
|
236
|
+
if (file) return file;
|
|
237
|
+
}
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
40
241
|
/**
|
|
41
|
-
* Every `import Name from "<
|
|
42
|
-
*
|
|
242
|
+
* Every `import Name from "<specifier>"` in a source file that names a file here — relative,
|
|
243
|
+
* root-relative (`/src/…`) or through a tsconfig path alias (`@/…`) — resolved to repo-relative
|
|
244
|
+
* paths. For .astro files only the frontmatter is read; for .ts/.mjs the whole file. Package and
|
|
43
245
|
* virtual-module imports are skipped — they are not files in this repo.
|
|
44
246
|
* @param {string} rootDir absolute repo root
|
|
45
247
|
* @param {string} fileRel repo-relative path of the file to read
|
|
@@ -50,14 +252,14 @@ export function defaultImports(rootDir, fileRel) {
|
|
|
50
252
|
if (!existsSync(abs)) return [];
|
|
51
253
|
const source = readFileSync(abs, "utf8");
|
|
52
254
|
const code = fileRel.endsWith(".astro") ? frontmatterOf(source) : source;
|
|
255
|
+
const aliases = readAliases(rootDir);
|
|
53
256
|
/** @type {{ name: string; file: string }[]} */
|
|
54
257
|
const out = [];
|
|
55
258
|
const re = /^\s*import\s+([A-Za-z_$][\w$]*)\s+from\s+["']([^"']+)["']/gm;
|
|
56
259
|
let m;
|
|
57
260
|
while ((m = re.exec(code))) {
|
|
58
|
-
const
|
|
59
|
-
if (
|
|
60
|
-
out.push({ name: m[1], file: toRepoRelative(rootDir, path.resolve(path.dirname(abs), spec)) });
|
|
261
|
+
const file = resolveImport(rootDir, abs, m[2], aliases);
|
|
262
|
+
if (file) out.push({ name: m[1], file: toRepoRelative(rootDir, file) });
|
|
61
263
|
}
|
|
62
264
|
return out;
|
|
63
265
|
}
|
package/src/shell/marks.mjs
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
// The lab's hand-set marks — everything a person ticks
|
|
2
|
-
//
|
|
1
|
+
// The lab's hand-set marks — everything a person ticks rather than something the lab derives,
|
|
2
|
+
// plus the path rule every mark obeys.
|
|
3
3
|
//
|
|
4
4
|
// <directory>/responsive.json { "done": string[], "approved": string[], "updated": string|null }
|
|
5
|
-
// <directory>/cull.json { "marked": string[], "updated": string|null }
|
|
6
5
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
6
|
+
// Keyed by the story module's repo-relative path (`<directory>/<tier>/…/<Name>.stories.ts`, or any
|
|
7
|
+
// extension in STORY_EXTENSIONS below) — one row means one key everywhere — and validated through
|
|
8
|
+
// `pathOffence` below.
|
|
10
9
|
//
|
|
11
10
|
// Why the responsive state is marked and not measured: responsive work is approval-gated and
|
|
12
11
|
// final-versions-only (ruled 2026-09-06) — a version is worked on only once it has been approved
|
|
@@ -14,16 +13,24 @@
|
|
|
14
13
|
// be withdrawn later. Neither fact is readable from the code, so both are ticked here.
|
|
15
14
|
//
|
|
16
15
|
// Dependency-free Node (fs + path only) on purpose: this module is imported inside Vite
|
|
17
|
-
// (./lab-index.ts, at build)
|
|
18
|
-
//
|
|
16
|
+
// (./lab-index.ts, at build) and by a plain Vite middleware (../../index.mjs). One parser, two
|
|
17
|
+
// runtimes.
|
|
19
18
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
19
|
import path from "node:path";
|
|
21
20
|
|
|
22
21
|
/** @typedef {import("../../defaults.mjs").LabConfig} LabConfig */
|
|
23
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Every extension the core finds a stories file by — the `*.stories.{…}` glob in
|
|
25
|
+
* src/core/virtual-module/get-story-modules.ts. A file the lab lists must be one it can mark.
|
|
26
|
+
*/
|
|
27
|
+
export const STORY_EXTENSIONS = ["ts", "tsx", "js", "jsx", "mts", "mtsx", "mjs", "mjsx"];
|
|
28
|
+
|
|
24
29
|
/** A story module inside the lab directory: the only thing any mark may name. */
|
|
25
30
|
export const storiesPattern = (directory) =>
|
|
26
|
-
new RegExp(
|
|
31
|
+
new RegExp(
|
|
32
|
+
`^${directory.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/(?:[\\w.-]+/)*[\\w.-]+\\.stories\\.(?:${STORY_EXTENSIONS.join("|")})$`,
|
|
33
|
+
);
|
|
27
34
|
|
|
28
35
|
/**
|
|
29
36
|
* Why `entry` is not a markable story module under `tier`, or null when it is.
|
|
@@ -37,7 +44,7 @@ export const storiesPattern = (directory) =>
|
|
|
37
44
|
export const pathOffence = (rootDir, config, entry, tier, tierReason) => {
|
|
38
45
|
if (typeof entry !== "string") return "not a string";
|
|
39
46
|
if (!storiesPattern(config.directory).test(entry) || entry.includes(".."))
|
|
40
|
-
return `not a ${config.directory}/**/*.stories.
|
|
47
|
+
return `not a ${config.directory}/**/*.stories.{${STORY_EXTENSIONS.join(",")}} path`;
|
|
41
48
|
if (!tier) return tierReason;
|
|
42
49
|
if (!entry.startsWith(tier)) return tierReason;
|
|
43
50
|
if (!existsSync(path.join(rootDir, entry))) return "no such file";
|
|
@@ -54,10 +61,6 @@ export const responsiveOffence = (rootDir, config, entry) =>
|
|
|
54
61
|
"only section versions can be marked responsive",
|
|
55
62
|
);
|
|
56
63
|
|
|
57
|
-
/** @param {string} rootDir @param {LabConfig} config @param {unknown} entry */
|
|
58
|
-
export const cullOffence = (rootDir, config, entry) =>
|
|
59
|
-
pathOffence(rootDir, config, entry, config.cullDir, "only the explorations tier can be marked");
|
|
60
|
-
|
|
61
64
|
/** @param {unknown} value */
|
|
62
65
|
const stringList = (value) =>
|
|
63
66
|
Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
|
|
@@ -107,30 +110,3 @@ export function writeResponsive(rootDir, config, done, approved) {
|
|
|
107
110
|
writeFileSync(path.join(rootDir, config.responsiveFile), JSON.stringify(data, null, 2) + "\n");
|
|
108
111
|
return data;
|
|
109
112
|
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* The cull marks as written.
|
|
113
|
-
* @param {string} rootDir absolute repo root
|
|
114
|
-
* @param {LabConfig} config
|
|
115
|
-
* @returns {{ marked: string[]; updated: string | null }}
|
|
116
|
-
*/
|
|
117
|
-
export function readCull(rootDir, config) {
|
|
118
|
-
const data = readJson(rootDir, config.cullFile);
|
|
119
|
-
if (!data) return { marked: [], updated: null };
|
|
120
|
-
return {
|
|
121
|
-
marked: stringList(data.marked),
|
|
122
|
-
updated: typeof data.updated === "string" ? data.updated : null,
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Replace the cull marks.
|
|
128
|
-
* @param {string} rootDir absolute repo root
|
|
129
|
-
* @param {LabConfig} config
|
|
130
|
-
* @param {string[]} marked
|
|
131
|
-
*/
|
|
132
|
-
export function writeCull(rootDir, config, marked) {
|
|
133
|
-
const data = { marked: [...new Set(marked)].sort(), updated: new Date().toISOString() };
|
|
134
|
-
writeFileSync(path.join(rootDir, config.cullFile), JSON.stringify(data, null, 2) + "\n");
|
|
135
|
-
return data;
|
|
136
|
-
}
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
// Vendored from astrobook 0.13.3 (packages/ui/src/components/app.astro) — see
|
|
3
|
-
// ../../core/LICENSE-astrobook.
|
|
3
|
+
// ../../core/LICENSE-astrobook.
|
|
4
4
|
//
|
|
5
|
-
// The `/lab` home page:
|
|
6
|
-
//
|
|
7
|
-
|
|
5
|
+
// The `/lab` home page. Divergence: upstream wrapped the `home` component in its layout; since the
|
|
6
|
+
// redesign (2026-09-24) the home component IS the whole document — it renders through the
|
|
7
|
+
// package's chrome shell (../../chrome/Shell.astro) itself, so this file only mounts it.
|
|
8
8
|
import Home from 'virtual:astrobook/components/home.mjs'
|
|
9
9
|
---
|
|
10
10
|
|
|
11
|
-
<
|
|
12
|
-
<Home />
|
|
13
|
-
</Layout>
|
|
11
|
+
<Home />
|