extrojs 0.1.0 → 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.
Files changed (96) hide show
  1. package/README.md +3 -3
  2. package/client.d.ts +8 -0
  3. package/dist/cli.d.ts +7 -0
  4. package/dist/cli.js +27 -0
  5. package/dist/commands/build.d.ts +5 -0
  6. package/dist/commands/build.js +26 -0
  7. package/dist/commands/dev.d.ts +7 -0
  8. package/dist/commands/dev.js +156 -0
  9. package/dist/config.d.ts +2 -2
  10. package/dist/core/asset.d.ts +10 -0
  11. package/dist/core/asset.js +10 -0
  12. package/dist/dev-assets.d.ts +3 -3
  13. package/dist/dev-assets.js +33 -12
  14. package/dist/env.d.ts +10 -0
  15. package/dist/env.js +18 -0
  16. package/dist/exports/asset.d.ts +1 -0
  17. package/dist/exports/asset.js +1 -0
  18. package/dist/exports/link.d.ts +1 -0
  19. package/dist/exports/link.js +1 -0
  20. package/dist/exports/navigation.d.ts +2 -0
  21. package/dist/exports/navigation.js +1 -0
  22. package/dist/exports/runtime.d.ts +2 -0
  23. package/dist/exports/runtime.js +3 -0
  24. package/dist/index.js +5 -136
  25. package/dist/load-config.d.ts +1 -1
  26. package/dist/logger.d.ts +34 -0
  27. package/dist/logger.js +65 -0
  28. package/dist/paths.d.ts +8 -0
  29. package/dist/paths.js +8 -0
  30. package/dist/pkg.d.ts +6 -0
  31. package/dist/pkg.js +5 -0
  32. package/dist/plugin/app-tree.d.ts +59 -0
  33. package/dist/plugin/app-tree.js +214 -0
  34. package/dist/plugin/asset-inventory.d.ts +24 -0
  35. package/dist/plugin/asset-inventory.js +9 -0
  36. package/dist/plugin/dev-reactions.d.ts +59 -0
  37. package/dist/plugin/dev-reactions.js +62 -0
  38. package/dist/plugin/emit-assets.d.ts +50 -0
  39. package/dist/plugin/emit-assets.js +40 -0
  40. package/dist/plugin/generators/html.d.ts +39 -0
  41. package/dist/plugin/generators/html.js +127 -0
  42. package/dist/plugin/generators/icons.d.ts +15 -0
  43. package/dist/plugin/generators/icons.js +16 -0
  44. package/dist/plugin/generators/public.d.ts +17 -0
  45. package/dist/plugin/generators/public.js +20 -0
  46. package/dist/plugin/icons.d.ts +5 -0
  47. package/dist/plugin/icons.js +20 -0
  48. package/dist/plugin/index.d.ts +31 -0
  49. package/dist/plugin/index.js +246 -0
  50. package/dist/plugin/internal.d.ts +14 -0
  51. package/dist/plugin/internal.js +6 -0
  52. package/dist/plugin/manifest.d.ts +29 -0
  53. package/dist/plugin/manifest.js +68 -0
  54. package/dist/plugin/public.d.ts +21 -0
  55. package/dist/plugin/public.js +63 -0
  56. package/dist/plugin/runtimes/clients/csui-mount.js +90 -0
  57. package/dist/plugin/runtimes/clients/dev-bridge.js +194 -0
  58. package/dist/plugin/runtimes/csui-mount.d.ts +18 -0
  59. package/dist/plugin/runtimes/csui-mount.js +21 -0
  60. package/dist/plugin/runtimes/dev-bridge.d.ts +22 -0
  61. package/dist/plugin/runtimes/dev-bridge.js +19 -0
  62. package/dist/plugin/runtimes/routes-module.d.ts +20 -0
  63. package/dist/plugin/runtimes/routes-module.js +51 -0
  64. package/dist/plugin/runtimes/runtime-module.d.ts +16 -0
  65. package/dist/plugin/runtimes/runtime-module.js +40 -0
  66. package/dist/plugin/surfaces.d.ts +37 -0
  67. package/dist/plugin/surfaces.js +67 -0
  68. package/dist/plugin/types/index.d.ts +9 -0
  69. package/dist/plugin/types/index.js +1 -0
  70. package/dist/plugin/utils/read-json.d.ts +1 -0
  71. package/dist/plugin/utils/read-json.js +8 -0
  72. package/dist/react/env.d.ts +13 -0
  73. package/dist/react/env.js +1 -0
  74. package/dist/router/build-tree.d.ts +46 -0
  75. package/dist/router/build-tree.js +56 -0
  76. package/dist/router/context.d.ts +13 -0
  77. package/dist/router/context.js +2 -0
  78. package/dist/router/create-router.d.ts +10 -0
  79. package/dist/router/create-router.js +126 -0
  80. package/dist/router/defaults.d.ts +24 -0
  81. package/dist/router/defaults.js +25 -0
  82. package/dist/router/error-boundary.d.ts +23 -0
  83. package/dist/router/error-boundary.js +21 -0
  84. package/dist/router/hooks.d.ts +18 -0
  85. package/dist/router/hooks.js +34 -0
  86. package/dist/router/index.d.ts +8 -0
  87. package/dist/router/index.js +7 -0
  88. package/dist/router/link.d.ts +305 -0
  89. package/dist/router/link.js +30 -0
  90. package/dist/router/match.d.ts +14 -0
  91. package/dist/router/match.js +27 -0
  92. package/dist/router/types.d.ts +55 -0
  93. package/dist/router/types.js +1 -0
  94. package/dist/types/index.d.ts +152 -0
  95. package/dist/types/index.js +1 -0
  96. package/package.json +47 -9
package/dist/logger.js ADDED
@@ -0,0 +1,65 @@
1
+ import pc from "picocolors";
2
+ import { createLogger } from "vite";
3
+ // Extro brand terracotta (#CC785C) on the logo's near-black (#0a0a0a).
4
+ // picocolors only does the 16 ANSI names, so emit 24-bit truecolor directly,
5
+ // gated on the same color-support check picocolors uses (auto-plain in pipes).
6
+ const brand = (s) => pc.isColorSupported ? `\x1b[38;2;204;120;92m${s}\x1b[39m` : s;
7
+ const brandTag = (s) => pc.isColorSupported
8
+ ? `\x1b[1m\x1b[48;2;204;120;92m\x1b[38;2;10;10;10m${s}\x1b[0m`
9
+ : s;
10
+ const tag = brandTag(" EXTRO ");
11
+ // Status prefixes stay conventional (green/yellow/red are universal terminal
12
+ // semantics); the Extro "voice" (tag, `›`, accents) carries the brand color.
13
+ export const log = {
14
+ success: (msg) => console.log(`${pc.green("✓")} ${msg}`),
15
+ info: (msg) => console.log(`${brand("›")} ${msg}`),
16
+ warn: (msg) => console.warn(`${pc.yellow("⚠")} ${msg}`),
17
+ error: (msg) => console.error(`${pc.red("✗")} ${msg}`),
18
+ /** Low-key line for frequent, low-importance output (e.g. HMR updates).
19
+ * Multi-line input is prefixed per line so the `›` rail stays continuous. */
20
+ muted: (msg) => {
21
+ for (const line of msg.split("\n"))
22
+ console.log(pc.dim(`› ${line}`));
23
+ },
24
+ };
25
+ /**
26
+ * @describe A Vite `customLogger` that rebadges Vite's own info chatter
27
+ * (HMR updates, page reloads, dep optimization, "building for production",
28
+ * bundle sizes, etc.) as Extro `›` muted lines. Warnings + errors still
29
+ * pass through Vite's logger untouched so they keep their semantics.
30
+ */
31
+ export const createViteLogger = () => {
32
+ const vite = createLogger();
33
+ vite.info = (msg) => {
34
+ const clean = msg
35
+ .replace(/\x1b\[[0-9;]*m/g, "")
36
+ .replace(/^[\d:apm.\s]*\[vite\]\s*/i, "")
37
+ .trim();
38
+ if (!clean)
39
+ return;
40
+ // Drop Vite's own startup banner — we already print "Building extension
41
+ // for production..." / the dev banner ourselves.
42
+ if (/^vite v[\d.]+ (building|dev server running)/i.test(clean))
43
+ return;
44
+ log.muted(clean);
45
+ };
46
+ return vite;
47
+ };
48
+ /**
49
+ * @describe Prints the grouped startup banner: a brand tag with version/mode,
50
+ * then aligned label/value rows and an optional dimmed hint. Mirrors Vite's
51
+ * own startup idiom so the two read as a coherent stack.
52
+ */
53
+ export const banner = ({ mode, version, rows, hint }) => {
54
+ const width = Math.max(...rows.map((row) => row.label.length));
55
+ const lines = [
56
+ "",
57
+ ` ${tag} ${pc.dim(`v${version}`)} ${brand(mode)}`,
58
+ "",
59
+ ...rows.map((row) => ` ${brand("➜")} ${row.label.padEnd(width)} ${brand(row.value)}`),
60
+ ];
61
+ if (hint)
62
+ lines.push("", ` ${pc.dim(hint)}`);
63
+ lines.push("");
64
+ console.log(lines.join("\n"));
65
+ };
@@ -0,0 +1,8 @@
1
+ import type { ExtroConfig } from "./types/index.js";
2
+ /**
3
+ * @describe Resolved unpacked-extension output dir for a build mode. The base
4
+ * comes from `config.outDir` (default `output`, resolved against the project
5
+ * root); the `chrome-mv3-<mode>` subdir keeps dev and prod artifacts separate
6
+ * and leaves room for other targets later.
7
+ */
8
+ export declare const outputDir: (root: string, config: ExtroConfig, mode: "dev" | "prod") => string;
package/dist/paths.js ADDED
@@ -0,0 +1,8 @@
1
+ import path from "node:path";
2
+ /**
3
+ * @describe Resolved unpacked-extension output dir for a build mode. The base
4
+ * comes from `config.outDir` (default `output`, resolved against the project
5
+ * root); the `chrome-mv3-<mode>` subdir keeps dev and prod artifacts separate
6
+ * and leaves room for other targets later.
7
+ */
8
+ export const outputDir = (root, config, mode) => path.join(path.resolve(root, config.outDir ?? "output"), `chrome-mv3-${mode}`);
package/dist/pkg.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ interface Pkg {
2
+ name: string;
3
+ version: string;
4
+ }
5
+ export declare const pkg: Pkg;
6
+ export {};
package/dist/pkg.js ADDED
@@ -0,0 +1,5 @@
1
+ import { readFileSync } from "node:fs";
2
+ // Read at runtime rather than `import`ing package.json: it lives outside
3
+ // `rootDir: src`, so tsc would reject a static import. The URL resolves the
4
+ // same in the workspace (dist/pkg.js -> ../package.json) and once published.
5
+ export const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
@@ -0,0 +1,59 @@
1
+ import type { RouteManifest } from "../types/index.js";
2
+ import { type RoutableSurface } from "./surfaces.js";
3
+ /**
4
+ * Basenames the scanner recognizes under `src/app/`. The dev watcher in
5
+ * `index.ts` derives its glob + match regex from this same list so dev
6
+ * structural-change detection can never drift from what the scanner reads.
7
+ * (Guard: #9 widened the scanner to include `layout` but not the watcher,
8
+ * which silently broke layout HMR. This list is the single source so it
9
+ * cannot recur.)
10
+ */
11
+ export declare const APP_FILE_BASENAMES: readonly ["page", "index", "layout", "error", "not-found"];
12
+ /**
13
+ * The Content surface has up to two Modes: a raw script entry
14
+ * (`src/app/content/index.{ts,tsx}`) and a CSUI page (`src/app/content/page.tsx`).
15
+ * At least one is set when the slot exists; both may be set together.
16
+ * Stage 3 turns `csui` into a `Route[]`.
17
+ */
18
+ export type ContentSlot = {
19
+ script?: string;
20
+ csui?: string;
21
+ };
22
+ export type AppTree = {
23
+ scripts: {
24
+ background?: string;
25
+ content?: ContentSlot;
26
+ };
27
+ /**
28
+ * Each present Routable surface's slot _is_ its `RouteManifest` (ADR 0007):
29
+ * routes + the per-surface not-found / surface-root layout in one record,
30
+ * so the pieces cannot desync. Present iff the surface has >= 1 page.
31
+ */
32
+ surfaces: Partial<Record<RoutableSurface, RouteManifest>>;
33
+ };
34
+ /**
35
+ * Single pass over `src/app/`. Every convention of the app tree lives here:
36
+ *
37
+ * - Routable surfaces use `page.{ts,tsx}` and may nest. `[id]` segments
38
+ * become dynamic params.
39
+ * src/app/popup/page.tsx → { type: "static", path: "/" }
40
+ * src/app/popup/settings/page.tsx → { type: "static", path: "/settings" }
41
+ * src/app/popup/user/[id]/page.tsx → { type: "dynamic", path: "/user/:id" }
42
+ *
43
+ * - Script surfaces use `index.{ts,tsx}` at the surface root and do not
44
+ * nest.
45
+ *
46
+ * Which surfaces fall into which kind is declared in `surfaces.ts`; this
47
+ * dispatcher consults `findSurface(name).kind` rather than hard-coding names.
48
+ *
49
+ * An empty result is returned as-is — the caller decides whether that's an
50
+ * error.
51
+ */
52
+ export declare function scanAppTree(root: string): Promise<AppTree>;
53
+ /**
54
+ * Accessor for one Routable surface's `RouteManifest` — its slot of the
55
+ * AppTree, or an empty manifest when the surface is absent (ADR 0005/0007).
56
+ * The single input to `emit` and the invalidation key, and the fixture seam
57
+ * for the round-trip test (build one by hand, no filesystem scan needed).
58
+ */
59
+ export declare function routeManifest(tree: AppTree, surface: RoutableSurface): RouteManifest;
@@ -0,0 +1,214 @@
1
+ import fg from "fast-glob";
2
+ import path from "node:path";
3
+ import { findSurface } from "./surfaces.js";
4
+ /**
5
+ * Basenames the scanner recognizes under `src/app/`. The dev watcher in
6
+ * `index.ts` derives its glob + match regex from this same list so dev
7
+ * structural-change detection can never drift from what the scanner reads.
8
+ * (Guard: #9 widened the scanner to include `layout` but not the watcher,
9
+ * which silently broke layout HMR. This list is the single source so it
10
+ * cannot recur.)
11
+ */
12
+ export const APP_FILE_BASENAMES = [
13
+ "page",
14
+ "index",
15
+ "layout",
16
+ "error",
17
+ "not-found",
18
+ ];
19
+ // ---------------------------------------------------------------------------
20
+ // Public API
21
+ // ---------------------------------------------------------------------------
22
+ /**
23
+ * Single pass over `src/app/`. Every convention of the app tree lives here:
24
+ *
25
+ * - Routable surfaces use `page.{ts,tsx}` and may nest. `[id]` segments
26
+ * become dynamic params.
27
+ * src/app/popup/page.tsx → { type: "static", path: "/" }
28
+ * src/app/popup/settings/page.tsx → { type: "static", path: "/settings" }
29
+ * src/app/popup/user/[id]/page.tsx → { type: "dynamic", path: "/user/:id" }
30
+ *
31
+ * - Script surfaces use `index.{ts,tsx}` at the surface root and do not
32
+ * nest.
33
+ *
34
+ * Which surfaces fall into which kind is declared in `surfaces.ts`; this
35
+ * dispatcher consults `findSurface(name).kind` rather than hard-coding names.
36
+ *
37
+ * An empty result is returned as-is — the caller decides whether that's an
38
+ * error.
39
+ */
40
+ export async function scanAppTree(root) {
41
+ const files = await fg(`src/app/**/{${APP_FILE_BASENAMES.join(",")}}.{ts,tsx}`, { cwd: root });
42
+ const scripts = {};
43
+ const pagesBySurface = new Map();
44
+ // surface → (segment key, e.g. "" or "settings" or "c/[id]") → file path.
45
+ const layoutsBySurface = new Map();
46
+ const errorsBySurface = new Map();
47
+ const notFoundBySurface = new Map();
48
+ for (const file of files) {
49
+ const parts = file.split("/").slice(2); // drop "src/app/"
50
+ const surface = parts[0];
51
+ if (!surface)
52
+ continue;
53
+ const desc = findSurface(surface);
54
+ if (!desc)
55
+ continue;
56
+ const filename = parts[parts.length - 1];
57
+ const isPage = /^page\.tsx?$/.test(filename);
58
+ const isIndex = /^index\.tsx?$/.test(filename);
59
+ const isLayout = /^layout\.tsx?$/.test(filename);
60
+ const isError = /^error\.tsx?$/.test(filename);
61
+ const isNotFound = /^not-found\.tsx?$/.test(filename);
62
+ if (desc.kind === "script") {
63
+ if (parts.length !== 2)
64
+ continue;
65
+ if (!isIndex && !(isPage && desc.acceptsCsuiPage))
66
+ continue;
67
+ const abs = path.join(root, file);
68
+ if (surface === "background") {
69
+ scripts.background = abs;
70
+ }
71
+ else if (surface === "content") {
72
+ scripts.content = {
73
+ ...scripts.content,
74
+ [isIndex ? "script" : "csui"]: abs,
75
+ };
76
+ }
77
+ continue;
78
+ }
79
+ if (desc.kind !== "routable")
80
+ continue;
81
+ const name = surface;
82
+ const segments = parts.slice(1, -1); // drop surface dir + filename
83
+ if (isPage) {
84
+ const list = pagesBySurface.get(name) ?? [];
85
+ list.push({ file: path.join(root, file), segments });
86
+ pagesBySurface.set(name, list);
87
+ }
88
+ else if (isLayout || isError) {
89
+ const bySurface = isLayout ? layoutsBySurface : errorsBySurface;
90
+ const map = bySurface.get(name) ?? new Map();
91
+ map.set(segments.join("/"), path.join(root, file));
92
+ bySurface.set(name, map);
93
+ }
94
+ else if (isNotFound && segments.length === 0) {
95
+ // ADR 0003 §4: only the surface-root not-found.tsx is recognized;
96
+ // deeper ones are intentionally ignored in v0.
97
+ notFoundBySurface.set(name, path.join(root, file));
98
+ }
99
+ }
100
+ const surfaces = {};
101
+ for (const [name, pages] of pagesBySurface) {
102
+ const layoutMap = layoutsBySurface.get(name);
103
+ const errorMap = errorsBySurface.get(name);
104
+ const routes = pages.map(({ file, segments }) => buildRoute(file, segments, resolveBoundaryChain(segments, layoutMap, errorMap)));
105
+ // One RouteManifest per surface: routes + not-found + root layout in a
106
+ // single record, so the pieces cannot desync (ADR 0007).
107
+ surfaces[name] = {
108
+ routes: sortRoutes(routes),
109
+ notFound: notFoundBySurface.get(name) ?? null,
110
+ rootLayout: layoutMap?.get("") ?? null,
111
+ };
112
+ }
113
+ return { scripts, surfaces };
114
+ }
115
+ const EMPTY_MANIFEST = {
116
+ routes: [],
117
+ notFound: null,
118
+ rootLayout: null,
119
+ };
120
+ /**
121
+ * Accessor for one Routable surface's `RouteManifest` — its slot of the
122
+ * AppTree, or an empty manifest when the surface is absent (ADR 0005/0007).
123
+ * The single input to `emit` and the invalidation key, and the fixture seam
124
+ * for the round-trip test (build one by hand, no filesystem scan needed).
125
+ */
126
+ export function routeManifest(tree, surface) {
127
+ return tree.surfaces[surface] ?? EMPTY_MANIFEST;
128
+ }
129
+ // ---------------------------------------------------------------------------
130
+ // Route builder
131
+ // ---------------------------------------------------------------------------
132
+ function buildRoute(file, segments, boundaries) {
133
+ if (segments.some(isDynamic))
134
+ return buildDynamicRoute(file, segments, boundaries);
135
+ return buildStaticRoute(file, segments, boundaries);
136
+ }
137
+ function buildStaticRoute(file, segments, boundaries) {
138
+ const urlPath = segments.length > 0 ? `/${segments.join("/")}` : "/";
139
+ return { type: "static", path: urlPath, file, boundaries };
140
+ }
141
+ function buildDynamicRoute(file, segments, boundaries) {
142
+ const paramKeys = [];
143
+ const patternParts = segments.map((seg) => {
144
+ if (isDynamic(seg)) {
145
+ paramKeys.push(seg.slice(1, -1)); // strip [ and ]
146
+ return "([^/]+)";
147
+ }
148
+ return escapeRegex(seg);
149
+ });
150
+ // Serializable: the RegExp body, materialized by `emit` at codegen time.
151
+ const patternSource = `^/${patternParts.join("/")}$`;
152
+ const urlPath = segments
153
+ .map((seg) => (isDynamic(seg) ? `:${seg.slice(1, -1)}` : seg))
154
+ .join("/");
155
+ return {
156
+ type: "dynamic",
157
+ path: `/${urlPath}`,
158
+ file,
159
+ paramKeys,
160
+ patternSource,
161
+ boundaries,
162
+ };
163
+ }
164
+ // ---------------------------------------------------------------------------
165
+ // Sorting
166
+ // ---------------------------------------------------------------------------
167
+ /**
168
+ * Static routes first (longest first for specificity), then dynamic routes
169
+ * (longest first so /user/:id beats /:anything for the same depth).
170
+ *
171
+ * Alphabetical tiebreak keeps the output stable across filesystems — otherwise
172
+ * two routes of equal length would sort by readdir order, which varies.
173
+ */
174
+ function sortRoutes(routes) {
175
+ const byLengthThenAlpha = (a, b) => b.path.length - a.path.length || a.path.localeCompare(b.path);
176
+ const statics = routes
177
+ .filter((r) => r.type === "static")
178
+ .sort(byLengthThenAlpha);
179
+ const dynamics = routes
180
+ .filter((r) => r.type === "dynamic")
181
+ .sort(byLengthThenAlpha);
182
+ return [...statics, ...dynamics];
183
+ }
184
+ // ---------------------------------------------------------------------------
185
+ // Helpers
186
+ // ---------------------------------------------------------------------------
187
+ /**
188
+ * Ancestor boundary chain for a page, outermost first: walk from the
189
+ * surface-root segment (key "") down to the page's own segment. At each
190
+ * depth the `layout.tsx` is pushed before the `error.tsx`, so composing the
191
+ * chain inside-out nests the error within its sibling layout (ADR 0003 §3).
192
+ * Only the files that exist are included.
193
+ */
194
+ function resolveBoundaryChain(segments, layoutMap, errorMap) {
195
+ const chain = [];
196
+ for (let i = 0; i <= segments.length; i++) {
197
+ const key = segments.slice(0, i).join("/");
198
+ const layout = layoutMap?.get(key);
199
+ if (layout)
200
+ chain.push({ kind: "layout", file: layout });
201
+ const error = errorMap?.get(key);
202
+ if (error)
203
+ chain.push({ kind: "error", file: error });
204
+ }
205
+ return chain;
206
+ }
207
+ /** Returns true for dynamic segments like `[id]` or `[userId]`. */
208
+ function isDynamic(segment) {
209
+ return segment.startsWith("[") && segment.endsWith("]");
210
+ }
211
+ /** Escapes special RegExp characters in a static path segment. */
212
+ function escapeRegex(str) {
213
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
214
+ }
@@ -0,0 +1,24 @@
1
+ import type { AppTree } from "./app-tree.js";
2
+ import { type PublicAssets } from "./public.js";
3
+ /**
4
+ * @file asset-inventory.ts
5
+ * @description The Asset inventory: the discovered static-file inputs a build
6
+ * ships. One `discoverAssets(root, tree)` pass over `icons/` and `public/`
7
+ * yields a value consumed by both the Manifest generator (kept pure, taking
8
+ * this as data) and the emit/copy paths, so the filesystem is walked once per
9
+ * build instead of three times.
10
+ *
11
+ * The icon set here is the recognized-sizes set (16/32/48/128) that
12
+ * `manifest.icons` references, and it is exactly what the emit/copy paths ship.
13
+ * A stray `icons/64.png` is not in the inventory, so it never ships: there is
14
+ * one notion of "an icon", and `manifest.icons` and the emitted icon files
15
+ * cannot diverge.
16
+ */
17
+ export interface AssetInventory {
18
+ /** Recognized icons: size -> output path ("icons/16.png"). null when absent. */
19
+ icons: Record<string, string> | null;
20
+ /** Public assets, partitioned against the names a build generates. */
21
+ public: PublicAssets;
22
+ }
23
+ /** Walk `icons/` + `public/` once and return the build's Asset inventory. */
24
+ export declare function discoverAssets(root: string, tree: AppTree): AssetInventory;
@@ -0,0 +1,9 @@
1
+ import { detectIcons } from "./icons.js";
2
+ import { collectPublicAssets } from "./public.js";
3
+ /** Walk `icons/` + `public/` once and return the build's Asset inventory. */
4
+ export function discoverAssets(root, tree) {
5
+ return {
6
+ icons: detectIcons(root) ?? null,
7
+ public: collectPublicAssets(root, tree),
8
+ };
9
+ }
@@ -0,0 +1,59 @@
1
+ import type { AppTree } from "./app-tree.js";
2
+ import type { RoutableSurface } from "./surfaces.js";
3
+ /**
4
+ * @file dev-reactions.ts
5
+ * @description Dev reactions: the framework's decision about what to do when a
6
+ * watched `src/app` file changes during `extro dev`. Pure functions only. The
7
+ * watchers that trigger them own every effect (rescan, broadcast, module
8
+ * invalidation); nothing here touches the filesystem, the module graph, or the
9
+ * WebSocket. The decisions are the test surface.
10
+ *
11
+ * Two complementary halves:
12
+ * - the script reaction (`classifyScriptChange` + the dirty reducer), driven
13
+ * by the CLI's Rollup build watcher over background/content;
14
+ * - the tree reaction (`decideTreeReaction`), driven by the plugin's
15
+ * dev-server watcher over the Routable surfaces.
16
+ */
17
+ /** Which Script surfaces a dev rebuild dirties. */
18
+ export interface ScriptDirty {
19
+ background: boolean;
20
+ content: boolean;
21
+ }
22
+ /**
23
+ * A changed file under `src/app/background/` dirties background; under
24
+ * `src/app/content/` dirties content; anything else (shared code) dirties both.
25
+ * This is the only place that knows the `src/app` layout, so the CLI no longer
26
+ * hardcodes those paths.
27
+ */
28
+ export declare function classifyScriptChange(changedPath: string): ScriptDirty;
29
+ /** Union two dirty states (accumulating `change` events across one build). */
30
+ export declare function mergeDirty(a: ScriptDirty, b: ScriptDirty): ScriptDirty;
31
+ /**
32
+ * Resolve the accumulated dirty state at `BUNDLE_END`. No classified change
33
+ * (the initial build, or an `extro dev` restart) conservatively means both,
34
+ * matching the broadcast-on-first-build behavior.
35
+ */
36
+ export declare function resolveFlush(dirty: ScriptDirty): ScriptDirty;
37
+ /**
38
+ * The reaction to a Routable-side change.
39
+ * - `restart`: a Surface was born mid-session. `rollupOptions.input` was
40
+ * fixed at `config()` time, so a fresh `extro dev` is required to register
41
+ * the new entry.
42
+ * - `invalidate`: existing Routable surfaces gained or lost a Route; their
43
+ * routes virtual modules must reload so the runtime picks up the new array.
44
+ * - `noop`: nothing actionable changed.
45
+ */
46
+ export type DevReaction = {
47
+ kind: "restart";
48
+ surface: "background" | "content" | RoutableSurface;
49
+ } | {
50
+ kind: "invalidate";
51
+ surfaces: RoutableSurface[];
52
+ } | {
53
+ kind: "noop";
54
+ };
55
+ /**
56
+ * Diff two AppTrees into a {@link DevReaction}. A birth short-circuits the
57
+ * invalidate scan: the session has to restart regardless of any route delta.
58
+ */
59
+ export declare function decideTreeReaction(prev: AppTree, next: AppTree, routables: readonly RoutableSurface[]): DevReaction;
@@ -0,0 +1,62 @@
1
+ import { routeManifest } from "./app-tree.js";
2
+ /**
3
+ * A changed file under `src/app/background/` dirties background; under
4
+ * `src/app/content/` dirties content; anything else (shared code) dirties both.
5
+ * This is the only place that knows the `src/app` layout, so the CLI no longer
6
+ * hardcodes those paths.
7
+ */
8
+ export function classifyScriptChange(changedPath) {
9
+ const p = changedPath.replace(/\\/g, "/");
10
+ const background = p.includes("/src/app/background/");
11
+ const content = p.includes("/src/app/content/");
12
+ if (!background && !content)
13
+ return { background: true, content: true };
14
+ return { background, content };
15
+ }
16
+ /** Union two dirty states (accumulating `change` events across one build). */
17
+ export function mergeDirty(a, b) {
18
+ return {
19
+ background: a.background || b.background,
20
+ content: a.content || b.content,
21
+ };
22
+ }
23
+ /**
24
+ * Resolve the accumulated dirty state at `BUNDLE_END`. No classified change
25
+ * (the initial build, or an `extro dev` restart) conservatively means both,
26
+ * matching the broadcast-on-first-build behavior.
27
+ */
28
+ export function resolveFlush(dirty) {
29
+ if (!dirty.background && !dirty.content) {
30
+ return { background: true, content: true };
31
+ }
32
+ return { background: dirty.background, content: dirty.content };
33
+ }
34
+ /**
35
+ * Diff two AppTrees into a {@link DevReaction}. A birth short-circuits the
36
+ * invalidate scan: the session has to restart regardless of any route delta.
37
+ */
38
+ export function decideTreeReaction(prev, next, routables) {
39
+ if (!prev.scripts.background && next.scripts.background) {
40
+ return { kind: "restart", surface: "background" };
41
+ }
42
+ if (!prev.scripts.content && next.scripts.content) {
43
+ return { kind: "restart", surface: "content" };
44
+ }
45
+ for (const surface of routables) {
46
+ const had = (prev.surfaces[surface]?.routes.length ?? 0) > 0;
47
+ const has = (next.surfaces[surface]?.routes.length ?? 0) > 0;
48
+ if (has && !had)
49
+ return { kind: "restart", surface };
50
+ }
51
+ // The Route manifest IS what the routes module emits and is fully
52
+ // serializable, so its stable stringify is a faithful identity: invalidation
53
+ // can no longer drift from the contract (ADR 0005). This is what kills the
54
+ // historical HMR-drift bug class.
55
+ const changed = [];
56
+ for (const surface of routables) {
57
+ const key = (t) => JSON.stringify(routeManifest(t, surface));
58
+ if (key(prev) !== key(next))
59
+ changed.push(surface);
60
+ }
61
+ return changed.length ? { kind: "invalidate", surfaces: changed } : { kind: "noop" };
62
+ }
@@ -0,0 +1,50 @@
1
+ import type { ExtroConfig, ManifestV3 } from "../types/index.js";
2
+ import type { AppTree } from "./app-tree.js";
3
+ import type { AssetInventory } from "./asset-inventory.js";
4
+ import type { RoutableSurface } from "./surfaces.js";
5
+ /**
6
+ * Receives one finished asset at a time. Sync (Vite's `emitFile`) and async
7
+ * (`fs.writeFile`) sinks both fit — callers that await the seam will see
8
+ * any rejection from the async path.
9
+ */
10
+ export type EmitSink = (fileName: string, source: string) => void | Promise<void>;
11
+ export interface AssetOptions {
12
+ tree: AppTree;
13
+ /**
14
+ * The build's discovered icons + Public assets. Both call paths
15
+ * (`generateBundle`, `writeDevAssets`) run `discoverAssets` once at their
16
+ * filesystem edge and pass the result in, so composition stays pure.
17
+ */
18
+ inventory: AssetInventory;
19
+ pkg: {
20
+ name?: string;
21
+ description?: string;
22
+ version?: string;
23
+ };
24
+ config: ExtroConfig;
25
+ dev?: {
26
+ port: number;
27
+ signalPort: number;
28
+ };
29
+ }
30
+ export interface Artifacts {
31
+ manifest: ManifestV3;
32
+ html: Partial<Record<RoutableSurface, string>>;
33
+ }
34
+ /**
35
+ * Pure projection from inputs to artifact strings: no filesystem access (the
36
+ * Asset inventory is discovered at the caller's edge and passed in). Exposed
37
+ * alongside `emitAssets` so tests can assert "given this tree + inventory, this
38
+ * is the manifest" without a sink and without staging files on disk.
39
+ */
40
+ export declare function composeArtifacts(opts: AssetOptions): Artifacts;
41
+ /**
42
+ * Canonical emission of Extro's static outputs (manifest + per-surface HTML).
43
+ * Both the build (`generateBundle`) and dev (`writeDevAssets`) call paths go
44
+ * through here — the only difference between them is the sink.
45
+ *
46
+ * Icons live outside this seam: they are emitted once during build via
47
+ * Rollup's binary-asset path and are not needed in dev (the initial
48
+ * `viteBuild` already wrote them to disk).
49
+ */
50
+ export declare function emitAssets(opts: AssetOptions, emit: EmitSink): Promise<void>;
@@ -0,0 +1,40 @@
1
+ import { generateManifest } from "./manifest.js";
2
+ import { generateHTML } from "./generators/html.js";
3
+ // ---------------------------------------------------------------------------
4
+ // Public API
5
+ // ---------------------------------------------------------------------------
6
+ /**
7
+ * Pure projection from inputs to artifact strings: no filesystem access (the
8
+ * Asset inventory is discovered at the caller's edge and passed in). Exposed
9
+ * alongside `emitAssets` so tests can assert "given this tree + inventory, this
10
+ * is the manifest" without a sink and without staging files on disk.
11
+ */
12
+ export function composeArtifacts(opts) {
13
+ const manifest = generateManifest(opts);
14
+ const html = {};
15
+ for (const surface of Object.keys(opts.tree.surfaces)) {
16
+ html[surface] = generateHTML({
17
+ surface,
18
+ dev: opts.dev ? { port: opts.dev.port } : undefined,
19
+ });
20
+ }
21
+ return { manifest, html };
22
+ }
23
+ /**
24
+ * Canonical emission of Extro's static outputs (manifest + per-surface HTML).
25
+ * Both the build (`generateBundle`) and dev (`writeDevAssets`) call paths go
26
+ * through here — the only difference between them is the sink.
27
+ *
28
+ * Icons live outside this seam: they are emitted once during build via
29
+ * Rollup's binary-asset path and are not needed in dev (the initial
30
+ * `viteBuild` already wrote them to disk).
31
+ */
32
+ export async function emitAssets(opts, emit) {
33
+ const { manifest, html } = composeArtifacts(opts);
34
+ await emit("manifest.json", JSON.stringify(manifest, null, 2));
35
+ for (const [surface, body] of Object.entries(html)) {
36
+ if (!body)
37
+ continue;
38
+ await emit(`${surface}.html`, body);
39
+ }
40
+ }
@@ -0,0 +1,39 @@
1
+ interface GenerateHTMLOptions {
2
+ surface: string;
3
+ dev?: {
4
+ port: number;
5
+ };
6
+ }
7
+ /**
8
+ * @describe Generates the HTML shell for a surface. In dev mode, the shell
9
+ * points at the Vite dev server (with @vite/client for HMR) instead of the
10
+ * built bundle, and pre-renders a brand-styled "dev server offline" screen
11
+ * inside #root. React's createRoot replaces #root's children on mount, so
12
+ * the screen vanishes on a normal start; if the dev server never comes up,
13
+ * the screen stays visible.
14
+ */
15
+ export declare function generateHTML({ surface, dev }: GenerateHTMLOptions): string;
16
+ export interface DevScreen {
17
+ title: string;
18
+ /** HTML body content (paragraphs, code, etc.). Inserted as-is. */
19
+ body: string;
20
+ /**
21
+ * When true, the screen fills the viewport and centers its card. Use for
22
+ * surfaces with a real viewport (options tab, sidepanel). When false
23
+ * (popup), the screen sizes to content with a fixed minimum.
24
+ */
25
+ fill?: boolean;
26
+ }
27
+ /**
28
+ * @describe Renders a brand-styled "developer screen" card. Used as the
29
+ * pre-render inside #root for offline-dev-server fallback today, intended
30
+ * for reuse in other build-time / runtime dev panels.
31
+ */
32
+ export declare const renderDevScreen: ({ title, body, fill }: DevScreen) => string;
33
+ /**
34
+ * @describe Global styles for any `.extro-dev-screen` rendered in dev. Body
35
+ * is painted dark only while a screen is in the DOM (via `:has()`), so the
36
+ * user's app reverts to default styling once React mounts.
37
+ */
38
+ export declare const devScreenStyles: () => string;
39
+ export {};