@stacksjs/browser-extension 0.70.88 → 0.70.91

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.
@@ -0,0 +1,16 @@
1
+ import type { BuildOptions, ExtensionConfig, ExtensionTarget } from './types';
2
+ /** Resolve the output directory for a target. */
3
+ export declare function resolveOutdir(config: ExtensionConfig, target: ExtensionTarget, override?: string): string;
4
+ /**`) + extra `assets` copied in
5
+ * - stx pages → `<name>.html` (+ companion `<name>.js`), sanitized for the
6
+ * extension CSP, with stx dev chunks removed
7
+ * - content + background scripts → classic IIFE bundles
8
+ * - declarativeNetRequest rulesets compiled from their `source` modules
9
+ * - `manifest.json` generated per target
10
+ * - the app's `hooks.postBuild` run last
11
+ *
12
+ * Replaces the hand-rolled `build-extension.ts` every extension used to carry.
13
+ */
14
+ export declare function buildExtension(config: ExtensionConfig, options: BuildOptions): Promise<{ outdir: string, target: ExtensionTarget }>;
15
+ /** Build every configured target. */
16
+ export declare function buildAllTargets(config: ExtensionConfig, options: Omit<BuildOptions, 'target'>): Promise<void>;
package/dist/build.js ADDED
@@ -0,0 +1,106 @@
1
+ import { Glob } from "bun";
2
+ import { cp, mkdir, rm } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import { join, resolve } from "node:path";
5
+ import { contentScriptOut, generateManifest } from "./manifest";
6
+ import { sanitizeExtensionHtml } from "./sanitize";
7
+ export function resolveOutdir(config, target, override) {
8
+ if (override)
9
+ return override;
10
+ if (typeof config.outdir === "string")
11
+ return config.outdir;
12
+ if (config.outdir?.[target])
13
+ return config.outdir[target];
14
+ return target === "firefox" ? "dist-firefox" : "dist";
15
+ }
16
+ function normalizePage(page) {
17
+ return typeof page === "string" ? { template: page } : page;
18
+ }
19
+ function pageEntries(pages) {
20
+ if (!pages)
21
+ return [];
22
+ const out = [];
23
+ if (pages.popup)
24
+ out.push({ name: "popup", page: normalizePage(pages.popup) });
25
+ if (pages.options)
26
+ out.push({ name: "options", page: normalizePage(pages.options) });
27
+ for (const [name, page] of Object.entries(pages.extra ?? {}))
28
+ out.push({ name, page: normalizePage(page) });
29
+ return out;
30
+ }
31
+ async function buildScript(entry, out, outdir, cwd, minify) {
32
+ const result = await Bun.build({
33
+ entrypoints: [resolve(cwd, entry)],
34
+ target: "browser",
35
+ format: "iife",
36
+ splitting: !1,
37
+ minify
38
+ });
39
+ if (!result.success)
40
+ throw Error(`[browser-extension] failed to build ${entry}: ${result.logs.join(`
41
+ `)}`);
42
+ const output = result.outputs[0];
43
+ if (!output)
44
+ throw Error(`[browser-extension] build produced no output for ${entry}`);
45
+ await Bun.write(join(outdir, out), await output.text());
46
+ }
47
+ export async function buildExtension(config, options) {
48
+ const target = options.target ?? "chrome", cwd = options.cwd ?? process.cwd(), outdir = resolve(cwd, resolveOutdir(config, target, options.outdir)), minify = options.minify ?? !0;
49
+ await rm(outdir, { recursive: !0, force: !0 });
50
+ await mkdir(join(outdir, "rules"), { recursive: !0 });
51
+ await mkdir(join(outdir, "icons"), { recursive: !0 });
52
+ if (config.public) {
53
+ const pub = resolve(cwd, config.public);
54
+ if (existsSync(pub))
55
+ await cp(pub, outdir, { recursive: !0 });
56
+ }
57
+ for (const [dest, src] of Object.entries(config.assets ?? {})) {
58
+ const s = resolve(cwd, src);
59
+ if (existsSync(s))
60
+ await Bun.write(join(outdir, dest), Bun.file(s));
61
+ }
62
+ const pages = pageEntries(config.pages);
63
+ await Promise.all(pages.filter((p) => p.page.script).map((p) => buildScript(p.page.script, `${p.name}.js`, outdir, cwd, minify)));
64
+ if (pages.length) {
65
+ const { default: stxPlugin } = await import("bun-plugin-stx"), result = await Bun.build({
66
+ entrypoints: pages.map((p) => resolve(cwd, p.page.template)),
67
+ outdir,
68
+ minify,
69
+ naming: { entry: "[name].html" },
70
+ plugins: [stxPlugin()]
71
+ });
72
+ if (!result.success)
73
+ throw Error(`[browser-extension] failed to build pages: ${result.logs.join(`
74
+ `)}`);
75
+ for (const { name, page } of pages) {
76
+ const file = join(outdir, `${name}.html`);
77
+ if (!existsSync(file))
78
+ continue;
79
+ const own = page.script ? [`${name}.js`] : [];
80
+ await Bun.write(file, sanitizeExtensionHtml(await Bun.file(file).text(), own));
81
+ }
82
+ for await (const chunk of new Glob("chunk-*.js").scan(outdir))
83
+ await rm(join(outdir, chunk), { force: !0 });
84
+ }
85
+ const scripts = [];
86
+ if (config.background)
87
+ scripts.push({ entry: config.background, out: "background.js" });
88
+ for (const cs of config.content ?? [])
89
+ scripts.push({ entry: cs.entry, out: contentScriptOut(cs.entry, cs.out) });
90
+ await Promise.all(scripts.map((s) => buildScript(s.entry, s.out, outdir, cwd, minify)));
91
+ for (const rule of config.rules ?? []) {
92
+ if (!rule.source)
93
+ continue;
94
+ const mod = await import(resolve(cwd, rule.source)), build = mod.default ?? mod.buildRules ?? mod.rules ?? Object.values(mod).find((v) => typeof v === "function") ?? Object.values(mod).find((v) => Array.isArray(v)), data = typeof build === "function" ? await build() : build;
95
+ await Bun.write(join(outdir, rule.path ?? `rules/${rule.id}.json`), `${JSON.stringify(data, null, 2)}
96
+ `);
97
+ }
98
+ await Bun.write(join(outdir, "manifest.json"), `${JSON.stringify(generateManifest(config, { version: options.version, target }), null, 2)}
99
+ `);
100
+ await config.hooks?.postBuild?.({ config, target, outdir, version: options.version, cwd });
101
+ return { outdir, target };
102
+ }
103
+ export async function buildAllTargets(config, options) {
104
+ for (const target of config.targets ?? ["chrome", "firefox"])
105
+ await buildExtension(config, { ...options, target });
106
+ }
@@ -0,0 +1,17 @@
1
+ import type { ExtensionConfig } from './types';
2
+ /**
3
+ * Identity helper for authoring `config/extension.ts` with full type-checking
4
+ * and editor completion:
5
+ *
6
+ * ```ts
7
+ * // config/extension.ts
8
+ * import { defineExtension } from '@stacksjs/browser-extension'
9
+ * export default defineExtension({ name: 'My Extension', description: '…', … })
10
+ * ```
11
+ */
12
+ export declare function defineExtension(config: ExtensionConfig): ExtensionConfig;
13
+ /**
14
+ * Load the project's extension config from `config/extension.ts` (default
15
+ * export). Returns null when the project has no extension config.
16
+ */
17
+ export declare function loadExtensionConfig(cwd?: string): Promise<ExtensionConfig | null>;
package/dist/config.js ADDED
@@ -0,0 +1,16 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ export function defineExtension(config) {
4
+ return config;
5
+ }
6
+ export async function loadExtensionConfig(cwd = process.cwd()) {
7
+ for (const rel of ["config/extension.ts", "extension.config.ts"]) {
8
+ const path = resolve(cwd, rel);
9
+ if (!existsSync(path))
10
+ continue;
11
+ const mod = await import(path), config = mod.default ?? mod.extension ?? mod.config;
12
+ if (config)
13
+ return config;
14
+ }
15
+ return null;
16
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * @stacksjs/browser-extension
3
+ *
4
+ * Build MV3 browser extensions (Chrome + Firefox) from a single
5
+ * `ExtensionConfig` — manifest generation, content/background script bundling,
6
+ * stx pages, declarativeNetRequest rulesets, and store-ready packaging — with
7
+ * none of the per-project boilerplate.
8
+ */
9
+ export * from './build';
10
+ export * from './config';
11
+ export * from './manifest';
12
+ export * from './package';
13
+ export * from './sanitize';
14
+ export * from './types';
package/dist/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export * from "./build";
2
+ export * from "./config";
3
+ export * from "./manifest";
4
+ export * from "./package";
5
+ export * from "./sanitize";
6
+ export * from "./types";
@@ -0,0 +1,45 @@
1
+ import type { ExtensionConfig, ExtensionTarget } from './types';
2
+ /** Output filename for a content script (explicit `out`, else entry basename). */
3
+ export declare function contentScriptOut(entry: string, out?: string): string;
4
+ /**
5
+ * Generate an MV3 manifest for a target from the extension config. Chrome uses
6
+ * a `service_worker` background + `minimum_chrome_version`; Firefox uses a
7
+ * `scripts` event page + `browser_specific_settings.gecko` (required by AMO).
8
+ */
9
+ export declare function generateManifest(config: ExtensionConfig, opts: { version: string, target?: ExtensionTarget }): GeneratedManifest;
10
+ /** MV3 manifest shape (loose — the two targets diverge on `background`). */
11
+ export declare interface GeneratedManifest {
12
+ manifest_version: 3
13
+ name: string
14
+ description: string
15
+ version: string
16
+ minimum_chrome_version?: string
17
+ action?: { default_title?: string, default_popup?: string }
18
+ options_page?: string
19
+ background?: { service_worker: string, type?: 'module' } | { scripts: string[], type?: 'module' }
20
+ browser_specific_settings?: {
21
+ gecko: {
22
+ id: string
23
+ strict_min_version: string
24
+ data_collection_permissions: { required: ['none'] }
25
+ }
26
+ }
27
+ permissions?: string[]
28
+ optional_permissions?: string[]
29
+ host_permissions?: string[]
30
+ icons?: Record<string, string>
31
+ content_scripts?: Array<{
32
+ matches: string[]
33
+ js: string[]
34
+ css?: string[]
35
+ run_at?: string
36
+ world?: string
37
+ all_frames?: boolean
38
+ match_about_blank?: boolean
39
+ exclude_matches?: string[]
40
+ }>
41
+ declarative_net_request?: { rule_resources: Array<{ id: string, enabled: boolean, path: string }> }
42
+ content_security_policy?: { extension_pages: string }
43
+ web_accessible_resources?: Array<{ resources: string[], matches: string[] }>
44
+ [key: string]: unknown
45
+ }
@@ -0,0 +1,60 @@
1
+ import { basename } from "node:path";
2
+ export function contentScriptOut(entry, out) {
3
+ return out ?? basename(entry).replace(/\.[cm]?tsx?$/, ".js");
4
+ }
5
+ export function generateManifest(config, opts) {
6
+ const isFirefox = (opts.target ?? "chrome") === "firefox", m = config.manifest ?? {}, manifest = {
7
+ manifest_version: 3,
8
+ name: config.name,
9
+ description: config.description,
10
+ version: opts.version
11
+ };
12
+ if (!isFirefox && m.minimumChromeVersion)
13
+ manifest.minimum_chrome_version = m.minimumChromeVersion;
14
+ if (config.pages?.popup)
15
+ manifest.action = { default_title: config.name, default_popup: "popup.html" };
16
+ if (config.pages?.options)
17
+ manifest.options_page = "options.html";
18
+ if (config.background)
19
+ manifest.background = isFirefox ? { scripts: ["background.js"], type: "module" } : { service_worker: "background.js", type: "module" };
20
+ if (isFirefox && config.geckoId)
21
+ manifest.browser_specific_settings = {
22
+ gecko: {
23
+ id: config.geckoId,
24
+ strict_min_version: m.firefoxMinVersion ?? "128.0",
25
+ data_collection_permissions: { required: ["none"] }
26
+ }
27
+ };
28
+ if (m.permissions?.length)
29
+ manifest.permissions = m.permissions;
30
+ if (m.optionalPermissions?.length)
31
+ manifest.optional_permissions = m.optionalPermissions;
32
+ if (m.hostPermissions?.length)
33
+ manifest.host_permissions = m.hostPermissions;
34
+ if (config.icons)
35
+ manifest.icons = Object.fromEntries(Object.entries(config.icons).map(([size, path]) => [String(size), path]));
36
+ if (config.content?.length)
37
+ manifest.content_scripts = config.content.map((cs) => ({
38
+ matches: cs.matches,
39
+ js: [contentScriptOut(cs.entry, cs.out)],
40
+ ...cs.runAt ? { run_at: cs.runAt } : {},
41
+ ...cs.world ? { world: cs.world } : {},
42
+ ...cs.allFrames ? { all_frames: !0 } : {},
43
+ ...cs.matchAboutBlank ? { match_about_blank: !0 } : {},
44
+ ...cs.excludeMatches ? { exclude_matches: cs.excludeMatches } : {}
45
+ }));
46
+ if (config.rules?.length)
47
+ manifest.declarative_net_request = {
48
+ rule_resources: config.rules.map((r) => ({
49
+ id: r.id,
50
+ enabled: r.enabled ?? !0,
51
+ path: r.path ?? `rules/${r.id}.json`
52
+ }))
53
+ };
54
+ manifest.content_security_policy = {
55
+ extension_pages: m.contentSecurityPolicy ?? "script-src 'self'; object-src 'self'"
56
+ };
57
+ if (m.webAccessibleResources?.length)
58
+ manifest.web_accessible_resources = m.webAccessibleResources;
59
+ return { ...manifest, ...m.extra ?? {} };
60
+ }
@@ -0,0 +1,15 @@
1
+ import type { ExtensionConfig, ExtensionTarget } from './types';
2
+ /**
3
+ * Package a built extension into a store-ready `.zip` (one per target). Builds
4
+ * first unless `build: false`. Uses the system `zip` (available on CI runners
5
+ * and dev machines) so the archive matches what the Chrome/Firefox stores
6
+ * expect (files at the archive root, no wrapping directory).
7
+ */
8
+ export declare function packageExtension(config: ExtensionConfig, options: PackageOptions): Promise<string>;
9
+ export declare interface PackageOptions {
10
+ target?: ExtensionTarget
11
+ version: string
12
+ cwd?: string
13
+ outfile?: string
14
+ build?: boolean
15
+ }
@@ -0,0 +1,21 @@
1
+ import { existsSync } from "node:fs";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { dirname, resolve } from "node:path";
4
+ import { buildExtension, resolveOutdir } from "./build";
5
+ function slug(name) {
6
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/(^-|-$)/g, "");
7
+ }
8
+ export async function packageExtension(config, options) {
9
+ const target = options.target ?? "chrome", cwd = options.cwd ?? process.cwd();
10
+ if (options.build !== !1)
11
+ await buildExtension(config, { target, version: options.version, cwd });
12
+ const outdir = resolve(cwd, resolveOutdir(config, target));
13
+ if (!existsSync(outdir))
14
+ throw Error(`[browser-extension] nothing to package: ${outdir} does not exist`);
15
+ const suffix = target === "firefox" ? "-firefox" : "", outfile = resolve(cwd, options.outfile ?? `${slug(config.name)}-${options.version}${suffix}.zip`);
16
+ await mkdir(dirname(outfile), { recursive: !0 });
17
+ const proc = Bun.spawn(["zip", "-r", "-q", outfile, "."], { cwd: outdir, stdout: "pipe", stderr: "pipe" }), code = await proc.exited;
18
+ if (code !== 0)
19
+ throw Error(`[browser-extension] zip failed (${code}): ${await new Response(proc.stderr).text()}`);
20
+ return outfile;
21
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Sanitize an stx-built page for use as an extension page.
3
+ *
4
+ * MV3 extension pages run under a strict CSP (`script-src 'self'`) — no inline
5
+ * `<script>` or `<style>`, and asset URLs must be relative (the page loads from
6
+ * `chrome-extension://…/<name>.html`, not a web root). stx emits dev niceties
7
+ * (SEO meta, an inline style block, an inline hydration script) and absolute
8
+ * `/asset` paths, so strip/rewrite them, keeping only the page's own compiled
9
+ * script(s).
10
+ */
11
+ export declare function sanitizeExtensionHtml(html: string, ownScripts: string[]): string;
@@ -0,0 +1,7 @@
1
+ export function sanitizeExtensionHtml(html, ownScripts) {
2
+ const keep = ownScripts.map((s) => `(?![^>]*src="/?${s.replace(".", "\\.")}")`).join("");
3
+ let out = html.replace(/\n?<!-- stx SEO Tags -->[\s\S]*?(?=\n\s*<meta charset=)/, "").replace(/\n?\s*<style\b[\s\S]*?<\/style>/g, "").replace(new RegExp(`\\n?\\s*<script\\b${keep}[\\s\\S]*?<\\/script>`, "g"), "").replaceAll('href="/styles.css"', 'href="styles.css"').replaceAll('href="/icons/', 'href="icons/');
4
+ for (const s of ownScripts)
5
+ out = out.replaceAll(`src="/${s}"`, `src="${s}"`);
6
+ return out;
7
+ }
@@ -0,0 +1,87 @@
1
+ /** A content script mapped into the manifest's `content_scripts`. */
2
+ export declare interface ContentScript {
3
+ entry: string
4
+ out?: string
5
+ matches: string[]
6
+ runAt?: 'document_start' | 'document_end' | 'document_idle'
7
+ world?: 'ISOLATED' | 'MAIN'
8
+ allFrames?: boolean
9
+ matchAboutBlank?: boolean
10
+ excludeMatches?: string[]
11
+ }
12
+ /**
13
+ * A declarativeNetRequest static ruleset. Either point `path` at a pre-built
14
+ * JSON file, or give `source` — a module whose default export is a function (or
15
+ * value) producing the rules array, which the build compiles to
16
+ * `rules/<id>.json`.
17
+ */
18
+ export declare interface RuleResource {
19
+ id: string
20
+ path?: string
21
+ enabled?: boolean
22
+ source?: string
23
+ }
24
+ /** An extension page: an stx template + an optional companion script. */
25
+ export declare interface ExtensionPage {
26
+ template: string
27
+ script?: string
28
+ }
29
+ /** stx pages bundled to HTML (popup, options, …). A bare string = template only. */
30
+ export declare interface ExtensionPages {
31
+ popup?: string | ExtensionPage
32
+ options?: string | ExtensionPage
33
+ extra?: Record<string, string | ExtensionPage>
34
+ }
35
+ /** Overrides merged verbatim into the generated manifest, per target. */
36
+ export declare interface ManifestOverrides {
37
+ permissions?: string[]
38
+ hostPermissions?: string[]
39
+ optionalPermissions?: string[]
40
+ minimumChromeVersion?: string
41
+ firefoxMinVersion?: string
42
+ contentSecurityPolicy?: string
43
+ webAccessibleResources?: Array<{ resources: string[], matches: string[] }>
44
+ extra?: Record<string, unknown>
45
+ }
46
+ /** Context passed to build hooks. */
47
+ export declare interface BuildContext {
48
+ config: ExtensionConfig
49
+ target: ExtensionTarget
50
+ outdir: string
51
+ version: string
52
+ cwd: string
53
+ }
54
+ export declare interface ExtensionConfig {
55
+ name: string
56
+ description: string
57
+ geckoId?: string
58
+ targets?: ExtensionTarget[]
59
+ background?: string
60
+ content?: ContentScript[]
61
+ pages?: ExtensionPages
62
+ icons?: Record<number, string>
63
+ public?: string
64
+ assets?: Record<string, string>
65
+ rules?: RuleResource[]
66
+ manifest?: ManifestOverrides
67
+ outdir?: Partial<Record<ExtensionTarget, string>> | string
68
+ hooks?: {
69
+ postBuild?: (ctx: BuildContext) => void | Promise<void>
70
+ }
71
+ }
72
+ export declare interface BuildOptions {
73
+ target?: ExtensionTarget
74
+ version: string
75
+ outdir?: string
76
+ minify?: boolean
77
+ cwd?: string
78
+ }
79
+ /**
80
+ * Config-driven MV3 browser-extension types.
81
+ *
82
+ * A consuming app declares one `ExtensionConfig` (typically in
83
+ * `config/extension.ts` via `defineExtension`) and the framework derives the
84
+ * manifest, build graph, and packaging from it — no hand-written manifest.json
85
+ * or per-project build script.
86
+ */
87
+ export type ExtensionTarget = 'chrome' | 'firefox';
package/dist/types.js ADDED
File without changes
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/browser-extension",
3
3
  "type": "module",
4
- "version": "0.70.88",
4
+ "version": "0.70.91",
5
5
  "description": "Build MV3 browser extensions (Chrome + Firefox) the Stacks way — manifest, content/background scripts, DNR rules, packaging, all config-driven.",
6
6
  "author": "Chris Breuer",
7
7
  "contributors": [