@orbytes/astrolab 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/LICENSE +37 -0
- package/README.md +410 -0
- package/bin/lab-cull.mjs +401 -0
- package/bin/pin-gallery.mjs +121 -0
- package/defaults.mjs +120 -0
- package/dist/core/astro-integration.js +130 -0
- package/dist/core/index.js +6 -0
- package/dist/core/lib-paths.js +29 -0
- package/dist/core/options.js +173 -0
- package/dist/core/utils/get-exports.js +52 -0
- package/dist/core/utils/invariant.js +12 -0
- package/dist/core/utils/kebab-case.js +15 -0
- package/dist/core/utils/path-builder.js +22 -0
- package/dist/core/utils/path.js +53 -0
- package/dist/core/virtual-module/get-story-modules.js +60 -0
- package/dist/core/virtual-module/story-modules.js +8 -0
- package/dist/core/virtual-module/virtual-module-ids.js +22 -0
- package/dist/core/virtual-module/virtual-routes.js +83 -0
- package/dist/core/virtual-module/vite-plugin.js +98 -0
- package/docs/PIN-CONTRACT.md +263 -0
- package/docs/PIN.md +429 -0
- package/index.d.ts +279 -0
- package/index.mjs +347 -0
- package/package.json +93 -0
- package/src/Empty.astro +4 -0
- package/src/Home.astro +298 -0
- package/src/LabHead.astro +1102 -0
- package/src/core/LICENSE-astrobook +166 -0
- package/src/core/astro-integration.ts +166 -0
- package/src/core/client.ts +89 -0
- package/src/core/index.ts +7 -0
- package/src/core/lib/components/empty.astro +1 -0
- package/src/core/lib/components/head.astro +1 -0
- package/src/core/lib/components/home.astro +8 -0
- package/src/core/lib/components/with-decorators.astro +22 -0
- package/src/core/lib/pages/app.astro +19 -0
- package/src/core/lib/pages/preview.astro +17 -0
- package/src/core/lib/pages/story.astro +16 -0
- package/src/core/lib-paths.ts +72 -0
- package/src/core/options.ts +262 -0
- package/src/core/utils/get-exports.ts +59 -0
- package/src/core/utils/invariant.ts +13 -0
- package/src/core/utils/kebab-case.ts +30 -0
- package/src/core/utils/path-builder.ts +45 -0
- package/src/core/utils/path.ts +80 -0
- package/src/core/virtual-module/get-story-modules.ts +110 -0
- package/src/core/virtual-module/story-modules.ts +9 -0
- package/src/core/virtual-module/virtual-module-ids.ts +17 -0
- package/src/core/virtual-module/virtual-routes.ts +130 -0
- package/src/core/virtual-module/vite-plugin.ts +125 -0
- package/src/pin/board.mjs +1521 -0
- package/src/pin/index.mjs +666 -0
- package/src/pin/shot.mjs +427 -0
- package/src/pin/source-stamp.mjs +159 -0
- package/src/pin/tickets.mjs +697 -0
- package/src/pin/toolbar.js +3181 -0
- package/src/shell/Browse.astro +371 -0
- package/src/shell/CardGrid.astro +297 -0
- package/src/shell/Viewport.astro +1330 -0
- package/src/shell/index.json.ts +12 -0
- package/src/shell/lab-index.ts +344 -0
- package/src/shell/lab-params.ts +245 -0
- package/src/shell/live-files.mjs +164 -0
- package/src/shell/marks.mjs +136 -0
- package/src/types/index.ts +6 -0
- package/src/types/types.ts +239 -0
- package/src/types/virtual.d.ts +29 -0
- package/src/ui/components/app.astro +13 -0
- package/src/ui/components/build-path.ts +13 -0
- package/src/ui/components/build-tree.ts +108 -0
- package/src/ui/components/collapse-duration.ts +28 -0
- package/src/ui/components/compress-terms.ts +10 -0
- package/src/ui/components/dashboard-layout.astro +39 -0
- package/src/ui/components/home.astro +65 -0
- package/src/ui/components/layout.astro +110 -0
- package/src/ui/components/preview-layout.astro +109 -0
- package/src/ui/components/sidebar-button-fullscreen.astro +38 -0
- package/src/ui/components/sidebar-button-search.astro +23 -0
- package/src/ui/components/sidebar-button-theme.astro +9 -0
- package/src/ui/components/sidebar-button.astro +24 -0
- package/src/ui/components/sidebar-resize-handle.astro +74 -0
- package/src/ui/components/sidebar-search-panel.astro +41 -0
- package/src/ui/components/sidebar-search-script.ts +103 -0
- package/src/ui/components/sidebar-title.astro +17 -0
- package/src/ui/components/sidebar-tree-node.astro +143 -0
- package/src/ui/components/sidebar-tree.astro +84 -0
- package/src/ui/components/sidebar.astro +29 -0
- package/src/ui/components/theme-message.ts +26 -0
- package/src/ui/components/theme-script.astro +71 -0
- package/src/ui/components/theme-toggle.astro +63 -0
- package/src/ui/components/theme.ts +32 -0
- package/src/ui/index.ts +4 -0
- package/src/ui/lab.css +549 -0
- package/virtual.d.ts +42 -0
package/index.d.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import type { AstroIntegration } from "astro";
|
|
2
|
+
|
|
3
|
+
/** One tier of the lab — the first path segment under `directory`. */
|
|
4
|
+
export interface LabTierOption {
|
|
5
|
+
/** The folder name under `directory`. */
|
|
6
|
+
id: string;
|
|
7
|
+
/** Shown on the tier card and the folder pages. Defaults to `id`, capitalised. */
|
|
8
|
+
label?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Section versions: this tier's stories carry the responsive mark and render at page width in
|
|
11
|
+
* thumbnails and in the viewport configurator. Defaults to true for a tier named "sections".
|
|
12
|
+
*/
|
|
13
|
+
responsive?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* This tier's stories may be marked for deletion in the sidebar and removed by
|
|
16
|
+
* `orbytes-lab-cull`. Defaults to true for a tier named "explorations".
|
|
17
|
+
*/
|
|
18
|
+
cullable?: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface OrbytesLabOptions {
|
|
22
|
+
/** Where the stories live, relative to the project root. Default `"src/lab"`. */
|
|
23
|
+
directory?: string;
|
|
24
|
+
/** Where the lab is served. Default `"/lab"`. */
|
|
25
|
+
subpath?: string;
|
|
26
|
+
/** The consumer's token/stylesheet files, as Astrobook takes them. Default `[]`. */
|
|
27
|
+
css?: string[];
|
|
28
|
+
/**
|
|
29
|
+
* A path to the consumer's own head component, rendered inside the lab's head — how fonts and
|
|
30
|
+
* anything else site-specific reach lab pages. Relative to the project root.
|
|
31
|
+
*/
|
|
32
|
+
head?: string;
|
|
33
|
+
/** The lab's name, shown in the dashboard. Default `"Component lab"`. */
|
|
34
|
+
title?: string;
|
|
35
|
+
/**
|
|
36
|
+
* The deployed lab's base URL — the real minified build, which a dev server is not. Every card
|
|
37
|
+
* and the story header link to the same story there. Omit and those links do not appear.
|
|
38
|
+
*/
|
|
39
|
+
stagingUrl?: string;
|
|
40
|
+
/**
|
|
41
|
+
* A branch preview URL with a `{branch}` placeholder, e.g.
|
|
42
|
+
* `"https://{branch}-my-site.workers.dev"`. Omit and the home page lists no branches.
|
|
43
|
+
*/
|
|
44
|
+
previewUrlTemplate?: string;
|
|
45
|
+
/**
|
|
46
|
+
* The Feedbucket key. Set it and every lab page carries the widget; the consumer keeps its own
|
|
47
|
+
* staging gate, so the option takes a key or nothing.
|
|
48
|
+
*/
|
|
49
|
+
feedbucketKey?: string;
|
|
50
|
+
/**
|
|
51
|
+
* The tiers, in reading order. Default `["sections", "components", "explorations"]`; a tier
|
|
52
|
+
* named "sections" takes the responsive role and one named "explorations" the cull role unless
|
|
53
|
+
* the entry says otherwise.
|
|
54
|
+
*/
|
|
55
|
+
tiers?: (string | LabTierOption)[];
|
|
56
|
+
/**
|
|
57
|
+
* The pin board — the dev-only click-to-ticket feedback half of this package (`docs/PIN.md`).
|
|
58
|
+
*
|
|
59
|
+
* `true` or omitted ships it at `/pin` with its defaults; `false` omits it entirely, for a site
|
|
60
|
+
* that wants the component lab alone. An object is the pin integration's own options, and the
|
|
61
|
+
* lab fills in `links` from its RESOLVED `subpath` so the board's "Lab" link is derived rather
|
|
62
|
+
* than guessed — pass your own `links` to override that.
|
|
63
|
+
*
|
|
64
|
+
* @default true
|
|
65
|
+
*/
|
|
66
|
+
pin?: boolean | null | OrbytesPinOptions;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The pin integration's options. Everything here is dev-only; none of it reaches a build. */
|
|
70
|
+
export interface OrbytesPinOptions {
|
|
71
|
+
/**
|
|
72
|
+
* SITE-relative directories whose `.astro` files get `data-orbytes-src`.
|
|
73
|
+
* @default ["src/lab/sections", "src/components"]
|
|
74
|
+
*/
|
|
75
|
+
stamp?: string[];
|
|
76
|
+
/**
|
|
77
|
+
* SITE-relative `.astro` paths exempt from the stamp's hard failure — the same base as `stamp`,
|
|
78
|
+
* which it was NOT until 2026-09-22 (it was repo-relative, so the two agreed only in a repo
|
|
79
|
+
* whose app sits in `site/` and the exemption silently lapsed everywhere else). A repo-relative
|
|
80
|
+
* path is still accepted, so a config written before that date keeps working.
|
|
81
|
+
* @default []
|
|
82
|
+
*/
|
|
83
|
+
stampSkip?: string[];
|
|
84
|
+
/** Repo-relative board directory: tickets in `tasks/`, PNGs in `assets/`. @default "backlog" */
|
|
85
|
+
backlogDir?: string;
|
|
86
|
+
/** Canonical screenshot home; the repo copy is a hardlink into it. @default ~/.orbytes/feedback-archive */
|
|
87
|
+
archiveDir?: string;
|
|
88
|
+
/**
|
|
89
|
+
* The archive folder under `archiveDir` this project's screenshots live in. Omit and it is the
|
|
90
|
+
* checkout's own directory name — which two clones of DIFFERENT repos under the same folder name
|
|
91
|
+
* silently share, one overwriting the other's `pin-007.png`. Set it to be sure.
|
|
92
|
+
*/
|
|
93
|
+
project?: string | null;
|
|
94
|
+
/** Write tickets but take no screenshots. `playwright` is then never loaded. @default true */
|
|
95
|
+
shots?: boolean;
|
|
96
|
+
/**
|
|
97
|
+
* Where the board is served, dev only. `<route>/assets/*` serves its screenshots and
|
|
98
|
+
* `<route>/api/ticket` is the one write endpoint.
|
|
99
|
+
*
|
|
100
|
+
* It is MIDDLEWARE, installed ahead of Astro's own request handler, so it SHADOWS a host page at
|
|
101
|
+
* the same path — in dev, silently. A site with its own `/pin` page sets something else here.
|
|
102
|
+
* @default "/pin"
|
|
103
|
+
*/
|
|
104
|
+
route?: string;
|
|
105
|
+
/**
|
|
106
|
+
* The links in the board's top-right corner. `null` keeps the board's historical pair (the site
|
|
107
|
+
* root and `/lab`); `[]` draws none. Through `orbytesLab()` this is filled in from the lab's
|
|
108
|
+
* resolved subpath, so it rarely needs setting by hand.
|
|
109
|
+
*/
|
|
110
|
+
links?: { href: string; label?: string }[] | null;
|
|
111
|
+
/** @default "orbytes-pin" */
|
|
112
|
+
appId?: string;
|
|
113
|
+
/** @default "Pin" */
|
|
114
|
+
appName?: string;
|
|
115
|
+
/** An Astro dev-toolbar icon name, or an inline `<svg…>` string. @default "bug" */
|
|
116
|
+
icon?: string;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The orbytes component lab AND the pin board: the Astrobook core vendored into this package
|
|
121
|
+
* (`src/core/`, 0.13.3), the lab shell around it, and the dev-only feedback board at `/pin`. Astro
|
|
122
|
+
* flattens the returned array, so one entry in `integrations` configures all three.
|
|
123
|
+
*
|
|
124
|
+
* `pin: false` returns the lab alone.
|
|
125
|
+
*/
|
|
126
|
+
export default function orbytesLab(options?: OrbytesLabOptions): AstroIntegration[];
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The pin board on its own, for a site that wants it without the lab. `orbytesLab()` returns this
|
|
130
|
+
* as one of its entries; calling it directly is the same integration with the same dev-only gates.
|
|
131
|
+
*/
|
|
132
|
+
export function orbytesPin(options?: OrbytesPinOptions): AstroIntegration;
|
|
133
|
+
|
|
134
|
+
/* ── the pin board's own surface, re-exported from the root ────────────────────────────────────
|
|
135
|
+
The vocabulary and the readers, so an agent working tickets off disk imports from one place.
|
|
136
|
+
`docs/PIN-CONTRACT.md` is the interface these agree on; do not restate it here. */
|
|
137
|
+
|
|
138
|
+
/** The six states a ticket can be in, in board order. Nothing else is ever WRITTEN to `status`. */
|
|
139
|
+
export type PinStatus =
|
|
140
|
+
| "Backlog"
|
|
141
|
+
| "Ready for agent"
|
|
142
|
+
| "In Progress"
|
|
143
|
+
| "Ready for review"
|
|
144
|
+
| "Resolved"
|
|
145
|
+
| "Cancelled";
|
|
146
|
+
|
|
147
|
+
/** Retired 2026-09-22 and still readable: `To Do` → `Ready for agent`, `Done` → `Resolved`. */
|
|
148
|
+
export type PinLegacyStatus = "To Do" | "Done";
|
|
149
|
+
|
|
150
|
+
/** What a `status:` line read off disk may say — today's vocabulary, or a legacy alias. */
|
|
151
|
+
export type PinStatusOnDisk = PinStatus | PinLegacyStatus | (string & {});
|
|
152
|
+
|
|
153
|
+
/** The three priorities a card may carry. */
|
|
154
|
+
export type PinPriority = "high" | "medium" | "low";
|
|
155
|
+
|
|
156
|
+
/** Per-ticket choice of whether an agent fires immediately or waits on the queue. */
|
|
157
|
+
export type PinDispatch = "now" | "queue";
|
|
158
|
+
|
|
159
|
+
/** One ticket, as `listTickets` reads it back. Pin fields come from the body's fenced block. */
|
|
160
|
+
export interface PinTicket {
|
|
161
|
+
id: string;
|
|
162
|
+
title: string;
|
|
163
|
+
/** RAW — put it through `normaliseStatus()`, or ask `isOpen()` / `isCancelled()` /
|
|
164
|
+
* `isAgentReady()`. Never compare it to a literal. */
|
|
165
|
+
status: PinStatusOnDisk;
|
|
166
|
+
labels: string[];
|
|
167
|
+
priority: string;
|
|
168
|
+
assignee: string[];
|
|
169
|
+
dependencies: string[];
|
|
170
|
+
created_date: string;
|
|
171
|
+
updated_date?: string;
|
|
172
|
+
/** Repo-relative path of the ticket file itself. */
|
|
173
|
+
file: string;
|
|
174
|
+
/** The reviewer's words, verbatim. */
|
|
175
|
+
comment: string;
|
|
176
|
+
pin: {
|
|
177
|
+
dispatch: PinDispatch;
|
|
178
|
+
/** Repo-relative, or the string `unresolved`. */
|
|
179
|
+
source: string;
|
|
180
|
+
selector: string;
|
|
181
|
+
url: string;
|
|
182
|
+
viewport: { width: number; height: number; dpr: number };
|
|
183
|
+
scroll: { x: number; y: number };
|
|
184
|
+
rect: { x: number; y: number; width: number; height: number };
|
|
185
|
+
/** Repo-relative path of the PNG on disk. */
|
|
186
|
+
shot: string;
|
|
187
|
+
outer_html: string;
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export const STATUSES: readonly PinStatus[];
|
|
192
|
+
export const BACKLOG: "Backlog";
|
|
193
|
+
export const READY_FOR_AGENT: "Ready for agent";
|
|
194
|
+
export const IN_PROGRESS: "In Progress";
|
|
195
|
+
export const READY_FOR_REVIEW: "Ready for review";
|
|
196
|
+
export const RESOLVED: "Resolved";
|
|
197
|
+
export const CANCELLED: "Cancelled";
|
|
198
|
+
/** What a freshly picked ticket is written as. */
|
|
199
|
+
export const DEFAULT_STATUS: "Ready for agent";
|
|
200
|
+
/** The two statuses that end a ticket's life: accepted, or abandoned. */
|
|
201
|
+
export const CLOSED_STATUSES: readonly ("Resolved" | "Cancelled")[];
|
|
202
|
+
/** Neither `Resolved` nor `Cancelled` — the one definition of "open", shared by every surface. */
|
|
203
|
+
export const OPEN_STATUSES: readonly PinStatus[];
|
|
204
|
+
|
|
205
|
+
/** Today's vocabulary for a status read off disk; an unknown status is never rounded to a known one. */
|
|
206
|
+
export function normaliseStatus(status: unknown): string;
|
|
207
|
+
/** Open — neither accepted nor cancelled. `isOpen("Done")` is `false`. */
|
|
208
|
+
export function isOpen(status: unknown): boolean;
|
|
209
|
+
/** Archived. Every agent-facing read drops these; `/pin` is the one place they are visible. */
|
|
210
|
+
export function isCancelled(status: unknown): boolean;
|
|
211
|
+
/** May an agent START work on this ticket? Exactly one status says yes. */
|
|
212
|
+
export function isAgentReady(status: unknown): boolean;
|
|
213
|
+
|
|
214
|
+
/** The first ancestor of `from` containing `.git`. Throws rather than guessing. */
|
|
215
|
+
export function findRepoRoot(from: string): string;
|
|
216
|
+
/** Throws, naming the file, on a ticket whose fenced pin block is missing or unparseable. */
|
|
217
|
+
export function parseTicket(text: string, file?: string): PinTicket;
|
|
218
|
+
/**
|
|
219
|
+
* Every LIVE ticket on disk — the agent-facing reader. `Cancelled` tickets are NOT in it unless
|
|
220
|
+
* `includeCancelled` is passed. Statuses come back raw; normalise before comparing.
|
|
221
|
+
*/
|
|
222
|
+
export function listTickets(
|
|
223
|
+
repoRoot: string,
|
|
224
|
+
options?: { backlogDir?: string; includeCancelled?: boolean },
|
|
225
|
+
): PinTicket[];
|
|
226
|
+
export function writeTicket(
|
|
227
|
+
repoRoot: string,
|
|
228
|
+
payload: {
|
|
229
|
+
comment: string;
|
|
230
|
+
dispatch: PinDispatch;
|
|
231
|
+
source: string | null;
|
|
232
|
+
selector: string;
|
|
233
|
+
url: string;
|
|
234
|
+
viewport: { width: number; height: number; dpr: number };
|
|
235
|
+
scroll: { x: number; y: number };
|
|
236
|
+
rect: { x: number; y: number; width: number; height: number };
|
|
237
|
+
outerHTML: string;
|
|
238
|
+
},
|
|
239
|
+
options?: { backlogDir?: string },
|
|
240
|
+
): { id: string; num: number; file: string; shot: string; archiveName: string; absolute: string };
|
|
241
|
+
/** Every ticket on disk, plus the ones that could not be read. Read fresh on every call. */
|
|
242
|
+
export function collectTickets(
|
|
243
|
+
repoRoot: string,
|
|
244
|
+
options?: { backlogDir?: string; includeCancelled?: boolean },
|
|
245
|
+
): { tickets: object[]; broken: { file: string; reason: string }[]; paths: { backlog: string; tasks: string; assets: string } };
|
|
246
|
+
/**
|
|
247
|
+
* The ONE writer of a ticket's frontmatter. Rewrites only the lines named in `change` (plus
|
|
248
|
+
* `updated_date`) and returns the body byte for byte. Refuses rather than warns.
|
|
249
|
+
*/
|
|
250
|
+
export function updateTicket(
|
|
251
|
+
repoRoot: string,
|
|
252
|
+
change: {
|
|
253
|
+
id: string;
|
|
254
|
+
status?: PinStatus | PinLegacyStatus;
|
|
255
|
+
priority?: PinPriority;
|
|
256
|
+
/** What the caller believed; a mismatch refuses the write rather than overwriting it. */
|
|
257
|
+
expect?: { status?: string | null; priority?: string | null };
|
|
258
|
+
},
|
|
259
|
+
options?: { backlogDir?: string; now?: Date },
|
|
260
|
+
): { id: string; file: string; status: PinStatus; priority: string | null; changed: string[]; bodyUnchanged: true };
|
|
261
|
+
/**
|
|
262
|
+
* The kanban, as one HTML document. Passing no `apiHref` is what makes a page read-only — the
|
|
263
|
+
* standalone `orbytes-pin-gallery` file passes none. `links` draws the corner nav.
|
|
264
|
+
*/
|
|
265
|
+
export function renderBoard(
|
|
266
|
+
tickets: object[],
|
|
267
|
+
broken?: { file: string; reason: string }[],
|
|
268
|
+
options?: {
|
|
269
|
+
assetHref?: (ticket: object) => string | null;
|
|
270
|
+
apiHref?: string | null;
|
|
271
|
+
generatedAt?: Date;
|
|
272
|
+
links?: { href: string; label?: string }[] | null;
|
|
273
|
+
},
|
|
274
|
+
): string;
|
|
275
|
+
/** One card on its own — what the write endpoint hands back after a successful change. */
|
|
276
|
+
export function renderCard(
|
|
277
|
+
ticket: object,
|
|
278
|
+
options?: { assetHref?: (ticket: object) => string | null; live?: boolean },
|
|
279
|
+
): string;
|
package/index.mjs
ADDED
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// @orbytes/astrolab — the component lab AND the pin board, as one Astro integration.
|
|
2
|
+
//
|
|
3
|
+
// import orbytesLab from "@orbytes/astrolab";
|
|
4
|
+
// integrations: [ includeLab ? orbytesLab({ css: ["./src/styles/global.css"] }) : null ]
|
|
5
|
+
//
|
|
6
|
+
// The consumer configures ONE integration, not three: this module builds the Astrobook integration
|
|
7
|
+
// from the copy of its core vendored at ./src/core (0.13.3 — src/core/LICENSE-astrobook) and
|
|
8
|
+
// returns it alongside the lab shell and the pin board, which Astro flattens into its integration
|
|
9
|
+
// list. The vendored core is unforked apart from the divergences each file's header names —
|
|
10
|
+
// everything the lab adds is layered beside it rather than patched into it.
|
|
11
|
+
//
|
|
12
|
+
// ── the pin board (./src/pin/, moved in 2026-09-22) ─────────────────────────────────────────────
|
|
13
|
+
// Decided 2026-09-22: the lab and the pin board are ONE app, bundled and working as one — not two
|
|
14
|
+
// packages a site installs separately. So `/lab` and `/pin` come from one call, one install, one
|
|
15
|
+
// dev server. `pin: false` omits it entirely — a site may want the lab alone — and `pin: {…}` passes
|
|
16
|
+
// options straight through (ops/standalone-plan.md › The pin board merges in; docs/PIN.md).
|
|
17
|
+
//
|
|
18
|
+
// The pin half is DEV ONLY and gates itself four times over; it is registered in staging builds
|
|
19
|
+
// along with the lab and does nothing there, because its first assertion is `command === "dev"`.
|
|
20
|
+
// Nothing in this file may gate it instead — a caller that reaches ./src/pin/index.mjs directly
|
|
21
|
+
// must be as safe as one that comes through here.
|
|
22
|
+
//
|
|
23
|
+
// Three jobs:
|
|
24
|
+
// 1. configure Astrobook — directory, subpath, css, title, and this package's own head and home
|
|
25
|
+
// components (which render the consumer's head inside them).
|
|
26
|
+
// 2. inject the shell's routes, prerendered like every Astrobook route:
|
|
27
|
+
// <subpath>/browse/[...path] folder pages → ./src/shell/Browse.astro
|
|
28
|
+
// <subpath>/viewport/[...id] viewport configurator → ./src/shell/Viewport.astro
|
|
29
|
+
// <subpath>/index.json the lab index → ./src/shell/index.json.ts
|
|
30
|
+
// and define `virtual:orbytes-lab/*` so those pages can read the resolved options, the
|
|
31
|
+
// consumer's head component and the consumer's CSS without importing anything by path.
|
|
32
|
+
// 3. serve the two mark APIs on the Vite dev server only (astro:server:setup never runs in a
|
|
33
|
+
// build, so a deployed lab has no write path and the switches hide themselves):
|
|
34
|
+
// GET /__lab/cull { marked: string[], updated: string | null }
|
|
35
|
+
// PUT /__lab/cull body { marked: string[] } — every entry must be an existing
|
|
36
|
+
// stories file in the CULLABLE tier AND not live on any page;
|
|
37
|
+
// otherwise 400 with the offenders. Writes <directory>/cull.json.
|
|
38
|
+
// GET /__lab/responsive { done: string[], approved: string[], updated: string | null }
|
|
39
|
+
// PUT /__lab/responsive body { done: string[], approved?: string[] } — every entry must
|
|
40
|
+
// be an existing stories file in the RESPONSIVE tier; otherwise 400
|
|
41
|
+
// with the offenders. Writes <directory>/responsive.json.
|
|
42
|
+
// Marks only. NOTHING HERE DELETES A FILE — that is `orbytes-lab-cull`, a separate command,
|
|
43
|
+
// run by hand and with a dry run first.
|
|
44
|
+
//
|
|
45
|
+
// Gotcha (2026-09-06): Astro re-imports astro.config.mjs on a config change, but this module and
|
|
46
|
+
// its ./src/shell/*.mjs imports stay in Node's ESM cache for the life of the process — so an edit
|
|
47
|
+
// here is NOT picked up by the config-change restart. Stop and start the dev server after editing.
|
|
48
|
+
import { fileURLToPath } from "node:url";
|
|
49
|
+
// GENERATED, not source — `scripts/build-core.mjs` compiles src/core/ into dist/core/ and the
|
|
50
|
+
// package's `prepack`/`prepare` scripts run it. It cannot be `./src/core/index.ts`: Node loads
|
|
51
|
+
// astro.config.mjs through its own ESM loader, and Node's type-stripping is hard-disabled under
|
|
52
|
+
// node_modules, so an INSTALLED copy of this package could not be loaded at all. Measured
|
|
53
|
+
// 2026-09-22 against a packed tarball in a clean project. Editing src/core/ means re-running
|
|
54
|
+
// `npm run build:core`.
|
|
55
|
+
import { createAstrobookIntegration } from "./dist/core/index.js";
|
|
56
|
+
import { resolveLabOptions } from "./defaults.mjs";
|
|
57
|
+
import orbytesPin from "./src/pin/index.mjs";
|
|
58
|
+
import { defaultImports, liveComponentFiles, storyComponentFile } from "./src/shell/live-files.mjs";
|
|
59
|
+
import {
|
|
60
|
+
cullOffence,
|
|
61
|
+
readCull,
|
|
62
|
+
readResponsive,
|
|
63
|
+
responsiveOffence,
|
|
64
|
+
writeCull,
|
|
65
|
+
writeResponsive,
|
|
66
|
+
} from "./src/shell/marks.mjs";
|
|
67
|
+
|
|
68
|
+
const file = (relative) => fileURLToPath(new URL(relative, import.meta.url));
|
|
69
|
+
|
|
70
|
+
const VIRTUAL = {
|
|
71
|
+
config: "virtual:orbytes-lab/config.mjs",
|
|
72
|
+
head: "virtual:orbytes-lab/user-head.mjs",
|
|
73
|
+
css: "virtual:orbytes-lab/user-css.mjs",
|
|
74
|
+
};
|
|
75
|
+
// Resolved ids carry no leading NUL, the same choice Astrobook makes: Astro's SSR externalisation
|
|
76
|
+
// and its .astro pipeline both handle plain ids, and a NUL-prefixed one has to be special-cased.
|
|
77
|
+
const RESOLVED = {
|
|
78
|
+
[VIRTUAL.config]: "__virtual_orbytes_lab_config__.mjs",
|
|
79
|
+
[VIRTUAL.head]: "__virtual_orbytes_lab_user_head__.mjs",
|
|
80
|
+
[VIRTUAL.css]: "__virtual_orbytes_lab_user_css__.mjs",
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Why an entry may not be marked for deletion, or null when it may. `importedByLive` maps a
|
|
85
|
+
* component file to the live section that imports it, so an exploration a live section uses is
|
|
86
|
+
* refused here with the same reason the cull script would give later.
|
|
87
|
+
*/
|
|
88
|
+
const cullEntryOffence = (rootDir, config, entry, liveByFile, importedByLive) => {
|
|
89
|
+
const bad = cullOffence(rootDir, config, entry);
|
|
90
|
+
if (bad) return bad;
|
|
91
|
+
const component = storyComponentFile(rootDir, entry);
|
|
92
|
+
const mount = component ? liveByFile.get(component) : undefined;
|
|
93
|
+
if (mount) return `live on ${mount.page} (slot ${mount.slot}: ${component})`;
|
|
94
|
+
const user = component ? importedByLive.get(component) : undefined;
|
|
95
|
+
if (user) return `used by live section ${user}`;
|
|
96
|
+
return null;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const readBody = (req) =>
|
|
100
|
+
new Promise((resolve, reject) => {
|
|
101
|
+
let body = "";
|
|
102
|
+
req.on("data", (c) => (body += c));
|
|
103
|
+
req.on("end", () => {
|
|
104
|
+
try {
|
|
105
|
+
resolve(body ? JSON.parse(body) : {});
|
|
106
|
+
} catch (e) {
|
|
107
|
+
reject(e);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
req.on("error", reject);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const send = (res, status, payload) => {
|
|
114
|
+
res.statusCode = status;
|
|
115
|
+
res.setHeader("Content-Type", "application/json");
|
|
116
|
+
res.setHeader("Cache-Control", "no-store");
|
|
117
|
+
res.end(JSON.stringify(payload));
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/** The virtual modules the package's own pages read. Everything in them is JSON-serialisable. */
|
|
121
|
+
const virtualModulesPlugin = (config, headEntrypoint) => ({
|
|
122
|
+
name: "orbytes-lab/virtual",
|
|
123
|
+
resolveId: (id) => RESOLVED[id],
|
|
124
|
+
load(id) {
|
|
125
|
+
switch (id) {
|
|
126
|
+
case RESOLVED[VIRTUAL.config]:
|
|
127
|
+
return `export default ${JSON.stringify(config)};`;
|
|
128
|
+
case RESOLVED[VIRTUAL.head]:
|
|
129
|
+
return `export { default } from ${JSON.stringify(headEntrypoint)};`;
|
|
130
|
+
case RESOLVED[VIRTUAL.css]:
|
|
131
|
+
return config.css.map((css) => `import ${JSON.stringify(css)};`).join("\n");
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/** @param {Record<string, unknown>} [options] */
|
|
137
|
+
function labShell(options) {
|
|
138
|
+
const config = resolveLabOptions(options);
|
|
139
|
+
let rootDir = process.cwd();
|
|
140
|
+
return {
|
|
141
|
+
name: "orbytes-lab",
|
|
142
|
+
hooks: {
|
|
143
|
+
"astro:config:setup": ({ config: astroConfig, injectRoute, updateConfig, logger }) => {
|
|
144
|
+
rootDir = fileURLToPath(astroConfig.root);
|
|
145
|
+
// The consumer's head component, resolved the way Astrobook resolves its own `head`:
|
|
146
|
+
// a relative path against the project root, anything else left alone. No head → a
|
|
147
|
+
// component that renders nothing, so the package's head has something to mount either way.
|
|
148
|
+
const head = options?.head
|
|
149
|
+
? String(options.head).startsWith(".")
|
|
150
|
+
? fileURLToPath(new URL(String(options.head), astroConfig.root))
|
|
151
|
+
: String(options.head)
|
|
152
|
+
: file("./src/Empty.astro");
|
|
153
|
+
|
|
154
|
+
for (const [pattern, entrypoint] of [
|
|
155
|
+
[`${config.subpath}/browse/[...path]`, "./src/shell/Browse.astro"],
|
|
156
|
+
[`${config.subpath}/viewport/[...id]`, "./src/shell/Viewport.astro"],
|
|
157
|
+
[`${config.subpath}/index.json`, "./src/shell/index.json.ts"],
|
|
158
|
+
]) {
|
|
159
|
+
injectRoute({ pattern, entrypoint: file(entrypoint), prerender: true });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
updateConfig({
|
|
163
|
+
vite: {
|
|
164
|
+
plugins: [virtualModulesPlugin(config, head)],
|
|
165
|
+
// This package ships SOURCE — .astro and .ts compiled by the consumer's Vite, the way
|
|
166
|
+
// Starlight ships its components. Vite externalises node_modules for SSR by default,
|
|
167
|
+
// and an externalised .astro file is handed to Node, which cannot read it. The
|
|
168
|
+
// consumer should need one line in its config and nothing more, so the exemption is
|
|
169
|
+
// declared here rather than there.
|
|
170
|
+
ssr: { noExternal: ["@orbytes/astrolab"] },
|
|
171
|
+
// Marking writes JSON files under src/; keep Vite from treating those as source
|
|
172
|
+
// changes and reloading the lab mid-triage.
|
|
173
|
+
server: {
|
|
174
|
+
watch: { ignored: [`**/${config.cullFile}`, `**/${config.responsiveFile}`] },
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
logger.info(
|
|
179
|
+
`folder pages at ${config.subpath}/browse, viewport at ${config.subpath}/viewport, index at ${config.subpath}/index.json`,
|
|
180
|
+
);
|
|
181
|
+
},
|
|
182
|
+
"astro:server:setup": ({ server, logger }) => {
|
|
183
|
+
server.middlewares.use(async (req, res, next) => {
|
|
184
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
185
|
+
if (url.pathname !== "/__lab/cull") return next();
|
|
186
|
+
try {
|
|
187
|
+
if (req.method === "GET") return send(res, 200, readCull(rootDir, config));
|
|
188
|
+
if (req.method === "PUT") {
|
|
189
|
+
const body = await readBody(req);
|
|
190
|
+
if (!Array.isArray(body.marked))
|
|
191
|
+
return send(res, 400, { error: "body must be { marked: string[] }" });
|
|
192
|
+
const live = liveComponentFiles(rootDir);
|
|
193
|
+
// First mount wins, so the refusal names the page a reader would look at first.
|
|
194
|
+
const liveByFile = new Map();
|
|
195
|
+
for (const l of live) if (!liveByFile.has(l.file)) liveByFile.set(l.file, l);
|
|
196
|
+
const importedByLive = new Map();
|
|
197
|
+
for (const l of live) {
|
|
198
|
+
for (const dep of defaultImports(rootDir, l.file)) {
|
|
199
|
+
if (!importedByLive.has(dep.file)) importedByLive.set(dep.file, l.file);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const offenders = body.marked
|
|
203
|
+
.map((entry) => ({
|
|
204
|
+
path: entry,
|
|
205
|
+
reason: cullEntryOffence(rootDir, config, entry, liveByFile, importedByLive),
|
|
206
|
+
}))
|
|
207
|
+
.filter((o) => o.reason !== null);
|
|
208
|
+
if (offenders.length > 0) {
|
|
209
|
+
return send(res, 400, { error: "some entries cannot be marked", offenders });
|
|
210
|
+
}
|
|
211
|
+
const data = writeCull(rootDir, config, body.marked);
|
|
212
|
+
logger.info(`cull marks saved: ${data.marked.length} → ${config.cullFile}`);
|
|
213
|
+
return send(res, 200, { ok: true, file: config.cullFile, ...data });
|
|
214
|
+
}
|
|
215
|
+
return send(res, 405, { error: "GET or PUT" });
|
|
216
|
+
} catch (e) {
|
|
217
|
+
return send(res, 500, { error: String(e?.message ?? e) });
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// The responsive marks — same shape of API as the cull one above, and deliberately its
|
|
222
|
+
// sibling rather than a second mechanism: a section version is marked responsive (and,
|
|
223
|
+
// separately, approved for that work) by hand, because neither fact is in the code.
|
|
224
|
+
server.middlewares.use(async (req, res, next) => {
|
|
225
|
+
const url = new URL(req.url ?? "/", "http://x");
|
|
226
|
+
if (url.pathname !== "/__lab/responsive") return next();
|
|
227
|
+
try {
|
|
228
|
+
if (req.method === "GET") return send(res, 200, readResponsive(rootDir, config));
|
|
229
|
+
if (req.method === "PUT") {
|
|
230
|
+
const body = await readBody(req);
|
|
231
|
+
const done = body.done;
|
|
232
|
+
const approved = body.approved ?? readResponsive(rootDir, config).approved;
|
|
233
|
+
if (!Array.isArray(done) || !Array.isArray(approved)) {
|
|
234
|
+
return send(res, 400, {
|
|
235
|
+
error: "body must be { done: string[], approved?: string[] }",
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
const offenders = [...done, ...approved]
|
|
239
|
+
.map((entry) => ({ path: entry, reason: responsiveOffence(rootDir, config, entry) }))
|
|
240
|
+
.filter((o) => o.reason !== null);
|
|
241
|
+
if (offenders.length > 0) {
|
|
242
|
+
return send(res, 400, { error: "some entries cannot be marked", offenders });
|
|
243
|
+
}
|
|
244
|
+
const data = writeResponsive(rootDir, config, done, approved);
|
|
245
|
+
logger.info(
|
|
246
|
+
`responsive marks saved: ${data.done.length} done, ${data.approved.length} approved → ${config.responsiveFile}`,
|
|
247
|
+
);
|
|
248
|
+
return send(res, 200, { ok: true, file: config.responsiveFile, ...data });
|
|
249
|
+
}
|
|
250
|
+
return send(res, 405, { error: "GET or PUT" });
|
|
251
|
+
} catch (e) {
|
|
252
|
+
return send(res, 500, { error: String(e?.message ?? e) });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
logger.info("mark APIs at /__lab/cull and /__lab/responsive (dev only)");
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* The pin half, or nothing.
|
|
264
|
+
*
|
|
265
|
+
* `pin: false` (or `null`) omits it — a site that wants the component lab and no feedback board
|
|
266
|
+
* gets exactly that, with no middleware, no toolbar app and no source stamp. Anything else is
|
|
267
|
+
* passed to the pin integration as its options, so `pin: { route: "/__pin", shots: false }` reads
|
|
268
|
+
* the way every other nested option in this file does. Default ON: the two are ruled one app, and
|
|
269
|
+
* an off-by-default half is one nobody would remember to turn on.
|
|
270
|
+
*
|
|
271
|
+
* The one value this function supplies rather than passes through is `links`. The board draws a
|
|
272
|
+
* "Lab" link in its corner, and until today that href was the string `/lab` written into the
|
|
273
|
+
* markup — correct only while the lab sits at its default subpath. Here the RESOLVED subpath is
|
|
274
|
+
* already in hand, derived from the same `resolveLabOptions` call that injects the routes, so the
|
|
275
|
+
* link and the routes cannot disagree. A consumer that passes its own `links` still wins.
|
|
276
|
+
*
|
|
277
|
+
* @param {Record<string, unknown>} [options] the lab's options
|
|
278
|
+
* @param {import("./defaults.mjs").LabConfig} config the RESOLVED lab config
|
|
279
|
+
*/
|
|
280
|
+
const pinHalf = (options, config) => {
|
|
281
|
+
const pin = options?.pin;
|
|
282
|
+
if (pin === false || pin === null) return [];
|
|
283
|
+
const given = typeof pin === "object" ? pin : {};
|
|
284
|
+
return [
|
|
285
|
+
orbytesPin({
|
|
286
|
+
// `subpath` is "" when the lab is mounted at the site root, and a link to "" is not a link —
|
|
287
|
+
// fall back to "/" there rather than emitting a dead href.
|
|
288
|
+
links: [
|
|
289
|
+
{ href: "/", label: "Site" },
|
|
290
|
+
{ href: config.subpath || "/", label: "Lab" },
|
|
291
|
+
],
|
|
292
|
+
...given,
|
|
293
|
+
}),
|
|
294
|
+
];
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* The lab: the vendored Astrobook core, configured, plus the orbytes shell around it — and the pin
|
|
299
|
+
* board beside them unless `pin: false`. Astro flattens the array, so this is one entry in a
|
|
300
|
+
* consumer's `integrations`.
|
|
301
|
+
* @param {Record<string, unknown>} [options]
|
|
302
|
+
*/
|
|
303
|
+
export default function orbytesLab(options = {}) {
|
|
304
|
+
const config = resolveLabOptions(options);
|
|
305
|
+
return [
|
|
306
|
+
createAstrobookIntegration({
|
|
307
|
+
directory: config.directory,
|
|
308
|
+
subpath: config.subpath,
|
|
309
|
+
title: config.title,
|
|
310
|
+
css: config.css,
|
|
311
|
+
// Both are this package's own components; each renders the consumer's contribution inside
|
|
312
|
+
// it (the head mounts `head`, the home reads the resolved config).
|
|
313
|
+
head: "@orbytes/astrolab/head",
|
|
314
|
+
home: "@orbytes/astrolab/home",
|
|
315
|
+
}),
|
|
316
|
+
labShell(options),
|
|
317
|
+
...pinHalf(options, config),
|
|
318
|
+
];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// The pin board's own surface, for anything that wants it without the lab — the gallery binary,
|
|
322
|
+
// an agent reading tickets off disk, a test. `orbytesPin` is the integration itself.
|
|
323
|
+
export { default as orbytesPin } from "./src/pin/index.mjs";
|
|
324
|
+
export {
|
|
325
|
+
BACKLOG,
|
|
326
|
+
CANCELLED,
|
|
327
|
+
CLOSED_STATUSES,
|
|
328
|
+
DEFAULT_STATUS,
|
|
329
|
+
IN_PROGRESS,
|
|
330
|
+
OPEN_STATUSES,
|
|
331
|
+
READY_FOR_AGENT,
|
|
332
|
+
READY_FOR_REVIEW,
|
|
333
|
+
RESOLVED,
|
|
334
|
+
STATUSES,
|
|
335
|
+
collectTickets,
|
|
336
|
+
findRepoRoot,
|
|
337
|
+
isAgentReady,
|
|
338
|
+
isCancelled,
|
|
339
|
+
isOpen,
|
|
340
|
+
listTickets,
|
|
341
|
+
normaliseStatus,
|
|
342
|
+
parseTicket,
|
|
343
|
+
renderBoard,
|
|
344
|
+
renderCard,
|
|
345
|
+
updateTicket,
|
|
346
|
+
writeTicket,
|
|
347
|
+
} from "./src/pin/index.mjs";
|