From 6f5bf89645f6fad9545eb7064c4ca2ea37969628 Mon Sep 17 00:00:00 2001 From: Aaron Klinker Date: Sun, 16 Jul 2023 10:10:25 -0500 Subject: [PATCH] feat: Support all content script options --- docs/guide/content-scripts.md | 4 ++ e2e/tests/manifest-content.test.ts | 6 +-- src/core/server.ts | 3 +- src/core/types/external.ts | 45 ++++++++++++++-- .../utils/__tests__/content-scripts.test.ts | 28 ++++++++++ src/core/utils/content-scripts.ts | 52 +++++++++++++++++++ src/core/utils/manifest.ts | 8 ++- 7 files changed, 137 insertions(+), 9 deletions(-) create mode 100644 src/core/utils/__tests__/content-scripts.test.ts create mode 100644 src/core/utils/content-scripts.ts diff --git a/docs/guide/content-scripts.md b/docs/guide/content-scripts.md index 6b025bc0..50b34ed6 100644 --- a/docs/guide/content-scripts.md +++ b/docs/guide/content-scripts.md @@ -17,6 +17,10 @@ When a filename matches the pattern below, it is added as a content script in th export default defineContentScript({ // Set manifest options matches: ['*://google.com/*', '*://duckduckgo.com/*'], + excludeMatches: undefined | [], + includeGlobs: undefined | [], + excludeGlobs: undefined | [], + allFrames: undefined | [], runAt: undefined | 'document_start' | 'document_end' | 'document_idle', matchAboutBlank: undefined | true | false, matchOriginAsFallback: undefined | true | false, diff --git a/e2e/tests/manifest-content.test.ts b/e2e/tests/manifest-content.test.ts index a5be42d7..e99a2202 100644 --- a/e2e/tests/manifest-content.test.ts +++ b/e2e/tests/manifest-content.test.ts @@ -149,7 +149,7 @@ describe('Manifest Content', () => { `import "./style.css"; export default defineContentScript({ matches: ["*://google.com/*"], - run_at: "document_end", + runAt: "document_end", main: () => {}, })`, ); @@ -162,7 +162,7 @@ describe('Manifest Content', () => { `import "./style.css"; export default defineContentScript({ matches: ["*://google.com/*"], - run_at: "document_end", + runAt: "document_end", main: () => {}, })`, ); @@ -175,7 +175,7 @@ describe('Manifest Content', () => { `import "./style.css"; export default defineContentScript({ matches: ["*://duckduckgo.com/*"], - run_at: "document_end", + runAt: "document_end", main: () => {}, })`, ); diff --git a/src/core/server.ts b/src/core/server.ts index e70b56bb..cb01ddd4 100644 --- a/src/core/server.ts +++ b/src/core/server.ts @@ -11,6 +11,7 @@ import { getEntrypointBundlePath } from './utils/entrypoints'; import { getContentScriptCssFiles } from './utils/manifest'; import { createWebExtRunner } from './runners/createWebExtRunner'; import { buildInternal } from './build'; +import { mapWxtOptionsToContentScript } from './utils/content-scripts'; export async function getServerInfo(): Promise { const port = await findOpenPort(3000, 3010); @@ -102,9 +103,9 @@ export function reloadContentScripts( const css = getContentScriptCssFiles([entry], server.currentOutput); server.reloadContentScript({ + ...mapWxtOptionsToContentScript(entry.options), js, css, - ...entry.options, }); }); } else { diff --git a/src/core/types/external.ts b/src/core/types/external.ts index 9a5f4b19..ed18c6f2 100644 --- a/src/core/types/external.ts +++ b/src/core/types/external.ts @@ -255,11 +255,50 @@ export type Entrypoint = export type OnContentScriptStopped = (cb: () => void) => void; export interface ContentScriptDefinition { - matches: string[]; - runAt?: 'document_start' | 'document_end' | 'document_idle'; - matchAboutBlank?: boolean; + matches: Manifest.ContentScript['matches']; + /** + * See https://developer.chrome.com/docs/extensions/mv3/content_scripts/ + * @default "documentIdle" + */ + runAt?: Manifest.ContentScript['run_at']; + /** + * See https://developer.chrome.com/docs/extensions/mv3/content_scripts/ + * @default false + */ + matchAboutBlank?: Manifest.ContentScript['match_about_blank']; + /** + * See https://developer.chrome.com/docs/extensions/mv3/content_scripts/ + * @default [] + */ + excludeMatches?: Manifest.ContentScript['exclude_matches']; + /** + * See https://developer.chrome.com/docs/extensions/mv3/content_scripts/ + * @default [] + */ + includeGlobs?: Manifest.ContentScript['include_globs']; + /** + * See https://developer.chrome.com/docs/extensions/mv3/content_scripts/ + * @default [] + */ + excludeGlobs?: Manifest.ContentScript['exclude_globs']; + /** + * See https://developer.chrome.com/docs/extensions/mv3/content_scripts/ + * @default false + */ + allFrames?: Manifest.ContentScript['all_frames']; + /** + * See https://developer.chrome.com/docs/extensions/mv3/content_scripts/ + * @default false + */ matchOriginAsFallback?: boolean; + /** + * See https://developer.chrome.com/docs/extensions/mv3/content_scripts/ + * @default "ISOLATED" + */ world?: 'ISOLATED' | 'MAIN'; + /** + * Main function executed when the content script is loaded. + */ main(): void | Promise; } diff --git a/src/core/utils/__tests__/content-scripts.test.ts b/src/core/utils/__tests__/content-scripts.test.ts new file mode 100644 index 00000000..0732a7a6 --- /dev/null +++ b/src/core/utils/__tests__/content-scripts.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { hashContentScriptOptions } from '../content-scripts'; + +describe('Content Script Utils', () => { + describe('hashContentScriptOptions', () => { + it('should return a string containing all the options with defaults applied', () => { + const hash = hashContentScriptOptions({ matches: [] }); + + expect(hash).toMatchInlineSnapshot( + '"[[\\"allFrames\\",false],[\\"excludeGlobs\\",[]],[\\"excludeMatches\\",[]],[\\"includeGlobs\\",[]],[\\"matchAboutBlank\\",false],[\\"matches\\",[]],[\\"matchOriginAsFallback\\",false],[\\"runAt\\",\\"document_idle\\"],[\\"world\\",\\"ISOLATED\\"]]"', + ); + }); + + it('should be consistent regardless of the object ordering and default values', () => { + const hash1 = hashContentScriptOptions({ + allFrames: true, + matches: ['*://google.com/*', '*://duckduckgo.com/*'], + matchAboutBlank: false, + }); + const hash2 = hashContentScriptOptions({ + matches: ['*://duckduckgo.com/*', '*://google.com/*'], + allFrames: true, + }); + + expect(hash1).toBe(hash2); + }); + }); +}); diff --git a/src/core/utils/content-scripts.ts b/src/core/utils/content-scripts.ts new file mode 100644 index 00000000..d1ae9017 --- /dev/null +++ b/src/core/utils/content-scripts.ts @@ -0,0 +1,52 @@ +import { Manifest } from 'webextension-polyfill'; +import { ContentScriptEntrypoint } from '../types'; + +/** + * Returns a unique and consistent string hash based on a content scripts options. + * + * It is able to recognize default values, + */ +export function hashContentScriptOptions( + options: ContentScriptEntrypoint['options'], +): string { + const withDefaults: ContentScriptEntrypoint['options'] = { + excludeGlobs: [], + excludeMatches: [], + includeGlobs: [], + matchAboutBlank: false, + matchOriginAsFallback: false, + runAt: 'document_idle', + allFrames: false, + world: 'ISOLATED', + // TODO: strip undefined fields from options object to improve content script grouping. + ...options, + }; + return JSON.stringify( + Object.entries(withDefaults) + // Sort any arrays so their values are consistent + .map<[string, unknown]>(([key, value]) => { + if (Array.isArray(value)) return [key, value.sort()]; + else return [key, value]; + }) + // Sort all the fields alphabetically + .sort((l, r) => l[0].localeCompare(r[0])), + ); +} + +export function mapWxtOptionsToContentScript( + options: ContentScriptEntrypoint['options'], +): Omit { + return { + matches: options.matches, + all_frames: options.allFrames, + match_about_blank: options.matchAboutBlank, + exclude_globs: options.excludeGlobs, + exclude_matches: options.excludeMatches, + include_globs: options.includeGlobs, + run_at: options.runAt, + + // @ts-expect-error: untyped chrome options + match_origin_as_fallback: options.matchOriginAsFallback, + world: options.world, + }; +} diff --git a/src/core/utils/manifest.ts b/src/core/utils/manifest.ts index e1e0a622..a79f5ad3 100644 --- a/src/core/utils/manifest.ts +++ b/src/core/utils/manifest.ts @@ -12,6 +12,10 @@ import fs from 'fs-extra'; import { resolve } from 'path'; import { getEntrypointBundlePath } from './entrypoints'; import { ContentSecurityPolicy } from './ContentSecurityPolicy'; +import { + hashContentScriptOptions, + mapWxtOptionsToContentScript, +} from './content-scripts'; /** * Writes the manifest to the output directory and the build output. @@ -285,7 +289,7 @@ function addEntrypoints( ); } else { const hashToEntrypointsMap = contentScripts.reduce((map, script) => { - const hash = JSON.stringify(script.options); + const hash = hashContentScriptOptions(script.options); if (map.has(hash)) map.get(hash)?.push(script); else map.set(hash, [script]); return map; @@ -293,7 +297,7 @@ function addEntrypoints( manifest.content_scripts = Array.from(hashToEntrypointsMap.entries()).map( ([, scripts]) => ({ - ...scripts[0].options, + ...mapWxtOptionsToContentScript(scripts[0].options), // TOOD: Sorting css and js arrays here so we get consistent test results... but we // shouldn't have to. Where is the inconsistency coming from? css: getContentScriptCssFiles(scripts, buildOutput)?.sort(),