@orbytes/astrolab 0.3.0 → 0.4.0-next.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +261 -116
  2. package/bin/pin-gallery.mjs +53 -19
  3. package/defaults.mjs +72 -20
  4. package/dist/core/virtual-module/virtual-routes.js +12 -1
  5. package/docs/PIN-CONTRACT.md +84 -10
  6. package/docs/PIN.md +117 -37
  7. package/index.d.ts +41 -23
  8. package/index.mjs +46 -91
  9. package/package.json +7 -3
  10. package/src/Home.astro +166 -264
  11. package/src/LabHead.astro +37 -1047
  12. package/src/chrome/ActionsMenu.astro +97 -0
  13. package/src/chrome/ComponentCard.astro +76 -0
  14. package/src/chrome/Icon.astro +21 -0
  15. package/src/chrome/LICENSE-icons +43 -0
  16. package/src/chrome/Nav.astro +131 -0
  17. package/src/chrome/Panel.astro +117 -0
  18. package/src/chrome/Properties.astro +104 -0
  19. package/src/chrome/SectionsTree.astro +128 -0
  20. package/src/chrome/Shell.astro +120 -0
  21. package/src/chrome/Sprite.astro +23 -0
  22. package/src/chrome/StoryView.astro +192 -0
  23. package/src/chrome/Tree.astro +86 -0
  24. package/src/chrome/ViewportControls.astro +173 -0
  25. package/src/chrome/ViewportStage.astro +55 -0
  26. package/src/chrome/fonts/OFL.txt +93 -0
  27. package/src/chrome/fonts/inter-latin-wght-normal.woff2 +0 -0
  28. package/src/chrome/icons.ts +68 -0
  29. package/src/chrome/marks-client.ts +75 -0
  30. package/src/chrome/model.ts +156 -0
  31. package/src/chrome/navbar-client.ts +324 -0
  32. package/src/chrome/params-client.ts +434 -0
  33. package/src/chrome/pins-data.ts +63 -0
  34. package/src/chrome/shell-client.ts +468 -0
  35. package/src/chrome/site-data.ts +230 -0
  36. package/src/chrome/trees.ts +257 -0
  37. package/src/chrome/viewport-client.ts +405 -0
  38. package/src/chrome/views/Assets.astro +125 -0
  39. package/src/chrome/views/Pages.astro +214 -0
  40. package/src/chrome/views/Placeholder.astro +37 -0
  41. package/src/chrome/views/Tasks.astro +79 -0
  42. package/src/core/LICENSE-astrobook +21 -0
  43. package/src/core/lib/components/home.astro +4 -2
  44. package/src/core/lib/pages/story.astro +12 -10
  45. package/src/core/utils/kebab-case.ts +2 -2
  46. package/src/core/virtual-module/virtual-routes.ts +20 -4
  47. package/src/pin/board.mjs +414 -190
  48. package/src/pin/index.mjs +67 -22
  49. package/src/pin/tickets.mjs +6 -5
  50. package/src/pin/toolbar.js +82 -4
  51. package/src/shell/Browse.astro +131 -353
  52. package/src/shell/Viewport.astro +22 -1315
  53. package/src/shell/lab-index.ts +28 -18
  54. package/src/shell/lab-params.ts +113 -6
  55. package/src/shell/live-files.mjs +212 -10
  56. package/src/shell/marks.mjs +17 -41
  57. package/src/ui/components/app.astro +5 -7
  58. package/src/ui/components/preview-layout.astro +17 -0
  59. package/src/ui/components/theme-script.astro +17 -5
  60. package/src/ui/lab.css +3754 -371
  61. package/virtual.d.ts +13 -4
  62. package/bin/lab-cull.mjs +0 -401
  63. package/src/shell/CardGrid.astro +0 -297
  64. package/src/ui/components/build-path.ts +0 -13
  65. package/src/ui/components/build-tree.ts +0 -108
  66. package/src/ui/components/collapse-duration.ts +0 -28
  67. package/src/ui/components/compress-terms.ts +0 -10
  68. package/src/ui/components/dashboard-layout.astro +0 -39
  69. package/src/ui/components/home.astro +0 -65
  70. package/src/ui/components/layout.astro +0 -110
  71. package/src/ui/components/sidebar-button-fullscreen.astro +0 -38
  72. package/src/ui/components/sidebar-button-search.astro +0 -23
  73. package/src/ui/components/sidebar-button-theme.astro +0 -9
  74. package/src/ui/components/sidebar-button.astro +0 -24
  75. package/src/ui/components/sidebar-resize-handle.astro +0 -74
  76. package/src/ui/components/sidebar-search-panel.astro +0 -41
  77. package/src/ui/components/sidebar-search-script.ts +0 -103
  78. package/src/ui/components/sidebar-title.astro +0 -17
  79. package/src/ui/components/sidebar-tree-node.astro +0 -143
  80. package/src/ui/components/sidebar-tree.astro +0 -84
  81. package/src/ui/components/sidebar.astro +0 -29
  82. package/src/ui/components/theme-toggle.astro +0 -63
@@ -0,0 +1,230 @@
1
+ // The site's own pages and images, read off disk for the Pages and Assets views. Server-side only.
2
+ //
3
+ // Pages come from the same walk the live derivation uses (../shell/live-files.mjs), so the Pages
4
+ // tree and the live pills can never disagree about what a page is or what it mounts.
5
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { root } from "astro:config/server";
9
+ import { pageComponentFiles, pageRoute, sitePages, templateOf } from "../shell/live-files.mjs";
10
+ import type { TreeNode } from "./model";
11
+ import { hrefs } from "./model";
12
+
13
+ export const rootDir = fileURLToPath(root);
14
+
15
+ export interface SitePage {
16
+ /** repo-relative, e.g. src/pages/legal/terms.astro */
17
+ file: string;
18
+ /** "/", "/legal/terms", "/blog/[slug]" */
19
+ route: string;
20
+ /** the lab URL that shows it */
21
+ href: string;
22
+ label: string;
23
+ /** the first `title="…"` in its template — usually the layout's title prop */
24
+ title: string | null;
25
+ /** a route with a parameter cannot be shown without one of its generated paths */
26
+ dynamic: boolean;
27
+ /** the components it mounts, in document order (repo-relative) */
28
+ mounts: string[];
29
+ }
30
+
31
+ /** "terms-and-conditions" → "Terms and conditions"; the home page is "Home". */
32
+ const labelOf = (route: string) => {
33
+ if (route === "/") return "Home";
34
+ const last = route.split("/").filter(Boolean).pop() ?? route;
35
+ if (last.startsWith("[")) return last;
36
+ const words = last.replace(/[-_]+/g, " ").trim();
37
+ return words.charAt(0).toUpperCase() + words.slice(1);
38
+ };
39
+
40
+ const titleOf = (file: string): string | null => {
41
+ try {
42
+ const template = templateOf(readFileSync(path.join(rootDir, file), "utf8"));
43
+ const m = template.match(/\btitle=["']([^"']{1,160})["']/);
44
+ return m ? m[1]! : null;
45
+ } catch {
46
+ return null;
47
+ }
48
+ };
49
+
50
+ export const sitePagesModel = (): SitePage[] =>
51
+ sitePages(rootDir).map((file) => {
52
+ const route = pageRoute(file);
53
+ return {
54
+ file,
55
+ route,
56
+ href: hrefs.page(route),
57
+ label: labelOf(route),
58
+ title: titleOf(file),
59
+ dynamic: route.includes("["),
60
+ mounts: pageComponentFiles(rootDir, file),
61
+ };
62
+ });
63
+
64
+ /**
65
+ * The Pages tree, nested by folder the way the routes nest. A folder that has its own index page
66
+ * is both a group and a link (Figma: `Services` with `Page 1` and `Page 2` under it).
67
+ */
68
+ export const pagesTree = (pages: SitePage[], currentRoute?: string): TreeNode[] => {
69
+ interface Branch {
70
+ node: TreeNode;
71
+ kids: Map<string, Branch>;
72
+ }
73
+ const top: Branch = { node: { id: "pages", label: "" }, kids: new Map() };
74
+ const nodeFor = (page: SitePage): TreeNode => ({
75
+ id: `page:${page.route}`,
76
+ label: page.label,
77
+ href: page.dynamic ? undefined : page.href,
78
+ icon: "page",
79
+ current: page.route === currentRoute,
80
+ disabled: page.dynamic,
81
+ title: page.dynamic
82
+ ? "A dynamic route — the lab needs one of its generated paths to show it"
83
+ : (page.title ?? page.route),
84
+ search: `${page.route} ${page.title ?? ""} ${page.file}`.toLowerCase(),
85
+ facets: page.dynamic ? "dynamic" : page.mounts.length ? "mounts" : "static",
86
+ });
87
+
88
+ const home = pages.find((page) => page.route === "/");
89
+ if (home) top.kids.set("", { node: nodeFor(home), kids: new Map() });
90
+
91
+ for (const page of pages) {
92
+ if (page.route === "/") continue;
93
+ const segments = page.route.split("/").filter(Boolean);
94
+ let branch = top;
95
+ let prefix = "";
96
+ for (const [i, segment] of segments.entries()) {
97
+ prefix += `/${segment}`;
98
+ let next = branch.kids.get(segment);
99
+ const isPage = i === segments.length - 1;
100
+ if (!next) {
101
+ next = {
102
+ node: isPage
103
+ ? nodeFor(page)
104
+ : { id: `dir:${prefix}`, label: labelOf(prefix), icon: "folder", search: prefix.toLowerCase() },
105
+ kids: new Map(),
106
+ };
107
+ branch.kids.set(segment, next);
108
+ } else if (isPage) {
109
+ // A folder we already made turns out to have its own index page: it becomes that page.
110
+ next.node = { ...nodeFor(page), id: next.node.id };
111
+ }
112
+ branch = next;
113
+ }
114
+ }
115
+
116
+ const build = (branch: Branch): TreeNode[] =>
117
+ [...branch.kids.values()].map(({ node, kids }) => {
118
+ if (!kids.size) return node;
119
+ const children = build({ node, kids });
120
+ return { ...node, icon: node.href ? "page" : "folder", children };
121
+ });
122
+ return build(top);
123
+ };
124
+
125
+ // ---- assets -------------------------------------------------------------------------------------
126
+
127
+ export interface SiteAsset {
128
+ /** repo-relative */
129
+ file: string;
130
+ /** for anchors: `asset-src-assets-images-hero-aurora-webp` */
131
+ id: string;
132
+ name: string;
133
+ folder: string;
134
+ where: "src" | "public";
135
+ bytes: number;
136
+ ext: string;
137
+ /** repo-relative files that reference it */
138
+ usedBy: string[];
139
+ }
140
+
141
+ const IMAGE = /\.(png|jpe?g|webp|avif|gif|svg)$/i;
142
+ const SOURCE = /\.(astro|[cm]?[jt]sx?|mdx?|css|scss|sass|less|json|html|ya?ml)$/i;
143
+ const SKIP_DIRS = new Set(["node_modules", "dist", ".git", ".astro"]);
144
+
145
+ const walk = (dir: string, test: RegExp, out: string[] = []): string[] => {
146
+ const abs = path.join(rootDir, dir);
147
+ if (!existsSync(abs)) return out;
148
+ for (const entry of readdirSync(abs, { withFileTypes: true })) {
149
+ if (entry.name.startsWith(".") || SKIP_DIRS.has(entry.name)) continue;
150
+ const rel = `${dir}/${entry.name}`;
151
+ if (entry.isDirectory()) walk(rel, test, out);
152
+ else if (test.test(entry.name)) out.push(rel);
153
+ }
154
+ return out;
155
+ };
156
+
157
+ /**
158
+ * Every image under src/ and public/, with who references it. "References" is textual: a source
159
+ * file under src/ that contains the image's `folder/name` (or, for a file at the top of its tree,
160
+ * its name). That covers an `import` of an src/ asset and a `/images/x.png` string for a public one,
161
+ * and it is exactly as good as a grep — a name built at runtime from parts is not seen. Good enough
162
+ * to find what nothing uses, which is the question this view answers.
163
+ */
164
+ export const siteAssetsModel = (): SiteAsset[] => {
165
+ const files = [...walk("src", IMAGE), ...walk("public", IMAGE)];
166
+ const sources = walk("src", SOURCE).map((file) => ({
167
+ file,
168
+ text: (() => {
169
+ try {
170
+ return readFileSync(path.join(rootDir, file), "utf8");
171
+ } catch {
172
+ return "";
173
+ }
174
+ })(),
175
+ }));
176
+ return files
177
+ .map((file) => {
178
+ const name = path.posix.basename(file);
179
+ const parent = path.posix.basename(path.posix.dirname(file));
180
+ const where = file.startsWith("public/") ? "public" : "src";
181
+ const needle = parent && parent !== "src" && parent !== "public" ? `${parent}/${name}` : name;
182
+ let bytes = 0;
183
+ try {
184
+ bytes = statSync(path.join(rootDir, file)).size;
185
+ } catch {
186
+ /* vanished between the walk and the stat */
187
+ }
188
+ return {
189
+ file,
190
+ id: `asset-${file.replace(/[^a-z0-9]+/gi, "-").toLowerCase()}`,
191
+ name,
192
+ folder: path.posix.dirname(file),
193
+ where: where as SiteAsset["where"],
194
+ bytes,
195
+ ext: path.posix.extname(name).slice(1).toLowerCase(),
196
+ usedBy: sources.filter((s) => s.file !== file && s.text.includes(needle)).map((s) => s.file),
197
+ };
198
+ })
199
+ .sort((a, b) => a.file.localeCompare(b.file));
200
+ };
201
+
202
+ export const assetsTree = (assets: SiteAsset[]): TreeNode[] => {
203
+ const folders = new Map<string, SiteAsset[]>();
204
+ for (const asset of assets) {
205
+ const list = folders.get(asset.folder) ?? [];
206
+ list.push(asset);
207
+ folders.set(asset.folder, list);
208
+ }
209
+ return [...folders.entries()].map(([folder, list]) => ({
210
+ id: `assets:${folder}`,
211
+ label: folder.replace(/^(src|public)\/(assets\/)?/, "") || folder,
212
+ icon: "folder" as const,
213
+ title: folder,
214
+ count: list.length,
215
+ search: folder.toLowerCase(),
216
+ children: list.map((asset) => ({
217
+ id: asset.id,
218
+ label: asset.name,
219
+ href: `#${asset.id}`,
220
+ icon: "assets" as const,
221
+ dot: asset.usedBy.length ? undefined : ("muted" as const),
222
+ dotTitle: asset.usedBy.length ? undefined : "nothing references it",
223
+ facets: asset.usedBy.length ? "used" : "unused",
224
+ search: `${asset.file} ${asset.ext}`.toLowerCase(),
225
+ })),
226
+ }));
227
+ };
228
+
229
+ export const formatBytes = (bytes: number) =>
230
+ bytes < 1024 ? `${bytes} B` : bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(0)} KB` : `${(bytes / 1048576).toFixed(1)} MB`;
@@ -0,0 +1,257 @@
1
+ // Level 2's trees, built from the lab index and the site's files. Server-side only.
2
+ //
3
+ // THE TREE STOPS AT THE COMPONENT (decided 2026-09-24). A component's stories are its variants,
4
+ // chosen in the navbar, not rows here, so a leaf is one stories module and links to its first
5
+ // story. Folders above it are the directories under the tier, with one fold: a directory holding
6
+ // exactly one module and nothing else IS that module, so `Section01Hero › V1 › Section01Hero`
7
+ // reads `Section01Hero › V1`.
8
+ //
9
+ // THE SECTIONS TIER IS DIFFERENT (decided 2026-09-24): its level 2 is the Sections tree
10
+ // (./SectionsTree.astro) — one row per section with its slot on the page, the sections the site
11
+ // mounts first, the rest grouped under them. See sectionsTree below.
12
+ import type { LabItem, LabIndex } from "../shell/lab-index";
13
+ import { livePillText } from "../shell/lab-index";
14
+ import { sectionsTier, type TreeNode } from "./model";
15
+
16
+ /**
17
+ * One stories module — a component — with its stories sorted by export name, not in file order:
18
+ * the core's getExports (src/core/utils/get-exports.ts) sorts them, and nothing after it re-orders.
19
+ */
20
+ export interface LabComponent {
21
+ moduleId: string;
22
+ moduleName: string;
23
+ moduleFile: string;
24
+ componentFile: string | null;
25
+ directory: string;
26
+ tier: string;
27
+ section: string | null;
28
+ version: string | null;
29
+ stories: LabItem[];
30
+ live: boolean;
31
+ usedBy: string[];
32
+ responsive: LabItem["responsive"];
33
+ tags: string[];
34
+ summary: string | null;
35
+ }
36
+
37
+ export const componentsOf = (index: LabIndex): LabComponent[] => {
38
+ const byModule = new Map<string, LabComponent>();
39
+ for (const item of index.items) {
40
+ let mod = byModule.get(item.moduleId);
41
+ if (!mod) {
42
+ mod = {
43
+ moduleId: item.moduleId,
44
+ moduleName: item.moduleName,
45
+ moduleFile: item.moduleFile,
46
+ componentFile: item.componentFile,
47
+ directory: item.directory,
48
+ tier: item.tier,
49
+ section: item.section,
50
+ version: item.version,
51
+ stories: [],
52
+ live: false,
53
+ usedBy: item.usedBy,
54
+ responsive: item.responsive,
55
+ tags: item.tags,
56
+ summary: item.summary,
57
+ };
58
+ byModule.set(item.moduleId, mod);
59
+ }
60
+ mod.stories.push(item);
61
+ if (item.live) mod.live = true;
62
+ }
63
+ return [...byModule.values()];
64
+ };
65
+
66
+ /** What the dot, the filter and the search know about a component. */
67
+ export const componentState = (mod: LabComponent) => {
68
+ const first = mod.stories[0]!;
69
+ const live = mod.live ? livePillText(mod.stories.find((s) => s.live) ?? first) : null;
70
+ const facets = [
71
+ mod.live ? "live" : mod.usedBy.length ? "used" : "unused",
72
+ mod.responsive ? (mod.responsive.done ? "responsive" : "unresponsive") : "",
73
+ ].filter(Boolean);
74
+ return {
75
+ dot: mod.live ? ("live" as const) : mod.usedBy.length ? ("used" as const) : undefined,
76
+ dotTitle: live ?? (mod.usedBy.length ? `used by ${mod.usedBy.length}` : undefined),
77
+ facets: facets.join(" "),
78
+ search: [
79
+ mod.moduleName,
80
+ mod.section,
81
+ mod.version,
82
+ mod.tier,
83
+ first.livePage,
84
+ ...facets,
85
+ ...mod.tags,
86
+ ...mod.stories.map((s) => s.storyName),
87
+ ]
88
+ .filter(Boolean)
89
+ .join(" ")
90
+ .toLowerCase(),
91
+ };
92
+ };
93
+
94
+ interface Dir {
95
+ name: string;
96
+ path: string;
97
+ dirs: Map<string, Dir>;
98
+ mods: LabComponent[];
99
+ }
100
+
101
+ /** Where a component is mounted — its primary mount (the home page's, when it is on it) — or null. */
102
+ const mountOf = (mod: LabComponent): { n: number; of: number; page: string } | null => {
103
+ const story = mod.stories.find((s) => s.live && s.livePage);
104
+ return story ? { n: story.live!.slot, of: story.live!.of, page: story.livePage! } : null;
105
+ };
106
+
107
+ const byName = (a: string, b: string) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
108
+
109
+ /** A section module's version folder (`V1`), read from its directory when the index has none. */
110
+ const versionOf = (mod: LabComponent): string | null => mod.version ?? mod.directory.split("/")[2] ?? null;
111
+
112
+ /** Home page first, then the other pages by route, then top to bottom. */
113
+ const bySlot = (a: { n: number; page: string }, b: { n: number; page: string }) =>
114
+ a.page === b.page ? a.n - b.n : a.page === "/" ? -1 : b.page === "/" ? 1 : byName(a.page, b.page);
115
+
116
+ /**
117
+ * The Sections tree (decided 2026-09-24). One row per section, with a slot badge: the sections the
118
+ * site mounts come first, each badged with its slot; the rest follow under their own heading,
119
+ * badged with a dash. A section's versions sit under its row, unbadged, and are drawn only while
120
+ * you are on one of them — the tree still stops at the component. A version no page mounts is
121
+ * listed in the unslotted group as "Section · Version" even when a sibling version is mounted,
122
+ * because that is where you look for what is not on the site.
123
+ *
124
+ * Rows come out in slot order, which is also the order previous / next and the cards follow. Each
125
+ * root row carries its rank in both sort orders; the viewer's choice between them is theirs, kept
126
+ * in their browser and applied on the page (./SectionsTree.astro, ./shell-client.ts).
127
+ *
128
+ * Leaves keep the `mod:<moduleId>` id every other tree uses, so a caller walking the leaves for
129
+ * previous / next gets exactly the section versions, in order.
130
+ */
131
+ const sectionsTree = (mods: LabComponent[], currentModuleId?: string): TreeNode[] => {
132
+ const groups = new Map<string, LabComponent[]>();
133
+ for (const mod of mods) {
134
+ const name = mod.section ?? mod.directory.split("/")[1] ?? mod.moduleName;
135
+ groups.set(name, [...(groups.get(name) ?? []), mod]);
136
+ }
137
+
138
+ const leaf = (mod: LabComponent, label: string, extra: Partial<TreeNode> = {}): TreeNode => {
139
+ const state = componentState(mod);
140
+ return {
141
+ id: `mod:${mod.moduleId}`,
142
+ label,
143
+ href: mod.stories[0]!.dashboardUrl,
144
+ current: mod.moduleId === currentModuleId,
145
+ facets: state.facets,
146
+ search: state.search,
147
+ title: label === mod.moduleName ? undefined : mod.moduleName,
148
+ ...extra,
149
+ };
150
+ };
151
+
152
+ const slotted: (TreeNode & { slot: NonNullable<TreeNode["slot"]> })[] = [];
153
+ const unslotted: TreeNode[] = [];
154
+ for (const [name, list] of groups) {
155
+ const versions = [...list].sort((a, b) => byName(versionOf(a) ?? a.moduleName, versionOf(b) ?? b.moduleName));
156
+ const mounted = versions
157
+ .map((mod) => ({ mod, mount: mountOf(mod) }))
158
+ .filter((v): v is { mod: LabComponent; mount: NonNullable<ReturnType<typeof mountOf>> } => v.mount !== null)
159
+ .sort((a, b) => bySlot(a.mount, b.mount));
160
+ const lead = mounted[0];
161
+ if (lead) {
162
+ if (versions.length === 1 && !versionOf(lead.mod)) {
163
+ // A section with no version folders is its own module: one row, badged, no children.
164
+ slotted.push({ ...leaf(lead.mod, name), slot: lead.mount, group: "slotted" });
165
+ } else {
166
+ const state = componentState(lead.mod);
167
+ slotted.push({
168
+ id: `sec:${lead.mod.tier}/${name}`,
169
+ label: name,
170
+ href: lead.mod.stories[0]!.dashboardUrl,
171
+ slot: lead.mount,
172
+ group: "slotted",
173
+ childrenWhenCurrent: true,
174
+ facets: state.facets,
175
+ search: [name, ...mounted.map((v) => componentState(v.mod).search)].join(" ").toLowerCase(),
176
+ children: mounted.map((v) => leaf(v.mod, versionOf(v.mod) ?? v.mod.moduleName)),
177
+ });
178
+ }
179
+ }
180
+ for (const mod of versions) {
181
+ if (mountOf(mod)) continue;
182
+ const version = versionOf(mod);
183
+ const label = version ? `${name} · ${version}` : mod.moduleName;
184
+ unslotted.push({ ...leaf(mod, label), slot: null, group: "unslotted" });
185
+ }
186
+ }
187
+
188
+ slotted.sort((a, b) => bySlot(a.slot, b.slot) || byName(a.label, b.label));
189
+ unslotted.sort((a, b) => byName(a.label, b.label));
190
+ // Ranks for the two sort orders. Unslotted rows always follow the heading between the groups,
191
+ // which sits at rank `slotted.length` in both.
192
+ const nameRank = new Map([...slotted].sort((a, b) => byName(a.label, b.label)).map((n, i) => [n.id, i]));
193
+ slotted.forEach((node, i) => (node.order = { slot: i, name: nameRank.get(node.id)! }));
194
+ unslotted.forEach((node, i) => (node.order = { slot: slotted.length + 1 + i, name: slotted.length + 1 + i }));
195
+ return [...slotted, ...unslotted];
196
+ };
197
+
198
+ /** The tree for one tier: its directories, folded, down to its components. */
199
+ export const tierTree = (index: LabIndex, tier: string, currentModuleId?: string): TreeNode[] => {
200
+ const mods = componentsOf(index).filter((mod) => mod.tier === tier);
201
+ if (tier === sectionsTier) return sectionsTree(mods, currentModuleId);
202
+ const top: Dir = { name: tier, path: tier, dirs: new Map(), mods: [] };
203
+ for (const mod of mods) {
204
+ const segments = mod.directory.split("/").slice(1);
205
+ let dir = top;
206
+ for (const segment of segments) {
207
+ let next = dir.dirs.get(segment);
208
+ if (!next) {
209
+ next = { name: segment, path: `${dir.path}/${segment}`, dirs: new Map(), mods: [] };
210
+ dir.dirs.set(segment, next);
211
+ }
212
+ dir = next;
213
+ }
214
+ dir.mods.push(mod);
215
+ }
216
+
217
+ const leaf = (mod: LabComponent, label = mod.moduleName): TreeNode => {
218
+ const state = componentState(mod);
219
+ return {
220
+ id: `mod:${mod.moduleId}`,
221
+ label,
222
+ href: mod.stories[0]!.dashboardUrl,
223
+ icon: "component",
224
+ current: mod.moduleId === currentModuleId,
225
+ dot: state.dot,
226
+ dotTitle: state.dotTitle,
227
+ facets: state.facets,
228
+ search: state.search,
229
+ title: label === mod.moduleName ? undefined : mod.moduleName,
230
+ };
231
+ };
232
+
233
+ const nodesOf = (dir: Dir): TreeNode[] => {
234
+ const out: TreeNode[] = [];
235
+ for (const child of [...dir.dirs.values()].sort((a, b) => a.name.localeCompare(b.name))) {
236
+ if (child.dirs.size === 0 && child.mods.length === 1) {
237
+ out.push(leaf(child.mods[0]!, child.name));
238
+ continue;
239
+ }
240
+ const children = nodesOf(child);
241
+ const liveCount = children.filter((c) => c.dot === "live").length;
242
+ out.push({
243
+ id: `dir:${child.path}`,
244
+ label: child.name,
245
+ icon: "folder",
246
+ children,
247
+ dot: liveCount ? "live" : undefined,
248
+ dotTitle: liveCount ? `${liveCount} live` : undefined,
249
+ search: child.name.toLowerCase(),
250
+ });
251
+ }
252
+ for (const mod of dir.mods) out.push(leaf(mod));
253
+ return out;
254
+ };
255
+
256
+ return nodesOf(top);
257
+ };