diff --git a/.gitignore b/.gitignore index b088d9a25..74cdcbe77 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,4 @@ __TEST__ /playwright/.cache/ /e2e-report/ _examples -specs/ \ No newline at end of file +specs/ diff --git a/examples-v2/data.ts b/examples-v2/data.ts new file mode 100644 index 000000000..b41b3bd4d --- /dev/null +++ b/examples-v2/data.ts @@ -0,0 +1,62 @@ +import {type Template} from './types' + +const ALL_TEMPLATES: Template[] = [ + { + name: 'javascript', + uiContext: ['content'], + uiFramework: undefined, + css: 'css', + hasBackground: false, + hasEnv: false, + configFiles: undefined + }, + { + name: 'typescript', + uiContext: ['content'], + uiFramework: undefined, + css: 'css', + hasBackground: false, + hasEnv: false, + configFiles: ['tsconfig.json'] + }, + { + name: 'react', + uiContext: ['content'], + uiFramework: 'react', + css: 'css', + hasBackground: false, + hasEnv: false, + configFiles: ['postcss.config.js', 'tailwind.config.js', 'tsconfig.json'] + }, + { + name: 'preact', + uiContext: ['content'], + uiFramework: 'preact', + css: 'css', + hasBackground: false, + hasEnv: false, + configFiles: ['postcss.config.js', 'tailwind.config.js', 'tsconfig.json'] + }, + { + name: 'vue', + uiContext: ['content'], + uiFramework: 'vue', + css: 'css', + hasBackground: false, + hasEnv: false, + configFiles: ['postcss.config.js', 'tailwind.config.js', 'tsconfig.json'] + }, + { + name: 'svelte', + uiContext: ['content'], + uiFramework: 'svelte', + css: 'css', + hasBackground: false, + hasEnv: false, + configFiles: ['postcss.config.js', 'tailwind.config.js', 'tsconfig.json'] + } +] + +const SUPPORTED_BROWSERS: string[] = ['chrome', 'edge', 'firefox'] + +export {SUPPORTED_BROWSERS, ALL_TEMPLATES} diff --git a/examples-v2/extension-fixtures.mjs b/examples-v2/extension-fixtures.mjs new file mode 100644 index 000000000..69c958bf0 --- /dev/null +++ b/examples-v2/extension-fixtures.mjs @@ -0,0 +1,104 @@ +import { + test as base, + chromium, +} from '@playwright/test' + +/** + * @typedef {import('@playwright/test').BrowserContext} BrowserContext + */ +const extensionFixtures = ( + /** @type {string} */ pathToExtension, + /** @type {boolean} */ headless + /** @returns {import('@playwright/test').TestModifier<{}, {context: BrowserContext, extensionId: string}>} */ +) => { + return base.extend({ + /** @type {() => Promise} */ + // eslint-disable-next-line no-empty-pattern + context: async ({}, use) => { + const context = await chromium.launchPersistentContext('', { + headless: false, + args: [ + headless ? `--headless=new` : '', + `--disable-extensions-except=${pathToExtension}`, + `--load-extension=${pathToExtension}`, + '--no-first-run', // Disable Chrome's native first run experience. + '--disable-client-side-phishing-detection', // Disables client-side phishing detection + '--disable-component-extensions-with-background-pages', // Disable some built-in extensions that aren't affected by '--disable-extensions' + '--disable-default-apps', // Disable installation of default apps + '--disable-features=InterestFeedContentSuggestions', // Disables the Discover feed on NTP + '--disable-features=Translate', // Disables Chrome translation, both the manual option and the popup prompt when a page with differing language is detected. + '--hide-scrollbars', // Hide scrollbars from screenshots. + '--mute-audio', // Mute any audio + '--no-default-browser-check', // Disable the default browser check, do not prompt to set it as such + '--no-first-run', // Skip first run wizards + '--ash-no-nudges', // Avoids blue bubble "user education" nudges (eg., "… give your browser a new look", Memory Saver) + '--disable-search-engine-choice-screen', // Disable the 2023+ search engine choice screen + '--disable-features=MediaRoute', // Avoid the startup dialog for `Do you want the application “Chromium.app” to accept incoming network connections?`. Also disables the Chrome Media Router which creates background networking activity to discover cast targets. A superset of disabling DialMediaRouteProvider. + '--use-mock-keychain', // Use mock keychain on Mac to prevent the blocking permissions dialog about "Chrome wants to use your confidential information stored in your keychain" + '--disable-background-networking', // Disable various background network services, including extension updating, safe browsing service, upgrade detector, translate, UMA + '--disable-breakpad', // Disable crashdump collection (reporting is already disabled in Chromium) + '--disable-component-update', // Don't update the browser 'components' listed at chrome://components/ + '--disable-domain-reliability', // Disables Domain Reliability Monitoring, which tracks whether the browser has difficulty contacting Google-owned sites and uploads reports to Google. + '--disable-features=AutofillServerCommunicatio', // Disables autofill server communication. This feature isn't disabled via other 'parent' flags. + '--disable-features=CertificateTransparencyComponentUpdate', + '--disable-sync', // Disable syncing to a Google account + '--disable-features=OptimizationHints', // Used for turning on Breakpad crash reporting in a debug environment where crash reporting is typically compiled but disabled. Disable the Chrome Optimization Guide and networking with its service API + '--disable-features=DialMediaRouteProvider', // A weaker form of disabling the MediaRouter feature. See that flag's details. + '--no-pings', // Don't send hyperlink auditing pings + '--enable-features=SidePanelUpdates' // Ensure the side panel is visible. This is used for testing the side panel feature. + ].filter((arg) => !!arg) + }) + await use(context) + await context.close() + }, + /** @type {() => Promise} */ + extensionId: async ({context}, use) => { + /* + // for manifest v2: + let [background] = context.backgroundPages() + if (!background) + background = await context.waitForEvent('backgroundpage') + */ + + // for manifest v3: + let [background] = context.serviceWorkers() + if (!background) background = await context.waitForEvent('serviceworker') + + const extensionId = background.url().split('/')[2] + await use(extensionId) + } + }) +} + +// Screenshot function +async function takeScreenshot(page, screenshotPath) { + await page.screenshot({path: screenshotPath}) +} + +export {extensionFixtures, takeScreenshot} + +/** + * Utility to access elements inside the Shadow DOM. + * @param page The Playwright Page object. + * @param shadowHostSelector The selector for the Shadow DOM host element. + * @param innerSelector The selector for the element inside the Shadow DOM. + * @returns A Promise resolving to an ElementHandle for the inner element or null if not found. + */ +export async function getShadowRootElement( + page, + shadowHostSelector, + innerSelector +) { + const shadowHost = page.locator(shadowHostSelector) + const shadowRootHandle = await shadowHost.evaluateHandle( + (host) => host.shadowRoot + ) + + const innerElement = await shadowRootHandle.evaluateHandle( + (shadowRoot, selector) => + shadowRoot.querySelector(selector), + innerSelector + ) + + return innerElement.asElement() +} diff --git a/examples-v2/extension-fixtures.ts b/examples-v2/extension-fixtures.ts new file mode 100644 index 000000000..abb51eeeb --- /dev/null +++ b/examples-v2/extension-fixtures.ts @@ -0,0 +1,110 @@ +import { + test as base, + chromium, + type Page, + type BrowserContext, + type ElementHandle +} from '@playwright/test' + +export const extensionFixtures = ( + pathToExtension: string, + headless: boolean +) => { + return base.extend<{ + context: BrowserContext + extensionId: string + }>({ + context: async ({}, use) => { + const context = await chromium.launchPersistentContext('', { + headless: false, + args: [ + headless ? `--headless=new` : '', + `--disable-extensions-except=${pathToExtension}`, + `--load-extension=${pathToExtension}`, + '--no-first-run', // Disable Chrome's native first run experience. + '--disable-client-side-phishing-detection', // Disables client-side phishing detection + '--disable-component-extensions-with-background-pages', // Disable some built-in extensions that aren't affected by '--disable-extensions' + '--disable-default-apps', // Disable installation of default apps + '--disable-features=InterestFeedContentSuggestions', // Disables the Discover feed on NTP + '--disable-features=Translate', // Disables Chrome translation, both the manual option and the popup prompt when a page with differing language is detected. + '--hide-scrollbars', // Hide scrollbars from screenshots. + '--mute-audio', // Mute any audio + '--no-default-browser-check', // Disable the default browser check, do not prompt to set it as such + '--no-first-run', // Skip first run wizards + '--ash-no-nudges', // Avoids blue bubble "user education" nudges (eg., "… give your browser a new look", Memory Saver) + '--disable-search-engine-choice-screen', // Disable the 2023+ search engine choice screen + '--disable-features=MediaRoute', // Avoid the startup dialog for `Do you want the application “Chromium.app” to accept incoming network connections?`. Also disables the Chrome Media Router which creates background networking activity to discover cast targets. A superset of disabling DialMediaRouteProvider. + '--use-mock-keychain', // Use mock keychain on Mac to prevent the blocking permissions dialog about "Chrome wants to use your confidential information stored in your keychain" + '--disable-background-networking', // Disable various background network services, including extension updating, safe browsing service, upgrade detector, translate, UMA + '--disable-breakpad', // Disable crashdump collection (reporting is already disabled in Chromium) + '--disable-component-update', // Don't update the browser 'components' listed at chrome://components/ + '--disable-domain-reliability', // Disables Domain Reliability Monitoring, which tracks whether the browser has difficulty contacting Google-owned sites and uploads reports to Google. + '--disable-features=AutofillServerCommunicatio', // Disables autofill server communication. This feature isn't disabled via other 'parent' flags. + '--disable-features=CertificateTransparencyComponentUpdate', + '--disable-sync', // Disable syncing to a Google account + '--disable-features=OptimizationHints', // Used for turning on Breakpad crash reporting in a debug environment where crash reporting is typically compiled but disabled. Disable the Chrome Optimization Guide and networking with its service API + '--disable-features=DialMediaRouteProvider', // A weaker form of disabling the MediaRouter feature. See that flag's details. + '--no-pings', // Don't send hyperlink auditing pings + '--enable-features=SidePanelUpdates' // Ensure the side panel is visible. This is used for testing the side panel feature. + ].filter((arg) => !!arg) + }) + await use(context) + await context.close() + }, + extensionId: async ({context}, use) => { + /* + // for manifest v2: + let [background] = context.backgroundPages() + if (!background) + background = await context.waitForEvent('backgroundpage') + */ + + // for manifest v3: + let [background] = context.serviceWorkers() + if (!background) background = await context.waitForEvent('serviceworker') + + const extensionId = background.url().split('/')[2] + await use(extensionId) + } + }) +} + +// Screenshot function +export async function takeScreenshot(page: any, screenshotPath: string) { + await page.screenshot({path: screenshotPath}) +} + +/** + * Utility to access elements inside the Shadow DOM. + * @param page The Playwright Page object. + * @param shadowHostSelector The selector for the Shadow DOM host element. + * @param innerSelector The selector for the element inside the Shadow DOM. + * @returns A Promise resolving to an ElementHandle for the inner element or null if not found. + */ +export async function getShadowRootElement( + page: Page, + shadowHostSelector: string, + innerSelector: string +): Promise | null> { + // Wait for shadow host to be present first + await page.waitForSelector(shadowHostSelector, {timeout: 15000}) + + // Get the shadow host element + const shadowHost = await page.$(shadowHostSelector) + if (!shadowHost) return null + + // Get its shadow root + const shadowRoot = await page.evaluateHandle( + (host) => host.shadowRoot, + shadowHost + ) + if (!shadowRoot) return null + + // Find element within shadow root + const element = await page.evaluateHandle( + (root) => root?.querySelector(innerSelector), + shadowRoot + ) + + return element.asElement() as ElementHandle | null +} diff --git a/examples-v2/javascript/.gitignore b/examples-v2/javascript/.gitignore new file mode 100644 index 000000000..5e8c65b73 --- /dev/null +++ b/examples-v2/javascript/.gitignore @@ -0,0 +1,31 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules + +# testing +coverage + +# production +dist + +# misc +.DS_Store + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# lock files +yarn.lock +package-lock.json + +# debug files +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# extension.js +extension-env.d.ts diff --git a/examples-v2/javascript/background.js b/examples-v2/javascript/background.js new file mode 100644 index 000000000..798d5018d --- /dev/null +++ b/examples-v2/javascript/background.js @@ -0,0 +1 @@ +console.log('Hello from the background script!') diff --git a/examples-v2/javascript/content/scripts.js b/examples-v2/javascript/content/scripts.js new file mode 100644 index 000000000..c2806aac9 --- /dev/null +++ b/examples-v2/javascript/content/scripts.js @@ -0,0 +1,43 @@ +import logo from '../images/logo.svg' + +console.log('hello from content_scripts') + +if (document.readyState === 'complete') { + initial() +} else { + document.addEventListener('readystatechange', () => { + if (document.readyState === 'complete') initial() + }) +} + +function initial() { + const rootDiv = document.createElement('div') + rootDiv.id = 'extension-root' + document.body.appendChild(rootDiv) + + // Injecting content_scripts inside a shadow dom + // prevents conflicts with the host page's styles. + // This way, styles from the extension won't leak into the host page. + const shadowRoot = rootDiv.attachShadow({mode: 'open'}) + + // Inform Extension.js that the shadow root is available. + window.__EXTENSION_SHADOW_ROOT__ = shadowRoot + + shadowRoot.innerHTML = ` +
+ +

+ Welcome to your Content Script Extension +

+

+ Learn more about creating cross-browser extensions at + https://extension.js.org + +

+
+ ` +} diff --git a/examples-v2/javascript/content/styles.css b/examples-v2/javascript/content/styles.css new file mode 100644 index 000000000..1f69e11c2 --- /dev/null +++ b/examples-v2/javascript/content/styles.css @@ -0,0 +1,40 @@ +.content_script { + color: #c9c9c9; + background-color: #0a0c10; + position: fixed; + right: 0; + bottom: 0; + z-index: 9; + width: 315px; + margin: 1rem; + padding: 2rem 1rem; + display: flex; + flex-direction: column; + gap: 1em; + border-radius: 6px; +} + +.content_logo { + width: 72px; +} + +.content_title { + font-size: 1.85em; + line-height: 1.1; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + 'Helvetica Neue', Arial, 'Noto Sans', sans-serif; + font-weight: 700; + margin: 0; +} + +.content_description { + font-size: small; + margin: 0; +} + +.content_description a { + text-decoration: none; + border-bottom: 2px solid #c9c9c9; + color: #e5e7eb; + margin: 0; +} diff --git a/examples-v2/javascript/images/extension_48.png b/examples-v2/javascript/images/extension_48.png new file mode 100644 index 000000000..f60575b39 Binary files /dev/null and b/examples-v2/javascript/images/extension_48.png differ diff --git a/examples/content-main-world/images/extension.svg b/examples-v2/javascript/images/logo.svg similarity index 100% rename from examples/content-main-world/images/extension.svg rename to examples-v2/javascript/images/logo.svg diff --git a/examples-v2/javascript/manifest.json b/examples-v2/javascript/manifest.json new file mode 100644 index 000000000..367081dd5 --- /dev/null +++ b/examples-v2/javascript/manifest.json @@ -0,0 +1,29 @@ +{ + "$schema": "https://json.schemastore.org/chrome-manifest.json", + "manifest_version": 3, + "version": "0.0.1", + "name": "JavaScript", + "description": "An Extension.js example.", + "icons": { + "48": "images/extension_48.png" + }, + "permissions": ["activeTab", "scripting"], + "host_permissions": [""], + "background": { + "chromium:service_worker": "background.js", + "firefox:scripts": ["background.js"] + }, + "web_accessible_resources": [ + { + "resources": ["logo.svg"], + "matches": [""] + } + ], + "content_scripts": [ + { + "matches": [""], + "js": ["content/scripts.js"] + } + ], + "options_page": "./options/index.html" +} diff --git a/examples-v2/javascript/options/index.html b/examples-v2/javascript/options/index.html new file mode 100644 index 000000000..42ac61efb --- /dev/null +++ b/examples-v2/javascript/options/index.html @@ -0,0 +1,30 @@ + + + + + + New Extension + + + +
+

+ +
+ Welcome to your New Extension +

+

+ Learn more about creating cross-browser extensions at + https://extension.js.org. +

+
+ + + diff --git a/examples-v2/javascript/options/scripts.js b/examples-v2/javascript/options/scripts.js new file mode 100644 index 000000000..7fcefeb34 --- /dev/null +++ b/examples-v2/javascript/options/scripts.js @@ -0,0 +1 @@ +console.log('Hello from the new tab page!') diff --git a/examples-v2/javascript/options/styles.css b/examples-v2/javascript/options/styles.css new file mode 100644 index 000000000..75e01d08f --- /dev/null +++ b/examples-v2/javascript/options/styles.css @@ -0,0 +1,86 @@ +html { + font-size: 62.5%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + 'Helvetica Neue', Arial, 'Noto Sans', sans-serif; +} + +body { + display: flex; + justify-content: center; + align-items: center; + height: calc(100vh - 4rem); + min-width: 300px; + padding: 2rem; + font-size: 1.8rem; + line-height: 1.618; + max-width: 38em; + margin: auto; + color: #c9c9c9; + background-color: #0A0C10; +} + +@media (max-width: 684px) { + body { + font-size: 1.53rem; + } +} + +@media (max-width: 382px) { + body { + font-size: 1.35rem; + } +} + +h1 { + line-height: 1.1; + font-weight: 700; + margin-bottom: 1.5rem; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + font-size: 4.7em; +} + +@media (max-width: 684px) { + h1 { + font-size: 2.7em; + } +} + +p { + margin-top: 0px; + margin-bottom: 2.5rem; +} + +a { + text-decoration: none; + border-bottom: 2px solid #c9c9c9; + color: #e5e7eb; +} + + +img { + height: auto; + max-width: 100%; + margin-top: 0px; + margin-bottom: 2.5rem; +} + +@media (max-width: 684px) { + img { + margin-top: 2rem; + margin-bottom: 1rem; + } +} + +body { + display: flex; + justify-content: center; + align-items: center; + height: calc(100vh - 4rem); +} + +header > div { + display: flex; + align-items: center; +} \ No newline at end of file diff --git a/examples-v2/javascript/package.json b/examples-v2/javascript/package.json new file mode 100644 index 000000000..a36545524 --- /dev/null +++ b/examples-v2/javascript/package.json @@ -0,0 +1,11 @@ +{ + "private": true, + "name": "javascript", + "description": "An Extension.js example.", + "version": "0.0.1", + "author": { + "name": "Cezar Augusto", + "email": "boss@cezaraugusto.net", + "url": "https://cezaraugusto.com" + } +} diff --git a/examples-v2/javascript/screenshot.png b/examples-v2/javascript/screenshot.png new file mode 100644 index 000000000..8a538286b Binary files /dev/null and b/examples-v2/javascript/screenshot.png differ diff --git a/examples-v2/javascript/template.spec.ts b/examples-v2/javascript/template.spec.ts new file mode 100644 index 000000000..6d3f1e4f1 --- /dev/null +++ b/examples-v2/javascript/template.spec.ts @@ -0,0 +1,75 @@ +import path from 'path' +import {execSync} from 'child_process' +import { + extensionFixtures, + getShadowRootElement, + takeScreenshot +} from '../extension-fixtures' + +const exampleDir = 'examples/content-basic' +const pathToExtension = path.join(__dirname, `dist/chrome`) +const test = extensionFixtures(pathToExtension, true) + +test.beforeAll(async () => { + execSync(`pnpm extension build ${exampleDir}`, { + cwd: path.join(__dirname, '..') + }) +}) + +test('should exist an element with the class name content_script', async ({ + page +}) => { + await page.goto('https://extension.js.org/') + const div = await getShadowRootElement( + page, + '#extension-root', + 'div.content_script' + ) + if (!div) { + throw new Error('div with class content_script not found in Shadow DOM') + } + test.expect(div).not.toBeNull() +}) + +test('should exist an h1 element with specified content', async ({page}) => { + await page.goto('https://extension.js.org/') + const h1 = await getShadowRootElement( + page, + '#extension-root', + 'div.content_script > h1' + ) + if (!h1) { + throw new Error('h1 element not found in Shadow DOM') + } + const textContent = await h1.evaluate((node) => node.textContent) + test.expect(textContent).toContain('Welcome to your') +}) + +test('should exist a default color value', async ({page}) => { + await page.goto('https://extension.js.org/') + const h1 = await getShadowRootElement( + page, + '#extension-root', + 'div.content_script > h1' + ) + if (!h1) { + throw new Error('h1 element not found in Shadow DOM') + } + const color = await h1.evaluate((node) => + window.getComputedStyle(node as HTMLElement).getPropertyValue('color') + ) + test.expect(color).toEqual('rgb(201, 201, 201)') +}) + +test.skip('takes a screenshot of the page', async ({page}) => { + await page.goto('https://extension.js.org/') + const contentScriptDiv = await getShadowRootElement( + page, + '#extension-root', + 'div.content_script' + ) + if (!contentScriptDiv) { + throw new Error('div.content_script not found in Shadow DOM for screenshot') + } + await takeScreenshot(page, path.join(__dirname, 'screenshot.png')) +}) diff --git a/examples-v2/preact/.gitignore b/examples-v2/preact/.gitignore new file mode 100644 index 000000000..5e8c65b73 --- /dev/null +++ b/examples-v2/preact/.gitignore @@ -0,0 +1,31 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules + +# testing +coverage + +# production +dist + +# misc +.DS_Store + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# lock files +yarn.lock +package-lock.json + +# debug files +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# extension.js +extension-env.d.ts diff --git a/examples-v2/preact/background.ts b/examples-v2/preact/background.ts new file mode 100644 index 000000000..798d5018d --- /dev/null +++ b/examples-v2/preact/background.ts @@ -0,0 +1 @@ +console.log('Hello from the background script!') diff --git a/examples-v2/preact/content/ContentApp.tsx b/examples-v2/preact/content/ContentApp.tsx new file mode 100644 index 000000000..5aa042346 --- /dev/null +++ b/examples-v2/preact/content/ContentApp.tsx @@ -0,0 +1,92 @@ +import {useSignal} from '@preact/signals' + +import preactLogo from '../images/preact.png' +import tailwindBg from '../images/tailwind_bg.png' +import typescriptLogo from '../images/typescript.png' +import tailwindLogo from '../images/tailwind.png' +import chromeWindowBg from '../images/chromeWindow.png' + +export default function ContentApp() { + const isdialogOpen = useSignal(true) + + const setIsDialogOpen = (bool: boolean) => { + isdialogOpen.value = bool + } + + if (!isdialogOpen.value) { + return ( +
+ +
+ ) + } + + return ( +
+
+
+
+ + + +
+
+
+
+ Preact logo +
+
+ TypeScript logo +
+
+ Tailwind logo +
+

+ This is a content script running Preact, TypeScript, and + Tailwind.css. +

+

+ Learn more about creating cross-browser extensions by{' '} + + . +

+
+
+ Chrome window screenshot +
+
+
+ ) +} diff --git a/examples-v2/preact/content/scripts.tsx b/examples-v2/preact/content/scripts.tsx new file mode 100644 index 000000000..77cc9568b --- /dev/null +++ b/examples-v2/preact/content/scripts.tsx @@ -0,0 +1,58 @@ +import {render} from 'preact' +import ContentApp from './ContentApp' + +let unmount: () => void + +if (import.meta.webpackHot) { + import.meta.webpackHot?.accept() + import.meta.webpackHot?.dispose(() => unmount?.()) +} + +if (document.readyState === 'complete') { + unmount = initial() || (() => {}) +} else { + document.addEventListener('readystatechange', () => { + if (document.readyState === 'complete') unmount = initial() || (() => {}) + }) +} + +console.log('Hello from content script') + +function initial() { + // Create a new div element and append it to the document's body + const rootDiv = document.createElement('div') + rootDiv.id = 'extension-root' + document.body.appendChild(rootDiv) + + // Injecting content_scripts inside a shadow dom + // prevents conflicts with the host page's styles. + // This way, styles from the extension won't leak into the host page. + const shadowRoot = rootDiv.attachShadow({mode: 'open'}) + const style = new CSSStyleSheet() + shadowRoot.adoptedStyleSheets = [style] + fetchCSS().then((response) => style.replace(response)) + + if (import.meta.webpackHot) { + import.meta.webpackHot?.accept('./styles.css', () => { + fetchCSS().then((response) => style.replace(response)) + }) + } + + render( +
+ +
, + shadowRoot + ) + + return () => { + rootDiv.remove() + } +} + +async function fetchCSS() { + const cssUrl = new URL('./styles.css', import.meta.url) + const response = await fetch(cssUrl) + const text = await response.text() + return response.ok ? text : Promise.reject(text) +} diff --git a/examples-v2/preact/content/styles.css b/examples-v2/preact/content/styles.css new file mode 100644 index 000000000..c0fc3552e --- /dev/null +++ b/examples-v2/preact/content/styles.css @@ -0,0 +1,10 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +.content_script { + position: fixed; + bottom: 0; + right: 0; + z-index: 99999; +} diff --git a/examples-v2/preact/images/chromeWindow.png b/examples-v2/preact/images/chromeWindow.png new file mode 100644 index 000000000..da525dd8e Binary files /dev/null and b/examples-v2/preact/images/chromeWindow.png differ diff --git a/examples-v2/preact/images/extension_48.png b/examples-v2/preact/images/extension_48.png new file mode 100644 index 000000000..f60575b39 Binary files /dev/null and b/examples-v2/preact/images/extension_48.png differ diff --git a/examples-v2/preact/images/preact.png b/examples-v2/preact/images/preact.png new file mode 100644 index 000000000..9bd16ecc2 Binary files /dev/null and b/examples-v2/preact/images/preact.png differ diff --git a/examples-v2/preact/images/tailwind.png b/examples-v2/preact/images/tailwind.png new file mode 100644 index 000000000..83ed5e126 Binary files /dev/null and b/examples-v2/preact/images/tailwind.png differ diff --git a/examples-v2/preact/images/tailwind_bg.png b/examples-v2/preact/images/tailwind_bg.png new file mode 100644 index 000000000..edc40be8d Binary files /dev/null and b/examples-v2/preact/images/tailwind_bg.png differ diff --git a/examples-v2/preact/images/typescript.png b/examples-v2/preact/images/typescript.png new file mode 100644 index 000000000..936146940 Binary files /dev/null and b/examples-v2/preact/images/typescript.png differ diff --git a/examples-v2/preact/manifest.json b/examples-v2/preact/manifest.json new file mode 100644 index 000000000..1f912922c --- /dev/null +++ b/examples-v2/preact/manifest.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/chrome-manifest.json", + "manifest_version": 3, + "version": "0.0.1", + "name": "Preact", + "description": "An Extension.js example.", + "icons": { + "48": "images/extension_48.png" + }, + "background": { + "chromium:service_worker": "background.ts", + "firefox:scripts": ["background.ts"] + }, + "content_scripts": [ + { + "matches": ["https://extension.js.org/*"], + "js": ["./content/scripts.tsx"], + "css": ["./content/styles.css"] + } + ], + "options_page": "./options/index.html" +} diff --git a/examples-v2/preact/options/OptionsApp.tsx b/examples-v2/preact/options/OptionsApp.tsx new file mode 100644 index 000000000..34a40a075 --- /dev/null +++ b/examples-v2/preact/options/OptionsApp.tsx @@ -0,0 +1,26 @@ +import './styles.css' +import preactLogo from '../images/preact.png' + +export default function ContentApp() { + return ( +
+

+ The Preact logo +
+ Welcome to your Preact Extension. +

+

+ Learn more about creating cross-browser extensions at{' '} + + https://extension.js.org + + . +

+
+ ) +} diff --git a/examples-v2/preact/options/index.html b/examples-v2/preact/options/index.html new file mode 100644 index 000000000..0d7167e1b --- /dev/null +++ b/examples-v2/preact/options/index.html @@ -0,0 +1,13 @@ + + + + + + Preact Template + + + +
+ + + diff --git a/examples-v2/preact/options/scripts.tsx b/examples-v2/preact/options/scripts.tsx new file mode 100644 index 000000000..887dd48e7 --- /dev/null +++ b/examples-v2/preact/options/scripts.tsx @@ -0,0 +1,5 @@ +import {render} from 'preact' +import OptionsApp from './OptionsApp' +import './styles.css' + +render(, document.getElementById('root')!) diff --git a/examples-v2/preact/options/styles.css b/examples-v2/preact/options/styles.css new file mode 100644 index 000000000..7d8a01f29 --- /dev/null +++ b/examples-v2/preact/options/styles.css @@ -0,0 +1,86 @@ +html { + font-size: 62.5%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + 'Helvetica Neue', Arial, 'Noto Sans', sans-serif; +} + +body { + display: flex; + justify-content: center; + align-items: center; + height: calc(100vh - 4rem); + min-width: 300px; + padding: 2rem; + font-size: 1.8rem; + line-height: 1.618; + max-width: 38em; + margin: auto; + color: #c9c9c9; + background-color: #0A0C10; +} + +@media (max-width: 684px) { + body { + font-size: 1.53rem; + } +} + +@media (max-width: 382px) { + body { + font-size: 1.35rem; + } +} + +h1 { + line-height: 1.1; + font-weight: 700; + margin-bottom: 1.5rem; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + font-size: 4.7em; +} + +@media (max-width: 684px) { + h1 { + font-size: 2.7em; + } +} + +p { + margin-top: 0px; + margin-bottom: 2.5rem; +} + +a { + text-decoration: none; + border-bottom: 2px solid #c9c9c9; + color: #e5e7eb; +} + + +img { + height: auto; + max-width: 100%; + margin-top: 0px; + margin-bottom: 2.5rem; +} + +@media (max-width: 684px) { + img { + margin-top: 2rem; + margin-bottom: 1rem; + } +} + +body { + display: flex; + justify-content: center; + align-items: center; + height: calc(100vh - 4rem); +} + +header>div { + display: flex; + align-items: center; +} \ No newline at end of file diff --git a/examples-v2/preact/package.json b/examples-v2/preact/package.json new file mode 100644 index 000000000..9df594385 --- /dev/null +++ b/examples-v2/preact/package.json @@ -0,0 +1,23 @@ +{ + "private": true, + "name": "preact", + "description": "An Extension.js example.", + "version": "0.0.1", + "author": { + "name": "OSpoon", + "email": "zxin088@gmail.com", + "url": "https://hw404.cn" + }, + "license": "MIT", + "dependencies": { + "@preact/signals": "^1.2.3", + "preact": "^10.22.0", + "tailwindcss": "^3.4.1" + }, + "devDependencies": { + "@types/react": "^18.0.9", + "@types/react-dom": "^18.0.5", + "@prefresh/core": "^1.5.2", + "typescript": "5.3.3" + } +} diff --git a/examples-v2/preact/postcss.config.js b/examples-v2/preact/postcss.config.js new file mode 100644 index 000000000..85f717cc0 --- /dev/null +++ b/examples-v2/preact/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +} diff --git a/examples-v2/preact/public/extension.svg b/examples-v2/preact/public/extension.svg new file mode 100644 index 000000000..ebe0773a6 --- /dev/null +++ b/examples-v2/preact/public/extension.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples-v2/preact/public/logo.svg b/examples-v2/preact/public/logo.svg new file mode 100644 index 000000000..ebe0773a6 --- /dev/null +++ b/examples-v2/preact/public/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples-v2/preact/tailwind.config.js b/examples-v2/preact/tailwind.config.js new file mode 100644 index 000000000..bce59f739 --- /dev/null +++ b/examples-v2/preact/tailwind.config.js @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./content/**/*.tsx'], + theme: { + extend: {} + }, + plugins: [] +} diff --git a/examples-v2/preact/template.spec.ts b/examples-v2/preact/template.spec.ts new file mode 100644 index 000000000..374218b77 --- /dev/null +++ b/examples-v2/preact/template.spec.ts @@ -0,0 +1,84 @@ +import path from 'path' +import {execSync} from 'child_process' +import {extensionFixtures, getShadowRootElement} from '../extension-fixtures' + +const exampleDir = 'examples/content-preact' +const pathToExtension = path.join(__dirname, `dist/chrome`) +const test = extensionFixtures(pathToExtension, true) + +test.beforeAll(async () => { + execSync(`pnpm extension build ${exampleDir}`, { + cwd: path.join(__dirname, '..') + }) +}) + +test('should exist an element with the class name extension-root', async ({ + page +}) => { + await page.goto('https://extension.js.org/') + const shadowRootHandle = await page + .locator('#extension-root') + .evaluateHandle((host: HTMLElement) => host.shadowRoot) + + // Validate that the Shadow DOM exists + test.expect(shadowRootHandle).not.toBeNull() + + // Verify Shadow DOM has children + const shadowChildrenCount = await shadowRootHandle.evaluate( + (shadowRoot: ShadowRoot) => shadowRoot.children.length + ) + test.expect(shadowChildrenCount).toBeGreaterThan(0) +}) + +test('should exist an h2 element with specified content', async ({page}) => { + await page.goto('https://extension.js.org/') + const h2 = await getShadowRootElement(page, '#extension-root', 'h2') + if (!h2) { + throw new Error('h2 element not found in Shadow DOM') + } + const textContent = await h2.evaluate((node) => node.textContent) + await test + .expect(textContent) + .toContain( + 'This is a content script running Preact, TypeScript, and Tailwind.css.' + ) +}) + +test('should exist a default color value', async ({page}) => { + await page.goto('https://extension.js.org/') + const h2 = await getShadowRootElement(page, '#extension-root', 'h2') + if (!h2) { + throw new Error('h2 element not found in Shadow DOM') + } + const color = await h2.evaluate((node) => + window.getComputedStyle(node as HTMLElement).getPropertyValue('color') + ) + test.expect(color).toEqual('rgb(255, 255, 255)') +}) + +test('should load all images successfully', async ({page}) => { + await page.goto('https://extension.js.org/') + const shadowRootHandle = await page + .locator('#extension-root') + .evaluateHandle((host: HTMLElement) => host.shadowRoot) + + const imagesHandle = await shadowRootHandle.evaluateHandle( + (shadow: ShadowRoot) => Array.from(shadow.querySelectorAll('img')) + ) + + const imageHandles = await imagesHandle.getProperties() + const results: boolean[] = [] + + for (const [, imageHandle] of imageHandles) { + const naturalWidth = await imageHandle.evaluate( + (img) => (img as HTMLImageElement).naturalWidth + ) + const naturalHeight = await imageHandle.evaluate( + (img) => (img as HTMLImageElement).naturalHeight + ) + const loadedSuccessfully = naturalWidth > 0 && naturalHeight > 0 + results.push(loadedSuccessfully) + } + + test.expect(results.every((result) => result)).toBeTruthy() +}) diff --git a/examples-v2/preact/tsconfig.json b/examples-v2/preact/tsconfig.json new file mode 100644 index 000000000..3b8dbcf49 --- /dev/null +++ b/examples-v2/preact/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": false, + "jsx": "react-jsx", + "lib": ["dom", "dom.iterable", "esnext"], + "moduleResolution": "node", + "module": "esnext", + "noEmit": true, + "resolveJsonModule": true, + "strict": true, + "target": "esnext", + "skipLibCheck": true, + "baseUrl": "./", + "paths": { + "react": ["./node_modules/preact/compat/"], + "react/jsx-runtime": ["./node_modules/preact/jsx-runtime"], + "react-dom": ["./node_modules/preact/compat/"], + "react-dom/*": ["./node_modules/preact/compat/*"] + } + }, + "include": ["./"], + "exclude": ["node_modules", "dist"] +} diff --git a/examples-v2/react/.gitignore b/examples-v2/react/.gitignore new file mode 100644 index 000000000..5e8c65b73 --- /dev/null +++ b/examples-v2/react/.gitignore @@ -0,0 +1,31 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules + +# testing +coverage + +# production +dist + +# misc +.DS_Store + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# lock files +yarn.lock +package-lock.json + +# debug files +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# extension.js +extension-env.d.ts diff --git a/examples-v2/react/background.ts b/examples-v2/react/background.ts new file mode 100644 index 000000000..798d5018d --- /dev/null +++ b/examples-v2/react/background.ts @@ -0,0 +1 @@ +console.log('Hello from the background script!') diff --git a/examples-v2/react/content/ContentApp.tsx b/examples-v2/react/content/ContentApp.tsx new file mode 100644 index 000000000..245ca43ec --- /dev/null +++ b/examples-v2/react/content/ContentApp.tsx @@ -0,0 +1,86 @@ +import React from 'react' +import reactLogo from '../images/react.png' +import tailwindBg from '../images/tailwind_bg.png' +import typescriptLogo from '../images/typescript.png' +import tailwindLogo from '../images/tailwind.png' +import chromeWindowBg from '../images/chromeWindow.png' + +export default function ContentApp() { + const [isdialogOpen, setIsDialogOpen] = React.useState(true) + + if (!isdialogOpen) { + return ( +
+ +
+ ) + } + + return ( +
+
+
+
+ + + +
+
+
+
+ React logo +
+
+ TypeScript logo +
+
+ Tailwind logo +
+

+ This is a content script running React, TypeScript, and Tailwind.css +

+

+ Learn more about creating cross-browser extensions by{' '} + + . +

+
+
+ Chrome window screenshot +
+
+
+ ) +} diff --git a/examples-v2/react/content/scripts.tsx b/examples-v2/react/content/scripts.tsx new file mode 100644 index 000000000..6df7eded5 --- /dev/null +++ b/examples-v2/react/content/scripts.tsx @@ -0,0 +1,58 @@ +import ReactDOM from 'react-dom/client' +import ContentApp from './ContentApp' + +let unmount: () => void + +if (import.meta.webpackHot) { + import.meta.webpackHot?.accept() + import.meta.webpackHot?.dispose(() => unmount?.()) +} + +if (document.readyState === 'complete') { + unmount = initial() || (() => {}) +} else { + document.addEventListener('readystatechange', () => { + if (document.readyState === 'complete') unmount = initial() || (() => {}) + }) +} + +console.log('Hello from content script') + +function initial() { + // Create a new div element and append it to the document's body + const rootDiv = document.createElement('div') + rootDiv.id = 'extension-root' + document.body.appendChild(rootDiv) + + // Injecting content_scripts inside a shadow dom + // prevents conflicts with the host page's styles. + // This way, styles from the extension won't leak into the host page. + const shadowRoot = rootDiv.attachShadow({mode: 'open'}) + const style = new CSSStyleSheet() + shadowRoot.adoptedStyleSheets = [style] + fetchCSS().then((response) => style.replace(response)) + + if (import.meta.webpackHot) { + import.meta.webpackHot?.accept('./styles.css', () => { + fetchCSS().then((response) => style.replace(response)) + }) + } + + const mountingPoint = ReactDOM.createRoot(shadowRoot) + mountingPoint.render( +
+ +
+ ) + return () => { + mountingPoint.unmount() + rootDiv.remove() + } +} + +async function fetchCSS() { + const cssUrl = new URL('./styles.css', import.meta.url) + const response = await fetch(cssUrl) + const text = await response.text() + return response.ok ? text : Promise.reject(text) +} diff --git a/examples-v2/react/content/styles.css b/examples-v2/react/content/styles.css new file mode 100644 index 000000000..c0fc3552e --- /dev/null +++ b/examples-v2/react/content/styles.css @@ -0,0 +1,10 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +.content_script { + position: fixed; + bottom: 0; + right: 0; + z-index: 99999; +} diff --git a/examples-v2/react/images/chromeWindow.png b/examples-v2/react/images/chromeWindow.png new file mode 100644 index 000000000..da525dd8e Binary files /dev/null and b/examples-v2/react/images/chromeWindow.png differ diff --git a/examples-v2/react/images/extension_48.png b/examples-v2/react/images/extension_48.png new file mode 100644 index 000000000..f60575b39 Binary files /dev/null and b/examples-v2/react/images/extension_48.png differ diff --git a/examples-v2/react/images/react.png b/examples-v2/react/images/react.png new file mode 100644 index 000000000..9080fddd7 Binary files /dev/null and b/examples-v2/react/images/react.png differ diff --git a/examples-v2/react/images/tailwind.png b/examples-v2/react/images/tailwind.png new file mode 100644 index 000000000..83ed5e126 Binary files /dev/null and b/examples-v2/react/images/tailwind.png differ diff --git a/examples-v2/react/images/tailwind_bg.png b/examples-v2/react/images/tailwind_bg.png new file mode 100644 index 000000000..edc40be8d Binary files /dev/null and b/examples-v2/react/images/tailwind_bg.png differ diff --git a/examples-v2/react/images/typescript.png b/examples-v2/react/images/typescript.png new file mode 100644 index 000000000..936146940 Binary files /dev/null and b/examples-v2/react/images/typescript.png differ diff --git a/examples-v2/react/manifest.json b/examples-v2/react/manifest.json new file mode 100644 index 000000000..88868db90 --- /dev/null +++ b/examples-v2/react/manifest.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/chrome-manifest.json", + "manifest_version": 3, + "version": "0.0.1", + "name": "React Extension", + "description": "An Extension.js example.", + "icons": { + "48": "images/extension_48.png" + }, + "background": { + "chromium:service_worker": "background.ts", + "firefox:scripts": ["background.ts"] + }, + "content_scripts": [ + { + "matches": [""], + "js": ["./content/scripts.tsx"] + } + ], + "options_page": "options/index.html" +} diff --git a/examples-v2/react/options/OptionsApp.tsx b/examples-v2/react/options/OptionsApp.tsx new file mode 100644 index 000000000..9b087ca89 --- /dev/null +++ b/examples-v2/react/options/OptionsApp.tsx @@ -0,0 +1,26 @@ +import './styles.css' +import reactLogo from '../images/react.png' + +export default function NewTabApp() { + return ( +
+

+ The React logo +
+ Welcome to your React Extension. +

+

+ Learn more about creating cross-browser extensions at{' '} + + https://extension.js.org + + . +

+
+ ) +} diff --git a/examples-v2/react/options/index.html b/examples-v2/react/options/index.html new file mode 100644 index 000000000..9a42fa22b --- /dev/null +++ b/examples-v2/react/options/index.html @@ -0,0 +1,13 @@ + + + + + + React Template + + + +
+ + + diff --git a/examples-v2/react/options/scripts.tsx b/examples-v2/react/options/scripts.tsx new file mode 100644 index 000000000..4fa4cdf9c --- /dev/null +++ b/examples-v2/react/options/scripts.tsx @@ -0,0 +1,12 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import OptionsApp from './OptionsApp' +import './styles.css' + +const root = ReactDOM.createRoot(document.getElementById('root')!) + +root.render( + + + +) diff --git a/examples-v2/react/options/styles.css b/examples-v2/react/options/styles.css new file mode 100644 index 000000000..7d8a01f29 --- /dev/null +++ b/examples-v2/react/options/styles.css @@ -0,0 +1,86 @@ +html { + font-size: 62.5%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + 'Helvetica Neue', Arial, 'Noto Sans', sans-serif; +} + +body { + display: flex; + justify-content: center; + align-items: center; + height: calc(100vh - 4rem); + min-width: 300px; + padding: 2rem; + font-size: 1.8rem; + line-height: 1.618; + max-width: 38em; + margin: auto; + color: #c9c9c9; + background-color: #0A0C10; +} + +@media (max-width: 684px) { + body { + font-size: 1.53rem; + } +} + +@media (max-width: 382px) { + body { + font-size: 1.35rem; + } +} + +h1 { + line-height: 1.1; + font-weight: 700; + margin-bottom: 1.5rem; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + font-size: 4.7em; +} + +@media (max-width: 684px) { + h1 { + font-size: 2.7em; + } +} + +p { + margin-top: 0px; + margin-bottom: 2.5rem; +} + +a { + text-decoration: none; + border-bottom: 2px solid #c9c9c9; + color: #e5e7eb; +} + + +img { + height: auto; + max-width: 100%; + margin-top: 0px; + margin-bottom: 2.5rem; +} + +@media (max-width: 684px) { + img { + margin-top: 2rem; + margin-bottom: 1rem; + } +} + +body { + display: flex; + justify-content: center; + align-items: center; + height: calc(100vh - 4rem); +} + +header>div { + display: flex; + align-items: center; +} \ No newline at end of file diff --git a/examples-v2/react/package.json b/examples-v2/react/package.json new file mode 100644 index 000000000..e1da718fd --- /dev/null +++ b/examples-v2/react/package.json @@ -0,0 +1,22 @@ +{ + "private": true, + "name": "content-react", + "description": "An Extension.js example.", + "version": "0.0.1", + "author": { + "name": "Cezar Augusto", + "email": "boss@cezaraugusto.net", + "url": "https://cezaraugusto.com" + }, + "license": "MIT", + "dependencies": { + "react": "^18.1.0", + "react-dom": "^18.1.0", + "tailwindcss": "^3.4.1" + }, + "devDependencies": { + "@types/react": "^18.0.9", + "@types/react-dom": "^18.0.5", + "typescript": "5.3.3" + } +} diff --git a/examples-v2/react/postcss.config.js b/examples-v2/react/postcss.config.js new file mode 100644 index 000000000..85f717cc0 --- /dev/null +++ b/examples-v2/react/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +} diff --git a/examples-v2/react/public/extension.svg b/examples-v2/react/public/extension.svg new file mode 100644 index 000000000..ebe0773a6 --- /dev/null +++ b/examples-v2/react/public/extension.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples-v2/react/public/logo.svg b/examples-v2/react/public/logo.svg new file mode 100644 index 000000000..ebe0773a6 --- /dev/null +++ b/examples-v2/react/public/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples-v2/react/tailwind.config.js b/examples-v2/react/tailwind.config.js new file mode 100644 index 000000000..bce59f739 --- /dev/null +++ b/examples-v2/react/tailwind.config.js @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./content/**/*.tsx'], + theme: { + extend: {} + }, + plugins: [] +} diff --git a/examples-v2/react/template.spec.ts b/examples-v2/react/template.spec.ts new file mode 100644 index 000000000..c7313630c --- /dev/null +++ b/examples-v2/react/template.spec.ts @@ -0,0 +1,80 @@ +import path from 'path' +import {execSync} from 'child_process' +import {extensionFixtures, getShadowRootElement} from '../extension-fixtures' + +const exampleDir = 'examples-v2/react' +const pathToExtension = path.join(__dirname, `dist/chrome`) +const test = extensionFixtures(pathToExtension, true) + +test.beforeAll(async () => { + execSync(`pnpm extension build ${exampleDir}`, { + cwd: path.join(__dirname, '..') + }) +}) + +test('should exist an element with the id extension-root', async ({page}) => { + await page.goto('https://extension.js.org/') + const shadowRootHandle = await page + .locator('#extension-root') + .evaluateHandle((host: HTMLElement) => host.shadowRoot) + + // Check that the Shadow DOM exists + test.expect(shadowRootHandle).not.toBeNull() + + // Verify if the Shadow DOM contains children + const shadowChildrenCount = await shadowRootHandle.evaluate( + (shadowRoot: ShadowRoot) => shadowRoot.children.length + ) + test.expect(shadowChildrenCount).toBeGreaterThan(0) +}) + +test('should exist an h2 element with specified content', async ({page}) => { + await page.goto('https://extension.js.org/') + const h2 = await getShadowRootElement(page, '#extension-root', 'h2') + if (!h2) { + throw new Error('h2 element not found in Shadow DOM') + } + + const textContent = await h2.evaluate((node) => node.textContent) + test.expect(textContent).toContain('This is a content script') +}) + +test('should exist a default color value', async ({page}) => { + await page.goto('https://extension.js.org/') + const h2 = await getShadowRootElement(page, '#extension-root', 'h2') + if (!h2) { + throw new Error('h2 element not found in Shadow DOM') + } + + const color = await h2.evaluate((node) => + window.getComputedStyle(node as HTMLElement).getPropertyValue('color') + ) + test.expect(color).toEqual('rgb(255, 255, 255)') +}) + +test('should load all images successfully', async ({page}) => { + await page.goto('https://extension.js.org/') + const shadowRoot = await page + .locator('#extension-root') + .evaluateHandle((host: HTMLElement) => host.shadowRoot) + + const imagesHandle = await shadowRoot.evaluateHandle((shadow: ShadowRoot) => + Array.from(shadow.querySelectorAll('img')) + ) + + const imageHandles = await imagesHandle.getProperties() + const results: boolean[] = [] + + for (const [, imageHandle] of imageHandles) { + const naturalWidth = await imageHandle.evaluate( + (img) => (img as HTMLImageElement).naturalWidth + ) + const naturalHeight = await imageHandle.evaluate( + (img) => (img as HTMLImageElement).naturalHeight + ) + const loadedSuccessfully = naturalWidth > 0 && naturalHeight > 0 + results.push(loadedSuccessfully) + } + + test.expect(results.every((result) => result)).toBeTruthy() +}) diff --git a/examples-v2/react/tsconfig.json b/examples-v2/react/tsconfig.json new file mode 100644 index 000000000..071ecf89f --- /dev/null +++ b/examples-v2/react/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "jsx": "react-jsx", + "lib": ["dom", "dom.iterable", "esnext"], + "moduleResolution": "node", + "module": "esnext", + "noEmit": true, + "resolveJsonModule": true, + "strict": true, + "target": "esnext", + "verbatimModuleSyntax": true, + "useDefineForClassFields": true, + "skipLibCheck": true + }, + "include": ["./", "extension-env.d.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/examples-v2/svelte/.gitignore b/examples-v2/svelte/.gitignore new file mode 100644 index 000000000..5e8c65b73 --- /dev/null +++ b/examples-v2/svelte/.gitignore @@ -0,0 +1,31 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules + +# testing +coverage + +# production +dist + +# misc +.DS_Store + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# lock files +yarn.lock +package-lock.json + +# debug files +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# extension.js +extension-env.d.ts diff --git a/examples-v2/svelte/background.ts b/examples-v2/svelte/background.ts new file mode 100644 index 000000000..798d5018d --- /dev/null +++ b/examples-v2/svelte/background.ts @@ -0,0 +1 @@ +console.log('Hello from the background script!') diff --git a/examples-v2/svelte/content/ContentApp.svelte b/examples-v2/svelte/content/ContentApp.svelte new file mode 100644 index 000000000..62ea4eecd --- /dev/null +++ b/examples-v2/svelte/content/ContentApp.svelte @@ -0,0 +1,84 @@ + + +{#if !isDialogOpen} +
+ +
+{:else} +
+
+
+
+ + + +
+
+
+
+ React logo +
+
+ TypeScript logo +
+
+ Tailwind logo +
+

+ This is a content script running Svelte, TypeScript, and Tailwind.css +

+

+ Learn more about creating cross-browser extensions by + + . +

+
+
+ Chrome window screenshot +
+
+
+{/if} diff --git a/examples-v2/svelte/content/scripts.ts b/examples-v2/svelte/content/scripts.ts new file mode 100644 index 000000000..c17da229d --- /dev/null +++ b/examples-v2/svelte/content/scripts.ts @@ -0,0 +1,60 @@ +import {mount} from 'svelte' +import ContentApp from './ContentApp.svelte' + +let unmount: () => void + +if (import.meta.webpackHot) { + import.meta.webpackHot?.accept() + import.meta.webpackHot?.dispose(() => unmount?.()) +} + +if (document.readyState === 'complete') { + unmount = initial() || (() => {}) +} else { + document.addEventListener('readystatechange', () => { + if (document.readyState === 'complete') unmount = initial() || (() => {}) + }) +} + +console.log('Hello from content script') + +function initial() { + const rootDiv = document.createElement('div') + rootDiv.id = 'extension-root' + document.body.appendChild(rootDiv) + + // Injecting content_scripts inside a shadow dom + // prevents conflicts with the host page's styles. + // This way, styles from the extension won't leak into the host page. + const shadowRoot = rootDiv.attachShadow({mode: 'open'}) + const style = new CSSStyleSheet() + shadowRoot.adoptedStyleSheets = [style] + fetchCSS().then((response) => style.replace(response)) + + if (import.meta.webpackHot) { + import.meta.webpackHot?.accept('./styles.css', () => { + fetchCSS().then((response) => style.replace(response)) + }) + } + + // Create container for Svelte app + const contentDiv = document.createElement('div') + contentDiv.className = 'content_script' + shadowRoot.appendChild(contentDiv) + + // Mount Svelte app using Svelte 5's mount function + mount(ContentApp, { + target: contentDiv + }) + + return () => { + rootDiv.remove() + } +} + +async function fetchCSS() { + const cssUrl = new URL('./styles.css', import.meta.url) + const response = await fetch(cssUrl) + const text = await response.text() + return response.ok ? text : Promise.reject(text) +} diff --git a/examples-v2/svelte/content/styles.css b/examples-v2/svelte/content/styles.css new file mode 100644 index 000000000..c0fc3552e --- /dev/null +++ b/examples-v2/svelte/content/styles.css @@ -0,0 +1,10 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +.content_script { + position: fixed; + bottom: 0; + right: 0; + z-index: 99999; +} diff --git a/examples-v2/svelte/content/svelte.d.ts b/examples-v2/svelte/content/svelte.d.ts new file mode 100644 index 000000000..e54bc1b62 --- /dev/null +++ b/examples-v2/svelte/content/svelte.d.ts @@ -0,0 +1,5 @@ +declare module '*.svelte' { + import type {ComponentType} from 'svelte' + const component: ComponentType + export default component +} diff --git a/examples-v2/svelte/images/chromeWindow.png b/examples-v2/svelte/images/chromeWindow.png new file mode 100644 index 000000000..da525dd8e Binary files /dev/null and b/examples-v2/svelte/images/chromeWindow.png differ diff --git a/examples-v2/svelte/images/extension_48.png b/examples-v2/svelte/images/extension_48.png new file mode 100644 index 000000000..f60575b39 Binary files /dev/null and b/examples-v2/svelte/images/extension_48.png differ diff --git a/examples-v2/svelte/images/svelte.png b/examples-v2/svelte/images/svelte.png new file mode 100644 index 000000000..a520d188d Binary files /dev/null and b/examples-v2/svelte/images/svelte.png differ diff --git a/examples-v2/svelte/images/tailwind.png b/examples-v2/svelte/images/tailwind.png new file mode 100644 index 000000000..83ed5e126 Binary files /dev/null and b/examples-v2/svelte/images/tailwind.png differ diff --git a/examples-v2/svelte/images/tailwind_bg.png b/examples-v2/svelte/images/tailwind_bg.png new file mode 100644 index 000000000..edc40be8d Binary files /dev/null and b/examples-v2/svelte/images/tailwind_bg.png differ diff --git a/examples-v2/svelte/images/typescript.png b/examples-v2/svelte/images/typescript.png new file mode 100644 index 000000000..936146940 Binary files /dev/null and b/examples-v2/svelte/images/typescript.png differ diff --git a/examples-v2/svelte/manifest.json b/examples-v2/svelte/manifest.json new file mode 100644 index 000000000..da5fa07f2 --- /dev/null +++ b/examples-v2/svelte/manifest.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/chrome-manifest.json", + "manifest_version": 3, + "version": "0.0.1", + "name": "Svelte", + "description": "An Extension.js example.", + "icons": { + "48": "images/extension_48.png" + }, + "background": { + "chromium:service_worker": "background.ts", + "firefox:scripts": ["background.ts"] + }, + "content_scripts": [ + { + "matches": [""], + "js": ["./content/scripts.ts"] + } + ], + "options_page": "options/index.html" +} diff --git a/examples-v2/svelte/options/OptionsApp.svelte b/examples-v2/svelte/options/OptionsApp.svelte new file mode 100644 index 000000000..57f69b7f3 --- /dev/null +++ b/examples-v2/svelte/options/OptionsApp.svelte @@ -0,0 +1,17 @@ + + +
+

+ The Svelte logo +
+ {message} +

+

+ Learn more about creating browser extensions at + + https://extension.js.org + . +

+
diff --git a/examples-v2/svelte/options/index.html b/examples-v2/svelte/options/index.html new file mode 100644 index 000000000..fc4690bd1 --- /dev/null +++ b/examples-v2/svelte/options/index.html @@ -0,0 +1,13 @@ + + + + + + Svelte Template + + + +
+ + + diff --git a/examples-v2/svelte/options/scripts.ts b/examples-v2/svelte/options/scripts.ts new file mode 100644 index 000000000..467880cb0 --- /dev/null +++ b/examples-v2/svelte/options/scripts.ts @@ -0,0 +1,10 @@ +import * as svelte from 'svelte' +import './styles.css' +import App from './OptionsApp.svelte' + +const container = document.getElementById('app') +const app = svelte.mount(App, { + target: container as HTMLElement +}) + +export default app diff --git a/examples-v2/svelte/options/styles.css b/examples-v2/svelte/options/styles.css new file mode 100644 index 000000000..27482048a --- /dev/null +++ b/examples-v2/svelte/options/styles.css @@ -0,0 +1,85 @@ +html { + font-size: 62.5%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + 'Helvetica Neue', Arial, 'Noto Sans', sans-serif; +} + +body { + display: flex; + justify-content: center; + align-items: center; + height: calc(100vh - 4rem); + min-width: 300px; + padding: 2rem; + font-size: 1.8rem; + line-height: 1.618; + max-width: 38em; + margin: auto; + color: #c9c9c9; + background-color: #0a0c10; +} + +@media (max-width: 684px) { + body { + font-size: 1.53rem; + } +} + +@media (max-width: 382px) { + body { + font-size: 1.35rem; + } +} + +h1 { + line-height: 1.1; + font-weight: 700; + margin-bottom: 1.5rem; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + font-size: 4.7em; +} + +@media (max-width: 684px) { + h1 { + font-size: 2.7em; + } +} + +p { + margin-top: 0px; + margin-bottom: 2.5rem; +} + +a { + text-decoration: none; + border-bottom: 2px solid #c9c9c9; + color: #e5e7eb; +} + +img { + height: auto; + max-width: 100%; + margin-top: 0px; + margin-bottom: 2.5rem; +} + +@media (max-width: 684px) { + img { + margin-top: 2rem; + margin-bottom: 1rem; + } +} + +body { + display: flex; + justify-content: center; + align-items: center; + height: calc(100vh - 4rem); +} + +header > div { + display: flex; + align-items: center; +} diff --git a/examples-v2/svelte/package.json b/examples-v2/svelte/package.json new file mode 100644 index 000000000..e808b873d --- /dev/null +++ b/examples-v2/svelte/package.json @@ -0,0 +1,20 @@ +{ + "private": true, + "name": "svelte", + "description": "An Extension.js example.", + "version": "0.0.1", + "author": { + "name": "Cezar Augusto", + "email": "boss@cezaraugusto.net", + "url": "https://cezaraugusto.com" + }, + "license": "MIT", + "dependencies": { + "svelte": "5.15.0", + "tailwindcss": "^3.4.1" + }, + "devDependencies": { + "@tsconfig/svelte": "5.0.4", + "typescript": "5.3.3" + } +} diff --git a/examples-v2/svelte/postcss.config.js b/examples-v2/svelte/postcss.config.js new file mode 100644 index 000000000..85f717cc0 --- /dev/null +++ b/examples-v2/svelte/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +} diff --git a/examples-v2/svelte/public/extension.svg b/examples-v2/svelte/public/extension.svg new file mode 100644 index 000000000..ebe0773a6 --- /dev/null +++ b/examples-v2/svelte/public/extension.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples-v2/svelte/public/logo.png b/examples-v2/svelte/public/logo.png new file mode 100644 index 000000000..a520d188d Binary files /dev/null and b/examples-v2/svelte/public/logo.png differ diff --git a/examples-v2/svelte/tailwind.config.js b/examples-v2/svelte/tailwind.config.js new file mode 100644 index 000000000..b8a301e04 --- /dev/null +++ b/examples-v2/svelte/tailwind.config.js @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['./content/**/*.svelte', './content/**/*.ts'], + theme: { + extend: {} + }, + plugins: [] +} diff --git a/examples-v2/svelte/template.spec.ts b/examples-v2/svelte/template.spec.ts new file mode 100644 index 000000000..f94746e93 --- /dev/null +++ b/examples-v2/svelte/template.spec.ts @@ -0,0 +1,80 @@ +import path from 'path' +import {execSync} from 'child_process' +import {extensionFixtures, getShadowRootElement} from '../extension-fixtures' + +const exampleDir = 'examples-v2/svelte' +const pathToExtension = path.join(__dirname, `dist/chrome`) +const test = extensionFixtures(pathToExtension, true) + +test.beforeAll(async () => { + execSync(`pnpm extension build ${exampleDir}`, { + cwd: path.join(__dirname, '..') + }) +}) + +test('should exist an element with the id extension-root', async ({page}) => { + await page.goto('https://extension.js.org/') + const shadowRootHandle = await page + .locator('#extension-root') + .evaluateHandle((host: HTMLElement) => host.shadowRoot) + + // Check that the Shadow DOM exists + test.expect(shadowRootHandle).not.toBeNull() + + // Verify if the Shadow DOM contains children + const shadowChildrenCount = await shadowRootHandle.evaluate( + (shadowRoot: ShadowRoot) => shadowRoot.children.length + ) + test.expect(shadowChildrenCount).toBeGreaterThan(0) +}) + +test('should exist an h2 element with specified content', async ({page}) => { + await page.goto('https://extension.js.org/') + const h2 = await getShadowRootElement(page, '#extension-root', 'h2') + if (!h2) { + throw new Error('h2 element not found in Shadow DOM') + } + + const textContent = await h2.evaluate((node) => node.textContent) + test.expect(textContent).toContain('This is a content script') +}) + +test('should exist a default color value', async ({page}) => { + await page.goto('https://extension.js.org/') + const h2 = await getShadowRootElement(page, '#extension-root', 'h2') + if (!h2) { + throw new Error('h2 element not found in Shadow DOM') + } + + const color = await h2.evaluate((node) => + window.getComputedStyle(node as HTMLElement).getPropertyValue('color') + ) + test.expect(color).toEqual('rgb(255, 255, 255)') +}) + +test('should load all images successfully', async ({page}) => { + await page.goto('https://extension.js.org/') + const shadowRoot = await page + .locator('#extension-root') + .evaluateHandle((host: HTMLElement) => host.shadowRoot) + + const imagesHandle = await shadowRoot.evaluateHandle((shadow: ShadowRoot) => + Array.from(shadow.querySelectorAll('img')) + ) + + const imageHandles = await imagesHandle.getProperties() + const results: boolean[] = [] + + for (const [, imageHandle] of imageHandles) { + const naturalWidth = await imageHandle.evaluate( + (img) => (img as HTMLImageElement).naturalWidth + ) + const naturalHeight = await imageHandle.evaluate( + (img) => (img as HTMLImageElement).naturalHeight + ) + const loadedSuccessfully = naturalWidth > 0 && naturalHeight > 0 + results.push(loadedSuccessfully) + } + + test.expect(results.every((result) => result)).toBeTruthy() +}) diff --git a/examples-v2/svelte/tsconfig.json b/examples-v2/svelte/tsconfig.json new file mode 100644 index 000000000..415f65637 --- /dev/null +++ b/examples-v2/svelte/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "@tsconfig/svelte/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "jsx": "react-jsx", + "lib": ["dom", "dom.iterable", "esnext"], + "moduleResolution": "node", + "module": "esnext", + "noEmit": true, + "resolveJsonModule": true, + "strict": true, + "target": "esnext", + "verbatimModuleSyntax": true, + "useDefineForClassFields": true, + "skipLibCheck": true + }, + "include": ["./", "extension-env.d.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/examples-v2/types.ts b/examples-v2/types.ts new file mode 100644 index 000000000..93da3b244 --- /dev/null +++ b/examples-v2/types.ts @@ -0,0 +1,20 @@ +export type UIContext = 'sidebar' | 'newTab' | 'content' | 'action' | 'devTools' +export type ConfigFiles = + | 'postcss.config.js' + | 'tailwind.config.js' + | 'tsconfig.json' + | '.stylelintrc.json' + | 'extension.config.js' + | 'babel.config.json' + | '.prettierrc' + | 'eslint.config.mjs' + +export interface Template { + name: string + uiContext: UIContext[] | undefined + uiFramework: 'react' | 'preact' | 'vue' | 'svelte' | undefined + css: 'css' | 'sass' | 'less' | 'stylus' + hasBackground: boolean + hasEnv: boolean + configFiles: ConfigFiles[] | undefined +} diff --git a/examples-v2/typescript/.gitignore b/examples-v2/typescript/.gitignore new file mode 100644 index 000000000..5e8c65b73 --- /dev/null +++ b/examples-v2/typescript/.gitignore @@ -0,0 +1,31 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules + +# testing +coverage + +# production +dist + +# misc +.DS_Store + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# lock files +yarn.lock +package-lock.json + +# debug files +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# extension.js +extension-env.d.ts diff --git a/examples-v2/typescript/background.ts b/examples-v2/typescript/background.ts new file mode 100644 index 000000000..bcc3c6536 --- /dev/null +++ b/examples-v2/typescript/background.ts @@ -0,0 +1 @@ +console.log('Hello from the background!') diff --git a/examples-v2/typescript/content/scripts.ts b/examples-v2/typescript/content/scripts.ts new file mode 100644 index 000000000..406fcaa56 --- /dev/null +++ b/examples-v2/typescript/content/scripts.ts @@ -0,0 +1,91 @@ +// @ts-expect-error - Import handled by webpack +import logo from '../images/logo.svg' + +declare global { + interface ImportMeta { + webpackHot?: { + accept: (path?: string, callback?: (newModule: any) => void) => void + dispose: (callback: () => void) => void + } + } +} + +let unmount: () => void + +if (import.meta.webpackHot) { + import.meta.webpackHot?.accept() + import.meta.webpackHot?.dispose(() => unmount?.()) +} + +console.log('hello from content_scripts') + +if (document.readyState === 'complete') { + unmount = initial() || (() => {}) +} else { + document.addEventListener('readystatechange', () => { + if (document.readyState === 'complete') unmount = initial() || (() => {}) + }) +} + +function initial() { + const rootDiv = document.createElement('div') + rootDiv.id = 'extension-root' + document.body.appendChild(rootDiv) + + // Injecting content_scripts inside a shadow dom + // prevents conflicts with the host page's styles. + // This way, styles from the extension won't leak into the host page. + const shadowRoot = rootDiv.attachShadow({mode: 'open'}) + const style = new CSSStyleSheet() + shadowRoot.adoptedStyleSheets = [style] + fetchCSS().then((response) => style.replace(response)) + + if (import.meta.webpackHot) { + import.meta.webpackHot?.accept('./styles.css', () => { + fetchCSS().then((response) => style.replace(response)) + }) + } + + // Create container div + const contentDiv = document.createElement('div') + contentDiv.className = 'content_script' + + // Create and append logo image + const img = document.createElement('img') + img.className = 'content_logo' + img.src = logo + contentDiv.appendChild(img) + + // Create and append title + const title = document.createElement('h1') + title.className = 'content_title' + title.textContent = 'Welcome to your TypeScript Extension' + contentDiv.appendChild(title) + + // Create and append description paragraph + const desc = document.createElement('p') + desc.className = 'content_description' + desc.innerHTML = 'Learn more about creating cross-browser extensions at ' + + const link = document.createElement('a') + link.href = 'https://extension.js.org' + link.target = '_blank' + link.textContent = 'https://extension.js.org' + + desc.appendChild(link) + contentDiv.appendChild(desc) + + // Append the content div to shadow root + shadowRoot.appendChild(contentDiv) + + return () => { + rootDiv.remove() + } +} + +async function fetchCSS() { + const cssUrl = new URL('./styles.css', import.meta.url) + const response = await fetch(cssUrl) + const text = await response.text() + return response.ok ? text : Promise.reject(text) +} diff --git a/examples-v2/typescript/content/styles.css b/examples-v2/typescript/content/styles.css new file mode 100644 index 000000000..1f69e11c2 --- /dev/null +++ b/examples-v2/typescript/content/styles.css @@ -0,0 +1,40 @@ +.content_script { + color: #c9c9c9; + background-color: #0a0c10; + position: fixed; + right: 0; + bottom: 0; + z-index: 9; + width: 315px; + margin: 1rem; + padding: 2rem 1rem; + display: flex; + flex-direction: column; + gap: 1em; + border-radius: 6px; +} + +.content_logo { + width: 72px; +} + +.content_title { + font-size: 1.85em; + line-height: 1.1; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + 'Helvetica Neue', Arial, 'Noto Sans', sans-serif; + font-weight: 700; + margin: 0; +} + +.content_description { + font-size: small; + margin: 0; +} + +.content_description a { + text-decoration: none; + border-bottom: 2px solid #c9c9c9; + color: #e5e7eb; + margin: 0; +} diff --git a/examples-v2/typescript/images/extension_48.png b/examples-v2/typescript/images/extension_48.png new file mode 100644 index 000000000..f60575b39 Binary files /dev/null and b/examples-v2/typescript/images/extension_48.png differ diff --git a/examples-v2/typescript/images/logo.svg b/examples-v2/typescript/images/logo.svg new file mode 100644 index 000000000..7fe14ba46 --- /dev/null +++ b/examples-v2/typescript/images/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples-v2/typescript/manifest.json b/examples-v2/typescript/manifest.json new file mode 100644 index 000000000..f6ecdf45c --- /dev/null +++ b/examples-v2/typescript/manifest.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/chrome-manifest.json", + "manifest_version": 3, + "version": "0.0.1", + "name": "Typescript", + "description": "An Extension.js example.", + "icons": { + "48": "images/extension_48.png" + }, + "permissions": ["activeTab", "scripting"], + "host_permissions": [""], + "background": { + "chromium:service_worker": "background.ts", + "firefox:scripts": ["background.ts"] + }, + "content_scripts": [ + { + "matches": [""], + "js": ["content/scripts.ts"] + } + ], + "options_page": "./options/index.html" +} diff --git a/examples-v2/typescript/options/index.html b/examples-v2/typescript/options/index.html new file mode 100644 index 000000000..772d8272d --- /dev/null +++ b/examples-v2/typescript/options/index.html @@ -0,0 +1,23 @@ + + + + + + TypeScript Extension + + + +
+

+ The TypeScript logo +
+ Welcome to your TypeScript Extension. +

+

+ Learn more about creating cross-browser extensions at + https://extension.js.org. +

+
+ + + diff --git a/examples-v2/typescript/options/scripts.ts b/examples-v2/typescript/options/scripts.ts new file mode 100644 index 000000000..7fcefeb34 --- /dev/null +++ b/examples-v2/typescript/options/scripts.ts @@ -0,0 +1 @@ +console.log('Hello from the new tab page!') diff --git a/examples-v2/typescript/options/styles.css b/examples-v2/typescript/options/styles.css new file mode 100644 index 000000000..24fc09f2d --- /dev/null +++ b/examples-v2/typescript/options/styles.css @@ -0,0 +1,86 @@ +html { + font-size: 62.5%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + 'Helvetica Neue', Arial, 'Noto Sans', sans-serif; +} + +body { + display: flex; + justify-content: center; + align-items: center; + height: calc(100vh - 4rem); + min-width: 300px; + padding: 2rem; + font-size: 1.8rem; + line-height: 1.618; + max-width: 38em; + margin: auto; + color: #c9c9c9; + background-color: #0A0C10; +} + +@media (max-width: 684px) { + body { + font-size: 1.53rem; + } +} + +@media (max-width: 382px) { + body { + font-size: 1.35rem; + } +} + +h1 { + line-height: 1.1; + font-weight: 700; + margin-bottom: 1.5rem; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + font-size: 3.8em; +} + +@media (max-width: 684px) { + h1 { + font-size: 2.7em; + } +} + +p { + margin-top: 0px; + margin-bottom: 2.5rem; +} + +a { + text-decoration: none; + border-bottom: 2px solid #c9c9c9; + color: #e5e7eb; +} + + +img { + height: auto; + max-width: 100%; + margin-top: 0px; + margin-bottom: 2.5rem; +} + +@media (max-width: 684px) { + img { + margin-top: 2rem; + margin-bottom: 1rem; + } +} + +body { + display: flex; + justify-content: center; + align-items: center; + height: calc(100vh - 4rem); +} + +header > div { + display: flex; + align-items: center; +} \ No newline at end of file diff --git a/examples-v2/typescript/package.json b/examples-v2/typescript/package.json new file mode 100644 index 000000000..2a2b4733e --- /dev/null +++ b/examples-v2/typescript/package.json @@ -0,0 +1,14 @@ +{ + "private": true, + "name": "typescript", + "description": "An Extension.js example.", + "version": "0.0.1", + "author": { + "name": "Cezar Augusto", + "email": "boss@cezaraugusto.net", + "url": "https://cezaraugusto.com" + }, + "devDependencies": { + "typescript": "5.3.3" + } +} diff --git a/examples-v2/typescript/public/logo.svg b/examples-v2/typescript/public/logo.svg new file mode 100644 index 000000000..7fe14ba46 --- /dev/null +++ b/examples-v2/typescript/public/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples-v2/typescript/screenshot.png b/examples-v2/typescript/screenshot.png new file mode 100644 index 000000000..647df1e95 Binary files /dev/null and b/examples-v2/typescript/screenshot.png differ diff --git a/examples-v2/typescript/template.spec.ts b/examples-v2/typescript/template.spec.ts new file mode 100644 index 000000000..0b5be25c0 --- /dev/null +++ b/examples-v2/typescript/template.spec.ts @@ -0,0 +1,75 @@ +import path from 'path' +import {execSync} from 'child_process' +import { + extensionFixtures, + getShadowRootElement, + takeScreenshot +} from '../extension-fixtures' + +const exampleDir = 'examples/content-typescript' +const pathToExtension = path.join(__dirname, `dist/chrome`) +const test = extensionFixtures(pathToExtension, true) + +test.beforeAll(async () => { + execSync(`pnpm extension build ${exampleDir}`, { + cwd: path.join(__dirname, '..') + }) +}) + +test('should exist an element with the class name content_script', async ({ + page +}) => { + await page.goto('https://extension.js.org/') + const div = await getShadowRootElement( + page, + '#extension-root', + 'div.content_script' + ) + if (!div) { + throw new Error('div with class content_script not found in Shadow DOM') + } + test.expect(div).not.toBeNull() +}) + +test('should exist an h1 element with specified content', async ({page}) => { + await page.goto('https://extension.js.org/') + const h1 = await getShadowRootElement( + page, + '#extension-root', + 'div.content_script > h1' + ) + if (!h1) { + throw new Error('h1 element not found in Shadow DOM') + } + const textContent = await h1.evaluate((node) => node.textContent) + test.expect(textContent).toContain('Welcome to your') +}) + +test('should exist a default color value', async ({page}) => { + await page.goto('https://extension.js.org/') + const h1 = await getShadowRootElement( + page, + '#extension-root', + 'div.content_script > h1' + ) + if (!h1) { + throw new Error('h1 element not found in Shadow DOM') + } + const color = await h1.evaluate((node) => + window.getComputedStyle(node as HTMLElement).getPropertyValue('color') + ) + test.expect(color).toEqual('rgb(201, 201, 201)') +}) + +test.skip('takes a screenshot of the page', async ({page}) => { + await page.goto('https://extension.js.org/') + const contentScriptDiv = await getShadowRootElement( + page, + '#extension-root', + 'div.content_script' + ) + if (!contentScriptDiv) { + throw new Error('div.content_script not found in Shadow DOM for screenshot') + } + await takeScreenshot(page, path.join(__dirname, 'screenshot.png')) +}) diff --git a/examples-v2/typescript/tsconfig.json b/examples-v2/typescript/tsconfig.json new file mode 100644 index 000000000..071ecf89f --- /dev/null +++ b/examples-v2/typescript/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "jsx": "react-jsx", + "lib": ["dom", "dom.iterable", "esnext"], + "moduleResolution": "node", + "module": "esnext", + "noEmit": true, + "resolveJsonModule": true, + "strict": true, + "target": "esnext", + "verbatimModuleSyntax": true, + "useDefineForClassFields": true, + "skipLibCheck": true + }, + "include": ["./", "extension-env.d.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/examples-v2/vue/.gitignore b/examples-v2/vue/.gitignore new file mode 100644 index 000000000..5e8c65b73 --- /dev/null +++ b/examples-v2/vue/.gitignore @@ -0,0 +1,31 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules + +# testing +coverage + +# production +dist + +# misc +.DS_Store + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# lock files +yarn.lock +package-lock.json + +# debug files +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# extension.js +extension-env.d.ts diff --git a/examples-v2/vue/background.ts b/examples-v2/vue/background.ts new file mode 100644 index 000000000..798d5018d --- /dev/null +++ b/examples-v2/vue/background.ts @@ -0,0 +1 @@ +console.log('Hello from the background script!') diff --git a/examples-v2/vue/content/ContentApp.vue b/examples-v2/vue/content/ContentApp.vue new file mode 100644 index 000000000..e99f663cc --- /dev/null +++ b/examples-v2/vue/content/ContentApp.vue @@ -0,0 +1,90 @@ + + diff --git a/examples-v2/vue/content/scripts.ts b/examples-v2/vue/content/scripts.ts new file mode 100644 index 000000000..a0b99c3f0 --- /dev/null +++ b/examples-v2/vue/content/scripts.ts @@ -0,0 +1,59 @@ +import {createApp} from 'vue' +import ContentApp from './ContentApp.vue' + +let unmount: () => void + +if (import.meta.webpackHot) { + import.meta.webpackHot?.accept() + import.meta.webpackHot?.dispose(() => unmount?.()) +} + +if (document.readyState === 'complete') { + unmount = initial() || (() => {}) +} else { + document.addEventListener('readystatechange', () => { + if (document.readyState === 'complete') unmount = initial() || (() => {}) + }) +} + +console.log('Hello from content script') + +function initial() { + const rootDiv = document.createElement('div') + rootDiv.id = 'extension-root' + document.body.appendChild(rootDiv) + + // Injecting content_scripts inside a shadow dom + // prevents conflicts with the host page's styles. + // This way, styles from the extension won't leak into the host page. + const shadowRoot = rootDiv.attachShadow({mode: 'open'}) + const style = new CSSStyleSheet() + shadowRoot.adoptedStyleSheets = [style] + fetchCSS().then((response) => style.replace(response)) + + if (import.meta.webpackHot) { + import.meta.webpackHot?.accept('./styles.css', () => { + fetchCSS().then((response) => style.replace(response)) + }) + } + + // Create container for Vue app + const contentDiv = document.createElement('div') + contentDiv.className = 'content_script' + shadowRoot.appendChild(contentDiv) + + // Mount the Vue app to the container inside the shadow DOM + const app = createApp(ContentApp) + app.mount(contentDiv) + + return () => { + rootDiv.remove() + } +} + +async function fetchCSS() { + const cssUrl = new URL('./styles.css', import.meta.url) + const response = await fetch(cssUrl) + const text = await response.text() + return response.ok ? text : Promise.reject(text) +} diff --git a/examples-v2/vue/content/shims-vue.d.ts b/examples-v2/vue/content/shims-vue.d.ts new file mode 100644 index 000000000..69226d04a --- /dev/null +++ b/examples-v2/vue/content/shims-vue.d.ts @@ -0,0 +1,6 @@ +/* eslint-disable */ +declare module '*.vue' { + import type {DefineComponent} from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/examples-v2/vue/content/styles.css b/examples-v2/vue/content/styles.css new file mode 100644 index 000000000..c0fc3552e --- /dev/null +++ b/examples-v2/vue/content/styles.css @@ -0,0 +1,10 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +.content_script { + position: fixed; + bottom: 0; + right: 0; + z-index: 99999; +} diff --git a/examples-v2/vue/images/chromeWindow.png b/examples-v2/vue/images/chromeWindow.png new file mode 100644 index 000000000..da525dd8e Binary files /dev/null and b/examples-v2/vue/images/chromeWindow.png differ diff --git a/examples-v2/vue/images/extension_48.png b/examples-v2/vue/images/extension_48.png new file mode 100644 index 000000000..f60575b39 Binary files /dev/null and b/examples-v2/vue/images/extension_48.png differ diff --git a/examples-v2/vue/images/logo.svg b/examples-v2/vue/images/logo.svg new file mode 100644 index 000000000..d4d5f0bdf --- /dev/null +++ b/examples-v2/vue/images/logo.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/examples-v2/vue/images/tailwind.png b/examples-v2/vue/images/tailwind.png new file mode 100644 index 000000000..83ed5e126 Binary files /dev/null and b/examples-v2/vue/images/tailwind.png differ diff --git a/examples-v2/vue/images/tailwind_bg.png b/examples-v2/vue/images/tailwind_bg.png new file mode 100644 index 000000000..edc40be8d Binary files /dev/null and b/examples-v2/vue/images/tailwind_bg.png differ diff --git a/examples-v2/vue/images/typescript.png b/examples-v2/vue/images/typescript.png new file mode 100644 index 000000000..936146940 Binary files /dev/null and b/examples-v2/vue/images/typescript.png differ diff --git a/examples-v2/vue/manifest.json b/examples-v2/vue/manifest.json new file mode 100644 index 000000000..25fccb1bb --- /dev/null +++ b/examples-v2/vue/manifest.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json.schemastore.org/chrome-manifest.json", + "manifest_version": 3, + "version": "0.0.1", + "name": "Vue", + "description": "An Extension.js example.", + "icons": { + "48": "images/extension_48.png" + }, + "background": { + "chromium:service_worker": "background.ts", + "firefox:scripts": ["background.ts"] + }, + "content_scripts": [ + { + "matches": ["https://extension.js.org/*"], + "js": ["./content/scripts.ts"], + "css": ["./content/styles.css"] + } + ], + "options_page": "./options/index.html" +} diff --git a/examples-v2/vue/options/OptionsApp.vue b/examples-v2/vue/options/OptionsApp.vue new file mode 100644 index 000000000..7d173cbb7 --- /dev/null +++ b/examples-v2/vue/options/OptionsApp.vue @@ -0,0 +1,30 @@ + + diff --git a/examples-v2/vue/options/index.html b/examples-v2/vue/options/index.html new file mode 100644 index 000000000..67e0c0b1c --- /dev/null +++ b/examples-v2/vue/options/index.html @@ -0,0 +1,13 @@ + + + + + + Vue Template + + + +
+ + + diff --git a/examples-v2/vue/options/scripts.ts b/examples-v2/vue/options/scripts.ts new file mode 100644 index 000000000..430d7dab2 --- /dev/null +++ b/examples-v2/vue/options/scripts.ts @@ -0,0 +1,6 @@ +import './styles.css' + +import {createApp} from 'vue' +import OptionsApp from './OptionsApp.vue' + +createApp(OptionsApp).mount('#app') diff --git a/examples-v2/vue/options/styles.css b/examples-v2/vue/options/styles.css new file mode 100644 index 000000000..75e01d08f --- /dev/null +++ b/examples-v2/vue/options/styles.css @@ -0,0 +1,86 @@ +html { + font-size: 62.5%; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, + 'Helvetica Neue', Arial, 'Noto Sans', sans-serif; +} + +body { + display: flex; + justify-content: center; + align-items: center; + height: calc(100vh - 4rem); + min-width: 300px; + padding: 2rem; + font-size: 1.8rem; + line-height: 1.618; + max-width: 38em; + margin: auto; + color: #c9c9c9; + background-color: #0A0C10; +} + +@media (max-width: 684px) { + body { + font-size: 1.53rem; + } +} + +@media (max-width: 382px) { + body { + font-size: 1.35rem; + } +} + +h1 { + line-height: 1.1; + font-weight: 700; + margin-bottom: 1.5rem; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; + font-size: 4.7em; +} + +@media (max-width: 684px) { + h1 { + font-size: 2.7em; + } +} + +p { + margin-top: 0px; + margin-bottom: 2.5rem; +} + +a { + text-decoration: none; + border-bottom: 2px solid #c9c9c9; + color: #e5e7eb; +} + + +img { + height: auto; + max-width: 100%; + margin-top: 0px; + margin-bottom: 2.5rem; +} + +@media (max-width: 684px) { + img { + margin-top: 2rem; + margin-bottom: 1rem; + } +} + +body { + display: flex; + justify-content: center; + align-items: center; + height: calc(100vh - 4rem); +} + +header > div { + display: flex; + align-items: center; +} \ No newline at end of file diff --git a/examples-v2/vue/package.json b/examples-v2/vue/package.json new file mode 100644 index 000000000..0ec2f678e --- /dev/null +++ b/examples-v2/vue/package.json @@ -0,0 +1,19 @@ +{ + "private": true, + "name": "content-vue", + "description": "An Extension.js example.", + "version": "0.0.1", + "author": { + "name": "OSpoon", + "email": "zxin088@gmail.com", + "url": "https://hw404.cn" + }, + "license": "MIT", + "dependencies": { + "vue": "^3.4.27", + "tailwindcss": "^3.4.1" + }, + "devDependencies": { + "typescript": "5.3.3" + } +} diff --git a/examples-v2/vue/postcss.config.js b/examples-v2/vue/postcss.config.js new file mode 100644 index 000000000..85f717cc0 --- /dev/null +++ b/examples-v2/vue/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +} diff --git a/examples-v2/vue/public/logo.svg b/examples-v2/vue/public/logo.svg new file mode 100644 index 000000000..d4d5f0bdf --- /dev/null +++ b/examples-v2/vue/public/logo.svg @@ -0,0 +1,8 @@ + + + + + diff --git a/examples-v2/vue/tailwind.config.js b/examples-v2/vue/tailwind.config.js new file mode 100644 index 000000000..fe1884596 --- /dev/null +++ b/examples-v2/vue/tailwind.config.js @@ -0,0 +1,8 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ['**/*.vue'], + theme: { + extend: {} + }, + plugins: [] +} diff --git a/examples-v2/vue/template.spec.ts b/examples-v2/vue/template.spec.ts new file mode 100644 index 000000000..8e9a1c818 --- /dev/null +++ b/examples-v2/vue/template.spec.ts @@ -0,0 +1,84 @@ +import path from 'path' +import {execSync} from 'child_process' +import {extensionFixtures, getShadowRootElement} from '../extension-fixtures' + +const exampleDir = 'examples/content-vue' +const pathToExtension = path.join(__dirname, `dist/chrome`) +const test = extensionFixtures(pathToExtension, true) + +test.beforeAll(async () => { + execSync(`pnpm extension build ${exampleDir}`, { + cwd: path.join(__dirname, '..') + }) +}) + +test('should exist an element with the class name extension-root', async ({ + page +}) => { + await page.goto('https://extension.js.org/') + const shadowRootHandle = await page + .locator('#extension-root') + .evaluateHandle((host: HTMLElement) => host.shadowRoot) + + // Validate that the Shadow DOM exists + test.expect(shadowRootHandle).not.toBeNull() + + // Verify Shadow DOM has children + const shadowChildrenCount = await shadowRootHandle.evaluate( + (shadowRoot: ShadowRoot) => shadowRoot.children.length + ) + test.expect(shadowChildrenCount).toBeGreaterThan(0) +}) + +test('should exist an h2 element with specified content', async ({page}) => { + await page.goto('https://extension.js.org/') + const h2 = await getShadowRootElement(page, '#extension-root', 'h2') + if (!h2) { + throw new Error('h2 element not found in Shadow DOM') + } + const textContent = await h2.evaluate((node) => node.textContent) + await test + .expect(textContent) + .toContain( + 'This is a content script running Vue, TypeScript, and Tailwind.css.' + ) +}) + +test('should exist a default color value', async ({page}) => { + await page.goto('https://extension.js.org/') + const h2 = await getShadowRootElement(page, '#extension-root', 'h2') + if (!h2) { + throw new Error('h2 element not found in Shadow DOM') + } + const color = await h2.evaluate((node) => + window.getComputedStyle(node as HTMLElement).getPropertyValue('color') + ) + test.expect(color).toEqual('rgb(255, 255, 255)') +}) + +test('should load all images successfully', async ({page}) => { + await page.goto('https://extension.js.org/') + const shadowRootHandle = await page + .locator('#extension-root') + .evaluateHandle((host: HTMLElement) => host.shadowRoot) + + const imagesHandle = await shadowRootHandle.evaluateHandle( + (shadow: ShadowRoot) => Array.from(shadow.querySelectorAll('img')) + ) + + const imageHandles = await imagesHandle.getProperties() + const results: boolean[] = [] + + for (const [, imageHandle] of imageHandles) { + const naturalWidth = await imageHandle.evaluate( + (img) => (img as HTMLImageElement).naturalWidth + ) + const naturalHeight = await imageHandle.evaluate( + (img) => (img as HTMLImageElement).naturalHeight + ) + const loadedSuccessfully = naturalWidth > 0 && naturalHeight > 0 + results.push(loadedSuccessfully) + } + + test.expect(results.every((result) => result)).toBeTruthy() +}) diff --git a/examples-v2/vue/tsconfig.json b/examples-v2/vue/tsconfig.json new file mode 100644 index 000000000..071ecf89f --- /dev/null +++ b/examples-v2/vue/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "allowJs": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "jsx": "react-jsx", + "lib": ["dom", "dom.iterable", "esnext"], + "moduleResolution": "node", + "module": "esnext", + "noEmit": true, + "resolveJsonModule": true, + "strict": true, + "target": "esnext", + "verbatimModuleSyntax": true, + "useDefineForClassFields": true, + "skipLibCheck": true + }, + "include": ["./", "extension-env.d.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/examples-v2/wasm/.gitkeep b/examples-v2/wasm/.gitkeep new file mode 100644 index 000000000..99b2b8b0b --- /dev/null +++ b/examples-v2/wasm/.gitkeep @@ -0,0 +1 @@ +TODO: cezaraugusto add wasm example diff --git a/examples/content-css-modules/content/scripts.js b/examples/content-css-modules/content/scripts.js index f136bcc03..b2ed73a62 100644 --- a/examples/content-css-modules/content/scripts.js +++ b/examples/content-css-modules/content/scripts.js @@ -1,14 +1,20 @@ -import './styles.css?inline_style' import styles from './Logo.module.css?inline_style' import logo from '../images/logo.png' +let unmount + +if (import.meta.webpackHot) { + import.meta.webpackHot?.accept() + import.meta.webpackHot?.dispose(() => unmount?.()) +} + console.log('hello from content_scripts') if (document.readyState === 'complete') { - initial() + unmount = initial() || (() => {}) } else { document.addEventListener('readystatechange', () => { - if (document.readyState === 'complete') initial() + if (document.readyState === 'complete') unmount = initial() || (() => {}) }) } @@ -21,25 +27,56 @@ function initial() { // prevents conflicts with the host page's styles. // This way, styles from the extension won't leak into the host page. const shadowRoot = rootDiv.attachShadow({mode: 'open'}) + const style = new CSSStyleSheet() + shadowRoot.adoptedStyleSheets = [style] + fetchCSS().then((response) => style.replace(response)) + + if (import.meta.webpackHot) { + import.meta.webpackHot?.accept('./styles.css', () => { + fetchCSS().then((response) => style.replace(response)) + }) + } + + // Create container div + const contentDiv = document.createElement('div') + contentDiv.className = 'content_script' + + // Create and append logo image with CSS module class + const img = document.createElement('img') + img.className = styles.content_logo + img.src = logo + contentDiv.appendChild(img) + + // Create and append title + const title = document.createElement('h1') + title.className = 'content_title' + title.textContent = 'Welcome to your CSS Modules Extension' + contentDiv.appendChild(title) + + // Create and append description paragraph + const desc = document.createElement('p') + desc.className = 'content_description' + desc.innerHTML = 'Learn more about creating cross-browser extensions at ' + + const link = document.createElement('a') + link.href = 'https://extension.js.org' + link.target = '_blank' + link.textContent = 'https://extension.js.org' + + desc.appendChild(link) + contentDiv.appendChild(desc) + + // Append the content div to shadow root + shadowRoot.appendChild(contentDiv) + + return () => { + rootDiv.remove() + } +} - // Inform Extension.js that the shadow root is available. - window.__EXTENSION_SHADOW_ROOT__ = shadowRoot - - shadowRoot.innerHTML = ` -
- -

- Welcome to your CSS Modules Extension -

-

- Learn more about creating cross-browser extensions at - https://extension.js.org - -

-
- ` +async function fetchCSS() { + const cssUrl = new URL('./styles.css', import.meta.url) + const response = await fetch(cssUrl) + const text = await response.text() + return response.ok ? text : Promise.reject(text) } diff --git a/examples/content-env/content/scripts.ts b/examples/content-env/content/scripts.ts index 24377021b..09e86760a 100644 --- a/examples/content-env/content/scripts.ts +++ b/examples/content-env/content/scripts.ts @@ -1,16 +1,22 @@ -import './styles.css?inline_style' import logo from '../images/logo.png' +let unmount: (() => void) | undefined + +if (import.meta.webpackHot) { + import.meta.webpackHot?.accept() + import.meta.webpackHot?.dispose(() => unmount?.()) +} + console.log( 'hello from content_scripts', process.env.EXTENSION_PUBLIC_DESCRIPTION_TEXT ) if (document.readyState === 'complete') { - initial() + unmount = initial() || (() => {}) } else { document.addEventListener('readystatechange', () => { - if (document.readyState === 'complete') initial() + if (document.readyState === 'complete') unmount = initial() || (() => {}) }) } @@ -23,26 +29,56 @@ function initial() { // prevents conflicts with the host page's styles. // This way, styles from the extension won't leak into the host page. const shadowRoot = rootDiv.attachShadow({mode: 'open'}) + const style = new CSSStyleSheet() + shadowRoot.adoptedStyleSheets = [style] + fetchCSS().then((response) => style.replace(response)) + + if (import.meta.webpackHot) { + import.meta.webpackHot?.accept('./styles.css', () => { + fetchCSS().then((response) => style.replace(response)) + }) + } + + // Create container div + const contentDiv = document.createElement('div') + contentDiv.className = 'content_script' + + // Create and append logo image + const img = document.createElement('img') + img.className = 'content_logo' + img.src = logo + contentDiv.appendChild(img) + + // Create and append title + const title = document.createElement('h1') + title.className = 'content_title' + title.textContent = 'Welcome to your Content Script Extension' + contentDiv.appendChild(title) + + // Create and append description paragraph + const desc = document.createElement('p') + desc.className = process.env.EXTENSION_PUBLIC_DESCRIPTION_TEXT as string + desc.innerHTML = 'Learn more about creating cross-browser extensions at ' + + const link = document.createElement('a') + link.href = 'https://extension.js.org' + link.target = '_blank' + link.textContent = 'https://extension.js.org' + + desc.appendChild(link) + contentDiv.appendChild(desc) + + // Append the content div to shadow root + shadowRoot.appendChild(contentDiv) + + return () => { + rootDiv.remove() + } +} - // Inform Extension.js that the shadow root is available. - window.__EXTENSION_SHADOW_ROOT__ = shadowRoot - - shadowRoot.innerHTML = ` -
- -

${process.env.EXTENSION_PUBLIC_DESCRIPTION_TEXT}

-

- Welcome to your .env Extension -

-

- Learn more about creating cross-browser extensions at - https://extension.js.org - -

-
- ` +async function fetchCSS() { + const cssUrl = new URL('./styles.css', import.meta.url) + const response = await fetch(cssUrl) + const text = await response.text() + return response.ok ? text : Promise.reject(text) } diff --git a/examples/content-esm/content/scripts.mjs b/examples/content-esm/content/scripts.mjs index 2e6cee1a8..f6f7b5fa7 100644 --- a/examples/content-esm/content/scripts.mjs +++ b/examples/content-esm/content/scripts.mjs @@ -1,13 +1,19 @@ import {contentComponent} from './contentComponent.mjs' -import './styles.css?inline_style' + +let unmount + +if (import.meta.webpackHot) { + import.meta.webpackHot?.accept() + import.meta.webpackHot?.dispose(() => unmount?.()) +} console.log('hello from content_scripts') if (document.readyState === 'complete') { - initial() + unmount = initial() || (() => {}) } else { document.addEventListener('readystatechange', () => { - if (document.readyState === 'complete') initial() + if (document.readyState === 'complete') unmount = initial() || (() => {}) }) } @@ -20,9 +26,33 @@ function initial() { // prevents conflicts with the host page's styles. // This way, styles from the extension won't leak into the host page. const shadowRoot = rootDiv.attachShadow({mode: 'open'}) + const style = new CSSStyleSheet() + shadowRoot.adoptedStyleSheets = [style] + fetchCSS().then((response) => style.replace(response)) + + if (import.meta.webpackHot) { + import.meta.webpackHot?.accept('./styles.css', () => { + fetchCSS().then((response) => style.replace(response)) + }) + } - // Inform Extension.js that the shadow root is available. - window.__EXTENSION_SHADOW_ROOT__ = shadowRoot + // Create container div and inject content + const contentDiv = document.createElement('div') + contentDiv.className = 'content_script' + contentDiv.innerHTML = contentComponent + + // Append the content div to shadow root + shadowRoot.appendChild(contentDiv) + + return () => { + rootDiv.remove() + + } +} - shadowRoot.innerHTML = contentComponent +async function fetchCSS() { + const cssUrl = new URL('./styles.css', import.meta.url) + const response = await fetch(cssUrl) + const text = await response.text() + return response.ok ? text : Promise.reject(text) } diff --git a/examples/content-extension-config/content/scripts.tsx b/examples/content-extension-config/content/scripts.tsx index eddd5b42e..6df7eded5 100644 --- a/examples/content-extension-config/content/scripts.tsx +++ b/examples/content-extension-config/content/scripts.tsx @@ -1,15 +1,23 @@ import ReactDOM from 'react-dom/client' import ContentApp from './ContentApp' -import './styles.css?inline_style' + +let unmount: () => void + +if (import.meta.webpackHot) { + import.meta.webpackHot?.accept() + import.meta.webpackHot?.dispose(() => unmount?.()) +} if (document.readyState === 'complete') { - initial() + unmount = initial() || (() => {}) } else { document.addEventListener('readystatechange', () => { - if (document.readyState === 'complete') initial() + if (document.readyState === 'complete') unmount = initial() || (() => {}) }) } +console.log('Hello from content script') + function initial() { // Create a new div element and append it to the document's body const rootDiv = document.createElement('div') @@ -20,22 +28,31 @@ function initial() { // prevents conflicts with the host page's styles. // This way, styles from the extension won't leak into the host page. const shadowRoot = rootDiv.attachShadow({mode: 'open'}) + const style = new CSSStyleSheet() + shadowRoot.adoptedStyleSheets = [style] + fetchCSS().then((response) => style.replace(response)) - // Inform Extension.js that the shadow root is available. - window.__EXTENSION_SHADOW_ROOT__ = shadowRoot + if (import.meta.webpackHot) { + import.meta.webpackHot?.accept('./styles.css', () => { + fetchCSS().then((response) => style.replace(response)) + }) + } - const shadowStyle = document.createElement('style') - shadowStyle.textContent = ` - :host { - all: initial; /* Reset all styles */ - } - ` - shadowRoot.appendChild(shadowStyle) - - const root = ReactDOM.createRoot(shadowRoot) - root.render( + const mountingPoint = ReactDOM.createRoot(shadowRoot) + mountingPoint.render(
) + return () => { + mountingPoint.unmount() + rootDiv.remove() + } +} + +async function fetchCSS() { + const cssUrl = new URL('./styles.css', import.meta.url) + const response = await fetch(cssUrl) + const text = await response.text() + return response.ok ? text : Promise.reject(text) } diff --git a/examples/content-less/content/scripts.js b/examples/content-less/content/scripts.js index 8cc39dd6e..07ed65d06 100644 --- a/examples/content-less/content/scripts.js +++ b/examples/content-less/content/scripts.js @@ -1,13 +1,20 @@ import './styles.less?inline_style' import logo from '../images/logo.svg' +let unmount + +if (import.meta.webpackHot) { + import.meta.webpackHot?.accept() + import.meta.webpackHot?.dispose(() => unmount?.()) +} + console.log('hello from content_scripts') if (document.readyState === 'complete') { - initial() + unmount = initial() || (() => {}) } else { document.addEventListener('readystatechange', () => { - if (document.readyState === 'complete') initial() + if (document.readyState === 'complete') unmount = initial() || (() => {}) }) } @@ -20,25 +27,56 @@ function initial() { // prevents conflicts with the host page's styles. // This way, styles from the extension won't leak into the host page. const shadowRoot = rootDiv.attachShadow({mode: 'open'}) + const style = new CSSStyleSheet() + shadowRoot.adoptedStyleSheets = [style] + fetchCSS().then((response) => style.replace(response)) + + if (import.meta.webpackHot) { + import.meta.webpackHot?.accept('./styles.less', () => { + fetchCSS().then((response) => style.replace(response)) + }) + } + + // Create container div + const contentDiv = document.createElement('div') + contentDiv.className = 'content_script' + + // Create and append logo image + const img = document.createElement('img') + img.className = 'content_logo' + img.src = logo + contentDiv.appendChild(img) + + // Create and append title + const title = document.createElement('h1') + title.className = 'content_title' + title.textContent = 'Welcome to your LESS Extension' + contentDiv.appendChild(title) + + // Create and append description paragraph + const desc = document.createElement('p') + desc.className = 'content_description' + desc.innerHTML = 'Learn more about creating cross-browser extensions at ' + + const link = document.createElement('a') + link.href = 'https://extension.js.org' + link.target = '_blank' + link.textContent = 'https://extension.js.org' + + desc.appendChild(link) + contentDiv.appendChild(desc) + + // Append the content div to shadow root + shadowRoot.appendChild(contentDiv) + + return () => { + rootDiv.remove() + } +} - // Inform Extension.js that the shadow root is available. - window.__EXTENSION_SHADOW_ROOT__ = shadowRoot - - shadowRoot.innerHTML = ` -
- -

- Welcome to your LESS Extension -

-

- Learn more about creating cross-browser extensions at - https://extension.js.org - -

-
- ` +async function fetchCSS() { + const cssUrl = new URL('./styles.less', import.meta.url) + const response = await fetch(cssUrl) + const text = await response.text() + return response.ok ? text : Promise.reject(text) } diff --git a/examples/content-main-world/content/scripts.js b/examples/content-main-world/content/scripts.js index e38e29aa4..025f73bfb 100644 --- a/examples/content-main-world/content/scripts.js +++ b/examples/content-main-world/content/scripts.js @@ -1,13 +1,19 @@ -import './styles.css?inline_style' -import logo from '../images/extension.svg' +// import logo from '../images/logo.svg' + +let unmount + +if (import.meta.webpackHot) { + import.meta.webpackHot?.accept() + import.meta.webpackHot?.dispose(() => unmount?.()) +} console.log('hello from content_scripts') if (document.readyState === 'complete') { - initial() + unmount = initial() || (() => {}) } else { document.addEventListener('readystatechange', () => { - if (document.readyState === 'complete') initial() + if (document.readyState === 'complete') unmount = initial() || (() => {}) }) } @@ -20,25 +26,56 @@ function initial() { // prevents conflicts with the host page's styles. // This way, styles from the extension won't leak into the host page. const shadowRoot = rootDiv.attachShadow({mode: 'open'}) + const style = new CSSStyleSheet() + shadowRoot.adoptedStyleSheets = [style] + fetchCSS().then((response) => style.replace(response)) + + if (import.meta.webpackHot) { + import.meta.webpackHot?.accept('./styles.css', () => { + fetchCSS().then((response) => style.replace(response)) + }) + } + + // Create container div + const contentDiv = document.createElement('div') + contentDiv.className = 'content_script' + + // Create and append logo image + const img = document.createElement('img') + img.className = 'content_logo' + img.src = '/logo.png' + contentDiv.appendChild(img) + + // Create and append title + const title = document.createElement('h1') + title.className = 'content_title' + title.textContent = 'Welcome to your Main World Extension' + contentDiv.appendChild(title) + + // Create and append description paragraph + const desc = document.createElement('p') + desc.className = 'content_description' + desc.innerHTML = 'Learn more about creating cross-browser extensions at ' + + const link = document.createElement('a') + link.href = 'https://extension.js.org' + link.target = '_blank' + link.textContent = 'https://extension.js.org' + + desc.appendChild(link) + contentDiv.appendChild(desc) + + // Append the content div to shadow root + shadowRoot.appendChild(contentDiv) + + return () => { + rootDiv.remove() + } +} - // Inform Extension.js that the shadow root is available. - window.__EXTENSION_SHADOW_ROOT__ = shadowRoot - - shadowRoot.innerHTML = ` -
- -

- Main World -

-

- Learn more about creating cross-browser extensions at - https://extension.js.org - -

-
- ` +async function fetchCSS() { + const cssUrl = new URL('./styles.css', import.meta.url) + const response = await fetch(cssUrl) + const text = await response.text() + return response.ok ? text : Promise.reject(text) } diff --git a/examples/content-main-world/images/logo.svg b/examples/content-main-world/images/logo.svg new file mode 100644 index 000000000..ebe0773a6 --- /dev/null +++ b/examples/content-main-world/images/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/examples/content-preact/content/scripts.tsx b/examples/content-preact/content/scripts.tsx index 4d4998c07..77cc9568b 100644 --- a/examples/content-preact/content/scripts.tsx +++ b/examples/content-preact/content/scripts.tsx @@ -1,15 +1,23 @@ import {render} from 'preact' import ContentApp from './ContentApp' -import './styles.css?inline_style' + +let unmount: () => void + +if (import.meta.webpackHot) { + import.meta.webpackHot?.accept() + import.meta.webpackHot?.dispose(() => unmount?.()) +} if (document.readyState === 'complete') { - initial() + unmount = initial() || (() => {}) } else { document.addEventListener('readystatechange', () => { - if (document.readyState === 'complete') initial() + if (document.readyState === 'complete') unmount = initial() || (() => {}) }) } +console.log('Hello from content script') + function initial() { // Create a new div element and append it to the document's body const rootDiv = document.createElement('div') @@ -20,9 +28,15 @@ function initial() { // prevents conflicts with the host page's styles. // This way, styles from the extension won't leak into the host page. const shadowRoot = rootDiv.attachShadow({mode: 'open'}) + const style = new CSSStyleSheet() + shadowRoot.adoptedStyleSheets = [style] + fetchCSS().then((response) => style.replace(response)) - // Inform Extension.js that the shadow root is available. - window.__EXTENSION_SHADOW_ROOT__ = shadowRoot + if (import.meta.webpackHot) { + import.meta.webpackHot?.accept('./styles.css', () => { + fetchCSS().then((response) => style.replace(response)) + }) + } render(
@@ -30,4 +44,15 @@ function initial() {
, shadowRoot ) + + return () => { + rootDiv.remove() + } +} + +async function fetchCSS() { + const cssUrl = new URL('./styles.css', import.meta.url) + const response = await fetch(cssUrl) + const text = await response.text() + return response.ok ? text : Promise.reject(text) } diff --git a/examples/content-preact/manifest.json b/examples/content-preact/manifest.json index b1e168860..12ef4bfd5 100644 --- a/examples/content-preact/manifest.json +++ b/examples/content-preact/manifest.json @@ -14,8 +14,13 @@ "content_scripts": [ { "matches": ["https://extension.js.org/*"], - "js": ["./content/scripts.tsx"], - "css": ["./content/styles.css"] + "js": ["./content/scripts.tsx"] + } + ], + "web_accessible_resources": [ + { + "resources": ["styles/*.css"], + "matches": ["https://extension.js.org/*"] } ] } diff --git a/examples/content-react-svgr/content/scripts.tsx b/examples/content-react-svgr/content/scripts.tsx index 8c80fd7df..6df7eded5 100644 --- a/examples/content-react-svgr/content/scripts.tsx +++ b/examples/content-react-svgr/content/scripts.tsx @@ -1,15 +1,23 @@ import ReactDOM from 'react-dom/client' import ContentApp from './ContentApp' -import './styles.css?inline_style' + +let unmount: () => void + +if (import.meta.webpackHot) { + import.meta.webpackHot?.accept() + import.meta.webpackHot?.dispose(() => unmount?.()) +} if (document.readyState === 'complete') { - initial() + unmount = initial() || (() => {}) } else { document.addEventListener('readystatechange', () => { - if (document.readyState === 'complete') initial() + if (document.readyState === 'complete') unmount = initial() || (() => {}) }) } +console.log('Hello from content script') + function initial() { // Create a new div element and append it to the document's body const rootDiv = document.createElement('div') @@ -20,14 +28,31 @@ function initial() { // prevents conflicts with the host page's styles. // This way, styles from the extension won't leak into the host page. const shadowRoot = rootDiv.attachShadow({mode: 'open'}) + const style = new CSSStyleSheet() + shadowRoot.adoptedStyleSheets = [style] + fetchCSS().then((response) => style.replace(response)) - // Inform Extension.js that the shadow root is available. - window.__EXTENSION_SHADOW_ROOT__ = shadowRoot + if (import.meta.webpackHot) { + import.meta.webpackHot?.accept('./styles.css', () => { + fetchCSS().then((response) => style.replace(response)) + }) + } - const root = ReactDOM.createRoot(shadowRoot) - root.render( + const mountingPoint = ReactDOM.createRoot(shadowRoot) + mountingPoint.render(
) + return () => { + mountingPoint.unmount() + rootDiv.remove() + } +} + +async function fetchCSS() { + const cssUrl = new URL('./styles.css', import.meta.url) + const response = await fetch(cssUrl) + const text = await response.text() + return response.ok ? text : Promise.reject(text) } diff --git a/examples/content-react/content/scripts.tsx b/examples/content-react/content/scripts.tsx index 90f2a7b93..6df7eded5 100644 --- a/examples/content-react/content/scripts.tsx +++ b/examples/content-react/content/scripts.tsx @@ -1,20 +1,24 @@ import ReactDOM from 'react-dom/client' import ContentApp from './ContentApp' -import {injectStyles} from './scripts-shell' -// import './styles.css?inline_style' -import './styles.css?inline_style' + +let unmount: () => void + +if (import.meta.webpackHot) { + import.meta.webpackHot?.accept() + import.meta.webpackHot?.dispose(() => unmount?.()) +} if (document.readyState === 'complete') { - initial() + unmount = initial() || (() => {}) } else { document.addEventListener('readystatechange', () => { - if (document.readyState === 'complete') initial() + if (document.readyState === 'complete') unmount = initial() || (() => {}) }) } -injectStyles() +console.log('Hello from content script') -export default function initial() { +function initial() { // Create a new div element and append it to the document's body const rootDiv = document.createElement('div') rootDiv.id = 'extension-root' @@ -24,19 +28,31 @@ export default function initial() { // prevents conflicts with the host page's styles. // This way, styles from the extension won't leak into the host page. const shadowRoot = rootDiv.attachShadow({mode: 'open'}) + const style = new CSSStyleSheet() + shadowRoot.adoptedStyleSheets = [style] + fetchCSS().then((response) => style.replace(response)) - // Use the shadow root as the root element to inject styles into. - window.__EXTENSION_SHADOW_ROOT__ = shadowRoot + if (import.meta.webpackHot) { + import.meta.webpackHot?.accept('./styles.css', () => { + fetchCSS().then((response) => style.replace(response)) + }) + } - const root = ReactDOM.createRoot(shadowRoot) - root.render( + const mountingPoint = ReactDOM.createRoot(shadowRoot) + mountingPoint.render(
) - return () => { - root.unmount() - // shadowRoot.remove() + mountingPoint.unmount() + rootDiv.remove() } } + +async function fetchCSS() { + const cssUrl = new URL('./styles.css', import.meta.url) + const response = await fetch(cssUrl) + const text = await response.text() + return response.ok ? text : Promise.reject(text) +} diff --git a/examples/content-sass/content/scripts.js b/examples/content-sass/content/scripts.js index ff923e7af..555cb5f62 100644 --- a/examples/content-sass/content/scripts.js +++ b/examples/content-sass/content/scripts.js @@ -1,13 +1,19 @@ -import './styles.scss?inline_style' import logo from '../images/logo.svg' +let unmount + +if (import.meta.webpackHot) { + import.meta.webpackHot?.accept() + import.meta.webpackHot?.dispose(() => unmount?.()) +} + console.log('hello from content_scripts') if (document.readyState === 'complete') { - initial() + unmount = initial() || (() => {}) } else { document.addEventListener('readystatechange', () => { - if (document.readyState === 'complete') initial() + if (document.readyState === 'complete') unmount = initial() || (() => {}) }) } @@ -21,24 +27,65 @@ function initial() { // This way, styles from the extension won't leak into the host page. const shadowRoot = rootDiv.attachShadow({mode: 'open'}) - // Inform Extension.js that the shadow root is available. - window.__EXTENSION_SHADOW_ROOT__ = shadowRoot - - shadowRoot.innerHTML = ` -
- -

- Welcome to your Sass Extension -

-

- Learn more about creating cross-browser extensions at - https://extension.js.org - -

-
- ` + // Create and inject style element with our CSS + const style = document.createElement('style') + shadowRoot.appendChild(style) + + fetchCSS().then((response) => { + style.textContent = response + }) + + if (import.meta.webpackHot) { + import.meta.webpackHot?.accept('./styles.scss', () => { + import('./styles.scss').then((newStyles) => { + style.textContent = newStyles.default + }) + }) + } + + // Create container div + const contentDiv = document.createElement('div') + contentDiv.className = 'content_script' + + // Create and append logo image + const img = document.createElement('img') + img.className = 'content_logo' + img.src = logo + contentDiv.appendChild(img) + + // Create and append title + const title = document.createElement('h1') + title.className = 'content_title' + title.textContent = 'Welcome to your SASS Extension' + contentDiv.appendChild(title) + + // Create and append description paragraph + const desc = document.createElement('p') + desc.className = 'content_description' + desc.innerHTML = 'Learn more about creating cross-browser extensions at ' + + const link = document.createElement('a') + + link.href = 'https://extension.js.org' + link.target = '_blank' + link.textContent = 'https://extension.js.org' + + desc.appendChild(link) + contentDiv.appendChild(desc) + + // Append the content div to shadow root + shadowRoot.appendChild(contentDiv) + + return () => { + rootDiv.remove() + } +} + +async function fetchCSS() { + const cssUrl = new URL('./styles.scss', import.meta.url) + console.log('cssUrl', cssUrl) + const response = await fetch(cssUrl) + const text = await response.text() + console.log('text', text) + return response.ok ? text : Promise.reject(text) } diff --git a/examples/content-sass/content/styles.scss b/examples/content-sass/content/styles.scss index 35586168b..9a1e0cece 100644 --- a/examples/content-sass/content/styles.scss +++ b/examples/content-sass/content/styles.scss @@ -3,7 +3,7 @@ background-color: #0a0c10; position: fixed; right: 0; - bottom: 0; + top: 0; z-index: 9; width: 315px; margin: 1rem; diff --git a/examples/content-svelte/content/scripts.ts b/examples/content-svelte/content/scripts.ts index c69d39825..c17da229d 100644 --- a/examples/content-svelte/content/scripts.ts +++ b/examples/content-svelte/content/scripts.ts @@ -2,8 +2,11 @@ import {mount} from 'svelte' import ContentApp from './ContentApp.svelte' let unmount: () => void -import.meta.webpackHot?.accept() -import.meta.webpackHot?.dispose(() => unmount?.()) + +if (import.meta.webpackHot) { + import.meta.webpackHot?.accept() + import.meta.webpackHot?.dispose(() => unmount?.()) +} if (document.readyState === 'complete') { unmount = initial() || (() => {}) @@ -13,25 +16,26 @@ if (document.readyState === 'complete') { }) } -export default function initial() { +console.log('Hello from content script') + +function initial() { const rootDiv = document.createElement('div') rootDiv.id = 'extension-root' document.body.appendChild(rootDiv) + // Injecting content_scripts inside a shadow dom + // prevents conflicts with the host page's styles. + // This way, styles from the extension won't leak into the host page. const shadowRoot = rootDiv.attachShadow({mode: 'open'}) + const style = new CSSStyleSheet() + shadowRoot.adoptedStyleSheets = [style] + fetchCSS().then((response) => style.replace(response)) - const style = document.createElement('style') - shadowRoot.appendChild(style) - - fetchCSS().then((response) => { - style.textContent = response - }) - - import.meta.webpackHot?.accept('./styles.css', () => { - fetchCSS().then((response) => { - style.textContent = response + if (import.meta.webpackHot) { + import.meta.webpackHot?.accept('./styles.css', () => { + fetchCSS().then((response) => style.replace(response)) }) - }) + } // Create container for Svelte app const contentDiv = document.createElement('div') @@ -49,7 +53,8 @@ export default function initial() { } async function fetchCSS() { - const response = await fetch(new URL('./styles.css', import.meta.url)) + const cssUrl = new URL('./styles.css', import.meta.url) + const response = await fetch(cssUrl) const text = await response.text() return response.ok ? text : Promise.reject(text) } diff --git a/examples/content-tailwind/content/scripts.js b/examples/content-tailwind/content/scripts.js index b8c0dec7d..9a1b41907 100644 --- a/examples/content-tailwind/content/scripts.js +++ b/examples/content-tailwind/content/scripts.js @@ -1,13 +1,19 @@ -import './styles.css?inline_style' import {getContentHtml} from './content' +let unmount + +if (import.meta.webpackHot) { + import.meta.webpackHot?.accept() + import.meta.webpackHot?.dispose(() => unmount?.()) +} + console.log('hello from content_scripts') if (document.readyState === 'complete') { - initial() + unmount = initial() || (() => {}) } else { document.addEventListener('readystatechange', () => { - if (document.readyState === 'complete') initial() + if (document.readyState === 'complete') unmount = initial() || (() => {}) }) } @@ -20,13 +26,32 @@ function initial() { // prevents conflicts with the host page's styles. // This way, styles from the extension won't leak into the host page. const shadowRoot = rootDiv.attachShadow({mode: 'open'}) + const style = new CSSStyleSheet() + shadowRoot.adoptedStyleSheets = [style] + fetchCSS().then((response) => style.replace(response)) + + if (import.meta.webpackHot) { + import.meta.webpackHot?.accept('./styles.css', () => { + fetchCSS().then((response) => style.replace(response)) + }) + } - // Inform Extension.js that the shadow root is available. - window.__EXTENSION_SHADOW_ROOT__ = shadowRoot + // Create container div and inject content + const contentDiv = document.createElement('div') + contentDiv.className = 'content_script' + contentDiv.innerHTML = getContentHtml() + + // Append the content div to shadow root + shadowRoot.appendChild(contentDiv) + + return () => { + rootDiv.remove() + } +} - shadowRoot.innerHTML = ` -
- ${getContentHtml()} -
- ` +async function fetchCSS() { + const cssUrl = new URL('./styles.css', import.meta.url) + const response = await fetch(cssUrl) + const text = await response.text() + return response.ok ? text : Promise.reject(text) } diff --git a/examples/content-typescript/content/scripts.ts b/examples/content-typescript/content/scripts.ts index 35aa66cc4..406fcaa56 100644 --- a/examples/content-typescript/content/scripts.ts +++ b/examples/content-typescript/content/scripts.ts @@ -1,47 +1,91 @@ -import './styles.css?inline_style' +// @ts-expect-error - Import handled by webpack import logo from '../images/logo.svg' +declare global { + interface ImportMeta { + webpackHot?: { + accept: (path?: string, callback?: (newModule: any) => void) => void + dispose: (callback: () => void) => void + } + } +} + +let unmount: () => void + +if (import.meta.webpackHot) { + import.meta.webpackHot?.accept() + import.meta.webpackHot?.dispose(() => unmount?.()) +} + console.log('hello from content_scripts') if (document.readyState === 'complete') { - initial() + unmount = initial() || (() => {}) } else { document.addEventListener('readystatechange', () => { - if (document.readyState === 'complete') initial() + if (document.readyState === 'complete') unmount = initial() || (() => {}) }) } function initial() { const rootDiv = document.createElement('div') rootDiv.id = 'extension-root' + document.body.appendChild(rootDiv) // Injecting content_scripts inside a shadow dom // prevents conflicts with the host page's styles. // This way, styles from the extension won't leak into the host page. const shadowRoot = rootDiv.attachShadow({mode: 'open'}) + const style = new CSSStyleSheet() + shadowRoot.adoptedStyleSheets = [style] + fetchCSS().then((response) => style.replace(response)) - // Tell Extension.js to use the shadow root as the root element - // to inject styles into. - // @ts-exspect-error - Ignore TS error for global variable - window.__EXTENSION_SHADOW_ROOT__ = shadowRoot + if (import.meta.webpackHot) { + import.meta.webpackHot?.accept('./styles.css', () => { + fetchCSS().then((response) => style.replace(response)) + }) + } - document.body.appendChild(rootDiv) + // Create container div + const contentDiv = document.createElement('div') + contentDiv.className = 'content_script' + + // Create and append logo image + const img = document.createElement('img') + img.className = 'content_logo' + img.src = logo + contentDiv.appendChild(img) + + // Create and append title + const title = document.createElement('h1') + title.className = 'content_title' + title.textContent = 'Welcome to your TypeScript Extension' + contentDiv.appendChild(title) + + // Create and append description paragraph + const desc = document.createElement('p') + desc.className = 'content_description' + desc.innerHTML = 'Learn more about creating cross-browser extensions at ' + + const link = document.createElement('a') + link.href = 'https://extension.js.org' + link.target = '_blank' + link.textContent = 'https://extension.js.org' + + desc.appendChild(link) + contentDiv.appendChild(desc) + + // Append the content div to shadow root + shadowRoot.appendChild(contentDiv) + + return () => { + rootDiv.remove() + } +} - shadowRoot.innerHTML = ` -
- -

- Welcome to your TypeScript Extension -

-

- Learn more about creating cross-browser extensions at - https://extension.js.org - -

-
- ` +async function fetchCSS() { + const cssUrl = new URL('./styles.css', import.meta.url) + const response = await fetch(cssUrl) + const text = await response.text() + return response.ok ? text : Promise.reject(text) } diff --git a/examples/content-vue/content/scripts.ts b/examples/content-vue/content/scripts.ts index 7422cb009..a0b99c3f0 100644 --- a/examples/content-vue/content/scripts.ts +++ b/examples/content-vue/content/scripts.ts @@ -1,34 +1,59 @@ import {createApp} from 'vue' import ContentApp from './ContentApp.vue' -import './styles.css?inline_style' + +let unmount: () => void + +if (import.meta.webpackHot) { + import.meta.webpackHot?.accept() + import.meta.webpackHot?.dispose(() => unmount?.()) +} + +if (document.readyState === 'complete') { + unmount = initial() || (() => {}) +} else { + document.addEventListener('readystatechange', () => { + if (document.readyState === 'complete') unmount = initial() || (() => {}) + }) +} + +console.log('Hello from content script') function initial() { - // Create a new div element and append it to the document's body const rootDiv = document.createElement('div') rootDiv.id = 'extension-root' document.body.appendChild(rootDiv) - // Inject content_scripts inside a shadow DOM - // to prevent conflicts with the host page's styles. + // Injecting content_scripts inside a shadow dom + // prevents conflicts with the host page's styles. + // This way, styles from the extension won't leak into the host page. const shadowRoot = rootDiv.attachShadow({mode: 'open'}) + const style = new CSSStyleSheet() + shadowRoot.adoptedStyleSheets = [style] + fetchCSS().then((response) => style.replace(response)) - window.__EXTENSION_SHADOW_ROOT__ = shadowRoot + if (import.meta.webpackHot) { + import.meta.webpackHot?.accept('./styles.css', () => { + fetchCSS().then((response) => style.replace(response)) + }) + } - // Create a container inside the shadow DOM for the Vue app - const shadowAppContainer = document.createElement('div') - shadowAppContainer.className = 'content_script' - shadowRoot.appendChild(shadowAppContainer) + // Create container for Vue app + const contentDiv = document.createElement('div') + contentDiv.className = 'content_script' + shadowRoot.appendChild(contentDiv) // Mount the Vue app to the container inside the shadow DOM const app = createApp(ContentApp) - app.mount(shadowAppContainer) + app.mount(contentDiv) + + return () => { + rootDiv.remove() + } } -// Initialize the app -if (document.readyState === 'complete') { - initial() -} else { - document.addEventListener('readystatechange', () => { - if (document.readyState === 'complete') initial() - }) +async function fetchCSS() { + const cssUrl = new URL('./styles.css', import.meta.url) + const response = await fetch(cssUrl) + const text = await response.text() + return response.ok ? text : Promise.reject(text) } diff --git a/examples/content/content/scripts.js b/examples/content/content/scripts.js index 46150272d..8a47b4565 100644 --- a/examples/content/content/scripts.js +++ b/examples/content/content/scripts.js @@ -1,13 +1,19 @@ -import './styles.css?inline_style' import logo from '../images/logo.svg' +let unmount + +if (import.meta.webpackHot) { + import.meta.webpackHot?.accept() + import.meta.webpackHot?.dispose(() => unmount?.()) +} + console.log('hello from content_scripts') if (document.readyState === 'complete') { - initial() + unmount = initial() || (() => {}) } else { document.addEventListener('readystatechange', () => { - if (document.readyState === 'complete') initial() + if (document.readyState === 'complete') unmount = initial() || (() => {}) }) } @@ -20,25 +26,57 @@ function initial() { // prevents conflicts with the host page's styles. // This way, styles from the extension won't leak into the host page. const shadowRoot = rootDiv.attachShadow({mode: 'open'}) + const style = new CSSStyleSheet() + shadowRoot.adoptedStyleSheets = [style] + fetchCSS().then((response) => style.replace(response)) + + if (import.meta.webpackHot) { + import.meta.webpackHot?.accept('./styles.css', () => { + fetchCSS().then((response) => style.replace(response)) + }) + } + + // Create container div + const contentDiv = document.createElement('div') + contentDiv.className = 'content_script' + + // Create and append logo image + const img = document.createElement('img') + img.className = 'content_logo' + img.src = logo + contentDiv.appendChild(img) + + // Create and append title + const title = document.createElement('h1') + title.className = 'content_title' + title.textContent = 'Welcome to your Content Script Extension' + contentDiv.appendChild(title) + + // Create and append description paragraph + const desc = document.createElement('p') + desc.className = 'content_description' + desc.innerHTML = 'Learn more about creating cross-browser extensions at ' + + const link = document.createElement('a') + + link.href = 'https://extension.js.org' + link.target = '_blank' + link.textContent = 'https://extension.js.org' + + desc.appendChild(link) + contentDiv.appendChild(desc) + + // Append the content div to shadow root + shadowRoot.appendChild(contentDiv) + + return () => { + rootDiv.remove() + } +} - // Inform Extension.js that the shadow root is available. - window.__EXTENSION_SHADOW_ROOT__ = shadowRoot - - shadowRoot.innerHTML = ` -
- -

- Welcome to your Content Script Extension -

-

- Learn more about creating cross-browser extensions at - https://extension.js.org - -

-
- ` +async function fetchCSS() { + const cssUrl = new URL('./styles.css', import.meta.url) + const response = await fetch(cssUrl) + const text = await response.text() + return response.ok ? text : Promise.reject(text) } diff --git a/examples/content/content/styles.css b/examples/content/content/styles.css index 1f69e11c2..7815cd3ca 100644 --- a/examples/content/content/styles.css +++ b/examples/content/content/styles.css @@ -12,6 +12,7 @@ flex-direction: column; gap: 1em; border-radius: 6px; + z-index: 9999; } .content_logo { diff --git a/examples/data.ts b/examples/data.ts index 43dcf6833..135faddbb 100644 --- a/examples/data.ts +++ b/examples/data.ts @@ -38,62 +38,35 @@ const JS_TEMPLATES: Template[] = [ hasEnv: false, configFiles: undefined }, - { - name: 'content-css-modules', - uiContext: ['content'], - uiFramework: undefined, - css: 'css', - hasBackground: false, - hasEnv: false, - configFiles: undefined - }, - { - name: 'content-less', - uiContext: ['content'], - uiFramework: undefined, - css: 'less', - hasBackground: false, - hasEnv: false, - configFiles: undefined - }, - { - name: 'content-less-modules', - uiContext: ['content'], - uiFramework: undefined, - css: 'less', - hasBackground: false, - hasEnv: false, - configFiles: undefined - }, - { - name: 'content-main-world', - uiContext: ['content'], - uiFramework: undefined, - css: 'css', - hasBackground: false, - hasEnv: false, - configFiles: undefined - }, - { - name: 'content-sass', - uiContext: ['content'], - uiFramework: undefined, - css: 'sass', - hasBackground: false, - hasEnv: false, - configFiles: undefined - }, - { - name: 'content-sass-modules', - uiContext: ['content'], - uiFramework: undefined, - css: 'sass', - hasBackground: false, - hasEnv: false, - configFiles: undefined - }, // { - // name: 'content-shadow-dom', + // name: 'content-css-modules', + // uiContext: ['content'], + // uiFramework: undefined, + // css: 'css', + // hasBackground: false, + // hasEnv: false, + // configFiles: undefined + // }, + // { + // name: 'content-less', + // uiContext: ['content'], + // uiFramework: undefined, + // css: 'less', + // hasBackground: false, + // hasEnv: false, + // configFiles: undefined + // }, + // { + // name: 'content-less-modules', + // uiContext: ['content'], + // uiFramework: undefined, + // css: 'less', + // hasBackground: false, + // hasEnv: false, + // configFiles: undefined + // }, + // { + // name: 'content-main-world', // uiContext: ['content'], // uiFramework: undefined, // css: 'css', @@ -101,6 +74,24 @@ const JS_TEMPLATES: Template[] = [ // hasEnv: false, // configFiles: undefined // }, + // { + // name: 'content-sass', + // uiContext: ['content'], + // uiFramework: undefined, + // css: 'sass', + // hasBackground: false, + // hasEnv: false, + // configFiles: undefined + // }, + // { + // name: 'content-sass-modules', + // uiContext: ['content'], + // uiFramework: undefined, + // css: 'sass', + // hasBackground: false, + // hasEnv: false, + // configFiles: undefined + // }, { name: 'declarative_net_request', uiContext: undefined, @@ -333,20 +324,20 @@ const FRAMEWORK_TEMPLATES: Template[] = [ hasEnv: false, configFiles: ['postcss.config.js', 'tailwind.config.js', 'tsconfig.json'] }, - // { - // name: 'content-extension-config', - // uiContext: ['content'], - // uiFramework: 'react', - // css: 'css', - // hasBackground: true, - // hasEnv: false, - // configFiles: [ - // 'extension.config.js', - // 'tsconfig.json', - // 'postcss.config.js', - // 'tailwind.config.js' - // ] - // }, + { + name: 'content-extension-config', + uiContext: ['content'], + uiFramework: 'react', + css: 'css', + hasBackground: true, + hasEnv: false, + configFiles: [ + 'extension.config.js', + 'tsconfig.json', + 'postcss.config.js', + 'tailwind.config.js' + ] + }, { name: 'new-vue', uiContext: ['newTab'], @@ -377,15 +368,6 @@ const FRAMEWORK_TEMPLATES: Template[] = [ ] const CONFIG_TEMPLATES: Template[] = [ - // { - // name: 'config-babel', - // uiContext: ['newTab'], - // uiFramework: undefined, - // css: 'css', - // hasBackground: false, - // hasEnv: false, - // configFiles: ['babel.config.json'] - // }, { name: 'new-config-eslint', uiContext: ['newTab'], diff --git a/examples/sidebar-shadcn/sidebar/styles.css b/examples/sidebar-shadcn/sidebar/styles.css index 3acafa2bd..8c5e8df61 100644 --- a/examples/sidebar-shadcn/sidebar/styles.css +++ b/examples/sidebar-shadcn/sidebar/styles.css @@ -72,11 +72,11 @@ @layer base { #root * { - @apply border-border; + @apply border; } #root { - @apply bg-background text-foreground h-screen; + @apply h-screen; font-feature-settings: 'rlig' 1, 'calt' 1; diff --git a/playwright.config.ts b/playwright.config.ts index 3542fd9c2..297e1faea 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -11,29 +11,54 @@ import {defineConfig, devices} from '@playwright/test' * See https://playwright.dev/docs/test-configuration. */ export default defineConfig({ - // timeout to 40s - timeout: 40 * 1000, + // Increase global timeout for CI environments + timeout: process.env.CI ? 90_000 : 60_000, testDir: './examples', - /* Run tests in files in parallel */ - fullyParallel: true, - /* Fail the build on CI if you accidentally left test.only in the source code. */ + + // Disable parallel execution if tests are interdependent + fullyParallel: false, + + // Prevent accidental test.only commits forbidOnly: !!process.env.CI, - /* Retry on CI only */ - retries: process.env.CI ? 2 : 0, - /* Opt out of parallel tests on CI. */ - workers: process.env.CI ? 1 : undefined, - /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: [['html', {outputFolder: 'e2e-report'}]], - /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + + // Increase retries for both CI and local + retries: process.env.CI ? 3 : 2, + + // Reduce concurrent workers to prevent resource contention + workers: process.env.CI ? 1 : 2, + + // Enhanced reporting for better debugging + reporter: [ + ['html', {outputFolder: 'e2e-report'}], + ['list'], + ['json', {outputFile: 'test-results.json'}] + ], + use: { - /* Base URL to use in actions like `await page.goto('/')`. */ - // baseURL: 'http://127.0.0.1:3000', + // Always collect traces for better debugging + trace: 'on', + + // Capture media for all test failures + screenshot: 'only-on-failure', + video: 'retain-on-failure', + + // Increase timeouts for network operations + actionTimeout: 20000, + navigationTimeout: 45000, + + // Stable viewport + viewport: {width: 1280, height: 720}, + + // Add additional stability settings + launchOptions: { + slowMo: process.env.CI ? 100 : 0 // Slow down operations in CI + }, - /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: 'retain-on-failure' + // Better error handling + ignoreHTTPSErrors: true }, - /* Configure projects for major browsers */ + // Focused browser testing projects: [ { name: 'chromium', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9a7235c9b..9c10f6af2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -825,30 +825,18 @@ importers: less-loader: specifier: ^12.2.0 version: 12.2.0(@rspack/core@1.2.8(@swc/helpers@0.5.15))(less@4.2.0)(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)) - postcss-flexbugs-fixes: - specifier: ^5.0.2 - version: 5.0.2(postcss@8.4.49) postcss-loader: specifier: ^8.1.1 version: 8.1.1(@rspack/core@1.2.8(@swc/helpers@0.5.15))(postcss@8.4.49)(typescript@5.7.2)(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)) - postcss-normalize: - specifier: ^13.0.1 - version: 13.0.1(browserslist@4.24.0)(postcss@8.4.49) postcss-preset-env: specifier: ^10.1.1 version: 10.1.1(postcss@8.4.49) - postcss-scss: - specifier: ^4.0.9 - version: 4.0.9(postcss@8.4.49) react-refresh: specifier: ^0.14.2 version: 0.14.2 resolve-url-loader: specifier: ^5.0.0 version: 5.0.0 - sass-embedded: - specifier: ^1.85.1 - version: 1.85.1 sass-loader: specifier: ^16.0.4 version: 16.0.4(@rspack/core@1.2.8(@swc/helpers@0.5.15))(sass-embedded@1.85.1)(sass@1.79.4)(webpack@5.97.1(@swc/core@1.10.1(@swc/helpers@0.5.15))(esbuild@0.24.0)) @@ -1695,9 +1683,6 @@ packages: '@csstools/css-parser-algorithms': ^3.0.4 '@csstools/css-tokenizer': ^3.0.3 - '@csstools/normalize.css@12.1.1': - resolution: {integrity: sha512-YAYeJ+Xqh7fUou1d1j9XHl44BmsuThiTr4iNrgCQ3J27IbhXsxXDGZ1cXv8Qvs99d4rBbLiSKy3+WZiet32PcQ==} - '@csstools/postcss-cascade-layers@5.0.1': resolution: {integrity: sha512-XOfhI7GShVcKiKwmPAnWSqd2tBR0uxt+runAxttbSp/LY2U16yAVPmAf7e9q4JJ0d+xMNmpwNDLBXnmRCl3HMQ==} engines: {node: '>=18'} @@ -5649,13 +5634,6 @@ packages: peerDependencies: postcss: ^8.4 - postcss-browser-comments@6.0.1: - resolution: {integrity: sha512-VE5mVLOW+L31a+Eyi7i5j7PmzOydObKLA9VwGBpTZy2OYB3XY1E7/xHxv4tURtEI/qb5h2TyyGHPhZ31sXOEXg==} - engines: {node: '>=18'} - peerDependencies: - browserslist: ^4.23.1 - postcss: ^8.4 - postcss-clamp@4.1.0: resolution: {integrity: sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow==} engines: {node: '>=7.6.0'} @@ -5710,11 +5688,6 @@ packages: peerDependencies: postcss: ^8.4 - postcss-flexbugs-fixes@5.0.2: - resolution: {integrity: sha512-18f9voByak7bTktR2QgDveglpn9DTbBWPUzSOe9g0N4WR/2eSt6Vrcbf0hmspvMI6YWGywz6B9f7jzpFNJJgnQ==} - peerDependencies: - postcss: ^8.1.4 - postcss-focus-visible@10.0.1: resolution: {integrity: sha512-U58wyjS/I1GZgjRok33aE8juW9qQgQUNwTSdxQGuShHzwuYdcklnvK/+qOWX1Q9kr7ysbraQ6ht6r+udansalA==} engines: {node: '>=18'} @@ -5826,13 +5799,6 @@ packages: peerDependencies: postcss: ^8.4 - postcss-normalize@13.0.1: - resolution: {integrity: sha512-oGfXG7IQ44FUIMFco2N2Uz71UotM+tZ9trEmT1bHIUR5gAplyG3RnHqpMDEcCx1r+1bwBJTrI5uhiQr4YOpqhQ==} - engines: {node: '>= 18'} - peerDependencies: - browserslist: '>= 4' - postcss: '>= 8' - postcss-opacity-percentage@3.0.0: resolution: {integrity: sha512-K6HGVzyxUxd/VgZdX04DCtdwWJ4NGLG212US4/LA1TLAbHgmAsTWVR86o+gGIbFtnTkfOpb9sCRBx8K7HO66qQ==} engines: {node: '>=18'} @@ -6175,9 +6141,6 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - sanitize.css@13.0.0: - resolution: {integrity: sha512-ZRwKbh/eQ6w9vmTjkuG0Ioi3HBwPFce0O+v//ve+aOq1oeCy7jMV2qzzAlpsNuqpqCBjjriM1lbtZbF/Q8jVyA==} - sass-embedded-android-arm64@1.85.1: resolution: {integrity: sha512-27oRheqNA3SJM2hAxpVbs7mCKUwKPWmEEhyiNFpBINb5ELVLg+Ck5RsGg+SJmo130ul5YX0vinmVB5uPWc8X5w==} engines: {node: '>=14.0.0'} @@ -8349,9 +8312,6 @@ snapshots: '@csstools/css-tokenizer': 3.0.3 optional: true - '@csstools/normalize.css@12.1.1': - optional: true - '@csstools/postcss-cascade-layers@5.0.1(postcss@8.4.49)': dependencies: '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.0.0) @@ -12836,12 +12796,6 @@ snapshots: postcss-selector-parser: 7.0.0 optional: true - postcss-browser-comments@6.0.1(browserslist@4.24.0)(postcss@8.4.49): - dependencies: - browserslist: 4.24.0 - postcss: 8.4.49 - optional: true - postcss-clamp@4.1.0(postcss@8.4.49): dependencies: postcss: 8.4.49 @@ -12914,11 +12868,6 @@ snapshots: postcss-value-parser: 4.2.0 optional: true - postcss-flexbugs-fixes@5.0.2(postcss@8.4.49): - dependencies: - postcss: 8.4.49 - optional: true - postcss-focus-visible@10.0.1(postcss@8.4.49): dependencies: postcss: 8.4.49 @@ -13028,15 +12977,6 @@ snapshots: postcss-selector-parser: 7.0.0 optional: true - postcss-normalize@13.0.1(browserslist@4.24.0)(postcss@8.4.49): - dependencies: - '@csstools/normalize.css': 12.1.1 - browserslist: 4.24.0 - postcss: 8.4.49 - postcss-browser-comments: 6.0.1(browserslist@4.24.0)(postcss@8.4.49) - sanitize.css: 13.0.0 - optional: true - postcss-opacity-percentage@3.0.0(postcss@8.4.49): dependencies: postcss: 8.4.49 @@ -13468,7 +13408,7 @@ snapshots: rxjs@7.8.2: dependencies: - tslib: 2.7.0 + tslib: 2.8.1 optional: true safe-buffer@5.1.2: {} @@ -13477,9 +13417,6 @@ snapshots: safer-buffer@2.1.2: {} - sanitize.css@13.0.0: - optional: true - sass-embedded-android-arm64@1.85.1: optional: true diff --git a/programs/develop/build.spec.ts b/programs/develop/build.spec.ts index a7a7a6e5a..d2eb2f60a 100644 --- a/programs/develop/build.spec.ts +++ b/programs/develop/build.spec.ts @@ -57,7 +57,22 @@ function distFileExists( } describe('extension build', () => { + afterAll(async () => { + // Clean up any mocks + jest.clearAllMocks() + // Clean up any remaining test artifacts + await removeAllTemplateDistFolders() + // Clear all timers + jest.useRealTimers() + // Clear any hanging handles + await new Promise((resolve) => setTimeout(resolve, 0)) + // Force garbage collection if available + if (global.gc) global.gc() + }, 30000) + beforeEach(async () => { + // Reset timers before each test + jest.useRealTimers() await removeAllTemplateDistFolders() }, 30000) @@ -75,75 +90,69 @@ describe('extension build', () => { const postCssConfig = path.join(templatePath, 'postcss.config.js') - // Dynamically mock the postcss.config.js file if it exists. - // Since the file is dynamically imported in the webpack config, - // we need to mock it before the webpack config is created. - if (fs.existsSync(postCssConfig)) { - jest.mock(postCssConfig, () => jest.fn()) - } - - await extensionBuild(templatePath, { - browser: SUPPORTED_BROWSERS[0] as 'chrome' - }) - - expect( - path.join( - templatePath, - 'dist', - SUPPORTED_BROWSERS[0], - 'manifest.json' - ) - ).toBeTruthy() - - const manifestText = fs.readFileSync( - path.join( - templatePath, - 'dist', - SUPPORTED_BROWSERS[0], - 'manifest.json' - ), - 'utf-8' - ) + try { + // Dynamically mock the postcss.config.js file if it exists + if (fs.existsSync(postCssConfig)) { + jest.mock(postCssConfig, () => jest.fn()) + } - const manifest: Manifest = JSON.parse(manifestText) - expect(manifest.name).toBeTruthy - expect(manifest.version).toBeTruthy - expect(manifest.manifest_version).toBeTruthy + await extensionBuild(templatePath, { + browser: SUPPORTED_BROWSERS[0] as 'chrome' + }) - if (template.name.includes('content')) { - // Since extension@2.0.0-beta-6, the content script is injected - // into the shadow DOM. Including in production mode. - // expect(manifest.content_scripts![0].css![0]).toEqual( - // 'content_scripts/content-0.css' - // ) + expect( + path.join( + templatePath, + 'dist', + SUPPORTED_BROWSERS[0], + 'manifest.json' + ) + ).toBeTruthy() - expect(manifest.content_scripts![0].js![0]).toEqual( - 'content_scripts/content-0.js' + const manifestText = fs.readFileSync( + path.join( + templatePath, + 'dist', + SUPPORTED_BROWSERS[0], + 'manifest.json' + ), + 'utf-8' ) - // Since extension@2.0.0-beta-6, the content script is injected - // into the shadow DOM. Including in production mode. - // expect( - // distFileExists( - // template.name, - // SUPPORTED_BROWSERS[0], - // 'content_scripts/content-0.css' - // ) - // ).toBeTruthy() + const manifest: Manifest = JSON.parse(manifestText) + expect(manifest.name).toBeTruthy() + expect(manifest.version).toBeTruthy() + expect(manifest.manifest_version).toBeTruthy() - expect( - distFileExists( - template.name, - SUPPORTED_BROWSERS[0], + if (template.name.includes('content')) { + expect(manifest.content_scripts![0].js![0]).toEqual( 'content_scripts/content-0.js' ) - ).toBeTruthy() + + expect( + distFileExists( + template.name, + SUPPORTED_BROWSERS[0], + 'content_scripts/content-0.js' + ) + ).toBeTruthy() + } + } finally { + // Clean up mocks after each test + if (fs.existsSync(postCssConfig)) { + jest.unmock(postCssConfig) + } } }, 80000 ) }) + afterEach(async () => { + jest.clearAllTimers() + await new Promise((resolve) => setImmediate(resolve)) + }) + describe('using the --browser flag', () => { it.each(ALL_TEMPLATES)( `builds the "$name" extension template across all supported browsers`, @@ -157,16 +166,25 @@ describe('extension build', () => { ) // Running browsers in parallel by invoking them sequentially - await Promise.all( - SUPPORTED_BROWSERS.filter((browser) => browser !== 'chrome').map( - async (browser) => { - await extensionBuild(templatePath, {browser: browser as any}) - expect( - path.join(templatePath, SUPPORTED_BROWSERS[0], 'manifest.json') - ).toBeTruthy() - } + try { + await Promise.all( + SUPPORTED_BROWSERS.filter((browser) => browser !== 'chrome').map( + async (browser) => { + await extensionBuild(templatePath, {browser: browser as any}) + expect( + path.join( + templatePath, + SUPPORTED_BROWSERS[0], + 'manifest.json' + ) + ).toBeTruthy() + } + ) ) - ) + } finally { + // Ensure promises are settled + await new Promise((resolve) => setImmediate(resolve)) + } }, 80000 ) diff --git a/programs/develop/commands/build.ts b/programs/develop/commands/build.ts index a18c7b4d0..2903f504c 100644 --- a/programs/develop/commands/build.ts +++ b/programs/develop/commands/build.ts @@ -37,7 +37,10 @@ export async function extensionBuild( const baseConfig: Configuration = webpackConfig(projectPath, { ...buildOptions, browser, - mode: 'production' + mode: 'production', + output: { + clean: true + } }) const allPluginsButBrowserRunners = baseConfig.plugins?.filter((plugin) => { diff --git a/programs/develop/commands/commands-lib/config-types.ts b/programs/develop/commands/commands-lib/config-types.ts index ff5aac0fe..4e590899d 100644 --- a/programs/develop/commands/commands-lib/config-types.ts +++ b/programs/develop/commands/commands-lib/config-types.ts @@ -34,7 +34,7 @@ export type ExtendedBrowserOptions = | NonBinaryOptions export interface DevOptions extends BrowserOptionsBase { - mode: 'development' | 'production' + mode: 'development' | 'production' | 'none' polyfill?: boolean // Narrow down the options based on `browser` chromiumBinary?: ChromiumOptions['chromiumBinary'] diff --git a/programs/develop/commands/preview.ts b/programs/develop/commands/preview.ts index 32d704324..a4ea639d3 100644 --- a/programs/develop/commands/preview.ts +++ b/programs/develop/commands/preview.ts @@ -39,7 +39,12 @@ export async function extensionPreview( browser, chromiumBinary: previewOptions.chromiumBinary, geckoBinary: previewOptions.geckoBinary, - startingUrl: previewOptions.startingUrl + startingUrl: previewOptions.startingUrl, + // Preview needs a build before running so + // we don't want to clean the output directory. + output: { + clean: false + } }) const onlyBrowserRunners = baseConfig.plugins?.filter((plugin) => { diff --git a/programs/develop/package.json b/programs/develop/package.json index c6dd0bd8c..df7d30c31 100644 --- a/programs/develop/package.json +++ b/programs/develop/package.json @@ -27,7 +27,7 @@ "compile": "tsup-node ./module.ts --format cjs --dts --target=node18 --minify && bash install_scripts.sh", "test": "jest --no-cache --testPathPattern='webpack/.*/__spec__/.*\\.spec\\.*'", "test:coverage": "jest --no-cache --testPathPattern='webpack/.*/__spec__/.*\\.spec\\.ts' --coverage", - "test:build": "jest ./build.spec.ts --no-cache" + "test:build": "jest ./build.spec.ts --no-cache --detectOpenHandles --forceExit" }, "dependencies": { "@colors/colors": "^1.6.0", @@ -97,14 +97,10 @@ "@rspack/plugin-react-refresh": "^1.0.1", "babel-loader": "^9.2.1", "less-loader": "^12.2.0", - "postcss-flexbugs-fixes": "^5.0.2", "postcss-loader": "^8.1.1", - "postcss-normalize": "^13.0.1", "postcss-preset-env": "^10.1.1", - "postcss-scss": "^4.0.9", "react-refresh": "^0.14.2", "resolve-url-loader": "^5.0.0", - "sass-embedded": "^1.85.1", "sass-loader": "^16.0.4", "svelte-loader": "^3.2.4", "svelte-preprocess": "^6.0.3", diff --git a/programs/develop/webpack/dev-server.ts b/programs/develop/webpack/dev-server.ts index e85fecff7..7e1419085 100644 --- a/programs/develop/webpack/dev-server.ts +++ b/programs/develop/webpack/dev-server.ts @@ -40,7 +40,10 @@ export async function devServer(projectPath: string, devOptions: DevOptions) { ...devOptions, ...commandConfig, ...browserConfig, - mode: 'development' + mode: 'development', + output: { + clean: true + } }) // Get webpack config defaults from extension.config.js diff --git a/programs/develop/webpack/plugin-css/common-style-loaders.ts b/programs/develop/webpack/plugin-css/common-style-loaders.ts index 3e1ad0af2..1e22ee77b 100644 --- a/programs/develop/webpack/plugin-css/common-style-loaders.ts +++ b/programs/develop/webpack/plugin-css/common-style-loaders.ts @@ -17,6 +17,7 @@ export async function commonStyleLoaders( ): Promise { const styleLoaders: RuleSetRule['use'] = [] + // Handle PostCSS for Tailwind, Sass, or Less if ( isUsingTailwind(projectPath) || isUsingSass(projectPath) || @@ -28,24 +29,26 @@ export async function commonStyleLoaders( } } + // Handle Sass/Less loaders if (opts.loader) { styleLoaders.push( - ...[ - { - loader: require.resolve('resolve-url-loader'), - options: { - sourceMap: opts.mode === 'development', - root: projectPath - } - }, - { - loader: require.resolve(opts.loader), - options: { - ...opts.loaderOptions, - sourceMap: opts.mode === 'development' - } + { + loader: require.resolve('resolve-url-loader'), + options: { + sourceMap: opts.mode === 'development', + root: projectPath } - ] + }, + { + // Use either external loader or builtin + loader: opts.loader.startsWith('builtin:') + ? opts.loader + : require.resolve(opts.loader), + options: { + ...opts.loaderOptions, + sourceMap: opts.mode === 'development' + } + } ) } diff --git a/programs/develop/webpack/plugin-css/css-in-content-script-loader.ts b/programs/develop/webpack/plugin-css/css-in-content-script-loader.ts new file mode 100644 index 000000000..073b62d54 --- /dev/null +++ b/programs/develop/webpack/plugin-css/css-in-content-script-loader.ts @@ -0,0 +1,25 @@ +import path from 'path' +import {commonStyleLoaders} from './common-style-loaders' +import {DevOptions} from '../../commands/commands-lib/config-types' +import {isContentScriptEntry} from './is-content-script' + +export async function cssInContentScriptLoader( + projectPath: string, + mode: DevOptions['mode'] +) { + const manifestPath = path.join(projectPath, 'manifest.json') + + return { + test: /\.css$/, + type: 'asset', + generator: { + // Add contenthash to avoid naming collisions between + // different content script CSS files + filename: 'content_scripts/[name].[contenthash:8].css' + }, + issuer: (issuer: string) => isContentScriptEntry(issuer, manifestPath), + use: await commonStyleLoaders(projectPath, { + mode: mode as 'development' | 'production' + }) + } +} diff --git a/programs/develop/webpack/plugin-css/css-in-html-loader.ts b/programs/develop/webpack/plugin-css/css-in-html-loader.ts new file mode 100644 index 000000000..7521c855a --- /dev/null +++ b/programs/develop/webpack/plugin-css/css-in-html-loader.ts @@ -0,0 +1,21 @@ +import path from 'path' +import {commonStyleLoaders} from './common-style-loaders' +import {DevOptions} from '../../commands/commands-lib/config-types' +import {isContentScriptEntry} from './is-content-script' + +export async function cssInHtmlLoader( + projectPath: string, + mode: DevOptions['mode'] +) { + const manifestPath = path.join(projectPath, 'manifest.json') + + return { + test: /\.css$/, + type: 'css', + // type: 'css' breaks content scripts so let's avoid it + issuer: (issuer: string) => !isContentScriptEntry(issuer, manifestPath), + use: await commonStyleLoaders(projectPath, { + mode: mode as 'development' | 'production' + }) + } +} diff --git a/programs/develop/webpack/plugin-css/css-tools/less.ts b/programs/develop/webpack/plugin-css/css-tools/less.ts index 85a550303..f9068c7ff 100644 --- a/programs/develop/webpack/plugin-css/css-tools/less.ts +++ b/programs/develop/webpack/plugin-css/css-tools/less.ts @@ -50,14 +50,32 @@ export async function maybeUseLess(projectPath: string): Promise { } return [ + // Regular .less files { test: /\.less$/, + exclude: /\.module\.less$/, + type: 'css', use: [ { - loader: require.resolve('less-loader') + loader: require.resolve('less-loader'), + options: { + sourceMap: true + } } - ], - type: 'css/auto' + ] + }, + // Module .less files + { + test: /\.module\.less$/, + type: 'css/module', + use: [ + { + loader: require.resolve('less-loader'), + options: { + sourceMap: true + } + } + ] } ] } diff --git a/programs/develop/webpack/plugin-css/css-tools/postcss.ts b/programs/develop/webpack/plugin-css/css-tools/postcss.ts index 5871c3beb..981da0151 100644 --- a/programs/develop/webpack/plugin-css/css-tools/postcss.ts +++ b/programs/develop/webpack/plugin-css/css-tools/postcss.ts @@ -89,17 +89,12 @@ export async function maybeUsePostCss( const postCssDependencies = [ 'postcss', 'postcss-loader', - 'postcss-scss', - 'postcss-flexbugs-fixes', - 'postcss-preset-env', - 'postcss-normalize' + 'postcss-preset-env' ] await installOptionalDependencies('PostCSS', postCssDependencies) } - // The compiler will exit after installing the dependencies - // as it can't read the new dependencies without a restart. console.log(messages.youAreAllSet('PostCSS')) process.exit(0) } @@ -108,11 +103,9 @@ export async function maybeUsePostCss( loader: require.resolve('postcss-loader'), options: { postcssOptions: { - parser: require.resolve('postcss-scss'), ident: 'postcss', config: path.resolve(projectPath, 'postcss.config.js'), plugins: [ - require.resolve('postcss-flexbugs-fixes'), [ require.resolve('postcss-preset-env'), { @@ -121,8 +114,7 @@ export async function maybeUsePostCss( }, stage: 3 } - ].filter(Boolean), - require.resolve('postcss-normalize') + ] ].filter(Boolean) }, sourceMap: opts.mode === 'development' diff --git a/programs/develop/webpack/plugin-css/css-tools/sass.ts b/programs/develop/webpack/plugin-css/css-tools/sass.ts index 7e4e0e38d..6590bf918 100644 --- a/programs/develop/webpack/plugin-css/css-tools/sass.ts +++ b/programs/develop/webpack/plugin-css/css-tools/sass.ts @@ -42,19 +42,12 @@ export async function maybeUseSass(projectPath: string): Promise { const postCssDependencies = [ 'postcss-loader', 'postcss-scss', - 'postcss-flexbugs-fixes', - 'postcss-preset-env', - 'postcss-normalize' + 'postcss-preset-env' ] await installOptionalDependencies('PostCSS', postCssDependencies) - const sassDependencies = [ - 'sass', - 'sass-loader', - 'sass-embedded', - 'resolve-url-loader' - ] + const sassDependencies = ['sass', 'sass-loader', 'resolve-url-loader'] await installOptionalDependencies('SASS', sassDependencies) @@ -65,21 +58,38 @@ export async function maybeUseSass(projectPath: string): Promise { } return [ + // Regular .sass/.scss files { test: /\.(sass|scss)$/, + exclude: /\.module\.(sass|scss)$/, + type: 'css', + use: [ + { + loader: require.resolve('sass-loader'), + options: { + sourceMap: true, + sassOptions: { + outputStyle: 'expanded' + } + } + } + ] + }, + // Module .sass/.scss files + { + test: /\.module\.(sass|scss)$/, + type: 'css/module', use: [ { loader: require.resolve('sass-loader'), options: { - // using `modern-compiler` and `sass-embedded` together - // significantly improve build performance, - // requires `sass-loader >= 14.2.1` - api: 'modern-compiler', - implementation: require.resolve('sass-embedded') + sourceMap: true, + sassOptions: { + outputStyle: 'expanded' + } } } - ], - type: 'css/auto' + ] } ] } diff --git a/programs/develop/webpack/plugin-css/index.ts b/programs/develop/webpack/plugin-css/index.ts index a9dcdce99..eaad3f9dd 100644 --- a/programs/develop/webpack/plugin-css/index.ts +++ b/programs/develop/webpack/plugin-css/index.ts @@ -4,11 +4,13 @@ import { type Compiler, type RuleSetRule } from '@rspack/core' -import {commonStyleLoaders} from './common-style-loaders' +import {DevOptions} from '../../commands/commands-lib/config-types' import {PluginInterface} from '../webpack-types' import {maybeUseSass} from './css-tools/sass' import {maybeUseLess} from './css-tools/less' import {maybeUseStylelint} from './css-tools/stylelint' +import {cssInContentScriptLoader} from './css-in-content-script-loader' +import {cssInHtmlLoader} from './css-in-html-loader' export class CssPlugin { public static readonly name: string = 'plugin-css' @@ -20,36 +22,41 @@ export class CssPlugin { } private async configureOptions(compiler: Compiler) { - const mode = compiler.options.mode || 'development' + const mode: DevOptions['mode'] = compiler.options.mode || 'development' const projectPath = path.dirname(this.manifestPath) const plugins: RspackPluginInstance[] = [] - - plugins.forEach((plugin) => plugin.apply(compiler)) - const maybeInstallStylelint = await maybeUseStylelint(projectPath) plugins.push(...maybeInstallStylelint) + // We have two main loaders: + // 1. cssInContentScriptLoader - for CSS in content scripts + // 2. cssInHtmlLoader - for CSS in HTML + // The reason is that for content scripts we need to use the asset loader + // because it's a content script and we need to load it as an asset. + // For HTML we need to use the css loader because it's a HTML file + // and we need to load it as a CSS file. const loaders: RuleSetRule[] = [ - { - test: /\.css$/, - exclude: /\.module\.css$/, - type: 'css', - use: await commonStyleLoaders(projectPath, { - mode: mode as 'development' | 'production' - }) - } + await cssInContentScriptLoader(projectPath, mode), + await cssInHtmlLoader(projectPath, mode) ] - compiler.options.plugins = [...compiler.options.plugins, ...plugins].filter( - Boolean - ) - + // Add Sass/Less support if needed const maybeInstallSass = await maybeUseSass(projectPath) const maybeInstallLess = await maybeUseLess(projectPath) - loaders.push(...maybeInstallSass) - loaders.push(...maybeInstallLess) + if (maybeInstallSass.length) { + loaders.push(...maybeInstallSass) + } + + if (maybeInstallLess.length) { + loaders.push(...maybeInstallLess) + } + + // Update compiler configuration + compiler.options.plugins = [...compiler.options.plugins, ...plugins].filter( + Boolean + ) compiler.options.module.rules = [ ...compiler.options.module.rules, ...loaders diff --git a/programs/develop/webpack/plugin-css/is-content-script.ts b/programs/develop/webpack/plugin-css/is-content-script.ts new file mode 100644 index 000000000..84c98a002 --- /dev/null +++ b/programs/develop/webpack/plugin-css/is-content-script.ts @@ -0,0 +1,26 @@ +import path from 'path' +import fs from 'fs' +import {Manifest} from '../../types' + +export function isContentScriptEntry( + absolutePath: string, + manifestPath: string +): boolean { + if (!absolutePath || !manifestPath) { + return false + } + const manifest: Manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) + + for (const content of manifest.content_scripts || []) { + if (content.js?.length) { + for (const js of content.js) { + const contentPath = path.resolve(path.dirname(manifestPath), js) + if (contentPath === absolutePath) { + return true + } + } + } + } + + return false +} diff --git a/programs/develop/webpack/plugin-extension/feature-manifest/steps/update-manifest.ts b/programs/develop/webpack/plugin-extension/feature-manifest/steps/update-manifest.ts index 4b06a563e..337e29a9c 100644 --- a/programs/develop/webpack/plugin-extension/feature-manifest/steps/update-manifest.ts +++ b/programs/develop/webpack/plugin-extension/feature-manifest/steps/update-manifest.ts @@ -1,4 +1,3 @@ -import path from 'path' import {Compiler, Compilation, sources} from '@rspack/core' import {getManifestOverrides} from '../manifest-overrides' import {getFilename, getManifestContent} from '../../../lib/utils' @@ -29,33 +28,6 @@ export class UpdateManifest { ) } - private applyProdOverrides( - compilation: Compilation, - overrides: Record - ) { - if (!overrides.content_scripts) return {} - - const outputPath = compilation.options.output?.path || '' - - // Collect all CSS assets in `content_scripts` for use in the manifest - const contentScriptsCss = compilation - .getAssets() - .filter( - (asset) => - asset.name.includes('content_scripts') && asset.name.endsWith('.css') - ) - .map((asset) => path.join(outputPath, asset.name)) - - // Assign the collected CSS files to each `content_scripts` entry - for (const contentObj of overrides.content_scripts) { - contentObj.css = contentScriptsCss.map((cssFilePath, index) => - getFilename(`content_scripts/content-${index}.css`, cssFilePath, {}) - ) - } - - return overrides.content_scripts - } - apply(compiler: Compiler) { compiler.hooks.thisCompilation.tap( 'manifest:update-manifest', @@ -126,13 +98,6 @@ export class UpdateManifest { ...JSON.parse(overrides) } - if (patchedManifest.content_scripts) { - patchedManifest.content_scripts = this.applyProdOverrides( - compilation, - patchedManifest - ) - } - const source = JSON.stringify(patchedManifest, null, 2) const rawSource = new sources.RawSource(source) diff --git a/programs/develop/webpack/plugin-extension/feature-resolve/steps/loader-types.ts b/programs/develop/webpack/plugin-extension/feature-resolve/steps/loader-types.ts index 319e46af8..0b06e18c6 100644 --- a/programs/develop/webpack/plugin-extension/feature-resolve/steps/loader-types.ts +++ b/programs/develop/webpack/plugin-extension/feature-resolve/steps/loader-types.ts @@ -11,5 +11,6 @@ export interface ResolvePluginContext extends LoaderContext { excludeList: FilepathList typescript: boolean jsx: boolean + mode: string } } diff --git a/programs/develop/webpack/plugin-extension/feature-scripts/index.ts b/programs/develop/webpack/plugin-extension/feature-scripts/index.ts index f929bc531..1c14ac0dd 100644 --- a/programs/develop/webpack/plugin-extension/feature-scripts/index.ts +++ b/programs/develop/webpack/plugin-extension/feature-scripts/index.ts @@ -46,25 +46,24 @@ export class ScriptsPlugin { }).apply(compiler) // 2 - Ensure scripts are HMR enabled by adding the HMR accept code. - if (compiler.options.mode === 'development') { - compiler.options.module.rules.push({ - test: /\.(js|mjs|jsx|mjsx|ts|mts|tsx|mtsx)$/, - include: [path.dirname(this.manifestPath)], - exclude: [/[\\/]node_modules[\\/]/], - use: [ - { - loader: require.resolve( - path.join(__dirname, 'add-hmr-accept-code.js') - ), - options: { - manifestPath: this.manifestPath, - includeList: this.includeList || {}, - excludeList: this.excludeList || {} - } + compiler.options.module.rules.push({ + test: /\.(js|mjs|jsx|mjsx|ts|mts|tsx|mtsx)$/, + include: [path.dirname(this.manifestPath)], + exclude: [/[\\/]node_modules[\\/]/], + use: [ + { + loader: require.resolve( + path.join(__dirname, 'add-hmr-accept-code.js') + ), + options: { + manifestPath: this.manifestPath, + mode: compiler.options.mode, + includeList: this.includeList || {}, + excludeList: this.excludeList || {} } - ] - }) - } + } + ] + }) // 3 - Fix the issue with the public path not being // available for content_scripts in the production build. diff --git a/programs/develop/webpack/plugin-extension/feature-scripts/steps/add-hmr-accept-code.ts b/programs/develop/webpack/plugin-extension/feature-scripts/steps/add-hmr-accept-code.ts index ca763fe1c..755b4456f 100644 --- a/programs/develop/webpack/plugin-extension/feature-scripts/steps/add-hmr-accept-code.ts +++ b/programs/develop/webpack/plugin-extension/feature-scripts/steps/add-hmr-accept-code.ts @@ -43,6 +43,9 @@ const schema: Schema = { }, manifestPath: { type: 'string' + }, + mode: { + type: 'string' } } } @@ -58,12 +61,10 @@ export default function (this: LoaderContext, source: string) { baseDataPath: 'options' }) - // @ts-expect-error this is not typed - if (this._compilation?.options.mode === 'production') return source - const url = urlToRequest(this.resourcePath) const reloadCode = ` -if (import.meta.webpackHot) { import.meta.webpackHot.accept() }; +// TODO: cezaraugusto re-visit this +// if (import.meta.webpackHot) { import.meta.webpackHot.accept() }; ` // 1 - Handle background.scripts. diff --git a/programs/develop/webpack/plugin-extension/feature-web-resources/__spec__/index.spec.ts b/programs/develop/webpack/plugin-extension/feature-web-resources/__spec__/index.spec.ts index 4f9478471..48b799239 100644 --- a/programs/develop/webpack/plugin-extension/feature-web-resources/__spec__/index.spec.ts +++ b/programs/develop/webpack/plugin-extension/feature-web-resources/__spec__/index.spec.ts @@ -157,7 +157,7 @@ describe('generateManifestPatches', () => { }) }) - it('should exclude .map, .css, and .js files from web_accessible_resources', () => { + it('should exclude .map and .js files from web_accessible_resources', () => { expect( runWith( { @@ -183,7 +183,10 @@ describe('generateManifestPatches', () => { web_accessible_resources: [ { matches: ['*://example.com/*'], - resources: ['content_scripts/content-0.svg'] + resources: [ + 'content_scripts/content-0.css', + 'content_scripts/content-0.svg' + ] } ] }) diff --git a/programs/develop/webpack/plugin-extension/feature-web-resources/index.ts b/programs/develop/webpack/plugin-extension/feature-web-resources/index.ts index 7413c7424..d1bf1ba4c 100644 --- a/programs/develop/webpack/plugin-extension/feature-web-resources/index.ts +++ b/programs/develop/webpack/plugin-extension/feature-web-resources/index.ts @@ -48,10 +48,7 @@ export class WebResourcesPlugin { // No need to add the output .css and .js to web_accessible_resources const filteredResources = resources.filter( - (resource) => - !resource.endsWith('.map') && - !resource.endsWith('.css') && - !resource.endsWith('.js') + (resource) => !resource.endsWith('.map') && !resource.endsWith('.js') ) if (filteredResources.length === 0) { @@ -73,7 +70,7 @@ export class WebResourcesPlugin { } else { webAccessibleResourcesV3.push({ resources: filteredResources, - matches: cleanMatches(matches) // Clean matches to conform to the spec + matches: cleanMatches(matches) }) } } else { @@ -92,18 +89,12 @@ export class WebResourcesPlugin { manifest.web_accessible_resources = webAccessibleResourcesV3 as Manifest['web_accessible_resources'] } - // else { - // // Do nothing - // } } else { if (webAccessibleResourcesV2.length > 0) { manifest.web_accessible_resources = Array.from( new Set(webAccessibleResourcesV2) ) as Manifest['web_accessible_resources'] } - // else { - // // Do nothing - // } } const source = JSON.stringify(manifest, null, 2) diff --git a/programs/develop/webpack/plugin-js-frameworks/index.ts b/programs/develop/webpack/plugin-js-frameworks/index.ts index 78e922360..773c5bb07 100644 --- a/programs/develop/webpack/plugin-js-frameworks/index.ts +++ b/programs/develop/webpack/plugin-js-frameworks/index.ts @@ -55,8 +55,8 @@ export class JsFrameworksPlugin { minify: mode === 'production', isModule: true, sourceMap: this.mode === 'development', + env: {targets: ['chrome >= 100']}, jsc: { - target: 'es2016', parser: { syntax: isUsingTypeScript(projectPath) ? 'typescript' diff --git a/programs/develop/webpack/plugin-js-frameworks/js-tools/vue.ts b/programs/develop/webpack/plugin-js-frameworks/js-tools/vue.ts index 0d76025c8..316c0d580 100644 --- a/programs/develop/webpack/plugin-js-frameworks/js-tools/vue.ts +++ b/programs/develop/webpack/plugin-js-frameworks/js-tools/vue.ts @@ -7,6 +7,7 @@ import path from 'path' import fs from 'fs' +import {VueLoaderPlugin} from 'vue-loader' import * as messages from '../../lib/messages' import {installOptionalDependencies} from '../../lib/utils' import {JsFramework} from '../../webpack-types' @@ -46,20 +47,10 @@ export async function maybeUseVue( try { require.resolve('vue-loader') } catch (e) { - const typeScriptDependencies = ['typescript'] - - await installOptionalDependencies('TypeScript', typeScriptDependencies) - - const vueDependencies = [ - 'vue-loader', - 'vue-template-compiler', - 'vue-style-loader' - ] + const vueDependencies = ['vue-loader', 'vue-template-compiler'] await installOptionalDependencies('Vue', vueDependencies) - // The compiler will exit after installing the dependencies - // as it can't read the new dependencies without a restart. console.log(messages.youAreAllSet('Vue')) process.exit(0) } @@ -68,20 +59,16 @@ export async function maybeUseVue( { test: /\.vue$/, loader: require.resolve('vue-loader'), - include: projectPath, - exclude: [/[\\/]node_modules[\\/]/], options: { - // Note, for the majority of features to be available, - // make sure this option is `true`. - // https://rspack.dev/guide/tech/vue#vue-3 + // Note, for the majority of features to be available, make sure this option is `true` experimentalInlineMatchResource: true - } + }, + include: projectPath, + exclude: /node_modules/ } ] - const vuePlugins: JsFramework['plugins'] = [ - new (require('vue-loader').VueLoaderPlugin)() - ] + const vuePlugins: JsFramework['plugins'] = [new VueLoaderPlugin() as any] return { plugins: vuePlugins, diff --git a/programs/develop/webpack/webpack-config.ts b/programs/develop/webpack/webpack-config.ts index a0e279f28..e6d8dc5ed 100644 --- a/programs/develop/webpack/webpack-config.ts +++ b/programs/develop/webpack/webpack-config.ts @@ -26,6 +26,10 @@ export default function webpackConfig( devOptions: DevOptions & { preferences?: Record browserFlags?: string[] + } & { + output: { + clean: boolean + } } ): Configuration { const manifestPath = path.join(projectPath, 'manifest.json') @@ -44,6 +48,12 @@ export default function webpackConfig( ? 'gecko-based' : devOptions.browser + const cleanConfig = devOptions.output?.clean + ? devOptions.output.clean + : { + keep: devOptions.mode === 'development' ? 'hot' : undefined + } + return { mode: devOptions.mode || 'development', entry: {}, @@ -54,7 +64,7 @@ export default function webpackConfig( ? 'cheap-source-map' : 'eval-cheap-source-map', output: { - clean: false, + clean: cleanConfig, path: userExtensionOutputPath, // See https://webpack.js.org/configuration/output/#outputpublicpath publicPath: '/', @@ -93,6 +103,7 @@ export default function webpackConfig( } } }, + cache: false, plugins: [ new CompilationPlugin({ manifestPath, @@ -145,8 +156,9 @@ export default function webpackConfig( }) ].filter(Boolean), stats: { - colors: true, - errorDetails: true + all: false, + errors: true, + warnings: true }, infrastructureLogging: { level: 'none' diff --git a/programs/develop/webpack/webpack-types.ts b/programs/develop/webpack/webpack-types.ts index a2fe3416e..56310155d 100644 --- a/programs/develop/webpack/webpack-types.ts +++ b/programs/develop/webpack/webpack-types.ts @@ -63,6 +63,7 @@ export interface LoaderContext { browser?: DevOptions['browser'] includeList?: FilepathList excludeList?: FilepathList + mode: string } }