@orbytes/astrolab 0.4.0-next.1 → 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 +184 -84
- package/bin/pin-gallery.mjs +53 -19
- package/defaults.mjs +7 -20
- package/docs/PIN-CONTRACT.md +76 -10
- package/docs/PIN.md +93 -23
- package/index.d.ts +1 -7
- package/index.mjs +14 -81
- package/package.json +2 -2
- package/src/Home.astro +7 -8
- package/src/LabHead.astro +1 -1
- package/src/chrome/ActionsMenu.astro +97 -0
- package/src/chrome/ComponentCard.astro +9 -2
- package/src/chrome/Nav.astro +36 -10
- package/src/chrome/Panel.astro +17 -4
- package/src/chrome/Properties.astro +104 -0
- package/src/chrome/SectionsTree.astro +128 -0
- package/src/chrome/Shell.astro +20 -6
- package/src/chrome/StoryView.astro +103 -162
- package/src/chrome/Tree.astro +56 -53
- package/src/chrome/ViewportControls.astro +136 -61
- package/src/chrome/ViewportStage.astro +26 -3
- package/src/chrome/icons.ts +9 -0
- package/src/chrome/marks-client.ts +26 -53
- package/src/chrome/model.ts +14 -0
- package/src/chrome/navbar-client.ts +324 -0
- package/src/chrome/params-client.ts +434 -0
- package/src/chrome/pins-data.ts +42 -9
- package/src/chrome/shell-client.ts +99 -3
- package/src/chrome/trees.ts +112 -7
- package/src/chrome/viewport-client.ts +68 -242
- package/src/chrome/views/Assets.astro +21 -6
- package/src/chrome/views/Pages.astro +90 -54
- package/src/chrome/views/Placeholder.astro +3 -3
- package/src/chrome/views/Tasks.astro +12 -40
- package/src/core/LICENSE-astrobook +5 -0
- package/src/core/utils/kebab-case.ts +2 -2
- package/src/pin/board.mjs +25 -15
- package/src/pin/index.mjs +34 -20
- package/src/pin/tickets.mjs +6 -5
- package/src/pin/toolbar.js +81 -3
- package/src/shell/Browse.astro +35 -10
- package/src/shell/lab-index.ts +5 -4
- 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/preview-layout.astro +17 -0
- package/src/ui/components/theme-script.astro +4 -3
- package/src/ui/lab.css +2167 -566
- package/virtual.d.ts +0 -4
- package/bin/lab-cull.mjs +0 -401
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
|
-
}
|
|
@@ -24,6 +24,16 @@
|
|
|
24
24
|
// this meta has to be here as well as in LabHead.astro. Do not make it conditional.
|
|
25
25
|
// · Feedbucket, when the consumer configured a key — a bare story page is a lab page you can
|
|
26
26
|
// open full screen, and it carried the widget before this split.
|
|
27
|
+
// · the FRAMED flag, which hides Astro's dev toolbar inside a lab frame (decided 2026-09-24).
|
|
28
|
+
// Every preview is an <iframe> of this document, and the dev server injects its toolbar into
|
|
29
|
+
// every page it serves, so a component page carried two: the lab page's own and a second,
|
|
30
|
+
// scaled with the canvas, sitting over the story. The outer one is enough — its pin picker
|
|
31
|
+
// already descends into same-origin frames. So an inline script marks <html> before first
|
|
32
|
+
// paint when `window.frameElement` is set (a same-origin parent, which every lab frame is),
|
|
33
|
+
// and one rule on that mark hides the toolbar. It stays on this route opened in its own tab,
|
|
34
|
+
// and on the site's own pages, which never render through this layout. The cost: a ticket
|
|
35
|
+
// left on a bare story draws its marker only there, because the outer toolbar places markers
|
|
36
|
+
// for its own page's url and a framed toolbar can no longer be opened to draw them.
|
|
27
37
|
import 'virtual:astrobook/user-css.mjs'
|
|
28
38
|
|
|
29
39
|
import { ClientRouter } from 'astro:transitions'
|
|
@@ -55,6 +65,9 @@ const feedbucketKey = labConfig.feedbucketKey
|
|
|
55
65
|
<meta name="generator" content={Astro.generator} />
|
|
56
66
|
<title>{config.title}</title>
|
|
57
67
|
<meta name="robots" content="noindex, nofollow" />
|
|
68
|
+
<script is:inline>
|
|
69
|
+
if (window.frameElement) document.documentElement.setAttribute('data-lab-framed', '')
|
|
70
|
+
</script>
|
|
58
71
|
<ThemeScript />
|
|
59
72
|
<script>
|
|
60
73
|
import { initThemeMessage } from './theme-message.ts'
|
|
@@ -95,6 +108,10 @@ const feedbucketKey = labConfig.feedbucketKey
|
|
|
95
108
|
min-width: 0;
|
|
96
109
|
overflow: auto;
|
|
97
110
|
}
|
|
111
|
+
|
|
112
|
+
html.lab-preview-shell[data-lab-framed] astro-dev-toolbar {
|
|
113
|
+
display: none !important;
|
|
114
|
+
}
|
|
98
115
|
</style>
|
|
99
116
|
</head>
|
|
100
117
|
<body class="lab-preview-shell">
|
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
//
|
|
7
7
|
// 1. The whole point of the pass is that the lab depends on nothing but Astro. One npm package
|
|
8
8
|
// left behind for a sun/moon button is the one that would surprise the next reader.
|
|
9
|
-
// 2. `.dark` on <html> is a DOM contract this package leans on hard — LabHead.astro
|
|
10
|
-
//
|
|
11
|
-
//
|
|
9
|
+
// 2. `.dark` on <html> is a DOM contract this package leans on hard — the stylesheet LabHead.astro
|
|
10
|
+
// loads (../lab.css) swaps every chrome token against it, and the sidebars, the device switch
|
|
11
|
+
// and the parameters drawer with them. A contract that load-bearing should be owned here, not
|
|
12
|
+
// read off a third party's minified inline script.
|
|
12
13
|
// 3. What was left after dropping the ripple `startViewTransition` animation — presentation,
|
|
13
14
|
// and phase 2's business if it wants it back — was about forty lines.
|
|
14
15
|
//
|