@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/bin/lab-cull.mjs
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Turns the lab's cull marks into deletions — and never from the browser.
|
|
3
|
+
//
|
|
4
|
+
// You mark lab items for deletion in the sidebar; the dev-only API saves the marks to
|
|
5
|
+
// <directory>/cull.json. This script is the only thing that deletes: it reads the marks, works
|
|
6
|
+
// out what can go, prints the plan, and removes files only when told to.
|
|
7
|
+
//
|
|
8
|
+
// orbytes-lab-cull dry run — print the plan, remove nothing
|
|
9
|
+
// orbytes-lab-cull --json the same plan as JSON
|
|
10
|
+
// orbytes-lab-cull --yes perform it: `git rm` (staged, NOT committed), clear the
|
|
11
|
+
// processed marks, print the build-log line to paste
|
|
12
|
+
// orbytes-lab-cull --dir=src/lab where the lab lives, when it is not the default
|
|
13
|
+
//
|
|
14
|
+
// exit 0 plan clean · 2 blockers · 1 error
|
|
15
|
+
//
|
|
16
|
+
// IT RUNS IN THE CONSUMER'S REPO, NOT ITS OWN. The root is `process.cwd()` — never this file's
|
|
17
|
+
// location, which since the lab became a package is somewhere under node_modules. Everything it
|
|
18
|
+
// reads is derived from that root plus the shared defaults in ../defaults.mjs, so a path this
|
|
19
|
+
// script protects and a path the integration writes cannot drift apart.
|
|
20
|
+
//
|
|
21
|
+
// What goes: each marked stories file, the .astro it exports as `component` (same directory or
|
|
22
|
+
// below), then any file under the lab directory that ONLY the removal set imports — closed
|
|
23
|
+
// repeatedly, so a vendored library goes when its last importer goes. Never, whatever the marks
|
|
24
|
+
// say: decorators/, shell/, vendor/*/LICENSE, the lab's own head and home components, the mark
|
|
25
|
+
// files, anything outside the lab directory — reported as "kept (protected)".
|
|
26
|
+
//
|
|
27
|
+
// Blockers refuse an entry outright: the entry is outside the cullable tier (section versions are
|
|
28
|
+
// the version history and components are shared chrome, so neither is ever culled), the component
|
|
29
|
+
// is LIVE (any page under src/pages/ mounts it, or mounts something that imports it — derived from
|
|
30
|
+
// the pages themselves, nothing declared), the path is not a stories file on disk, or an unmarked
|
|
31
|
+
// file still imports the component.
|
|
32
|
+
// --yes refuses to run while any blocker stands.
|
|
33
|
+
import { execFileSync } from "node:child_process";
|
|
34
|
+
import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
35
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
36
|
+
import { resolveLabOptions } from "../defaults.mjs";
|
|
37
|
+
import { pageComponentFiles, sitePages } from "../src/shell/live-files.mjs";
|
|
38
|
+
import { readCull } from "../src/shell/marks.mjs";
|
|
39
|
+
|
|
40
|
+
const SOURCE_EXT = new Set([".astro", ".ts", ".mts", ".tsx", ".js", ".mjs", ".jsx", ".css"]);
|
|
41
|
+
|
|
42
|
+
const args = process.argv.slice(2);
|
|
43
|
+
const YES = args.includes("--yes");
|
|
44
|
+
const JSON_OUT = args.includes("--json");
|
|
45
|
+
const DIR = args.find((a) => a.startsWith("--dir="))?.slice("--dir=".length);
|
|
46
|
+
for (const a of args)
|
|
47
|
+
if (!["--yes", "--json"].includes(a) && !a.startsWith("--dir="))
|
|
48
|
+
die(`unknown flag ${a} — use --yes, --json and/or --dir=<lab directory>`);
|
|
49
|
+
|
|
50
|
+
// The repo the command was run in, never this file's own location: as a package binary it lives
|
|
51
|
+
// under node_modules, and deriving the root from there would delete nothing or the wrong thing.
|
|
52
|
+
const ROOT = process.cwd();
|
|
53
|
+
const CONFIG = resolveLabOptions(DIR ? { directory: DIR } : {});
|
|
54
|
+
const CULL = CONFIG.cullFile;
|
|
55
|
+
const LAB = `${CONFIG.directory}/`;
|
|
56
|
+
|
|
57
|
+
function die(message) {
|
|
58
|
+
console.error(" FAIL " + message);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const abs = (rel) => join(ROOT, rel);
|
|
63
|
+
const rel = (absolute) => relative(ROOT, absolute).split(sep).join("/");
|
|
64
|
+
const read = (relFile) => readFileSync(abs(relFile), "utf8");
|
|
65
|
+
const git = (...argv) => execFileSync("git", argv, { cwd: ROOT, stdio: ["ignore", "pipe", "pipe"] }).toString();
|
|
66
|
+
|
|
67
|
+
// ---- protection ------------------------------------------------------------------------------
|
|
68
|
+
// Everything outside the lab directory is protected by the first clause, so the site's own source
|
|
69
|
+
// can never be swept up. Inside it, the shell the lab needs and the marks themselves are named.
|
|
70
|
+
const PROTECTED_DIRS = [`${LAB}decorators/`, `${LAB}shell/`];
|
|
71
|
+
const PROTECTED_FILES = new Set([
|
|
72
|
+
`${LAB}Home.astro`,
|
|
73
|
+
`${LAB}LabHead.astro`,
|
|
74
|
+
`${LAB}integration.mjs`,
|
|
75
|
+
CONFIG.responsiveFile,
|
|
76
|
+
CULL,
|
|
77
|
+
]);
|
|
78
|
+
const vendorLicense = new RegExp(
|
|
79
|
+
`^${LAB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}vendor/.*/LICENSE(\\.\\w+)?$`,
|
|
80
|
+
);
|
|
81
|
+
const isProtected = (f) =>
|
|
82
|
+
!f.startsWith(LAB) ||
|
|
83
|
+
PROTECTED_DIRS.some((d) => f.startsWith(d)) ||
|
|
84
|
+
PROTECTED_FILES.has(f) ||
|
|
85
|
+
vendorLicense.test(f);
|
|
86
|
+
|
|
87
|
+
// ---- the import graph over all of src/ ------------------------------------------------------
|
|
88
|
+
const walk = (dir, out = []) => {
|
|
89
|
+
for (const entry of readdirSync(dir)) {
|
|
90
|
+
const full = join(dir, entry);
|
|
91
|
+
if (statSync(full).isDirectory()) walk(full, out);
|
|
92
|
+
else if (SOURCE_EXT.has(full.slice(full.lastIndexOf(".")))) out.push(full);
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// Strips comments that could carry a stale `import … from "…"` line, then collects every
|
|
98
|
+
// specifier: static imports and re-exports (multi-line, `type`, side-effect), dynamic import(),
|
|
99
|
+
// and CSS @import. Bare packages and anything not starting ./ or ../ are ignored.
|
|
100
|
+
const stripComments = (src) => src.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "");
|
|
101
|
+
const SPECIFIER_PATTERNS = [
|
|
102
|
+
/\b(?:import|export)\s+(?:type\s+)?(?:[\w$*{}\s,]*?\s+from\s+)?["']([^"'\n]+)["']/g,
|
|
103
|
+
/\bimport\s*\(\s*["']([^"'\n]+)["']\s*\)/g,
|
|
104
|
+
/@import\s+(?:url\(\s*)?["']([^"'\n]+)["']/g,
|
|
105
|
+
];
|
|
106
|
+
const specifiersOf = (src) => {
|
|
107
|
+
const clean = stripComments(src);
|
|
108
|
+
const found = [];
|
|
109
|
+
for (const pattern of SPECIFIER_PATTERNS) for (const m of clean.matchAll(pattern)) found.push(m[1]);
|
|
110
|
+
return found;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
// Relative specifier → existing file, or null for "not ours to follow" (bare package, /src/…
|
|
114
|
+
// glob, alias), or undefined when a relative path resolves to nothing on disk.
|
|
115
|
+
function resolveSpecifier(fromAbs, specifier) {
|
|
116
|
+
const clean = specifier.replace(/[?#].*$/, "");
|
|
117
|
+
if (!clean.startsWith("./") && !clean.startsWith("../")) return null;
|
|
118
|
+
const base = resolve(dirname(fromAbs), clean);
|
|
119
|
+
const candidates = [base];
|
|
120
|
+
for (const ext of [".ts", ".mts", ".tsx", ".mjs", ".js", ".astro"]) candidates.push(base + ext);
|
|
121
|
+
if (base.endsWith(".js")) candidates.push(base.slice(0, -3) + ".ts", base.slice(0, -3) + ".tsx");
|
|
122
|
+
if (base.endsWith(".mjs")) candidates.push(base.slice(0, -4) + ".mts");
|
|
123
|
+
for (const index of ["index.ts", "index.mts", "index.mjs", "index.js"]) candidates.push(join(base, index));
|
|
124
|
+
for (const c of candidates) if (existsSync(c) && statSync(c).isFile()) return c;
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const imports = new Map(); // file → Set(files it imports)
|
|
129
|
+
const importers = new Map(); // file → Set(files that import it)
|
|
130
|
+
const unresolved = []; // { file, specifier } — relative paths that point at nothing
|
|
131
|
+
for (const fileAbs of walk(abs("src"))) {
|
|
132
|
+
const file = rel(fileAbs);
|
|
133
|
+
const deps = new Set();
|
|
134
|
+
for (const specifier of specifiersOf(readFileSync(fileAbs, "utf8"))) {
|
|
135
|
+
const target = resolveSpecifier(fileAbs, specifier);
|
|
136
|
+
if (target === null) continue;
|
|
137
|
+
if (target === undefined) {
|
|
138
|
+
unresolved.push({ file, specifier });
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
const dep = rel(target);
|
|
142
|
+
deps.add(dep);
|
|
143
|
+
if (!importers.has(dep)) importers.set(dep, new Set());
|
|
144
|
+
importers.get(dep).add(file);
|
|
145
|
+
}
|
|
146
|
+
imports.set(file, deps);
|
|
147
|
+
}
|
|
148
|
+
const importersOf = (f) => importers.get(f) ?? new Set();
|
|
149
|
+
|
|
150
|
+
// ---- LIVE: what any page mounts, and what those mount ---------------------------------------
|
|
151
|
+
// "Live" has ONE definition, shared with the lab's own pill (../src/shell/live-files.mjs). This
|
|
152
|
+
// block used to read src/pages/index.astro alone, which is correct for a one-page site and wrong
|
|
153
|
+
// for every client build — the same defect the pill carried until 2026-09-06. A second copy of a
|
|
154
|
+
// rule like this is how the two quietly disagree, so it imports the helper rather than repeat it.
|
|
155
|
+
const pages = sitePages(ROOT);
|
|
156
|
+
if (pages.length === 0) die("no pages found under src/pages — cannot tell what is live");
|
|
157
|
+
const live = new Map(); // file → why
|
|
158
|
+
for (const page of pages) {
|
|
159
|
+
for (const dep of pageComponentFiles(ROOT, page)) {
|
|
160
|
+
if (!live.has(dep)) live.set(dep, `imported by ${page}`);
|
|
161
|
+
for (const inner of imports.get(dep) ?? [])
|
|
162
|
+
if (!live.has(inner)) live.set(inner, `imported by ${dep}, which ${page} imports`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---- the marks ---------------------------------------------------------------------------------
|
|
167
|
+
if (!existsSync(abs(CULL))) {
|
|
168
|
+
finish({ marked: [], entries: [], removal: [], kept: [], protectedKept: [], blockers: [], notes: [`${CULL} does not exist — nothing is marked`] });
|
|
169
|
+
}
|
|
170
|
+
// Read through the same helper the mark API writes with, so the two cannot disagree about shape.
|
|
171
|
+
const cull = readCull(ROOT, CONFIG);
|
|
172
|
+
const marked = [...new Set(cull.marked.map((p) => String(p).replace(/^\.\//, "").replace(/\\/g, "/")))];
|
|
173
|
+
|
|
174
|
+
// The `component:` identifier at the top level of `export default { … }`, ignoring nested
|
|
175
|
+
// braces, then the `import X from "./X.astro"` that names it.
|
|
176
|
+
function componentOf(storiesRel) {
|
|
177
|
+
const src = stripComments(read(storiesRel));
|
|
178
|
+
const start = src.search(/export\s+default\s*\{/);
|
|
179
|
+
if (start < 0) return { error: "no `export default { … }`" };
|
|
180
|
+
let depth = 0;
|
|
181
|
+
let topLevel = "";
|
|
182
|
+
for (let i = src.indexOf("{", start); i < src.length; i++) {
|
|
183
|
+
const ch = src[i];
|
|
184
|
+
if (ch === "{" || ch === "[") depth++;
|
|
185
|
+
else if (ch === "}" || ch === "]") {
|
|
186
|
+
depth--;
|
|
187
|
+
if (depth === 0) break;
|
|
188
|
+
} else if (depth === 1) topLevel += ch;
|
|
189
|
+
}
|
|
190
|
+
const name = topLevel.match(/\bcomponent\s*:\s*([A-Za-z_$][\w$]*)/)?.[1];
|
|
191
|
+
if (!name) return { error: "no `component: X` in the default export" };
|
|
192
|
+
const specifier = src.match(new RegExp(`\\bimport\\s+${name}\\s+from\\s+["']([^"'\\n]+\\.astro)["']`))?.[1];
|
|
193
|
+
if (!specifier) return { error: `no \`import ${name} from "…astro"\` to follow` };
|
|
194
|
+
const target = resolveSpecifier(abs(storiesRel), specifier);
|
|
195
|
+
if (!target) return { error: `component import "${specifier}" resolves to nothing` };
|
|
196
|
+
const inside = relative(dirname(abs(storiesRel)), target);
|
|
197
|
+
if (inside.startsWith("..")) return { error: `component "${specifier}" lives outside the stories file's directory` };
|
|
198
|
+
return { component: rel(target) };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const entries = []; // { stories, component?, blocker? }
|
|
202
|
+
for (const stories of marked) {
|
|
203
|
+
const entry = { stories };
|
|
204
|
+
entries.push(entry);
|
|
205
|
+
if (!stories.startsWith(LAB) || !stories.endsWith(".stories.ts")) {
|
|
206
|
+
entry.blocker = `not a ${LAB}**/*.stories.ts path`;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
// Ruled 2026-09-05: deletion is restricted to the explorations tier — section versions are the
|
|
210
|
+
// version history and components are shared chrome; neither is ever culled, whatever the marks say.
|
|
211
|
+
// Which tier that is comes from the consumer's `tiers` option, through the shared defaults.
|
|
212
|
+
if (!CONFIG.cullDir || !stories.startsWith(CONFIG.cullDir)) {
|
|
213
|
+
entry.blocker = `only the ${CONFIG.cullTier ?? "(none)"} tier can be culled — section versions and components stay`;
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (!existsSync(abs(stories)) || !statSync(abs(stories)).isFile()) {
|
|
217
|
+
entry.blocker = "file does not exist";
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (isProtected(stories)) {
|
|
221
|
+
entry.blocker = "protected path";
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const parsed = componentOf(stories);
|
|
225
|
+
if (parsed.error) {
|
|
226
|
+
entry.blocker = parsed.error;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
entry.component = parsed.component;
|
|
230
|
+
if (isProtected(entry.component)) entry.blocker = `component ${entry.component} is protected`;
|
|
231
|
+
else if (live.has(entry.component)) entry.blocker = `component ${entry.component} is LIVE — ${live.get(entry.component)}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// An unmarked importer outside the removal set blocks the entry. Blocking one entry shrinks the
|
|
235
|
+
// set, which can expose another, so iterate to a fixed point.
|
|
236
|
+
let changed = true;
|
|
237
|
+
while (changed) {
|
|
238
|
+
changed = false;
|
|
239
|
+
const seeds = new Set(entries.filter((e) => !e.blocker).flatMap((e) => [e.stories, e.component]));
|
|
240
|
+
for (const entry of entries) {
|
|
241
|
+
if (entry.blocker) continue;
|
|
242
|
+
for (const seed of [entry.component, entry.stories]) {
|
|
243
|
+
const outside = [...importersOf(seed)].filter((i) => !seeds.has(i)).sort();
|
|
244
|
+
if (outside.length) {
|
|
245
|
+
entry.blocker = `${seed === entry.stories ? "stories file" : "component " + seed} is still imported by unmarked ${outside.join(", ")}`;
|
|
246
|
+
changed = true;
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---- closure: what only the removal set imports ------------------------------------------------
|
|
254
|
+
// A `.d.ts` beside a `.js` of the same name is that file's type sidecar (the vendored
|
|
255
|
+
// html-to-image ships one per module); nothing imports it, so it rides along with its .js.
|
|
256
|
+
const companionsOf = (file) => {
|
|
257
|
+
const dts = file.replace(/\.m?js$/, ".d.ts");
|
|
258
|
+
return dts !== file && existsSync(abs(dts)) ? [dts] : [];
|
|
259
|
+
};
|
|
260
|
+
const removal = new Set(entries.filter((e) => !e.blocker).flatMap((e) => [e.stories, e.component]));
|
|
261
|
+
const kept = new Map(); // file → importers outside the set
|
|
262
|
+
const protectedKept = new Map(); // file → files in the set that import it
|
|
263
|
+
changed = true;
|
|
264
|
+
while (changed) {
|
|
265
|
+
changed = false;
|
|
266
|
+
for (const file of [...removal]) {
|
|
267
|
+
for (const dep of [...(imports.get(file) ?? []), ...companionsOf(file)]) {
|
|
268
|
+
if (removal.has(dep)) continue;
|
|
269
|
+
if (isProtected(dep)) {
|
|
270
|
+
if (!protectedKept.has(dep)) protectedKept.set(dep, new Set());
|
|
271
|
+
protectedKept.get(dep).add(file);
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const outside = [...importersOf(dep)].filter((i) => !removal.has(i)).sort();
|
|
275
|
+
if (outside.length) kept.set(dep, outside);
|
|
276
|
+
else {
|
|
277
|
+
removal.add(dep);
|
|
278
|
+
kept.delete(dep);
|
|
279
|
+
changed = true;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// A vendored library whose every file goes leaves its LICENSE behind on purpose — say so.
|
|
286
|
+
const notes = [];
|
|
287
|
+
const vendorDir = abs(`${LAB}vendor`);
|
|
288
|
+
if (existsSync(vendorDir))
|
|
289
|
+
for (const lib of readdirSync(vendorDir)) {
|
|
290
|
+
const libDir = join(vendorDir, lib);
|
|
291
|
+
if (!statSync(libDir).isDirectory()) continue;
|
|
292
|
+
const files = walkAll(libDir).map(rel);
|
|
293
|
+
const code = files.filter((f) => !isProtected(f));
|
|
294
|
+
if (code.length && code.every((f) => removal.has(f)))
|
|
295
|
+
notes.push(`${LAB}vendor/${lib}/ empties out — its LICENSE stays (protected); delete it by hand if the library is truly gone`);
|
|
296
|
+
}
|
|
297
|
+
function walkAll(dir, out = []) {
|
|
298
|
+
for (const entry of readdirSync(dir)) {
|
|
299
|
+
const full = join(dir, entry);
|
|
300
|
+
statSync(full).isDirectory() ? walkAll(full, out) : out.push(full);
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const plan = {
|
|
306
|
+
marked,
|
|
307
|
+
entries,
|
|
308
|
+
removal: [...removal].sort(),
|
|
309
|
+
kept: [...kept].map(([file, by]) => ({ file, importedBy: by })).sort((a, b) => a.file.localeCompare(b.file)),
|
|
310
|
+
protectedKept: [...protectedKept].map(([file, by]) => ({ file, importedBy: [...by].sort() })).sort((a, b) => a.file.localeCompare(b.file)),
|
|
311
|
+
blockers: entries.filter((e) => e.blocker).map((e) => ({ entry: e.stories, reason: e.blocker })),
|
|
312
|
+
notes,
|
|
313
|
+
};
|
|
314
|
+
|
|
315
|
+
// ---- perform -----------------------------------------------------------------------------------
|
|
316
|
+
if (YES && plan.blockers.length === 0 && plan.removal.length) {
|
|
317
|
+
const tracked = new Set(git("ls-files", "-z", "--", ...plan.removal).split("\0").filter(Boolean));
|
|
318
|
+
const viaGit = plan.removal.filter((f) => tracked.has(f));
|
|
319
|
+
const viaDisk = plan.removal.filter((f) => !tracked.has(f));
|
|
320
|
+
if (viaGit.length) git("rm", "-q", "--", ...viaGit);
|
|
321
|
+
for (const f of viaDisk) rmSync(abs(f));
|
|
322
|
+
|
|
323
|
+
const processed = new Set(entries.filter((e) => !e.blocker).map((e) => e.stories));
|
|
324
|
+
const remaining = cull.marked.filter((p) => !processed.has(String(p).replace(/^\.\//, "").replace(/\\/g, "/")));
|
|
325
|
+
writeFileSync(abs(CULL), JSON.stringify({ marked: remaining, updated: new Date().toISOString() }, null, 2) + "\n");
|
|
326
|
+
const cullTracked = git("ls-files", "--", CULL).trim() !== "";
|
|
327
|
+
if (cullTracked) git("add", "--", CULL);
|
|
328
|
+
|
|
329
|
+
const now = new Date();
|
|
330
|
+
const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
331
|
+
const names = entries.filter((e) => !e.blocker).map((e) => e.component.replace(/^.*\//, "").replace(/\.astro$/, ""));
|
|
332
|
+
plan.performed = {
|
|
333
|
+
gitRm: viaGit,
|
|
334
|
+
deletedUntracked: viaDisk,
|
|
335
|
+
marksCleared: processed.size,
|
|
336
|
+
marksRemaining: remaining.length,
|
|
337
|
+
cullJsonStaged: cullTracked,
|
|
338
|
+
buildLogLine: `#### ${date} · [WILL] Lab cull — ${names.length} item${names.length === 1 ? "" : "s"} removed: ${names.join(", ")}`,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
finish(plan);
|
|
343
|
+
|
|
344
|
+
// ---- report ------------------------------------------------------------------------------------
|
|
345
|
+
function finish(p) {
|
|
346
|
+
const exitCode = p.blockers.length ? 2 : 0;
|
|
347
|
+
if (JSON_OUT) {
|
|
348
|
+
console.log(JSON.stringify({ ok: exitCode === 0, dryRun: !p.performed, ...p, unresolved }, null, 2));
|
|
349
|
+
process.exit(exitCode);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const list = (items, render = (x) => x) => items.forEach((i) => console.log(" " + render(i)));
|
|
353
|
+
const header = p.performed
|
|
354
|
+
? "lab cull — performed"
|
|
355
|
+
: YES && p.blockers.length
|
|
356
|
+
? "lab cull — REFUSED (blockers below; nothing removed)"
|
|
357
|
+
: YES
|
|
358
|
+
? "lab cull — nothing to perform"
|
|
359
|
+
: "lab cull — dry run (pass --yes to perform)";
|
|
360
|
+
console.log(header);
|
|
361
|
+
console.log(`marked: ${p.marked.length} entr${p.marked.length === 1 ? "y" : "ies"} in ${CULL}`);
|
|
362
|
+
if (p.marked.length === 0) {
|
|
363
|
+
p.notes.forEach((n) => console.log(" " + n));
|
|
364
|
+
console.log(" ok nothing marked — nothing to do");
|
|
365
|
+
process.exit(0);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
console.log(`\nremove (${p.removal.length}):`);
|
|
369
|
+
list(p.removal);
|
|
370
|
+
console.log(`\nkept — still imported by unmarked files (${p.kept.length}):`);
|
|
371
|
+
list(p.kept, (k) => `${k.file} ← ${k.importedBy.join(", ")}`);
|
|
372
|
+
console.log(`\nkept (protected) (${p.protectedKept.length}):`);
|
|
373
|
+
list(p.protectedKept, (k) => `${k.file} ← ${k.importedBy.join(", ")}`);
|
|
374
|
+
console.log(`\nblockers (${p.blockers.length}):`);
|
|
375
|
+
p.blockers.forEach((b) => console.error(` FAIL ${b.entry} — ${b.reason}`));
|
|
376
|
+
if (p.notes.length) {
|
|
377
|
+
console.log("\nnotes:");
|
|
378
|
+
list(p.notes);
|
|
379
|
+
}
|
|
380
|
+
if (unresolved.length) {
|
|
381
|
+
console.log(`\nunresolved relative imports (${unresolved.length}) — treated as nothing, check them:`);
|
|
382
|
+
list(unresolved, (u) => `${u.file} → "${u.specifier}"`);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
console.log("");
|
|
386
|
+
if (p.performed) {
|
|
387
|
+
const r = p.performed;
|
|
388
|
+
console.log(` ok git rm (staged, not committed): ${r.gitRm.length} file${r.gitRm.length === 1 ? "" : "s"}`);
|
|
389
|
+
if (r.deletedUntracked.length) console.log(` ok deleted from disk (were untracked): ${r.deletedUntracked.join(", ")}`);
|
|
390
|
+
console.log(` ok ${CULL}: ${r.marksCleared} mark${r.marksCleared === 1 ? "" : "s"} cleared, ${r.marksRemaining} left${r.cullJsonStaged ? ", staged" : " (untracked, not staged)"}`);
|
|
391
|
+
console.log("\nBuild-log line — paste it, this script edits no log and commits nothing:\n");
|
|
392
|
+
console.log(r.buildLogLine);
|
|
393
|
+
} else if (exitCode === 2) {
|
|
394
|
+
console.log(YES ? " FAIL refusing --yes while blockers stand — clear them (unmark, or unmark the importer too) and rerun" : " FAIL plan has blockers — --yes would be refused");
|
|
395
|
+
} else if (p.removal.length) {
|
|
396
|
+
console.log(` ok plan is clean — ${p.removal.length} file${p.removal.length === 1 ? "" : "s"} would be removed`);
|
|
397
|
+
} else {
|
|
398
|
+
console.log(" ok nothing to remove");
|
|
399
|
+
}
|
|
400
|
+
process.exit(exitCode);
|
|
401
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// pin-gallery — render every pin ticket as ONE standalone kanban on disk.
|
|
3
|
+
//
|
|
4
|
+
// The board itself lives at `/pin` on `astro dev` now, generated from `backlog/tasks/` on every
|
|
5
|
+
// request, so looking at it is a refresh and this script is no longer part of that loop. What it
|
|
6
|
+
// is still for is the file: a page that opens with no dev server running — on a plane, on another
|
|
7
|
+
// machine, attached to a message — and a snapshot of the board at a moment in time.
|
|
8
|
+
//
|
|
9
|
+
// The snapshot is READ-ONLY, and that falls out of one argument rather than a flag: the board is
|
|
10
|
+
// writable exactly when `renderBoard` is given an endpoint to write to, and a `file://` page has
|
|
11
|
+
// nothing listening behind it. So this call passes none, the page renders with no drag handlers
|
|
12
|
+
// and no status menus, and there is no second, quieter copy of the write path to keep in step.
|
|
13
|
+
// Search and the detail panel still work offline, because both are pure client-side reads.
|
|
14
|
+
//
|
|
15
|
+
// npx orbytes-pin-gallery # → backlog/gallery.html
|
|
16
|
+
// npx orbytes-pin-gallery --out x.html
|
|
17
|
+
// npx orbytes-pin-gallery --backlog-dir feedback # a site that moved its board
|
|
18
|
+
//
|
|
19
|
+
// IT RUNS IN THE CONSUMER'S REPO, NOT ITS OWN — the same rule as ./lab-cull.mjs, and for the same
|
|
20
|
+
// reason. The root is walked up from `process.cwd()`, never from this file's location, which since
|
|
21
|
+
// the merge into @orbytes/astrolab (2026-09-22) is somewhere under the consumer's node_modules and,
|
|
22
|
+
// when the package is workspace-linked, resolves to THIS repo instead of theirs. A gallery of the
|
|
23
|
+
// lab's own (empty) backlog, written into the lab's own checkout, would exit 0 and look fine.
|
|
24
|
+
//
|
|
25
|
+
// EVERY line of the board — the parser, the cards, the columns, the CSS — comes from
|
|
26
|
+
// `../src/pin/board.mjs`, which the `/pin` board renders with as well. Nothing about a ticket is read
|
|
27
|
+
// or drawn here. That is the whole shape of this file, and it is not a preference: this script
|
|
28
|
+
// carried its own copy of the parsing and rendering until 2026-09-21 and that copy produced three
|
|
29
|
+
// separate bugs, each of them a drift from the package's own reader — a frontmatter parser that
|
|
30
|
+
// went on reading frontmatter after the pin data moved into the body, a block-scalar reader that
|
|
31
|
+
// mangled long titles, and a fence-stripping regex that ate every word after a quoted CSS rule.
|
|
32
|
+
// A second implementation of this page would be the fourth. Add nothing here; add it to board.mjs.
|
|
33
|
+
//
|
|
34
|
+
// The page opens straight from file:// — image sources stay relative to the output file, so the
|
|
35
|
+
// HTML stays a few KB and the (gitignored) PNGs are never inlined.
|
|
36
|
+
|
|
37
|
+
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
38
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
39
|
+
|
|
40
|
+
// `../src/pin/board.mjs` and not the package root: the root pulls in the lab, which imports
|
|
41
|
+
// TypeScript the consumer's Vite compiles and plain Node cannot. The board module is dependency-
|
|
42
|
+
// free by design, and it does not reach shot.mjs — so a static HTML renderer needs no browser and
|
|
43
|
+
// no bundler to run.
|
|
44
|
+
import { collectTickets, renderBoard, findRepoRoot } from "../src/pin/board.mjs";
|
|
45
|
+
|
|
46
|
+
const REPO = findRepoRoot(process.cwd());
|
|
47
|
+
|
|
48
|
+
/* -------------------------------------------------------------------- main */
|
|
49
|
+
|
|
50
|
+
const args = process.argv.slice(2);
|
|
51
|
+
const flag = (name) => {
|
|
52
|
+
const at = args.indexOf(name);
|
|
53
|
+
return at !== -1 && args[at + 1] ? args[at + 1] : null;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
// The board directory, and the reason this flag exists at all.
|
|
57
|
+
//
|
|
58
|
+
// A binary cannot read the integration's options — it runs in a different process, from a shell,
|
|
59
|
+
// with nothing but a cwd. Until 2026-09-22 it did not try: `collectTickets(REPO)` took the
|
|
60
|
+
// DEFAULT `backlog`, and the output path said `backlog` as a literal. A site that configured
|
|
61
|
+
// `pin: { backlogDir: "feedback" }` therefore had its gallery read an empty directory, print
|
|
62
|
+
// `0 ticket(s)`, write a page with nothing on it and EXIT 0 — the failure looked like a site with
|
|
63
|
+
// no tickets, which is a thing that happens. So the directory is an argument now, it is used for
|
|
64
|
+
// both the read and the default output path, and the two cannot drift.
|
|
65
|
+
const backlogDir = flag("--backlog-dir") ?? "backlog";
|
|
66
|
+
const outPath = flag("--out")
|
|
67
|
+
? resolve(process.cwd(), flag("--out"))
|
|
68
|
+
: join(REPO, backlogDir, "gallery.html");
|
|
69
|
+
|
|
70
|
+
// `includeCancelled`: this file is the offline twin of `/pin`, so it shows what `/pin` shows —
|
|
71
|
+
// archive rail included. The default is the other way round precisely because every OTHER reader
|
|
72
|
+
// is agent-facing, and a cancelled ticket must not reach an agent's context (ruled 2026-09-22).
|
|
73
|
+
const { tickets, broken, paths } = collectTickets(REPO, { backlogDir, includeCancelled: true });
|
|
74
|
+
|
|
75
|
+
// The guard, and it runs BEFORE the write.
|
|
76
|
+
//
|
|
77
|
+
// Until 2026-09-21 this check sat *after* `writeFileSync`, so a run that hit an unreadable ticket
|
|
78
|
+
// wrote a complete-looking wall to disk and then exited 1. The exit code is gone the moment the
|
|
79
|
+
// shell moves on; the file stays, looking finished, silently short one ticket — for whoever opens
|
|
80
|
+
// it next, and for anything that reads it. That is a warning wearing a guard's clothes, and the
|
|
81
|
+
// build contract forbids it: a check that cannot pass writes nothing.
|
|
82
|
+
//
|
|
83
|
+
// Refusing means refusing outright. No partial page, no banner-only page. Any gallery.html already
|
|
84
|
+
// on disk is left exactly as it was — stale, but the message below says so in as many words, which
|
|
85
|
+
// is the difference between a stale file and a lie.
|
|
86
|
+
//
|
|
87
|
+
// The `/pin` board does the opposite and shows the panel instead, because a live page that refuses is a
|
|
88
|
+
// blank screen with nothing to act on. A file that refuses is simply the file you already had.
|
|
89
|
+
if (broken.length) {
|
|
90
|
+
console.error(`pin-gallery: refusing to write — ${broken.length} ticket(s) could not be read:`);
|
|
91
|
+
for (const b of broken) console.error(` ${b.file}: ${b.reason}`);
|
|
92
|
+
console.error(` ${tickets.length} readable ticket(s) were NOT written anywhere.`);
|
|
93
|
+
console.error(` An agent reading the board hits the same wall: listTickets() throws on the first`);
|
|
94
|
+
console.error(` of these and returns no tickets at all. Fix the ticket(s) and run again.`);
|
|
95
|
+
console.error(` The /pin board lists them by name on the dev server, if it is running.`);
|
|
96
|
+
if (existsSync(outPath)) {
|
|
97
|
+
const stamp = statSync(outPath).mtime.toLocaleString("en-AU", { dateStyle: "medium", timeStyle: "short" });
|
|
98
|
+
console.error(` ${relative(REPO, outPath) || outPath} was left untouched (written ${stamp}) and is now out of date.`);
|
|
99
|
+
}
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Relative to the OUTPUT FILE, not to backlog/ — the two are the same place by default, and this
|
|
104
|
+
// is what makes `--out` somewhere else produce a page whose pictures still load.
|
|
105
|
+
const outDir = dirname(outPath);
|
|
106
|
+
const assetHref = (t) => (t.shotAbs ? relative(outDir, t.shotAbs).split(sep).join("/") : null);
|
|
107
|
+
|
|
108
|
+
// The directory may not exist yet: on a fresh install `backlog/` is only created when the first
|
|
109
|
+
// ticket is written, and this command can run before that — or be pointed somewhere else entirely
|
|
110
|
+
// with `--out`. Measured 2026-09-22 on a clean project: without this the command died with a raw
|
|
111
|
+
// `ENOENT ... open 'backlog/gallery.html'` stack trace. Its sibling `orbytes-lab-cull` already
|
|
112
|
+
// handles the same case.
|
|
113
|
+
mkdirSync(outDir, { recursive: true });
|
|
114
|
+
writeFileSync(outPath, renderBoard(tickets, broken, { assetHref }), "utf8");
|
|
115
|
+
|
|
116
|
+
const missing = tickets.filter((t) => !assetHref(t)).length;
|
|
117
|
+
console.log(`pin-gallery: ${tickets.length} ticket(s), ${missing} without a screenshot`);
|
|
118
|
+
console.log(` → ${relative(REPO, outPath) || outPath}`);
|
|
119
|
+
console.log(` read from ${relative(REPO, paths.tasks)}`);
|
|
120
|
+
console.log(` the live board is /pin on the dev server; this file is the read-only standalone copy`);
|
|
121
|
+
if (!existsSync(paths.assets)) console.log(` note: ${relative(REPO, paths.assets)} does not exist yet`);
|
package/defaults.mjs
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The lab's defaults and the one option resolver — imported by the integration (./index.mjs) and
|
|
2
|
+
// by the cull binary (./bin/lab-cull.mjs), so a path the lab writes and a path the cull script
|
|
3
|
+
// reads can never drift. Dependency-free: the binary runs in plain Node, outside Vite.
|
|
4
|
+
//
|
|
5
|
+
// Everything `resolveLabOptions` returns is JSON-serialisable on purpose. It is handed to the
|
|
6
|
+
// package's own .astro pages through the `virtual:orbytes-lab/config.mjs` module the integration
|
|
7
|
+
// defines, and a function could not survive that trip — which is why `previewUrlTemplate` is a
|
|
8
|
+
// string with a {branch} placeholder rather than a callback.
|
|
9
|
+
|
|
10
|
+
/** Where the consumer keeps its stories, repo-relative. */
|
|
11
|
+
export const DEFAULT_DIRECTORY = "src/lab";
|
|
12
|
+
|
|
13
|
+
/** Where the lab is served from. */
|
|
14
|
+
export const DEFAULT_SUBPATH = "/lab";
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_TITLE = "Component lab";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The orbytes convention: page sections, shared components, and throwaway experiments. Tier names
|
|
20
|
+
* are a convention, not a law — pass `tiers` to rename, reorder or drop them.
|
|
21
|
+
*/
|
|
22
|
+
export const DEFAULT_TIERS = ["sections", "components", "explorations"];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What a tier NAMED like one of these does, unless the consumer says otherwise on the tier itself:
|
|
26
|
+
*
|
|
27
|
+
* - `responsive` — its stories are section versions: they carry the responsive mark (ticked by
|
|
28
|
+
* hand in the sidebar, stored in `<directory>/responsive.json`) and they render at page width
|
|
29
|
+
* in thumbnails and in the viewport configurator.
|
|
30
|
+
* - `cullable` — its stories may be marked for deletion (`<directory>/cull.json`, drained by
|
|
31
|
+
* `orbytes-lab-cull`). Nothing else in the lab can be marked: version history and shared chrome
|
|
32
|
+
* are never culled from a browser.
|
|
33
|
+
*
|
|
34
|
+
* At most one tier holds each role; the first one that claims it wins.
|
|
35
|
+
*/
|
|
36
|
+
export const TIER_ROLES = {
|
|
37
|
+
sections: { responsive: true },
|
|
38
|
+
explorations: { cullable: true },
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** `./src/lab/` and `src/lab` both mean `src/lab`. @param {string} value */
|
|
42
|
+
export const normaliseDirectory = (value) =>
|
|
43
|
+
String(value)
|
|
44
|
+
.replace(/\\/g, "/")
|
|
45
|
+
.replace(/^\.\//, "")
|
|
46
|
+
.replace(/\/+$/, "");
|
|
47
|
+
|
|
48
|
+
/** `lab`, `/lab` and `/lab/` all mean `/lab`; `""` means the site root. @param {string} value */
|
|
49
|
+
export const normaliseSubpath = (value) => {
|
|
50
|
+
const trimmed = String(value).replace(/\\/g, "/").replace(/\/+$/, "");
|
|
51
|
+
if (!trimmed) return "";
|
|
52
|
+
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @typedef {object} LabTier
|
|
57
|
+
* @property {string} id the first path segment under `directory`
|
|
58
|
+
* @property {string} label shown on the tier card and the folder pages
|
|
59
|
+
* @property {boolean} responsive section versions: responsive marks, page-width thumbnails
|
|
60
|
+
* @property {boolean} cullable may be marked for deletion
|
|
61
|
+
*/
|
|
62
|
+
|
|
63
|
+
/** @param {string | Partial<LabTier>} tier @returns {LabTier} */
|
|
64
|
+
const resolveTier = (tier) => {
|
|
65
|
+
const raw = typeof tier === "string" ? { id: tier } : { ...tier };
|
|
66
|
+
const id = String(raw.id ?? "");
|
|
67
|
+
const role = /** @type {Record<string, {responsive?: boolean, cullable?: boolean}>} */ (TIER_ROLES)[id] ?? {};
|
|
68
|
+
return {
|
|
69
|
+
id,
|
|
70
|
+
label: raw.label ?? (id ? id.charAt(0).toUpperCase() + id.slice(1) : "Root"),
|
|
71
|
+
responsive: raw.responsive ?? role.responsive ?? false,
|
|
72
|
+
cullable: raw.cullable ?? role.cullable ?? false,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @typedef {object} LabConfig
|
|
78
|
+
* @property {string} directory
|
|
79
|
+
* @property {string} subpath
|
|
80
|
+
* @property {string} title
|
|
81
|
+
* @property {string[]} css
|
|
82
|
+
* @property {string | null} stagingUrl
|
|
83
|
+
* @property {string | null} previewUrlTemplate
|
|
84
|
+
* @property {string | null} feedbucketKey
|
|
85
|
+
* @property {LabTier[]} tiers
|
|
86
|
+
* @property {string | null} sectionsTier the id of the tier holding the responsive role
|
|
87
|
+
* @property {string | null} cullTier the id of the tier holding the cull role
|
|
88
|
+
* @property {string | null} responsiveDir `<directory>/<sectionsTier>/`, or null
|
|
89
|
+
* @property {string | null} cullDir `<directory>/<cullTier>/`, or null
|
|
90
|
+
* @property {string} responsiveFile `<directory>/responsive.json`
|
|
91
|
+
* @property {string} cullFile `<directory>/cull.json`
|
|
92
|
+
*/
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Fill in every default and derive the paths the lab reads and writes.
|
|
96
|
+
* @param {Record<string, unknown>} [options]
|
|
97
|
+
* @returns {LabConfig}
|
|
98
|
+
*/
|
|
99
|
+
export function resolveLabOptions(options = {}) {
|
|
100
|
+
const directory = normaliseDirectory(options.directory ?? DEFAULT_DIRECTORY);
|
|
101
|
+
const tiers = (Array.isArray(options.tiers) ? options.tiers : DEFAULT_TIERS).map(resolveTier);
|
|
102
|
+
const sections = tiers.find((tier) => tier.responsive) ?? null;
|
|
103
|
+
const cull = tiers.find((tier) => tier.cullable) ?? null;
|
|
104
|
+
return {
|
|
105
|
+
directory,
|
|
106
|
+
subpath: normaliseSubpath(options.subpath ?? DEFAULT_SUBPATH),
|
|
107
|
+
title: String(options.title ?? DEFAULT_TITLE),
|
|
108
|
+
css: Array.isArray(options.css) ? options.css.map(String) : [],
|
|
109
|
+
stagingUrl: options.stagingUrl ? String(options.stagingUrl) : null,
|
|
110
|
+
previewUrlTemplate: options.previewUrlTemplate ? String(options.previewUrlTemplate) : null,
|
|
111
|
+
feedbucketKey: options.feedbucketKey ? String(options.feedbucketKey) : null,
|
|
112
|
+
tiers,
|
|
113
|
+
sectionsTier: sections ? sections.id : null,
|
|
114
|
+
cullTier: cull ? cull.id : null,
|
|
115
|
+
responsiveDir: sections ? `${directory}/${sections.id}/` : null,
|
|
116
|
+
cullDir: cull ? `${directory}/${cull.id}/` : null,
|
|
117
|
+
responsiveFile: `${directory}/responsive.json`,
|
|
118
|
+
cullFile: `${directory}/cull.json`,
|
|
119
|
+
};
|
|
120
|
+
}
|