extrojs 0.2.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.
- package/client.d.ts +7 -15
- package/dist/commands/build.js +1 -1
- package/dist/commands/dev.js +12 -29
- package/dist/config.d.ts +2 -2
- package/dist/core/asset.d.ts +10 -0
- package/dist/core/asset.js +10 -0
- package/dist/dev-assets.d.ts +3 -3
- package/dist/dev-assets.js +18 -16
- package/dist/exports/asset.d.ts +1 -0
- package/dist/exports/asset.js +1 -0
- package/dist/exports/link.d.ts +1 -0
- package/dist/exports/link.js +1 -0
- package/dist/exports/navigation.d.ts +2 -0
- package/dist/exports/navigation.js +1 -0
- package/dist/exports/runtime.d.ts +2 -0
- package/dist/exports/runtime.js +3 -0
- package/dist/load-config.d.ts +1 -1
- package/dist/paths.d.ts +1 -1
- package/dist/plugin/app-tree.d.ts +59 -0
- package/dist/plugin/app-tree.js +214 -0
- package/dist/plugin/asset-inventory.d.ts +24 -0
- package/dist/plugin/asset-inventory.js +9 -0
- package/dist/plugin/dev-reactions.d.ts +59 -0
- package/dist/plugin/dev-reactions.js +62 -0
- package/dist/plugin/emit-assets.d.ts +50 -0
- package/dist/plugin/emit-assets.js +40 -0
- package/dist/plugin/generators/html.d.ts +39 -0
- package/dist/plugin/generators/html.js +127 -0
- package/dist/plugin/generators/icons.d.ts +15 -0
- package/dist/plugin/generators/icons.js +16 -0
- package/dist/plugin/generators/public.d.ts +17 -0
- package/dist/plugin/generators/public.js +20 -0
- package/dist/plugin/icons.d.ts +5 -0
- package/dist/plugin/icons.js +20 -0
- package/dist/plugin/index.d.ts +31 -0
- package/dist/plugin/index.js +246 -0
- package/dist/plugin/internal.d.ts +14 -0
- package/dist/plugin/internal.js +6 -0
- package/dist/plugin/manifest.d.ts +29 -0
- package/dist/plugin/manifest.js +68 -0
- package/dist/plugin/public.d.ts +21 -0
- package/dist/plugin/public.js +63 -0
- package/dist/plugin/runtimes/clients/csui-mount.js +90 -0
- package/dist/plugin/runtimes/clients/dev-bridge.js +194 -0
- package/dist/plugin/runtimes/csui-mount.d.ts +18 -0
- package/dist/plugin/runtimes/csui-mount.js +21 -0
- package/dist/plugin/runtimes/dev-bridge.d.ts +22 -0
- package/dist/plugin/runtimes/dev-bridge.js +19 -0
- package/dist/plugin/runtimes/routes-module.d.ts +20 -0
- package/dist/plugin/runtimes/routes-module.js +51 -0
- package/dist/plugin/runtimes/runtime-module.d.ts +16 -0
- package/dist/plugin/runtimes/runtime-module.js +40 -0
- package/dist/plugin/surfaces.d.ts +37 -0
- package/dist/plugin/surfaces.js +67 -0
- package/dist/plugin/types/index.d.ts +9 -0
- package/dist/plugin/types/index.js +1 -0
- package/dist/plugin/utils/read-json.d.ts +1 -0
- package/dist/plugin/utils/read-json.js +8 -0
- package/dist/react/env.d.ts +13 -0
- package/dist/react/env.js +1 -0
- package/dist/router/build-tree.d.ts +46 -0
- package/dist/router/build-tree.js +56 -0
- package/dist/router/context.d.ts +13 -0
- package/dist/router/context.js +2 -0
- package/dist/router/create-router.d.ts +10 -0
- package/dist/router/create-router.js +126 -0
- package/dist/router/defaults.d.ts +24 -0
- package/dist/router/defaults.js +25 -0
- package/dist/router/error-boundary.d.ts +23 -0
- package/dist/router/error-boundary.js +21 -0
- package/dist/router/hooks.d.ts +18 -0
- package/dist/router/hooks.js +34 -0
- package/dist/router/index.d.ts +8 -0
- package/dist/router/index.js +7 -0
- package/dist/router/link.d.ts +305 -0
- package/dist/router/link.js +30 -0
- package/dist/router/match.d.ts +14 -0
- package/dist/router/match.js +27 -0
- package/dist/router/types.d.ts +55 -0
- package/dist/router/types.js +1 -0
- package/dist/types/index.d.ts +152 -0
- package/dist/types/index.js +1 -0
- package/package.json +39 -7
package/client.d.ts
CHANGED
|
@@ -1,16 +1,8 @@
|
|
|
1
|
-
// Ambient types for Extro's env
|
|
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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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" />
|
package/dist/commands/build.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { build as viteBuild } from "vite";
|
|
2
|
-
import { extro } from "
|
|
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";
|
package/dist/commands/dev.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { createServer, build as viteBuild } from "vite";
|
|
2
2
|
import { WebSocketServer } from "ws";
|
|
3
|
-
import { extro } from "
|
|
3
|
+
import { extro } from "../plugin/index.js";
|
|
4
4
|
import react from "@vitejs/plugin-react";
|
|
5
|
-
import { scanAppTree } from "
|
|
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
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
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
|
|
115
|
-
let csDirty = false;
|
|
112
|
+
let dirty = { background: false, content: false };
|
|
116
113
|
w.on("change", (id) => {
|
|
117
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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 (
|
|
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 "
|
|
2
|
-
export type { ExtroConfig } from "
|
|
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);
|
package/dist/dev-assets.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ExtroConfig } from "
|
|
2
|
-
import { type AppTree } from "
|
|
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)
|
|
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 {};
|
package/dist/dev-assets.js
CHANGED
|
@@ -1,39 +1,41 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { emitAssets,
|
|
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)
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
await
|
|
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
|
|
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,
|
|
36
|
-
const { files, conflicts } =
|
|
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 @@
|
|
|
1
|
+
export { useLocation, useParams, useRouter, useSearchParams, } from "../router/index.js";
|
package/dist/load-config.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import type { ExtroConfig } from "
|
|
1
|
+
import type { ExtroConfig } from "./types/index.js";
|
|
2
2
|
export declare const loadConfig: (root: string) => Promise<ExtroConfig>;
|
package/dist/paths.d.ts
CHANGED
|
@@ -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;
|