extrojs 0.1.0 → 0.2.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/README.md CHANGED
@@ -16,11 +16,11 @@ pnpm add react react-dom
16
16
  ## Quick start
17
17
 
18
18
  ```bash
19
- extro dev # dev server with HMR, writes .output/chrome-mv3-dev/
20
- extro build # production build to .output/chrome-mv3-prod/
19
+ extro dev # dev server with HMR, writes output/chrome-mv3-dev/
20
+ extro build # production build to output/chrome-mv3-prod/
21
21
  ```
22
22
 
23
- Load `.output/chrome-mv3-dev/` (or `.output/chrome-mv3-prod/`) in Chrome via **Load Unpacked**.
23
+ Load `output/chrome-mv3-dev/` (or `output/chrome-mv3-prod/`) in Chrome via **Load Unpacked**.
24
24
 
25
25
  ## Docs and source
26
26
 
package/client.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ // Ambient types for Extro's env. Opt in from a project by adding:
2
+ // /// <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
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @describe Parses argv and dispatches. cac itself prints --help/--version
3
+ * during parse() (scoped to the matched command), so we only handle the
4
+ * fallback: bare `extro` and unknown commands print top-level help (exit 0
5
+ * when bare, exit 1 when the command was unknown).
6
+ */
7
+ export declare const run: () => Promise<void>;
package/dist/cli.js ADDED
@@ -0,0 +1,27 @@
1
+ import { cac } from "cac";
2
+ import { pkg } from "./pkg.js";
3
+ import { dev } from "./commands/dev.js";
4
+ import { build } from "./commands/build.js";
5
+ const cli = cac("extro");
6
+ cli.command("dev", "Start the dev server with HMR").action(dev);
7
+ cli.command("build", "Build a production extension bundle").action(build);
8
+ cli.help();
9
+ cli.version(pkg.version);
10
+ /**
11
+ * @describe Parses argv and dispatches. cac itself prints --help/--version
12
+ * during parse() (scoped to the matched command), so we only handle the
13
+ * fallback: bare `extro` and unknown commands print top-level help (exit 0
14
+ * when bare, exit 1 when the command was unknown).
15
+ */
16
+ export const run = async () => {
17
+ const parsed = cli.parse(process.argv, { run: false });
18
+ // cac has already printed help/version to stdout at this point.
19
+ if (parsed.options.help || parsed.options.version)
20
+ return;
21
+ if (!cli.matchedCommand) {
22
+ cli.outputHelp();
23
+ process.exitCode = process.argv.slice(2).length > 0 ? 1 : 0;
24
+ return;
25
+ }
26
+ await cli.runMatchedCommand();
27
+ };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * @describe Produces a standalone production bundle in
3
+ * <outDir>/chrome-mv3-prod/: manifest, HTML shells, and script bundles.
4
+ */
5
+ export declare const build: () => Promise<void>;
@@ -0,0 +1,26 @@
1
+ import { build as viteBuild } from "vite";
2
+ import { extro } from "@extrojs/vite-plugin";
3
+ import react from "@vitejs/plugin-react";
4
+ import { loadConfig } from "../load-config.js";
5
+ import { loadEnvIntoProcess } from "../env.js";
6
+ import { outputDir } from "../paths.js";
7
+ import { createViteLogger, log } from "../logger.js";
8
+ /**
9
+ * @describe Produces a standalone production bundle in
10
+ * <outDir>/chrome-mv3-prod/: manifest, HTML shells, and script bundles.
11
+ */
12
+ export const build = async () => {
13
+ const root = process.cwd();
14
+ log.info("Building extension for production...");
15
+ loadEnvIntoProcess(root, "production");
16
+ const config = await loadConfig(root);
17
+ const prodOutDir = outputDir(root, config, "prod");
18
+ await viteBuild({
19
+ root,
20
+ plugins: [react(), extro({ root, config })],
21
+ build: { outDir: prodOutDir },
22
+ customLogger: createViteLogger(),
23
+ });
24
+ log.success("Build complete");
25
+ log.info(`Unpacked extension: ${prodOutDir}`);
26
+ };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * @describe Starts the Extro dev server: a Vite server for routable surfaces, a
3
+ * signal WS the extension's BG SW connects to, and a build-watch sidecar for
4
+ * background + content scripts. Writes the dev manifest/HTML into the dev
5
+ * output dir and wires SIGINT/SIGTERM to a clean shutdown.
6
+ */
7
+ export declare const dev: () => Promise<void>;
@@ -0,0 +1,173 @@
1
+ import { createServer, build as viteBuild } from "vite";
2
+ import { WebSocketServer } from "ws";
3
+ import { extro } from "@extrojs/vite-plugin";
4
+ import react from "@vitejs/plugin-react";
5
+ import { scanAppTree } from "@extrojs/vite-plugin/internal";
6
+ import { loadConfig } from "../load-config.js";
7
+ import { loadEnvIntoProcess } from "../env.js";
8
+ import { outputDir } from "../paths.js";
9
+ import { writeDevAssets } from "../dev-assets.js";
10
+ import { pkg } from "../pkg.js";
11
+ import { banner, createViteLogger, log } from "../logger.js";
12
+ const once = (emitter, event) => new Promise((resolve) => emitter.once(event, () => resolve()));
13
+ /**
14
+ * @describe Throws a helpful error when the user's src/app has no entrypoints,
15
+ * so `extro dev` fails fast with guidance instead of starting an empty server.
16
+ */
17
+ const validateTree = (tree) => {
18
+ const empty = Object.keys(tree.scripts).length === 0 &&
19
+ Object.keys(tree.surfaces).length === 0;
20
+ if (!empty)
21
+ return;
22
+ 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");
23
+ };
24
+ /**
25
+ * @describe Starts the Extro dev server: a Vite server for routable surfaces, a
26
+ * signal WS the extension's BG SW connects to, and a build-watch sidecar for
27
+ * background + content scripts. Writes the dev manifest/HTML into the dev
28
+ * output dir and wires SIGINT/SIGTERM to a clean shutdown.
29
+ */
30
+ export const dev = async () => {
31
+ const root = process.cwd();
32
+ loadEnvIntoProcess(root, "development");
33
+ const config = await loadConfig(root);
34
+ // Separate output dirs let dev artifacts (with the bridge installed) persist
35
+ // across `extro dev` sessions without needing a prod-restore on shutdown.
36
+ const devOutDir = outputDir(root, config, "dev");
37
+ // 1. Scan once up front so we can decide what to start.
38
+ const tree = await scanAppTree(root);
39
+ validateTree(tree);
40
+ // 2. Signal WS — the dev bridge in the extension's BG SW connects here.
41
+ // Fixed port (not :0) so the port baked into a previously-loaded BG SW
42
+ // keeps working across `extro dev` restarts — otherwise users have to
43
+ // refresh the extension every time to pick up a new random port.
44
+ const signalPort = config.dev?.bridgePort ?? 9012;
45
+ const wss = new WebSocketServer({ port: signalPort });
46
+ wss.on("error", (err) => {
47
+ if (err.code === "EADDRINUSE") {
48
+ log.error(`Signal port ${signalPort} is in use. Is another \`extro dev\` already running?`);
49
+ process.exit(1);
50
+ }
51
+ throw err;
52
+ });
53
+ await once(wss, "listening");
54
+ const broadcast = (msg) => {
55
+ const payload = JSON.stringify(msg);
56
+ for (const client of wss.clients) {
57
+ if (client.readyState === 1)
58
+ client.send(payload);
59
+ }
60
+ };
61
+ // 3. Vite dev server for routable surfaces.
62
+ // `broadcastHmr` lets the plugin push HMR updates over our signal WS —
63
+ // we can't piggy-back on Vite's own HMR WS because its origin check
64
+ // rejects chrome-extension:// service workers.
65
+ const viteLogger = createViteLogger();
66
+ const server = await createServer({
67
+ root,
68
+ plugins: [
69
+ react(),
70
+ extro({
71
+ root,
72
+ config,
73
+ broadcastHmr: (payload) => broadcast({ kind: "vite-hmr", payload }),
74
+ }),
75
+ ],
76
+ server: { cors: true, port: config.dev?.port, strictPort: config.dev?.strictPort },
77
+ // Keep the user's scrollback + our banner; Vite clears the terminal by
78
+ // default on start and on each HMR.
79
+ clearScreen: false,
80
+ customLogger: viteLogger,
81
+ });
82
+ await server.listen();
83
+ const addr = server.httpServer?.address();
84
+ const port = addr && typeof addr === "object"
85
+ ? addr.port
86
+ : server.config.server.port ?? 5173;
87
+ // 4. Dev manifest + HTML + icons.
88
+ await writeDevAssets({ tree, root, outDir: devOutDir, port, signalPort, config });
89
+ // 5. Build-watch sidecar for background + content. Always runs in dev so
90
+ // the dev bridge gets bundled into background.js (even if the user has
91
+ // no BG of their own).
92
+ const watcher = await viteBuild({
93
+ root,
94
+ // Same mode as the dev server (createServer defaults to development) so
95
+ // background/content resolve the same .env set as the routables. Without
96
+ // this the sidecar defaults to production and the scripts would load
97
+ // .env.production while the popup loads .env.development. See ADR 0002.
98
+ mode: "development",
99
+ plugins: [extro({ root, config, scriptsOnly: true, devBridge: { signalPort, vitePort: port } })],
100
+ // emptyOutDir: false so the watcher doesn't wipe the manifest / HTML /
101
+ // icons that writeDevAssets just put down.
102
+ build: { watch: {}, emptyOutDir: false, outDir: devOutDir },
103
+ logLevel: "error",
104
+ });
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.
112
+ if (watcher && typeof watcher.on === "function") {
113
+ const w = watcher;
114
+ let bgDirty = false;
115
+ let csDirty = false;
116
+ 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
+ }
128
+ });
129
+ w.on("event", (event) => {
130
+ if (event.code !== "BUNDLE_END")
131
+ return;
132
+ if (!bgDirty && !csDirty) {
133
+ bgDirty = true;
134
+ csDirty = true;
135
+ }
136
+ if (bgDirty)
137
+ broadcast({ kind: "bg-rebuilt" });
138
+ if (csDirty)
139
+ broadcast({ kind: "cs-rebuilt" });
140
+ bgDirty = false;
141
+ csDirty = false;
142
+ });
143
+ }
144
+ banner({
145
+ mode: "dev",
146
+ version: pkg.version,
147
+ rows: [
148
+ { label: "Server", value: `http://localhost:${port}` },
149
+ { label: "Unpacked", value: devOutDir },
150
+ ],
151
+ hint: 'Load the unpacked dir in Chrome via "Load Unpacked".',
152
+ });
153
+ let shuttingDown = false;
154
+ const shutdown = async () => {
155
+ if (shuttingDown)
156
+ return;
157
+ shuttingDown = true;
158
+ process.off("SIGINT", shutdown);
159
+ process.off("SIGTERM", shutdown);
160
+ log.info("Shutting down dev server...");
161
+ if (watcher && typeof watcher.close === "function") {
162
+ await watcher.close();
163
+ }
164
+ wss.close();
165
+ await server.close();
166
+ // No prod-restore: dev artifacts live in their own output/chrome-mv3-dev
167
+ // dir so the loaded extension stays untouched. Run `extro build` for a
168
+ // standalone prod bundle in output/chrome-mv3-prod.
169
+ process.exit(0);
170
+ };
171
+ process.on("SIGINT", shutdown);
172
+ process.on("SIGTERM", shutdown);
173
+ };
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { emitAssets, } from "@extrojs/vite-plugin/internal";
3
+ import { emitAssets, collectPublicAssets, } from "@extrojs/vite-plugin/internal";
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
@@ -13,6 +13,7 @@ export const writeDevAssets = async ({ tree, root, outDir, port, signalPort, con
13
13
  const pkg = JSON.parse(pkgRaw);
14
14
  await emitAssets({ tree, root, pkg, config, dev: { port, signalPort } }, (fileName, source) => fs.writeFile(path.join(outDir, fileName), source));
15
15
  await copyIcons(root, outDir);
16
+ await copyPublic(root, outDir, tree);
16
17
  };
17
18
  const copyIcons = async (root, outDir) => {
18
19
  const srcDir = path.join(root, "icons");
@@ -24,3 +25,21 @@ const copyIcons = async (root, outDir) => {
24
25
  const files = await fs.readdir(srcDir);
25
26
  await Promise.all(files.map((f) => fs.copyFile(path.join(srcDir, f), path.join(dstDir, f))));
26
27
  };
28
+ /**
29
+ * @describe Copies Public assets into the dev output dir so they resolve at
30
+ * the extension origin in dev exactly as in prod (chrome.runtime.getURL, or a
31
+ * root-relative ref on a routable surface). Mirrors copyIcons; the collision
32
+ * guard from collectPublicAssets keeps a stray file from shadowing a
33
+ * generated output.
34
+ */
35
+ const copyPublic = async (root, outDir, tree) => {
36
+ const { files, conflicts } = collectPublicAssets(root, tree);
37
+ for (const conflict of conflicts) {
38
+ console.warn(`[extro] public/${conflict} collides with a generated output; skipping. Rename it to ship it.`);
39
+ }
40
+ await Promise.all(files.map(async (rel) => {
41
+ const dst = path.join(outDir, rel);
42
+ await fs.mkdir(path.dirname(dst), { recursive: true });
43
+ await fs.copyFile(path.join(root, "public", rel), dst);
44
+ }));
45
+ };
package/dist/env.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @describe Loads `.env` files into `process.env` before config is read, so
3
+ * `extro.config.ts` and manifest generation (the build-time env tier) can see
4
+ * them. Uses Vite's own `loadEnv` with an empty prefix (all vars, same
5
+ * stacking as `import.meta.env`), and does NOT override existing keys, so real
6
+ * process env (CI) wins over `.env` files. The public tier (`EXTRO_PUBLIC_*`
7
+ * via `import.meta.env`) is handled separately by Vite's `envPrefix`. See ADR
8
+ * 0002.
9
+ */
10
+ export declare const loadEnvIntoProcess: (root: string, mode: "development" | "production") => void;
package/dist/env.js ADDED
@@ -0,0 +1,18 @@
1
+ import { loadEnv } from "vite";
2
+ /**
3
+ * @describe Loads `.env` files into `process.env` before config is read, so
4
+ * `extro.config.ts` and manifest generation (the build-time env tier) can see
5
+ * them. Uses Vite's own `loadEnv` with an empty prefix (all vars, same
6
+ * stacking as `import.meta.env`), and does NOT override existing keys, so real
7
+ * process env (CI) wins over `.env` files. The public tier (`EXTRO_PUBLIC_*`
8
+ * via `import.meta.env`) is handled separately by Vite's `envPrefix`. See ADR
9
+ * 0002.
10
+ */
11
+ export const loadEnvIntoProcess = (root, mode) => {
12
+ const env = loadEnv(mode, root, "");
13
+ for (const [key, value] of Object.entries(env)) {
14
+ if (process.env[key] === undefined) {
15
+ process.env[key] = value;
16
+ }
17
+ }
18
+ };
package/dist/index.js CHANGED
@@ -1,137 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import path from "node:path";
3
- import { createServer, build as viteBuild } from "vite";
4
- import { WebSocketServer } from "ws";
5
- import { extro } from "@extrojs/vite-plugin";
6
- import react from "@vitejs/plugin-react";
7
- import { scanAppTree } from "@extrojs/vite-plugin/internal";
8
- import { loadConfig } from "./load-config.js";
9
- import { writeDevAssets } from "./dev-assets.js";
10
- const command = process.argv[2];
11
- const root = process.cwd();
12
- // Separate output dirs let dev artifacts (with the bridge installed) persist
13
- // across `extro dev` sessions without needing a prod-restore on shutdown.
14
- const devOutDir = path.join(root, ".output", "chrome-mv3-dev");
15
- const prodOutDir = path.join(root, ".output", "chrome-mv3-prod");
16
- const dev = async () => {
17
- const config = await loadConfig(root);
18
- // 1. Scan once up front so we can decide what to start.
19
- const tree = await scanAppTree(root);
20
- validateTree(tree);
21
- // 2. Signal WS — the dev bridge in the extension's BG SW connects here.
22
- // Fixed port (not :0) so the port baked into a previously-loaded BG SW
23
- // keeps working across `extro dev` restarts — otherwise users have to
24
- // refresh the extension every time to pick up a new random port.
25
- const signalPort = 9012;
26
- const wss = new WebSocketServer({ port: signalPort });
27
- wss.on("error", (err) => {
28
- if (err.code === "EADDRINUSE") {
29
- console.error(`\n[extro] Signal port ${signalPort} is in use — is another \`extro dev\` already running?\n`);
30
- process.exit(1);
31
- }
32
- throw err;
33
- });
34
- await once(wss, "listening");
35
- const broadcast = (msg) => {
36
- const payload = JSON.stringify(msg);
37
- for (const client of wss.clients) {
38
- if (client.readyState === 1)
39
- client.send(payload);
40
- }
41
- };
42
- // 3. Vite dev server for routable surfaces.
43
- // `broadcastHmr` lets the plugin push HMR updates over our signal WS —
44
- // we can't piggy-back on Vite's own HMR WS because its origin check
45
- // rejects chrome-extension:// service workers.
46
- const server = await createServer({
47
- root,
48
- plugins: [
49
- react(),
50
- extro({
51
- root,
52
- config,
53
- broadcastHmr: (payload) => broadcast({ kind: "vite-hmr", payload }),
54
- }),
55
- ],
56
- server: { cors: true },
57
- });
58
- await server.listen();
59
- const addr = server.httpServer?.address();
60
- const port = addr && typeof addr === "object" ? addr.port : server.config.server.port ?? 5173;
61
- // 4. Dev manifest + HTML + icons.
62
- await writeDevAssets({ tree, root, outDir: devOutDir, port, signalPort, config });
63
- // 5. Build-watch sidecar for background + content. Always runs in dev so
64
- // the dev bridge gets bundled into background.js (even if the user has
65
- // no BG of their own).
66
- const watcher = await viteBuild({
67
- root,
68
- plugins: [extro({ root, config, scriptsOnly: true, devBridge: { signalPort } })],
69
- // emptyOutDir: false so the watcher doesn't wipe the manifest / HTML /
70
- // icons that writeDevAssets just put down.
71
- build: { watch: {}, emptyOutDir: false, outDir: devOutDir },
72
- logLevel: "error",
73
- });
74
- // The watcher is a RollupWatcher; bundle events fire on every rebuild
75
- // (including the first one). Broadcast on each so the bridge reloads.
76
- if (watcher && typeof watcher.on === "function") {
77
- ;
78
- watcher.on("event", (event) => {
79
- if (event.code === "BUNDLE_END") {
80
- broadcast({ kind: "scripts-rebuilt" });
81
- }
82
- });
83
- }
84
- console.log(`\nExtro dev server: http://localhost:${port}`);
85
- console.log(`Load unpacked extension from: ${devOutDir}\n`);
86
- let shuttingDown = false;
87
- const shutdown = async () => {
88
- if (shuttingDown) {
89
- console.log("\nAlready shutting down, please wait...");
90
- return;
91
- }
92
- shuttingDown = true;
93
- console.log("\nShutting down dev server...");
94
- if (watcher && typeof watcher.close === "function") {
95
- await watcher.close();
96
- }
97
- wss.close();
98
- await server.close();
99
- // No prod-restore: dev artifacts live in their own .output/chrome-mv3-dev
100
- // dir so the loaded extension stays untouched. Run `extro build` for a
101
- // standalone prod bundle in .output/chrome-mv3-prod.
102
- process.exit(0);
103
- };
104
- process.on("SIGINT", shutdown);
105
- process.on("SIGTERM", shutdown);
106
- };
107
- const validateTree = (tree) => {
108
- const empty = Object.keys(tree.scripts).length === 0 &&
109
- Object.keys(tree.surfaces).length === 0;
110
- if (!empty)
111
- return;
112
- 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");
113
- };
114
- const once = (emitter, event) => new Promise((resolve) => emitter.once(event, () => resolve()));
115
- const build = async () => {
116
- console.log("Building extension...");
117
- const config = await loadConfig(root);
118
- await viteBuild({
119
- root,
120
- plugins: [react(), extro({ root, config })],
121
- build: { outDir: prodOutDir },
122
- });
123
- console.log(`Build complete. Load unpacked extension from: ${prodOutDir}`);
124
- };
125
- switch (command) {
126
- case "dev":
127
- await dev();
128
- break;
129
- case "build":
130
- await build();
131
- break;
132
- case "init":
133
- console.log("Initializing Extro project...");
134
- break;
135
- default:
136
- console.log("Extro CLI");
137
- }
2
+ import { run } from "./cli.js";
3
+ run().catch((err) => {
4
+ console.error(err);
5
+ process.exit(1);
6
+ });
@@ -0,0 +1,34 @@
1
+ import { type Logger } from "vite";
2
+ interface BannerRow {
3
+ label: string;
4
+ value: string;
5
+ }
6
+ interface BannerOptions {
7
+ mode: string;
8
+ version: string;
9
+ rows: BannerRow[];
10
+ hint?: string;
11
+ }
12
+ export declare const log: {
13
+ success: (msg: string) => void;
14
+ info: (msg: string) => void;
15
+ warn: (msg: string) => void;
16
+ error: (msg: string) => void;
17
+ /** Low-key line for frequent, low-importance output (e.g. HMR updates).
18
+ * Multi-line input is prefixed per line so the `›` rail stays continuous. */
19
+ muted: (msg: string) => void;
20
+ };
21
+ /**
22
+ * @describe A Vite `customLogger` that rebadges Vite's own info chatter
23
+ * (HMR updates, page reloads, dep optimization, "building for production",
24
+ * bundle sizes, etc.) as Extro `›` muted lines. Warnings + errors still
25
+ * pass through Vite's logger untouched so they keep their semantics.
26
+ */
27
+ export declare const createViteLogger: () => Logger;
28
+ /**
29
+ * @describe Prints the grouped startup banner: a brand tag with version/mode,
30
+ * then aligned label/value rows and an optional dimmed hint. Mirrors Vite's
31
+ * own startup idiom so the two read as a coherent stack.
32
+ */
33
+ export declare const banner: ({ mode, version, rows, hint }: BannerOptions) => void;
34
+ export {};
package/dist/logger.js ADDED
@@ -0,0 +1,65 @@
1
+ import pc from "picocolors";
2
+ import { createLogger } from "vite";
3
+ // Extro brand terracotta (#CC785C) on the logo's near-black (#0a0a0a).
4
+ // picocolors only does the 16 ANSI names, so emit 24-bit truecolor directly,
5
+ // gated on the same color-support check picocolors uses (auto-plain in pipes).
6
+ const brand = (s) => pc.isColorSupported ? `\x1b[38;2;204;120;92m${s}\x1b[39m` : s;
7
+ const brandTag = (s) => pc.isColorSupported
8
+ ? `\x1b[1m\x1b[48;2;204;120;92m\x1b[38;2;10;10;10m${s}\x1b[0m`
9
+ : s;
10
+ const tag = brandTag(" EXTRO ");
11
+ // Status prefixes stay conventional (green/yellow/red are universal terminal
12
+ // semantics); the Extro "voice" (tag, `›`, accents) carries the brand color.
13
+ export const log = {
14
+ success: (msg) => console.log(`${pc.green("✓")} ${msg}`),
15
+ info: (msg) => console.log(`${brand("›")} ${msg}`),
16
+ warn: (msg) => console.warn(`${pc.yellow("⚠")} ${msg}`),
17
+ error: (msg) => console.error(`${pc.red("✗")} ${msg}`),
18
+ /** Low-key line for frequent, low-importance output (e.g. HMR updates).
19
+ * Multi-line input is prefixed per line so the `›` rail stays continuous. */
20
+ muted: (msg) => {
21
+ for (const line of msg.split("\n"))
22
+ console.log(pc.dim(`› ${line}`));
23
+ },
24
+ };
25
+ /**
26
+ * @describe A Vite `customLogger` that rebadges Vite's own info chatter
27
+ * (HMR updates, page reloads, dep optimization, "building for production",
28
+ * bundle sizes, etc.) as Extro `›` muted lines. Warnings + errors still
29
+ * pass through Vite's logger untouched so they keep their semantics.
30
+ */
31
+ export const createViteLogger = () => {
32
+ const vite = createLogger();
33
+ vite.info = (msg) => {
34
+ const clean = msg
35
+ .replace(/\x1b\[[0-9;]*m/g, "")
36
+ .replace(/^[\d:apm.\s]*\[vite\]\s*/i, "")
37
+ .trim();
38
+ if (!clean)
39
+ return;
40
+ // Drop Vite's own startup banner — we already print "Building extension
41
+ // for production..." / the dev banner ourselves.
42
+ if (/^vite v[\d.]+ (building|dev server running)/i.test(clean))
43
+ return;
44
+ log.muted(clean);
45
+ };
46
+ return vite;
47
+ };
48
+ /**
49
+ * @describe Prints the grouped startup banner: a brand tag with version/mode,
50
+ * then aligned label/value rows and an optional dimmed hint. Mirrors Vite's
51
+ * own startup idiom so the two read as a coherent stack.
52
+ */
53
+ export const banner = ({ mode, version, rows, hint }) => {
54
+ const width = Math.max(...rows.map((row) => row.label.length));
55
+ const lines = [
56
+ "",
57
+ ` ${tag} ${pc.dim(`v${version}`)} ${brand(mode)}`,
58
+ "",
59
+ ...rows.map((row) => ` ${brand("➜")} ${row.label.padEnd(width)} ${brand(row.value)}`),
60
+ ];
61
+ if (hint)
62
+ lines.push("", ` ${pc.dim(hint)}`);
63
+ lines.push("");
64
+ console.log(lines.join("\n"));
65
+ };
@@ -0,0 +1,8 @@
1
+ import type { ExtroConfig } from "@extrojs/types";
2
+ /**
3
+ * @describe Resolved unpacked-extension output dir for a build mode. The base
4
+ * comes from `config.outDir` (default `output`, resolved against the project
5
+ * root); the `chrome-mv3-<mode>` subdir keeps dev and prod artifacts separate
6
+ * and leaves room for other targets later.
7
+ */
8
+ export declare const outputDir: (root: string, config: ExtroConfig, mode: "dev" | "prod") => string;
package/dist/paths.js ADDED
@@ -0,0 +1,8 @@
1
+ import path from "node:path";
2
+ /**
3
+ * @describe Resolved unpacked-extension output dir for a build mode. The base
4
+ * comes from `config.outDir` (default `output`, resolved against the project
5
+ * root); the `chrome-mv3-<mode>` subdir keeps dev and prod artifacts separate
6
+ * and leaves room for other targets later.
7
+ */
8
+ export const outputDir = (root, config, mode) => path.join(path.resolve(root, config.outDir ?? "output"), `chrome-mv3-${mode}`);
package/dist/pkg.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ interface Pkg {
2
+ name: string;
3
+ version: string;
4
+ }
5
+ export declare const pkg: Pkg;
6
+ export {};
package/dist/pkg.js ADDED
@@ -0,0 +1,5 @@
1
+ import { readFileSync } from "node:fs";
2
+ // Read at runtime rather than `import`ing package.json: it lives outside
3
+ // `rootDir: src`, so tsc would reject a static import. The URL resolves the
4
+ // same in the workspace (dist/pkg.js -> ../package.json) and once published.
5
+ export const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "extrojs",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Next.js for Chrome extensions. File-based entrypoints, automatic Manifest V3 generation, and React routing in a single Vite-powered CLI.",
5
5
  "keywords": [
6
6
  "extro",
@@ -29,13 +29,17 @@
29
29
  ".": {
30
30
  "types": "./dist/config.d.ts",
31
31
  "import": "./dist/config.js"
32
+ },
33
+ "./client": {
34
+ "types": "./client.d.ts"
32
35
  }
33
36
  },
34
37
  "bin": {
35
38
  "extro": "./dist/index.js"
36
39
  },
37
40
  "files": [
38
- "dist"
41
+ "dist",
42
+ "client.d.ts"
39
43
  ],
40
44
  "publishConfig": {
41
45
  "access": "public"
@@ -45,12 +49,13 @@
45
49
  },
46
50
  "dependencies": {
47
51
  "@vitejs/plugin-react": "^6.0.1",
52
+ "cac": "^7.0.0",
48
53
  "jiti": "^2.4.2",
54
+ "picocolors": "^1.1.1",
49
55
  "vite": "^8.0.0",
50
56
  "ws": "^8.20.0",
51
- "@extrojs/react": "0.1.0",
52
- "@extrojs/vite-plugin": "0.1.0",
53
- "@extrojs/types": "0.1.0"
57
+ "@extrojs/types": "0.2.0",
58
+ "@extrojs/vite-plugin": "0.2.0"
54
59
  },
55
60
  "devDependencies": {
56
61
  "@types/ws": "^8.18.1"
@@ -58,6 +63,7 @@
58
63
  "scripts": {
59
64
  "build": "tsc",
60
65
  "dev": "tsc -w",
61
- "typecheck": "tsc --noEmit"
66
+ "typecheck": "tsc --noEmit",
67
+ "test": "vitest run"
62
68
  }
63
69
  }