extrojs 0.2.0 → 0.3.1

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 (83) hide show
  1. package/client.d.ts +7 -15
  2. package/dist/commands/build.js +1 -1
  3. package/dist/commands/dev.js +12 -29
  4. package/dist/config.d.ts +2 -2
  5. package/dist/core/asset.d.ts +10 -0
  6. package/dist/core/asset.js +10 -0
  7. package/dist/dev-assets.d.ts +3 -3
  8. package/dist/dev-assets.js +18 -16
  9. package/dist/exports/asset.d.ts +1 -0
  10. package/dist/exports/asset.js +1 -0
  11. package/dist/exports/link.d.ts +1 -0
  12. package/dist/exports/link.js +1 -0
  13. package/dist/exports/navigation.d.ts +2 -0
  14. package/dist/exports/navigation.js +1 -0
  15. package/dist/exports/runtime.d.ts +2 -0
  16. package/dist/exports/runtime.js +3 -0
  17. package/dist/load-config.d.ts +1 -1
  18. package/dist/paths.d.ts +1 -1
  19. package/dist/plugin/app-tree.d.ts +59 -0
  20. package/dist/plugin/app-tree.js +214 -0
  21. package/dist/plugin/asset-inventory.d.ts +24 -0
  22. package/dist/plugin/asset-inventory.js +9 -0
  23. package/dist/plugin/dev-reactions.d.ts +59 -0
  24. package/dist/plugin/dev-reactions.js +62 -0
  25. package/dist/plugin/emit-assets.d.ts +56 -0
  26. package/dist/plugin/emit-assets.js +47 -0
  27. package/dist/plugin/generators/html.d.ts +64 -0
  28. package/dist/plugin/generators/html.js +167 -0
  29. package/dist/plugin/generators/icons.d.ts +15 -0
  30. package/dist/plugin/generators/icons.js +16 -0
  31. package/dist/plugin/generators/public.d.ts +17 -0
  32. package/dist/plugin/generators/public.js +20 -0
  33. package/dist/plugin/icons.d.ts +5 -0
  34. package/dist/plugin/icons.js +20 -0
  35. package/dist/plugin/index.d.ts +31 -0
  36. package/dist/plugin/index.js +246 -0
  37. package/dist/plugin/internal.d.ts +14 -0
  38. package/dist/plugin/internal.js +6 -0
  39. package/dist/plugin/manifest.d.ts +29 -0
  40. package/dist/plugin/manifest.js +68 -0
  41. package/dist/plugin/public.d.ts +21 -0
  42. package/dist/plugin/public.js +69 -0
  43. package/dist/plugin/runtimes/clients/csui-mount.js +90 -0
  44. package/dist/plugin/runtimes/clients/dev-bridge.js +194 -0
  45. package/dist/plugin/runtimes/csui-mount.d.ts +18 -0
  46. package/dist/plugin/runtimes/csui-mount.js +21 -0
  47. package/dist/plugin/runtimes/dev-bridge.d.ts +22 -0
  48. package/dist/plugin/runtimes/dev-bridge.js +19 -0
  49. package/dist/plugin/runtimes/routes-module.d.ts +20 -0
  50. package/dist/plugin/runtimes/routes-module.js +51 -0
  51. package/dist/plugin/runtimes/runtime-module.d.ts +16 -0
  52. package/dist/plugin/runtimes/runtime-module.js +40 -0
  53. package/dist/plugin/surfaces.d.ts +37 -0
  54. package/dist/plugin/surfaces.js +67 -0
  55. package/dist/plugin/types/index.d.ts +9 -0
  56. package/dist/plugin/types/index.js +1 -0
  57. package/dist/plugin/utils/read-json.d.ts +1 -0
  58. package/dist/plugin/utils/read-json.js +8 -0
  59. package/dist/react/env.d.ts +13 -0
  60. package/dist/react/env.js +1 -0
  61. package/dist/router/build-tree.d.ts +46 -0
  62. package/dist/router/build-tree.js +56 -0
  63. package/dist/router/context.d.ts +13 -0
  64. package/dist/router/context.js +2 -0
  65. package/dist/router/create-router.d.ts +10 -0
  66. package/dist/router/create-router.js +126 -0
  67. package/dist/router/defaults.d.ts +24 -0
  68. package/dist/router/defaults.js +25 -0
  69. package/dist/router/error-boundary.d.ts +23 -0
  70. package/dist/router/error-boundary.js +21 -0
  71. package/dist/router/hooks.d.ts +18 -0
  72. package/dist/router/hooks.js +34 -0
  73. package/dist/router/index.d.ts +8 -0
  74. package/dist/router/index.js +7 -0
  75. package/dist/router/link.d.ts +305 -0
  76. package/dist/router/link.js +30 -0
  77. package/dist/router/match.d.ts +14 -0
  78. package/dist/router/match.js +27 -0
  79. package/dist/router/types.d.ts +55 -0
  80. package/dist/router/types.js +1 -0
  81. package/dist/types/index.d.ts +152 -0
  82. package/dist/types/index.js +1 -0
  83. package/package.json +39 -7
package/client.d.ts CHANGED
@@ -1,16 +1,8 @@
1
- // Ambient types for Extro's env. Opt in from a project by adding:
1
+ // Ambient types for Extro's env, for code that imports nothing else from the
2
+ // framework (a bare background or content script). Opt in once:
2
3
  // /// <reference types="extrojs/client" />
3
- // Public env vars (EXTRO_PUBLIC_*) are inlined into every surface via
4
- // import.meta.env. See ADR 0002.
5
-
6
- interface ImportMetaEnv {
7
- readonly MODE: string
8
- readonly DEV: boolean
9
- readonly PROD: boolean
10
- readonly BASE_URL: string
11
- readonly [key: `EXTRO_PUBLIC_${string}`]: string | undefined
12
- }
13
-
14
- interface ImportMeta {
15
- readonly env: ImportMetaEnv
16
- }
4
+ // The single source of the env shape is src/react/env.ts (ADR 0002); this
5
+ // re-surfaces that one declaration for the explicit-reference path, so the two
6
+ // opt-in routes never declare ImportMetaEnv twice. EXTRO_PUBLIC_* is inlined
7
+ // into every surface via import.meta.env.
8
+ /// <reference path="./dist/react/env.d.ts" />
@@ -1,5 +1,5 @@
1
1
  import { build as viteBuild } from "vite";
2
- import { extro } from "@extrojs/vite-plugin";
2
+ import { extro } from "../plugin/index.js";
3
3
  import react from "@vitejs/plugin-react";
4
4
  import { loadConfig } from "../load-config.js";
5
5
  import { loadEnvIntoProcess } from "../env.js";
@@ -1,8 +1,8 @@
1
1
  import { createServer, build as viteBuild } from "vite";
2
2
  import { WebSocketServer } from "ws";
3
- import { extro } from "@extrojs/vite-plugin";
3
+ import { extro } from "../plugin/index.js";
4
4
  import react from "@vitejs/plugin-react";
5
- import { scanAppTree } from "@extrojs/vite-plugin/internal";
5
+ import { scanAppTree, classifyScriptChange, mergeDirty, resolveFlush, } from "../plugin/internal.js";
6
6
  import { loadConfig } from "../load-config.js";
7
7
  import { loadEnvIntoProcess } from "../env.js";
8
8
  import { outputDir } from "../paths.js";
@@ -103,42 +103,25 @@ export const dev = async () => {
103
103
  logLevel: "error",
104
104
  });
105
105
  // The watcher is a RollupWatcher. We split the rebuild signal by which
106
- // entry changed: a background-only edit must not reload tabs / remount
107
- // CSUI, and a content-only edit must not reload the extension. `change`
108
- // events (one per changed file) accumulate until the next `BUNDLE_END`;
109
- // a file outside both surface dirs (shared code) conservatively dirties
110
- // both. No classified change (initial build, or an `extro dev` restart)
111
- // also means both, matching the old broadcast-on-first-build behavior.
106
+ // entry changed: a background-only edit must not reload tabs / remount CSUI,
107
+ // and a content-only edit must not reload the extension. `change` events
108
+ // accumulate the Dev reaction until the next `BUNDLE_END`; the classify and
109
+ // flush rules (incl. shared-code-dirties-both) live in `dev-reactions.ts`.
112
110
  if (watcher && typeof watcher.on === "function") {
113
111
  const w = watcher;
114
- let bgDirty = false;
115
- let csDirty = false;
112
+ let dirty = { background: false, content: false };
116
113
  w.on("change", (id) => {
117
- const p = String(id).replace(/\\/g, "/");
118
- const isBg = p.includes("/src/app/background/");
119
- const isCs = p.includes("/src/app/content/");
120
- if (isBg)
121
- bgDirty = true;
122
- if (isCs)
123
- csDirty = true;
124
- if (!isBg && !isCs) {
125
- bgDirty = true;
126
- csDirty = true;
127
- }
114
+ dirty = mergeDirty(dirty, classifyScriptChange(String(id)));
128
115
  });
129
116
  w.on("event", (event) => {
130
117
  if (event.code !== "BUNDLE_END")
131
118
  return;
132
- if (!bgDirty && !csDirty) {
133
- bgDirty = true;
134
- csDirty = true;
135
- }
136
- if (bgDirty)
119
+ const { background, content } = resolveFlush(dirty);
120
+ dirty = { background: false, content: false };
121
+ if (background)
137
122
  broadcast({ kind: "bg-rebuilt" });
138
- if (csDirty)
123
+ if (content)
139
124
  broadcast({ kind: "cs-rebuilt" });
140
- bgDirty = false;
141
- csDirty = false;
142
125
  });
143
126
  }
144
127
  banner({
package/dist/config.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- import type { ExtroConfig } from "@extrojs/types";
2
- export type { ExtroConfig } from "@extrojs/types";
1
+ import type { ExtroConfig } from "./types/index.js";
2
+ export type { ExtroConfig } from "./types/index.js";
3
3
  export declare const defineConfig: (config: ExtroConfig) => ExtroConfig;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Resolve a public asset to its extension URL. Works in every surface (popup,
3
+ * options, sidepanel, background, content), unlike a root-relative `/logo.svg`
4
+ * which resolves against a content script's host-page origin.
5
+ *
6
+ * @example
7
+ * import { asset } from "extrojs/asset"
8
+ * <img src={asset("logo.svg")} />
9
+ */
10
+ export declare const asset: (path: string) => string;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Resolve a public asset to its extension URL. Works in every surface (popup,
3
+ * options, sidepanel, background, content), unlike a root-relative `/logo.svg`
4
+ * which resolves against a content script's host-page origin.
5
+ *
6
+ * @example
7
+ * import { asset } from "extrojs/asset"
8
+ * <img src={asset("logo.svg")} />
9
+ */
10
+ export const asset = (path) => chrome.runtime.getURL(path);
@@ -1,5 +1,5 @@
1
- import type { ExtroConfig } from "@extrojs/types";
2
- import { type AppTree } from "@extrojs/vite-plugin/internal";
1
+ import type { ExtroConfig } from "./types/index.js";
2
+ import { type AppTree } from "./plugin/internal.js";
3
3
  interface WriteDevAssetsOptions {
4
4
  tree: AppTree;
5
5
  root: string;
@@ -12,7 +12,7 @@ interface WriteDevAssetsOptions {
12
12
  * @describe Writes the dev manifest + HTML shells + icons to disk so Chrome
13
13
  * can load the unpacked extension while the Vite dev server serves modules
14
14
  * over HTTP. Background/content scripts are written by the build-watch
15
- * sidecar (a Vite watch-mode build) not here.
15
+ * sidecar (a Vite watch-mode build), not here.
16
16
  */
17
17
  export declare const writeDevAssets: ({ tree, root, outDir, port, signalPort, config, }: WriteDevAssetsOptions) => Promise<void>;
18
18
  export {};
@@ -1,39 +1,41 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { emitAssets, collectPublicAssets, } from "@extrojs/vite-plugin/internal";
3
+ import { emitAssets, discoverAssets, } from "./plugin/internal.js";
4
4
  /**
5
5
  * @describe Writes the dev manifest + HTML shells + icons to disk so Chrome
6
6
  * can load the unpacked extension while the Vite dev server serves modules
7
7
  * over HTTP. Background/content scripts are written by the build-watch
8
- * sidecar (a Vite watch-mode build) not here.
8
+ * sidecar (a Vite watch-mode build), not here.
9
9
  */
10
10
  export const writeDevAssets = async ({ tree, root, outDir, port, signalPort, config, }) => {
11
11
  await fs.mkdir(outDir, { recursive: true });
12
12
  const pkgRaw = await fs.readFile(path.join(root, "package.json"), "utf8").catch(() => "{}");
13
13
  const pkg = JSON.parse(pkgRaw);
14
- await emitAssets({ tree, root, pkg, config, dev: { port, signalPort } }, (fileName, source) => fs.writeFile(path.join(outDir, fileName), source));
15
- await copyIcons(root, outDir);
16
- await copyPublic(root, outDir, tree);
14
+ // One discovery pass for the dev manifest, the icon copy, and the public
15
+ // copy, mirroring the prod path. See the Asset inventory.
16
+ const inventory = discoverAssets(root, tree);
17
+ await emitAssets({ tree, inventory, pkg, config, dev: { port, signalPort } }, (fileName, source) => fs.writeFile(path.join(outDir, fileName), source));
18
+ await copyIcons(root, outDir, inventory.icons);
19
+ await copyPublic(root, outDir, inventory.public);
17
20
  };
18
- const copyIcons = async (root, outDir) => {
19
- const srcDir = path.join(root, "icons");
20
- const exists = await fs.stat(srcDir).then((s) => s.isDirectory()).catch(() => false);
21
- if (!exists)
21
+ const copyIcons = async (root, outDir, icons) => {
22
+ if (!icons)
22
23
  return;
23
- const dstDir = path.join(outDir, "icons");
24
- await fs.mkdir(dstDir, { recursive: true });
25
- const files = await fs.readdir(srcDir);
26
- await Promise.all(files.map((f) => fs.copyFile(path.join(srcDir, f), path.join(dstDir, f))));
24
+ const entries = Object.values(icons);
25
+ if (entries.length === 0)
26
+ return;
27
+ await fs.mkdir(path.join(outDir, "icons"), { recursive: true });
28
+ await Promise.all(entries.map((rel) => fs.copyFile(path.join(root, rel), path.join(outDir, rel))));
27
29
  };
28
30
  /**
29
31
  * @describe Copies Public assets into the dev output dir so they resolve at
30
32
  * the extension origin in dev exactly as in prod (chrome.runtime.getURL, or a
31
33
  * root-relative ref on a routable surface). Mirrors copyIcons; the collision
32
- * guard from collectPublicAssets keeps a stray file from shadowing a
34
+ * guard from the Asset inventory keeps a stray file from shadowing a
33
35
  * generated output.
34
36
  */
35
- const copyPublic = async (root, outDir, tree) => {
36
- const { files, conflicts } = collectPublicAssets(root, tree);
37
+ const copyPublic = async (root, outDir, publicAssets) => {
38
+ const { files, conflicts } = publicAssets;
37
39
  for (const conflict of conflicts) {
38
40
  console.warn(`[extro] public/${conflict} collides with a generated output; skipping. Rename it to ship it.`);
39
41
  }
@@ -0,0 +1 @@
1
+ export { asset } from "../core/asset.js";
@@ -0,0 +1 @@
1
+ export { asset } from "../core/asset.js";
@@ -0,0 +1 @@
1
+ export { Link } from "../router/index.js";
@@ -0,0 +1 @@
1
+ export { Link } from "../router/index.js";
@@ -0,0 +1,2 @@
1
+ export { useLocation, useParams, useRouter, useSearchParams, } from "../router/index.js";
2
+ export type { ErrorProps, LayoutProps, PageProps, Router } from "../router/index.js";
@@ -0,0 +1 @@
1
+ export { useLocation, useParams, useRouter, useSearchParams, } from "../router/index.js";
@@ -0,0 +1,2 @@
1
+ export { createExtroRouter, matchRoutes } from "../router/index.js";
2
+ export type { CreateRouterOptions, DynamicRoute, ExtroRouterHandle, Route, RouteMatch, StaticRoute, } from "../router/index.js";
@@ -0,0 +1,3 @@
1
+ // Internal subpath. Emitted by the generated Runtime module
2
+ // (`virtual:extro/runtime/<surface>`), not for direct user import.
3
+ export { createExtroRouter, matchRoutes } from "../router/index.js";
@@ -1,2 +1,2 @@
1
- import type { ExtroConfig } from "@extrojs/types";
1
+ import type { ExtroConfig } from "./types/index.js";
2
2
  export declare const loadConfig: (root: string) => Promise<ExtroConfig>;
package/dist/paths.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExtroConfig } from "@extrojs/types";
1
+ import type { ExtroConfig } from "./types/index.js";
2
2
  /**
3
3
  * @describe Resolved unpacked-extension output dir for a build mode. The base
4
4
  * comes from `config.outDir` (default `output`, resolved against the project
@@ -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;