@stacksjs/browser-extension 0.75.409 → 0.75.417

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/dist/build.d.ts CHANGED
@@ -1,6 +1,6 @@
1
- import type { BuildOptions, ExtensionConfig, ExtensionTarget } from './types';
1
+ import type { BuildOptions, BuildTarget, ExtensionConfig } from './types';
2
2
  /** Resolve the output directory for a target. */
3
- export declare function resolveOutdir(config: ExtensionConfig, target: ExtensionTarget, override?: string): string;
3
+ export declare function resolveOutdir(config: ExtensionConfig, target: BuildTarget, override?: string): string;
4
4
  /** Rewrite promise-style `chrome.*` API access in `code` to `browser.*`. */
5
5
  export declare function rewriteBrowserNamespace(code: string): { code: string, replacements: number };
6
6
  /**`) + extra `assets` copied in
@@ -13,6 +13,6 @@ export declare function rewriteBrowserNamespace(code: string): { code: string, r
13
13
  *
14
14
  * Replaces the hand-rolled `build-extension.ts` every extension used to carry.
15
15
  */
16
- export declare function buildExtension(config: ExtensionConfig, options: BuildOptions): Promise<{ outdir: string, target: ExtensionTarget }>;
16
+ export declare function buildExtension(config: ExtensionConfig, options: BuildOptions): Promise<{ outdir: string, target: BuildTarget }>;
17
17
  /** Build every configured target. */
18
18
  export declare function buildAllTargets(config: ExtensionConfig, options: Omit<BuildOptions, 'target'>): Promise<void>;
package/dist/build.js CHANGED
@@ -1,5 +1,4 @@
1
- import{Glob}from"bun";import{cp,mkdir,rm}from"node:fs/promises";import{existsSync}from"node:fs";import{join,resolve}from"node:path";import{contentScriptOut,generateManifest}from"./manifest";import{sanitizeExtensionHtml}from"./sanitize";export function resolveOutdir(config,target,override){if(override)return override;if(typeof config.outdir==="string")return config.outdir;if(config.outdir?.[target])return config.outdir[target];return target==="chrome"?"dist":`dist-${target}`}const browserApiNamespaces=["runtime","tabs","declarativeNetRequest","storage","action","alarms","scripting","webNavigation","cookies","contextMenus","notifications","i18n","downloads","permissions","windows","bookmarks","history","search","sidePanel","omnibox","idle","management","commands","sessions","topSites","extension","types","devtools","offscreen","clipboard"],browserNamespacePattern=new RegExp(`\\bchrome\\.(?=(?:${browserApiNamespaces.join("|")})[?.])`,"g");export function rewriteBrowserNamespace(code){const replacements=(code.match(browserNamespacePattern)??[]).length;if(!replacements)return{code,replacements:0};return{code:code.replace(browserNamespacePattern,"browser."),replacements}}function normalizePage(page){return typeof page==="string"?{template:page}:page}function pageEntries(pages){if(!pages)return[];const out=[];if(pages.popup)out.push({name:"popup",page:normalizePage(pages.popup)});if(pages.options)out.push({name:"options",page:normalizePage(pages.options)});for(const[name,page]of Object.entries(pages.extra??{}))out.push({name,page:normalizePage(page)});return out}async function buildScript(entry,out,outdir,cwd,minify){const result=await Bun.build({entrypoints:[resolve(cwd,entry)],target:"browser",format:"iife",splitting:!1,minify});if(!result.success)throw Error(`[browser-extension] failed to build ${entry}: ${result.logs.join(`
2
- `)}`);const output=result.outputs[0];if(!output)throw Error(`[browser-extension] build produced no output for ${entry}`);await Bun.write(join(outdir,out),await output.text())}export async function buildExtension(config,options){const target=options.target??"chrome",cwd=options.cwd??process.cwd(),outdir=resolve(cwd,resolveOutdir(config,target,options.outdir)),minify=options.minify??!0;await rm(outdir,{recursive:!0,force:!0});await mkdir(join(outdir,"rules"),{recursive:!0});await mkdir(join(outdir,"icons"),{recursive:!0});if(config.public){const pub=resolve(cwd,config.public);if(existsSync(pub))await cp(pub,outdir,{recursive:!0})}for(const[dest,src]of Object.entries(config.assets??{})){const s=resolve(cwd,src);if(existsSync(s))await Bun.write(join(outdir,dest),Bun.file(s))}const pages=pageEntries(config.pages);await Promise.all(pages.filter((p)=>p.page.script).map((p)=>buildScript(p.page.script,`${p.name}.js`,outdir,cwd,minify)));if(pages.length){const{default:stxPlugin}=await import("bun-plugin-stx"),resourcesPartials=resolve(cwd,"resources/partials"),legacyPartials=resolve(cwd,"partials"),partialsDir=existsSync(resourcesPartials)?resourcesPartials:legacyPartials,result=await Bun.build({entrypoints:pages.map((p)=>resolve(cwd,p.page.template)),outdir,minify,naming:{entry:"[name].html"},plugins:[stxPlugin({partialsDir})]});if(!result.success)throw Error(`[browser-extension] failed to build pages: ${result.logs.join(`
3
- `)}`);for(const{name,page}of pages){const file=join(outdir,`${name}.html`);if(!existsSync(file))continue;const own=page.script?[`${name}.js`]:[];await Bun.write(file,sanitizeExtensionHtml(await Bun.file(file).text(),own))}for await(const chunk of new Glob("chunk-*.js").scan(outdir))await rm(join(outdir,chunk),{force:!0})}const scripts=[];if(config.background)scripts.push({entry:config.background,out:"background.js"});for(const cs of config.content??[])scripts.push({entry:cs.entry,out:contentScriptOut(cs.entry,cs.out)});await Promise.all(scripts.map((s)=>buildScript(s.entry,s.out,outdir,cwd,minify)));if(target==="safari")for await(const file of new Glob("*.js").scan(outdir)){const path=join(outdir,file),{code,replacements}=rewriteBrowserNamespace(await Bun.file(path).text());if(replacements)await Bun.write(path,code)}for(const rule of config.rules??[]){if(!rule.source)continue;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;await Bun.write(join(outdir,rule.path??`rules/${rule.id}.json`),`${JSON.stringify(data,null,2)}
1
+ import{Glob}from"bun";import{cp,mkdir,rm}from"node:fs/promises";import{existsSync}from"node:fs";import{join,resolve}from"node:path";import{bundleScript,loadModuleValue}from"./bundle";import{contentScriptOut,generateManifest}from"./manifest";import{sanitizeExtensionHtml}from"./sanitize";import{buildWebKit}from"./webkit-build";export function resolveOutdir(config,target,override){if(override)return override;if(typeof config.outdir==="string")return config.outdir;if(config.outdir?.[target])return config.outdir[target];return target==="chrome"?"dist":`dist-${target}`}const browserApiNamespaces=["runtime","tabs","declarativeNetRequest","storage","action","alarms","scripting","webNavigation","cookies","contextMenus","notifications","i18n","downloads","permissions","windows","bookmarks","history","search","sidePanel","omnibox","idle","management","commands","sessions","topSites","extension","types","devtools","offscreen","clipboard"],browserNamespacePattern=new RegExp(`\\bchrome\\.(?=(?:${browserApiNamespaces.join("|")})[?.])`,"g");export function rewriteBrowserNamespace(code){const replacements=(code.match(browserNamespacePattern)??[]).length;if(!replacements)return{code,replacements:0};return{code:code.replace(browserNamespacePattern,"browser."),replacements}}function normalizePage(page){return typeof page==="string"?{template:page}:page}function pageEntries(pages){if(!pages)return[];const out=[];if(pages.popup)out.push({name:"popup",page:normalizePage(pages.popup)});if(pages.options)out.push({name:"options",page:normalizePage(pages.options)});for(const[name,page]of Object.entries(pages.extra??{}))out.push({name,page:normalizePage(page)});return out}async function buildScript(entry,out,outdir,cwd,minify){await Bun.write(join(outdir,out),await bundleScript(entry,cwd,minify))}export async function buildExtension(config,options){const target=options.target??"chrome",cwd=options.cwd??process.cwd(),outdir=resolve(cwd,resolveOutdir(config,target,options.outdir)),minify=options.minify??!0;if(target==="webkit"){await buildWebKit(config,{outdir,cwd,version:options.version,minify});await config.hooks?.postBuild?.({config,target,outdir,version:options.version,cwd});return{outdir,target}}await rm(outdir,{recursive:!0,force:!0});await mkdir(join(outdir,"rules"),{recursive:!0});await mkdir(join(outdir,"icons"),{recursive:!0});if(config.public){const pub=resolve(cwd,config.public);if(existsSync(pub))await cp(pub,outdir,{recursive:!0})}for(const[dest,src]of Object.entries(config.assets??{})){const s=resolve(cwd,src);if(existsSync(s))await Bun.write(join(outdir,dest),Bun.file(s))}const pages=pageEntries(config.pages);await Promise.all(pages.filter((p)=>p.page.script).map((p)=>buildScript(p.page.script,`${p.name}.js`,outdir,cwd,minify)));if(pages.length){const{default:stxPlugin}=await import("bun-plugin-stx"),resourcesPartials=resolve(cwd,"resources/partials"),legacyPartials=resolve(cwd,"partials"),partialsDir=existsSync(resourcesPartials)?resourcesPartials:legacyPartials,result=await Bun.build({entrypoints:pages.map((p)=>resolve(cwd,p.page.template)),outdir,minify,naming:{entry:"[name].html"},plugins:[stxPlugin({partialsDir})]});if(!result.success)throw Error(`[browser-extension] failed to build pages: ${result.logs.join(`
2
+ `)}`);for(const{name,page}of pages){const file=join(outdir,`${name}.html`);if(!existsSync(file))continue;const own=page.script?[`${name}.js`]:[];await Bun.write(file,sanitizeExtensionHtml(await Bun.file(file).text(),own))}for await(const chunk of new Glob("chunk-*.js").scan(outdir))await rm(join(outdir,chunk),{force:!0})}const scripts=[];if(config.background)scripts.push({entry:config.background,out:"background.js"});for(const cs of config.content??[])scripts.push({entry:cs.entry,out:contentScriptOut(cs.entry,cs.out)});await Promise.all(scripts.map((s)=>buildScript(s.entry,s.out,outdir,cwd,minify)));if(target==="safari")for await(const file of new Glob("*.js").scan(outdir)){const path=join(outdir,file),{code,replacements}=rewriteBrowserNamespace(await Bun.file(path).text());if(replacements)await Bun.write(path,code)}for(const rule of config.rules??[]){if(!rule.source)continue;const data=await loadModuleValue(cwd,rule.source);await Bun.write(join(outdir,rule.path??`rules/${rule.id}.json`),`${JSON.stringify(data,null,2)}
4
3
  `)}await Bun.write(join(outdir,"manifest.json"),`${JSON.stringify(generateManifest(config,{version:options.version,target}),null,2)}
5
4
  `);await config.hooks?.postBuild?.({config,target,outdir,version:options.version,cwd});return{outdir,target}}export async function buildAllTargets(config,options){for(const target of config.targets??["chrome","firefox"])await buildExtension(config,{...options,target})}
@@ -0,0 +1,8 @@
1
+ /** Bundle one entrypoint to a classic IIFE script (content scripts + page scripts). */
2
+ export declare function bundleScript(entry: string, cwd: string, minify: boolean): Promise<string>;
3
+ /**
4
+ * Load what a rules module produces. `path#name` reads that export; a bare
5
+ * path reads its default export, `buildRules` or `rules`, else its sole
6
+ * exported function or array. A function is called (and awaited).
7
+ */
8
+ export declare function loadModuleValue<T = unknown>(cwd: string, source: string): Promise<T>;
package/dist/bundle.js ADDED
@@ -0,0 +1,2 @@
1
+ import{resolve}from"node:path";export async function bundleScript(entry,cwd,minify){const result=await Bun.build({entrypoints:[resolve(cwd,entry)],target:"browser",format:"iife",splitting:!1,minify});if(!result.success)throw Error(`[browser-extension] failed to build ${entry}: ${result.logs.join(`
2
+ `)}`);const output=result.outputs[0];if(!output)throw Error(`[browser-extension] build produced no output for ${entry}`);return output.text()}export async function loadModuleValue(cwd,source){const[path,name]=source.split("#"),mod=await import(resolve(cwd,path));if(name!==void 0&&!(name in mod))throw Error(`[browser-extension] ${path} has no export named "${name}"`);const build=name!==void 0?mod[name]:mod.default??mod.buildRules??mod.rules??Object.values(mod).find((v)=>typeof v==="function")??Object.values(mod).find((v)=>Array.isArray(v));return typeof build==="function"?await build():build}
package/dist/index.d.ts CHANGED
@@ -21,3 +21,6 @@ export * from './safari';
21
21
  export * from './scaffold';
22
22
  export * from './sanitize';
23
23
  export * from './types';
24
+ export * from './webkit';
25
+ export * from './webkit-build';
26
+ export * from './webkit-scripts';
package/dist/index.js CHANGED
@@ -1 +1 @@
1
- export*from"./build";export*from"./app-store-connect";export*from"./app-store-submission";export*from"./chrome-web-store";export*from"./config";export*from"./firefox-addons";export*from"./firefox-previews";export*from"./manifest";export*from"./package";export*from"./publish-plan";export*from"./safari";export*from"./scaffold";export*from"./sanitize";export*from"./types";
1
+ export*from"./build";export*from"./app-store-connect";export*from"./app-store-submission";export*from"./chrome-web-store";export*from"./config";export*from"./firefox-addons";export*from"./firefox-previews";export*from"./manifest";export*from"./package";export*from"./publish-plan";export*from"./safari";export*from"./scaffold";export*from"./sanitize";export*from"./types";export*from"./webkit";export*from"./webkit-build";export*from"./webkit-scripts";
package/dist/package.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExtensionConfig, ExtensionTarget } from './types';
1
+ import type { BuildTarget, ExtensionConfig } from './types';
2
2
  /**
3
3
  * Package a built extension into a store-ready `.zip` (one per target). Builds
4
4
  * first unless `build: false`. Uses the system `zip` (available on CI runners
@@ -7,7 +7,7 @@ import type { ExtensionConfig, ExtensionTarget } from './types';
7
7
  */
8
8
  export declare function packageExtension(config: ExtensionConfig, options: PackageOptions): Promise<string>;
9
9
  export declare interface PackageOptions {
10
- target?: ExtensionTarget
10
+ target?: BuildTarget
11
11
  version: string
12
12
  cwd?: string
13
13
  outfile?: string
package/dist/types.d.ts CHANGED
@@ -44,10 +44,25 @@ export declare interface ManifestOverrides {
44
44
  webAccessibleResources?: Array<{ resources: string[], matches: string[] }>
45
45
  extra?: Record<string, unknown>
46
46
  }
47
+ /** A module whose default export (or sole exported function/value) produces rules. */
48
+ export declare interface WebKitRuleSource {
49
+ id: string
50
+ source: string
51
+ enabled?: boolean
52
+ }
53
+ /** The `webkit` build target: `dist-webkit/` for apps that embed WKWebView. */
54
+ export declare interface WebKitOptions {
55
+ maxRulesPerList?: number
56
+ dynamicRules?: WebKitRuleSource[]
57
+ cosmetic?: WebKitRuleSource[]
58
+ selectorsPerRule?: number
59
+ messageHandler?: string
60
+ excludeScripts?: string[]
61
+ }
47
62
  /** Context passed to build hooks. */
48
63
  export declare interface BuildContext {
49
64
  config: ExtensionConfig
50
- target: ExtensionTarget
65
+ target: BuildTarget
51
66
  outdir: string
52
67
  version: string
53
68
  cwd: string
@@ -141,7 +156,8 @@ export declare interface ExtensionConfig {
141
156
  safariAppCategory?: string
142
157
  safariAppStore?: SafariAppStoreConfig
143
158
  safariExclude?: string[]
144
- targets?: ExtensionTarget[]
159
+ targets?: BuildTarget[]
160
+ webkit?: WebKitOptions
145
161
  background?: string
146
162
  content?: ContentScript[]
147
163
  pages?: ExtensionPages
@@ -151,13 +167,13 @@ export declare interface ExtensionConfig {
151
167
  assets?: Record<string, string>
152
168
  rules?: RuleResource[]
153
169
  manifest?: ManifestOverrides
154
- outdir?: Partial<Record<ExtensionTarget, string>> | string
170
+ outdir?: Partial<Record<BuildTarget, string>> | string
155
171
  hooks?: {
156
172
  postBuild?: (ctx: BuildContext) => void | Promise<void>
157
173
  }
158
174
  }
159
175
  export declare interface BuildOptions {
160
- target?: ExtensionTarget
176
+ target?: BuildTarget
161
177
  version: string
162
178
  outdir?: string
163
179
  minify?: boolean
@@ -171,7 +187,14 @@ export declare interface BuildOptions {
171
187
  * manifest, build graph, and packaging from it — no hand-written manifest.json
172
188
  * or per-project build script.
173
189
  */
190
+ /** The browsers an extension ships to through a store. */
174
191
  export type ExtensionTarget = 'chrome' | 'firefox' | 'safari';
192
+ /**
193
+ * Everything `buddy extension:build` can produce: the store targets, plus
194
+ * `webkit` - the extension's rules and content scripts as WebKit
195
+ * content-blocker lists and `WKUserScript`s, for apps that embed WKWebView.
196
+ */
197
+ export type BuildTarget = ExtensionTarget | 'webkit';
175
198
  export type SafariPlatform = 'macos' | 'ios';
176
199
  export type SafariScreenshotDisplayType = 'APP_DESKTOP' | 'APP_IPHONE_67' | 'APP_IPAD_PRO_3GEN_129';
177
200
  export type SafariAppStoreReleaseType = 'MANUAL' | 'AFTER_APPROVAL' | 'SCHEDULED';
@@ -0,0 +1,48 @@
1
+ import type { ExtensionConfig } from './types';
2
+ import type { WebKitConversionReport } from './webkit';
3
+ import type { WebKitUserScript } from './webkit-scripts';
4
+ /** `Very Good AdBlock` → `very-good-adblock`. */
5
+ export declare function webkitSlug(name: string): string;
6
+ /**
7
+ * Build the `webkit` target into `outdir`:
8
+ *
9
+ * - `lists/<id>.json` - the static rulesets, `webkit.dynamicRules` and
10
+ * `webkit.cosmetic` as WebKit content-blocker lists (see `buildWebKitLists`)
11
+ * - `scripts/<id>.js` - every content script as a standalone `WKUserScript`
12
+ * source, isolated-world ones bridged to `window.webkit.messageHandlers`
13
+ * - `manifest.json` - the lists (id, content-hash version, group, file), the
14
+ * scripts (matches, run time, world, file) and the conversion report
15
+ *
16
+ * Rules WebKit cannot express are counted in `report.unsupported` and printed,
17
+ * never dropped silently.
18
+ */
19
+ export declare function buildWebKit(config: ExtensionConfig, options: { outdir: string, cwd: string, version: string, minify?: boolean }): Promise<WebKitManifest>;
20
+ /** One compiled-list entry in `dist-webkit/manifest.json`. */
21
+ export declare interface WebKitManifestList {
22
+ id: string
23
+ version: string
24
+ group: string
25
+ kind: 'network' | 'cosmetic'
26
+ enabled: boolean
27
+ ruleCount: number
28
+ file: string
29
+ }
30
+ /** One user-script entry in `dist-webkit/manifest.json`; the source lives in `file`. */
31
+ export declare interface WebKitManifestScript extends Omit<WebKitUserScript, 'source'> {
32
+ file: string
33
+ bridged: boolean
34
+ apis: string[]
35
+ unsupportedApis: string[]
36
+ }
37
+ /** `dist-webkit/manifest.json`. */
38
+ export declare interface WebKitManifest {
39
+ format: 'stacks-webkit'
40
+ formatVersion: 1
41
+ name: string
42
+ version: string
43
+ maxRulesPerList: number
44
+ bridge: { messageHandler: string, version: number }
45
+ lists: WebKitManifestList[]
46
+ scripts: WebKitManifestScript[]
47
+ report: WebKitConversionReport
48
+ }
@@ -0,0 +1,2 @@
1
+ import{existsSync}from"node:fs";import{mkdir,rm}from"node:fs/promises";import{join,resolve}from"node:path";import{bundleScript,loadModuleValue}from"./bundle";import{contentScriptOut}from"./manifest";import{buildWebKitLists}from"./webkit";import{WEBKIT_BRIDGE_VERSION,wrapForWebKit}from"./webkit-scripts";export function webkitSlug(name){return name.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/(^-|-$)/g,"")}async function rulesetRules(rule,config,cwd){if(rule.source)return asArray(await loadModuleValue(cwd,rule.source),rule.id);const file=resolve(cwd,config.public??"public",rule.path??`rules/${rule.id}.json`);if(!existsSync(file))throw Error(`[browser-extension] ruleset "${rule.id}" has no source module and no file at ${file}`);return asArray(await Bun.file(file).json(),rule.id)}function asArray(value,id){if(!Array.isArray(value))throw Error(`[browser-extension] "${id}" did not produce an array of rules`);return value}export async function buildWebKit(config,options){const{outdir,cwd}=options,webkit=config.webkit??{},maxRulesPerList=webkit.maxRulesPerList??50000,messageHandler=webkit.messageHandler??webkitSlug(config.name);await rm(outdir,{recursive:!0,force:!0});await mkdir(join(outdir,"lists"),{recursive:!0});await mkdir(join(outdir,"scripts"),{recursive:!0});const groups=[];for(const rule of config.rules??[])groups.push({id:rule.id,enabled:rule.enabled??!0,dnr:await rulesetRules(rule,config,cwd)});for(const source of webkit.dynamicRules??[])groups.push({id:source.id,enabled:source.enabled??!0,dnr:asArray(await loadModuleValue(cwd,source.source),source.id)});for(const source of webkit.cosmetic??[])groups.push({id:source.id,enabled:source.enabled??!0,cosmetic:asArray(await loadModuleValue(cwd,source.source),source.id)});const{lists,report}=buildWebKitLists(groups,{maxRulesPerList,selectorsPerRule:webkit.selectorsPerRule}),manifestLists=[];for(const list of lists){const file=`lists/${list.id}.json`;await Bun.write(join(outdir,file),JSON.stringify(list.rules));manifestLists.push({id:list.id,version:list.version,group:list.group,kind:list.kind,enabled:list.enabled,ruleCount:list.rules.length,file})}const excluded=new Set(webkit.excludeScripts??[]),scripts=[];for(const cs of config.content??[]){const id=contentScriptOut(cs.entry,cs.out).replace(/\.js$/,"");if(excluded.has(id))continue;const world=cs.world==="MAIN"?"page":"isolated",wrapped=wrapForWebKit(await bundleScript(cs.entry,cwd,options.minify??!0),{handler:messageHandler,world}),file=`scripts/${id}.js`;await Bun.write(join(outdir,file),wrapped.source);scripts.push({id,matches:cs.matches,excludeMatches:cs.excludeMatches??[],runAt:cs.runAt==="document_start"?"document-start":"document-end",allFrames:cs.allFrames??!1,matchAboutBlank:cs.matchAboutBlank??!1,world,file,bridged:wrapped.bridged,apis:wrapped.apis,unsupportedApis:wrapped.unsupportedApis})}const manifest={format:"stacks-webkit",formatVersion:1,name:config.name,version:options.version,maxRulesPerList,bridge:{messageHandler,version:WEBKIT_BRIDGE_VERSION},lists:manifestLists,scripts,report};await Bun.write(join(outdir,"manifest.json"),`${JSON.stringify(manifest,null,2)}
2
+ `);logWebKitReport(manifest);return manifest}function logWebKitReport(manifest){const rules=manifest.lists.reduce((sum,list)=>sum+list.ruleCount,0);console.log(`[browser-extension] webkit: ${manifest.lists.length} list(s), ${rules} rule(s), ${manifest.scripts.length} script(s)`);for(const issue of manifest.report.unsupported)console.warn(`[browser-extension] webkit: ${issue.count} rule(s) left out - ${issue.reason} (e.g. ${issue.examples.slice(0,3).join(", ")})`);for(const issue of manifest.report.approximated)console.warn(`[browser-extension] webkit: ${issue.count} rule(s) approximated - ${issue.reason}`);for(const script of manifest.scripts)if(script.world==="isolated"&&script.unsupportedApis.length)console.warn(`[browser-extension] webkit: script ${script.id} calls ${script.unsupportedApis.join(", ")}, which the WebKit bridge does not provide`)}
@@ -0,0 +1,69 @@
1
+ /** Every `chrome.<namespace>.<member>` (or `browser.…`) a bundle touches. */
2
+ export declare function chromeApisUsed(code: string): string[];
3
+ /**
4
+ * The bridge as a JavaScript expression evaluating to a `chrome`-shaped
5
+ * object. One instance per handler per content world, shared by every script
6
+ * that world runs, so listeners registered by one script see every event.
7
+ */
8
+ export declare function webkitBridgeSource(handler: string): string;
9
+ /**
10
+ * Prepare one bundled content script for WebKit. Page-world scripts ship as
11
+ * they are. Isolated-world scripts that touch `chrome.*` / `browser.*` get the
12
+ * bridge as those bindings.
13
+ */
14
+ export declare function wrapForWebKit(code: string, options: { handler: string, world: 'page' | 'isolated' }): WebKitScriptApis & { source: string };
15
+ /** The `chrome.*` surface the bridge provides. */
16
+ export declare const WEBKIT_BRIDGE_APIS: readonly string[];
17
+ /** The version of the message contract the bridge speaks. */
18
+ export declare const WEBKIT_BRIDGE_VERSION: 1;
19
+ /**
20
+ * Content scripts as standalone `WKUserScript` sources.
21
+ *
22
+ * A WebKit app has no `chrome.*`. A page-world script never had it either, so
23
+ * it ships as bundled. An isolated-world script that calls `chrome.runtime` or
24
+ * `chrome.storage` is wrapped with a small bridge that turns those calls into
25
+ * messages to a `window.webkit.messageHandlers.<name>` handler the host app
26
+ * installs. Nothing global is defined for the page: the bridge is passed into
27
+ * the bundle as its `chrome` and `browser` bindings.
28
+ *
29
+ * The message contract (version 1), page to app, through
30
+ * `webkit.messageHandlers[name].postMessage(body)` (register the handler with
31
+ * `WKScriptMessageHandlerWithReply` so `postMessage` resolves to the reply):
32
+ *
33
+ * | `body.api` | `body.payload` | reply |
34
+ * |-----------------------|-----------------------------------------|-------------------------------------|
35
+ * | `runtime.sendMessage` | `{ message }` | the response, or null |
36
+ * | `storage.get` | `{ area, keys: null \| string \| string[] \| object }` | an object of the stored items |
37
+ * | `storage.set` | `{ area, items }` | null |
38
+ * | `storage.remove` | `{ area, keys: string[] }` | null |
39
+ * | `storage.clear` | `{ area }` | null |
40
+ *
41
+ * Every body also carries `v: 1`. `area` is `local`, `sync` or `session`. A
42
+ * rejected reply (an error from the handler) rejects the call, and callback
43
+ * callers see it as `chrome.runtime.lastError`.
44
+ *
45
+ * App to page, by evaluating JavaScript in the script's content world
46
+ * (`callAsyncJavaScript` awaits the returned promise):
47
+ *
48
+ * ```js
49
+ * __stacksWebKit[name].dispatch({ type: 'runtime.message', message, sender }) // resolves to the first listener's response
50
+ * __stacksWebKit[name].dispatch({ type: 'storage.changed', area, changes }) // changes: { key: { oldValue, newValue } }
51
+ * ```
52
+ */
53
+ /** A content script ready for `WKUserScript`. */
54
+ export declare interface WebKitUserScript {
55
+ id: string
56
+ matches: string[]
57
+ excludeMatches: string[]
58
+ runAt: 'document-start' | 'document-end'
59
+ allFrames: boolean
60
+ matchAboutBlank: boolean
61
+ world: 'page' | 'isolated'
62
+ source: string
63
+ }
64
+ /** What a wrapped script calls, and whether the bridge covers it. */
65
+ export declare interface WebKitScriptApis {
66
+ apis: string[]
67
+ unsupportedApis: string[]
68
+ bridged: boolean
69
+ }
@@ -0,0 +1,88 @@
1
+ export const WEBKIT_BRIDGE_APIS=["runtime.sendMessage","runtime.onMessage","runtime.id","runtime.lastError","storage.local","storage.sync","storage.session","storage.onChanged"],WEBKIT_BRIDGE_VERSION=1;const apiUse=/(?<![\w$.])(?:chrome|browser)\s*(?:\?\.|\.)\s*([A-Za-z]+)\s*(?:\?\.|\.)\s*([A-Za-z]+)/g;export function chromeApisUsed(code){const found=new Set;for(const match of code.matchAll(apiUse))found.add(`${match[1]}.${match[2]}`);return[...found].sort()}export function webkitBridgeSource(handler){return`(function (name) {
2
+ var root = typeof globalThis !== 'undefined' ? globalThis : window
3
+ var registry = root.__stacksWebKit || (root.__stacksWebKit = {})
4
+ if (registry[name]) return registry[name].chrome
5
+ var runtime = { id: name, lastError: undefined }
6
+ function post(api, payload) {
7
+ var handlers = root.webkit && root.webkit.messageHandlers
8
+ var target = handlers && handlers[name]
9
+ if (!target) return Promise.reject(new Error('Stacks WebKit bridge: no "' + name + '" message handler is installed'))
10
+ try { return Promise.resolve(target.postMessage({ v: ${WEBKIT_BRIDGE_VERSION}, api: api, payload: payload })) }
11
+ catch (error) { return Promise.reject(error) }
12
+ }
13
+ function settle(promise, callback) {
14
+ if (typeof callback !== 'function') return promise
15
+ promise.then(function (value) { callback(value) }, function (error) {
16
+ runtime.lastError = { message: String((error && error.message) || error) }
17
+ try { callback(undefined) } finally { runtime.lastError = undefined }
18
+ })
19
+ }
20
+ function nothing() { return undefined }
21
+ function event(listeners) {
22
+ return {
23
+ addListener: function (fn) { if (listeners.indexOf(fn) < 0) listeners.push(fn) },
24
+ removeListener: function (fn) { var i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1) },
25
+ hasListener: function (fn) { return listeners.indexOf(fn) >= 0 },
26
+ hasListeners: function () { return listeners.length > 0 }
27
+ }
28
+ }
29
+ var messageListeners = []
30
+ var changeListeners = []
31
+ var areaListeners = { local: [], sync: [], session: [] }
32
+ function area(areaName) {
33
+ return {
34
+ get: function (keys, callback) {
35
+ if (typeof keys === 'function') { callback = keys; keys = undefined }
36
+ var request = keys === undefined ? null : keys
37
+ return settle(post('storage.get', { area: areaName, keys: request }).then(function (items) {
38
+ items = items || {}
39
+ if (!request || typeof request !== 'object' || Array.isArray(request)) return items
40
+ var merged = {}
41
+ for (var key in request) merged[key] = Object.prototype.hasOwnProperty.call(items, key) ? items[key] : request[key]
42
+ return merged
43
+ }), callback)
44
+ },
45
+ set: function (items, callback) { return settle(post('storage.set', { area: areaName, items: items }).then(nothing), callback) },
46
+ remove: function (keys, callback) { return settle(post('storage.remove', { area: areaName, keys: typeof keys === 'string' ? [keys] : keys }).then(nothing), callback) },
47
+ clear: function (callback) { return settle(post('storage.clear', { area: areaName }).then(nothing), callback) },
48
+ onChanged: event(areaListeners[areaName])
49
+ }
50
+ }
51
+ runtime.sendMessage = function () {
52
+ var args = Array.prototype.slice.call(arguments)
53
+ var callback = typeof args[args.length - 1] === 'function' ? args.pop() : undefined
54
+ var message = typeof args[0] === 'string' && args.length > 1 ? args[1] : args[0]
55
+ return settle(post('runtime.sendMessage', { message: message }), callback)
56
+ }
57
+ runtime.onMessage = event(messageListeners)
58
+ var chrome = { runtime: runtime, storage: { local: area('local'), sync: area('sync'), session: area('session'), onChanged: event(changeListeners) } }
59
+ function each(listeners, args) {
60
+ listeners.slice().forEach(function (fn) { try { fn.apply(null, args) } catch (error) { console.error(error) } })
61
+ }
62
+ function dispatch(event) {
63
+ if (!event || typeof event !== 'object') return undefined
64
+ if (event.type === 'storage.changed') {
65
+ var areaName = event.area || 'local'
66
+ each(changeListeners, [event.changes || {}, areaName])
67
+ each(areaListeners[areaName] || [], [event.changes || {}])
68
+ return undefined
69
+ }
70
+ if (event.type !== 'runtime.message') return undefined
71
+ return new Promise(function (resolve) {
72
+ var waiting = 0
73
+ var done = false
74
+ function respond(value) { if (!done) { done = true; resolve(value) } }
75
+ messageListeners.slice().forEach(function (fn) {
76
+ var result
77
+ try { result = fn(event.message, event.sender || { id: name }, respond) }
78
+ catch (error) { console.error(error); return }
79
+ if (result === true) waiting++
80
+ else if (result && typeof result.then === 'function') { waiting++; result.then(respond, function () { respond(undefined) }) }
81
+ })
82
+ if (!waiting) respond(undefined)
83
+ })
84
+ }
85
+ registry[name] = { chrome: chrome, dispatch: dispatch, version: ${WEBKIT_BRIDGE_VERSION} }
86
+ return chrome
87
+ })(${JSON.stringify(handler)})`}export function wrapForWebKit(code,options){const apis=chromeApisUsed(code),unsupportedApis=apis.filter((api)=>!WEBKIT_BRIDGE_APIS.includes(api));if(options.world==="page"||!apis.length)return{source:code,apis,unsupportedApis:options.world==="page"?apis:[],bridged:!1};const bridge=webkitBridgeSource(options.handler);return{source:["(function (chrome) {","var browser = chrome;",code.trim(),`})(${bridge});`,""].join(`
88
+ `),apis,unsupportedApis,bridged:!0}}
@@ -0,0 +1,178 @@
1
+ /**
2
+ * A DNR `urlFilter` as the regular expression a WebKit `url-filter` takes.
3
+ *
4
+ * `||` anchors at a host label, `|` at the start or end of the URL, `*` is any
5
+ * run of characters and `^` a separator (or, at the end, the end of the URL).
6
+ * Everything else is literal. WebKit has no alternation, so a trailing `^` is
7
+ * written `(sep.*)?$`: a separator then anything, or nothing at all.
8
+ */
9
+ export declare function urlFilterToWebKitRegex(filter: string): string;
10
+ /** The regex a WebKit `url-filter` uses to match one host and its subdomains. */
11
+ export declare function hostToWebKitRegex(host: string): string;
12
+ /** Whether `pattern` stays inside WebKit's content-blocker regex subset. */
13
+ export declare function isWebKitRegex(pattern: string): boolean;
14
+ /**
15
+ * A DNR `regexFilter` as one or more WebKit `url-filter` patterns. Alternation
16
+ * becomes several patterns (each its own rule); anything outside WebKit's
17
+ * subset throws `WebKitUnsupportedError`.
18
+ */
19
+ export declare function regexFilterToWebKit(regex: string): string[];
20
+ /**
21
+ * One DNR rule as WebKit rules. Throws `WebKitUnsupportedError` when WebKit
22
+ * has no way to say it; otherwise lists any way the result matches differently.
23
+ */
24
+ export declare function convertDnrRule(rule: DnrRule): DnrRuleConversion;
25
+ /** Why a cosmetic selector cannot ship, or null when it can. */
26
+ export declare function cosmeticSelectorProblem(selector: string): string | null;
27
+ /** A content hash: the list's `version`. */
28
+ export declare function webkitListVersion(rules: WebKitRule[]): string;
29
+ /**
30
+ * Convert rule groups to WebKit lists.
31
+ *
32
+ * Within a list, WebKit applies `ignore-previous-rules` only to rules before
33
+ * it, so each list is ordered by DNR priority and then DNR's precedence
34
+ * (allow beats block at one priority). Lists compile independently, while DNR
35
+ * evaluates every enabled ruleset together, so each network list repeats every
36
+ * exception from the enabled groups (plus its own group's). A group over
37
+ * `maxRulesPerList` is split into `<id>`, `<id>-2`, … with the exceptions
38
+ * counted against each part. Cosmetic groups never receive network exceptions:
39
+ * an extension's DNR `allow` does not unhide elements.
40
+ */
41
+ export declare function buildWebKitLists(groups: WebKitRuleGroup[], options?: WebKitListOptions): { lists: WebKitRuleList[], report: WebKitConversionReport };
42
+ /** WebKit's hard limit on rules in one compiled list. */
43
+ export declare const WEBKIT_MAX_RULES_PER_LIST: unknown;
44
+ /** The parts of a `chrome.declarativeNetRequest.RuleCondition` this converter reads. */
45
+ export declare interface DnrCondition {
46
+ urlFilter?: string
47
+ regexFilter?: string
48
+ isUrlFilterCaseSensitive?: boolean
49
+ initiatorDomains?: string[]
50
+ excludedInitiatorDomains?: string[]
51
+ domains?: string[]
52
+ excludedDomains?: string[]
53
+ requestDomains?: string[]
54
+ excludedRequestDomains?: string[]
55
+ resourceTypes?: DnrResourceType[] | string[]
56
+ excludedResourceTypes?: DnrResourceType[] | string[]
57
+ requestMethods?: string[]
58
+ excludedRequestMethods?: string[]
59
+ domainType?: 'firstParty' | 'thirdParty'
60
+ tabIds?: number[]
61
+ excludedTabIds?: number[]
62
+ responseHeaders?: unknown[]
63
+ excludedResponseHeaders?: unknown[]
64
+ }
65
+ /** A `chrome.declarativeNetRequest.Rule`, loosely typed so `@types/chrome` rules assign to it. */
66
+ export declare interface DnrRule {
67
+ id: number
68
+ priority?: number
69
+ action: { type: DnrActionType | string, [key: string]: unknown }
70
+ condition: DnrCondition
71
+ }
72
+ export declare interface WebKitTrigger {
73
+ 'url-filter': string
74
+ 'url-filter-is-case-sensitive'?: boolean
75
+ 'resource-type'?: WebKitResourceType[]
76
+ 'load-type'?: Array<'first-party' | 'third-party'>
77
+ 'load-context'?: Array<'top-frame' | 'child-frame'>
78
+ 'if-domain'?: string[]
79
+ 'unless-domain'?: string[]
80
+ }
81
+ /** One rule in WebKit's content-blocker JSON (`WKContentRuleListStore`). */
82
+ export declare interface WebKitRule {
83
+ trigger: WebKitTrigger
84
+ action: WebKitAction
85
+ }
86
+ /** A cosmetic rule: elements matching `selector` are hidden (`css-display-none`). */
87
+ export declare interface WebKitCosmeticRule {
88
+ selector: string | string[]
89
+ domains?: string[]
90
+ excludedDomains?: string[]
91
+ }
92
+ /**
93
+ * A named source of rules: one DNR ruleset, the extension's dynamic rules, or
94
+ * a set of cosmetic selectors. Each group becomes one or more lists, so a host
95
+ * app can switch groups on and off the way the extension switches rulesets.
96
+ */
97
+ export declare interface WebKitRuleGroup {
98
+ id: string
99
+ enabled?: boolean
100
+ dnr?: DnrRule[]
101
+ cosmetic?: WebKitCosmeticRule[]
102
+ }
103
+ export declare interface WebKitListOptions {
104
+ maxRulesPerList?: number
105
+ selectorsPerRule?: number
106
+ }
107
+ /** A WebKit content-blocker list, ready for `WKContentRuleListStore.compileContentRuleList`. */
108
+ export declare interface WebKitRuleList {
109
+ id: string
110
+ version: string
111
+ group: string
112
+ kind: 'network' | 'cosmetic'
113
+ enabled: boolean
114
+ rules: WebKitRule[]
115
+ }
116
+ export declare interface WebKitConversionIssue {
117
+ reason: string
118
+ count: number
119
+ examples: Array<number | string>
120
+ }
121
+ export declare interface WebKitConversionReport {
122
+ input: number
123
+ output: number
124
+ unsupported: WebKitConversionIssue[]
125
+ approximated: WebKitConversionIssue[]
126
+ }
127
+ /** A converted rule with what ordering it needs: DNR priority, then action precedence. */
128
+ export declare interface RankedWebKitRule {
129
+ rule: WebKitRule
130
+ priority: number
131
+ rank: number
132
+ exception: boolean
133
+ }
134
+ export declare interface DnrRuleConversion {
135
+ rules: RankedWebKitRule[]
136
+ approximated: string[]
137
+ }
138
+ export type DnrResourceType = | 'main_frame'
139
+ | 'sub_frame'
140
+ | 'stylesheet'
141
+ | 'script'
142
+ | 'image'
143
+ | 'font'
144
+ | 'object'
145
+ | 'xmlhttprequest'
146
+ | 'ping'
147
+ | 'csp_report'
148
+ | 'media'
149
+ | 'websocket'
150
+ | 'webtransport'
151
+ | 'webbundle'
152
+ | 'other';
153
+ export type DnrActionType = 'block' | 'allow' | 'allowAllRequests' | 'upgradeScheme' | 'redirect' | 'modifyHeaders';
154
+ export type WebKitResourceType = | 'document'
155
+ | 'image'
156
+ | 'style-sheet'
157
+ | 'script'
158
+ | 'font'
159
+ | 'raw'
160
+ | 'svg-document'
161
+ | 'media'
162
+ | 'popup'
163
+ | 'ping'
164
+ | 'fetch'
165
+ | 'websocket'
166
+ | 'other';
167
+ export type WebKitAction = | { type: 'block' }
168
+ | { type: 'ignore-previous-rules' }
169
+ | { type: 'make-https' }
170
+ | { type: 'css-display-none', selector: string }
171
+ /** Thrown for a rule (or pattern) WebKit's content-blocker format cannot express. */
172
+ export declare class WebKitUnsupportedError extends Error {
173
+ name: string;
174
+ }
175
+ declare class Issues {
176
+ add(reason: string, example: number | string): void;
177
+ list(): WebKitConversionIssue[];
178
+ }
package/dist/webkit.js ADDED
@@ -0,0 +1 @@
1
+ import{createHash}from"node:crypto";export const WEBKIT_MAX_RULES_PER_LIST=150000;export class WebKitUnsupportedError extends Error{name="WebKitUnsupportedError"}const ALL_DNR_TYPES=["main_frame","sub_frame","stylesheet","script","image","font","object","xmlhttprequest","ping","csp_report","media","websocket","webtransport","webbundle","other"],RESOURCE_TYPE_MAP={stylesheet:"style-sheet",script:"script",image:"image",font:"font",object:"other",xmlhttprequest:"fetch",ping:"ping",csp_report:"ping",media:"media",websocket:"websocket",other:"other"},ALL_WEBKIT_SUBRESOURCE_TYPES=["style-sheet","script","image","font","fetch","ping","media","websocket","other"],SEPARATOR="[^a-zA-Z0-9_.%-]",HOST_ANCHOR="^[a-z][a-z0-9+.-]*://([^/:]*\\.)?",MAX_REPEAT=16,MAX_EXPANSIONS=64;function escapeRegexChar(char){return/[\\.+?$(){}[\]|*^]/.test(char)?`\\${char}`:char}function escapeRegex(text){return[...text].map(escapeRegexChar).join("")}function assertAscii(text,what){if(/[^\x00-\x7F]/.test(text))throw new WebKitUnsupportedError(`non-ASCII characters in ${what}`)}export function urlFilterToWebKitRegex(filter){assertAscii(filter,"urlFilter");let pattern=filter,prefix="",suffix="";if(pattern.startsWith("||")){prefix=HOST_ANCHOR;pattern=pattern.slice(2)}else if(pattern.startsWith("|")){prefix="^";pattern=pattern.slice(1)}if(pattern.endsWith("|")){suffix="$";pattern=pattern.slice(0,-1)}if(prefix===HOST_ANCHOR&&!suffix&&/^[\w.-]+\^$/.test(pattern))return`${prefix}${escapeRegex(pattern.slice(0,-1))}[:/]`;let body="";for(let index=0;index<pattern.length;index++){const char=pattern[index];if(char==="*")body+=".*";else if(char==="^"){const last=index===pattern.length-1;body+=last?suffix?`(${SEPARATOR})?`:`(${SEPARATOR}.*)?$`:SEPARATOR}else body+=escapeRegexChar(char)}if(!prefix)body=body.replace(/^(?:\.\*)+/,"");if(!suffix)body=body.replace(/(?:\.\*)+$/,"");return`${prefix}${body}${suffix}`||".*"}export function hostToWebKitRegex(host){return`${HOST_ANCHOR}${escapeRegex(host)}[:/]`}export function isWebKitRegex(pattern){if(/[^\x00-\x7F]/.test(pattern))return!1;let inClass=!1;for(let index=0;index<pattern.length;index++){const char=pattern[index];if(char==="\\"){const next=pattern[index+1]??"";if(/[0-9a-zA-Z]/.test(next))return!1;index++;continue}if(inClass){if(char==="]")inClass=!1;continue}if(char==="["){inClass=!0;continue}if(char==="|"||char==="{"||char==="}")return!1;if(char==="("&&pattern[index+1]==="?")return!1}return!inClass}function peek(cursor){return cursor.source[cursor.index]}function product(left,right){const out=[];for(const a of left)for(const b of right)out.push(a+b);if(out.length>MAX_EXPANSIONS)throw new WebKitUnsupportedError(`regexFilter expands to more than ${MAX_EXPANSIONS} alternatives`);return out}function parseAlternation(cursor){const options=[...parseSequence(cursor)];while(peek(cursor)==="|"){cursor.index++;options.push(...parseSequence(cursor));if(options.length>MAX_EXPANSIONS)throw new WebKitUnsupportedError(`regexFilter expands to more than ${MAX_EXPANSIONS} alternatives`)}return options}function parseSequence(cursor){let options=[""];while(cursor.index<cursor.source.length&&peek(cursor)!=="|"&&peek(cursor)!==")"){const atom=parseAtom(cursor);options=product(options,applyQuantifier(cursor,atom))}return options}function parseClass(cursor){let out="[";cursor.index++;if(peek(cursor)==="^"){out+="^";cursor.index++}if(peek(cursor)==="]"){out+="\\]";cursor.index++}while(cursor.index<cursor.source.length&&peek(cursor)!=="]"){const char=peek(cursor);if(char==="\\"){const next=cursor.source[cursor.index+1]??"";cursor.index+=2;if(next==="d")out+="0-9";else if(next==="w")out+="a-zA-Z0-9_";else if(/[a-zA-Z0-9]/.test(next))throw new WebKitUnsupportedError(`regexFilter uses \\${next}, which WebKit does not support`);else out+=`\\${next}`;continue}if(char==="["&&cursor.source[cursor.index+1]===":")throw new WebKitUnsupportedError("regexFilter uses a POSIX character class");out+=char;cursor.index++}if(peek(cursor)!=="]")throw new WebKitUnsupportedError("regexFilter has an unterminated character class");cursor.index++;return`${out}]`}function parseAtom(cursor){const char=peek(cursor);if(char==="("){cursor.index++;if(peek(cursor)==="?")if(cursor.source[cursor.index+1]===":")cursor.index+=2;else throw new WebKitUnsupportedError("regexFilter uses a lookaround, named group or inline flag");const inner=parseAlternation(cursor);if(peek(cursor)!==")")throw new WebKitUnsupportedError("regexFilter has an unbalanced group");cursor.index++;return{options:inner.map((option)=>`(${option})`),quantifiable:!0}}if(char==="[")return{options:[parseClass(cursor)],quantifiable:!0};if(char==="\\"){const next=cursor.source[cursor.index+1]??"";cursor.index+=2;if(next==="d")return{options:["[0-9]"],quantifiable:!0};if(next==="w")return{options:["[a-zA-Z0-9_]"],quantifiable:!0};if(next==="/")return{options:["/"],quantifiable:!0};if(/[a-zA-Z0-9]/.test(next))throw new WebKitUnsupportedError(`regexFilter uses \\${next}, which WebKit does not support`);return{options:[`\\${next}`],quantifiable:!0}}if(char==="*"||char==="+"||char==="?"||char==="{")throw new WebKitUnsupportedError("regexFilter has a quantifier with nothing to repeat");cursor.index++;if(char==="^"||char==="$")return{options:[char],quantifiable:!1};return{options:[char==="}"||char==="]"?`\\${char}`:char],quantifiable:!0}}function applyQuantifier(cursor,atom){const char=peek(cursor);if(char!=="*"&&char!=="+"&&char!=="?"&&char!=="{")return atom.options;if(!atom.quantifiable)throw new WebKitUnsupportedError("regexFilter quantifies an anchor");let min,max;if(char==="{"){const match=/^\{(\d+)(,(\d*))?\}/.exec(cursor.source.slice(cursor.index));if(!match)throw new WebKitUnsupportedError("regexFilter has a malformed {n,m} repetition");cursor.index+=match[0].length;min=Number(match[1]);max=match[2]===void 0?min:match[3]?Number(match[3]):void 0;if(min>MAX_REPEAT||max!==void 0&&(max>MAX_REPEAT||max<min))throw new WebKitUnsupportedError(`regexFilter repeats more than ${MAX_REPEAT} times`)}else{cursor.index++;min=char==="+"?1:0;max=char==="?"?1:void 0}if(peek(cursor)==="?")cursor.index++;if(atom.options.length===1){const unit=atom.options[0];if(char!=="{")return[`${unit}${char}`];const required=unit.repeat(min);if(max===void 0)return[`${required}${unit}*`];return[`${required}${`${unit}?`.repeat(max-min)}`]}if(max===void 0)throw new WebKitUnsupportedError("regexFilter repeats an alternation without bound");let options=[];for(let count=min;count<=max;count++){let repeated=[""];for(let n=0;n<count;n++)repeated=product(repeated,atom.options);options.push(...repeated);if(options.length>MAX_EXPANSIONS)throw new WebKitUnsupportedError(`regexFilter expands to more than ${MAX_EXPANSIONS} alternatives`)}options=[...new Set(options)];return options}export function regexFilterToWebKit(regex){assertAscii(regex,"regexFilter");const cursor={source:regex,index:0},options=parseAlternation(cursor);if(cursor.index!==regex.length)throw new WebKitUnsupportedError("regexFilter has an unbalanced group");const patterns=[...new Set(options.map((option)=>option||".*"))];for(const pattern of patterns)if(!isWebKitRegex(pattern))throw new WebKitUnsupportedError("regexFilter cannot be expressed in WebKit's regex subset");return patterns}function normalizeDomain(domain){const bare=domain.trim().toLowerCase().replace(/^\*?\./,"");if(!/[^\x00-\x7F]/.test(bare))return bare;try{return new URL(`http://${bare}/`).hostname}catch{throw new WebKitUnsupportedError(`domain "${domain}" is not a valid host`)}}function isUnder(domain,parent){return domain===parent||domain.endsWith(`.${parent}`)}function domainCondition(included,excluded,what){let include=[...new Set(included.map(normalizeDomain))];const exclude=[...new Set(excluded.map(normalizeDomain))];if(include.length&&exclude.length){include=include.filter((domain)=>!exclude.some((ex)=>isUnder(domain,ex)));if(!include.length)return null;if(exclude.some((ex)=>include.some((domain)=>isUnder(ex,domain))))throw new WebKitUnsupportedError(`${what} excluded under an included domain`);return{"if-domain":include.map((domain)=>`*${domain}`)}}if(include.length)return{"if-domain":include.map((domain)=>`*${domain}`)};if(exclude.length)return{"unless-domain":exclude.map((domain)=>`*${domain}`)};return{}}function resourceVariants(condition,approximate){const listed=condition.resourceTypes??ALL_DNR_TYPES.filter((type)=>type!=="main_frame"),excluded=new Set(condition.excludedResourceTypes??[]),types=new Set(listed.filter((type)=>!excluded.has(type))),unmappable=[...types].filter((type)=>type!=="main_frame"&&type!=="sub_frame"&&!RESOURCE_TYPE_MAP[type]);if(unmappable.length&&unmappable.length===types.size)throw new WebKitUnsupportedError(`only resource types WebKit cannot match (${unmappable.join(", ")})`);if(unmappable.length&&condition.resourceTypes)approximate(`resource types without a WebKit equivalent ignored (${unmappable.sort().join(", ")})`);const sub=[...new Set([...types].map((type)=>RESOURCE_TYPE_MAP[type]).filter((type)=>Boolean(type)))],main=types.has("main_frame"),frame=types.has("sub_frame"),variants=[];if(main&&frame){if(ALL_WEBKIT_SUBRESOURCE_TYPES.every((type)=>sub.includes(type)))return[{}];sub.push("document","svg-document","popup")}if(sub.length)variants.push({types:sub});if(main&&!frame){variants.push({types:["document","svg-document"],loadContext:["top-frame"]});variants.push({types:["popup"]})}if(frame&&!main)variants.push({types:["document","svg-document"],loadContext:["child-frame"]});return variants}function hostOfFilter(filter){const match=/^\|\|([\w.-]+)(?:\^|\/)?\*?\|?$/.exec(filter);return match?match[1].toLowerCase():null}function leadingHostOfFilter(filter){const match=/^\|\|([\w.-]+)/.exec(filter);return match?match[1].toLowerCase():null}function urlPatterns(condition,approximate){const requestDomains=(condition.requestDomains??[]).map(normalizeDomain);if(condition.regexFilter!==void 0){if(requestDomains.length)throw new WebKitUnsupportedError("regexFilter combined with requestDomains");return regexFilterToWebKit(condition.regexFilter)}if(condition.urlFilter!==void 0&&condition.urlFilter!==""){const filter=condition.urlFilter,pattern=urlFilterToWebKitRegex(filter);if(!requestDomains.length)return[pattern];const leading=leadingHostOfFilter(filter);if(leading){if(requestDomains.some((domain)=>isUnder(leading,domain)))return[pattern];throw new WebKitUnsupportedError("a ||host urlFilter outside its requestDomains")}if(filter.startsWith("|"))throw new WebKitUnsupportedError("a |-anchored urlFilter combined with requestDomains");approximate("requestDomains with an unanchored urlFilter match it only after the host");return requestDomains.map((domain)=>`${HOST_ANCHOR}${escapeRegex(domain)}[:/].*${pattern}`)}if(requestDomains.length)return requestDomains.map(hostToWebKitRegex);return[".*"]}function convertAllowAllRequests(rule,approximated){const{condition}=rule,types=new Set(condition.resourceTypes??[]);if(!types.has("main_frame"))throw new WebKitUnsupportedError("allowAllRequests for subframes only");if(condition.regexFilter!==void 0)throw new WebKitUnsupportedError("allowAllRequests with a regexFilter");const pages=(condition.requestDomains??[]).map(normalizeDomain);if(condition.urlFilter){const host=hostOfFilter(condition.urlFilter);if(!host)throw new WebKitUnsupportedError("allowAllRequests with a urlFilter that is not a bare ||host");pages.push(host)}const initiators=[...condition.initiatorDomains??[],...condition.domains??[]],domains=domainCondition([...pages,...initiators],[...condition.excludedInitiatorDomains??[],...condition.excludedDomains??[]],"allowAllRequests page");if(domains===null)throw new WebKitUnsupportedError("every included domain is also excluded");if(types.has("sub_frame")&&(pages.length||initiators.length))approximated.push("allowAllRequests applies to top-level pages on its domains, not to their frames embedded elsewhere");return[{rule:{trigger:{"url-filter":".*",...domains},action:{type:"ignore-previous-rules"}},priority:rule.priority??1,rank:2,exception:!0}]}export function convertDnrRule(rule){const condition=rule.condition??{},approximated=[],approximate=(reason)=>{approximated.push(reason)};if(condition.tabIds?.length||condition.excludedTabIds?.length)throw new WebKitUnsupportedError("tabIds conditions");if(condition.requestMethods?.length||condition.excludedRequestMethods?.length)throw new WebKitUnsupportedError("request method conditions");if(condition.responseHeaders?.length||condition.excludedResponseHeaders?.length)throw new WebKitUnsupportedError("response header conditions");if(condition.excludedRequestDomains?.length)throw new WebKitUnsupportedError("excludedRequestDomains");const type=rule.action?.type;if(type==="allowAllRequests")return{rules:convertAllowAllRequests(rule,approximated),approximated};let action,rank;if(type==="block"){action={type:"block"};rank=1}else if(type==="allow"){action={type:"ignore-previous-rules"};rank=3}else if(type==="upgradeScheme"){action={type:"make-https"};rank=0}else if(type==="redirect")throw new WebKitUnsupportedError("redirect actions (content blockers cannot redirect)");else if(type==="modifyHeaders")throw new WebKitUnsupportedError("modifyHeaders actions");else throw new WebKitUnsupportedError(`unknown action type "${String(type)}"`);const domains=domainCondition([...condition.initiatorDomains??[],...condition.domains??[]],[...condition.excludedInitiatorDomains??[],...condition.excludedDomains??[]],"initiator domain");if(domains===null)throw new WebKitUnsupportedError("every included domain is also excluded");const patterns=urlPatterns(condition,approximate),variants=resourceVariants(condition,approximate),loadType=condition.domainType==="firstParty"?{"load-type":["first-party"]}:condition.domainType==="thirdParty"?{"load-type":["third-party"]}:{},caseSensitive=condition.isUrlFilterCaseSensitive?{"url-filter-is-case-sensitive":!0}:{},rules=[];for(const pattern of patterns)for(const variant of variants){const trigger={"url-filter":pattern,...caseSensitive,...variant.types?{"resource-type":variant.types}:{},...variant.loadContext?{"load-context":variant.loadContext}:{},...loadType,...domains};rules.push({rule:{trigger,action},priority:rule.priority??1,rank,exception:action.type==="ignore-previous-rules"})}return{rules,approximated}}const PROCEDURAL_PSEUDO=/:(?:-abp-|has-text|contains|matches-css|matches-attr|matches-path|xpath|upward|remove|style|min-text-length|watch-attr|others|if|if-not)\b/;export function cosmeticSelectorProblem(selector){const text=selector.trim();if(!text)return"an empty selector";if(/[{};]/.test(text))return"a selector containing CSS declarations";if(PROCEDURAL_PSEUDO.test(text))return"a procedural (adblock-only) pseudo-class";let depth=0,quote="";for(const char of text){if(quote){if(char===quote)quote="";continue}if(char==='"'||char==="'")quote=char;else if(char==="("||char==="[")depth++;else if(char===")"||char==="]")depth--;if(depth<0)return"a selector with unbalanced brackets"}if(depth!==0||quote)return"a selector with unbalanced brackets or quotes";return null}class Issues{byReason=new Map;add(reason,example){const issue=this.byReason.get(reason)??{reason,count:0,examples:[]};issue.count++;if(issue.examples.length<10)issue.examples.push(example);this.byReason.set(reason,issue)}list(){return[...this.byReason.values()].sort((a,b)=>b.count-a.count)}}export function webkitListVersion(rules){return createHash("sha256").update(JSON.stringify(rules)).digest("hex").slice(0,16)}function safeId(id){if(!/^[\w.-]+$/.test(id))throw Error(`[browser-extension] WebKit list id "${id}" may only contain letters, digits, '.', '_' and '-'`);return id}function chunk(items,size){const out=[];for(let index=0;index<items.length;index+=size)out.push(items.slice(index,index+size));return out}function byPrecedence(a,b){return a.priority-b.priority||a.rank-b.rank||a.order-b.order}export function buildWebKitLists(groups,options={}){const max=options.maxRulesPerList??50000;if(!Number.isInteger(max)||max<1||max>WEBKIT_MAX_RULES_PER_LIST)throw Error(`[browser-extension] maxRulesPerList must be an integer from 1 to ${WEBKIT_MAX_RULES_PER_LIST}`);const perRule=options.selectorsPerRule??100,unsupported=new Issues,approximated=new Issues;let input=0,output=0,order=0;const seen=new Set,converted=groups.map((group)=>{const id=safeId(group.id);if(seen.has(id))throw Error(`[browser-extension] two WebKit rule groups are both named "${id}"`);seen.add(id);const network=[];for(const rule of group.dnr??[]){input++;try{const result=convertDnrRule(rule);for(const reason of result.approximated)approximated.add(reason,rule.id);for(const ranked of result.rules)network.push({...ranked,order:order++});output+=result.rules.length}catch(error){if(!(error instanceof WebKitUnsupportedError))throw error;unsupported.add(error.message,rule.id)}}const cosmetic=[];for(const[index,entry]of(group.cosmetic??[]).entries()){input++;const example=`${id}#${index}`,selectors=(Array.isArray(entry.selector)?entry.selector:[entry.selector]).filter((selector)=>{const problem=cosmeticSelectorProblem(selector);if(problem)unsupported.add(`cosmetic: ${problem}`,example);return!problem});if(!selectors.length)continue;let domains;try{domains=domainCondition(entry.domains??[],entry.excludedDomains??[],"cosmetic page")}catch(error){if(!(error instanceof WebKitUnsupportedError))throw error;unsupported.add(`cosmetic: ${error.message}`,example);continue}if(domains===null){unsupported.add("cosmetic: every included domain is also excluded",example);continue}for(const part of chunk(selectors,perRule)){cosmetic.push({rule:{trigger:{"url-filter":".*",...domains},action:{type:"css-display-none",selector:part.join(", ")}},priority:0,rank:0,exception:!1,order:order++});output++}}return{id,enabled:group.enabled??!0,network,cosmetic}}),globalExceptions=converted.filter((group)=>group.enabled).flatMap((group)=>group.network.filter((rule)=>rule.exception)),lists=[],emit=(id,kind,enabled,ordinary,exceptions)=>{if(!ordinary.length)return;const room=max-exceptions.length;if(room<1)throw Error(`[browser-extension] group "${id}" needs ${exceptions.length} exception rules in every list, which leaves no room under maxRulesPerList (${max})`);chunk(ordinary,room).forEach((part,index)=>{const rules=[...part,...exceptions].sort(byPrecedence).map((entry)=>entry.rule);lists.push({id:index?`${id}-${index+1}`:id,version:webkitListVersion(rules),group:id,kind,enabled,rules})})};for(const group of converted){const own=group.network.filter((rule)=>rule.exception),exceptions=group.enabled?globalExceptions:[...globalExceptions,...own];emit(group.id,"network",group.enabled,group.network.filter((rule)=>!rule.exception),exceptions);emit(group.network.length?`${group.id}-cosmetic`:group.id,"cosmetic",group.enabled,group.cosmetic,[])}return{lists,report:{input,output,unsupported:unsupported.list(),approximated:approximated.list()}}}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stacksjs/browser-extension",
3
3
  "type": "module",
4
- "version": "0.75.409",
4
+ "version": "0.75.417",
5
5
  "description": "Build MV3 browser extensions (Chrome, Firefox, Safari) the Stacks way — manifest, content/background scripts, DNR rules, packaging, Safari container app, all config-driven.",
6
6
  "author": "Chris Breuer",
7
7
  "contributors": [