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
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @describe Generates the HTML shell for a surface. In dev mode, the shell
3
+ * points at the Vite dev server (with @vite/client for HMR) instead of the
4
+ * built bundle, and pre-renders a brand-styled "dev server offline" screen
5
+ * inside #root. React's createRoot replaces #root's children on mount, so
6
+ * the screen vanishes on a normal start; if the dev server never comes up,
7
+ * the screen stays visible.
8
+ */
9
+ export function generateHTML({ surface, dev }) {
10
+ const title = surface.charAt(0).toUpperCase() + surface.slice(1);
11
+ const scripts = dev
12
+ ? `
13
+ <script type="module" src="http://localhost:${dev.port}/@vite/client"></script>
14
+ <script type="module" src="http://localhost:${dev.port}/@id/@vitejs/plugin-react/preamble"></script>
15
+ <script type="module" src="http://localhost:${dev.port}/@id/virtual:extro/runtime/${surface}"></script>
16
+ `
17
+ : `<script type="module" src="./${surface}.js"></script>`;
18
+ const rootContent = dev
19
+ ? renderDevScreen({
20
+ title: "Dev server isn't running",
21
+ body: `
22
+ <p>Start it with <code>extro dev</code>, then reopen this surface.</p>
23
+ <p>Expected at <code>http://localhost:${dev.port}</code>.</p>
24
+ `,
25
+ // Popups size to content (no viewport to fill); everything else
26
+ // (options/sidepanel/tab) gets a full-viewport centered layout.
27
+ fill: surface !== "popup",
28
+ })
29
+ : "";
30
+ return `
31
+ <!doctype html>
32
+ <html>
33
+ <head>
34
+ <title>${title}</title>
35
+ ${dev ? devScreenStyles() : ""}
36
+ </head>
37
+ <body>
38
+ <div id="root">${rootContent}</div>
39
+ ${scripts.trim()}
40
+ </body>
41
+ </html>
42
+ `.trim();
43
+ }
44
+ /**
45
+ * @describe Renders a brand-styled "developer screen" card. Used as the
46
+ * pre-render inside #root for offline-dev-server fallback today, intended
47
+ * for reuse in other build-time / runtime dev panels.
48
+ */
49
+ export const renderDevScreen = ({ title, body, fill = false }) => {
50
+ const cls = `extro-dev-screen${fill ? " extro-dev-screen--fill" : ""}`;
51
+ return `
52
+ <div class="${cls}">
53
+ <div class="extro-dev-screen__card">
54
+ <span class="extro-dev-screen__tag">EXTRO</span>
55
+ <h1>${title}</h1>
56
+ ${body.trim()}
57
+ </div>
58
+ </div>
59
+ `.trim();
60
+ };
61
+ /**
62
+ * @describe Global styles for any `.extro-dev-screen` rendered in dev. Body
63
+ * is painted dark only while a screen is in the DOM (via `:has()`), so the
64
+ * user's app reverts to default styling once React mounts.
65
+ */
66
+ export const devScreenStyles = () => `
67
+ <style>
68
+ body { margin: 0; }
69
+ body:has(.extro-dev-screen) { background: #0a0a0a; }
70
+ .extro-dev-screen {
71
+ box-sizing: border-box;
72
+ min-width: 360px;
73
+ min-height: 240px;
74
+ padding: 24px 28px;
75
+ background: #0a0a0a;
76
+ display: flex;
77
+ align-items: center;
78
+ justify-content: center;
79
+ opacity: 0;
80
+ animation: extro-dev-screen-in 0.18s 0.05s forwards;
81
+ }
82
+ .extro-dev-screen--fill {
83
+ min-height: 100vh;
84
+ }
85
+ .extro-dev-screen__card {
86
+ box-sizing: border-box;
87
+ width: 100%;
88
+ max-width: 480px;
89
+ display: flex;
90
+ flex-direction: column;
91
+ gap: 10px;
92
+ font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
93
+ color: #e5e5e5;
94
+ }
95
+ .extro-dev-screen__tag {
96
+ align-self: flex-start;
97
+ font-size: 11px;
98
+ font-weight: 600;
99
+ letter-spacing: 0.08em;
100
+ padding: 3px 8px;
101
+ color: #0a0a0a;
102
+ background: #CC785C;
103
+ border-radius: 3px;
104
+ }
105
+ .extro-dev-screen h1 {
106
+ margin: 4px 0 0;
107
+ font-size: 15px;
108
+ font-weight: 600;
109
+ color: #fafafa;
110
+ }
111
+ .extro-dev-screen p {
112
+ margin: 0;
113
+ font-size: 13px;
114
+ line-height: 1.5;
115
+ color: #a3a3a3;
116
+ }
117
+ .extro-dev-screen code {
118
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
119
+ font-size: 12px;
120
+ padding: 1px 6px;
121
+ color: #CC785C;
122
+ background: #1a1a1a;
123
+ border-radius: 3px;
124
+ }
125
+ @keyframes extro-dev-screen-in { to { opacity: 1; } }
126
+ </style>
127
+ `.trim();
@@ -0,0 +1,15 @@
1
+ import type { PluginContextLike } from "../types/index.js";
2
+ interface EmitIconsOptions {
3
+ ctx: PluginContextLike;
4
+ root: string;
5
+ /** The recognized icon set from the Asset inventory (size -> "icons/16.png"). */
6
+ icons: Record<string, string> | null;
7
+ }
8
+ /**
9
+ * @file generators/icons.ts
10
+ * @description Emits the recognized extension icons named by the Asset
11
+ * inventory. Discovery already decided which sizes exist, so this only reads
12
+ * bytes: what ships is exactly what `manifest.icons` references.
13
+ */
14
+ export declare function emitIcons({ ctx, root, icons }: EmitIconsOptions): void;
15
+ export {};
@@ -0,0 +1,16 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * @file generators/icons.ts
5
+ * @description Emits the recognized extension icons named by the Asset
6
+ * inventory. Discovery already decided which sizes exist, so this only reads
7
+ * bytes: what ships is exactly what `manifest.icons` references.
8
+ */
9
+ export function emitIcons({ ctx, root, icons }) {
10
+ if (!icons)
11
+ return;
12
+ for (const rel of Object.values(icons)) {
13
+ const source = fs.readFileSync(path.join(root, rel));
14
+ ctx.emitFile({ type: "asset", fileName: rel, source });
15
+ }
16
+ }
@@ -0,0 +1,17 @@
1
+ import type { PluginContextLike } from "../types/index.js";
2
+ import { type PublicAssets } from "../public.js";
3
+ interface EmitPublicAssetsOptions {
4
+ ctx: PluginContextLike;
5
+ root: string;
6
+ /** The partitioned Public assets from the Asset inventory. */
7
+ publicAssets: PublicAssets;
8
+ }
9
+ /**
10
+ * @file generators/public.ts
11
+ * @description Emits Public assets into the build output with their original
12
+ * names, from the partition the Asset inventory already computed. Mirrors
13
+ * `emitIcons`; the collision guard skips any file that would overwrite a
14
+ * generated output and warns instead.
15
+ */
16
+ export declare function emitPublicAssets({ ctx, root, publicAssets }: EmitPublicAssetsOptions): void;
17
+ export {};
@@ -0,0 +1,20 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { PUBLIC_DIR } from "../public.js";
4
+ /**
5
+ * @file generators/public.ts
6
+ * @description Emits Public assets into the build output with their original
7
+ * names, from the partition the Asset inventory already computed. Mirrors
8
+ * `emitIcons`; the collision guard skips any file that would overwrite a
9
+ * generated output and warns instead.
10
+ */
11
+ export function emitPublicAssets({ ctx, root, publicAssets }) {
12
+ const { files, conflicts } = publicAssets;
13
+ for (const conflict of conflicts) {
14
+ ctx.warn(`public/${conflict} collides with a generated output; skipping. Rename it to ship it.`);
15
+ }
16
+ for (const file of files) {
17
+ const source = fs.readFileSync(path.join(root, PUBLIC_DIR, file));
18
+ ctx.emitFile({ type: "asset", fileName: file, source });
19
+ }
20
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * @file icons.ts
3
+ * @description Detects the icons for the extension.
4
+ */
5
+ export declare function detectIcons(root: string): Record<string, string> | undefined;
@@ -0,0 +1,20 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * @file icons.ts
5
+ * @description Detects the icons for the extension.
6
+ */
7
+ export function detectIcons(root) {
8
+ const iconsDir = path.join(root, "icons");
9
+ if (!fs.existsSync(iconsDir))
10
+ return undefined;
11
+ const sizes = ["16", "32", "48", "128"];
12
+ const icons = {};
13
+ for (const size of sizes) {
14
+ const file = path.join(iconsDir, `${size}.png`);
15
+ if (fs.existsSync(file)) {
16
+ icons[size] = `icons/${size}.png`;
17
+ }
18
+ }
19
+ return Object.keys(icons).length ? icons : undefined;
20
+ }
@@ -0,0 +1,31 @@
1
+ import type { Plugin } from "vite";
2
+ import type { ExtroConfig } from "../types/index.js";
3
+ interface ExtroPluginOptions {
4
+ root: string;
5
+ config?: ExtroConfig;
6
+ /**
7
+ * When true, only background/content are bundled. Manifest + HTML emission
8
+ * is skipped (writeDevAssets handles those separately during `extro dev`).
9
+ * Used by the dev build-watch sidecar.
10
+ */
11
+ scriptsOnly?: boolean;
12
+ /**
13
+ * When set, wrap the background entry in the dev bridge so a service
14
+ * worker exists in dev mode (even if the user didn't write one) to
15
+ * receive rebuild signals from the CLI's WS server and forward Vite HMR
16
+ * events to content scripts.
17
+ */
18
+ devBridge?: {
19
+ signalPort: number;
20
+ vitePort: number;
21
+ };
22
+ /**
23
+ * Called from the dev-server plugin's `handleHotUpdate` with a payload
24
+ * shaped like Vite's own HMR `update` event. The CLI uses this to
25
+ * broadcast HMR over the signal WS (avoiding Vite's HMR WS, whose
26
+ * origin check rejects chrome-extension:// service workers).
27
+ */
28
+ broadcastHmr?: (payload: object) => void;
29
+ }
30
+ export declare function extro(options: ExtroPluginOptions): Plugin;
31
+ export {};
@@ -0,0 +1,246 @@
1
+ import path from "node:path";
2
+ import { scanAppTree, routeManifest, APP_FILE_BASENAMES, } from "./app-tree.js";
3
+ import { emitAssets } from "./emit-assets.js";
4
+ import { discoverAssets } from "./asset-inventory.js";
5
+ import { decideTreeReaction } from "./dev-reactions.js";
6
+ import { SURFACES } from "./surfaces.js";
7
+ import { emitIcons } from "./generators/icons.js";
8
+ import { emitPublicAssets } from "./generators/public.js";
9
+ import { emit } from "./runtimes/routes-module.js";
10
+ import { generateRuntimeModule } from "./runtimes/runtime-module.js";
11
+ import { DEV_BRIDGE_ENTRY_ID, DEV_BRIDGE_CONFIG_ID, DEV_BRIDGE_USER_BG_ID, loadDevBridgeClient, devBridgeConfigSource, devBridgeUserBackgroundSource, } from "./runtimes/dev-bridge.js";
12
+ import { CSUI_ENTRY_ID, CSUI_CONFIG_ID, CSUI_USER_PAGE_ID, CSUI_USER_SCRIPT_ID, loadCSUIClient, csuiConfigSource, csuiUserPageSource, csuiUserScriptSource, } from "./runtimes/csui-mount.js";
13
+ import { readJson } from "./utils/read-json.js";
14
+ // Virtual IDs follow a slash-namespaced convention so each surface has its
15
+ // own runtime + routes module. Resolved IDs are prefixed with "\0" per
16
+ // Rollup's convention for internal/virtual modules.
17
+ const routesId = (surface) => `virtual:extro/routes/${surface}`;
18
+ const runtimeId = (surface) => `virtual:extro/runtime/${surface}`;
19
+ const resolved = (id) => `\0${id}`;
20
+ export function extro(options) {
21
+ const root = options.root;
22
+ const config = options.config ?? {};
23
+ const scriptsOnly = options.scriptsOnly ?? false;
24
+ const devBridge = options.devBridge;
25
+ const broadcastHmr = options.broadcastHmr;
26
+ let tree = { scripts: {}, surfaces: {} };
27
+ let pkg = {};
28
+ return {
29
+ name: "extro",
30
+ async config() {
31
+ tree = await scanAppTree(root);
32
+ const empty = Object.keys(tree.scripts).length === 0 &&
33
+ Object.keys(tree.surfaces).length === 0;
34
+ if (empty && !devBridge) {
35
+ // In dev with bridge, an entry-less project still gets a synthesized
36
+ // background SW for HMR signalling. Otherwise, complain.
37
+ throw new Error("Extro: No extension entrypoints found.\n\nExpected files like:\n src/app/popup/page.tsx\n src/app/options/page.tsx\n src/app/sidepanel/page.tsx\n src/app/background/index.ts\n src/app/content/index.ts");
38
+ }
39
+ pkg = readJson("package.json", root) ?? {};
40
+ const input = {};
41
+ const contentEntry = tree.scripts.content?.csui
42
+ ? CSUI_ENTRY_ID
43
+ : tree.scripts.content?.script;
44
+ if (devBridge) {
45
+ // Force a background entry in dev — wraps user's BG (if any) with
46
+ // the WS bridge.
47
+ input.background = DEV_BRIDGE_ENTRY_ID;
48
+ if (contentEntry)
49
+ input.content = contentEntry;
50
+ }
51
+ else if (scriptsOnly) {
52
+ if (tree.scripts.background)
53
+ input.background = tree.scripts.background;
54
+ if (contentEntry)
55
+ input.content = contentEntry;
56
+ }
57
+ else {
58
+ if (tree.scripts.background)
59
+ input.background = tree.scripts.background;
60
+ if (contentEntry)
61
+ input.content = contentEntry;
62
+ for (const surface of Object.keys(tree.surfaces)) {
63
+ input[surface] = runtimeId(surface);
64
+ }
65
+ }
66
+ return {
67
+ // Extro owns `public/` emission through its own pipeline (collision
68
+ // guard + WAR list + dev-output parity), so Vite's native copy is
69
+ // off. See ADR 0004.
70
+ publicDir: false,
71
+ // Only EXTRO_PUBLIC_* is inlined into surfaces via import.meta.env.
72
+ // Exactly EXTRO_PUBLIC_, never EXTRO_ (that would leak EXTRO_CRX_KEY
73
+ // and other build-time vars into bundles). See ADR 0002.
74
+ envPrefix: "EXTRO_PUBLIC_",
75
+ build: {
76
+ rollupOptions: {
77
+ input,
78
+ output: {
79
+ entryFileNames: "[name].js",
80
+ },
81
+ },
82
+ },
83
+ };
84
+ },
85
+ async generateBundle() {
86
+ if (scriptsOnly)
87
+ return;
88
+ // One discovery pass feeds the manifest (pure), the icon emit, and the
89
+ // public emit. See the Asset inventory.
90
+ const inventory = discoverAssets(root, tree);
91
+ await emitAssets({ tree, inventory, pkg, config }, (fileName, source) => {
92
+ this.emitFile({ type: "asset", fileName, source });
93
+ });
94
+ emitIcons({ ctx: this, root, icons: inventory.icons });
95
+ emitPublicAssets({ ctx: this, root, publicAssets: inventory.public });
96
+ },
97
+ configureServer(server) {
98
+ if (scriptsOnly)
99
+ return;
100
+ const basenames = APP_FILE_BASENAMES.join(",");
101
+ server.watcher.add(path.join(root, `src/app/**/{${basenames}}.{ts,tsx}`));
102
+ // Warm the CSUI module into the dev server's module graph so its
103
+ // transitive imports are tracked too. Without this, content files
104
+ // never appear in handleHotUpdate's ctx.modules (the dev server
105
+ // only owns the routable surfaces — popup / options / sidepanel —
106
+ // as Rollup inputs), so edits under src/app/content/ would never
107
+ // produce an HMR payload for the BG-fetch RFR transport.
108
+ const csui = tree.scripts.content?.csui;
109
+ if (csui && broadcastHmr) {
110
+ const url = "/" + path.relative(root, csui).split(path.sep).join("/");
111
+ server.warmupRequest(url).catch(() => { });
112
+ }
113
+ // Same source as the scanner glob — see APP_FILE_BASENAMES.
114
+ const isAppEntry = new RegExp(`^src/app/[^/]+/(?:.+/)?(?:${APP_FILE_BASENAMES.join("|")})\\.tsx?$`);
115
+ const routableSurfaceList = SURFACES
116
+ .filter((s) => s.kind === "routable")
117
+ .map((s) => s.name);
118
+ const handleChange = async (file) => {
119
+ const rel = path.relative(root, file).split(path.sep).join("/");
120
+ if (!isAppEntry.test(rel))
121
+ return;
122
+ const prevTree = tree;
123
+ tree = await scanAppTree(root);
124
+ // Decide the Dev reaction (pure); the handler owns the effects.
125
+ const reaction = decideTreeReaction(prevTree, tree, routableSurfaceList);
126
+ if (reaction.kind === "restart") {
127
+ // A Surface born mid-session: its Rollup input was fixed at config()
128
+ // time, so a fresh dev session is required to register the new entry.
129
+ const what = reaction.surface === "background" || reaction.surface === "content"
130
+ ? `${reaction.surface} entrypoint`
131
+ : `${reaction.surface} surface`;
132
+ console.log(`\n[extro] New ${what} detected. Restart \`extro dev\` to pick it up.\n`);
133
+ return;
134
+ }
135
+ if (reaction.kind === "invalidate") {
136
+ // Reload each changed surface's routes virtual module; the runtime
137
+ // module's accept("virtual:extro/routes/<surface>") boundary picks
138
+ // up the new array and calls handle.update without a remount.
139
+ for (const surface of reaction.surfaces) {
140
+ const mod = server.moduleGraph.getModuleById(resolved(routesId(surface)));
141
+ if (mod) {
142
+ await server.reloadModule(mod);
143
+ console.log(`[extro] Routes updated for ${surface}.`);
144
+ }
145
+ }
146
+ }
147
+ };
148
+ server.watcher.on("add", handleChange);
149
+ server.watcher.on("unlink", handleChange);
150
+ },
151
+ resolveId(id) {
152
+ // Dev bridge virtuals
153
+ if (devBridge && id === DEV_BRIDGE_ENTRY_ID)
154
+ return resolved(DEV_BRIDGE_ENTRY_ID);
155
+ if (devBridge && id === DEV_BRIDGE_CONFIG_ID)
156
+ return resolved(DEV_BRIDGE_CONFIG_ID);
157
+ if (devBridge && id === DEV_BRIDGE_USER_BG_ID)
158
+ return resolved(DEV_BRIDGE_USER_BG_ID);
159
+ // CSUI virtuals
160
+ if (id === CSUI_ENTRY_ID)
161
+ return resolved(CSUI_ENTRY_ID);
162
+ if (id === CSUI_CONFIG_ID)
163
+ return resolved(CSUI_CONFIG_ID);
164
+ if (id === CSUI_USER_PAGE_ID)
165
+ return resolved(CSUI_USER_PAGE_ID);
166
+ if (id === CSUI_USER_SCRIPT_ID)
167
+ return resolved(CSUI_USER_SCRIPT_ID);
168
+ if (scriptsOnly)
169
+ return;
170
+ for (const desc of SURFACES) {
171
+ if (desc.kind !== "routable")
172
+ continue;
173
+ const surface = desc.name;
174
+ if (id === runtimeId(surface))
175
+ return resolved(runtimeId(surface));
176
+ if (id === routesId(surface))
177
+ return resolved(routesId(surface));
178
+ }
179
+ },
180
+ load(id) {
181
+ // Dev bridge: entry + its config + the optional user-background re-export.
182
+ if (devBridge && id === resolved(DEV_BRIDGE_ENTRY_ID)) {
183
+ return loadDevBridgeClient();
184
+ }
185
+ if (devBridge && id === resolved(DEV_BRIDGE_CONFIG_ID)) {
186
+ return devBridgeConfigSource({
187
+ signalPort: devBridge.signalPort,
188
+ vitePort: devBridge.vitePort,
189
+ hasCSUI: !!tree.scripts.content?.csui,
190
+ });
191
+ }
192
+ if (devBridge && id === resolved(DEV_BRIDGE_USER_BG_ID)) {
193
+ return devBridgeUserBackgroundSource(tree.scripts.background);
194
+ }
195
+ // CSUI mount: entry + its config + the user page + optional side-effect script.
196
+ const content = tree.scripts.content;
197
+ if (id === resolved(CSUI_ENTRY_ID) && content?.csui) {
198
+ return loadCSUIClient();
199
+ }
200
+ if (id === resolved(CSUI_CONFIG_ID)) {
201
+ return csuiConfigSource(!!devBridge);
202
+ }
203
+ if (id === resolved(CSUI_USER_PAGE_ID) && content?.csui) {
204
+ return csuiUserPageSource(content.csui);
205
+ }
206
+ if (id === resolved(CSUI_USER_SCRIPT_ID)) {
207
+ return csuiUserScriptSource(content?.script);
208
+ }
209
+ if (scriptsOnly)
210
+ return;
211
+ for (const desc of SURFACES) {
212
+ if (desc.kind !== "routable")
213
+ continue;
214
+ const surface = desc.name;
215
+ if (id === resolved(runtimeId(surface))) {
216
+ return generateRuntimeModule({ surface });
217
+ }
218
+ if (id === resolved(routesId(surface))) {
219
+ return emit(routeManifest(tree, surface));
220
+ }
221
+ }
222
+ },
223
+ handleHotUpdate(ctx) {
224
+ if (!broadcastHmr)
225
+ return;
226
+ // Mirror Vite's own HMR `update` payload shape. Modules without a
227
+ // resolved url (rare — e.g. virtual-only) are skipped; the CSUI
228
+ // re-mount signal still covers those via `cs-rebuilt`. Content
229
+ // files reach this list naturally because configureServer warms the
230
+ // CSUI module into the graph at startup.
231
+ const updates = ctx.modules
232
+ .filter((m) => !!m.url)
233
+ .map((m) => ({
234
+ type: m.type === "css" ? "css-update" : "js-update",
235
+ path: m.url,
236
+ acceptedPath: m.url,
237
+ timestamp: ctx.timestamp,
238
+ explicitImportRequired: false,
239
+ isWithinCircularImport: false,
240
+ }));
241
+ if (updates.length === 0)
242
+ return;
243
+ broadcastHmr({ type: "update", updates });
244
+ },
245
+ };
246
+ }
@@ -0,0 +1,14 @@
1
+ export { scanAppTree, routeManifest } from "./app-tree.js";
2
+ export type { AppTree, ContentSlot } from "./app-tree.js";
3
+ export type { ManifestRoute, RouteManifest } from "../types/index.js";
4
+ export { emitAssets, composeArtifacts } from "./emit-assets.js";
5
+ export type { EmitSink, AssetOptions, Artifacts } from "./emit-assets.js";
6
+ export { discoverAssets } from "./asset-inventory.js";
7
+ export type { AssetInventory } from "./asset-inventory.js";
8
+ export type { PublicAssets } from "./public.js";
9
+ export { classifyScriptChange, mergeDirty, resolveFlush } from "./dev-reactions.js";
10
+ export type { ScriptDirty } from "./dev-reactions.js";
11
+ export { renderDevScreen, devScreenStyles } from "./generators/html.js";
12
+ export type { DevScreen } from "./generators/html.js";
13
+ export { SURFACES } from "./surfaces.js";
14
+ export type { RoutableSurface, ScriptSurface, SurfaceName, SurfaceKind, SurfaceContext, SurfaceDescriptor, } from "./surfaces.js";
@@ -0,0 +1,6 @@
1
+ export { scanAppTree, routeManifest } from "./app-tree.js";
2
+ export { emitAssets, composeArtifacts } from "./emit-assets.js";
3
+ export { discoverAssets } from "./asset-inventory.js";
4
+ export { classifyScriptChange, mergeDirty, resolveFlush } from "./dev-reactions.js";
5
+ export { renderDevScreen, devScreenStyles } from "./generators/html.js";
6
+ export { SURFACES } from "./surfaces.js";
@@ -0,0 +1,29 @@
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
+ interface GenerateManifestOptions {
5
+ tree: AppTree;
6
+ /**
7
+ * The build's discovered icons + Public assets. Passed in as data so this
8
+ * generator never touches the filesystem: its inputs are the test surface.
9
+ */
10
+ inventory: AssetInventory;
11
+ pkg: {
12
+ name?: string;
13
+ description?: string;
14
+ version?: string;
15
+ };
16
+ config: ExtroConfig;
17
+ /**
18
+ * When set, the manifest is generated for a dev session:
19
+ * - CSP relaxed for the Vite dev server + signal WS
20
+ * - Background descriptor reports present (bridge runs there)
21
+ * - Background descriptor adds `tabs` to permissions
22
+ */
23
+ dev?: {
24
+ port: number;
25
+ signalPort: number;
26
+ };
27
+ }
28
+ export declare function generateManifest({ tree, inventory, pkg, config, dev, }: GenerateManifestOptions): ManifestV3;
29
+ export {};
@@ -0,0 +1,68 @@
1
+ import { SURFACES } from "./surfaces.js";
2
+ export function generateManifest({ tree, inventory, pkg, config, dev, }) {
3
+ const manifest = {
4
+ manifest_version: 3,
5
+ name: config.name ?? pkg.name ?? "Extro Extension",
6
+ version: config.version ?? pkg.version ?? "0.0.1",
7
+ };
8
+ const description = config.description ?? pkg.description;
9
+ if (description) {
10
+ manifest.description = description;
11
+ }
12
+ const publicAssets = inventory.public.files;
13
+ const ctx = { tree, config, dev, publicAssets };
14
+ const permissions = new Set(config.permissions ?? []);
15
+ const hostPermissions = new Set(config.hostPermissions ?? []);
16
+ for (const desc of SURFACES) {
17
+ if (!desc.isPresent(ctx))
18
+ continue;
19
+ Object.assign(manifest, desc.manifestContribution(ctx));
20
+ if (!config.permissions && desc.permissions) {
21
+ for (const p of desc.permissions(ctx))
22
+ permissions.add(p);
23
+ }
24
+ if (!config.hostPermissions && desc.hostPermissions) {
25
+ for (const p of desc.hostPermissions(ctx))
26
+ hostPermissions.add(p);
27
+ }
28
+ }
29
+ if (permissions.size > 0) {
30
+ manifest.permissions = [...permissions];
31
+ }
32
+ if (hostPermissions.size > 0) {
33
+ manifest.host_permissions = [...hostPermissions];
34
+ }
35
+ const icons = config.icons ?? inventory.icons ?? undefined;
36
+ if (icons) {
37
+ manifest.icons = icons;
38
+ }
39
+ if (dev) {
40
+ // CSP relaxed for dev: Vite dev server (HTTP + HMR WS) + the CLI's
41
+ // signal WS that the dev bridge connects to.
42
+ const origin = `http://localhost:${dev.port}`;
43
+ const viteWs = `ws://localhost:${dev.port}`;
44
+ const signalWs = `ws://localhost:${dev.signalPort}`;
45
+ manifest.content_security_policy = {
46
+ extension_pages: [
47
+ `script-src 'self' ${origin} 'wasm-unsafe-eval'`,
48
+ `object-src 'self'`,
49
+ `connect-src 'self' ${origin} ${viteWs} ${signalWs}`,
50
+ ].join("; "),
51
+ };
52
+ }
53
+ // EXTRO_CRX_KEY pins the extension ID (ADR 0002). Set before the
54
+ // config.manifest merge so an explicit manifest.key still wins.
55
+ const crxKey = process.env.EXTRO_CRX_KEY;
56
+ if (crxKey) {
57
+ manifest.key = crxKey;
58
+ }
59
+ if (config.manifest) {
60
+ Object.assign(manifest, config.manifest);
61
+ }
62
+ // Final imperative hook: sees the fully generated manifest and may mutate it
63
+ // or return a replacement. Runs last so it can change anything (ADR 0008).
64
+ if (config.transformManifest) {
65
+ return config.transformManifest(manifest) ?? manifest;
66
+ }
67
+ return manifest;
68
+ }
@@ -0,0 +1,21 @@
1
+ import type { AppTree } from "./app-tree.js";
2
+ /**
3
+ * @file public.ts
4
+ * @description Discovers Public assets (static files under the project-root
5
+ * `public/` directory) and partitions them against the names Extro itself
6
+ * emits, so a stray `public/manifest.json` can never shadow the real one.
7
+ * The same partition drives prod emission, dev copy, and the
8
+ * `web_accessible_resources` list, so all three agree on exactly what ships.
9
+ */
10
+ export declare const PUBLIC_DIR = "public";
11
+ export interface PublicAssets {
12
+ /** Posix-relative paths that will ship, sorted for stable output. */
13
+ files: string[];
14
+ /** Files skipped because their name collides with a generated output. */
15
+ conflicts: string[];
16
+ }
17
+ /**
18
+ * Partition `public/` into shippable files and conflicts. Returns empty
19
+ * lists when `public/` is absent.
20
+ */
21
+ export declare function collectPublicAssets(root: string, tree: AppTree): PublicAssets;