From 79362a171b1d9ae235d28fcc18b8dcb6e2328f80 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:12:41 +0300 Subject: [PATCH 01/24] fix(manifest): map execute action commands for MV2 --- src/cli/builders/manifest/Manifest.test.ts | 53 +++++++++++++++++++ src/cli/builders/manifest/ManifestBase.ts | 4 +- src/cli/builders/manifest/ManifestV2.test.ts | 38 +++++++++++++ src/cli/builders/manifest/ManifestV2.ts | 20 +++++++ src/cli/builders/manifest/ManifestV3.test.ts | 3 ++ src/entry/command/Builder.test.ts | 41 ++++++++++++++ .../build/action/action.integration.test.ts | 12 ++++- .../fixture/variants/named-action.command.ts | 6 +++ 8 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 src/entry/command/Builder.test.ts create mode 100644 tests/integration/build/action/fixture/variants/named-action.command.ts diff --git a/src/cli/builders/manifest/Manifest.test.ts b/src/cli/builders/manifest/Manifest.test.ts index 86940a60..635c2cd3 100644 --- a/src/cli/builders/manifest/Manifest.test.ts +++ b/src/cli/builders/manifest/Manifest.test.ts @@ -267,6 +267,59 @@ describe("Manifest common builder methods", () => { }); }); +describe.each([ + {version: 2, Builder: ManifestV2, actionCommand: "_execute_browser_action"}, + {version: 3, Builder: ManifestV3, actionCommand: "_execute_action"}, +] as const)("MV$version commands", ({Builder, actionCommand}) => { + it("preserves command options and ordinary names without changing the internal action name", () => { + const command = Object.freeze({ + name: CommandExecuteActionName, + defaultKey: "Ctrl+Shift+1", + windowsKey: "Ctrl+Shift+2", + macKey: "Ctrl+Shift+3", + chromeosKey: "Ctrl+Shift+4", + linuxKey: "Ctrl+Shift+5", + description: "Run the action", + global: true, + }); + + const manifest = new Builder(Browser.Chrome) + .setCommands( + new Set([ + command, + { + name: "open-settings", + defaultKey: "Ctrl+Shift+S", + macKey: "Command+Shift+S", + description: "Open settings", + global: false, + }, + ]) + ) + .build(); + + expect(manifest.commands).toEqual({ + [actionCommand]: { + suggested_key: { + default: "Ctrl+Shift+1", + windows: "Ctrl+Shift+2", + mac: "Ctrl+Shift+3", + chromeos: "Ctrl+Shift+4", + linux: "Ctrl+Shift+5", + }, + description: "Run the action", + global: true, + }, + "open-settings": { + suggested_key: {default: "Ctrl+Shift+S", mac: "Command+Shift+S"}, + description: "Open settings", + global: false, + }, + }); + expect(command.name).toBe("_execute_action"); + }); +}); + describe.each([ {version: 2, Builder: ManifestV2, field: "browser_action"}, {version: 3, Builder: ManifestV3, field: "action"}, diff --git a/src/cli/builders/manifest/ManifestBase.ts b/src/cli/builders/manifest/ManifestBase.ts index 8ee999c1..a4075a11 100644 --- a/src/cli/builders/manifest/ManifestBase.ts +++ b/src/cli/builders/manifest/ManifestBase.ts @@ -598,7 +598,9 @@ export default abstract class implements ManifestBuilder const commands = _.merge(this.combinedRaws.commands, internalCommands); - if (Object.keys(commands).length) return {commands}; + if (Object.keys(commands).length) { + return {commands}; + } } protected buildContentScripts(): Partial | undefined { diff --git a/src/cli/builders/manifest/ManifestV2.test.ts b/src/cli/builders/manifest/ManifestV2.test.ts index 1f0707f0..da7b2d45 100644 --- a/src/cli/builders/manifest/ManifestV2.test.ts +++ b/src/cli/builders/manifest/ManifestV2.test.ts @@ -49,6 +49,44 @@ describe("ManifestV2", () => { .build(); expect(manifest.browser_action).toEqual({default_title: "Command Addon"}); + expect(manifest.commands).toHaveProperty("_execute_browser_action"); + expect(manifest.commands).not.toHaveProperty("_execute_action"); + expect(manifest.commands._execute_browser_action.description).toBeUndefined(); + }); + + it("merges the renamed action with native raw command options across repeated builds", () => { + const builder = new ManifestV2(Browser.Chrome) + .setCommands( + new Set([ + { + name: CommandExecuteActionName, + defaultKey: "Ctrl+Shift+Y", + description: "Run the action", + }, + ]) + ) + .raw({ + commands: { + _execute_browser_action: { + suggested_key: {default: "Ctrl+Shift+O", mac: "Command+Shift+Y"}, + description: "Raw action", + global: false, + }, + "open-settings": {suggested_key: {default: "Ctrl+Shift+S"}, description: "Open settings"}, + }, + }); + + const expected = { + _execute_browser_action: { + suggested_key: {default: "Ctrl+Shift+Y", mac: "Command+Shift+Y"}, + description: "Run the action", + global: false, + }, + "open-settings": {suggested_key: {default: "Ctrl+Shift+S"}, description: "Open settings"}, + }; + + expect(builder.build().commands).toEqual(expected); + expect(builder.build().commands).toEqual(expected); }); it("builds sidebar_action only for MV2 browsers that support it", () => { diff --git a/src/cli/builders/manifest/ManifestV2.ts b/src/cli/builders/manifest/ManifestV2.ts index 09c49190..362f1345 100644 --- a/src/cli/builders/manifest/ManifestV2.ts +++ b/src/cli/builders/manifest/ManifestV2.ts @@ -1,9 +1,12 @@ +import _ from "lodash"; + import ManifestBase, {ManifestError} from "./ManifestBase"; import {filterHostPatterns, filterOptionalPermissions, filterPermissionsForMV2} from "./utils"; import {CoreManifest, ManifestVersion} from "@typing/manifest"; import {Browser} from "@typing/browser"; +import {CommandExecuteActionName} from "@typing/command"; import {SidebarAlternativeBrowsers} from "@typing/sidebar"; type ManifestV2 = chrome.runtime.ManifestV2; @@ -18,6 +21,23 @@ export default class extends ManifestBase { return 2; } + protected buildCommands(): Partial | undefined { + const manifest = super.buildCommands(); + + if (!manifest?.commands?.[CommandExecuteActionName]) { + return manifest; + } + + const {[CommandExecuteActionName]: action, ...commands} = manifest.commands; + + return { + commands: { + ...commands, + _execute_browser_action: _.merge({}, commands._execute_browser_action, action), + }, + }; + } + protected buildAction(): Partial | undefined { const action = this.getAction(); diff --git a/src/cli/builders/manifest/ManifestV3.test.ts b/src/cli/builders/manifest/ManifestV3.test.ts index a2819d8a..de8f958c 100644 --- a/src/cli/builders/manifest/ManifestV3.test.ts +++ b/src/cli/builders/manifest/ManifestV3.test.ts @@ -56,6 +56,9 @@ describe("ManifestV3", () => { .build(); expect(manifest.action).toEqual({default_title: "Command Addon"}); + expect(manifest.commands).toHaveProperty("_execute_action"); + expect(manifest.commands).not.toHaveProperty("_execute_browser_action"); + expect(manifest.commands._execute_action.description).toBeUndefined(); }); it("builds side_panel for Chrome and sidebar_action for alternative browsers", () => { diff --git a/src/entry/command/Builder.test.ts b/src/entry/command/Builder.test.ts new file mode 100644 index 00000000..79d64153 --- /dev/null +++ b/src/entry/command/Builder.test.ts @@ -0,0 +1,41 @@ +import Builder from "./Builder"; + +import {defineExecuteActionCommand} from "@main/command"; +import {getBrowserTest} from "@tests/browser-harness/session"; + +describe.each([ + {manifestVersion: 2, action: "browserAction"}, + {manifestVersion: 3, action: "action"}, +] as const)("MV$manifestVersion command handler", ({manifestVersion, action}) => { + it("connects the internal action command to the button and disconnects it on destroy", async () => { + const {harness} = getBrowserTest(); + + harness.runtime.setManifest({name: "Command Addon", version: "1.0.0", manifest_version: manifestVersion}); + + const execute = jest.fn(); + const definition = defineExecuteActionCommand({defaultKey: "Ctrl+Shift+Y", execute}); + const builder = new Builder(definition); + const tab = {id: 1} as chrome.tabs.Tab; + + expect(definition.name).toBe("_execute_action"); + + try { + await builder.build(); + await harness.configurable.active.commands.onCommand.emit("_execute_action", tab); + + expect(execute).not.toHaveBeenCalled(); + + await harness.configurable.active[action].onClicked.emit(tab); + + expect(execute).toHaveBeenCalledTimes(1); + expect(execute).toHaveBeenCalledWith(tab, {name: "_execute_action", defaultKey: "Ctrl+Shift+Y"}); + + await builder.destroy(); + await harness.configurable.active[action].onClicked.emit(tab); + + expect(execute).toHaveBeenCalledTimes(1); + } finally { + await builder.destroy(); + } + }); +}); diff --git a/tests/integration/build/action/action.integration.test.ts b/tests/integration/build/action/action.integration.test.ts index 15ab1d55..134b107a 100644 --- a/tests/integration/build/action/action.integration.test.ts +++ b/tests/integration/build/action/action.integration.test.ts @@ -24,6 +24,14 @@ describe.each([ icon: "active", title: "action", }, + { + scenario: "execute action from named exports", + entry: "named-action.command.ts", + popup: false, + command: true, + icon: "active", + title: "action", + }, { scenario: "popup overrides", entry: "full.popup.ts", @@ -79,7 +87,9 @@ describe.each([ expect((await stat(path.join(extensionDir, "icons", `${icon}-16.png`))).isFile()).toBe(true); if (command) { - expect(manifest.commands).toBeDefined(); + const name = manifestVersion === 2 ? "_execute_browser_action" : "_execute_action"; + + expect(manifest.commands).toEqual({[name]: {suggested_key: {default: "Ctrl+Shift+Y"}}}); expect(manifest.background).toBeDefined(); } else { expect(manifest.commands).toBeUndefined(); diff --git a/tests/integration/build/action/fixture/variants/named-action.command.ts b/tests/integration/build/action/fixture/variants/named-action.command.ts new file mode 100644 index 00000000..64a87337 --- /dev/null +++ b/tests/integration/build/action/fixture/variants/named-action.command.ts @@ -0,0 +1,6 @@ +export const name = "_execute_action"; +export const defaultKey = "Ctrl+Shift+Y"; + +export const execute = () => { + console.info("Action clicked"); +}; From a787bd21cddf83953b4954d15a3fe5f76b0060f9 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:18:57 +0300 Subject: [PATCH 02/24] fix(command): validate shortcuts for the target browser --- .../entrypoint/finder/CommandFinder.test.ts | 186 +++++++++++++++ src/cli/entrypoint/finder/CommandFinder.ts | 201 +++++++++++++++- .../command/compatibility/chromium.command.ts | 8 + .../command/compatibility/firefox.command.ts | 7 + .../command/compatibility/global.command.ts | 9 + .../command/compatibility/shared.command.ts | 3 + .../command/invalid-syntax/open.command.ts | 7 + .../command/shared-global/open.command.ts | 3 + .../command/shortcuts/alt-alias.command.ts | 8 + .../shortcuts/command-chromeos.command.ts | 8 + .../command/shortcuts/ctrl-command.command.ts | 7 + .../shortcuts/firefox-functions.command.ts | 11 + .../shortcuts/firefox-media.command.ts | 10 + .../shortcuts/four-chromeos.command.ts | 8 + .../command/shortcuts/four-mac.command.ts | 8 + .../command/shortcuts/function.command.ts | 7 + .../shortcuts/global-chromeos.command.ts | 9 + .../shortcuts/global-fallback.command.ts | 10 + .../shortcuts/global-macctrl.command.ts | 9 + .../shortcuts/global-overrides.command.ts | 11 + .../command/shortcuts/lowercase.command.ts | 7 + .../command/shortcuts/mac-default.command.ts | 7 + .../command/shortcuts/mac-pair.command.ts | 8 + .../shortcuts/macctrl-windows.command.ts | 8 + .../command/shortcuts/media.command.ts | 11 + .../command/shortcuts/option-linux.command.ts | 8 + .../command/shortcuts/search-alt.command.ts | 8 + .../shortcuts/search-default.command.ts | 7 + .../command/shortcuts/search-linux.command.ts | 8 + .../command/shortcuts/search-mac.command.ts | 8 + .../shortcuts/search-windows.command.ts | 8 + .../command/shortcuts/shift.command.ts | 7 + .../fixtures/command/shortcuts/tab.command.ts | 7 + .../command/shortcuts/valid.command.ts | 11 + .../command/unsupported/open.command.ts | 4 + .../entrypoint/parser/CommandParser.test.ts | 219 +++++++++--------- src/cli/entrypoint/parser/CommandParser.ts | 121 +++++++--- .../command/invalid/empty-component.ts | 6 + .../tests/fixtures/command/invalid/empty.ts | 6 + .../command/invalid/function-range.ts | 6 + .../fixtures/command/invalid/global-type.ts | 3 + .../fixtures/command/invalid/key-order.ts | 6 + .../fixtures/command/invalid/key-type.ts | 3 + .../command/invalid/media-modifier.ts | 6 + .../command/invalid/missing-key-name.ts | 6 + .../fixtures/command/invalid/missing-key.ts | 3 + .../command/invalid/missing-modifier.ts | 6 + .../command/invalid/repeated-modifier.ts | 6 + .../command/invalid/trailing-separator.ts | 6 + .../fixtures/command/invalid/unknown-key.ts | 6 + .../command/invalid/unknown-modifier.ts | 6 + .../command/invalid/unsupported-key.ts | 6 + .../fixtures/command/invalid/whitespace.ts | 6 + .../command/invalid/wrong-separator.ts | 6 + .../command/options/firefox/open.command.ts | 3 + .../command/options/full/save.command.ts | 3 + .../options/shortcuts/characters.command.ts | 10 + .../options/shortcuts/editing.command.ts | 10 + .../options/shortcuts/functions.command.ts | 10 + .../options/shortcuts/media.command.ts | 10 + .../options/shortcuts/named.command.ts | 10 + .../options/shortcuts/navigation.command.ts | 10 + .../command/options/sibling/background.ts | 3 + .../build/command/command.integration.test.ts | 52 +++++ .../build/command/fixture/adnbn.config.ts | 3 + .../build/command/fixture/package.json | 9 + .../command/fixture/src/firefox.command.ts | 11 + .../command/fixture/src/global.command.ts | 9 + .../build/command/fixture/src/save.command.ts | 13 ++ .../build/command/fixture/tsconfig.json | 3 + 70 files changed, 1108 insertions(+), 141 deletions(-) create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/compatibility/chromium.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/compatibility/firefox.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/compatibility/global.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/compatibility/shared.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/invalid-syntax/open.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shared-global/open.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/alt-alias.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/command-chromeos.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/ctrl-command.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/firefox-functions.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/firefox-media.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/four-chromeos.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/four-mac.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/function.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/global-chromeos.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/global-fallback.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/global-macctrl.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/global-overrides.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/lowercase.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/mac-default.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/mac-pair.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/macctrl-windows.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/media.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/option-linux.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/search-alt.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/search-default.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/search-linux.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/search-mac.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/search-windows.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/shift.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/tab.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/valid.command.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/command/unsupported/open.command.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/invalid/empty-component.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/invalid/empty.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/invalid/function-range.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/invalid/global-type.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/invalid/key-order.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/invalid/key-type.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/invalid/media-modifier.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/invalid/missing-key-name.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/invalid/missing-key.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/invalid/missing-modifier.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/invalid/repeated-modifier.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/invalid/trailing-separator.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/invalid/unknown-key.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/invalid/unknown-modifier.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/invalid/unsupported-key.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/invalid/whitespace.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/invalid/wrong-separator.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/options/firefox/open.command.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/characters.command.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/editing.command.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/functions.command.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/media.command.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/named.command.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/navigation.command.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/command/options/sibling/background.ts create mode 100644 tests/integration/build/command/command.integration.test.ts create mode 100644 tests/integration/build/command/fixture/adnbn.config.ts create mode 100644 tests/integration/build/command/fixture/package.json create mode 100644 tests/integration/build/command/fixture/src/firefox.command.ts create mode 100644 tests/integration/build/command/fixture/src/global.command.ts create mode 100644 tests/integration/build/command/fixture/src/save.command.ts create mode 100644 tests/integration/build/command/fixture/tsconfig.json diff --git a/src/cli/entrypoint/finder/CommandFinder.test.ts b/src/cli/entrypoint/finder/CommandFinder.test.ts index 6ec802f1..e3533b30 100644 --- a/src/cli/entrypoint/finder/CommandFinder.test.ts +++ b/src/cli/entrypoint/finder/CommandFinder.test.ts @@ -3,6 +3,7 @@ import path from "path"; import CommandFinder from "./CommandFinder"; import {toPosix} from "@cli/utils/path"; +import {Browser} from "@typing/browser"; import type {ReadonlyConfig} from "@typing/config"; @@ -11,6 +12,7 @@ const fixtures = path.resolve(__dirname, "tests", "fixtures", "command"); const makeFinder = (scenario: string, overrides: Partial = {}): CommandFinder => { const config = { app: "app", + browser: Browser.Chrome, appSrcDir: ".", appsDir: "apps", debug: false, @@ -34,6 +36,190 @@ const source = (scenario: string, file: string): string => { }; describe("CommandFinder", () => { + test("reports compatibility errors for a selected command using the build browser", async () => { + await expect(makeFinder("unsupported").commands()).rejects.toThrow( + `Invalid command options in "${source("unsupported", "open.command.ts")}": defaultKey="Ctrl+Alt+Y" for chrome/default` + ); + await expect(makeFinder("unsupported", {browser: Browser.Firefox}).commands()).resolves.toHaveProperty( + "size", + 1 + ); + }); + + test.each([ + [Browser.Chrome, ["chromium", "global", "shared"]], + [Browser.Firefox, ["firefox", "shared"]], + [Browser.Safari, ["shared"]], + ] as const)("validates only commands selected for %s", async (browser, names) => { + const commands = await makeFinder("compatibility", {browser}).commands(); + + expect(Array.from(commands.values(), ({name}) => name).sort()).toEqual(names); + }); + + test.each([ + [ + "valid", + { + defaultKey: "Ctrl+Tab", + windowsKey: "Alt+Shift+U", + macKey: "Option+Shift+U", + chromeosKey: "Search+Ctrl+Y", + linuxKey: "Ctrl+Shift+Comma", + }, + ], + ["mac-pair", {macKey: "Command+MacCtrl+Y"}], + [ + "global-overrides", + { + global: true, + defaultKey: "Ctrl+Y", + windowsKey: "Ctrl+Shift+1", + linuxKey: "Ctrl+Shift+2", + macKey: "Command+Shift+3", + }, + ], + [ + "media", + { + global: true, + defaultKey: "MediaPlayPause", + windowsKey: "MediaNextTrack", + macKey: "MediaPrevTrack", + linuxKey: "MediaStop", + }, + ], + ] as const)("preserves compatible Chromium shortcuts for %s", async (app, options) => { + const commands = await makeFinder("shortcuts", {app}).commands(); + + expect(Array.from(commands.values())).toEqual([expect.objectContaining(options)]); + }); + + test.each([ + ["mac-default", "only supported in macKey"], + ["option-linux", "only supported in macKey"], + ["macctrl-windows", "only supported in macKey"], + ["command-chromeos", "only supported in macKey"], + ["search-default", "only supported in chromeosKey"], + ["search-windows", "only supported in chromeosKey"], + ["search-mac", "only supported in chromeosKey"], + ["search-linux", "only supported in chromeosKey"], + ["four-mac", "at most 3 tokens"], + ["four-chromeos", "at most 3 tokens"], + ["function", 'Unsupported Chromium key "F12"'], + ["lowercase", 'Unsupported Chromium key "y"'], + ["shift", "A Ctrl or Alt modifier is required"], + ["alt-alias", "AltGr"], + ["search-alt", "AltGr"], + ["global-fallback", 'defaultKey="Ctrl+Y" for chrome/linux'], + ["global-macctrl", 'macKey="MacCtrl+Shift+3" for chrome/mac'], + ["global-chromeos", "Global commands are not supported on ChromeOS"], + ])("rejects incompatible Chromium shortcuts for %s", async (app, reason) => { + const commands = makeFinder("shortcuts", {app}).commands(); + + await expect(commands).rejects.toThrow( + `Invalid command options in "${source("shortcuts", `${app}.command.ts`)}"` + ); + + await expect(commands).rejects.toThrow(reason); + }); + + test.each([Browser.Chromium, Browser.Edge, Browser.Opera])( + "applies Chromium compatibility rules to %s", + async browser => { + await expect(makeFinder("shortcuts", {app: "valid", browser}).commands()).resolves.toHaveProperty( + "size", + 1 + ); + + await expect(makeFinder("shortcuts", {app: "function", browser}).commands()).rejects.toThrow( + 'Unsupported Chromium key "F12"' + ); + } + ); + + test.each([ + ["function", {defaultKey: "Ctrl+F12"}], + ["mac-default", {defaultKey: "Command+Shift+P"}], + ["macctrl-windows", {windowsKey: "MacCtrl+Y"}], + [ + "firefox-functions", + { + defaultKey: "F1", + windowsKey: "Shift+F12", + macKey: "Command+MacCtrl+Y", + linuxKey: "Ctrl+Alt+Y", + chromeosKey: "Search+Ctrl+Shift+Y", + }, + ], + [ + "firefox-media", + { + defaultKey: "MediaPlayPause", + windowsKey: "MediaNextTrack", + macKey: "MediaPrevTrack", + linuxKey: "MediaStop", + }, + ], + ] as const)( + "preserves compatible Firefox shortcuts and ignores ChromeOS compatibility for %s", + async (app, options) => { + const commands = await makeFinder("shortcuts", {app, browser: Browser.Firefox}).commands(); + + expect(Array.from(commands.values())).toEqual([expect.objectContaining(options)]); + } + ); + + test.each([ + ["four-mac", "at most 3 tokens"], + ["tab", 'Unsupported Firefox key "Tab"'], + ["lowercase", 'Unsupported Firefox key "y"'], + ["option-linux", 'Unsupported Firefox modifier "Option"'], + ["search-default", 'Unsupported Firefox modifier "Search"'], + ["search-windows", 'Unsupported Firefox modifier "Search"'], + ["search-mac", 'Unsupported Firefox modifier "Search"'], + ["search-linux", 'Unsupported Firefox modifier "Search"'], + ["ctrl-command", "Ctrl and Command refer to the same Firefox modifier"], + ["shift", "Firefox requires a modifier other than Shift"], + ])("rejects incompatible Firefox shortcuts for %s", async (app, reason) => { + const commands = makeFinder("shortcuts", {app, browser: Browser.Firefox}).commands(); + + await expect(commands).rejects.toThrow( + `Invalid command options in "${source("shortcuts", `${app}.command.ts`)}"` + ); + + await expect(commands).rejects.toThrow(reason); + }); + + test.each([Browser.Chrome, Browser.Firefox])( + "rejects unknown keys before filtering commands for %s", + async browser => { + await expect(makeFinder("invalid-syntax", {browser}).commands()).rejects.toThrow( + `Invalid options defaultKey in "${source("invalid-syntax", "open.command.ts")}": "Ctrl+UnlistedKey": Unknown key "UnlistedKey"` + ); + } + ); + + test.each([Browser.Firefox, Browser.Safari])( + "warns about a shared global command for %s without rejecting it", + async browser => { + const warning = jest.spyOn(console, "warn").mockImplementation(() => {}); + + try { + const commands = await makeFinder("shared-global", {browser}).commands(); + + expect(Array.from(commands.values())).toEqual([ + expect.objectContaining({name: "open", global: true, defaultKey: "Ctrl+Shift+Y"}), + ]); + + expect(warning).toHaveBeenCalledWith( + `Command "${source("shared-global", "open.command.ts")}": global=true is not supported by ${browser}; the shortcut will remain browser-scoped` + ); + } finally { + warning.mockRestore(); + } + } + ); + test("keeps explicit, file-derived and action command names as they are", async () => { const root = path.join(fixtures, "unique"); const commands = await makeFinder("unique").commands(); diff --git a/src/cli/entrypoint/finder/CommandFinder.ts b/src/cli/entrypoint/finder/CommandFinder.ts index 7fcccc1e..aee78a05 100644 --- a/src/cli/entrypoint/finder/CommandFinder.ts +++ b/src/cli/entrypoint/finder/CommandFinder.ts @@ -5,10 +5,53 @@ import {CommandParser} from "../parser"; import {InlineNameGenerator} from "../name"; import {ReadonlyConfig} from "@typing/config"; +import {Browser} from "@typing/browser"; import {CommandEntrypointOptions, CommandExecuteActionName, CommandOptions} from "@typing/command"; import {EntrypointFile, EntrypointOptionsFinder, EntrypointParser, EntrypointType} from "@typing/entrypoint"; -export default class extends AbstractPluginFinder { +interface CommandShortcut { + modifiers: string[]; + key: string; +} + +type CommandShortcutOption = "defaultKey" | "windowsKey" | "macKey" | "chromeosKey" | "linuxKey"; + +const chromiumBrowsers: ReadonlySet = new Set([Browser.Chrome, Browser.Chromium, Browser.Edge, Browser.Opera]); +const macModifiers = new Set(["Command", "MacCtrl", "Option"]); +const firefoxModifiers = new Set(["Ctrl", "Alt", "Command", "MacCtrl", "Shift"]); +const mediaKeys = new Set(["MediaNextTrack", "MediaPlayPause", "MediaPrevTrack", "MediaStop"]); + +// Browser profiles are explicit so extending CommandParser's syntax cannot silently broaden compatibility. +// https://github.com/chromium/chromium/blob/main/ui/base/accelerators/command_constants.h +// https://github.com/mozilla-firefox/firefox/blob/main/toolkit/modules/ShortcutUtils.sys.mjs +const commonKeys = [ + "Comma", + "Period", + "Up", + "Down", + "Left", + "Right", + "Insert", + "Delete", + "Home", + "End", + "PageUp", + "PageDown", + "Space", +]; + +const chromiumKeys = new Set([...commonKeys, ...mediaKeys, "Tab"]); +const firefoxKeys = new Set([...commonKeys, ...mediaKeys]); + +const shortcutPlatforms: Record = { + defaultKey: "default", + windowsKey: "windows", + macKey: "mac", + chromeosKey: "chromeos", + linuxKey: "linux", +}; + +export default class CommandFinder extends AbstractPluginFinder { protected _commands?: Map; protected readonly names: InlineNameGenerator; @@ -43,6 +86,12 @@ export default class extends AbstractPluginFinder { const {name = this.names.derive(file), ...definition} = option; const owner = owners.get(name); + const warnings = this.validateShortcuts(file, option); + + for (const warning of warnings) { + console.warn(`Command "${file.file}": ${warning}`); + } + if (owner && name === CommandExecuteActionName) { throw new Error( `Invalid command options in "${file.file}": An action command is already defined in "${owner.file}". Only one action command is allowed` @@ -62,6 +111,156 @@ export default class extends AbstractPluginFinder { return commands; } + /** Check browser compatibility only after the parser and entrypoint filters have accepted the command. */ + private validateShortcuts(file: EntrypointFile, options: CommandEntrypointOptions): string[] { + const {browser} = this.config; + const chromium = chromiumBrowsers.has(browser); + const shortcuts = new Map(); + + const fail = (option: CommandShortcutOption, platform: string, reason: string): never => { + throw new Error( + `Invalid command options in "${file.file}": ${option}=${JSON.stringify(options[option])} for ${browser}/${platform}: ${reason}` + ); + }; + + for (const option of Object.keys(shortcutPlatforms) as CommandShortcutOption[]) { + const value = options[option]; + + // Firefox treats chromeosKey as an unused string, not a shortcut for one of its platforms. + if (value === undefined || (browser === Browser.Firefox && option === "chromeosKey")) { + continue; + } + + // CommandParser has already checked the structure and known token names. + const modifiers = value.split("+"); + const key = modifiers.pop()!; + const shortcut = {modifiers, key}; + + try { + if ((chromium || browser === Browser.Firefox) && modifiers.length > 2) { + // ChromeOS accepts four tokens, but desktop Chromium validates every platform override. + const reason = "Shortcuts must contain at most 3 tokens (2 modifiers and 1 key)"; + + throw new Error( + chromium + ? `${reason} for desktop Chromium compatibility; desktop Chromium also validates chromeosKey` + : reason + ); + } + + if (chromium) { + this.validateChromiumShortcut(shortcut, option); + } else if (browser === Browser.Firefox) { + this.validateFirefoxShortcut(shortcut); + } + } catch (error) { + fail(option, shortcutPlatforms[option], (error as Error).message); + } + + shortcuts.set(option, shortcut); + } + + if (!options.global) { + return []; + } + + if (!chromium) { + return [`global=true is not supported by ${browser}; the shortcut will remain browser-scoped`]; + } + + if (options.chromeosKey !== undefined) { + fail("chromeosKey", "chromeos", "Global commands are not supported on ChromeOS"); + } + + // https://developer.chrome.com/docs/extensions/reference/api/commands#scope + // defaultKey is a desktop fallback here; global commands cannot activate on ChromeOS. + for (const platformOption of ["windowsKey", "macKey", "linuxKey"] as const) { + const option = shortcuts.has(platformOption) ? platformOption : "defaultKey"; + const shortcut = shortcuts.get(option); + + if (!shortcut || mediaKeys.has(shortcut.key)) { + continue; + } + + const {modifiers, key} = shortcut; + + const primary = + modifiers.includes("Ctrl") || (platformOption === "macKey" && modifiers.includes("Command")); + + if (modifiers.length !== 2 || !primary || !modifiers.includes("Shift") || !/^[0-9]$/.test(key)) { + fail( + option, + shortcutPlatforms[platformOption], + "Global shortcuts must use Ctrl+Shift+[0..9] (Command+Shift+[0..9] also works on macOS)" + ); + } + } + + return []; + } + + private validateChromiumShortcut({modifiers, key}: CommandShortcut, option: CommandShortcutOption): void { + if (option !== "macKey" && modifiers.some(modifier => macModifiers.has(modifier))) { + throw new Error("Command, MacCtrl and Option are only supported in macKey"); + } + + if (option !== "chromeosKey" && modifiers.includes("Search")) { + throw new Error("Search is only supported in chromeosKey"); + } + + if (!/^[A-Z0-9]$/.test(key) && !chromiumKeys.has(key)) { + throw new Error( + `Unsupported Chromium key "${key}"; use an uppercase A-Z, a digit 0-9 or a supported named key` + ); + } + + if (mediaKeys.has(key)) { + return; + } + + // Mac aliases follow the same AltGr restriction. Search maps to Command on ChromeOS. + // https://github.com/chromium/chromium/blob/main/ui/base/accelerators/command.cc + if ( + modifiers.some(modifier => modifier === "Alt" || modifier === "Option") && + modifiers.some(modifier => ["Ctrl", "Command", "MacCtrl", "Search"].includes(modifier)) + ) { + throw new Error( + "Alt/Option cannot be combined with Ctrl, Command, MacCtrl or Search (Chromium's AltGr restriction)" + ); + } + + if (!modifiers.some(modifier => modifier !== "Shift")) { + throw new Error("A Ctrl or Alt modifier is required (macOS equivalents and Search on ChromeOS also work)"); + } + } + + private validateFirefoxShortcut({modifiers, key}: CommandShortcut): void { + const unsupportedModifier = modifiers.find(modifier => !firefoxModifiers.has(modifier)); + + if (unsupportedModifier) { + throw new Error( + `Unsupported Firefox modifier "${unsupportedModifier}"; use Ctrl, Alt, Command, MacCtrl or Shift` + ); + } + + const functionKey = /^F(?:[1-9]|1[0-2])$/.test(key); + + if (!/^[A-Z0-9]$/.test(key) && !functionKey && !firefoxKeys.has(key)) { + throw new Error( + `Unsupported Firefox key "${key}"; use an uppercase A-Z, a digit 0-9, F1-F12 or a supported named key` + ); + } + + // Firefox maps both tokens to "accel", even outside macOS. + if (modifiers.includes("Ctrl") && modifiers.includes("Command")) { + throw new Error("Ctrl and Command refer to the same Firefox modifier and cannot be combined"); + } + + if (!mediaKeys.has(key) && !functionKey && !modifiers.some(modifier => modifier !== "Shift")) { + throw new Error("Firefox requires a modifier other than Shift except for function keys and media keys"); + } + } + public async commands(): Promise> { return (this._commands ??= await this.getCommands()); } diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/compatibility/chromium.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/compatibility/chromium.command.ts new file mode 100644 index 00000000..7c229b78 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/compatibility/chromium.command.ts @@ -0,0 +1,8 @@ +import {Browser, defineCommand} from "adnbn"; + +export default defineCommand({ + excludeBrowser: [Browser.Firefox, Browser.Safari], + defaultKey: "Ctrl+Tab", + chromeosKey: "Search+Shift+Y", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/compatibility/firefox.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/compatibility/firefox.command.ts new file mode 100644 index 00000000..dd9cbf7a --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/compatibility/firefox.command.ts @@ -0,0 +1,7 @@ +import {Browser, defineCommand} from "adnbn"; + +export default defineCommand({ + includeBrowser: [Browser.Firefox], + defaultKey: "Ctrl+Alt+Y", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/compatibility/global.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/compatibility/global.command.ts new file mode 100644 index 00000000..abcf5053 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/compatibility/global.command.ts @@ -0,0 +1,9 @@ +import {Browser, defineCommand} from "adnbn"; + +export default defineCommand({ + includeBrowser: [Browser.Chrome], + global: true, + defaultKey: "Ctrl+Shift+5", + macKey: "Command+Shift+5", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/compatibility/shared.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/compatibility/shared.command.ts new file mode 100644 index 00000000..f7df7813 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/compatibility/shared.command.ts @@ -0,0 +1,3 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({defaultKey: "Ctrl+Shift+Y", execute() {}}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/invalid-syntax/open.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/invalid-syntax/open.command.ts new file mode 100644 index 00000000..2d8857ba --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/invalid-syntax/open.command.ts @@ -0,0 +1,7 @@ +import {Browser, defineCommand} from "adnbn"; + +export default defineCommand({ + includeBrowser: [Browser.Firefox], + defaultKey: "Ctrl+UnlistedKey", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shared-global/open.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shared-global/open.command.ts new file mode 100644 index 00000000..3f3f548b --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shared-global/open.command.ts @@ -0,0 +1,3 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({global: true, defaultKey: "Ctrl+Shift+Y", execute() {}}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/alt-alias.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/alt-alias.command.ts new file mode 100644 index 00000000..8d9161df --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/alt-alias.command.ts @@ -0,0 +1,8 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["alt-alias"], + defaultKey: "Ctrl+Shift+Y", + macKey: "Command+Option+Y", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/command-chromeos.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/command-chromeos.command.ts new file mode 100644 index 00000000..8626e949 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/command-chromeos.command.ts @@ -0,0 +1,8 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["command-chromeos"], + defaultKey: "Ctrl+Shift+Y", + chromeosKey: "Command+Y", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/ctrl-command.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/ctrl-command.command.ts new file mode 100644 index 00000000..2126c187 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/ctrl-command.command.ts @@ -0,0 +1,7 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["ctrl-command"], + defaultKey: "Ctrl+Command+Y", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/firefox-functions.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/firefox-functions.command.ts new file mode 100644 index 00000000..80c0a9a2 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/firefox-functions.command.ts @@ -0,0 +1,11 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["firefox-functions"], + defaultKey: "F1", + windowsKey: "Shift+F12", + macKey: "Command+MacCtrl+Y", + linuxKey: "Ctrl+Alt+Y", + chromeosKey: "Search+Ctrl+Shift+Y", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/firefox-media.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/firefox-media.command.ts new file mode 100644 index 00000000..a8af617d --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/firefox-media.command.ts @@ -0,0 +1,10 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["firefox-media"], + defaultKey: "MediaPlayPause", + windowsKey: "MediaNextTrack", + macKey: "MediaPrevTrack", + linuxKey: "MediaStop", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/four-chromeos.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/four-chromeos.command.ts new file mode 100644 index 00000000..c49f3ef4 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/four-chromeos.command.ts @@ -0,0 +1,8 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["four-chromeos"], + defaultKey: "Ctrl+Shift+Y", + chromeosKey: "Search+Ctrl+Shift+Y", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/four-mac.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/four-mac.command.ts new file mode 100644 index 00000000..634270f6 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/four-mac.command.ts @@ -0,0 +1,8 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["four-mac"], + defaultKey: "Ctrl+Shift+Y", + macKey: "Command+MacCtrl+Shift+Y", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/function.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/function.command.ts new file mode 100644 index 00000000..f0d8355c --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/function.command.ts @@ -0,0 +1,7 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["function"], + defaultKey: "Ctrl+F12", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/global-chromeos.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/global-chromeos.command.ts new file mode 100644 index 00000000..22637e41 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/global-chromeos.command.ts @@ -0,0 +1,9 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["global-chromeos"], + defaultKey: "Ctrl+Shift+5", + global: true, + chromeosKey: "Ctrl+Shift+5", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/global-fallback.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/global-fallback.command.ts new file mode 100644 index 00000000..c50d2a6e --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/global-fallback.command.ts @@ -0,0 +1,10 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["global-fallback"], + defaultKey: "Ctrl+Y", + global: true, + windowsKey: "Ctrl+Shift+1", + macKey: "Command+Shift+3", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/global-macctrl.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/global-macctrl.command.ts new file mode 100644 index 00000000..2aed40c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/global-macctrl.command.ts @@ -0,0 +1,9 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["global-macctrl"], + defaultKey: "Ctrl+Shift+5", + global: true, + macKey: "MacCtrl+Shift+3", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/global-overrides.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/global-overrides.command.ts new file mode 100644 index 00000000..c97f6c07 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/global-overrides.command.ts @@ -0,0 +1,11 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["global-overrides"], + defaultKey: "Ctrl+Y", + global: true, + windowsKey: "Ctrl+Shift+1", + linuxKey: "Ctrl+Shift+2", + macKey: "Command+Shift+3", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/lowercase.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/lowercase.command.ts new file mode 100644 index 00000000..63431f8f --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/lowercase.command.ts @@ -0,0 +1,7 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["lowercase"], + defaultKey: "Ctrl+y", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/mac-default.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/mac-default.command.ts new file mode 100644 index 00000000..d028f9d8 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/mac-default.command.ts @@ -0,0 +1,7 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["mac-default"], + defaultKey: "Command+Shift+P", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/mac-pair.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/mac-pair.command.ts new file mode 100644 index 00000000..3545c041 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/mac-pair.command.ts @@ -0,0 +1,8 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["mac-pair"], + defaultKey: "Ctrl+Shift+Y", + macKey: "Command+MacCtrl+Y", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/macctrl-windows.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/macctrl-windows.command.ts new file mode 100644 index 00000000..3102ba40 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/macctrl-windows.command.ts @@ -0,0 +1,8 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["macctrl-windows"], + defaultKey: "Ctrl+Shift+Y", + windowsKey: "MacCtrl+Y", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/media.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/media.command.ts new file mode 100644 index 00000000..5dff1eac --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/media.command.ts @@ -0,0 +1,11 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["media"], + defaultKey: "MediaPlayPause", + global: true, + windowsKey: "MediaNextTrack", + macKey: "MediaPrevTrack", + linuxKey: "MediaStop", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/option-linux.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/option-linux.command.ts new file mode 100644 index 00000000..f841de56 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/option-linux.command.ts @@ -0,0 +1,8 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["option-linux"], + defaultKey: "Ctrl+Shift+Y", + linuxKey: "Option+Shift+U", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/search-alt.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/search-alt.command.ts new file mode 100644 index 00000000..b895eb54 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/search-alt.command.ts @@ -0,0 +1,8 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["search-alt"], + defaultKey: "Ctrl+Shift+Y", + chromeosKey: "Search+Alt+Y", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/search-default.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/search-default.command.ts new file mode 100644 index 00000000..39e755dc --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/search-default.command.ts @@ -0,0 +1,7 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["search-default"], + defaultKey: "Search+Y", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/search-linux.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/search-linux.command.ts new file mode 100644 index 00000000..a8cbffb2 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/search-linux.command.ts @@ -0,0 +1,8 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["search-linux"], + defaultKey: "Ctrl+Shift+Y", + linuxKey: "Search+Y", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/search-mac.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/search-mac.command.ts new file mode 100644 index 00000000..d0ee668c --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/search-mac.command.ts @@ -0,0 +1,8 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["search-mac"], + defaultKey: "Ctrl+Shift+Y", + macKey: "Search+Y", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/search-windows.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/search-windows.command.ts new file mode 100644 index 00000000..8a3002b9 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/search-windows.command.ts @@ -0,0 +1,8 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["search-windows"], + defaultKey: "Ctrl+Shift+Y", + windowsKey: "Search+Y", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/shift.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/shift.command.ts new file mode 100644 index 00000000..7b43f738 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/shift.command.ts @@ -0,0 +1,7 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["shift"], + defaultKey: "Shift+Y", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/tab.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/tab.command.ts new file mode 100644 index 00000000..c45d4535 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/tab.command.ts @@ -0,0 +1,7 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["tab"], + defaultKey: "Ctrl+Tab", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/valid.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/valid.command.ts new file mode 100644 index 00000000..08567ab6 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/shortcuts/valid.command.ts @@ -0,0 +1,11 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + includeApp: ["valid"], + defaultKey: "Ctrl+Tab", + windowsKey: "Alt+Shift+U", + macKey: "Option+Shift+U", + chromeosKey: "Search+Ctrl+Y", + linuxKey: "Ctrl+Shift+Comma", + execute() {}, +}); diff --git a/src/cli/entrypoint/finder/tests/fixtures/command/unsupported/open.command.ts b/src/cli/entrypoint/finder/tests/fixtures/command/unsupported/open.command.ts new file mode 100644 index 00000000..10765c63 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/command/unsupported/open.command.ts @@ -0,0 +1,4 @@ +export const name = "_execute_action"; +export const defaultKey = "Ctrl+Alt+Y"; + +export function execute() {} diff --git a/src/cli/entrypoint/parser/CommandParser.test.ts b/src/cli/entrypoint/parser/CommandParser.test.ts index 696895a7..1e003fa6 100644 --- a/src/cli/entrypoint/parser/CommandParser.test.ts +++ b/src/cli/entrypoint/parser/CommandParser.test.ts @@ -1,75 +1,30 @@ -import fs from "fs"; -import os from "os"; import path from "path"; import CommandParser from "./CommandParser"; import type {ReadonlyConfig} from "@typing/config"; -jest.mock("../file/resolvers", () => { - class TsResolver { - public static make(): TsResolver { - return new TsResolver(); - } - - public get matchPath(): {(_path: string): string | undefined} { - return () => undefined; - } - } - - class ImportResolver { - public setBaseDir(): this { - return this; - } - - public get(importPath: string): string { - return importPath; - } - } - - return {ImportResolver, TsResolver}; -}); - const rootDir = path.resolve(__dirname, "../../../.."); +const fixtures = path.resolve(__dirname, "tests", "fixtures", "command"); +const parser = new CommandParser({rootDir} as ReadonlyConfig); -const parse = (source: string) => { - const directory = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-command-parser-")); - const file = path.join(directory, "test.command.ts"); - - fs.writeFileSync(file, source); +const parseOptions = (...parts: string[]) => { + const file = path.join(fixtures, ...parts); - return new CommandParser({rootDir} as ReadonlyConfig).options({file, import: file}); + return parser.options({file, import: file}); }; -const parseError = (source: string): Error => { - try { - parse(source); - } catch (error) { - return error as Error; - } - - throw new Error("Expected command parser to throw"); -}; - -const command = (options: string) => ` -import {defineCommand} from "adnbn"; - -export default defineCommand({ - ${options}, - execute() {}, -}); -`; - describe("CommandParser", () => { - test("parses defineCommand with shortcuts and inherited background options from a real entrypoint file", () => { - const file = path.join(__dirname, "tests", "fixtures", "command", "options", "full", "save.command.ts"); - - expect(new CommandParser({rootDir} as ReadonlyConfig).options({file, import: file})).toEqual({ + test("reads every command option and the adopted background schema", () => { + expect(parseOptions("options", "full", "save.command.ts")).toEqual({ name: "save", description: "Save the page", global: false, defaultKey: "Ctrl+Shift+K", + windowsKey: "Alt+Shift+U", macKey: "Command+Shift+K", + chromeosKey: "Search+K", + linuxKey: "Ctrl+Y", persistent: true, permissions: ["storage", "tabs"], optionalPermissions: ["history"], @@ -79,70 +34,126 @@ describe("CommandParser", () => { }); }); - test("names a defineExecuteActionCommand entrypoint after the browser action command", () => { - const file = path.join( - __dirname, - "tests", - "fixtures", - "command", - "options", - "execute-action", - "open.command.ts" - ); - - expect(new CommandParser({rootDir} as ReadonlyConfig).options({file, import: file})).toEqual({ + test("keeps the internal execute action name and leaves optional values unset", () => { + expect(parseOptions("options", "execute-action", "open.command.ts")).toEqual({ name: "_execute_action", defaultKey: "Ctrl+Shift+O", }); }); - test("accepts browser command shortcuts", () => { - const options = parse( - command(` - defaultKey: "Ctrl+Shift+Y", - windowsKey: "Alt+Shift+U", - linuxKey: "Ctrl+F12", - chromeosKey: "MediaPlayPause" - `) - ); + test("accepts Firefox combinations before browser compatibility is checked", () => { + expect(parseOptions("options", "firefox", "open.command.ts")).toEqual({ + defaultKey: "Ctrl+Alt+Y", + includeBrowser: ["firefox"], + }); + }); - expect(options.defaultKey).toBe("Ctrl+Shift+Y"); - expect(options.windowsKey).toBe("Alt+Shift+U"); - expect(options.linuxKey).toBe("Ctrl+F12"); - expect(options.chromeosKey).toBe("MediaPlayPause"); + test.each([ + [ + "characters", + { + defaultKey: "Ctrl+A", + windowsKey: "Alt+Z", + macKey: "MacCtrl+0", + chromeosKey: "Search+9", + linuxKey: "Ctrl+y", + }, + ], + [ + "named", + { + defaultKey: "Ctrl+Tab", + windowsKey: "Alt+Shift+Insert", + macKey: "Option+Shift+Comma", + chromeosKey: "Search+Ctrl+Shift+Y", + linuxKey: "Ctrl+PageDown", + }, + ], + [ + "navigation", + { + defaultKey: "Ctrl+Up", + windowsKey: "Alt+Down", + macKey: "Command+Left", + chromeosKey: "Search+Right", + linuxKey: "Ctrl+PageUp", + }, + ], + [ + "editing", + { + defaultKey: "Ctrl+Period", + windowsKey: "Alt+Delete", + macKey: "Command+Home", + chromeosKey: "Search+End", + linuxKey: "Ctrl+Space", + }, + ], + [ + "functions", + { + defaultKey: "F1", + windowsKey: "Ctrl+Alt+F12", + macKey: "Command+MacCtrl+Y", + chromeosKey: "Search+Shift+Y", + linuxKey: "Shift+F12", + }, + ], + [ + "media", + { + defaultKey: "MediaNextTrack", + windowsKey: "MediaPlayPause", + macKey: "MediaPrevTrack", + chromeosKey: "MediaStop", + linuxKey: "MediaPlayPause", + }, + ], + ] as const)("accepts the shared shortcut syntax for %s and preserves every value", (scenario, options) => { + expect(parseOptions("options", "shortcuts", `${scenario}.command.ts`)).toEqual(options); }); - test("accepts macOS-specific command modifiers only for macKey", () => { - const options = parse( - command(` - defaultKey: "Ctrl+Shift+Y", - macKey: "Command+Shift+P" - `) + test.each([ + ["empty.ts", "", "empty components"], + ["whitespace.ts", "Ctrl +Y", "Whitespace"], + ["empty-component.ts", "Ctrl++Y", "empty components"], + ["trailing-separator.ts", "Ctrl+Y+", "empty components"], + ["wrong-separator.ts", "Ctrl-Y", 'Unknown key "Ctrl-Y"'], + ["unknown-modifier.ts", "Control+Y", 'Unknown modifier "Control"'], + ["repeated-modifier.ts", "Ctrl+Ctrl+Y", "Repeated modifiers"], + ["key-order.ts", "Ctrl+Y+Shift", "key must be the last"], + ["missing-key-name.ts", "Ctrl+Shift", "Expected one key name"], + ["unknown-key.ts", "Ctrl+UnlistedKey", 'Unknown key "UnlistedKey"'], + ["unsupported-key.ts", "Ctrl+Enter", 'Unknown key "Enter"'], + ["function-range.ts", "Ctrl+F13", 'Unknown key "F13"'], + ["media-modifier.ts", "Ctrl+MediaPlayPause", "Media keys cannot have modifiers"], + ["missing-modifier.ts", "Y", "A modifier is required"], + ])("rejects invalid shortcut syntax in %s with the option and value", (filename, value, reason) => { + expect(() => parseOptions("invalid", filename)).toThrow( + `Invalid options defaultKey in "${path.join(fixtures, "invalid", filename)}": ${JSON.stringify(value)}:` ); - expect(options.macKey).toBe("Command+Shift+P"); - - expect(() => parse(command(`defaultKey: "Command+Shift+P"`))).toThrow("Invalid shortcut key"); - expect(() => parse(command(`linuxKey: "Option+Shift+U"`))).toThrow("Invalid shortcut key"); + expect(() => parseOptions("invalid", filename)).toThrow(reason); }); - test("rejects modifiers with media keys", () => { - expect(() => parse(command(`defaultKey: "Ctrl+MediaPlayPause"`))).toThrow("Invalid shortcut key"); + test("does not read command options from a sibling definition", () => { + expect(() => parseOptions("options", "sibling", "background.ts")).toThrow( + "At least one suggested key must be defined" + ); }); - test("includes file path when no suggested key is defined", () => { - const error = parseError(command(`name: "missing-key"`)); - - expect(error.message).toContain("At least one suggested key must be defined"); - expect(error.message).toContain("test.command.ts"); + test("includes the source file when no suggested key is declared", () => { + expect(() => parseOptions("invalid", "missing-key.ts")).toThrow( + `Invalid command options in "${path.join(fixtures, "invalid", "missing-key.ts")}": At least one suggested key must be defined` + ); }); - test("requires Chrome global command shortcuts to use Ctrl+Shift+[0..9]", () => { - expect(parse(command(`global: true, defaultKey: "Ctrl+Shift+5"`)).defaultKey).toBe("Ctrl+Shift+5"); - - const error = parseError(command(`global: true, defaultKey: "Ctrl+Shift+Y"`)); - - expect(error.message).toContain("must use Ctrl+Shift+[0..9]"); - expect(error.message).toContain("test.command.ts"); + test.each([ + ["key-type.ts", "defaultKey"], + ["global-type.ts", "global"], + ])("rejects invalid option types in %s", (filename, option) => { + expect(() => parseOptions("invalid", filename)).toThrow( + `Invalid options ${option} in "${path.join(fixtures, "invalid", filename)}"` + ); }); }); diff --git a/src/cli/entrypoint/parser/CommandParser.ts b/src/cli/entrypoint/parser/CommandParser.ts index e5440901..77c4a777 100644 --- a/src/cli/entrypoint/parser/CommandParser.ts +++ b/src/cli/entrypoint/parser/CommandParser.ts @@ -7,62 +7,111 @@ import {modifyLocaleMessageKey} from "@shared/locale"; import {CommandEntrypointOptions, CommandExecuteActionName} from "@typing/command"; import {EntrypointFile} from "@typing/entrypoint"; -export default class extends BackgroundParser { +const shortcutModifiers = new Set(["Ctrl", "Alt", "Shift", "Command", "MacCtrl", "Option", "Search"]); + +// Union of the named keys accepted by supported browsers; compatibility is checked after filtering. +// https://github.com/chromium/chromium/blob/main/ui/base/accelerators/command_constants.h +// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/commands#shortcut_values +const shortcutKeys = new Set([ + "Comma", + "Period", + "Up", + "Down", + "Left", + "Right", + "Insert", + "Delete", + "Home", + "End", + "PageUp", + "PageDown", + "Space", + "Tab", + "MediaNextTrack", + "MediaPlayPause", + "MediaPrevTrack", + "MediaStop", +]); + +export default class CommandParser extends BackgroundParser { protected definition(): string[] { return ["defineCommand", "defineExecuteActionCommand"]; } protected schema(): typeof this.CommonPropertiesSchema { - const key = - "(?:[A-Z0-9]|F(?:[1-9]|1[0-2])|Comma|Period|Home|End|PageUp|PageDown|Space|Insert|Delete|Up|Down|Left|Right)"; - const mediaKey = "(?:MediaNextTrack|MediaPlayPause|MediaPrevTrack|MediaStop)"; - const shortcutMessage = - "Invalid shortcut key, expected format like: Ctrl+Shift+K, Alt+Shift+U, or MediaPlayPause"; - const macShortcutMessage = - "Invalid mac shortcut key, expected format like: Command+Shift+P, MacCtrl+K, Option+Shift+U, or MediaPlayPause"; - - const ShortcutKeySchema = z + const shortcutKeySchema = z .string() - .regex(new RegExp(`^(?:(?:Ctrl|Alt)(?:\\+Shift)?\\+${key}|${mediaKey})$`), shortcutMessage) - .optional(); + .superRefine((value, context) => { + const reason = this.getShortcutError(value); - const MacShortcutKeySchema = z - .string() - .regex( - new RegExp(`^(?:(?:Ctrl|Alt|Command|MacCtrl|Option)(?:\\+Shift)?\\+${key}|${mediaKey})$`), - macShortcutMessage - ) + if (reason) { + context.addIssue({code: z.ZodIssueCode.custom, message: `${JSON.stringify(value)}: ${reason}`}); + } + }) .optional(); return super.schema().extend({ name: z.string().nonempty().optional(), description: z.string().nonempty().optional(), global: z.boolean().optional(), - defaultKey: ShortcutKeySchema, - windowsKey: ShortcutKeySchema, - macKey: MacShortcutKeySchema, - chromeosKey: ShortcutKeySchema, - linuxKey: ShortcutKeySchema, + defaultKey: shortcutKeySchema, + windowsKey: shortcutKeySchema, + macKey: shortcutKeySchema, + chromeosKey: shortcutKeySchema, + linuxKey: shortcutKeySchema, }); } - public options(file: EntrypointFile): CommandEntrypointOptions { - const {defaultKey, windowsKey, macKey, chromeosKey, linuxKey, ...options} = super.options(file); + private getShortcutError(value: string): string | undefined { + // Addon Bone requires a whitespace-free declaration, even where a browser accepts spaces. + if (/\s/.test(value)) { + return "Whitespace is not allowed in shortcuts; use a value such as Ctrl+Shift+Y"; + } - if (!defaultKey && !windowsKey && !macKey && !chromeosKey && !linuxKey) { - throw new Error(`Invalid command options in "${file.file}": At least one suggested key must be defined`); + const modifiers = value.split("+"); + const key = modifiers.pop()!; + + if (!key || modifiers.some(modifier => !modifier)) { + return "Expected modifiers separated by '+' followed by one key; empty components are not allowed"; + } + + const unknownModifier = modifiers.find(modifier => !shortcutModifiers.has(modifier)); + + if (unknownModifier) { + return `Unknown modifier "${unknownModifier}"; the key must be the last component`; + } + + if (new Set(modifiers).size !== modifiers.length) { + return "Repeated modifiers are not allowed"; + } + + if (shortcutModifiers.has(key)) { + return "Expected one key name after the modifiers"; + } + + const functionKey = /^F(?:[1-9]|1[0-2])$/.test(key); + + // Safari also accepts lowercase letters; CommandFinder checks their compatibility with other browsers. + if (!/^[A-Za-z0-9]$/.test(key) && !functionKey && !shortcutKeys.has(key)) { + return `Unknown key "${key}"; use a letter, a digit, F1-F12 or a supported named key`; } - if (options.global) { - const globalShortcut = /^Ctrl\+Shift\+[0-9]$/; - const keys = {defaultKey, windowsKey, macKey, chromeosKey, linuxKey}; - const invalidKey = Object.entries(keys).find(([, key]) => key && !globalShortcut.test(key)); + if (key.startsWith("Media")) { + return modifiers.length ? "Media keys cannot have modifiers" : undefined; + } + + if (!modifiers.length && !functionKey) { + return "A modifier is required except for function keys and media keys"; + } - if (invalidKey) { - throw new Error( - `Invalid command options in "${file.file}": Global command shortcut "${invalidKey[1]}" in "${invalidKey[0]}" must use Ctrl+Shift+[0..9]` - ); - } + return undefined; + } + + public options(file: EntrypointFile): CommandEntrypointOptions { + const {defaultKey, windowsKey, macKey, chromeosKey, linuxKey, ...options} = super.options(file); + + if ([defaultKey, windowsKey, macKey, chromeosKey, linuxKey].every(key => key === undefined)) { + throw new Error(`Invalid command options in "${file.file}": At least one suggested key must be defined`); } return { diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/invalid/empty-component.ts b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/empty-component.ts new file mode 100644 index 00000000..a27bdb60 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/empty-component.ts @@ -0,0 +1,6 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "Ctrl++Y", + execute() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/invalid/empty.ts b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/empty.ts new file mode 100644 index 00000000..fa32d061 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/empty.ts @@ -0,0 +1,6 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "", + execute() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/invalid/function-range.ts b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/function-range.ts new file mode 100644 index 00000000..a75e7514 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/function-range.ts @@ -0,0 +1,6 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "Ctrl+F13", + execute() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/invalid/global-type.ts b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/global-type.ts new file mode 100644 index 00000000..577727a8 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/global-type.ts @@ -0,0 +1,3 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({defaultKey: "Ctrl+Y", global: "true", execute() {}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/invalid/key-order.ts b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/key-order.ts new file mode 100644 index 00000000..2ed9a2c2 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/key-order.ts @@ -0,0 +1,6 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "Ctrl+Y+Shift", + execute() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/invalid/key-type.ts b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/key-type.ts new file mode 100644 index 00000000..e7e9e20d --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/key-type.ts @@ -0,0 +1,3 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({defaultKey: 42, execute() {}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/invalid/media-modifier.ts b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/media-modifier.ts new file mode 100644 index 00000000..71120be1 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/media-modifier.ts @@ -0,0 +1,6 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "Ctrl+MediaPlayPause", + execute() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/invalid/missing-key-name.ts b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/missing-key-name.ts new file mode 100644 index 00000000..1071081f --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/missing-key-name.ts @@ -0,0 +1,6 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "Ctrl+Shift", + execute() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/invalid/missing-key.ts b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/missing-key.ts new file mode 100644 index 00000000..63b448b7 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/missing-key.ts @@ -0,0 +1,3 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({name: "missing-key", execute() {}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/invalid/missing-modifier.ts b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/missing-modifier.ts new file mode 100644 index 00000000..1f1f718f --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/missing-modifier.ts @@ -0,0 +1,6 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "Y", + execute() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/invalid/repeated-modifier.ts b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/repeated-modifier.ts new file mode 100644 index 00000000..d8d77684 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/repeated-modifier.ts @@ -0,0 +1,6 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "Ctrl+Ctrl+Y", + execute() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/invalid/trailing-separator.ts b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/trailing-separator.ts new file mode 100644 index 00000000..abc1d7ba --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/trailing-separator.ts @@ -0,0 +1,6 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "Ctrl+Y+", + execute() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/invalid/unknown-key.ts b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/unknown-key.ts new file mode 100644 index 00000000..f90028d2 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/unknown-key.ts @@ -0,0 +1,6 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "Ctrl+UnlistedKey", + execute() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/invalid/unknown-modifier.ts b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/unknown-modifier.ts new file mode 100644 index 00000000..44866de4 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/unknown-modifier.ts @@ -0,0 +1,6 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "Control+Y", + execute() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/invalid/unsupported-key.ts b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/unsupported-key.ts new file mode 100644 index 00000000..1beaa3a4 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/unsupported-key.ts @@ -0,0 +1,6 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "Ctrl+Enter", + execute() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/invalid/whitespace.ts b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/whitespace.ts new file mode 100644 index 00000000..bca50a36 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/whitespace.ts @@ -0,0 +1,6 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "Ctrl +Y", + execute() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/invalid/wrong-separator.ts b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/wrong-separator.ts new file mode 100644 index 00000000..038c324f --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/invalid/wrong-separator.ts @@ -0,0 +1,6 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "Ctrl-Y", + execute() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/options/firefox/open.command.ts b/src/cli/entrypoint/parser/tests/fixtures/command/options/firefox/open.command.ts new file mode 100644 index 00000000..cb09b351 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/options/firefox/open.command.ts @@ -0,0 +1,3 @@ +import {Browser, defineCommand} from "adnbn"; + +export default defineCommand({defaultKey: "Ctrl+Alt+Y", includeBrowser: [Browser.Firefox], execute() {}}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/options/full/save.command.ts b/src/cli/entrypoint/parser/tests/fixtures/command/options/full/save.command.ts index 71c0c29d..bcc94767 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/command/options/full/save.command.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/command/options/full/save.command.ts @@ -5,7 +5,10 @@ export default defineCommand({ description: "Save the page", global: false, defaultKey: "Ctrl+Shift+K", + windowsKey: "Alt+Shift+U", macKey: "Command+Shift+K", + chromeosKey: "Search+K", + linuxKey: "Ctrl+Y", persistent: true, permissions: ["storage", "tabs"], optionalPermissions: ["history"], diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/characters.command.ts b/src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/characters.command.ts new file mode 100644 index 00000000..e4b58a28 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/characters.command.ts @@ -0,0 +1,10 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "Ctrl+A", + windowsKey: "Alt+Z", + macKey: "MacCtrl+0", + chromeosKey: "Search+9", + linuxKey: "Ctrl+y", + execute() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/editing.command.ts b/src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/editing.command.ts new file mode 100644 index 00000000..1a3af683 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/editing.command.ts @@ -0,0 +1,10 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "Ctrl+Period", + windowsKey: "Alt+Delete", + macKey: "Command+Home", + chromeosKey: "Search+End", + linuxKey: "Ctrl+Space", + execute() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/functions.command.ts b/src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/functions.command.ts new file mode 100644 index 00000000..d6649301 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/functions.command.ts @@ -0,0 +1,10 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "F1", + windowsKey: "Ctrl+Alt+F12", + macKey: "Command+MacCtrl+Y", + chromeosKey: "Search+Shift+Y", + linuxKey: "Shift+F12", + execute() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/media.command.ts b/src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/media.command.ts new file mode 100644 index 00000000..5b60066c --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/media.command.ts @@ -0,0 +1,10 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "MediaNextTrack", + windowsKey: "MediaPlayPause", + macKey: "MediaPrevTrack", + chromeosKey: "MediaStop", + linuxKey: "MediaPlayPause", + execute() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/named.command.ts b/src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/named.command.ts new file mode 100644 index 00000000..3f97b387 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/named.command.ts @@ -0,0 +1,10 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "Ctrl+Tab", + windowsKey: "Alt+Shift+Insert", + macKey: "Option+Shift+Comma", + chromeosKey: "Search+Ctrl+Shift+Y", + linuxKey: "Ctrl+PageDown", + execute() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/navigation.command.ts b/src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/navigation.command.ts new file mode 100644 index 00000000..0e45ab05 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/options/shortcuts/navigation.command.ts @@ -0,0 +1,10 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "Ctrl+Up", + windowsKey: "Alt+Down", + macKey: "Command+Left", + chromeosKey: "Search+Right", + linuxKey: "Ctrl+PageUp", + execute() {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/command/options/sibling/background.ts b/src/cli/entrypoint/parser/tests/fixtures/command/options/sibling/background.ts new file mode 100644 index 00000000..c9be913b --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/command/options/sibling/background.ts @@ -0,0 +1,3 @@ +import {defineBackground} from "adnbn"; + +export default defineBackground({defaultKey: "Ctrl+Y", run() {}}); diff --git a/tests/integration/build/command/command.integration.test.ts b/tests/integration/build/command/command.integration.test.ts new file mode 100644 index 00000000..54a84944 --- /dev/null +++ b/tests/integration/build/command/command.integration.test.ts @@ -0,0 +1,52 @@ +import {readFile} from "fs/promises"; +import path from "path"; + +import {createIntegrationFixture} from "../../utils/fixture"; + +const projectRoot = path.resolve(__dirname, "../../../.."); +const fixtureDir = path.join(__dirname, "fixture"); + +jest.setTimeout(90_000); + +test.each(["chrome", "firefox"])("builds filtered shortcuts for %s and preserves manifest options", async browser => { + const fixture = await createIntegrationFixture(projectRoot, fixtureDir); + + try { + const extensionDir = await fixture.build({browser}); + const manifest = JSON.parse(await readFile(path.join(extensionDir, "manifest.json"), "utf8")); + + expect(manifest.commands).toEqual({ + "save-page": { + description: "Save the page", + global: false, + suggested_key: { + default: "Ctrl+Shift+Y", + windows: "Alt+Shift+U", + mac: "Command+MacCtrl+Y", + chromeos: "Search+Shift+Y", + linux: "Ctrl+Shift+L", + }, + }, + global: { + description: "Global shortcut", + global: true, + suggested_key: {default: "Ctrl+Shift+5", mac: "Command+Shift+5"}, + }, + ...(browser === "firefox" + ? { + firefox: { + description: "Firefox shortcut", + suggested_key: { + default: "Ctrl+Alt+Y", + mac: "Command+Alt+Y", + linux: "F12", + chromeos: "Search+Ctrl+Shift+Y", + }, + }, + } + : {}), + }); + } finally { + await fixture.dispose(); + } +}); diff --git a/tests/integration/build/command/fixture/adnbn.config.ts b/tests/integration/build/command/fixture/adnbn.config.ts new file mode 100644 index 00000000..d92dc892 --- /dev/null +++ b/tests/integration/build/command/fixture/adnbn.config.ts @@ -0,0 +1,3 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({name: "Command Integration", version: "1.0.0"}); diff --git a/tests/integration/build/command/fixture/package.json b/tests/integration/build/command/fixture/package.json new file mode 100644 index 00000000..1ed174d1 --- /dev/null +++ b/tests/integration/build/command/fixture/package.json @@ -0,0 +1,9 @@ +{ + "name": "adnbn-test-command", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../../.." + } +} diff --git a/tests/integration/build/command/fixture/src/firefox.command.ts b/tests/integration/build/command/fixture/src/firefox.command.ts new file mode 100644 index 00000000..70a16d54 --- /dev/null +++ b/tests/integration/build/command/fixture/src/firefox.command.ts @@ -0,0 +1,11 @@ +import {Browser, defineCommand} from "adnbn"; + +export default defineCommand({ + includeBrowser: [Browser.Firefox], + defaultKey: "Ctrl+Alt+Y", + macKey: "Command+Alt+Y", + linuxKey: "F12", + chromeosKey: "Search+Ctrl+Shift+Y", + description: "Firefox shortcut", + execute() {}, +}); diff --git a/tests/integration/build/command/fixture/src/global.command.ts b/tests/integration/build/command/fixture/src/global.command.ts new file mode 100644 index 00000000..bcb53b70 --- /dev/null +++ b/tests/integration/build/command/fixture/src/global.command.ts @@ -0,0 +1,9 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + defaultKey: "Ctrl+Shift+5", + macKey: "Command+Shift+5", + global: true, + description: "Global shortcut", + execute() {}, +}); diff --git a/tests/integration/build/command/fixture/src/save.command.ts b/tests/integration/build/command/fixture/src/save.command.ts new file mode 100644 index 00000000..392912a7 --- /dev/null +++ b/tests/integration/build/command/fixture/src/save.command.ts @@ -0,0 +1,13 @@ +import {defineCommand} from "adnbn"; + +export default defineCommand({ + name: "save-page", + description: "Save the page", + global: false, + defaultKey: "Ctrl+Shift+Y", + windowsKey: "Alt+Shift+U", + macKey: "Command+MacCtrl+Y", + chromeosKey: "Search+Shift+Y", + linuxKey: "Ctrl+Shift+L", + execute() {}, +}); diff --git a/tests/integration/build/command/fixture/tsconfig.json b/tests/integration/build/command/fixture/tsconfig.json new file mode 100644 index 00000000..eadf1be8 --- /dev/null +++ b/tests/integration/build/command/fixture/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.adnbn/tsconfig.json" +} From d5c90f379de0a67f3cb7d0fec26bda047909c4b0 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Mon, 5 Oct 2026 02:51:34 +0300 Subject: [PATCH 03/24] fix(style)!: resolve Sass modules and resources from their source files Prepare standalone and merged styles with their original filenames so local Sass requests and complete literal URLs retain their origin. Resolve resources in imported partials through Sass source maps while preserving one CSS module, shared-to-app order and existing style layers. Track existing and missing Sass candidates for bare and explicit relative requests so watch builds recover when modules are created or restored. Keep preparation internal and reuse an existing URL loader when supplied by another plugin. Cover multi-app assets, font mixins, native loader loading and watch recovery with unit and build tests. Document supported URL patterns and source-map limitations. BREAKING CHANGE: Relative resources in Sass partials now resolve from the partial instead of the entry stylesheet, including builds with mergeStyles disabled. Update paths that relied on the entry directory. Local Sass module requests now retain the declaring file's directory. Repeated variable assignments and configured module loads are preserved, and late @use/@forward directives are rejected. Correct configurations that previously depended on deduplication or directive reordering. --- .prettierignore | 2 +- jest.config.ts | 2 + package-lock.json | 51 +++ package.json | 1 + src/cli/bundler/loaders/resolve-style-urls.ts | 19 + src/cli/bundler/styles/index.ts | 3 + .../styles/prepare-style-sources.test.ts | 401 ++++++++++++++++++ .../bundler/styles/prepare-style-sources.ts | 129 ++++++ src/cli/bundler/styles/style-paths.test.ts | 245 +++++++++++ src/cli/bundler/styles/style-paths.ts | 205 +++++++++ src/cli/bundler/styles/style-url-join.ts | 17 + .../tests/fixtures/ambiguous/_tokens.scss | 1 + .../tests/fixtures/ambiguous/content.scss | 1 + .../tests/fixtures/ambiguous/tokens.scss | 1 + .../bare-local/app/_legacy.import.scss | 1 + .../fixtures/bare-local/app/_tokens.v2.scss | 1 + .../fixtures/bare-local/app/content.scss | 5 + .../bare-local/app/indexed/_index.scss | 1 + .../tests/fixtures/bare-local/app/plain.css | 1 + .../fixtures/bare-local/import-index.scss | 2 + .../legacy-index/_index.import.scss | 1 + .../bare-local/shared/_tokens.v2.scss | 1 + .../fixtures/bare-local/shared/content.scss | 2 + .../tests/fixtures/block-atrules/app.scss | 0 .../tests/fixtures/block-atrules/shared.scss | 0 .../styles}/tests/fixtures/charset/app.scss | 0 .../tests/fixtures/charset/shared.scss | 0 .../styles}/tests/fixtures/comments/app.scss | 0 .../tests/fixtures/comments/shared.scss | 0 .../fixtures/configured-repeat/_tokens.scss | 1 + .../tests/fixtures/configured-repeat/app.scss | 2 + .../fixtures/configured-repeat/shared.scss | 2 + .../fixtures/conflicting-prelude/app.scss | 0 .../fixtures/conflicting-prelude/shared.scss | 0 .../tests/fixtures/css-effects/app.scss | 4 + .../tests/fixtures/css-effects/shared.scss | 3 + .../tests/fixtures/css-prelude/app.scss | 0 .../tests/fixtures/css-prelude/shared.scss | 0 .../tests/fixtures/duplicate-body/app.scss | 0 .../tests/fixtures/duplicate-body/shared.scss | 0 .../tests/fixtures/duplicate-prelude/app.scss | 0 .../fixtures/duplicate-prelude/shared.scss | 0 .../fixtures/escaped-modules/_50%off.scss | 1 + .../escaped-modules/_linecontinuation.scss | 1 + .../fixtures/escaped-modules/_quo'te.scss | 1 + .../fixtures/escaped-modules/_space name.scss | 1 + .../fixtures/escaped-modules/_tokens.scss | 1 + .../fixtures/escaped-modules/content.scss | 8 + .../styles}/tests/fixtures/forward/app.scss | 0 .../tests/fixtures/forward/shared.scss | 0 .../fixtures/invalid-order/body-use.scss | 2 + .../invalid-order/empty-block-use.scss | 2 + .../fixtures/invalid-order/import-use.scss | 2 + .../fixtures/invalid-order/layer-forward.scss | 2 + .../tests/fixtures/layer-statement/app.scss | 0 .../fixtures/layer-statement/shared.scss | 0 .../tests/fixtures/module-order/app.scss | 0 .../tests/fixtures/module-order/shared.scss | 0 .../fixtures/multiple-sources/first.scss | 2 + .../fixtures/multiple-sources/second.scss | 2 + .../fixtures/multiple-sources/third.scss | 2 + .../styles}/tests/fixtures/namespace/app.scss | 0 .../tests/fixtures/namespace/shared.scss | 0 .../path with spaces#percent%/_tokens.scss | 1 + .../path with spaces#percent%/content.scss | 2 + .../fixtures/preserved-variables/app.scss | 2 + .../fixtures/preserved-variables/shared.scss | 2 + .../relative-conflict/app/content.scss | 0 .../relative-conflict/app/theme/_tokens.scss | 0 .../relative-conflict/shared/content.scss | 0 .../shared/theme/_tokens.scss | 0 .../relative-modules/app/content.scss | 0 .../relative-modules/app/theme/_hidden.scss | 0 .../relative-modules/app/theme/_metrics.scss | 0 .../relative-modules/app/theme/_tokens.scss | 0 .../app/theme/fonts/_index.scss | 0 .../relative-modules/shared/content.scss | 0 .../shared/theme/_tokens.scss | 0 .../fixtures/same-module/app/content.scss | 0 .../fixtures/same-module/common/_tokens.scss | 0 .../fixtures/same-module/shared/content.scss | 0 .../fixtures/same-source-duplicate/app.scss | 2 + .../same-source-duplicate/shared.scss | 1 + .../tests/fixtures/shared-scope/_tokens.scss | 1 + .../tests/fixtures/shared-scope/app.scss | 5 + .../tests/fixtures/shared-scope/shared.scss | 4 + .../fixtures/url-values/app/content.scss | 0 .../fixtures/url-values/shared/content.scss | 0 .../styles}/tests/fixtures/variables/app.scss | 0 .../tests/fixtures/variables/shared.scss | 0 src/cli/bundler/styles/types.ts | 8 + src/cli/plugins/style/README.md | 162 +++++-- src/cli/plugins/style/index.ts | 54 ++- src/cli/plugins/style/rebase.test.ts | 101 ----- src/cli/plugins/style/rebase.ts | 98 ----- src/cli/plugins/style/utils.test.ts | 247 ----------- src/cli/plugins/style/utils.ts | 146 ------- src/types/config.ts | 7 +- tests/integration/README.md | 13 +- .../content-chunks.integration.test.ts | 6 +- .../style-routing/delivery-spike/compiler.ts | 6 +- .../style-routing/selective-split/compiler.ts | 6 +- .../style-routing.integration.test.ts | 6 +- tests/integration/build/style/compiler.ts | 23 +- .../bare-module-watch-updates/created.scss | 1 + .../bare-module-watch-updates/index.scss | 1 + .../bare-module-watch-updates/updated.scss | 1 + .../style/fixtures/bare-module-watch/asis.js | 1 + .../fixtures/bare-module-watch/document.js | 3 + .../fixtures/bare-module-watch/normal.js | 3 + .../src/apps/alpha/content/content.scss | 6 + .../src/shared/content/_tokens.scss | 1 + .../src/shared/content/content.scss | 6 + .../build/style/fixtures/font-reuse/asis.js | 1 + .../style/fixtures/font-reuse/document.js | 3 + .../build/style/fixtures/font-reuse/normal.js | 3 + .../src/apps/alpha/content/Geist.woff2 | 1 + .../src/apps/alpha/content/content.scss | 7 + .../src/apps/alpha/theme/fonts/Geist.woff2 | 1 + .../src/apps/alpha/theme/fonts/_index.scss | 9 + .../font-reuse/src/shared/content/Geist.woff2 | 1 + .../src/shared/content/content.scss | 3 + .../style/fixtures/font-reuse/standalone.js | 3 + .../multi-app-updates/content-default.scss | 8 + .../fixtures/multi-app-updates/fonts.scss | 2 +- .../src/apps/beta/content/content.scss | 4 +- .../src/apps/beta/theme/fonts/_index.scss | 2 +- .../style/fixtures/multi-app/standalone.js | 3 + .../relative-module-watch-updates/child.scss | 6 + .../relative-module-watch-updates/index.scss | 1 + .../relative-module-watch-updates/parent.scss | 6 + .../relative-module-watch-updates/shared.scss | 4 + .../virtual-theme-updates/fonts-alias.scss | 11 + .../virtual-theme-updates/generated.scss | 14 + .../style/fixtures/virtual-theme/asis.js | 3 + .../style/fixtures/virtual-theme/document.js | 3 + .../virtual-theme/generated/style.scss | 14 + .../style/fixtures/virtual-theme/normal.js | 3 + .../src/apps/alpha/theme/app.svg | 1 + .../src/apps/alpha/theme/fonts/Virtual.woff2 | 1 + .../src/apps/alpha/theme/fonts/_index.scss | 6 + .../virtual-theme/src/shared/theme/shared.svg | 1 + .../style/font-reuse.integration.test.ts | 57 +++ .../module-resolution.integration.test.ts | 208 +++++++++ .../build/style/resolve-style-urls-loader.cjs | 4 + .../style/style-merge.integration.test.ts | 59 ++- tests/integration/build/style/style-output.ts | 31 ++ .../build/style/verify-native-consumer.mjs | 91 ++++ .../style/virtual-style.integration.test.ts | 186 ++++++++ 149 files changed, 2113 insertions(+), 706 deletions(-) create mode 100644 src/cli/bundler/loaders/resolve-style-urls.ts create mode 100644 src/cli/bundler/styles/index.ts create mode 100644 src/cli/bundler/styles/prepare-style-sources.test.ts create mode 100644 src/cli/bundler/styles/prepare-style-sources.ts create mode 100644 src/cli/bundler/styles/style-paths.test.ts create mode 100644 src/cli/bundler/styles/style-paths.ts create mode 100644 src/cli/bundler/styles/style-url-join.ts create mode 100644 src/cli/bundler/styles/tests/fixtures/ambiguous/_tokens.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/ambiguous/content.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/ambiguous/tokens.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/bare-local/app/_legacy.import.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/bare-local/app/_tokens.v2.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/bare-local/app/content.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/bare-local/app/indexed/_index.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/bare-local/app/plain.css create mode 100644 src/cli/bundler/styles/tests/fixtures/bare-local/import-index.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/bare-local/legacy-index/_index.import.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/bare-local/shared/_tokens.v2.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/bare-local/shared/content.scss rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/block-atrules/app.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/block-atrules/shared.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/charset/app.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/charset/shared.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/comments/app.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/comments/shared.scss (100%) create mode 100644 src/cli/bundler/styles/tests/fixtures/configured-repeat/_tokens.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/configured-repeat/app.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/configured-repeat/shared.scss rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/conflicting-prelude/app.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/conflicting-prelude/shared.scss (100%) create mode 100644 src/cli/bundler/styles/tests/fixtures/css-effects/app.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/css-effects/shared.scss rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/css-prelude/app.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/css-prelude/shared.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/duplicate-body/app.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/duplicate-body/shared.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/duplicate-prelude/app.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/duplicate-prelude/shared.scss (100%) create mode 100644 src/cli/bundler/styles/tests/fixtures/escaped-modules/_50%off.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/escaped-modules/_linecontinuation.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/escaped-modules/_quo'te.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/escaped-modules/_space name.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/escaped-modules/_tokens.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/escaped-modules/content.scss rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/forward/app.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/forward/shared.scss (100%) create mode 100644 src/cli/bundler/styles/tests/fixtures/invalid-order/body-use.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/invalid-order/empty-block-use.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/invalid-order/import-use.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/invalid-order/layer-forward.scss rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/layer-statement/app.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/layer-statement/shared.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/module-order/app.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/module-order/shared.scss (100%) create mode 100644 src/cli/bundler/styles/tests/fixtures/multiple-sources/first.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/multiple-sources/second.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/multiple-sources/third.scss rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/namespace/app.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/namespace/shared.scss (100%) create mode 100644 src/cli/bundler/styles/tests/fixtures/path with spaces#percent%/_tokens.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/path with spaces#percent%/content.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/preserved-variables/app.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/preserved-variables/shared.scss rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/relative-conflict/app/content.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/relative-conflict/app/theme/_tokens.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/relative-conflict/shared/content.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/relative-conflict/shared/theme/_tokens.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/relative-modules/app/content.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/relative-modules/app/theme/_hidden.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/relative-modules/app/theme/_metrics.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/relative-modules/app/theme/_tokens.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/relative-modules/app/theme/fonts/_index.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/relative-modules/shared/content.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/relative-modules/shared/theme/_tokens.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/same-module/app/content.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/same-module/common/_tokens.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/same-module/shared/content.scss (100%) create mode 100644 src/cli/bundler/styles/tests/fixtures/same-source-duplicate/app.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/same-source-duplicate/shared.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/shared-scope/_tokens.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/shared-scope/app.scss create mode 100644 src/cli/bundler/styles/tests/fixtures/shared-scope/shared.scss rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/url-values/app/content.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/url-values/shared/content.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/variables/app.scss (100%) rename src/cli/{plugins/style => bundler/styles}/tests/fixtures/variables/shared.scss (100%) create mode 100644 src/cli/bundler/styles/types.ts delete mode 100644 src/cli/plugins/style/rebase.test.ts delete mode 100644 src/cli/plugins/style/rebase.ts delete mode 100644 src/cli/plugins/style/utils.test.ts delete mode 100644 src/cli/plugins/style/utils.ts create mode 100644 tests/integration/build/style/fixtures/bare-module-watch-updates/created.scss create mode 100644 tests/integration/build/style/fixtures/bare-module-watch-updates/index.scss create mode 100644 tests/integration/build/style/fixtures/bare-module-watch-updates/updated.scss create mode 100644 tests/integration/build/style/fixtures/bare-module-watch/asis.js create mode 100644 tests/integration/build/style/fixtures/bare-module-watch/document.js create mode 100644 tests/integration/build/style/fixtures/bare-module-watch/normal.js create mode 100644 tests/integration/build/style/fixtures/bare-module-watch/src/apps/alpha/content/content.scss create mode 100644 tests/integration/build/style/fixtures/bare-module-watch/src/shared/content/_tokens.scss create mode 100644 tests/integration/build/style/fixtures/bare-module-watch/src/shared/content/content.scss create mode 100644 tests/integration/build/style/fixtures/font-reuse/asis.js create mode 100644 tests/integration/build/style/fixtures/font-reuse/document.js create mode 100644 tests/integration/build/style/fixtures/font-reuse/normal.js create mode 100644 tests/integration/build/style/fixtures/font-reuse/src/apps/alpha/content/Geist.woff2 create mode 100644 tests/integration/build/style/fixtures/font-reuse/src/apps/alpha/content/content.scss create mode 100644 tests/integration/build/style/fixtures/font-reuse/src/apps/alpha/theme/fonts/Geist.woff2 create mode 100644 tests/integration/build/style/fixtures/font-reuse/src/apps/alpha/theme/fonts/_index.scss create mode 100644 tests/integration/build/style/fixtures/font-reuse/src/shared/content/Geist.woff2 create mode 100644 tests/integration/build/style/fixtures/font-reuse/src/shared/content/content.scss create mode 100644 tests/integration/build/style/fixtures/font-reuse/standalone.js create mode 100644 tests/integration/build/style/fixtures/multi-app-updates/content-default.scss create mode 100644 tests/integration/build/style/fixtures/multi-app/standalone.js create mode 100644 tests/integration/build/style/fixtures/relative-module-watch-updates/child.scss create mode 100644 tests/integration/build/style/fixtures/relative-module-watch-updates/index.scss create mode 100644 tests/integration/build/style/fixtures/relative-module-watch-updates/parent.scss create mode 100644 tests/integration/build/style/fixtures/relative-module-watch-updates/shared.scss create mode 100644 tests/integration/build/style/fixtures/virtual-theme-updates/fonts-alias.scss create mode 100644 tests/integration/build/style/fixtures/virtual-theme-updates/generated.scss create mode 100644 tests/integration/build/style/fixtures/virtual-theme/asis.js create mode 100644 tests/integration/build/style/fixtures/virtual-theme/document.js create mode 100644 tests/integration/build/style/fixtures/virtual-theme/generated/style.scss create mode 100644 tests/integration/build/style/fixtures/virtual-theme/normal.js create mode 100644 tests/integration/build/style/fixtures/virtual-theme/src/apps/alpha/theme/app.svg create mode 100644 tests/integration/build/style/fixtures/virtual-theme/src/apps/alpha/theme/fonts/Virtual.woff2 create mode 100644 tests/integration/build/style/fixtures/virtual-theme/src/apps/alpha/theme/fonts/_index.scss create mode 100644 tests/integration/build/style/fixtures/virtual-theme/src/shared/theme/shared.svg create mode 100644 tests/integration/build/style/font-reuse.integration.test.ts create mode 100644 tests/integration/build/style/module-resolution.integration.test.ts create mode 100644 tests/integration/build/style/resolve-style-urls-loader.cjs create mode 100644 tests/integration/build/style/style-output.ts create mode 100644 tests/integration/build/style/verify-native-consumer.mjs create mode 100644 tests/integration/build/style/virtual-style.integration.test.ts diff --git a/.prettierignore b/.prettierignore index 418e7b6e..d65af364 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,6 +4,6 @@ dist/ /build/ addon/ src/cli/entrypoint/file/tests/fixtures/ -src/cli/plugins/style/tests/fixtures/ +src/cli/bundler/styles/tests/fixtures/ tests/integration/build/style/fixtures/ CHANGELOG.md diff --git a/jest.config.ts b/jest.config.ts index 02896a3b..16b09195 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -13,6 +13,8 @@ const shared: Config = { modulePathIgnorePatterns: ["/.cache/"], resolver: "/tests/raw-module-resolver.cjs", moduleNameMapper: { + "^\\.\\./\\.\\./bundler/loaders/resolve-style-urls$": + "/tests/integration/build/style/resolve-style-urls-loader.cjs", "^#adnbn/(.*)$": "/src/virtual/$1.ts", "^@cli/(.*)$": "/src/cli/$1", "^@entry/(.*)$": "/src/entry/$1", diff --git a/package-lock.json b/package-lock.json index cd62d884..6657145e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,7 @@ "postcss": "^8.5.15", "postcss-scss": "^4.0.9", "postcss-value-parser": "^4.2.0", + "resolve-url-loader": "^5.0.0", "rspack-plugin-virtual-module": "^1.0.1", "sass": "^1.83.4", "sass-loader": "^16.0.4", @@ -8728,6 +8729,19 @@ "node": ">=0.4.0" } }, + "node_modules/adjust-sourcemap-loader": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz", + "integrity": "sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==", + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "regex-parser": "^2.2.11" + }, + "engines": { + "node": ">=8.9" + } + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", @@ -16907,6 +16921,12 @@ "node": ">=4" } }, + "node_modules/regex-parser": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/regex-parser/-/regex-parser-2.3.1.tgz", + "integrity": "sha512-yXLRqatcCuKtVHsWrNg0JL3l1zGfdXeEvDa0bdu4tCDQw0RpMDZsqbkyRTUnKMR0tXF627V2oEWjBEaEdqTwtQ==", + "license": "MIT" + }, "node_modules/regexpu-core": { "version": "6.4.0", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.4.0.tgz", @@ -17183,6 +17203,37 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/resolve-url-loader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-url-loader/-/resolve-url-loader-5.0.0.tgz", + "integrity": "sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==", + "license": "MIT", + "dependencies": { + "adjust-sourcemap-loader": "^4.0.0", + "convert-source-map": "^1.7.0", + "loader-utils": "^2.0.0", + "postcss": "^8.2.14", + "source-map": "0.6.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/resolve-url-loader/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "license": "MIT" + }, + "node_modules/resolve-url-loader/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", diff --git a/package.json b/package.json index b59ade7b..474bbeab 100644 --- a/package.json +++ b/package.json @@ -157,6 +157,7 @@ "postcss": "^8.5.15", "postcss-scss": "^4.0.9", "postcss-value-parser": "^4.2.0", + "resolve-url-loader": "^5.0.0", "rspack-plugin-virtual-module": "^1.0.1", "sass": "^1.83.4", "sass-loader": "^16.0.4", diff --git a/src/cli/bundler/loaders/resolve-style-urls.ts b/src/cli/bundler/loaders/resolve-style-urls.ts new file mode 100644 index 00000000..30e2f659 --- /dev/null +++ b/src/cli/bundler/loaders/resolve-style-urls.ts @@ -0,0 +1,19 @@ +import type {LoaderDefinition} from "@rspack/core"; +import resolveUrlLoader from "resolve-url-loader"; + +/** Cooperate with plugins that already install resolve-url-loader in this resource's chain. */ +const resolveStyleUrls: LoaderDefinition = function (content, sourceMap, metadata) { + const existing = this.loaders.some( + (loader, index) => index !== this.loaderIndex && /(?:^|[/\\])resolve-url-loader(?:[/\\]|$)/.test(loader.path) + ); + + if (existing) { + this.callback(null, content, sourceMap, metadata); + + return; + } + + return resolveUrlLoader.call(this, content, sourceMap); +}; + +export default resolveStyleUrls; diff --git a/src/cli/bundler/styles/index.ts b/src/cli/bundler/styles/index.ts new file mode 100644 index 00000000..1ae70555 --- /dev/null +++ b/src/cli/bundler/styles/index.ts @@ -0,0 +1,3 @@ +export {prepareStyleSources} from "./prepare-style-sources"; +export {joinStyleUrl} from "./style-url-join"; +export type {StyleDependencyHandler, StyleSource} from "./types"; diff --git a/src/cli/bundler/styles/prepare-style-sources.test.ts b/src/cli/bundler/styles/prepare-style-sources.test.ts new file mode 100644 index 00000000..265b2826 --- /dev/null +++ b/src/cli/bundler/styles/prepare-style-sources.test.ts @@ -0,0 +1,401 @@ +import fs from "fs"; +import path from "path"; +import sass from "sass"; +import {pathToFileURL} from "url"; + +import {prepareStyleSources} from "./prepare-style-sources"; +import type {StyleSource} from "./types"; + +const fixtures = path.resolve(__dirname, "tests", "fixtures"); + +const readSource = (...parts: string[]): StyleSource => { + const filename = path.join(fixtures, ...parts); + + return {filename, content: fs.readFileSync(filename, "utf8")}; +}; + +const mergeFixture = (name: string): string => { + const shared = readSource(name, "shared.scss"); + + return prepareStyleSources([shared, readSource(name, "app.scss")], shared.filename); +}; + +const mergeLocatedFixture = (name: string): string => { + const shared = readSource(name, "shared", "content.scss"); + + return prepareStyleSources([shared, readSource(name, "app", "content.scss")], shared.filename); +}; + +const compileLocatedFixture = (name: string): string => + compile(mergeLocatedFixture(name), path.join(fixtures, name, "shared", "content.scss")); + +const compile = (content: string, filename = path.join(fixtures, "virtual.scss")): string => { + return sass.compileString(content, { + url: pathToFileURL(filename), + // A load path enables Sass's filesystem importer when compiling an in-memory source. + loadPaths: [fixtures], + silenceDeprecations: ["import"], + }).css; +}; + +const moduleUrl = (...parts: string[]): string => pathToFileURL(path.join(fixtures, ...parts)).href; +const resourcePath = (...parts: string[]): string => + path + .join(fixtures, ...parts) + .split(path.sep) + .join("/"); + +const normalizeStyle = (style: string): string => style.replace(/\s+/g, " ").trim(); + +describe("prepareStyleSources", () => { + test("accepts an absent theme", () => { + expect(prepareStyleSources([], path.join(fixtures, "virtual.scss"))).toBe(""); + }); + + test("prepares a single source compiled under its own filename", () => { + const source = readSource("url-values", "shared", "content.scss"); + const prepared = prepareStyleSources([source], source.filename); + + expect(prepared).toContain(`url("${resourcePath("url-values", "shared", "logo.svg")}")`); + expect(prepared).toContain(`url("${resourcePath("url-values", "shared", "shared.svg")}")`); + }); + + test("reports bare module dependencies from every original source", () => { + const shared = readSource("bare-local", "shared", "content.scss"); + const app = readSource("bare-local", "app", "content.scss"); + const dependencies = new Map(); + + prepareStyleSources([shared, app], shared.filename, (filename, exists) => { + dependencies.set(filename, exists); + }); + + expect(dependencies.get(path.join(path.dirname(shared.filename), "_tokens.v2.scss"))).toBe(true); + expect(dependencies.get(path.join(path.dirname(app.filename), "_tokens.v2.scss"))).toBe(true); + expect(dependencies.get(path.join(path.dirname(app.filename), "indexed", "_index.scss"))).toBe(true); + expect(dependencies.get(path.join(path.dirname(app.filename), "_legacy.import.scss"))).toBe(true); + expect(dependencies.get(path.join(path.dirname(app.filename), "tokens.v2.scss"))).toBe(false); + expect(dependencies.get(path.join(path.dirname(shared.filename), "tokens.v2", "_index.scss"))).toBe(false); + }); + + test("preserves cascade order for more than two sources", () => { + const prepared = prepareStyleSources( + ["first.scss", "second.scss", "third.scss"].map(name => readSource("multiple-sources", name)), + path.join(fixtures, "virtual.scss") + ); + + expect(compile(prepared)).toMatchOrder(["width: 3px", "width: 4px", "width: 5px"]); + expect(prepared.match(/@use "sass:math";/g)).toHaveLength(1); + }); + + test("moves app Sass module rules before merged style bodies", () => { + const merged = mergeFixture("module-order"); + + expect(merged).toMatchOrder([ + '@use "sass:map";', + '@use "sass:color";', + ".badge { color: red; }", + ".badge { color: blue; }", + ]); + }); + + test("keeps Sass variable declarations with the module prelude that uses them", () => { + const merged = mergeFixture("variables"); + + expect(merged).toMatchOrder([ + '@use "sass:map";', + "$accent: blue;", + '@use "sass:color";', + ".shared { color: red; }", + ".app { color: $accent; }", + ]); + }); + + test("places CSS prelude after Sass module rules from both sources", () => { + const merged = mergeFixture("css-prelude"); + + expect(merged).toMatchOrder([ + '@use "sass:map";', + `@import url("${resourcePath("css-prelude", "reset.css")}");`, + ".shared { color: red; }", + ".app { color: blue; }", + ]); + }); + + test("keeps leading comments in the merged prelude", () => { + const merged = mergeFixture("comments"); + + expect(merged).toMatchOrder([ + "/* shared theme */", + '@use "sass:map";', + "/* app theme */", + '@use "sass:color";', + ".shared { color: red; }", + ".app { color: blue; }", + ]); + }); + + test("keeps block at-rules in the style body", () => { + const merged = mergeFixture("block-atrules"); + + expect(merged).toMatchOrder([ + '@use "sass:map";', + ".shared { color: red; }", + "@layer components { .app { color: blue; } }", + ]); + }); + + test("hoists @charset to the very top of the merged styles", () => { + const merged = mergeFixture("charset"); + + expect(merged).toMatchOrder([ + '@charset "UTF-8";', + '@use "sass:map";', + '@use "sass:color";', + ".shared { color: red; }", + ".app { color: blue; }", + ]); + }); + + test("moves @forward into the Sass module prelude", () => { + const merged = mergeFixture("forward"); + + expect(merged).toMatchOrder([ + '@forward "sass:map";', + '@use "sass:color";', + ".shared { color: red; }", + ".app { color: blue; }", + ]); + }); + + test("treats @namespace as a CSS prelude rule", () => { + const merged = mergeFixture("namespace"); + + expect(merged).toMatchOrder([ + '@use "sass:map";', + '@namespace svg "svg-ns";', + ".shared { color: red; }", + ".app { color: blue; }", + ]); + }); + + test("hoists @layer statements while keeping @layer blocks in the body", () => { + const merged = mergeFixture("layer-statement"); + + expect(merged).toMatchOrder([ + "@layer extra;", + "@layer base { .shared { color: red; } }", + ".app { color: blue; }", + ]); + }); + + test("collapses identical Sass module rules repeated by an app override", () => { + const merged = mergeFixture("duplicate-prelude"); + + expect(merged.match(/@use "sass:map";/g)).toHaveLength(1); + expect(() => sass.compileString(merged)).not.toThrow(); + }); + + test("keeps conflicting module rules so Sass can still report them", () => { + const merged = mergeFixture("conflicting-prelude"); + + expect(() => sass.compileString(merged)).toThrow(/already a module with namespace "map"/); + }); + + test("does not deduplicate identical style bodies", () => { + const merged = mergeFixture("duplicate-body"); + + expect(normalizeStyle(merged).match(/\.badge \{ color: red; \}/g)).toHaveLength(2); + }); + + test("preserves repeated variable assignments across sources", () => { + expect(compile(mergeFixture("preserved-variables"))).toContain("color: red"); + }); + + test("keeps configured loads so Sass reports repeated configuration", () => { + const prepared = mergeFixture("configured-repeat"); + + expect(prepared.match(/@use /g)).toHaveLength(2); + expect(() => compile(prepared)).toThrow(/already loaded/); + }); + + test("does not hide duplicate modules within one source behind a previous source", () => { + const prepared = mergeFixture("same-source-duplicate"); + + expect(prepared.match(/@use "sass:math";/g)).toHaveLength(3); + expect(() => compile(prepared)).toThrow(/already a module with namespace "math"/); + }); + + test("retains CSS import and layer effects from both sources", () => { + const prepared = mergeFixture("css-effects"); + + expect(prepared.match(/@import /g)).toHaveLength(2); + expect(prepared.match(/@layer base, app;/g)).toHaveLength(2); + expect(prepared.lastIndexOf("@import")).toBeLessThan(prepared.indexOf(".shared")); + expect(prepared.indexOf("@use")).toBeLessThan(prepared.indexOf("@layer")); + }); + + test.each(["body-use.scss", "import-use.scss", "layer-forward.scss", "empty-block-use.scss"])( + "reports late module rules in %s against their original source location", + filename => { + const source = readSource("invalid-order", filename); + + expect(() => prepareStyleSources([source], path.join(fixtures, "virtual.scss"))).toThrow( + `${source.filename}:2:1` + ); + } + ); + + test("keeps shared Sass variables and mixins available to the app body", () => { + const css = compile(mergeFixture("shared-scope")); + + expect(css).toContain("color: blue"); + expect(css).toContain("padding: 11px"); + expect(css.indexOf(".shared")).toBeLessThan(css.indexOf(".app")); + }); + + test("resolves bare local dotted modules, indexes, CSS modules and import-only partials", () => { + const css = compileLocatedFixture("bare-local"); + + expect(css).toContain("color: red"); + expect(css).toContain("color: blue"); + expect(css).toContain("width: 17px"); + expect(css).toContain("height: 23px"); + expect(css).toContain(".plain"); + }); + + test("leaves import-only index lookup to Sass", () => { + const source = readSource("bare-local", "import-index.scss"); + + expect(compile(prepareStyleSources([source], path.join(fixtures, "virtual.scss")))).toContain("width: 29px"); + }); + + test("resolves module files from source directories containing spaces and URL-reserved characters", () => { + const source = readSource("path with spaces#percent%", "content.scss"); + + expect(compile(prepareStyleSources([source], path.join(fixtures, "virtual.scss")))).toContain("color: orange"); + }); + + test("resolves escaped module strings as Sass does before source preparation", () => { + const source = readSource("escaped-modules", "content.scss"); + const original = compile(source.content, source.filename); + const prepared = compile(prepareStyleSources([source], path.join(fixtures, "virtual.scss"))); + + expect(prepared).toBe(original); + expect(prepared).toContain("color: orange"); + expect(prepared).toContain("width: 17px"); + expect(prepared).toContain("height: 23px"); + expect(prepared).toContain("margin: 29px"); + expect(prepared).toContain("padding: 50px"); + }); + + test("leaves ambiguous local partials to Sass", () => { + const source = readSource("ambiguous", "content.scss"); + + expect(() => compile(prepareStyleSources([source], path.join(fixtures, "virtual.scss")))).toThrow( + /not clear which file to import/ + ); + }); + + test("rebases module requests before merging and preserves Sass configuration and forwarding", () => { + const merged = mergeLocatedFixture("relative-modules"); + + expect(merged).toContain( + `@use "${moduleUrl("relative-modules", "shared", "theme", "tokens")}" as sharedTokens;` + ); + expect(merged).toContain(`@use "${moduleUrl("relative-modules", "app", "theme", "tokens")}" as appTokens;`); + expect(merged).toContain( + `@forward "${moduleUrl("relative-modules", "app", "theme", "tokens")}" as theme-* show $theme-accent with ($accent: blue);` + ); + expect(merged).toContain( + `@forward "${moduleUrl("relative-modules", "app", "theme", "hidden")}" hide $private;` + ); + expect(normalizeStyle(merged)).toContain( + `@use "${moduleUrl("relative-modules", "app", "theme", "fonts")}" with ( $font: url("${resourcePath("relative-modules", "app", "theme", "Inter.woff2")}?browser&v=a/../b#face") );` + ); + + const css = compileLocatedFixture("relative-modules"); + + expect(css).toContain("color: red"); + expect(css).toContain("background: blue"); + expect(css).toContain("font-weight: 400"); + expect(css).toContain( + `url("${resourcePath("relative-modules", "app", "theme", "Inter.woff2")}?browser&v=a/../b#face")` + ); + }); + + test("does not deduplicate identical requests to different files or hide their namespace conflict", () => { + const merged = mergeLocatedFixture("relative-conflict"); + + expect(merged).toContain(`@use "${moduleUrl("relative-conflict", "shared", "theme", "tokens")}";`); + expect(merged).toContain(`@use "${moduleUrl("relative-conflict", "app", "theme", "tokens")}";`); + expect(() => compileLocatedFixture("relative-conflict")).toThrow(/already a module with namespace "tokens"/); + }); + + test("normalizes shared requests so equivalent relative requests to the same module deduplicate", () => { + const merged = mergeLocatedFixture("same-module"); + + expect(merged).not.toContain("./../common/tokens"); + expect(merged.split(`@use "${moduleUrl("same-module", "common", "tokens")}";`)).toHaveLength(2); + expect(compileLocatedFixture("same-module").match(/color: red/g)).toHaveLength(2); + }); + + test("prepares both source URLs in declarations and at-rule parameters while keeping text and comments", () => { + const merged = normalizeStyle(mergeLocatedFixture("url-values")); + + for (const rebased of [ + `$icon: url("${resourcePath("url-values", "app", "theme", "icon.svg")}");`, + `background: url("${resourcePath("url-values", "app", "theme", "plain.svg")}");`, + `@supports (background: url("${resourcePath("url-values", "app", "theme", "support.svg")}"))`, + `$logo: url("${resourcePath("url-values", "shared", "logo.svg")}");`, + `background: url("${resourcePath("url-values", "shared", "shared.svg")}");`, + ]) { + expect(merged).toContain(rebased); + } + + for (const unchanged of ['content: "url(./text.svg)";', '/* url("./comment.svg") */']) { + expect(merged).toContain(unchanged); + } + }); +}); + +declare global { + namespace jest { + interface Matchers { + toMatchOrder(expected: string[]): R; + } + } +} + +expect.extend({ + toMatchOrder(received: string, expected: string[]) { + received = normalizeStyle(received); + expected = expected.map(normalizeStyle); + + let lastIndex = -1; + + for (const value of expected) { + const index = received.indexOf(value); + + if (index === -1) { + return { + pass: false, + message: () => `Expected merged style to contain ${this.utils.printExpected(value)}.`, + }; + } + + if (index < lastIndex) { + return { + pass: false, + message: () => `Expected ${this.utils.printExpected(value)} to appear after previous style part.`, + }; + } + + lastIndex = index; + } + + return { + pass: true, + message: () => "Expected merged style parts not to appear in order.", + }; + }, +}); diff --git a/src/cli/bundler/styles/prepare-style-sources.ts b/src/cli/bundler/styles/prepare-style-sources.ts new file mode 100644 index 00000000..437bec5e --- /dev/null +++ b/src/cli/bundler/styles/prepare-style-sources.ts @@ -0,0 +1,129 @@ +import {type AtRule, type ChildNode} from "postcss"; +import * as scss from "postcss-scss"; +import valueParser from "postcss-value-parser"; + +import {prepareModuleRequests, prepareUrlFunctions} from "./style-paths"; +import type {StyleDependencyHandler, StyleSource} from "./types"; + +type StyleSourceParts = { + charsets: ChildNode[]; + sassPrelude: ChildNode[]; + cssPrelude: ChildNode[]; + body: ChildNode[]; +}; + +/** + * Prepares ordered CSS/SCSS sources for compilation as one resource. Source filenames must be absolute; + * the caller owns file discovery, reading and watch dependencies. Local Sass requests and literal + * resource URLs retain their declaring file's location, including values passed through `with`. + * The target remains the resource compiled by Sass and css-loader, preserving its CSS module identity. + */ +export const prepareStyleSources = ( + sources: readonly StyleSource[], + targetFilename: string, + onDependency?: StyleDependencyHandler +): string => { + const parts = sources.map(source => splitStyleSource(source, onDependency)); + const seen = new Set(); + + for (const source of parts) { + const counts = new Map(); + + for (const node of source.sassPrelude) { + const key = reusableModuleKey(node); + + if (key) { + counts.set(key, (counts.get(key) ?? 0) + 1); + } + } + + source.sassPrelude = source.sassPrelude.filter(node => { + const key = reusableModuleKey(node); + + // Repeated directives within one source must remain visible to Sass, even if + // a previous source used the same module. Configuration and variables are stateful. + return !key || counts.get(key) !== 1 || !seen.has(key); + }); + + counts.forEach((_count, key) => seen.add(key)); + } + + const result = scss.parse("", {from: targetFilename}); + + for (const bucket of ["charsets", "sassPrelude", "cssPrelude", "body"] as const) { + for (const source of parts) { + for (const node of source[bucket]) { + node.raws.before = "\n"; + result.append(node); + } + } + } + + result.raws.semicolon = true; + + return result.toString(scss.stringify).trim(); +}; + +const splitStyleSource = (source: StyleSource, onDependency?: StyleDependencyHandler): StyleSourceParts => { + const root = scss.parse(source.content, {from: source.filename}); + + root.walkAtRules(node => { + node.params = prepareUrlFunctions(node.params, source.filename); + + if (["use", "forward", "import"].includes(node.name)) { + node.params = prepareModuleRequests(node.params, source.filename, node.name === "import", onDependency); + } + }); + + root.walkDecls(node => { + node.value = prepareUrlFunctions(node.value, source.filename); + }); + + const parts: StyleSourceParts = {charsets: [], sassPrelude: [], cssPrelude: [], body: []}; + let modulesClosed = false; + let bodyStarted = false; + + for (const node of root.nodes) { + const module = isModuleRule(node); + + if (module && modulesClosed) { + throw node.error(`@${node.name} must precede other rules in its source stylesheet`); + } + + const charset = node.type === "atrule" && node.name === "charset"; + const variable = node.type === "decl" && node.prop.startsWith("$"); + + if (!modulesClosed && (module || variable || charset || node.type === "comment")) { + parts[charset ? "charsets" : "sassPrelude"].push(node); + } else if (!bodyStarted && (isCssPreludeRule(node) || node.type === "comment")) { + modulesClosed = true; + parts.cssPrelude.push(node); + } else { + modulesClosed = true; + bodyStarted = true; + parts.body.push(node); + } + } + + return parts; +}; + +const reusableModuleKey = (node: ChildNode): string | undefined => { + if (!isModuleRule(node) || node.nodes) { + return; + } + + const configured = valueParser(node.params).nodes.some( + token => (token.type === "word" || token.type === "function") && token.value === "with" + ); + + return configured ? undefined : node.toString(scss.stringify).trim(); +}; + +const isModuleRule = (node: ChildNode): node is AtRule => { + return node.type === "atrule" && ["use", "forward"].includes(node.name); +}; + +const isCssPreludeRule = (node: ChildNode): boolean => { + return node.type === "atrule" && !node.nodes && ["import", "namespace", "layer"].includes(node.name); +}; diff --git a/src/cli/bundler/styles/style-paths.test.ts b/src/cli/bundler/styles/style-paths.test.ts new file mode 100644 index 00000000..3c76a38e --- /dev/null +++ b/src/cli/bundler/styles/style-paths.test.ts @@ -0,0 +1,245 @@ +import path from "node:path"; +import {pathToFileURL} from "node:url"; + +import {prepareModuleRequests, prepareUrlFunctions} from "./style-paths"; + +const source = path.resolve("src"); +const from = path.join(source, "apps", "alpha", "content", "content.scss"); +const directory = path.dirname(from).split(path.sep).join("/"); +const moduleUrl = (request: string): string => pathToFileURL(path.resolve(path.dirname(from), request)).href; +const fixtures = path.resolve(__dirname, "tests", "fixtures"); + +describe("prepareModuleRequests", () => { + test.each(["tokens", "./tokens"])("reports every candidate for %s even after finding a stylesheet", request => { + const filename = path.join(fixtures, "ambiguous", "content.scss"); + const dependencies = new Map(); + + prepareModuleRequests(`"${request}"`, filename, false, (candidate, exists) => { + dependencies.set(candidate, exists); + }); + + expect(dependencies).toEqual( + new Map([ + [path.join(fixtures, "ambiguous", "tokens.scss"), true], + [path.join(fixtures, "ambiguous", "_tokens.scss"), true], + [path.join(fixtures, "ambiguous", "tokens", "index.scss"), false], + [path.join(fixtures, "ambiguous", "tokens", "_index.scss"), false], + [path.join(fixtures, "ambiguous", "tokens.sass"), false], + [path.join(fixtures, "ambiguous", "_tokens.sass"), false], + [path.join(fixtures, "ambiguous", "tokens", "index.sass"), false], + [path.join(fixtures, "ambiguous", "tokens", "_index.sass"), false], + [path.join(fixtures, "ambiguous", "tokens.css"), false], + [path.join(fixtures, "ambiguous", "_tokens.css"), false], + [path.join(fixtures, "ambiguous", "tokens", "index.css"), false], + [path.join(fixtures, "ambiguous", "tokens", "_index.css"), false], + ]) + ); + }); + + test("reports missing bare candidates without changing package fallback", () => { + const filename = path.join(fixtures, "bare-local", "app", "content.scss"); + const dependencies = new Map(); + + const prepared = prepareModuleRequests('"absent-theme"', filename, false, (candidate, exists) => { + dependencies.set(candidate, exists); + }); + + expect(prepared).toBe('"absent-theme"'); + expect(dependencies.size).toBe(12); + expect([...dependencies.values()]).toEqual(Array(12).fill(false)); + expect(dependencies.has(path.join(path.dirname(filename), "_absent-theme.scss"))).toBe(true); + expect(dependencies.has(path.join(path.dirname(filename), "absent-theme", "_index.scss"))).toBe(true); + }); + + test("reports existing import-only files and missing alternatives", () => { + const filename = path.join(fixtures, "bare-local", "app", "content.scss"); + const dependencies = new Map(); + + prepareModuleRequests('"legacy"', filename, true, (candidate, exists) => { + dependencies.set(candidate, exists); + }); + + expect(dependencies.size).toBe(24); + expect(dependencies.get(path.join(path.dirname(filename), "_legacy.import.scss"))).toBe(true); + expect(dependencies.get(path.join(path.dirname(filename), "legacy", "_index.import.scss"))).toBe(false); + expect(dependencies.get(path.join(path.dirname(filename), "_legacy.scss"))).toBe(false); + }); + + test.each(["./absent-theme", "../theme/absent-fonts"])( + "reports missing candidates for %s while retaining its source-relative request", + request => { + const dependencies = new Map(); + const prepared = prepareModuleRequests(`"${request}"`, from, false, (candidate, exists) => { + dependencies.set(candidate, exists); + }); + const filename = path.resolve(path.dirname(from), request); + + expect(prepared).toBe(`"${moduleUrl(request)}"`); + expect(dependencies.size).toBe(12); + expect([...dependencies.values()]).toEqual(Array(12).fill(false)); + expect(dependencies.has(path.join(path.dirname(filename), "_" + path.basename(filename) + ".scss"))).toBe( + true + ); + expect(dependencies.has(path.join(filename, "_index.scss"))).toBe(true); + } + ); + + test.each(['"sass:math"', '"@theme/tokens"', '"theme.css"'])( + "leaves dependency ownership for %s with its existing resolver", + params => { + const dependencies = new Map(); + + prepareModuleRequests(params, from, true, (candidate, exists) => { + dependencies.set(candidate, exists); + }); + + expect(dependencies.size).toBe(0); + } + ); + + test.each([ + ['"./theme/tokens" as appTokens', `"${moduleUrl("./theme/tokens")}" as appTokens`], + ["'../theme/fonts'", `'${moduleUrl("../theme/fonts")}'`], + [ + '"./theme/tokens" as theme-* show $theme-accent with ($accent: blue)', + `"${moduleUrl("./theme/tokens")}" as theme-* show $theme-accent with ($accent: blue)`, + ], + [ + '"../theme/fonts" with (\n $font: url("../theme/font.woff2?browser")\n)', + `"${moduleUrl("../theme/fonts")}" with (\n $font: url("../theme/font.woff2?browser")\n)`, + ], + ['/* app */ "./theme/tokens"', `/* app */ "${moduleUrl("./theme/tokens")}"`], + ['"./theme/./tokens"', `"${moduleUrl("./theme/tokens")}"`], + ['"./space\\ name"', `"${moduleUrl("./space name")}"`], + ['"./space\\20 name"', `"${moduleUrl("./space name")}"`], + ['"./space\\000020name"', `"${moduleUrl("./space name")}"`], + ['"./space%20name"', `"${moduleUrl("./space name")}"`], + ['"./50%off"', `"${moduleUrl("./50%off")}"`], + ['"./50%25off"', `"${moduleUrl("./50%off")}"`], + ['"./\\74 okens"', `"${moduleUrl("./tokens")}"`], + ])("prepares the relative request in %j", (params, expected) => { + expect(prepareModuleRequests(params, from)).toBe(expected); + }); + + test.each([ + '"sass:math"', + '"some-package/theme"', + '"@theme/tokens"', + '"~theme/tokens"', + '"theme/tokens"', + '"pkg:some-package"', + '"./#{$theme}"', + '"./theme/tokens', + "", + ])("keeps %j as written", params => { + expect(prepareModuleRequests(params, from)).toBe(params); + }); + + test("uses encoded file URLs for module paths with spaces and URL-reserved characters", () => { + const filename = path.resolve("project with spaces", "theme#one%", "content.scss"); + const expected = pathToFileURL(path.resolve(path.dirname(filename), "fonts")).href; + + expect(prepareModuleRequests('"./fonts"', filename)).toBe(`"${expected}"`); + expect(expected).toContain("project%20with%20spaces/theme%23one%25/fonts"); + }); + + test.each(["'", '"'])("escapes %s introduced by the source directory", quote => { + const filename = path.resolve(`author${quote}s project`, "content.scss"); + const expected = pathToFileURL(path.resolve(path.dirname(filename), "fonts")).href.replaceAll( + quote, + `\\${quote}` + ); + + expect(prepareModuleRequests(`${quote}./fonts${quote}`, filename)).toBe(`${quote}${expected}${quote}`); + }); + + test.each(["'", '"'])("escapes %s in quoted CSS imports", quote => { + const filename = path.resolve(`author${quote}s project`, "content.scss"); + const expected = path.dirname(filename).split(path.sep).join("/").replaceAll(quote, `\\${quote}`); + + expect(prepareModuleRequests(`${quote}./theme.css${quote}`, filename, true)).toBe( + `${quote}${expected}/theme.css${quote}` + ); + }); + + test("prepares all legacy Sass import requests, keeping CSS imports as filesystem requests", () => { + expect(prepareModuleRequests('"./one", "../two", "./base.css?theme=1#part"', from, true)).toBe( + `"${moduleUrl("./one")}", "${moduleUrl("../two")}", "${directory}/base.css?theme=1#part"` + ); + }); + + test.each([ + ['"./screen" screen', `"${directory}/screen" screen`], + ['"./screen" layer(theme)', `"${directory}/screen" layer(theme)`], + ['"./screen" supports(display: grid)', `"${directory}/screen" supports(display: grid)`], + ['url("./screen.css")', 'url("./screen.css")'], + ['"https://example.com/theme.css"', '"https://example.com/theme.css"'], + ['"@theme/base.css"', '"@theme/base.css"'], + ])("preserves CSS import behavior for %j", (params, expected) => { + expect(prepareModuleRequests(params, from, true)).toBe(expected); + }); +}); + +describe("prepareUrlFunctions", () => { + test.each([ + ["url(theme/plain.svg)", `url("${directory}/theme/plain.svg")`], + [ + 'url( "./theme/spaced font.woff2?browser#face" )', + `url( "${directory}/theme/spaced font.woff2?browser#face" )`, + ], + ["url('./theme/icon.svg?next=a/../b#face')", `url('${directory}/theme/icon.svg?next=a/../b#face')`], + ['url("./theme/escaped\\ name.svg")', `url("${directory}/theme/escaped name.svg")`], + ['url("./theme/escaped\\20 name.svg")', `url("${directory}/theme/escaped name.svg")`], + ['URL("../theme/upper.svg")', `URL("${path.posix.join(directory, "../theme/upper.svg")}")`], + ['url("./theme/font.woff2") format("woff2")', `url("${directory}/theme/font.woff2") format("woff2")`], + ['url("./a.svg"), url("./b.svg")', `url("${directory}/a.svg"), url("${directory}/b.svg")`], + [ + 'image-set(url("./a.png") 1x, url("./a@2x.png") 2x)', + `image-set(url("${directory}/a.png") 1x, url("${directory}/a@2x.png") 2x)`, + ], + [ + '(\n $font: url("../theme/font.woff2?browser")\n)', + `(\n $font: url("${path.posix.join(directory, "../theme/font.woff2")}?browser")\n)`, + ], + ])("prepares the literal URL in %j", (value, expected) => { + expect(prepareUrlFunctions(value, from)).toBe(expected); + }); + + test.each([ + 'url("https://example.com/font.woff2")', + 'url("//example.com/font.woff2")', + 'url("data:image/svg+xml;base64,AAAA")', + 'url("chrome-extension://id/font.woff2")', + 'url("/font.woff2")', + 'url("#icon")', + 'url("?version=1")', + 'url("~package/font.woff2")', + 'url("@theme/font.woff2")', + "url($font)", + 'url("#{$directory}/font.woff2")', + "url(./#{$theme}.svg)", + "url(font-path())", + 'url("./a.svg" "b")', + 'url("./unclosed', + "url()", + '"url(./text.svg)"', + '$font format("woff2")', + ])("keeps %j as written", value => { + expect(prepareUrlFunctions(value, from)).toBe(value); + }); + + test("can prepare already prepared URLs again without changing their origin", () => { + const prepared = prepareUrlFunctions('url("./font.woff2?browser#face")', from); + + expect(prepareUrlFunctions(prepared, path.join(source, "virtual.scss"))).toBe(prepared); + }); + + test.each(["'", '"'])("escapes %s from the source directory in literal URLs", quote => { + const filename = path.resolve(`author${quote}s project`, "content.scss"); + const directory = path.dirname(filename).split(path.sep).join("/").replaceAll(quote, `\\${quote}`); + + expect(prepareUrlFunctions(`url(${quote}./font.woff2${quote})`, filename)).toBe( + `url(${quote}${directory}/font.woff2${quote})` + ); + }); +}); diff --git a/src/cli/bundler/styles/style-paths.ts b/src/cli/bundler/styles/style-paths.ts new file mode 100644 index 00000000..a21834a2 --- /dev/null +++ b/src/cli/bundler/styles/style-paths.ts @@ -0,0 +1,205 @@ +import {statSync} from "node:fs"; +import path from "node:path"; +import {fileURLToPath, pathToFileURL} from "node:url"; +import valueParser from "postcss-value-parser"; + +import {toPosix} from "@cli/utils/path"; + +import type {StyleDependencyHandler} from "./types"; + +const RelativeRequestPattern = /^\.{1,2}\//; +const ExternalRequestPattern = /^(?:[a-z][a-z\d+.-]*:|[/#?~@$])/i; +const SassExtensions = [".scss", ".sass", ".css"]; + +/** Resolves local Sass module requests without selecting a partial, index or import-only file. */ +export const prepareModuleRequests = ( + params: string, + filename: string, + legacyImport = false, + onDependency?: StyleDependencyHandler +): string => { + const parsed = valueParser(params); + let start = true; + + for (const [index, node] of parsed.nodes.entries()) { + if (node.type === "space" || node.type === "comment") { + continue; + } + + if (legacyImport && node.type === "div" && node.value === ",") { + start = true; + + continue; + } + + if (start && node.type === "string" && !node.unclosed && isLocalRequest(decodeCssString(node.value))) { + const request = decodeCssString(node.value); + const tail = parsed.nodes.slice(index + 1); + const nextComma = tail.findIndex(token => token.type === "div" && token.value === ","); + const suffix = nextComma === -1 ? tail : tail.slice(0, nextComma); + + const cssImport = + legacyImport && + (/\.css(?:[?#]|$)/i.test(request) || + suffix.some(token => token.type !== "space" && token.type !== "comment")); + + if (cssImport) { + node.value = escapeQuotedValue(absoluteResourcePath(request, filename), node.quote); + } else { + // Sass treats bare Windows drive paths as URL schemes. File URLs also encode + // spaces while preserving existing percent escapes in module requests. + // Sass permits literal percent signs; encode those before Node decodes the URL. + const resolved = new URL(request.replace(/%(?![\da-f]{2})/gi, "%25"), pathToFileURL(filename)); + // Track missing relative imports too, so creating the module can recover a failed build. + const local = hasLocalModule(fileURLToPath(resolved), legacyImport, onDependency); + + if (RelativeRequestPattern.test(request) || local) { + node.value = escapeQuotedValue(resolved.href, node.quote); + } + } + } + + if (!legacyImport) { + break; + } + + start = false; + } + + return parsed.toString(); +}; + +/** Makes literal resource values independent of the Sass file that eventually emits them. */ +export const prepareUrlFunctions = (value: string, filename: string): string => { + const parsed = valueParser(value); + let prepared = false; + + parsed.walk(node => { + if (node.type !== "function" || node.value.toLowerCase() !== "url") { + return; + } + + const tokens = node.nodes.filter(token => token.type !== "space" && token.type !== "comment"); + const url = tokens[0]; + + if (node.unclosed || tokens.length !== 1 || !isLiteralUrl(url)) { + return false; + } + + // Absolute filesystem requests survive Sass configuration/variable substitution and + // are left alone by resolve-url-loader. Quoting protects spaces in source directories. + const quote = url.type === "string" ? url.quote : '"'; + const quoted: valueParser.StringNode = { + ...url, + type: "string", + quote, + value: escapeQuotedValue(absoluteResourcePath(decodeCssString(url.value), filename), quote), + }; + + node.nodes = node.nodes.map(token => (token === url ? quoted : token)); + prepared = true; + + return false; + }); + + return prepared ? parsed.toString() : value; +}; + +const isLiteralUrl = (token: valueParser.Node): token is valueParser.StringNode | valueParser.WordNode => { + if (token.type === "string") { + return !token.unclosed && isLocalRequest(decodeCssString(token.value)); + } + + if (token.type === "word") { + return isLocalRequest(decodeCssString(token.value)) && !/[$()]/.test(token.value); + } + + return false; +}; + +const isLocalRequest = (value: string): boolean => { + return Boolean(value) && !ExternalRequestPattern.test(value) && !value.includes("#{"); +}; + +const absoluteResourcePath = (request: string, filename: string): string => { + const suffixIndex = request.search(/[?#]/); + const pathname = suffixIndex === -1 ? request : request.slice(0, suffixIndex); + const suffix = suffixIndex === -1 ? "" : request.slice(suffixIndex); + + return path.posix.join(toPosix(path.dirname(filename)), pathname) + suffix; +}; + +const escapeQuotedValue = (value: string, quote: string): string => { + return value.replace(/[\\\n\r\f'"]/g, token => { + if (/[\n\r\f]/.test(token)) { + return `\\${token.codePointAt(0)!.toString(16)} `; + } + + return token === "\\" || token === quote ? `\\${token}` : token; + }); +}; + +// Value-parser retains CSS escapes. Decode string contents before treating them as a +// filesystem path; otherwise `space\\ name` would become a URL containing a backslash. +const decodeCssString = (value: string): string => { + return value.replace( + /\\(?:([\da-f]{1,6})(?:\r\n|[ \t\n\r\f])?|(\r\n|[\n\r\f])|([\s\S]))/gi, + (_match, hex: string | undefined, newline: string | undefined, character: string | undefined) => { + if (hex) { + const codePoint = Number.parseInt(hex, 16); + + return codePoint === 0 || codePoint > 0x10ffff || (codePoint >= 0xd800 && codePoint <= 0xdfff) + ? "\uFFFD" + : String.fromCodePoint(codePoint); + } + + return newline ? "" : character!; + } + ); +}; + +// Detect local candidates without selecting a stylesheet. Sass still chooses among +// partials, indexes and import-only files, and reports ambiguity using its normal rules. +// Visit every candidate so creating an absent alternative invalidates the watch compilation. +const hasLocalModule = (filename: string, legacyImport: boolean, onDependency?: StyleDependencyHandler): boolean => { + const candidates = SassExtensions.includes(path.extname(filename)) + ? [filename] + : SassExtensions.flatMap(extension => [filename + extension, path.join(filename, "index" + extension)]); + + if (legacyImport) { + candidates.push( + ...candidates.map(candidate => { + const extension = path.extname(candidate); + + return candidate.slice(0, -extension.length) + ".import" + extension; + }) + ); + } + + const filenames = candidates.flatMap(candidate => [ + candidate, + path.join(path.dirname(candidate), "_" + path.basename(candidate)), + ]); + + let found = false; + + for (const candidate of new Set(filenames)) { + const exists = isFile(candidate); + onDependency?.(candidate, exists); + found ||= exists; + } + + return found; +}; + +const isFile = (filename: string): boolean => { + try { + return statSync(filename).isFile(); + } catch (error) { + if (!["ENOENT", "ENOTDIR"].includes((error as NodeJS.ErrnoException).code ?? "")) { + throw error; + } + + return false; + } +}; diff --git a/src/cli/bundler/styles/style-url-join.ts b/src/cli/bundler/styles/style-url-join.ts new file mode 100644 index 00000000..949fb051 --- /dev/null +++ b/src/cli/bundler/styles/style-url-join.ts @@ -0,0 +1,17 @@ +import type {LoaderContext} from "@rspack/core"; +import resolveUrlLoader from "resolve-url-loader"; + +type StyleUrlRequest = {uri: string}; + +/** Keep explicit alias requests for css-loader instead of treating them as partial-local files. */ +export const joinStyleUrl = (options: object, context: LoaderContext) => { + const join = resolveUrlLoader.defaultJoin(options, context); + + return (request: StyleUrlRequest): string | null => { + if (request.uri.startsWith("@")) { + return null; + } + + return join(request); + }; +}; diff --git a/src/cli/bundler/styles/tests/fixtures/ambiguous/_tokens.scss b/src/cli/bundler/styles/tests/fixtures/ambiguous/_tokens.scss new file mode 100644 index 00000000..20b8f837 --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/ambiguous/_tokens.scss @@ -0,0 +1 @@ +$color: blue; diff --git a/src/cli/bundler/styles/tests/fixtures/ambiguous/content.scss b/src/cli/bundler/styles/tests/fixtures/ambiguous/content.scss new file mode 100644 index 00000000..bff9ecea --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/ambiguous/content.scss @@ -0,0 +1 @@ +@use "tokens"; diff --git a/src/cli/bundler/styles/tests/fixtures/ambiguous/tokens.scss b/src/cli/bundler/styles/tests/fixtures/ambiguous/tokens.scss new file mode 100644 index 00000000..4b04915e --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/ambiguous/tokens.scss @@ -0,0 +1 @@ +$color: red; diff --git a/src/cli/bundler/styles/tests/fixtures/bare-local/app/_legacy.import.scss b/src/cli/bundler/styles/tests/fixtures/bare-local/app/_legacy.import.scss new file mode 100644 index 00000000..cbbdce5d --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/bare-local/app/_legacy.import.scss @@ -0,0 +1 @@ +$height: 23px; diff --git a/src/cli/bundler/styles/tests/fixtures/bare-local/app/_tokens.v2.scss b/src/cli/bundler/styles/tests/fixtures/bare-local/app/_tokens.v2.scss new file mode 100644 index 00000000..20b8f837 --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/bare-local/app/_tokens.v2.scss @@ -0,0 +1 @@ +$color: blue; diff --git a/src/cli/bundler/styles/tests/fixtures/bare-local/app/content.scss b/src/cli/bundler/styles/tests/fixtures/bare-local/app/content.scss new file mode 100644 index 00000000..dc41fd63 --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/bare-local/app/content.scss @@ -0,0 +1,5 @@ +@use "tokens.v2" as appTokens; +@use "indexed"; +@use "plain.css"; +@import "legacy"; +.app { color: appTokens.$color; width: indexed.$size; height: $height; } diff --git a/src/cli/bundler/styles/tests/fixtures/bare-local/app/indexed/_index.scss b/src/cli/bundler/styles/tests/fixtures/bare-local/app/indexed/_index.scss new file mode 100644 index 00000000..f1fba7b6 --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/bare-local/app/indexed/_index.scss @@ -0,0 +1 @@ +$size: 17px; diff --git a/src/cli/bundler/styles/tests/fixtures/bare-local/app/plain.css b/src/cli/bundler/styles/tests/fixtures/bare-local/app/plain.css new file mode 100644 index 00000000..106b823f --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/bare-local/app/plain.css @@ -0,0 +1 @@ +.plain { display: block; } diff --git a/src/cli/bundler/styles/tests/fixtures/bare-local/import-index.scss b/src/cli/bundler/styles/tests/fixtures/bare-local/import-index.scss new file mode 100644 index 00000000..f90a8220 --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/bare-local/import-index.scss @@ -0,0 +1,2 @@ +@import "legacy-index"; +.legacy { width: $size; } diff --git a/src/cli/bundler/styles/tests/fixtures/bare-local/legacy-index/_index.import.scss b/src/cli/bundler/styles/tests/fixtures/bare-local/legacy-index/_index.import.scss new file mode 100644 index 00000000..7055bb6e --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/bare-local/legacy-index/_index.import.scss @@ -0,0 +1 @@ +$size: 29px; diff --git a/src/cli/bundler/styles/tests/fixtures/bare-local/shared/_tokens.v2.scss b/src/cli/bundler/styles/tests/fixtures/bare-local/shared/_tokens.v2.scss new file mode 100644 index 00000000..4b04915e --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/bare-local/shared/_tokens.v2.scss @@ -0,0 +1 @@ +$color: red; diff --git a/src/cli/bundler/styles/tests/fixtures/bare-local/shared/content.scss b/src/cli/bundler/styles/tests/fixtures/bare-local/shared/content.scss new file mode 100644 index 00000000..0a956494 --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/bare-local/shared/content.scss @@ -0,0 +1,2 @@ +@use "tokens.v2" as sharedTokens; +.shared { color: sharedTokens.$color; } diff --git a/src/cli/plugins/style/tests/fixtures/block-atrules/app.scss b/src/cli/bundler/styles/tests/fixtures/block-atrules/app.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/block-atrules/app.scss rename to src/cli/bundler/styles/tests/fixtures/block-atrules/app.scss diff --git a/src/cli/plugins/style/tests/fixtures/block-atrules/shared.scss b/src/cli/bundler/styles/tests/fixtures/block-atrules/shared.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/block-atrules/shared.scss rename to src/cli/bundler/styles/tests/fixtures/block-atrules/shared.scss diff --git a/src/cli/plugins/style/tests/fixtures/charset/app.scss b/src/cli/bundler/styles/tests/fixtures/charset/app.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/charset/app.scss rename to src/cli/bundler/styles/tests/fixtures/charset/app.scss diff --git a/src/cli/plugins/style/tests/fixtures/charset/shared.scss b/src/cli/bundler/styles/tests/fixtures/charset/shared.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/charset/shared.scss rename to src/cli/bundler/styles/tests/fixtures/charset/shared.scss diff --git a/src/cli/plugins/style/tests/fixtures/comments/app.scss b/src/cli/bundler/styles/tests/fixtures/comments/app.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/comments/app.scss rename to src/cli/bundler/styles/tests/fixtures/comments/app.scss diff --git a/src/cli/plugins/style/tests/fixtures/comments/shared.scss b/src/cli/bundler/styles/tests/fixtures/comments/shared.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/comments/shared.scss rename to src/cli/bundler/styles/tests/fixtures/comments/shared.scss diff --git a/src/cli/bundler/styles/tests/fixtures/configured-repeat/_tokens.scss b/src/cli/bundler/styles/tests/fixtures/configured-repeat/_tokens.scss new file mode 100644 index 00000000..71c5ba06 --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/configured-repeat/_tokens.scss @@ -0,0 +1 @@ +$color: blue !default; diff --git a/src/cli/bundler/styles/tests/fixtures/configured-repeat/app.scss b/src/cli/bundler/styles/tests/fixtures/configured-repeat/app.scss new file mode 100644 index 00000000..36b99f60 --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/configured-repeat/app.scss @@ -0,0 +1,2 @@ +$color: red; +@use "./tokens" with /* configure */ ($color: $color); diff --git a/src/cli/bundler/styles/tests/fixtures/configured-repeat/shared.scss b/src/cli/bundler/styles/tests/fixtures/configured-repeat/shared.scss new file mode 100644 index 00000000..36b99f60 --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/configured-repeat/shared.scss @@ -0,0 +1,2 @@ +$color: red; +@use "./tokens" with /* configure */ ($color: $color); diff --git a/src/cli/plugins/style/tests/fixtures/conflicting-prelude/app.scss b/src/cli/bundler/styles/tests/fixtures/conflicting-prelude/app.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/conflicting-prelude/app.scss rename to src/cli/bundler/styles/tests/fixtures/conflicting-prelude/app.scss diff --git a/src/cli/plugins/style/tests/fixtures/conflicting-prelude/shared.scss b/src/cli/bundler/styles/tests/fixtures/conflicting-prelude/shared.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/conflicting-prelude/shared.scss rename to src/cli/bundler/styles/tests/fixtures/conflicting-prelude/shared.scss diff --git a/src/cli/bundler/styles/tests/fixtures/css-effects/app.scss b/src/cli/bundler/styles/tests/fixtures/css-effects/app.scss new file mode 100644 index 00000000..79925b7d --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/css-effects/app.scss @@ -0,0 +1,4 @@ +@use "sass:math"; +@layer base, app; +@import "https://example.com/theme.css"; +.app { color: blue; } diff --git a/src/cli/bundler/styles/tests/fixtures/css-effects/shared.scss b/src/cli/bundler/styles/tests/fixtures/css-effects/shared.scss new file mode 100644 index 00000000..dfbff1b6 --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/css-effects/shared.scss @@ -0,0 +1,3 @@ +@layer base, app; +@import "https://example.com/theme.css"; +.shared { color: red; } diff --git a/src/cli/plugins/style/tests/fixtures/css-prelude/app.scss b/src/cli/bundler/styles/tests/fixtures/css-prelude/app.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/css-prelude/app.scss rename to src/cli/bundler/styles/tests/fixtures/css-prelude/app.scss diff --git a/src/cli/plugins/style/tests/fixtures/css-prelude/shared.scss b/src/cli/bundler/styles/tests/fixtures/css-prelude/shared.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/css-prelude/shared.scss rename to src/cli/bundler/styles/tests/fixtures/css-prelude/shared.scss diff --git a/src/cli/plugins/style/tests/fixtures/duplicate-body/app.scss b/src/cli/bundler/styles/tests/fixtures/duplicate-body/app.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/duplicate-body/app.scss rename to src/cli/bundler/styles/tests/fixtures/duplicate-body/app.scss diff --git a/src/cli/plugins/style/tests/fixtures/duplicate-body/shared.scss b/src/cli/bundler/styles/tests/fixtures/duplicate-body/shared.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/duplicate-body/shared.scss rename to src/cli/bundler/styles/tests/fixtures/duplicate-body/shared.scss diff --git a/src/cli/plugins/style/tests/fixtures/duplicate-prelude/app.scss b/src/cli/bundler/styles/tests/fixtures/duplicate-prelude/app.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/duplicate-prelude/app.scss rename to src/cli/bundler/styles/tests/fixtures/duplicate-prelude/app.scss diff --git a/src/cli/plugins/style/tests/fixtures/duplicate-prelude/shared.scss b/src/cli/bundler/styles/tests/fixtures/duplicate-prelude/shared.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/duplicate-prelude/shared.scss rename to src/cli/bundler/styles/tests/fixtures/duplicate-prelude/shared.scss diff --git a/src/cli/bundler/styles/tests/fixtures/escaped-modules/_50%off.scss b/src/cli/bundler/styles/tests/fixtures/escaped-modules/_50%off.scss new file mode 100644 index 00000000..1585cbe2 --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/escaped-modules/_50%off.scss @@ -0,0 +1 @@ +$padding: 50px; diff --git a/src/cli/bundler/styles/tests/fixtures/escaped-modules/_linecontinuation.scss b/src/cli/bundler/styles/tests/fixtures/escaped-modules/_linecontinuation.scss new file mode 100644 index 00000000..57e67ef3 --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/escaped-modules/_linecontinuation.scss @@ -0,0 +1 @@ +$margin: 29px; diff --git a/src/cli/bundler/styles/tests/fixtures/escaped-modules/_quo'te.scss b/src/cli/bundler/styles/tests/fixtures/escaped-modules/_quo'te.scss new file mode 100644 index 00000000..cbbdce5d --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/escaped-modules/_quo'te.scss @@ -0,0 +1 @@ +$height: 23px; diff --git a/src/cli/bundler/styles/tests/fixtures/escaped-modules/_space name.scss b/src/cli/bundler/styles/tests/fixtures/escaped-modules/_space name.scss new file mode 100644 index 00000000..ca95971b --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/escaped-modules/_space name.scss @@ -0,0 +1 @@ +$color: orange; diff --git a/src/cli/bundler/styles/tests/fixtures/escaped-modules/_tokens.scss b/src/cli/bundler/styles/tests/fixtures/escaped-modules/_tokens.scss new file mode 100644 index 00000000..f1fba7b6 --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/escaped-modules/_tokens.scss @@ -0,0 +1 @@ +$size: 17px; diff --git a/src/cli/bundler/styles/tests/fixtures/escaped-modules/content.scss b/src/cli/bundler/styles/tests/fixtures/escaped-modules/content.scss new file mode 100644 index 00000000..4bcb0c27 --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/escaped-modules/content.scss @@ -0,0 +1,8 @@ +@use "./space\ name" as space; +@use "./space%20name" as encodedSpace; +@use "50%off" as percent; +@use "./\74 okens" as tokens; +@use "./quo\'te" as quoted; +@use "./line\ +continuation" as continued; +.badge { color: space.$color; background: encodedSpace.$color; width: tokens.$size; height: quoted.$height; margin: continued.$margin; padding: percent.$padding; } diff --git a/src/cli/plugins/style/tests/fixtures/forward/app.scss b/src/cli/bundler/styles/tests/fixtures/forward/app.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/forward/app.scss rename to src/cli/bundler/styles/tests/fixtures/forward/app.scss diff --git a/src/cli/plugins/style/tests/fixtures/forward/shared.scss b/src/cli/bundler/styles/tests/fixtures/forward/shared.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/forward/shared.scss rename to src/cli/bundler/styles/tests/fixtures/forward/shared.scss diff --git a/src/cli/bundler/styles/tests/fixtures/invalid-order/body-use.scss b/src/cli/bundler/styles/tests/fixtures/invalid-order/body-use.scss new file mode 100644 index 00000000..3bee9da5 --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/invalid-order/body-use.scss @@ -0,0 +1,2 @@ +.badge { color: red; } +@use "sass:math"; diff --git a/src/cli/bundler/styles/tests/fixtures/invalid-order/empty-block-use.scss b/src/cli/bundler/styles/tests/fixtures/invalid-order/empty-block-use.scss new file mode 100644 index 00000000..75f38f55 --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/invalid-order/empty-block-use.scss @@ -0,0 +1,2 @@ +@media screen {} +@use "sass:math"; diff --git a/src/cli/bundler/styles/tests/fixtures/invalid-order/import-use.scss b/src/cli/bundler/styles/tests/fixtures/invalid-order/import-use.scss new file mode 100644 index 00000000..2b591bf2 --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/invalid-order/import-use.scss @@ -0,0 +1,2 @@ +@import "https://example.com/theme.css"; +@use "sass:math"; diff --git a/src/cli/bundler/styles/tests/fixtures/invalid-order/layer-forward.scss b/src/cli/bundler/styles/tests/fixtures/invalid-order/layer-forward.scss new file mode 100644 index 00000000..673f726f --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/invalid-order/layer-forward.scss @@ -0,0 +1,2 @@ +@layer base, app; +@forward "sass:math"; diff --git a/src/cli/plugins/style/tests/fixtures/layer-statement/app.scss b/src/cli/bundler/styles/tests/fixtures/layer-statement/app.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/layer-statement/app.scss rename to src/cli/bundler/styles/tests/fixtures/layer-statement/app.scss diff --git a/src/cli/plugins/style/tests/fixtures/layer-statement/shared.scss b/src/cli/bundler/styles/tests/fixtures/layer-statement/shared.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/layer-statement/shared.scss rename to src/cli/bundler/styles/tests/fixtures/layer-statement/shared.scss diff --git a/src/cli/plugins/style/tests/fixtures/module-order/app.scss b/src/cli/bundler/styles/tests/fixtures/module-order/app.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/module-order/app.scss rename to src/cli/bundler/styles/tests/fixtures/module-order/app.scss diff --git a/src/cli/plugins/style/tests/fixtures/module-order/shared.scss b/src/cli/bundler/styles/tests/fixtures/module-order/shared.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/module-order/shared.scss rename to src/cli/bundler/styles/tests/fixtures/module-order/shared.scss diff --git a/src/cli/bundler/styles/tests/fixtures/multiple-sources/first.scss b/src/cli/bundler/styles/tests/fixtures/multiple-sources/first.scss new file mode 100644 index 00000000..f8d417ac --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/multiple-sources/first.scss @@ -0,0 +1,2 @@ +@use "sass:math"; +.badge { width: math.div(6px, 2); } diff --git a/src/cli/bundler/styles/tests/fixtures/multiple-sources/second.scss b/src/cli/bundler/styles/tests/fixtures/multiple-sources/second.scss new file mode 100644 index 00000000..7e2a3995 --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/multiple-sources/second.scss @@ -0,0 +1,2 @@ +@use "sass:math"; +.badge { width: math.div(8px, 2); } diff --git a/src/cli/bundler/styles/tests/fixtures/multiple-sources/third.scss b/src/cli/bundler/styles/tests/fixtures/multiple-sources/third.scss new file mode 100644 index 00000000..a5a3cddc --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/multiple-sources/third.scss @@ -0,0 +1,2 @@ +@use "sass:math"; +.badge { width: math.div(10px, 2); } diff --git a/src/cli/plugins/style/tests/fixtures/namespace/app.scss b/src/cli/bundler/styles/tests/fixtures/namespace/app.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/namespace/app.scss rename to src/cli/bundler/styles/tests/fixtures/namespace/app.scss diff --git a/src/cli/plugins/style/tests/fixtures/namespace/shared.scss b/src/cli/bundler/styles/tests/fixtures/namespace/shared.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/namespace/shared.scss rename to src/cli/bundler/styles/tests/fixtures/namespace/shared.scss diff --git a/src/cli/bundler/styles/tests/fixtures/path with spaces#percent%/_tokens.scss b/src/cli/bundler/styles/tests/fixtures/path with spaces#percent%/_tokens.scss new file mode 100644 index 00000000..ca95971b --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/path with spaces#percent%/_tokens.scss @@ -0,0 +1 @@ +$color: orange; diff --git a/src/cli/bundler/styles/tests/fixtures/path with spaces#percent%/content.scss b/src/cli/bundler/styles/tests/fixtures/path with spaces#percent%/content.scss new file mode 100644 index 00000000..2c431a8e --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/path with spaces#percent%/content.scss @@ -0,0 +1,2 @@ +@use "./tokens"; +.badge { color: tokens.$color; } diff --git a/src/cli/bundler/styles/tests/fixtures/preserved-variables/app.scss b/src/cli/bundler/styles/tests/fixtures/preserved-variables/app.scss new file mode 100644 index 00000000..e3f4308e --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/preserved-variables/app.scss @@ -0,0 +1,2 @@ +$color: red; +.app { color: $color; } diff --git a/src/cli/bundler/styles/tests/fixtures/preserved-variables/shared.scss b/src/cli/bundler/styles/tests/fixtures/preserved-variables/shared.scss new file mode 100644 index 00000000..b304dd3d --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/preserved-variables/shared.scss @@ -0,0 +1,2 @@ +$color: red; +$color: blue; diff --git a/src/cli/plugins/style/tests/fixtures/relative-conflict/app/content.scss b/src/cli/bundler/styles/tests/fixtures/relative-conflict/app/content.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/relative-conflict/app/content.scss rename to src/cli/bundler/styles/tests/fixtures/relative-conflict/app/content.scss diff --git a/src/cli/plugins/style/tests/fixtures/relative-conflict/app/theme/_tokens.scss b/src/cli/bundler/styles/tests/fixtures/relative-conflict/app/theme/_tokens.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/relative-conflict/app/theme/_tokens.scss rename to src/cli/bundler/styles/tests/fixtures/relative-conflict/app/theme/_tokens.scss diff --git a/src/cli/plugins/style/tests/fixtures/relative-conflict/shared/content.scss b/src/cli/bundler/styles/tests/fixtures/relative-conflict/shared/content.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/relative-conflict/shared/content.scss rename to src/cli/bundler/styles/tests/fixtures/relative-conflict/shared/content.scss diff --git a/src/cli/plugins/style/tests/fixtures/relative-conflict/shared/theme/_tokens.scss b/src/cli/bundler/styles/tests/fixtures/relative-conflict/shared/theme/_tokens.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/relative-conflict/shared/theme/_tokens.scss rename to src/cli/bundler/styles/tests/fixtures/relative-conflict/shared/theme/_tokens.scss diff --git a/src/cli/plugins/style/tests/fixtures/relative-modules/app/content.scss b/src/cli/bundler/styles/tests/fixtures/relative-modules/app/content.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/relative-modules/app/content.scss rename to src/cli/bundler/styles/tests/fixtures/relative-modules/app/content.scss diff --git a/src/cli/plugins/style/tests/fixtures/relative-modules/app/theme/_hidden.scss b/src/cli/bundler/styles/tests/fixtures/relative-modules/app/theme/_hidden.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/relative-modules/app/theme/_hidden.scss rename to src/cli/bundler/styles/tests/fixtures/relative-modules/app/theme/_hidden.scss diff --git a/src/cli/plugins/style/tests/fixtures/relative-modules/app/theme/_metrics.scss b/src/cli/bundler/styles/tests/fixtures/relative-modules/app/theme/_metrics.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/relative-modules/app/theme/_metrics.scss rename to src/cli/bundler/styles/tests/fixtures/relative-modules/app/theme/_metrics.scss diff --git a/src/cli/plugins/style/tests/fixtures/relative-modules/app/theme/_tokens.scss b/src/cli/bundler/styles/tests/fixtures/relative-modules/app/theme/_tokens.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/relative-modules/app/theme/_tokens.scss rename to src/cli/bundler/styles/tests/fixtures/relative-modules/app/theme/_tokens.scss diff --git a/src/cli/plugins/style/tests/fixtures/relative-modules/app/theme/fonts/_index.scss b/src/cli/bundler/styles/tests/fixtures/relative-modules/app/theme/fonts/_index.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/relative-modules/app/theme/fonts/_index.scss rename to src/cli/bundler/styles/tests/fixtures/relative-modules/app/theme/fonts/_index.scss diff --git a/src/cli/plugins/style/tests/fixtures/relative-modules/shared/content.scss b/src/cli/bundler/styles/tests/fixtures/relative-modules/shared/content.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/relative-modules/shared/content.scss rename to src/cli/bundler/styles/tests/fixtures/relative-modules/shared/content.scss diff --git a/src/cli/plugins/style/tests/fixtures/relative-modules/shared/theme/_tokens.scss b/src/cli/bundler/styles/tests/fixtures/relative-modules/shared/theme/_tokens.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/relative-modules/shared/theme/_tokens.scss rename to src/cli/bundler/styles/tests/fixtures/relative-modules/shared/theme/_tokens.scss diff --git a/src/cli/plugins/style/tests/fixtures/same-module/app/content.scss b/src/cli/bundler/styles/tests/fixtures/same-module/app/content.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/same-module/app/content.scss rename to src/cli/bundler/styles/tests/fixtures/same-module/app/content.scss diff --git a/src/cli/plugins/style/tests/fixtures/same-module/common/_tokens.scss b/src/cli/bundler/styles/tests/fixtures/same-module/common/_tokens.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/same-module/common/_tokens.scss rename to src/cli/bundler/styles/tests/fixtures/same-module/common/_tokens.scss diff --git a/src/cli/plugins/style/tests/fixtures/same-module/shared/content.scss b/src/cli/bundler/styles/tests/fixtures/same-module/shared/content.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/same-module/shared/content.scss rename to src/cli/bundler/styles/tests/fixtures/same-module/shared/content.scss diff --git a/src/cli/bundler/styles/tests/fixtures/same-source-duplicate/app.scss b/src/cli/bundler/styles/tests/fixtures/same-source-duplicate/app.scss new file mode 100644 index 00000000..0ead68ce --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/same-source-duplicate/app.scss @@ -0,0 +1,2 @@ +@use "sass:math"; +@use "sass:math"; diff --git a/src/cli/bundler/styles/tests/fixtures/same-source-duplicate/shared.scss b/src/cli/bundler/styles/tests/fixtures/same-source-duplicate/shared.scss new file mode 100644 index 00000000..6e1f29f6 --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/same-source-duplicate/shared.scss @@ -0,0 +1 @@ +@use "sass:math"; diff --git a/src/cli/bundler/styles/tests/fixtures/shared-scope/_tokens.scss b/src/cli/bundler/styles/tests/fixtures/shared-scope/_tokens.scss new file mode 100644 index 00000000..aab261b9 --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/shared-scope/_tokens.scss @@ -0,0 +1 @@ +$color: red !default; diff --git a/src/cli/bundler/styles/tests/fixtures/shared-scope/app.scss b/src/cli/bundler/styles/tests/fixtures/shared-scope/app.scss new file mode 100644 index 00000000..78a6a744 --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/shared-scope/app.scss @@ -0,0 +1,5 @@ +// The comment must not consume the next statement. +@use "sass:math"; +$accent: blue; +@use "./tokens" with ($color: $accent); +.app { @include common; color: tokens.$color; } diff --git a/src/cli/bundler/styles/tests/fixtures/shared-scope/shared.scss b/src/cli/bundler/styles/tests/fixtures/shared-scope/shared.scss new file mode 100644 index 00000000..d48fce68 --- /dev/null +++ b/src/cli/bundler/styles/tests/fixtures/shared-scope/shared.scss @@ -0,0 +1,4 @@ +@use "sass:math"; +$size: 11px; +@mixin common { padding: $size; } +.shared { width: math.div(10px, 2); } diff --git a/src/cli/plugins/style/tests/fixtures/url-values/app/content.scss b/src/cli/bundler/styles/tests/fixtures/url-values/app/content.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/url-values/app/content.scss rename to src/cli/bundler/styles/tests/fixtures/url-values/app/content.scss diff --git a/src/cli/plugins/style/tests/fixtures/url-values/shared/content.scss b/src/cli/bundler/styles/tests/fixtures/url-values/shared/content.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/url-values/shared/content.scss rename to src/cli/bundler/styles/tests/fixtures/url-values/shared/content.scss diff --git a/src/cli/plugins/style/tests/fixtures/variables/app.scss b/src/cli/bundler/styles/tests/fixtures/variables/app.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/variables/app.scss rename to src/cli/bundler/styles/tests/fixtures/variables/app.scss diff --git a/src/cli/plugins/style/tests/fixtures/variables/shared.scss b/src/cli/bundler/styles/tests/fixtures/variables/shared.scss similarity index 100% rename from src/cli/plugins/style/tests/fixtures/variables/shared.scss rename to src/cli/bundler/styles/tests/fixtures/variables/shared.scss diff --git a/src/cli/bundler/styles/types.ts b/src/cli/bundler/styles/types.ts new file mode 100644 index 00000000..f0fd4771 --- /dev/null +++ b/src/cli/bundler/styles/types.ts @@ -0,0 +1,8 @@ +/** An original stylesheet whose location remains the base for its local requests. */ +export type StyleSource = { + filename: string; + content: string; +}; + +/** Reports every local file candidate that can change how a Sass request resolves. */ +export type StyleDependencyHandler = (filename: string, exists: boolean) => void; diff --git a/src/cli/plugins/style/README.md b/src/cli/plugins/style/README.md index 684ecb37..6de7a554 100644 --- a/src/cli/plugins/style/README.md +++ b/src/cli/plugins/style/README.md @@ -6,24 +6,52 @@ using the shared resource path and the existing app-specific class-name salt. Shared body rules precede app body rules. Ordinary, `?unisolated`, and `?asis` imports retain their existing routing and CSS module behavior. -Before merging, the plugin rebases explicit `./` and `../` requests in `@use` -and `@forward` from each original file to the shared resource. Module parameters -remain intact; Sass resolves partials and directory indexes. Package requests, -aliases, built-in modules, and requests without an explicit relative prefix keep -their normal resolution. `rebase.ts` owns the request and URL rewriting; -`utils.ts` owns which nodes receive it, the prelude order, and deduplication. +## Source preparation + +The common implementation lives in `cli/bundler/styles`. It prepares every +entry stylesheet, including standalone files and builds with `mergeStyles: false`. +Local `@use`, `@forward`, and Sass `@import` requests resolve from their original +source file. Explicit `./` and `../` requests retain that base even when missing; +a bare request is made source-local only when a matching local Sass file exists. +Sass still selects partials, directory indexes, and import-only files, and reports +ambiguous imports. Package requests, aliases, and built-in Sass modules retain +normal resolution. Module parameters such as `as`, `show`, `hide`, and `with` +remain intact. File URLs represent local Sass requests safely on Windows and in +project directories containing spaces. + +Sources share one Sass scope. Their leading Sass variables and module directives +precede the CSS prelude and the ordered bodies. App prelude variables can affect +the shared body; use distinct variables or configured modules when those values +must differ. An original source with a late `@use` or `@forward` fails preparation +with its filename and location. + +Only identical, unconfigured module directives occurring once in each source are +collapsed across sources. Variable assignments, configured module loads, CSS +imports, and body rules are retained. Repeated directives within a single source +remain visible to Sass. Two identical relative requests from different directories +can identify different modules; give them distinct namespaces when both are needed. + +## Resources in partials and module configuration + +Sass compiles with an internal source map. The framework then runs +`resolve-url-loader` before css-loader, resolving relative resources in imported +partials from the Sass source map. This also works when production output source +maps are disabled; internal maps do not force emitted `.map` files. -Only identical prepared prelude statements are deduplicated. Shared requests pass -through the same normalization, so equivalent spellings of one module match. Two -identical relative requests from different source directories can refer to -different modules. Use distinct `as` namespaces when both modules need to -coexist; Sass still reports namespace and configuration conflicts. +```scss +// src/apps/example/content/content.scss +@use "../theme/fonts"; +``` -## Resources configured through `with` +```scss +// src/apps/example/theme/fonts/_index.scss +@font-face { + font-family: Inter; + src: url("./Inter-Latin.woff2?browser") format("woff2"); +} +``` -The plugin also rebases literal app `url(...)` values, including values supplied -inside module configuration. Query strings and fragments are preserved for the -normal CSS and asset loaders. +Full literal URLs passed through `with` also remain supported: ```scss // src/apps/example/content/content.scss @@ -42,37 +70,93 @@ $inter-latin: url("./Inter-Latin.woff2?browser") !default; } ``` -Pass the complete `url(...)` from the app source. +Before Sass runs, literal resource URLs in entry sources are converted to absolute +filesystem requests. This preserves the declaring file's base through variable +substitution and prevents the partial-resource pass from rebasing those values a +second time. Query strings and fragments are retained for the CSS and asset loaders. +Explicit `@` and `~` resource requests remain available to css-loader's resolver; +other bare resource paths are treated as local files. + +If another plugin already installs `resolve-url-loader` in the resource's effective +loader chain, the framework's adapter leaves processing to that loader. This avoids +double rebasing and loss of source maps with existing plugins. The compatibility +rule concerns that loader specifically, not arbitrary custom URL processors. -## Limitations +## Reusing font settings -- Only `@use` and `@forward` requests are rebased. Sass `@import` and - `meta.load-css()` requests keep resolving from the shared resource. -- Resources written inside imported partials are not rebased. Pass the complete - `url(...)` from the app source through `with`. -- URL aliases are not recognized. A `url(...)` without a scheme, root, `~`, `#`, - or `?` prefix is treated as a relative resource, so an aliased or package URL - written in an app source becomes a relative path and fails to resolve. Write a - full relative `url(...)` from the app source; alias-aware URL rewriting would - have to follow the bundler resolve settings. -- Dynamic Sass URL expressions and interpolated requests are left for Sass to - evaluate. -- Sass reports errors in the merged app body against the shared resource with - positions in the merged text. Errors inside imported modules keep their own - file positions. +Keep a full literal URL and the declaration that consumes it in the same Sass +module. Export a mixin containing the complete `@font-face` rule to reuse it from +different entry stylesheets: -## Migration +```scss +// src/apps/example/theme/fonts/_index.scss +$family: Inter; +$font: url("./Inter-Latin.woff2?browser"); + +@mixin register-fonts { + @font-face { + font-family: $family; + font-style: normal; + font-weight: 400 900; + font-display: swap; + src: $font format("woff2"); + } +} +``` + +```scss +// src/apps/example/relay/relay.scss +@use "../theme/fonts"; + +@include fonts.register-fonts; + +.panel { + font-family: fonts.$family, sans-serif; +} +``` -Relative `@use` and `@forward` requests in app sources used to resolve from the -shared file. They now resolve from the app file, as written. An app stylesheet -that intentionally reuses a shared module through such a request must address -it by a path or a configured alias to the shared directory. +The source map keeps the `src` declaration attached to the fonts module, so its +relative resource resolves beside that module in both standalone and merged entry +stylesheets. Each `@include` emits the rule; include it once per stylesheet that +needs to register the font. Names, weights and other settings without resource +paths can be reused separately. + +This preparation is internal to the framework. Plugins that generate stylesheets +still own their source selection, generated content and watch dependencies. + +## Limitations and migration + +- Relative resource URLs in imported partials now resolve beside the partial. + Update paths that previously relied on resolution from the entry stylesheet. +- Write `url(...)` in lowercase inside imported partials: the resource loader does + not rebase uppercase `URL(...)` or mixed-case variants there. +- Interpolated module requests and `meta.load-css()` arguments are not rewritten. +- Building resource paths with `url($path)` or interpolation is outside the supported + contract. In a merged app body, the source map can attribute such a declaration + to the shared file and resolve the resource from the shared directory. +- A full relative URL declared in imported partial A and used as `src: a.$font` + in file B can resolve from B, because source maps track emitted declarations, + not variable origins. Keep the URL and `src` together in a mixin as above, or + pass a full literal URL from an entry source through `with`. Entry-source URLs + are prepared before Sass; imported partial variables are not. +- Sass reports errors in a merged app body against the target resource with + positions in the merged text. Imported modules retain their own file positions. +- Source-relative Sass requests intentionally address the source's own directory. + Imports intended to reuse a shared module must explicitly address it or use an + alias to shared. +- Repeated variable assignments and configured module loads are no longer removed + by broad prelude deduplication. Variables follow Sass assignment order; duplicate + configured loads may now expose a Sass error that older merging hid. ## Watch dependencies and errors -Preparation runs through `sass-loader.additionalData`. Existing overrides are -registered with `addDependency`; absent overrides use `addMissingDependency` so -creation, removal, and recreation trigger compilation. Sass and the asset loader +Ordinary source preparation runs through `sass-loader.additionalData`. Existing +app overrides are registered with `addDependency`; absent overrides use +`addMissingDependency`, so creation, removal, and recreation trigger compilation. +In prepared entry sources, local candidates for bare and explicit `./` or `../` +Sass requests are also registered, including missing partials and indexes. Creating +a local module can therefore replace a bare request's fallback or recover a failed +relative import without editing the entry stylesheet. Sass and the asset loaders register imported modules and resources. Preparation errors are emitted as compilation errors and produce no stylesheet, diff --git a/src/cli/plugins/style/index.ts b/src/cli/plugins/style/index.ts index 66ed0354..2a93fe47 100644 --- a/src/cli/plugins/style/index.ts +++ b/src/cli/plugins/style/index.ts @@ -1,6 +1,7 @@ import _ from "lodash"; import path from "path"; import fs from "fs"; +import {createRequire} from "module"; import { Configuration as RspackConfig, CssExtractRspackPlugin, @@ -9,7 +10,7 @@ import { RuleSetUseItem, } from "@rspack/core"; -import {mergeStyleSources} from "./utils"; +import {prepareStyleSources, joinStyleUrl, type StyleSource} from "@cli/bundler/styles"; import {DocumentStylesLayer, DefaultStylesLayer} from "@cli/bundler/layers"; import {definePlugin} from "@main/plugin"; @@ -31,26 +32,31 @@ const createStyleMerger = (config: ReadonlyConfig) => { return (content: string, context: LoaderContext): string => { const filename = context.resourcePath; const relative = path.relative(sharedDir, filename); - - if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { - return content; - } - - const appPath = path.join(appDir, relative); - - if (!fs.existsSync(appPath)) { - context.addMissingDependency(appPath); - - return content; - } - - context.addDependency(appPath); + const sources: StyleSource[] = [{filename, content}]; + const inShared = relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative); try { - return mergeStyleSources( - {filename, content}, - {filename: appPath, content: fs.readFileSync(appPath, "utf8")} - ); + if (config.mergeStyles && inShared) { + const appPath = path.join(appDir, relative); + + if (appPath !== filename) { + if (fs.existsSync(appPath)) { + context.addDependency(appPath); + sources.push({filename: appPath, content: fs.readFileSync(appPath, "utf8")}); + } else { + context.addMissingDependency(appPath); + } + } + } + + // Even an unmerged source can configure a partial with a full relative url(). + return prepareStyleSources(sources, filename, (dependency, exists) => { + if (exists) { + context.addDependency(dependency); + } else { + context.addMissingDependency(dependency); + } + }); } catch (error) { // sass-loader prepares additionalData outside its error handler. Fail the compilation // explicitly and supply no stylesheet instead of compiling an incomplete shared fallback. @@ -65,18 +71,24 @@ export default definePlugin(() => { return { name: "adnbn:styles", bundler: ({config}) => { - const {app, cssDir, cssFilename, cssIdentName, mergeStyles} = config; + const {app, cssDir, cssFilename, cssIdentName} = config; const filename = appFilenameResolver(app, cssFilename, cssDir); const kebabApp = _.kebabCase(app); + const urlLoader = createRequire(import.meta.url).resolve("../../bundler/loaders/resolve-style-urls"); + const createSassRuleSet = (rule: RuleSetUseItem): RuleSetUse => { return [ CssExtractRspackPlugin.loader, rule, + { + loader: urlLoader, + options: {join: joinStyleUrl}, + }, { loader: "sass-loader", - options: mergeStyles ? {additionalData: createStyleMerger(config)} : {}, + options: {sourceMap: true, additionalData: createStyleMerger(config)}, }, ]; }; diff --git a/src/cli/plugins/style/rebase.test.ts b/src/cli/plugins/style/rebase.test.ts deleted file mode 100644 index 471236c8..00000000 --- a/src/cli/plugins/style/rebase.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import path from "path"; - -import {rebaseModuleRequest, rebaseUrlFunctions} from "./rebase"; - -const source = path.resolve("src"); -const from = path.join(source, "apps", "alpha", "content", "content.scss"); -const to = path.join(source, "shared", "content", "content.scss"); - -describe("rebaseModuleRequest", () => { - test.each([ - ['"./theme/tokens" as appTokens', '"../../apps/alpha/content/theme/tokens" as appTokens'], - ["'../theme/fonts'", "'../../apps/alpha/theme/fonts'"], - [ - '"./theme/tokens" as theme-* show $theme-accent with ($accent: blue)', - '"../../apps/alpha/content/theme/tokens" as theme-* show $theme-accent with ($accent: blue)', - ], - [ - '"../theme/fonts" with (\n $font: url("../theme/font.woff2?browser")\n)', - '"../../apps/alpha/theme/fonts" with (\n $font: url("../theme/font.woff2?browser")\n)', - ], - ['/* app */ "./theme/tokens"', '/* app */ "../../apps/alpha/content/theme/tokens"'], - ])("rebases the relative request in %j", (params, expected) => { - expect(rebaseModuleRequest(params, from, to)).toBe(expected); - }); - - test.each([ - '"sass:math"', - '"some-package/theme"', - '"@theme/tokens"', - '"~theme/tokens"', - '"theme/tokens"', - '"pkg:some-package"', - '"./#{$theme}"', - '"./theme/tokens', - "", - ])("keeps %j as written", params => { - expect(rebaseModuleRequest(params, from, to)).toBe(params); - }); - - test.each([ - ['"./../common/tokens"', '"../common/tokens"'], - ['"./theme/./tokens" as tokens', '"./theme/tokens" as tokens'], - ['"../common/tokens"', '"../common/tokens"'], - ['"theme/tokens"', '"theme/tokens"'], - ])("normalizes %j for the same stylesheet", (params, expected) => { - expect(rebaseModuleRequest(params, to, to)).toBe(expected); - }); -}); - -describe("rebaseUrlFunctions", () => { - test.each([ - ["url(theme/plain.svg)", 'url("../../apps/alpha/content/theme/plain.svg")'], - [ - 'url( "./theme/spaced font.woff2?browser#face" )', - 'url( "../../apps/alpha/content/theme/spaced font.woff2?browser#face" )', - ], - ["url('./theme/icon.svg?next=a/../b#face')", "url('../../apps/alpha/content/theme/icon.svg?next=a/../b#face')"], - ['url("./theme/escaped\\ name.svg")', 'url("../../apps/alpha/content/theme/escaped\\ name.svg")'], - ['URL("../theme/upper.svg")', 'URL("../../apps/alpha/theme/upper.svg")'], - [ - 'url("./theme/font.woff2") format("woff2")', - 'url("../../apps/alpha/content/theme/font.woff2") format("woff2")', - ], - [ - 'url("./a.svg"), url("./b.svg")', - 'url("../../apps/alpha/content/a.svg"), url("../../apps/alpha/content/b.svg")', - ], - [ - 'image-set(url("./a.png") 1x, url("./a@2x.png") 2x)', - 'image-set(url("../../apps/alpha/content/a.png") 1x, url("../../apps/alpha/content/a@2x.png") 2x)', - ], - [ - '(\n $font: url("../theme/font.woff2?browser")\n)', - '(\n $font: url("../../apps/alpha/theme/font.woff2?browser")\n)', - ], - ])("rebases the literal URL in %j", (value, expected) => { - expect(rebaseUrlFunctions(value, from, to)).toBe(expected); - }); - - test.each([ - 'url("https://example.com/font.woff2")', - 'url("//example.com/font.woff2")', - 'url("data:image/svg+xml;base64,AAAA")', - 'url("chrome-extension://id/font.woff2")', - 'url("/font.woff2")', - 'url("#icon")', - 'url("?version=1")', - 'url("~package/font.woff2")', - "url($font)", - 'url("#{$directory}/font.woff2")', - "url(./#{$theme}.svg)", - "url(font-path())", - 'url("./a.svg" "b")', - 'url("./unclosed', - "url()", - '"url(./text.svg)"', - '$font format("woff2")', - ])("keeps %j as written", value => { - expect(rebaseUrlFunctions(value, from, to)).toBe(value); - }); -}); diff --git a/src/cli/plugins/style/rebase.ts b/src/cli/plugins/style/rebase.ts deleted file mode 100644 index 1e869ecb..00000000 --- a/src/cli/plugins/style/rebase.ts +++ /dev/null @@ -1,98 +0,0 @@ -import path from "path"; -import valueParser from "postcss-value-parser"; - -import {toPosix} from "@cli/utils/path"; - -const RelativeRequestPattern = /^\.{1,2}\//; -// Schemes, root-relative paths, fragments, queries and ~-prefixed requests are not rebased. -const ExternalUrlPattern = /^(?:[a-z][a-z\d+.-]*:|[/#?~])/i; - -/** - * Rebases the explicit `./` or `../` module request of `@use` or `@forward` parameters written in the - * stylesheet `from` so it resolves from the stylesheet `to`. Namespaces, `show`, `hide` and `with` - * configuration stay as written; package, alias, built-in and interpolated requests keep their normal - * resolution. When both stylesheets are the same file, the request is only normalized. - */ -export const rebaseModuleRequest = (params: string, from: string, to: string): string => { - const parsed = valueParser(params); - const request = parsed.nodes.find(node => node.type !== "space" && node.type !== "comment"); - - if ( - request?.type !== "string" || - request.unclosed || - !RelativeRequestPattern.test(request.value) || - request.value.includes("#{") - ) { - return params; - } - - request.value = rebaseStylePath(request.value, from, to); - - return parsed.toString(); -}; - -/** - * Rebases literal relative `url(...)` values written in the stylesheet `from` so they resolve from the - * stylesheet `to`, including values nested in lists, functions and module configuration. Absolute URLs, - * schemes, root-relative paths, fragments, queries, ~-prefixed requests and Sass expressions stay as written. - * Rebased URLs are quoted and keep their query and fragment. - */ -export const rebaseUrlFunctions = (value: string, from: string, to: string): string => { - const parsed = valueParser(value); - let rebased = false; - - parsed.walk(node => { - if (node.type !== "function" || node.value.toLowerCase() !== "url") { - return; - } - - const tokens = node.nodes.filter(token => token.type !== "space" && token.type !== "comment"); - const url = tokens[0]; - - if (node.unclosed || tokens.length !== 1 || !isLiteralUrl(url)) { - return false; - } - - // Quote rebased URLs so spaces in the app directory remain part of the path. - const quoted: valueParser.StringNode = { - ...url, - type: "string", - quote: url.type === "string" ? url.quote : '"', - value: rebaseStylePath(url.value, from, to), - }; - - node.nodes = node.nodes.map(token => (token === url ? quoted : token)); - rebased = true; - - return false; - }); - - return rebased ? parsed.toString() : value; -}; - -const isLiteralUrl = (token: valueParser.Node): token is valueParser.StringNode | valueParser.WordNode => { - if (token.type === "string") { - return !token.unclosed && isLiteralPath(token.value); - } - - if (token.type === "word") { - return isLiteralPath(token.value) && !/[$()]/.test(token.value); - } - - return false; -}; - -const isLiteralPath = (value: string): boolean => { - return Boolean(value) && !ExternalUrlPattern.test(value) && !value.includes("#{"); -}; - -const rebaseStylePath = (request: string, from: string, to: string): string => { - const suffixIndex = request.search(/[?#]/); - const pathname = suffixIndex === -1 ? request : request.slice(0, suffixIndex); - const suffix = suffixIndex === -1 ? "" : request.slice(suffixIndex); - const directory = toPosix(path.relative(path.dirname(to), path.dirname(from))); - // Stylesheet requests are URLs, including on Windows. Keep query/fragment data out of path normalization. - const relative = path.posix.join(directory, pathname); - - return `${relative.startsWith("../") ? relative : `./${relative}`}${suffix}`; -}; diff --git a/src/cli/plugins/style/utils.test.ts b/src/cli/plugins/style/utils.test.ts deleted file mode 100644 index 3e9c355a..00000000 --- a/src/cli/plugins/style/utils.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import fs from "fs"; -import path from "path"; -import sass from "sass"; -import {pathToFileURL} from "url"; - -import {mergeStyleSources, type StyleSource} from "./utils"; - -const fixtures = path.resolve(__dirname, "tests", "fixtures"); - -const readSource = (...parts: string[]): StyleSource => { - const filename = path.join(fixtures, ...parts); - - return {filename, content: fs.readFileSync(filename, "utf8")}; -}; - -const mergeFixture = (name: string): string => - mergeStyleSources(readSource(name, "shared.scss"), readSource(name, "app.scss")); - -const mergeLocatedFixture = (name: string): string => - mergeStyleSources(readSource(name, "shared", "content.scss"), readSource(name, "app", "content.scss")); - -const compileLocatedFixture = (name: string): string => - sass.compileString(mergeLocatedFixture(name), { - url: pathToFileURL(path.join(fixtures, name, "shared", "content.scss")), - }).css; - -const normalizeStyle = (style: string): string => style.replace(/\s+/g, " ").trim(); - -describe("mergeStyleSources", () => { - test("moves app Sass module rules before merged style bodies", () => { - const merged = mergeFixture("module-order"); - - expect(merged).toMatchOrder([ - '@use "sass:map";', - '@use "sass:color";', - ".badge { color: red; }", - ".badge { color: blue; }", - ]); - }); - - test("keeps Sass variable declarations with the module prelude that uses them", () => { - const merged = mergeFixture("variables"); - - expect(merged).toMatchOrder([ - '@use "sass:map";', - "$accent: blue;", - '@use "sass:color";', - ".shared { color: red; }", - ".app { color: $accent; }", - ]); - }); - - test("places CSS prelude after Sass module rules from both sources", () => { - const merged = mergeFixture("css-prelude"); - - expect(merged).toMatchOrder([ - '@use "sass:map";', - '@import url("./reset.css");', - ".shared { color: red; }", - ".app { color: blue; }", - ]); - }); - - test("keeps leading comments in the merged prelude", () => { - const merged = mergeFixture("comments"); - - expect(merged).toMatchOrder([ - "/* shared theme */", - '@use "sass:map";', - "/* app theme */", - '@use "sass:color";', - ".shared { color: red; }", - ".app { color: blue; }", - ]); - }); - - test("keeps block at-rules in the style body", () => { - const merged = mergeFixture("block-atrules"); - - expect(merged).toMatchOrder([ - '@use "sass:map";', - ".shared { color: red; }", - "@layer components { .app { color: blue; } }", - ]); - }); - - test("hoists @charset to the very top of the merged styles", () => { - const merged = mergeFixture("charset"); - - expect(merged).toMatchOrder([ - '@charset "UTF-8";', - '@use "sass:map";', - '@use "sass:color";', - ".shared { color: red; }", - ".app { color: blue; }", - ]); - }); - - test("moves @forward into the Sass module prelude", () => { - const merged = mergeFixture("forward"); - - expect(merged).toMatchOrder([ - '@forward "sass:map";', - '@use "sass:color";', - ".shared { color: red; }", - ".app { color: blue; }", - ]); - }); - - test("treats @namespace as a CSS prelude rule", () => { - const merged = mergeFixture("namespace"); - - expect(merged).toMatchOrder([ - '@use "sass:map";', - '@namespace svg "svg-ns";', - ".shared { color: red; }", - ".app { color: blue; }", - ]); - }); - - test("hoists @layer statements while keeping @layer blocks in the body", () => { - const merged = mergeFixture("layer-statement"); - - expect(merged).toMatchOrder([ - "@layer extra;", - "@layer base { .shared { color: red; } }", - ".app { color: blue; }", - ]); - }); - - test("collapses identical Sass module rules repeated by an app override", () => { - const merged = mergeFixture("duplicate-prelude"); - - expect(merged.match(/@use "sass:map";/g)).toHaveLength(1); - expect(() => sass.compileString(merged)).not.toThrow(); - }); - - test("keeps conflicting module rules so Sass can still report them", () => { - const merged = mergeFixture("conflicting-prelude"); - - expect(() => sass.compileString(merged)).toThrow(/already a module with namespace "map"/); - }); - - test("does not deduplicate identical style bodies", () => { - const merged = mergeFixture("duplicate-body"); - - expect(normalizeStyle(merged).match(/\.badge \{ color: red; \}/g)).toHaveLength(2); - }); - - test("rebases module requests before merging and preserves Sass configuration and forwarding", () => { - const merged = mergeLocatedFixture("relative-modules"); - - expect(merged).toContain('@use "./theme/tokens" as sharedTokens;'); - expect(merged).toContain('@use "../app/theme/tokens" as appTokens;'); - expect(merged).toContain('@forward "../app/theme/tokens" as theme-* show $theme-accent with ($accent: blue);'); - expect(merged).toContain('@forward "../app/theme/hidden" hide $private;'); - expect(normalizeStyle(merged)).toContain( - '@use "../app/theme/fonts" with ( $font: url("../app/theme/Inter.woff2?browser&v=a/../b#face") );' - ); - - const css = compileLocatedFixture("relative-modules"); - - expect(css).toContain("color: red"); - expect(css).toContain("background: blue"); - expect(css).toContain("font-weight: 400"); - expect(css).toContain('url("../app/theme/Inter.woff2?browser&v=a/../b#face")'); - }); - - test("does not deduplicate identical requests to different files or hide their namespace conflict", () => { - const merged = mergeLocatedFixture("relative-conflict"); - - expect(merged).toContain('@use "./theme/tokens";'); - expect(merged).toContain('@use "../app/theme/tokens";'); - expect(() => compileLocatedFixture("relative-conflict")).toThrow(/already a module with namespace "tokens"/); - }); - - test("normalizes shared requests so equivalent relative requests to the same module deduplicate", () => { - const merged = mergeLocatedFixture("same-module"); - - expect(merged).not.toContain("./../common/tokens"); - expect(merged.match(/@use "\.\.\/common\/tokens";/g)).toHaveLength(1); - expect(compileLocatedFixture("same-module").match(/color: red/g)).toHaveLength(2); - }); - - test("rebases app URLs in declarations and at-rule parameters and leaves shared values and comments as written", () => { - const merged = normalizeStyle(mergeLocatedFixture("url-values")); - - for (const rebased of [ - '$icon: url("../app/theme/icon.svg");', - 'background: url("../app/theme/plain.svg");', - '@supports (background: url("../app/theme/support.svg"))', - ]) { - expect(merged).toContain(rebased); - } - - for (const unchanged of [ - "$logo: url(./logo.svg);", - 'background: url("./shared.svg");', - 'content: "url(./text.svg)";', - '/* url("./comment.svg") */', - ]) { - expect(merged).toContain(unchanged); - } - }); -}); - -declare global { - namespace jest { - interface Matchers { - toMatchOrder(expected: string[]): R; - } - } -} - -expect.extend({ - toMatchOrder(received: string, expected: string[]) { - received = normalizeStyle(received); - expected = expected.map(normalizeStyle); - - let lastIndex = -1; - - for (const value of expected) { - const index = received.indexOf(value); - - if (index === -1) { - return { - pass: false, - message: () => `Expected merged style to contain ${this.utils.printExpected(value)}.`, - }; - } - - if (index < lastIndex) { - return { - pass: false, - message: () => `Expected ${this.utils.printExpected(value)} to appear after previous style part.`, - }; - } - - lastIndex = index; - } - - return { - pass: true, - message: () => "Expected merged style parts not to appear in order.", - }; - }, -}); diff --git a/src/cli/plugins/style/utils.ts b/src/cli/plugins/style/utils.ts deleted file mode 100644 index 695d51a7..00000000 --- a/src/cli/plugins/style/utils.ts +++ /dev/null @@ -1,146 +0,0 @@ -import {ChildNode} from "postcss"; -import * as scss from "postcss-scss"; - -import {rebaseModuleRequest, rebaseUrlFunctions} from "./rebase"; - -export type StyleSource = { - filename: string; - content: string; -}; - -type StyleSourceParts = { - charsets: string[]; - sassPrelude: string[]; - cssPrelude: string[]; - body: string[]; -}; - -export const mergeStyleSources = (sharedStyle: StyleSource, appStyle: StyleSource): string => { - const shared = splitStyleSource(sharedStyle, sharedStyle.filename); - const app = splitStyleSource(appStyle, sharedStyle.filename); - - return [ - ...dedupePrelude([...shared.charsets, ...app.charsets]), - ...dedupePrelude([...shared.sassPrelude, ...app.sassPrelude]), - ...dedupePrelude([...shared.cssPrelude, ...app.cssPrelude]), - ...shared.body, - ...app.body, - ] - .map(part => part.trim()) - .filter(Boolean) - .join("\n\n"); -}; - -// App overrides repeat the shared @use/@forward; Sass rejects duplicate modules, so collapse exact matches while keeping differing statements that signal real conflicts. -const dedupePrelude = (parts: string[]): string[] => { - const seen = new Set(); - - return parts.filter(part => { - const key = part.trim(); - - if (seen.has(key)) { - return false; - } - - seen.add(key); - - return true; - }); -}; - -const splitStyleSource = (source: StyleSource, target: string): StyleSourceParts => { - const root = scss.parse(source.content, {from: source.filename}); - // Only a source compiled under another filename needs its resources rebased. - const relocated = source.filename !== target; - - root.walkAtRules(node => { - if (isModuleRuleNode(node)) { - // Shared requests pass through the same normalization, so equivalent prepared - // imports from both sources match when the prelude is deduplicated. - node.params = rebaseModuleRequest(node.params, source.filename, target); - } - - if (relocated) { - // Module configuration such as `with ($font: url(...))` carries app resources as well. - node.params = rebaseUrlFunctions(node.params, source.filename, target); - } - }); - - if (relocated) { - root.walkDecls(node => { - node.value = rebaseUrlFunctions(node.value, source.filename, target); - }); - } - - const parts: StyleSourceParts = { - charsets: [], - sassPrelude: [], - cssPrelude: [], - body: [], - }; - - let bodyStarted = false; - let preludeType: "sass" | "css" = "sass"; - - root.nodes?.forEach(node => { - if (!bodyStarted && isCharsetNode(node)) { - parts.charsets.push(stringifyNode(node)); - - return; - } - - if (!bodyStarted && node.type === "comment") { - parts[preludeType === "sass" ? "sassPrelude" : "cssPrelude"].push(stringifyNode(node)); - - return; - } - - if (!bodyStarted && isSassPreludeNode(node)) { - parts.sassPrelude.push(stringifyNode(node)); - - return; - } - - if (!bodyStarted && isCssPreludeNode(node)) { - preludeType = "css"; - parts.cssPrelude.push(stringifyNode(node)); - - return; - } - - bodyStarted = true; - parts.body.push(stringifyNode(node)); - }); - - return parts; -}; - -const stringifyNode = (node: ChildNode): string => { - const content = node.toString(scss.stringify); - - if ((node.type === "atrule" && !node.nodes?.length) || node.type === "decl") { - return `${content};`; - } - - return content; -}; - -const isCharsetNode = (node: ChildNode): boolean => node.type === "atrule" && node.name === "charset"; - -const isModuleRuleNode = (node: ChildNode): boolean => node.type === "atrule" && ["forward", "use"].includes(node.name); - -const isSassPreludeNode = (node: ChildNode): boolean => { - if (node.type === "decl") { - return node.prop.startsWith("$"); - } - - return isModuleRuleNode(node); -}; - -const isCssPreludeNode = (node: ChildNode): boolean => { - if (node.type !== "atrule" || node.nodes?.length) { - return false; - } - - return ["import", "namespace", "layer"].includes(node.name); -}; diff --git a/src/types/config.ts b/src/types/config.ts index 8dd9c958..0afde210 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -464,9 +464,10 @@ export interface Config { /** * Flag indicating whether to merge styles from App and Shared directories. * When `true`, importing a shared stylesheet also compiles the app stylesheet at the same - * relative path into the same CSS module, with shared rules before app rules. Relative `@use` - * and `@forward` requests and literal `url(...)` values in the merged sources resolve from the - * file that declares them. + * relative path into the same CSS module, with shared rules before app rules. Local Sass + * requests and literal `url(...)` values in the merged sources resolve from their source file. + * Resources declared in imported Sass partials are resolved using Sass source maps, even + * when merging is disabled. Full literal URLs passed through `with` remain supported. * * @default true */ diff --git a/tests/integration/README.md b/tests/integration/README.md index 4a0dcffb..33316819 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -50,10 +50,19 @@ tests/integration/ │ │ └── views/ │ ├── style/ │ │ ├── style-merge.integration.test.ts +│ │ ├── module-resolution.integration.test.ts +│ │ ├── font-reuse.integration.test.ts +│ │ ├── virtual-style.integration.test.ts │ │ ├── compiler.ts │ │ └── fixtures/ +│ │ ├── bare-module-watch/ +│ │ ├── bare-module-watch-updates/ +│ │ ├── relative-module-watch-updates/ +│ │ ├── font-reuse/ │ │ ├── multi-app/ -│ │ └── multi-app-updates/ +│ │ ├── multi-app-updates/ +│ │ ├── virtual-theme/ +│ │ └── virtual-theme-updates/ │ └── … ├── browser/ │ ├── relay/ @@ -238,7 +247,7 @@ Parallel test sessions use distinct working directories; production directory na - `browser/options`: two Chrome MV3 cases covering Vanilla and React rendering, CSS, state/events, opening Options from background, and a View chunk shared with a Page. - `build/override`: separate New Tab, Bookmarks, History and competing-entrypoint files let Jest schedule the scenarios across workers. The files share assertions, while each case keeps its own application copy and build. Thirty manifest checks cover the New Tab, Bookmarks, and History applications across Chrome, Edge, Opera, Safari, and Firefox in MV2 and MV3. Supporting browsers receive `chrome_url_overrides`, the page HTML, its CSP, and the permission declared by the entrypoint; the others receive none of them. Three more cases add the History entrypoint to a copy of the New Tab application: Chrome and Edge builds fail naming both entrypoints, while Firefox keeps the New Tab with its own permission and without the History one. No browser is launched. - `build/permissions/views`: four manifest checks of one application with two popups, two sidebars, and an options page that each declare permissions. Every built view contributes, including a popup and a sidebar that are not applied by default; Chrome MV2 has no sidebar, so its permissions are not requested; Firefox builds the sidebar action without `sidePanel`; MV2 declares hosts as permissions. No browser is launched. -- `build/style`: shared and app stylesheet merging with `mergeStyles` through the production style and asset rules. Two applications with the same layout and different fonts each receive one CSS module per destination, the same class identifier in ordinary and `?unisolated` output, and their own font. App overrides with a namespace conflict, a missing module, or invalid SCSS fail the build. Watch mode tracks override creation, edits, imported partials, resources, removal, and recreation. No browser is launched. +- `build/style`: shared and app stylesheet merging with `mergeStyles` through the production style and asset rules. Two applications with the same layout and different fonts each receive one CSS module per destination, the same class identifier in ordinary and `?unisolated` output, and their own font. Tests cover resources declared in partials, passed through `with`, and reused through a complete font-face mixin; standalone styles with merging disabled; and production/development source maps. Generated styles exercise aliases, `?asis`, and an existing external `resolve-url-loader`. A native Node build checks the built framework and ESM loader. App overrides with a namespace conflict, a missing module, or invalid SCSS fail the build. Watch mode tracks override creation, edits, imported partials, resources, removal, recreation, the appearance of a local module for a bare Sass request, and recovery after creating a missing module for an explicit relative request. No browser is launched. - `browser/override`: three Chrome MV3 cases opening `chrome://newtab`, `chrome://bookmarks`, and `chrome://history`. They verify that Chrome serves the extension page, React and Vanilla rendering, CSS, state/events, custom `as` and `htmlDir` naming, and a View chunk shared with a Page. - `browser/offscreen/service`: one Chrome MV3 round trip from background through Offscreen to a registered background service. - `browser/content/entrypoint-assets`: one Chrome MV3 case and two Firefox cases (MV2 and MV3) using the same application and probe assertions. They cover current asset getters, rejecting the full-map getter outside background, common chunks, dynamic imports, CSS/SVG resource URLs, world separation, and top/child frames. The Chrome case also reads the full-map readiness flag in background. diff --git a/tests/integration/build/style-routing/content-chunks.integration.test.ts b/tests/integration/build/style-routing/content-chunks.integration.test.ts index 39acc8fa..efe5f34a 100644 --- a/tests/integration/build/style-routing/content-chunks.integration.test.ts +++ b/tests/integration/build/style-routing/content-chunks.integration.test.ts @@ -23,7 +23,11 @@ test.each([true, false])( async commonChunks => { const output = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-content-chunks-")); const config = { - rootDir: root, + rootDir: __dirname, + srcDir: "src", + sharedDir: ".", + appsDir: "apps", + appSrcDir: ".", app: "test", commonChunks, mergeStyles: false, diff --git a/tests/integration/build/style-routing/delivery-spike/compiler.ts b/tests/integration/build/style-routing/delivery-spike/compiler.ts index 666d95d0..cd4f9b8c 100644 --- a/tests/integration/build/style-routing/delivery-spike/compiler.ts +++ b/tests/integration/build/style-routing/delivery-spike/compiler.ts @@ -35,7 +35,11 @@ export const createCompiler = async (output: string, options: CompilerOptions = const cssFilename = options.cssFilename ?? "css/[name].[contenthash:8].css"; let selection = "none"; const config = { - rootDir: project, + rootDir: __dirname, + srcDir: "src", + sharedDir: ".", + appsDir: "apps", + appSrcDir: ".", app: "test", mergeStyles: false, cssDir: "", diff --git a/tests/integration/build/style-routing/selective-split/compiler.ts b/tests/integration/build/style-routing/selective-split/compiler.ts index 8b538f8f..433a3a2b 100644 --- a/tests/integration/build/style-routing/selective-split/compiler.ts +++ b/tests/integration/build/style-routing/selective-split/compiler.ts @@ -35,7 +35,11 @@ export const createCompiler = async ( const cssFilename = options.cssFilename ?? "css/[name].[contenthash:8].css"; const cssChunkFilename = cssFilename; const config = { - rootDir: project, + rootDir: __dirname, + srcDir: "src", + sharedDir: ".", + appsDir: "apps", + appSrcDir: ".", app: "selective-split", mergeStyles: false, cssDir: "", diff --git a/tests/integration/build/style-routing/style-routing.integration.test.ts b/tests/integration/build/style-routing/style-routing.integration.test.ts index 6f5f8238..b97c4039 100644 --- a/tests/integration/build/style-routing/style-routing.integration.test.ts +++ b/tests/integration/build/style-routing/style-routing.integration.test.ts @@ -78,7 +78,11 @@ test.each<{ const output = fs.mkdtempSync(path.join(os.tmpdir(), "adnbn-css-routing-")); const config = { - rootDir: path.join(__dirname, "src"), + rootDir: __dirname, + srcDir: "src", + sharedDir: ".", + appsDir: "apps", + appSrcDir: ".", app: "test", browser, commonChunks, diff --git a/tests/integration/build/style/compiler.ts b/tests/integration/build/style/compiler.ts index b4f1e3f2..e73e8c3f 100644 --- a/tests/integration/build/style/compiler.ts +++ b/tests/integration/build/style/compiler.ts @@ -12,12 +12,12 @@ const project = path.resolve(__dirname, "../../../.."); export const fixtures = path.join(__dirname, "fixtures"); export const entries = ["normal", "document", "asis"]; -export const createFixture = async (): Promise => { +export const createFixture = async (name = "multi-app"): Promise => { // Rspack resolves symlinks, including the macOS temporary-directory alias. - const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "adnbn-style-merge-"))); + const root = await realpath(await mkdtemp(path.join(os.tmpdir(), "adnbn style merge "))); try { - await cp(path.join(fixtures, "multi-app"), root, {recursive: true}); + await cp(path.join(fixtures, name), root, {recursive: true}); } catch (error) { await rm(root, {recursive: true, force: true}); @@ -27,7 +27,12 @@ export const createFixture = async (): Promise => { return root; }; -export const createCompiler = async (root: string, app: string, mergeStyles = true): Promise => { +export const createCompiler = async ( + root: string, + app: string, + mergeStyles = true, + overrides: Configuration = {} +): Promise => { const config = { rootDir: root, srcDir: "src", @@ -67,13 +72,17 @@ export const createCompiler = async (root: string, app: string, mergeStyles = tr library: {type: "commonjs2"}, }, resolve: { - alias: {"@tokens": path.join(root, "src/shared/theme/_alias.scss")}, + alias: { + "@tokens": path.join(root, "src/shared/theme/_alias.scss"), + "@font": path.join(root, "src/apps", app, "theme/fonts"), + }, modules: [path.join(root, "vendor"), path.join(project, "node_modules")], }, resolveLoader: {modules: [path.join(project, "node_modules")]}, - module: {rules: [...styles.module!.rules!, ...assets.module!.rules!]}, - plugins: [...styles.plugins!, ...assets.plugins!], optimization: {minimize: false}, + ...overrides, + module: {rules: [...styles.module!.rules!, ...assets.module!.rules!, ...(overrides.module?.rules ?? [])]}, + plugins: [...styles.plugins!, ...assets.plugins!, ...(overrides.plugins ?? [])], }); }; diff --git a/tests/integration/build/style/fixtures/bare-module-watch-updates/created.scss b/tests/integration/build/style/fixtures/bare-module-watch-updates/created.scss new file mode 100644 index 00000000..2e174f9b --- /dev/null +++ b/tests/integration/build/style/fixtures/bare-module-watch-updates/created.scss @@ -0,0 +1 @@ +$accent: blue; diff --git a/tests/integration/build/style/fixtures/bare-module-watch-updates/index.scss b/tests/integration/build/style/fixtures/bare-module-watch-updates/index.scss new file mode 100644 index 00000000..cc045af9 --- /dev/null +++ b/tests/integration/build/style/fixtures/bare-module-watch-updates/index.scss @@ -0,0 +1 @@ +$accent: purple; diff --git a/tests/integration/build/style/fixtures/bare-module-watch-updates/updated.scss b/tests/integration/build/style/fixtures/bare-module-watch-updates/updated.scss new file mode 100644 index 00000000..0a9308d8 --- /dev/null +++ b/tests/integration/build/style/fixtures/bare-module-watch-updates/updated.scss @@ -0,0 +1 @@ +$accent: green; diff --git a/tests/integration/build/style/fixtures/bare-module-watch/asis.js b/tests/integration/build/style/fixtures/bare-module-watch/asis.js new file mode 100644 index 00000000..c4c4a14f --- /dev/null +++ b/tests/integration/build/style/fixtures/bare-module-watch/asis.js @@ -0,0 +1 @@ +import "./src/shared/content/content.scss?asis"; diff --git a/tests/integration/build/style/fixtures/bare-module-watch/document.js b/tests/integration/build/style/fixtures/bare-module-watch/document.js new file mode 100644 index 00000000..b6beb654 --- /dev/null +++ b/tests/integration/build/style/fixtures/bare-module-watch/document.js @@ -0,0 +1,3 @@ +import styles from "./src/shared/content/content.scss?unisolated"; + +export default styles; diff --git a/tests/integration/build/style/fixtures/bare-module-watch/normal.js b/tests/integration/build/style/fixtures/bare-module-watch/normal.js new file mode 100644 index 00000000..54953851 --- /dev/null +++ b/tests/integration/build/style/fixtures/bare-module-watch/normal.js @@ -0,0 +1,3 @@ +import styles from "./src/shared/content/content.scss"; + +export default styles; diff --git a/tests/integration/build/style/fixtures/bare-module-watch/src/apps/alpha/content/content.scss b/tests/integration/build/style/fixtures/bare-module-watch/src/apps/alpha/content/content.scss new file mode 100644 index 00000000..8be36a5b --- /dev/null +++ b/tests/integration/build/style/fixtures/bare-module-watch/src/apps/alpha/content/content.scss @@ -0,0 +1,6 @@ +@use "tokens" as appTokens; + +.badge { + color: appTokens.$accent; + --order: app; +} diff --git a/tests/integration/build/style/fixtures/bare-module-watch/src/shared/content/_tokens.scss b/tests/integration/build/style/fixtures/bare-module-watch/src/shared/content/_tokens.scss new file mode 100644 index 00000000..a96ed09a --- /dev/null +++ b/tests/integration/build/style/fixtures/bare-module-watch/src/shared/content/_tokens.scss @@ -0,0 +1 @@ +$accent: red; diff --git a/tests/integration/build/style/fixtures/bare-module-watch/src/shared/content/content.scss b/tests/integration/build/style/fixtures/bare-module-watch/src/shared/content/content.scss new file mode 100644 index 00000000..7aeb4448 --- /dev/null +++ b/tests/integration/build/style/fixtures/bare-module-watch/src/shared/content/content.scss @@ -0,0 +1,6 @@ +@use "tokens" as sharedTokens; + +.badge { + color: sharedTokens.$accent; + --order: shared; +} diff --git a/tests/integration/build/style/fixtures/font-reuse/asis.js b/tests/integration/build/style/fixtures/font-reuse/asis.js new file mode 100644 index 00000000..c4c4a14f --- /dev/null +++ b/tests/integration/build/style/fixtures/font-reuse/asis.js @@ -0,0 +1 @@ +import "./src/shared/content/content.scss?asis"; diff --git a/tests/integration/build/style/fixtures/font-reuse/document.js b/tests/integration/build/style/fixtures/font-reuse/document.js new file mode 100644 index 00000000..b6beb654 --- /dev/null +++ b/tests/integration/build/style/fixtures/font-reuse/document.js @@ -0,0 +1,3 @@ +import styles from "./src/shared/content/content.scss?unisolated"; + +export default styles; diff --git a/tests/integration/build/style/fixtures/font-reuse/normal.js b/tests/integration/build/style/fixtures/font-reuse/normal.js new file mode 100644 index 00000000..54953851 --- /dev/null +++ b/tests/integration/build/style/fixtures/font-reuse/normal.js @@ -0,0 +1,3 @@ +import styles from "./src/shared/content/content.scss"; + +export default styles; diff --git a/tests/integration/build/style/fixtures/font-reuse/src/apps/alpha/content/Geist.woff2 b/tests/integration/build/style/fixtures/font-reuse/src/apps/alpha/content/Geist.woff2 new file mode 100644 index 00000000..e77e1ad1 --- /dev/null +++ b/tests/integration/build/style/fixtures/font-reuse/src/apps/alpha/content/Geist.woff2 @@ -0,0 +1 @@ +WRONG-APP-DECOY diff --git a/tests/integration/build/style/fixtures/font-reuse/src/apps/alpha/content/content.scss b/tests/integration/build/style/fixtures/font-reuse/src/apps/alpha/content/content.scss new file mode 100644 index 00000000..b540459e --- /dev/null +++ b/tests/integration/build/style/fixtures/font-reuse/src/apps/alpha/content/content.scss @@ -0,0 +1,7 @@ +@use "../theme/fonts"; + +@include fonts.face; + +.badge { + color: blue; +} diff --git a/tests/integration/build/style/fixtures/font-reuse/src/apps/alpha/theme/fonts/Geist.woff2 b/tests/integration/build/style/fixtures/font-reuse/src/apps/alpha/theme/fonts/Geist.woff2 new file mode 100644 index 00000000..d14c9ddb --- /dev/null +++ b/tests/integration/build/style/fixtures/font-reuse/src/apps/alpha/theme/fonts/Geist.woff2 @@ -0,0 +1 @@ +CORRECT-THEME-FONT diff --git a/tests/integration/build/style/fixtures/font-reuse/src/apps/alpha/theme/fonts/_index.scss b/tests/integration/build/style/fixtures/font-reuse/src/apps/alpha/theme/fonts/_index.scss new file mode 100644 index 00000000..6f0a2e46 --- /dev/null +++ b/tests/integration/build/style/fixtures/font-reuse/src/apps/alpha/theme/fonts/_index.scss @@ -0,0 +1,9 @@ +$font: url("./Geist.woff2?browser&v=1#face"); +$family: Geist; + +@mixin face { + @font-face { + font-family: $family; + src: $font format("woff2"); + } +} diff --git a/tests/integration/build/style/fixtures/font-reuse/src/shared/content/Geist.woff2 b/tests/integration/build/style/fixtures/font-reuse/src/shared/content/Geist.woff2 new file mode 100644 index 00000000..a91bfbfc --- /dev/null +++ b/tests/integration/build/style/fixtures/font-reuse/src/shared/content/Geist.woff2 @@ -0,0 +1 @@ +WRONG-SHARED-DECOY diff --git a/tests/integration/build/style/fixtures/font-reuse/src/shared/content/content.scss b/tests/integration/build/style/fixtures/font-reuse/src/shared/content/content.scss new file mode 100644 index 00000000..63bd9246 --- /dev/null +++ b/tests/integration/build/style/fixtures/font-reuse/src/shared/content/content.scss @@ -0,0 +1,3 @@ +.badge { + color: red; +} diff --git a/tests/integration/build/style/fixtures/font-reuse/standalone.js b/tests/integration/build/style/fixtures/font-reuse/standalone.js new file mode 100644 index 00000000..3070e544 --- /dev/null +++ b/tests/integration/build/style/fixtures/font-reuse/standalone.js @@ -0,0 +1,3 @@ +import styles from "./src/apps/alpha/content/content.scss"; + +export default styles; diff --git a/tests/integration/build/style/fixtures/multi-app-updates/content-default.scss b/tests/integration/build/style/fixtures/multi-app-updates/content-default.scss new file mode 100644 index 00000000..dbd4f77b --- /dev/null +++ b/tests/integration/build/style/fixtures/multi-app-updates/content-default.scss @@ -0,0 +1,8 @@ +@use "sass:math"; +@use "../theme/tokens" as appTokens; +@use "../theme/fonts"; + +.badge { + color: appTokens.$accent; + --order: app-updated; +} diff --git a/tests/integration/build/style/fixtures/multi-app-updates/fonts.scss b/tests/integration/build/style/fixtures/multi-app-updates/fonts.scss index 4ae076d6..1e5d3b09 100644 --- a/tests/integration/build/style/fixtures/multi-app-updates/fonts.scss +++ b/tests/integration/build/style/fixtures/multi-app-updates/fonts.scss @@ -1,6 +1,6 @@ @use "../metrics"; -$inter-latin: url("./Inter-Latin.woff2?browser") !default; +$inter-latin: url("./Inter-Latin.woff2?browser&v=1#face") !default; @font-face { font-family: ExampleUpdated; diff --git a/tests/integration/build/style/fixtures/multi-app/src/apps/beta/content/content.scss b/tests/integration/build/style/fixtures/multi-app/src/apps/beta/content/content.scss index de9cc26e..6ff88114 100644 --- a/tests/integration/build/style/fixtures/multi-app/src/apps/beta/content/content.scss +++ b/tests/integration/build/style/fixtures/multi-app/src/apps/beta/content/content.scss @@ -1,8 +1,6 @@ @use "sass:math"; @use "../theme/tokens" as appTokens; -@use "../theme/fonts" with ( - $inter-latin: url("../theme/fonts/Inter-Latin.woff2?browser&v=1#face") -); +@use "../theme/fonts"; .badge { color: appTokens.$accent; diff --git a/tests/integration/build/style/fixtures/multi-app/src/apps/beta/theme/fonts/_index.scss b/tests/integration/build/style/fixtures/multi-app/src/apps/beta/theme/fonts/_index.scss index 07fdd6df..d119187f 100644 --- a/tests/integration/build/style/fixtures/multi-app/src/apps/beta/theme/fonts/_index.scss +++ b/tests/integration/build/style/fixtures/multi-app/src/apps/beta/theme/fonts/_index.scss @@ -1,6 +1,6 @@ @use "../metrics"; -$inter-latin: url("./Inter-Latin.woff2?browser") !default; +$inter-latin: url("./Inter-Latin.woff2?browser&v=1#face") !default; @font-face { font-family: Example; diff --git a/tests/integration/build/style/fixtures/multi-app/standalone.js b/tests/integration/build/style/fixtures/multi-app/standalone.js new file mode 100644 index 00000000..83dfcfeb --- /dev/null +++ b/tests/integration/build/style/fixtures/multi-app/standalone.js @@ -0,0 +1,3 @@ +import styles from "./src/apps/beta/content/content.scss"; + +export default styles; diff --git a/tests/integration/build/style/fixtures/relative-module-watch-updates/child.scss b/tests/integration/build/style/fixtures/relative-module-watch-updates/child.scss new file mode 100644 index 00000000..9dbf7a62 --- /dev/null +++ b/tests/integration/build/style/fixtures/relative-module-watch-updates/child.scss @@ -0,0 +1,6 @@ +@use "./fonts"; + +.badge { + color: fonts.$accent; + --order: app; +} diff --git a/tests/integration/build/style/fixtures/relative-module-watch-updates/index.scss b/tests/integration/build/style/fixtures/relative-module-watch-updates/index.scss new file mode 100644 index 00000000..2e174f9b --- /dev/null +++ b/tests/integration/build/style/fixtures/relative-module-watch-updates/index.scss @@ -0,0 +1 @@ +$accent: blue; diff --git a/tests/integration/build/style/fixtures/relative-module-watch-updates/parent.scss b/tests/integration/build/style/fixtures/relative-module-watch-updates/parent.scss new file mode 100644 index 00000000..dce00e85 --- /dev/null +++ b/tests/integration/build/style/fixtures/relative-module-watch-updates/parent.scss @@ -0,0 +1,6 @@ +@use "../theme/fonts"; + +.badge { + color: fonts.$accent; + --order: app; +} diff --git a/tests/integration/build/style/fixtures/relative-module-watch-updates/shared.scss b/tests/integration/build/style/fixtures/relative-module-watch-updates/shared.scss new file mode 100644 index 00000000..bda05045 --- /dev/null +++ b/tests/integration/build/style/fixtures/relative-module-watch-updates/shared.scss @@ -0,0 +1,4 @@ +.badge { + color: red; + --order: shared; +} diff --git a/tests/integration/build/style/fixtures/virtual-theme-updates/fonts-alias.scss b/tests/integration/build/style/fixtures/virtual-theme-updates/fonts-alias.scss new file mode 100644 index 00000000..de06e389 --- /dev/null +++ b/tests/integration/build/style/fixtures/virtual-theme-updates/fonts-alias.scss @@ -0,0 +1,11 @@ +$font: url("./Virtual.woff2?browser&v=1#face") !default; + +@font-face { + font-family: Virtual; + src: $font format("woff2"); +} + +@font-face { + font-family: VirtualAlias; + src: url("@font/Virtual.woff2?browser#alias") format("woff2"); +} diff --git a/tests/integration/build/style/fixtures/virtual-theme-updates/generated.scss b/tests/integration/build/style/fixtures/virtual-theme-updates/generated.scss new file mode 100644 index 00000000..de3b1128 --- /dev/null +++ b/tests/integration/build/style/fixtures/virtual-theme-updates/generated.scss @@ -0,0 +1,14 @@ +@use "../src/apps/alpha/theme/fonts" with ( + $font: url("../src/apps/alpha/theme/fonts/Virtual.woff2?browser&v=2#configured") +); + +.badge { + color: red; + --order: shared; + background-image: url("../src/shared/theme/shared.svg?browser#shape"); +} + +.badge { + color: green; + --order: app-updated; +} diff --git a/tests/integration/build/style/fixtures/virtual-theme/asis.js b/tests/integration/build/style/fixtures/virtual-theme/asis.js new file mode 100644 index 00000000..1f2157f7 --- /dev/null +++ b/tests/integration/build/style/fixtures/virtual-theme/asis.js @@ -0,0 +1,3 @@ +import styles from "./generated/style.scss?asis"; + +export default styles; diff --git a/tests/integration/build/style/fixtures/virtual-theme/document.js b/tests/integration/build/style/fixtures/virtual-theme/document.js new file mode 100644 index 00000000..0cdf9324 --- /dev/null +++ b/tests/integration/build/style/fixtures/virtual-theme/document.js @@ -0,0 +1,3 @@ +import styles from "./generated/style.scss?unisolated"; + +export default styles; diff --git a/tests/integration/build/style/fixtures/virtual-theme/generated/style.scss b/tests/integration/build/style/fixtures/virtual-theme/generated/style.scss new file mode 100644 index 00000000..c518d517 --- /dev/null +++ b/tests/integration/build/style/fixtures/virtual-theme/generated/style.scss @@ -0,0 +1,14 @@ +// A consumer plugin supplies prepared contents for this stable resource. +@use "../src/apps/alpha/theme/fonts"; + +.badge { + color: red; + --order: shared; + background-image: url("../src/shared/theme/shared.svg?browser#shape"); +} + +.badge { + color: blue; + --order: app; + background-image: url("../src/apps/alpha/theme/app.svg?browser#shape"); +} diff --git a/tests/integration/build/style/fixtures/virtual-theme/normal.js b/tests/integration/build/style/fixtures/virtual-theme/normal.js new file mode 100644 index 00000000..b7aea907 --- /dev/null +++ b/tests/integration/build/style/fixtures/virtual-theme/normal.js @@ -0,0 +1,3 @@ +import styles from "./generated/style.scss"; + +export default styles; diff --git a/tests/integration/build/style/fixtures/virtual-theme/src/apps/alpha/theme/app.svg b/tests/integration/build/style/fixtures/virtual-theme/src/apps/alpha/theme/app.svg new file mode 100644 index 00000000..1116df79 --- /dev/null +++ b/tests/integration/build/style/fixtures/virtual-theme/src/apps/alpha/theme/app.svg @@ -0,0 +1 @@ + diff --git a/tests/integration/build/style/fixtures/virtual-theme/src/apps/alpha/theme/fonts/Virtual.woff2 b/tests/integration/build/style/fixtures/virtual-theme/src/apps/alpha/theme/fonts/Virtual.woff2 new file mode 100644 index 00000000..caefae3c --- /dev/null +++ b/tests/integration/build/style/fixtures/virtual-theme/src/apps/alpha/theme/fonts/Virtual.woff2 @@ -0,0 +1 @@ +virtual-theme-font diff --git a/tests/integration/build/style/fixtures/virtual-theme/src/apps/alpha/theme/fonts/_index.scss b/tests/integration/build/style/fixtures/virtual-theme/src/apps/alpha/theme/fonts/_index.scss new file mode 100644 index 00000000..137ba33a --- /dev/null +++ b/tests/integration/build/style/fixtures/virtual-theme/src/apps/alpha/theme/fonts/_index.scss @@ -0,0 +1,6 @@ +$font: url("./Virtual.woff2?browser&v=1#face") !default; + +@font-face { + font-family: Virtual; + src: $font format("woff2"); +} diff --git a/tests/integration/build/style/fixtures/virtual-theme/src/shared/theme/shared.svg b/tests/integration/build/style/fixtures/virtual-theme/src/shared/theme/shared.svg new file mode 100644 index 00000000..4bd50fb2 --- /dev/null +++ b/tests/integration/build/style/fixtures/virtual-theme/src/shared/theme/shared.svg @@ -0,0 +1 @@ + diff --git a/tests/integration/build/style/font-reuse.integration.test.ts b/tests/integration/build/style/font-reuse.integration.test.ts new file mode 100644 index 00000000..f26c12f7 --- /dev/null +++ b/tests/integration/build/style/font-reuse.integration.test.ts @@ -0,0 +1,57 @@ +import {readFile, rm} from "fs/promises"; +import path from "path"; +import postcss from "postcss"; +import type {Compiler} from "@rspack/core"; + +import {closeCompiler, createCompiler, createFixture, entries, getCss, runCompiler} from "./compiler"; +import {assertSuccess, getBadgeClass, getFont, hasDeclaration} from "./style-output"; + +let root: string; +let compiler: Compiler | undefined; + +beforeEach(async () => { + root = await createFixture("font-reuse"); +}); + +afterEach(async () => { + try { + if (compiler) { + await closeCompiler(compiler); + } + } finally { + compiler = undefined; + await rm(root, {recursive: true, force: true}); + } +}); + +test.each([true, false])("a reused font mixin resolves its own resource with mergeStyles=%s", async mergeStyles => { + compiler = await createCompiler(root, "alpha", mergeStyles, { + ...(mergeStyles ? {} : {entry: {normal: "./standalone.js"}}), + }); + + const stats = await runCompiler(compiler); + assertSuccess(stats); + const {compilation} = stats; + const font = path.join(root, "src/apps/alpha/theme/fonts/Geist.woff2"); + + expect(Buffer.from(getFont(compilation))).toEqual(await readFile(font)); + expect(compilation.fileDependencies.has(font)).toBe(true); + expect(compilation.fileDependencies.has(path.join(root, "src/shared/content/Geist.woff2"))).toBe(false); + expect(compilation.fileDependencies.has(path.join(root, "src/apps/alpha/content/Geist.woff2"))).toBe(false); + + const badge = getBadgeClass(compilation, "normal"); + + if (mergeStyles) { + expect(getBadgeClass(compilation, "document")).toBe(badge); + } + + for (const entry of mergeStyles ? entries : ["normal"]) { + const css = getCss(compilation, entry); + const rules = postcss.parse(css).nodes.filter(node => node.type === "rule"); + const selector = entry === "asis" ? ".badge" : `.${badge}`; + + expect(rules.map(rule => rule.selector)).toEqual(mergeStyles ? [selector, selector] : [selector]); + expect(hasDeclaration(css, "font-family", "Geist")).toBe(true); + expect(css).toMatch(/url\(chrome-extension:\/\/__MSG_@@extension_id__\/assets\/Geist\.[a-f0-9]+\.woff2#face\)/); + } +}); diff --git a/tests/integration/build/style/module-resolution.integration.test.ts b/tests/integration/build/style/module-resolution.integration.test.ts new file mode 100644 index 00000000..af92fbf6 --- /dev/null +++ b/tests/integration/build/style/module-resolution.integration.test.ts @@ -0,0 +1,208 @@ +import {mkdir, readFile, rm, unlink, writeFile} from "fs/promises"; +import path from "path"; +import postcss, {type Declaration} from "postcss"; +import type {Compilation, Compiler, Stats} from "@rspack/core"; + +import {closeCompiler, createCompiler, createFixture, entries, fixtures, getCss} from "./compiler"; +import {getBadgeClass} from "./style-output"; + +let root: string; +let compiler: Compiler | undefined; + +beforeEach(async () => { + root = await createFixture("bare-module-watch"); +}); + +afterEach(async () => { + try { + if (compiler) { + await closeCompiler(compiler); + } + } finally { + compiler = undefined; + await rm(root, {recursive: true, force: true}); + } +}); + +const colors = (compilation: Compilation, entry: string): string[] => { + return postcss + .parse(getCss(compilation, entry)) + .nodes.filter(node => node.type === "rule") + .map( + rule => rule.nodes.find((node): node is Declaration => node.type === "decl" && node.prop === "color")!.value + ); +}; + +test("watch reevaluates a bare app request when local module candidates appear and disappear", async () => { + const appDir = path.join(root, "src/apps/alpha/content"); + const localPartial = path.join(appDir, "_tokens.scss"); + const localIndex = path.join(appDir, "tokens/_index.scss"); + const entry = path.join(appDir, "content.scss"); + const unchangedEntry = await readFile(entry, "utf8"); + compiler = await createCompiler(root, "alpha"); + let complete: ((error: Error | null, stats?: Stats) => void) | undefined; + + const next = (description: string, color: string, update?: () => Promise) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + complete = undefined; + reject(new Error(`Bare Sass module watch did not observe ${description}`)); + }, 10_000); + + complete = (error, stats) => { + if ( + !error && + stats && + !stats.hasErrors() && + !entries.every( + name => JSON.stringify(colors(stats.compilation, name)) === JSON.stringify(["red", color]) + ) + ) { + return; + } + + clearTimeout(timer); + complete = undefined; + + if (error || !stats || stats.hasErrors()) { + reject(error ?? new Error(stats?.toString({all: false, errors: true}))); + } else { + // The compiler reconnects its watcher after invoking this callback. + setImmediate(() => resolve(stats)); + } + }; + + void update?.().catch(error => complete?.(error)); + }); + + const updateFrom = async (destination: string, filename: string): Promise => { + await writeFile(destination, await readFile(path.join(fixtures, "bare-module-watch-updates", filename))); + }; + + const initial = next("the shared fallback", "red"); + const watcher = compiler.watch({poll: 50}, (error, stats) => complete?.(error, stats)); + + try { + let stats = await initial; + const badge = getBadgeClass(stats.compilation, "normal"); + expect(stats.compilation.missingDependencies.has(localPartial)).toBe(true); + expect(stats.compilation.missingDependencies.has(localIndex)).toBe(true); + + stats = await next("local partial creation", "blue", () => updateFrom(localPartial, "created.scss")); + expect(stats.compilation.fileDependencies.has(localPartial)).toBe(true); + expect(getBadgeClass(stats.compilation, "normal")).toBe(badge); + + stats = await next("local partial edit", "green", () => updateFrom(localPartial, "updated.scss")); + expect(getBadgeClass(stats.compilation, "normal")).toBe(badge); + + stats = await next("local partial removal and shared fallback", "red", () => unlink(localPartial)); + expect(stats.compilation.missingDependencies.has(localPartial)).toBe(true); + expect(getBadgeClass(stats.compilation, "normal")).toBe(badge); + + stats = await next("local partial recreation", "blue", () => updateFrom(localPartial, "created.scss")); + expect(getBadgeClass(stats.compilation, "normal")).toBe(badge); + + stats = await next("a replacement directory index", "purple", async () => { + await unlink(localPartial); + await mkdir(path.dirname(localIndex)); + await updateFrom(localIndex, "index.scss"); + }); + + expect(stats.compilation.fileDependencies.has(localIndex)).toBe(true); + expect(stats.compilation.missingDependencies.has(localPartial)).toBe(true); + expect(getBadgeClass(stats.compilation, "normal")).toBe(badge); + expect(getBadgeClass(stats.compilation, "document")).toBe(badge); + expect(await readFile(entry, "utf8")).toBe(unchangedEntry); + } finally { + await new Promise(resolve => watcher.close(resolve)); + } +}, 30_000); + +test.each([ + {request: "../theme/fonts", source: "parent.scss", directory: "theme/fonts"}, + {request: "./fonts", source: "child.scss", directory: "content/fonts"}, +])( + "watch recovers a missing explicit $request import without changing its caller", + async ({source, directory}) => { + const appDir = path.join(root, "src/apps/alpha"); + const entry = path.join(appDir, "content/content.scss"); + const index = path.join(appDir, directory, "_index.scss"); + const updates = path.join(fixtures, "relative-module-watch-updates"); + + await writeFile( + path.join(root, "src/shared/content/content.scss"), + await readFile(path.join(updates, "shared.scss")) + ); + + await writeFile(entry, await readFile(path.join(updates, source))); + // Observe the missing file itself, rather than incidental creation of its parent directory. + await mkdir(path.dirname(index), {recursive: true}); + const unchangedEntry = await readFile(entry, "utf8"); + compiler = await createCompiler(root, "alpha"); + let complete: ((error: Error | null, stats?: Stats) => void) | undefined; + + const next = (description: string, expectErrors: boolean, update?: () => Promise) => + new Promise((resolve, reject) => { + const timer = setTimeout(() => { + complete = undefined; + reject(new Error(`Explicit Sass module watch did not observe ${description}`)); + }, 10_000); + + complete = (error, stats) => { + if (!error && stats && stats.hasErrors() !== expectErrors) { + return; + } + + clearTimeout(timer); + complete = undefined; + + if (error || !stats) { + reject(error ?? new Error("Explicit Sass module watch did not return stats")); + } else { + setImmediate(() => resolve(stats)); + } + }; + + void update?.().catch(error => complete?.(error)); + }); + + const createIndex = async (): Promise => { + await mkdir(path.dirname(index), {recursive: true}); + await writeFile(index, await readFile(path.join(updates, "index.scss"))); + }; + + const initial = next("the missing import error", true); + const watcher = compiler.watch({poll: 50}, (error, stats) => complete?.(error, stats)); + + try { + const missing = await initial; + expect(missing.toString({all: false, errors: true})).toContain("Can't find stylesheet to import"); + expect(missing.compilation.missingDependencies.has(index)).toBe(true); + + let stats = await next("index creation and recovery", false, createIndex); + expect(stats.compilation.fileDependencies.has(index)).toBe(true); + const badge = getBadgeClass(stats.compilation, "normal"); + + for (const name of entries) { + expect(colors(stats.compilation, name)).toEqual(["red", "blue"]); + } + + stats = await next("index removal and the missing import error", true, () => unlink(index)); + expect(stats.toString({all: false, errors: true})).toContain("Can't find stylesheet to import"); + expect(stats.compilation.missingDependencies.has(index)).toBe(true); + + stats = await next("index recreation and recovery", false, createIndex); + expect(getBadgeClass(stats.compilation, "normal")).toBe(badge); + expect(getBadgeClass(stats.compilation, "document")).toBe(badge); + + for (const name of entries) { + expect(colors(stats.compilation, name)).toEqual(["red", "blue"]); + } + + expect(await readFile(entry, "utf8")).toBe(unchangedEntry); + } finally { + await new Promise(resolve => watcher.close(resolve)); + } + }, + 30_000 +); diff --git a/tests/integration/build/style/resolve-style-urls-loader.cjs b/tests/integration/build/style/resolve-style-urls-loader.cjs new file mode 100644 index 00000000..419954ee --- /dev/null +++ b/tests/integration/build/style/resolve-style-urls-loader.cjs @@ -0,0 +1,4 @@ +// Rspack uses native import() for ESM loaders, which Jest's CJS VM does not host. +// Adapt only module loading; the built production loader still owns all behavior. +// The native consumer smoke test additionally exercises its real ESM entry. +module.exports = require("../../../../dist/cli/bundler/loaders/resolve-style-urls.js").default; diff --git a/tests/integration/build/style/style-merge.integration.test.ts b/tests/integration/build/style/style-merge.integration.test.ts index d83eaf4b..081900c7 100644 --- a/tests/integration/build/style/style-merge.integration.test.ts +++ b/tests/integration/build/style/style-merge.integration.test.ts @@ -1,12 +1,12 @@ import {readFile, rm, unlink, writeFile} from "fs/promises"; import path from "path"; -import vm from "vm"; import postcss from "postcss"; import type {Compilation, Compiler, NormalModule, Stats} from "@rspack/core"; import {DefaultStylesLayer, DocumentStylesLayer} from "@cli/bundler/layers"; import {closeCompiler, createCompiler, createFixture, entries, fixtures, getCss, runCompiler} from "./compiler"; +import {assertSuccess, getBadgeClass, getFont, hasDeclaration} from "./style-output"; let root: string; let compiler: Compiler | undefined; @@ -26,34 +26,6 @@ afterEach(async () => { } }); -const getBadgeClass = (compilation: Compilation, entry: string): string => { - const script = compilation.getAsset(`${entry}.js`)!.source.source().toString(); - - return vm.runInNewContext(`${script}\nmodule.exports.default.badge`, {module: {exports: {}}}); -}; - -const getFont = (compilation: Compilation): string => { - return compilation - .getAssets() - .filter(asset => asset.name.endsWith(".woff2")) - .map(asset => asset.source.source().toString()) - .join(""); -}; - -const assertSuccess = (stats: Stats): void => { - expect(stats.toJson({all: false, errors: true}).errors).toEqual([]); -}; - -const hasDeclaration = (css: string, prop: string, value: string): boolean => { - let found = false; - - postcss.parse(css).walkDecls(prop, declaration => { - found ||= declaration.value === value; - }); - - return found; -}; - test.each([ ["alpha", "blue"], ["beta", "green"], @@ -121,6 +93,28 @@ test("mergeStyles=false compiles the shared source without loading the app overr expect(stats.compilation.fileDependencies.has(path.join(root, "src/apps/alpha/content/content.scss"))).toBe(false); }); +test.each([false, "source-map"] as const)( + "resolves standalone partial assets with merging disabled and devtool=%s", + async devtool => { + compiler = await createCompiler(root, "beta", false, { + mode: devtool ? "development" : "production", + devtool, + entry: {normal: "./standalone.js"}, + }); + + const stats = await runCompiler(compiler); + assertSuccess(stats); + const css = getCss(stats.compilation, "normal"); + const font = path.join(root, "src/apps/beta/theme/fonts/Inter-Latin.woff2"); + + expect(Buffer.from(getFont(stats.compilation))).toEqual(await readFile(font)); + expect(css).toMatch(/Inter-Latin\.[a-f0-9]+\.woff2#face/); + expect(hasDeclaration(css, "--order", "app")).toBe(true); + expect(hasDeclaration(css, "--order", "shared")).toBe(false); + expect(stats.compilation.getAssets().some(asset => asset.name.endsWith(".css.map"))).toBe(Boolean(devtool)); + } +); + test.each([ ["conflict", /already a module with namespace "sharedTokens"/], ["missing", /Can't find stylesheet to import/], @@ -140,6 +134,7 @@ test.each(["alpha", "beta"])( async app => { const appPath = path.join(root, "src/apps", app, "content/content.scss"); const initialSource = path.join(fixtures, "multi-app/src/apps", app, "content/content.scss"); + const updatedSource = app === "beta" ? "content-default.scss" : "content.scss"; await unlink(appPath); compiler = await createCompiler(root, app); let complete: ((error: Error | null, stats?: Stats) => void) | undefined; @@ -198,7 +193,7 @@ test.each(["alpha", "beta"])( expect(getBadgeClass(stats.compilation, "normal")).toBe(badge); stats = await next("override edit", contains("--order", "app-updated"), () => - updateFrom(appPath, "content.scss") + updateFrom(appPath, updatedSource) ); expect(getBadgeClass(stats.compilation, "normal")).toBe(badge); @@ -213,7 +208,7 @@ test.each(["alpha", "beta"])( expect(stats.compilation.fileDependencies.has(appPath)).toBe(true); stats = await next("override correction", contains("--order", "app-updated"), () => - updateFrom(appPath, "content.scss") + updateFrom(appPath, updatedSource) ); expect(getBadgeClass(stats.compilation, "normal")).toBe(badge); @@ -246,7 +241,7 @@ test.each(["alpha", "beta"])( expect(getBadgeClass(stats.compilation, "normal")).toBe(badge); stats = await next("override recreation", contains("--order", "app-updated"), () => - updateFrom(appPath, "content.scss") + updateFrom(appPath, updatedSource) ); expect(getBadgeClass(stats.compilation, "normal")).toBe(badge); diff --git a/tests/integration/build/style/style-output.ts b/tests/integration/build/style/style-output.ts new file mode 100644 index 00000000..647775f8 --- /dev/null +++ b/tests/integration/build/style/style-output.ts @@ -0,0 +1,31 @@ +import vm from "vm"; +import postcss from "postcss"; +import type {Compilation, Stats} from "@rspack/core"; + +export const getBadgeClass = (compilation: Compilation, entry: string): string => { + const script = compilation.getAsset(`${entry}.js`)!.source.source().toString(); + + return vm.runInNewContext(`${script}\nmodule.exports.default.badge`, {module: {exports: {}}}); +}; + +export const getFont = (compilation: Compilation): string => { + return compilation + .getAssets() + .filter(asset => asset.name.endsWith(".woff2")) + .map(asset => asset.source.source().toString()) + .join(""); +}; + +export const assertSuccess = (stats: Stats): void => { + expect(stats.toJson({all: false, errors: true}).errors).toEqual([]); +}; + +export const hasDeclaration = (css: string, prop: string, value: string): boolean => { + let found = false; + + postcss.parse(css).walkDecls(prop, declaration => { + found ||= declaration.value === value; + }); + + return found; +}; diff --git a/tests/integration/build/style/verify-native-consumer.mjs b/tests/integration/build/style/verify-native-consumer.mjs new file mode 100644 index 00000000..ea54e58d --- /dev/null +++ b/tests/integration/build/style/verify-native-consumer.mjs @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import {readFile} from "node:fs/promises"; +import path from "node:path"; +import vm from "node:vm"; +import {rspack} from "@rspack/core"; + +import stylePlugin from "../../../../dist/cli/plugins/style/index.js"; +import assetPlugin from "../../../../dist/cli/plugins/asset.js"; + +const root = process.argv[2]; +const project = path.resolve(import.meta.dirname, "../../../.."); +const config = { + rootDir: root, + srcDir: "src", + sharedDir: "shared", + appsDir: "apps", + appSrcDir: "", + app: "alpha", + browser: "chrome", + mergeStyles: false, + cssDir: "css", + cssFilename: "[name].css", + cssIdentName: "[app]-[local]__[hash:base64:5]", + assetsDir: "assets", + assetsFilename: "[name].[contenthash:8][ext]", +}; +const styles = await stylePlugin().bundler({config, rspack: {}}); +const assets = await assetPlugin().bundler({config, rspack: {}}); +const compiler = rspack({ + context: root, + mode: "production", + devtool: false, + entry: {normal: "./normal.js"}, + output: { + ...assets.output, + path: path.join(root, "dist/native"), + filename: "[name].js", + publicPath: "", + library: {type: "commonjs2"}, + }, + resolve: { + alias: {"@font": path.join(root, "src/apps/alpha/theme/fonts")}, + modules: [path.join(project, "node_modules")], + }, + resolveLoader: {modules: [path.join(project, "node_modules")]}, + module: {rules: [...styles.module.rules, ...assets.module.rules]}, + plugins: [...styles.plugins, ...assets.plugins], + optimization: {minimize: false}, +}); + +try { + const stats = await new Promise((resolve, reject) => { + compiler.run((error, result) => { + if (error || !result) { + reject(error ?? new Error("Native style compilation did not return stats")); + } else { + resolve(result); + } + }); + }); + + assert.equal(stats.hasErrors(), false, stats.toString({all: false, errors: true})); + const compilation = stats.compilation; + const css = compilation.getAsset("css/normal.css").source.source().toString(); + const script = compilation.getAsset("normal.js").source.source().toString(); + const badge = vm.runInNewContext(`${script}\nmodule.exports.default.badge`, {module: {exports: {}}}); + + assert.match(badge, /^alpha-badge__/); + assert.equal(css.split(`.${badge}`).length - 1, 2); + assert.match(css, /shared\.[a-f0-9]+\.svg#shape/); + assert.match(css, /app\.[a-f0-9]+\.svg#shape/); + assert.match(css, /Virtual\.[a-f0-9]+\.woff2#face/); + assert.match(css, /Virtual\.[a-f0-9]+\.woff2#alias/); + const font = compilation.getAssets().find(asset => asset.name.endsWith(".woff2")); + + assert.equal( + font.source.source().toString(), + await readFile(path.join(root, "src/apps/alpha/theme/fonts/Virtual.woff2"), "utf8") + ); + console.log("Native style consumer verified"); +} finally { + await new Promise((resolve, reject) => { + compiler.close(error => { + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} diff --git a/tests/integration/build/style/virtual-style.integration.test.ts b/tests/integration/build/style/virtual-style.integration.test.ts new file mode 100644 index 00000000..ed82c80c --- /dev/null +++ b/tests/integration/build/style/virtual-style.integration.test.ts @@ -0,0 +1,186 @@ +import {createRequire} from "module"; +import {execFile} from "child_process"; +import {readFile, rm, writeFile} from "fs/promises"; +import path from "path"; +import {promisify} from "util"; +import postcss, {type Declaration} from "postcss"; +import type {Compiler, RuleSetRule} from "@rspack/core"; + +import {closeCompiler, createCompiler, createFixture, entries, fixtures, getCss, runCompiler} from "./compiler"; +import {assertSuccess, getBadgeClass, getFont, hasDeclaration} from "./style-output"; + +const require = createRequire(import.meta.url); +let root: string; +let compiler: Compiler | undefined; + +beforeEach(async () => { + root = await createFixture("virtual-theme"); +}); + +afterEach(async () => { + try { + if (compiler) { + await closeCompiler(compiler); + } + } finally { + compiler = undefined; + await rm(root, {recursive: true, force: true}); + } +}); + +const createThemeCompiler = async (mergeStyles: boolean, devtool: false | "source-map" = false) => { + return createCompiler(root, "alpha", mergeStyles, { + mode: devtool ? "development" : "production", + devtool, + }); +}; + +// A pre-existing plugin may insert its own URL resolver before Sass in the +// configured chain. Exercise that supported rule hook without depending on UI. +const addExternalUrlResolver = (target: Compiler): void => { + let nextRule = 0; + const patch = (rule: RuleSetRule): void => { + for (const child of [...(rule.oneOf ?? []), ...(rule.rules ?? [])]) { + if (child && typeof child === "object") { + patch(child); + } + } + + const original = rule.use; + + if (!Array.isArray(original)) { + return; + } + + const ruleId = nextRule++; + + rule.use = data => { + if (data.resource?.split("?")[0] !== path.join(root, "generated/style.scss")) { + return original; + } + + return original.flatMap((item, index) => { + const entry = typeof item === "string" ? {loader: item} : item; + + if (!/(?:^|[/\\])sass-loader(?:[/\\]|$)/.test(entry.loader)) { + return [item]; + } + + if (typeof entry.options === "string") { + throw new Error("Expected Sass loader options to be an object"); + } + + return [ + { + loader: require.resolve("resolve-url-loader"), + options: {sourceMap: Boolean(target.options.devtool)}, + }, + { + ...entry, + ident: `consumer-sass-${ruleId}-${index}`, + options: {...entry.options, sourceMap: true}, + }, + ]; + }); + }; + }; + + for (const rule of target.options.module.rules) { + if (rule && typeof rule === "object") { + patch(rule); + } + } +}; + +test.each([true, false])("compiles an already prepared virtual resource with mergeStyles=%s", async mergeStyles => { + compiler = await createThemeCompiler(mergeStyles); + const stats = await runCompiler(compiler); + assertSuccess(stats); + const badge = getBadgeClass(stats.compilation, "normal"); + expect(getBadgeClass(stats.compilation, "document")).toBe(badge); + + for (const entry of entries) { + const css = getCss(stats.compilation, entry); + const rules = postcss.parse(css).nodes.filter(node => node.type === "rule"); + const selector = entry === "asis" ? ".badge" : `.${badge}`; + + expect(rules.map(rule => rule.selector)).toEqual([selector, selector]); + expect( + rules.map( + rule => + rule.nodes.find((node): node is Declaration => node.type === "decl" && node.prop === "color")!.value + ) + ).toEqual(["red", "blue"]); + expect(css).toMatch(/shared\.[a-f0-9]+\.svg#shape/); + expect(css).toMatch(/app\.[a-f0-9]+\.svg#shape/); + expect(css).toMatch(/Virtual\.[a-f0-9]+\.woff2#face/); + } +}); + +test("the production style pipeline loads its private ESM loader under native Node", async () => { + await writeFile( + path.join(root, "src/apps/alpha/theme/fonts/_index.scss"), + await readFile(path.join(fixtures, "virtual-theme-updates/fonts-alias.scss")) + ); + + const {stdout, stderr} = await promisify(execFile)( + process.execPath, + [path.join(__dirname, "verify-native-consumer.mjs"), root], + {timeout: 15_000, maxBuffer: 1024 * 1024} + ); + + expect(stdout).toContain("Native style consumer verified"); + expect(stderr).toBe(""); +}, 20_000); + +test("preserves an asset alias declared inside an imported partial", async () => { + await writeFile( + path.join(root, "src/apps/alpha/theme/fonts/_index.scss"), + await readFile(path.join(fixtures, "virtual-theme-updates/fonts-alias.scss")) + ); + + compiler = await createThemeCompiler(true); + const stats = await runCompiler(compiler); + assertSuccess(stats); + + for (const entry of entries) { + const css = getCss(stats.compilation, entry); + + expect(css).toMatch(/Virtual\.[a-f0-9]+\.woff2#alias/); + expect(css).toMatch(/Virtual\.[a-f0-9]+\.woff2#face/); + } +}); + +test.each([ + {devtool: false, configured: false}, + {devtool: false, configured: true}, + {devtool: "source-map", configured: false}, + {devtool: "source-map", configured: true}, +] as const)( + "coexists with an external URL resolver: devtool=$devtool, configured=$configured", + async ({devtool, configured}) => { + if (configured) { + await writeFile( + path.join(root, "generated/style.scss"), + await readFile(path.join(fixtures, "virtual-theme-updates/generated.scss")) + ); + } + + compiler = await createThemeCompiler(true, devtool); + addExternalUrlResolver(compiler); + const stats = await runCompiler(compiler); + assertSuccess(stats); + + for (const entry of entries) { + const css = getCss(stats.compilation, entry); + + expect(hasDeclaration(css, "--order", "shared")).toBe(true); + expect(hasDeclaration(css, "--order", configured ? "app-updated" : "app")).toBe(true); + expect(css).toMatch(new RegExp(`Virtual\\.[a-f0-9]+\\.woff2#${configured ? "configured" : "face"}`)); + } + + expect(getFont(stats.compilation)).toBe( + await readFile(path.join(root, "src/apps/alpha/theme/fonts/Virtual.woff2"), "utf8") + ); + } +); From 8015806ba9e0bfdfe26d54c6f622f4248d9662d9 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:33:19 +0300 Subject: [PATCH 04/24] fix(build): compile JSX entrypoints and components --- src/cli/entrypoint/file/OptionFile.test.ts | 14 ++++ .../fixtures/export-shapes/jsx-definition.jsx | 14 ++++ src/cli/plugins/typescript/index.ts | 16 +++- .../browser/view/jsx.integration.test.ts | 75 +++++++++++++++++++ .../build/jsx/fixture/adnbn.config.ts | 8 ++ .../build/jsx/fixture/package.json | 12 +++ .../build/jsx/fixture/src/Counter.jsx | 11 +++ .../build/jsx/fixture/src/definition.page.jsx | 17 +++++ .../build/jsx/fixture/src/element.page.jsx | 8 ++ .../build/jsx/fixture/src/javascript.page.js | 6 ++ .../build/jsx/fixture/src/named.page.jsx | 14 ++++ .../jsx/fixture/src/react-typescript.page.tsx | 9 +++ .../build/jsx/fixture/src/typescript.page.ts | 6 ++ .../build/jsx/fixture/tsconfig.json | 7 ++ .../build/jsx/jsx.integration.test.ts | 45 +++++++++++ 15 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 src/cli/entrypoint/file/tests/fixtures/export-shapes/jsx-definition.jsx create mode 100644 tests/integration/browser/view/jsx.integration.test.ts create mode 100644 tests/integration/build/jsx/fixture/adnbn.config.ts create mode 100644 tests/integration/build/jsx/fixture/package.json create mode 100644 tests/integration/build/jsx/fixture/src/Counter.jsx create mode 100644 tests/integration/build/jsx/fixture/src/definition.page.jsx create mode 100644 tests/integration/build/jsx/fixture/src/element.page.jsx create mode 100644 tests/integration/build/jsx/fixture/src/javascript.page.js create mode 100644 tests/integration/build/jsx/fixture/src/named.page.jsx create mode 100644 tests/integration/build/jsx/fixture/src/react-typescript.page.tsx create mode 100644 tests/integration/build/jsx/fixture/src/typescript.page.ts create mode 100644 tests/integration/build/jsx/fixture/tsconfig.json create mode 100644 tests/integration/build/jsx/jsx.integration.test.ts diff --git a/src/cli/entrypoint/file/OptionFile.test.ts b/src/cli/entrypoint/file/OptionFile.test.ts index 820edb25..95af349b 100644 --- a/src/cli/entrypoint/file/OptionFile.test.ts +++ b/src/cli/entrypoint/file/OptionFile.test.ts @@ -19,6 +19,20 @@ const shape = (name: string, properties: string[], definition: string | string[] * The definition used by a fixture is arbitrary; each parser proves its own one in its `full` scenario. */ describe("OptionFile export shapes", () => { + test("reads static options from a JSX file without evaluating its render component", () => { + const file = OptionFile.make(path.join(shapes, "jsx-definition.jsx")) + .setDefinition("definePage") + .setProperties(["name", "title", "matches"]); + + expect(file.getOptions()).toEqual({ + name: "jsx-page", + title: "JSX page", + matches: ["https://example.com/*"], + }); + expect(file.getDefinition()).toBe("definePage"); + expect(file.getDeclaredProperties()).toEqual(new Set(["title", "name", "matches", "render"])); + }); + test.each([ { title: "a definition call", diff --git a/src/cli/entrypoint/file/tests/fixtures/export-shapes/jsx-definition.jsx b/src/cli/entrypoint/file/tests/fixtures/export-shapes/jsx-definition.jsx new file mode 100644 index 00000000..916f1c0f --- /dev/null +++ b/src/cli/entrypoint/file/tests/fixtures/export-shapes/jsx-definition.jsx @@ -0,0 +1,14 @@ +import React, {useState} from "react"; +import {definePage} from "adnbn"; + +export const title = "JSX page"; + +export default definePage({ + name: "jsx-page", + matches: ["https://example.com/*"], + render: () => { + const [count, setCount] = useState(0); + + return ; + }, +}); diff --git a/src/cli/plugins/typescript/index.ts b/src/cli/plugins/typescript/index.ts index b8d940b1..a4504735 100644 --- a/src/cli/plugins/typescript/index.ts +++ b/src/cli/plugins/typescript/index.ts @@ -22,7 +22,7 @@ export default definePlugin(() => { bundler: () => { return { resolve: { - extensions: [".ts", ".tsx", ".js"], + extensions: [".ts", ".tsx", ".js", ".jsx"], alias: typescript.aliases(), }, module: { @@ -41,6 +41,20 @@ export default definePlugin(() => { }, type: "javascript/auto", }, + { + test: /\.jsx$/, + loader: "builtin:swc-loader", + options: { + jsc: { + parser: { + syntax: "ecmascript", + jsx: true, + }, + target: "es2020", + }, + }, + type: "javascript/auto", + }, ], }, } satisfies RspackConfig; diff --git a/tests/integration/browser/view/jsx.integration.test.ts b/tests/integration/browser/view/jsx.integration.test.ts new file mode 100644 index 00000000..c53d4659 --- /dev/null +++ b/tests/integration/browser/view/jsx.integration.test.ts @@ -0,0 +1,75 @@ +import path from "path"; + +import {startBrowserSession, type BrowserSession} from "../utils/session"; +import {waitFor} from "../utils/browser"; +import {createIntegrationFixture} from "../../utils/fixture"; + +const rootDir = path.resolve(__dirname, "..", "..", "..", ".."); +const fixtureDir = path.resolve(__dirname, "..", "..", "build", "jsx", "fixture"); + +jest.setTimeout(90_000); + +test("Chrome MV3 renders JSX definitions, components and elements with working React state", async () => { + const fixture = await createIntegrationFixture(rootDir, fixtureDir); + let session: BrowserSession | undefined; + + try { + const extensionDir = await fixture.build(); + const browser = await startBrowserSession("chrome", rootDir, extensionDir); + session = browser; + + for (const [name, title] of [ + ["definition", "JSX definition"], + ["named", "JSX named"], + ["element", "JSX element"], + ["react-typescript", "TSX page"], + ]) { + await browser.navigate(`chrome-extension://${browser.extensionId}/${name}.html`); + + const readButton = () => browser.evaluate("document.querySelector('button')?.textContent"); + + await waitFor( + async () => ((await readButton()) === `${title}: 0` ? true : undefined), + 10_000, + `${name} initial render` + ); + + expect(await browser.evaluate("document.title")).toBe(title); + + await browser.evaluate("document.querySelector('button').click()"); + + await waitFor( + async () => ((await readButton()) === `${title}: 1` ? true : undefined), + 10_000, + `${name} state update` + ); + + expect(await readButton()).toBe(`${title}: 1`); + } + + for (const [name, title] of [ + ["javascript", "JavaScript page"], + ["typescript", "TypeScript page"], + ]) { + await browser.navigate(`chrome-extension://${browser.extensionId}/${name}.html`); + + await waitFor( + async () => ((await browser.evaluate("document.body.textContent.trim()")) === title ? true : undefined), + 10_000, + `${name} render` + ); + + expect(await browser.evaluate("document.title")).toBe(title); + } + + expect(browser.errors).toEqual([]); + } catch (error) { + throw new Error(`${String(error)}; Chrome output: ${session?.output ?? ""}`, {cause: error}); + } finally { + try { + await session?.close(); + } finally { + await fixture.dispose(); + } + } +}); diff --git a/tests/integration/build/jsx/fixture/adnbn.config.ts b/tests/integration/build/jsx/fixture/adnbn.config.ts new file mode 100644 index 00000000..9d9c85e9 --- /dev/null +++ b/tests/integration/build/jsx/fixture/adnbn.config.ts @@ -0,0 +1,8 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "JSX Integration", + description: "JSX pages, static options and React state alongside JavaScript and TypeScript pages.", + version: "1.0.0", + jsFilename: "[name].js", +}); diff --git a/tests/integration/build/jsx/fixture/package.json b/tests/integration/build/jsx/fixture/package.json new file mode 100644 index 00000000..527f29c5 --- /dev/null +++ b/tests/integration/build/jsx/fixture/package.json @@ -0,0 +1,12 @@ +{ + "name": "adnbn-test-jsx", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../..", + "react": "*", + "react-dom": "*", + "scheduler": "*" + } +} diff --git a/tests/integration/build/jsx/fixture/src/Counter.jsx b/tests/integration/build/jsx/fixture/src/Counter.jsx new file mode 100644 index 00000000..bbb7fb7e --- /dev/null +++ b/tests/integration/build/jsx/fixture/src/Counter.jsx @@ -0,0 +1,11 @@ +import React, {useState} from "react"; + +export function Counter({label}) { + const [count, setCount] = useState(0); + + return ( + + ); +} diff --git a/tests/integration/build/jsx/fixture/src/definition.page.jsx b/tests/integration/build/jsx/fixture/src/definition.page.jsx new file mode 100644 index 00000000..00e74640 --- /dev/null +++ b/tests/integration/build/jsx/fixture/src/definition.page.jsx @@ -0,0 +1,17 @@ +import React, {useState} from "react"; +import {definePage} from "adnbn"; + +export default definePage({ + name: "jsx-definition", + title: "JSX definition", + matches: ["https://example.com/*"], + render: ({title}) => { + const [count, setCount] = useState(0); + + return ( + + ); + }, +}); diff --git a/tests/integration/build/jsx/fixture/src/element.page.jsx b/tests/integration/build/jsx/fixture/src/element.page.jsx new file mode 100644 index 00000000..c5e271b3 --- /dev/null +++ b/tests/integration/build/jsx/fixture/src/element.page.jsx @@ -0,0 +1,8 @@ +import React from "react"; + +import {Counter} from "./Counter"; + +export const name = "jsx-element"; +export const title = "JSX element"; + +export default ; diff --git a/tests/integration/build/jsx/fixture/src/javascript.page.js b/tests/integration/build/jsx/fixture/src/javascript.page.js new file mode 100644 index 00000000..dac47325 --- /dev/null +++ b/tests/integration/build/jsx/fixture/src/javascript.page.js @@ -0,0 +1,6 @@ +import {definePage} from "adnbn"; + +export default definePage({ + title: "JavaScript page", + render: ({title}) => title, +}); diff --git a/tests/integration/build/jsx/fixture/src/named.page.jsx b/tests/integration/build/jsx/fixture/src/named.page.jsx new file mode 100644 index 00000000..3637754b --- /dev/null +++ b/tests/integration/build/jsx/fixture/src/named.page.jsx @@ -0,0 +1,14 @@ +import React, {useState} from "react"; + +export const name = "jsx-named"; +export const title = "JSX named"; + +export default function NamedPage({title}) { + const [count, setCount] = useState(0); + + return ( + + ); +} diff --git a/tests/integration/build/jsx/fixture/src/react-typescript.page.tsx b/tests/integration/build/jsx/fixture/src/react-typescript.page.tsx new file mode 100644 index 00000000..8df4ccfc --- /dev/null +++ b/tests/integration/build/jsx/fixture/src/react-typescript.page.tsx @@ -0,0 +1,9 @@ +import React from "react"; +import {definePage} from "adnbn"; + +import {Counter} from "./Counter"; + +export default definePage({ + title: "TSX page", + render: ({title}) => , +}); diff --git a/tests/integration/build/jsx/fixture/src/typescript.page.ts b/tests/integration/build/jsx/fixture/src/typescript.page.ts new file mode 100644 index 00000000..9bed5274 --- /dev/null +++ b/tests/integration/build/jsx/fixture/src/typescript.page.ts @@ -0,0 +1,6 @@ +import {definePage} from "adnbn"; + +export default definePage({ + title: "TypeScript page", + render: ({title}) => title, +}); diff --git a/tests/integration/build/jsx/fixture/tsconfig.json b/tests/integration/build/jsx/fixture/tsconfig.json new file mode 100644 index 00000000..56714ff5 --- /dev/null +++ b/tests/integration/build/jsx/fixture/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./.adnbn/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "jsx": "react" + } +} diff --git a/tests/integration/build/jsx/jsx.integration.test.ts b/tests/integration/build/jsx/jsx.integration.test.ts new file mode 100644 index 00000000..ece2a7b6 --- /dev/null +++ b/tests/integration/build/jsx/jsx.integration.test.ts @@ -0,0 +1,45 @@ +import {readFile} from "fs/promises"; +import path from "path"; + +import {createIntegrationFixture} from "../../utils/fixture"; + +const rootDir = path.resolve(__dirname, "..", "..", "..", ".."); +const fixtureDir = path.join(__dirname, "fixture"); + +jest.setTimeout(90_000); + +test.each(["chrome", "firefox"])("%s builds JSX pages and preserves their AST options", async browser => { + const fixture = await createIntegrationFixture(rootDir, fixtureDir); + + try { + const extensionDir = await fixture.build({browser}); + const manifest = JSON.parse(await readFile(path.join(extensionDir, "manifest.json"), "utf8")); + + expect(manifest.manifest_version).toBe(3); + expect(manifest.web_accessible_resources).toEqual([ + {resources: ["definition.html"], matches: ["https://example.com/*"]}, + ]); + + const declarations = await readFile(path.join(fixture.directory, ".adnbn", "page.d.ts"), "utf8"); + + for (const alias of ["jsx-definition", "jsx-named", "jsx-element"]) { + expect(declarations).toContain(`"${alias}": true;`); + } + + for (const [name, title] of [ + ["definition", "JSX definition"], + ["named", "JSX named"], + ["element", "JSX element"], + ["javascript", "JavaScript page"], + ["typescript", "TypeScript page"], + ["react-typescript", "TSX page"], + ]) { + const html = await readFile(path.join(extensionDir, `${name}.html`), "utf8"); + + expect(html).toContain(`${title}`); + expect(html).toContain(" Date: Mon, 5 Oct 2026 22:25:38 +0300 Subject: [PATCH 05/24] fix(html)!: align HTML options with the tags plugin Replace html-rspack-tags-plugin with @rspackjs/plugin-html-tags and own HTML contracts in the shared types layer. Keep entrypoint options static and validate them once through ViewParser; let the tags plugin validate resolved config.html values. Cover metadata, static options and configuration forms at their owning layers, with real parser fixtures and focused build/type integrations. Validation: 229 suites / 2361 tests passed, followed by 45 parser tests and source/package type checks for 49 fixtures after fixture cleanup. Package build, typecheck, inventory, Prettier and diff checks passed. BREAKING CHANGE: Require Node.js >=22.12.0. Meta tags must be objects with attributes; string values and arrays containing strings are rejected. The files option in entrypoints now raises an error instead of being ignored. config.html values of null, an empty string or zero now fail instead of being skipped. --- package-lock.json | 86 +++---------- package.json | 4 +- scripts/check-node-version.js | 2 +- .../entrypoint/parser/OffscreenParser.test.ts | 17 +++ .../entrypoint/parser/OptionsParser.test.ts | 11 ++ src/cli/entrypoint/parser/PageParser.test.ts | 37 +++++- src/cli/entrypoint/parser/PopupParser.test.ts | 17 +++ .../entrypoint/parser/SandboxParser.test.ts | 15 +++ .../entrypoint/parser/SidebarParser.test.ts | 17 +++ src/cli/entrypoint/parser/ViewParser.test.ts | 108 ++++++++++++++++ src/cli/entrypoint/parser/ViewParser.ts | 6 +- src/cli/entrypoint/parser/index.ts | 3 +- .../parser/override-parsers.test.ts | 11 ++ src/cli/entrypoint/parser/schemas/html.ts | 91 +++++-------- .../bookmarks/options/defaults/bookmarks.ts | 3 + .../bookmarks/options/full/bookmarks.ts | 7 + .../history/options/defaults/history.ts | 3 + .../fixtures/history/options/full/history.ts | 7 + .../newtab/options/defaults/newtab.ts | 3 + .../fixtures/newtab/options/full/newtab.ts | 7 + .../options/defaults/audio.offscreen.ts | 5 + .../foreign-definition/audio.offscreen.ts | 3 + .../offscreen/options/full/audio.offscreen.ts | 9 ++ .../options/foreign-definition/options.ts | 3 + .../fixtures/options/options/full/options.ts | 7 + .../tests/fixtures/page/invalid/matches.ts | 6 + .../tests/fixtures/page/invalid/name.ts | 6 + .../tests/fixtures/page/options/csp/page.ts | 15 --- .../fixtures/page/options/defaults/page.ts | 5 + .../page/options/foreign-definition/page.ts | 6 + .../tests/fixtures/page/options/full/page.ts | 36 ++++++ .../fixtures/popup/options/defaults/popup.ts | 3 + .../popup/options/foreign-definition/popup.ts | 3 + .../fixtures/popup/options/full/popup.ts | 9 ++ .../sandbox/options/defaults/sandbox.ts | 5 + .../options/foreign-definition/sandbox.ts | 3 + .../fixtures/sandbox/options/full/sandbox.ts | 7 + .../sidebar/options/defaults/sidebar.ts | 3 + .../options/foreign-definition/sidebar.ts | 3 + .../fixtures/sidebar/options/full/sidebar.ts | 9 ++ .../tests/fixtures/view/invalid/add-hash.ts | 6 + .../fixtures/view/invalid/add-public-path.ts | 6 + .../parser/tests/fixtures/view/invalid/as.ts | 5 + .../tests/fixtures/view/invalid/files.ts | 6 + .../tests/fixtures/view/invalid/hash.ts | 6 + .../tests/fixtures/view/invalid/meta-empty.ts | 5 + .../view/invalid/meta-invalid-attribute.ts | 6 + .../fixtures/view/invalid/meta-missing.ts | 6 + .../tests/fixtures/view/invalid/meta-mixed.ts | 6 + .../view/invalid/meta-nonfinite-attribute.ts | 5 + .../tests/fixtures/view/invalid/meta-null.ts | 6 + .../fixtures/view/invalid/meta-string.ts | 6 + .../fixtures/view/invalid/meta-strings.ts | 6 + .../fixtures/view/invalid/nested-function.ts | 6 + .../fixtures/view/invalid/public-path.ts | 6 + .../tests/fixtures/view/invalid/template.ts | 5 + .../tests/fixtures/view/invalid/title.ts | 5 + .../tests/fixtures/view/options/array/page.ts | 6 + .../view/options/boolean-shortcuts/page.ts | 7 + .../fixtures/view/options/defaults/page.ts | 5 + .../fixtures/view/options/empty-array/page.ts | 6 + .../tests/fixtures/view/options/full/page.ts | 24 ++++ .../fixtures/view/options/object/page.ts | 6 + .../fixtures/view/options/shortcuts/page.ts | 7 + src/cli/plugins/html/html.test.ts | 74 +++++++++++ src/cli/plugins/{html.ts => html/index.ts} | 13 +- src/cli/plugins/offscreen/index.ts | 4 +- src/cli/plugins/options/index.ts | 4 +- src/cli/plugins/override/index.ts | 4 +- src/cli/plugins/page/index.ts | 5 +- src/cli/plugins/popup/index.ts | 4 +- src/cli/plugins/sandbox/index.ts | 4 +- src/cli/plugins/sidebar/index.ts | 4 +- src/cli/plugins/view/View.ts | 12 +- src/main/html.ts | 7 + src/main/index.ts | 1 + src/types/config.ts | 2 +- src/types/helpers.ts | 6 - src/types/html.ts | 29 +++++ src/types/view/common.ts | 6 +- tests/integration/README.md | 8 ++ .../build/html/fixture/adnbn.config.ts | 9 ++ .../build/html/fixture/assets/global.js | 1 + .../build/html/fixture/assets/local.js | 1 + .../build/html/fixture/assets/style.css | 3 + .../build/html/fixture/html-options.ts | 12 ++ .../build/html/fixture/package.json | 9 ++ .../build/html/fixture/src/array.page.ts | 10 ++ .../build/html/fixture/src/object.page.ts | 32 +++++ .../build/html/fixture/tsconfig.json | 3 + .../build/html/html.integration.test.ts | 98 ++++++++++++++ .../build/html/scenarios/config-array.ts | 9 ++ .../html/scenarios/config-function-array.ts | 9 ++ .../html/scenarios/config-function-object.ts | 9 ++ .../build/html/scenarios/config-object.ts | 9 ++ .../build/html/scenarios/options-mixed.ts | 3 + .../html/scenarios/page-conflicting-hash.ts | 5 + .../html/scenarios/page-invalid-metas.ts | 5 + .../types/fixtures/html/options.ts | 120 ++++++++++++++++++ .../types/html.integration.test.ts | 48 +++++++ 100 files changed, 1154 insertions(+), 187 deletions(-) create mode 100644 src/cli/entrypoint/parser/ViewParser.test.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/bookmarks/options/defaults/bookmarks.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/history/options/defaults/history.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/newtab/options/defaults/newtab.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/offscreen/options/defaults/audio.offscreen.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/offscreen/options/foreign-definition/audio.offscreen.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/options/options/foreign-definition/options.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/page/invalid/matches.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/page/invalid/name.ts delete mode 100644 src/cli/entrypoint/parser/tests/fixtures/page/options/csp/page.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/page/options/defaults/page.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/page/options/foreign-definition/page.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/page/options/full/page.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/popup/options/defaults/popup.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/popup/options/foreign-definition/popup.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/sandbox/options/defaults/sandbox.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/sandbox/options/foreign-definition/sandbox.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/sidebar/options/defaults/sidebar.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/sidebar/options/foreign-definition/sidebar.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/invalid/add-hash.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/invalid/add-public-path.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/invalid/as.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/invalid/files.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/invalid/hash.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-empty.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-invalid-attribute.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-missing.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-mixed.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-nonfinite-attribute.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-null.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-string.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-strings.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/invalid/nested-function.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/invalid/public-path.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/invalid/template.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/invalid/title.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/options/array/page.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/options/boolean-shortcuts/page.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/options/defaults/page.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/options/empty-array/page.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/options/full/page.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/options/object/page.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/view/options/shortcuts/page.ts create mode 100644 src/cli/plugins/html/html.test.ts rename src/cli/plugins/{html.ts => html/index.ts} (63%) create mode 100644 src/main/html.ts create mode 100644 src/types/html.ts create mode 100644 tests/integration/build/html/fixture/adnbn.config.ts create mode 100644 tests/integration/build/html/fixture/assets/global.js create mode 100644 tests/integration/build/html/fixture/assets/local.js create mode 100644 tests/integration/build/html/fixture/assets/style.css create mode 100644 tests/integration/build/html/fixture/html-options.ts create mode 100644 tests/integration/build/html/fixture/package.json create mode 100644 tests/integration/build/html/fixture/src/array.page.ts create mode 100644 tests/integration/build/html/fixture/src/object.page.ts create mode 100644 tests/integration/build/html/fixture/tsconfig.json create mode 100644 tests/integration/build/html/html.integration.test.ts create mode 100644 tests/integration/build/html/scenarios/config-array.ts create mode 100644 tests/integration/build/html/scenarios/config-function-array.ts create mode 100644 tests/integration/build/html/scenarios/config-function-object.ts create mode 100644 tests/integration/build/html/scenarios/config-object.ts create mode 100644 tests/integration/build/html/scenarios/options-mixed.ts create mode 100644 tests/integration/build/html/scenarios/page-conflicting-hash.ts create mode 100644 tests/integration/build/html/scenarios/page-invalid-metas.ts create mode 100644 tests/integration/types/fixtures/html/options.ts create mode 100644 tests/integration/types/html.integration.test.ts diff --git a/package-lock.json b/package-lock.json index 6657145e..d3fde11a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@rspack/cli": "^1.7.5", "@rspack/core": "^1.7.5", "@rspack/lite-tapable": "^1.1.0", + "@rspackjs/plugin-html-tags": "^1.0.0", "@svgr/webpack": "^8.1.0", "c12": "^3.3.2", "cac": "^6.7.14", @@ -24,7 +25,6 @@ "css-loader": "^7.1.2", "file-loader": "^6.2.0", "get-value": "^4.0.1", - "html-rspack-tags-plugin": "^0.0.3", "js-yaml": "^4.1.1", "json-stringify-deterministic": "^1.0.12", "lodash": "4.18.1", @@ -93,7 +93,7 @@ "utility-types": "^3.11.0" }, "engines": { - "node": ">=22.0.0" + "node": ">=22.12.0" }, "optionalDependencies": { "@esbuild/darwin-arm64": "0.20.2" @@ -6741,6 +6741,22 @@ "win32" ] }, + "node_modules/@rspackjs/plugin-html-tags": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@rspackjs/plugin-html-tags/-/plugin-html-tags-1.0.0.tgz", + "integrity": "sha512-WhtWEmq7hFg2s5DqKRzndurAcLjh2zvnfR594hvYY28j9Eb8rNtZDW+r+WqeLZlhzLuLI3jdaPPJWdMWyrdIJA==", + "license": "MIT", + "dependencies": { + "glob": "^11.0.0", + "minimatch": "^10.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rspack/core": "^1.0.13 || ^2" + } + }, "node_modules/@rushstack/node-core-library": { "version": "5.23.1", "resolved": "https://registry.npmjs.org/@rushstack/node-core-library/-/node-core-library-5.23.1.tgz", @@ -9967,12 +9983,6 @@ "node": ">= 0.6" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "license": "MIT" - }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", @@ -12192,66 +12202,6 @@ "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", "license": "MIT" }, - "node_modules/html-rspack-tags-plugin": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/html-rspack-tags-plugin/-/html-rspack-tags-plugin-0.0.3.tgz", - "integrity": "sha512-EJpjOXtCXYRSCI8ZLjBxyWZ3oD8KWUz8kWNAk5hq/U+7E3qq5NXfuTGIfEJenDbXXOACCM6w8AIRGc6pVLFnSg==", - "license": "MIT", - "dependencies": { - "glob": "^7.2.0", - "minimatch": "^3.0.4", - "slash": "^3.0.0" - }, - "engines": { - "node": ">=16" - }, - "peerDependencies": { - "@rspack/core": "^1.0.13", - "html-webpack-plugin": "^5.6.0" - }, - "peerDependenciesMeta": { - "html-webpack-plugin": { - "optional": true - } - } - }, - "node_modules/html-rspack-tags-plugin/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" - }, - "node_modules/html-rspack-tags-plugin/node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/html-rspack-tags-plugin/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/html-rspack-tags-plugin/node_modules/slash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", - "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/htmlparser2": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz", diff --git a/package.json b/package.json index 474bbeab..6cfbca49 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "types" ], "engines": { - "node": ">=22.0.0" + "node": ">=22.12.0" }, "bin": { "adnbn": "bin/adnbn.js" @@ -140,6 +140,7 @@ "@rspack/cli": "^1.7.5", "@rspack/core": "^1.7.5", "@rspack/lite-tapable": "^1.1.0", + "@rspackjs/plugin-html-tags": "^1.0.0", "@svgr/webpack": "^8.1.0", "c12": "^3.3.2", "cac": "^6.7.14", @@ -147,7 +148,6 @@ "css-loader": "^7.1.2", "file-loader": "^6.2.0", "get-value": "^4.0.1", - "html-rspack-tags-plugin": "^0.0.3", "js-yaml": "^4.1.1", "json-stringify-deterministic": "^1.0.12", "lodash": "4.18.1", diff --git a/scripts/check-node-version.js b/scripts/check-node-version.js index da07a6b4..73be4aca 100644 --- a/scripts/check-node-version.js +++ b/scripts/check-node-version.js @@ -8,7 +8,7 @@ const __dirname = dirname(__filename); const pkgPath = resolve(__dirname, "../package.json"); const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); -const required = pkg.engines?.node || ">=22.0.0"; +const required = pkg.engines?.node || ">=22.12.0"; const current = process.version; if (!semver.satisfies(semver.clean(current), required)) { diff --git a/src/cli/entrypoint/parser/OffscreenParser.test.ts b/src/cli/entrypoint/parser/OffscreenParser.test.ts index 96405d72..d9bcbc64 100644 --- a/src/cli/entrypoint/parser/OffscreenParser.test.ts +++ b/src/cli/entrypoint/parser/OffscreenParser.test.ts @@ -17,6 +17,14 @@ const file = (...parts: string[]) => { describe("OffscreenParser", () => { describe("options", () => { + test("ignores a sibling view definition", () => { + expect(parser.options(file("options", "foreign-definition", "audio.offscreen.ts"))).toEqual({}); + }); + + test("leaves omitted options for later defaults", () => { + expect(parser.options(file("options", "defaults", "audio.offscreen.ts"))).toEqual({}); + }); + test("parses defineOffscreen with its name, reasons and inherited view and CSP options", () => { expect(parser.options(file("options", "full", "audio.offscreen.ts"))).toEqual({ name: "audio", @@ -28,6 +36,15 @@ describe("OffscreenParser", () => { includeBrowser: ["chrome"], csp: {sources: {connect: ["'self'", "https://api.example.com"]}}, links: "extra.css", + append: false, + useHash: true, + usePublicPath: false, + prependExternals: false, + jsExtensions: [".js", ".module"], + cssExtensions: ".css", + tags: [{path: "runtime.module", type: "js"}], + scripts: "extra.js", + metas: {attributes: {name: "offscreen-test", content: "enabled"}}, }); }); diff --git a/src/cli/entrypoint/parser/OptionsParser.test.ts b/src/cli/entrypoint/parser/OptionsParser.test.ts index 081e3f66..8151505e 100644 --- a/src/cli/entrypoint/parser/OptionsParser.test.ts +++ b/src/cli/entrypoint/parser/OptionsParser.test.ts @@ -21,6 +21,10 @@ const file = (...parts: string[]) => { const parseOptions = (...parts: string[]) => parser.options(file(...parts)); describe("OptionsParser", () => { + test("ignores a sibling view definition", () => { + expect(parseOptions("options", "foreign-definition", "options.ts")).toEqual({}); + }); + test("parses defineOptions with permissions and inherited view, CSP and build filters", () => { expect(parseOptions("options", "full", "options.ts")).toEqual({ openInTab: true, @@ -48,6 +52,13 @@ describe("OptionsParser", () => { optionalHostPermissions: ["https://other.test/*"], scripts: "extra.js", links: "extra.css", + append: false, + useHash: true, + usePublicPath: false, + prependExternals: false, + jsExtensions: [".js", ".module"], + cssExtensions: ".css", + tags: [{path: "runtime.module", type: "js"}], metas: { attributes: { name: "options-test", diff --git a/src/cli/entrypoint/parser/PageParser.test.ts b/src/cli/entrypoint/parser/PageParser.test.ts index 667f268e..11994b31 100644 --- a/src/cli/entrypoint/parser/PageParser.test.ts +++ b/src/cli/entrypoint/parser/PageParser.test.ts @@ -21,9 +21,30 @@ const file = (...parts: string[]) => { const parseOptions = (...parts: string[]) => parser.options(file(...parts)); describe("PageParser", () => { - test("parses view CSP options from a real entrypoint file", () => { - expect(parseOptions("options", "csp", "page.ts")).toEqual({ + test("parses definePage with its name, matches and inherited view, HTML, CSP and build options", () => { + expect(parseOptions("options", "full", "page.ts")).toEqual({ name: "help", + matches: ["https://example.com/*"], + as: "help-view", + title: "Help", + template: "./template.html", + includeApp: ["app"], + excludeApp: ["legacy"], + includeBrowser: ["chrome"], + excludeBrowser: ["firefox"], + mode: "production", + debug: true, + manifestVersion: 3, + append: false, + useHash: true, + usePublicPath: false, + prependExternals: false, + jsExtensions: [".js", ".module"], + cssExtensions: ".css", + scripts: "extra.js", + links: "extra.css", + tags: [{path: "runtime.module", type: "js"}], + metas: {attributes: {name: "page-test", content: "enabled"}}, csp: { wasm: true, sources: { @@ -36,4 +57,16 @@ describe("PageParser", () => { }, }); }); + + test("leaves omitted page options for later defaults", () => { + expect(parseOptions("options", "defaults", "page.ts")).toEqual({}); + }); + + test("ignores a sibling view definition", () => { + expect(parseOptions("options", "foreign-definition", "page.ts")).toEqual({}); + }); + + test.each(["name", "matches"])("rejects an invalid %s value", field => { + expect(() => parseOptions("invalid", `${field}.ts`)).toThrow(`Invalid options ${field}`); + }); }); diff --git a/src/cli/entrypoint/parser/PopupParser.test.ts b/src/cli/entrypoint/parser/PopupParser.test.ts index a6fbb0e5..f8ea35b1 100644 --- a/src/cli/entrypoint/parser/PopupParser.test.ts +++ b/src/cli/entrypoint/parser/PopupParser.test.ts @@ -16,6 +16,14 @@ const parseOptions = (...parts: string[]) => { }; describe("PopupParser", () => { + test("ignores a sibling view definition", () => { + expect(parseOptions("options", "foreign-definition", "popup.ts")).toEqual({}); + }); + + test("leaves omitted options for later defaults", () => { + expect(parseOptions("options", "defaults", "popup.ts")).toEqual({}); + }); + test("parses definePopup with its icon, apply flag, permissions and inherited view and CSP options", () => { expect(parseOptions("options", "full", "popup.ts")).toEqual({ as: "panel", @@ -30,6 +38,15 @@ describe("PopupParser", () => { hostPermissions: ["https://*.example.com/*"], optionalHostPermissions: ["https://other.test/*"], links: "extra.css", + append: false, + useHash: true, + usePublicPath: false, + prependExternals: false, + jsExtensions: [".js", ".module"], + cssExtensions: ".css", + tags: [{path: "runtime.module", type: "js"}], + scripts: "extra.js", + metas: {attributes: {name: "popup-test", content: "enabled"}}, }); }); diff --git a/src/cli/entrypoint/parser/SandboxParser.test.ts b/src/cli/entrypoint/parser/SandboxParser.test.ts index 9f6c70df..0978d9ff 100644 --- a/src/cli/entrypoint/parser/SandboxParser.test.ts +++ b/src/cli/entrypoint/parser/SandboxParser.test.ts @@ -23,6 +23,14 @@ const parseContract = (...parts: string[]) => parser.contract(file(...parts)); describe("SandboxParser", () => { describe("options", () => { + test("ignores a sibling view definition", () => { + expect(parseOptions("options", "foreign-definition", "sandbox.ts")).toEqual({}); + }); + + test("leaves omitted options for later defaults", () => { + expect(parseOptions("options", "defaults", "sandbox.ts")).toEqual({}); + }); + test("parses full sandbox options from a real entrypoint file", () => { expect(parseOptions("options", "full", "sandbox.ts")).toEqual({ as: "unsafe-parser-frame", @@ -55,6 +63,13 @@ describe("SandboxParser", () => { }, scripts: "extra.js", links: "extra.css", + append: false, + useHash: true, + usePublicPath: false, + prependExternals: false, + jsExtensions: [".js", ".module"], + cssExtensions: ".css", + tags: [{path: "runtime.module", type: "js"}], metas: { attributes: { name: "sandbox-test", diff --git a/src/cli/entrypoint/parser/SidebarParser.test.ts b/src/cli/entrypoint/parser/SidebarParser.test.ts index a9afa0c6..ca793acb 100644 --- a/src/cli/entrypoint/parser/SidebarParser.test.ts +++ b/src/cli/entrypoint/parser/SidebarParser.test.ts @@ -16,6 +16,14 @@ const parseOptions = (...parts: string[]) => { }; describe("SidebarParser", () => { + test("ignores a sibling view definition", () => { + expect(parseOptions("options", "foreign-definition", "sidebar.ts")).toEqual({}); + }); + + test("leaves omitted options for later defaults", () => { + expect(parseOptions("options", "defaults", "sidebar.ts")).toEqual({}); + }); + test("parses defineSidebar with its icon, apply flag, permissions and inherited view and CSP options", () => { expect(parseOptions("options", "full", "sidebar.ts")).toEqual({ as: "panel", @@ -30,6 +38,15 @@ describe("SidebarParser", () => { hostPermissions: ["https://*.example.com/*"], optionalHostPermissions: ["https://other.test/*"], links: "extra.css", + append: false, + useHash: true, + usePublicPath: false, + prependExternals: false, + jsExtensions: [".js", ".module"], + cssExtensions: ".css", + tags: [{path: "runtime.module", type: "js"}], + scripts: "extra.js", + metas: {attributes: {name: "sidebar-test", content: "enabled"}}, }); }); diff --git a/src/cli/entrypoint/parser/ViewParser.test.ts b/src/cli/entrypoint/parser/ViewParser.test.ts new file mode 100644 index 00000000..8076d8cb --- /dev/null +++ b/src/cli/entrypoint/parser/ViewParser.test.ts @@ -0,0 +1,108 @@ +import path from "path"; + +import ViewParser from "./ViewParser"; + +import type {ReadonlyConfig} from "@typing/config"; +import type {ViewEntrypointOptions} from "@typing/view"; + +class TestViewParser extends ViewParser { + protected definition(): string { + return "definePage"; + } +} + +const rootDir = path.resolve(__dirname, "../../../.."); +const fixtures = path.join(__dirname, "tests", "fixtures", "view"); +const parser = new TestViewParser({rootDir} as ReadonlyConfig); +const parseOptions = (...parts: string[]) => { + const file = path.join(fixtures, ...parts); + + return parser.options({file, import: file}); +}; + +describe("ViewParser", () => { + test("reads the view options, HTML flags and static tag fields from a real entrypoint", () => { + expect(parseOptions("options", "full", "page.ts")).toEqual({ + as: "panel", + title: "HTML options", + template: "./template.html", + append: false, + useHash: true, + usePublicPath: false, + prependExternals: false, + jsExtensions: [".js", ".module"], + cssExtensions: ".css", + links: ["theme.css", {path: "icon.ico", sourcePath: "icon.ico", attributes: {rel: "icon"}}], + scripts: ["script.js", {path: "vendor.js", external: {packageName: "vendor", variableName: "Vendor"}}], + tags: ["extra.css", {path: "extra.module", type: "js"}], + metas: { + attributes: {name: "description", content: "test", hidden: false, priority: 1}, + path: "meta.png", + glob: "*.png", + globPath: "images", + globFlatten: true, + }, + }); + }); + + test("reads hash and publicPath string shortcuts", () => { + expect(parseOptions("options", "shortcuts", "page.ts")).toEqual({hash: "v1", publicPath: "/"}); + }); + + test("reads hash and publicPath boolean shortcuts", () => { + expect(parseOptions("options", "boolean-shortcuts", "page.ts")).toEqual({hash: false, publicPath: true}); + }); + + test("leaves omitted view options for later defaults and ignores the runtime render function", () => { + expect(parseOptions("options", "defaults", "page.ts")).toEqual({}); + }); + + test("reads a meta object", () => { + expect(parseOptions("options", "object", "page.ts")).toEqual({metas: {attributes: {charset: "utf-8"}}}); + }); + + test("reads an array of meta objects", () => { + expect(parseOptions("options", "array", "page.ts")).toEqual({ + metas: [{attributes: {name: "description", content: "Static metadata"}}], + }); + }); + + test("reads an empty meta array", () => { + expect(parseOptions("options", "empty-array", "page.ts")).toEqual({metas: []}); + }); + + test.each([ + ["meta-string", /Expected a meta object with nonempty attributes, or an array of such objects/], + ["meta-strings", /Expected a meta object with nonempty attributes, or an array of such objects/], + ["meta-mixed", /Expected a meta object with nonempty attributes, or an array of such objects/], + ["meta-empty", /nonempty attributes object/], + ["meta-missing", /meta object with nonempty attributes/], + ["meta-null", /meta object with nonempty attributes/], + ["meta-invalid-attribute", /meta object with nonempty attributes/], + ["meta-nonfinite-attribute", /Number must be finite/], + ])("rejects %s and identifies its field and source file", (scenario, message) => { + expect(() => parseOptions("invalid", `${scenario}.ts`)).toThrow("Invalid options metas"); + expect(() => parseOptions("invalid", `${scenario}.ts`)).toThrow(`${scenario}.ts`); + expect(() => parseOptions("invalid", `${scenario}.ts`)).toThrow(message); + }); + + test.each(["as", "title", "template"])("rejects an empty %s value", field => { + expect(() => parseOptions("invalid", `${field}.ts`)).toThrow(`Invalid options ${field}`); + }); + + test("rejects the files selector instead of dropping it", () => { + expect(() => parseOptions("invalid", "files.ts")).toThrow( + /Invalid options files.*files.ts.*entrypoint selects its HTML file/ + ); + }); + + test.each([ + ["add-hash", "addHash"], + ["add-public-path", "addPublicPath"], + ["hash", "hash"], + ["public-path", "publicPath"], + ["nested-function", "scripts[0].hash"], + ])("rejects the nonstatic %s option through the real reader", (scenario, field) => { + expect(() => parseOptions("invalid", `${scenario}.ts`)).toThrow(`${field} must be statically known`); + }); +}); diff --git a/src/cli/entrypoint/parser/ViewParser.ts b/src/cli/entrypoint/parser/ViewParser.ts index ae88d03b..7062c5a2 100644 --- a/src/cli/entrypoint/parser/ViewParser.ts +++ b/src/cli/entrypoint/parser/ViewParser.ts @@ -1,13 +1,13 @@ import {z} from "zod"; import AbstractParser from "./AbstractParser"; -import {HtmlOptionsSchema} from "./schemas/html"; +import {HtmlEntrypointOptionsSchema} from "./schemas/html"; import {ViewEntrypointOptions} from "@typing/view"; -export default abstract class extends AbstractParser { +export default abstract class ViewParser extends AbstractParser { protected schema(): typeof this.CommonPropertiesSchema { - return this.CommonPropertiesSchema.merge(HtmlOptionsSchema).extend({ + return this.CommonPropertiesSchema.merge(HtmlEntrypointOptionsSchema).extend({ as: z.string().nonempty().optional(), title: z.string().nonempty().optional(), template: z.string().nonempty().optional(), diff --git a/src/cli/entrypoint/parser/index.ts b/src/cli/entrypoint/parser/index.ts index da5bde4d..1545b952 100644 --- a/src/cli/entrypoint/parser/index.ts +++ b/src/cli/entrypoint/parser/index.ts @@ -12,4 +12,5 @@ export {default as SandboxParser} from "./SandboxParser"; export {default as ServiceParser} from "./ServiceParser"; export {default as SidebarParser} from "./SidebarParser"; export {default as OffscreenParser} from "./OffscreenParser"; -export {HtmlOptionKeys} from "./schemas/html"; + +export {HtmlEntrypointOptionKeys} from "./schemas/html"; diff --git a/src/cli/entrypoint/parser/override-parsers.test.ts b/src/cli/entrypoint/parser/override-parsers.test.ts index 9ed3f7ab..ca8e21b9 100644 --- a/src/cli/entrypoint/parser/override-parsers.test.ts +++ b/src/cli/entrypoint/parser/override-parsers.test.ts @@ -49,6 +49,13 @@ describe.each([ optionalHostPermissions: ["https://other.test/*"], scripts: "extra.js", links: "extra.css", + append: false, + useHash: true, + usePublicPath: false, + prependExternals: false, + jsExtensions: [".js", ".module"], + cssExtensions: ".css", + tags: [{path: "runtime.module", type: "js"}], metas: { attributes: { name: `${type}-test`, @@ -58,6 +65,10 @@ describe.each([ }); }); + test("leaves omitted options for later defaults", () => { + expect(parseOptions("defaults")).toEqual({}); + }); + test("ignores options wrapped in another override definition", () => { expect(parseOptions("foreign-definition")).toEqual({}); }); diff --git a/src/cli/entrypoint/parser/schemas/html.ts b/src/cli/entrypoint/parser/schemas/html.ts index 2cb6efce..2433c459 100644 --- a/src/cli/entrypoint/parser/schemas/html.ts +++ b/src/cli/entrypoint/parser/schemas/html.ts @@ -1,87 +1,64 @@ import {z} from "zod"; -const HtmlTypeStringSchema = z.enum(["css", "js"]); +const AttributesSchema = z.record(z.union([z.string(), z.boolean(), z.number().finite()])); -const HtmlAttributesObjectSchema = z.record(z.string(), z.union([z.string(), z.boolean(), z.number()])); - -const HtmlAddHashFunctionSchema = z.function().args(z.string(), z.string()).returns(z.string()); - -const HtmlAddPublicPathFunctionSchema = z.function().args(z.string(), z.string()).returns(z.string()); - -const HtmlCommonOptionsSchema = z.object({ +const CommonSchema = z.object({ append: z.boolean().optional(), useHash: z.boolean().optional(), - addHash: HtmlAddHashFunctionSchema.optional(), - hash: z.union([z.boolean(), z.string(), HtmlAddHashFunctionSchema]).optional(), + addHash: z.never({invalid_type_error: "Configure addHash in config.html"}).optional(), + hash: z.union([z.boolean(), z.string()]).optional(), usePublicPath: z.boolean().optional(), - addPublicPath: HtmlAddPublicPathFunctionSchema.optional(), - publicPath: z.union([z.boolean(), z.string(), HtmlAddPublicPathFunctionSchema]).optional(), + addPublicPath: z.never({invalid_type_error: "Configure addPublicPath in config.html"}).optional(), + publicPath: z.union([z.boolean(), z.string()]).optional(), }); -const HtmlExternalObjectSchema = z.object({ - packageName: z.string(), - variableName: z.string(), -}); - -const HtmlBaseTagOptionsSchema = HtmlCommonOptionsSchema.extend({ +const BaseTagSchema = CommonSchema.extend({ glob: z.string().optional(), globPath: z.string().optional(), globFlatten: z.boolean().optional(), sourcePath: z.string().optional(), }); -const HtmlLinkTagOptionsSchema = HtmlBaseTagOptionsSchema.extend({ - path: z.string(), - attributes: HtmlAttributesObjectSchema.optional(), -}); - -const HtmlScriptTagOptionsSchema = HtmlBaseTagOptionsSchema.extend({ +const LinkSchema = BaseTagSchema.extend({ path: z.string(), - attributes: HtmlAttributesObjectSchema.optional(), - external: HtmlExternalObjectSchema.optional(), -}); - -const HtmlMaybeLinkTagOptionsSchema = HtmlLinkTagOptionsSchema.extend({ - type: HtmlTypeStringSchema.optional(), + attributes: AttributesSchema.optional(), + external: z.never({invalid_type_error: "external is only supported for scripts"}).optional(), }); -const HtmlMaybeScriptTagOptionsSchema = HtmlScriptTagOptionsSchema.extend({ - type: HtmlTypeStringSchema.optional(), +const ScriptSchema = LinkSchema.extend({ + external: z.object({packageName: z.string(), variableName: z.string()}).optional(), }); -const HtmlMetaTagOptionsSchema = HtmlBaseTagOptionsSchema.extend({ +const MetaSchema = BaseTagSchema.extend({ path: z.string().optional(), - attributes: HtmlAttributesObjectSchema, + attributes: AttributesSchema.refine(attributes => Object.keys(attributes).length > 0, { + message: "A meta tag requires a nonempty attributes object", + }), + external: z.never({invalid_type_error: "external is only supported for scripts"}).optional(), }); -/** - * Options a view passes to the HTML tags plugin. - * - * The same list validates what an entrypoint may declare and selects what reaches the HTML plugin, - * so manifest-only options such as permissions or CSP never leak into it. - */ -export const HtmlOptionsSchema = HtmlCommonOptionsSchema.extend({ - append: z.boolean().optional(), +const TagSchema = ScriptSchema.extend({type: z.enum(["css", "js"]).optional()}); +const LinkValueSchema = z.union([z.string(), LinkSchema]); +const ScriptValueSchema = z.union([z.string(), ScriptSchema]); +const TagValueSchema = z.union([z.string(), TagSchema]); + +export const HtmlEntrypointOptionsSchema = CommonSchema.extend({ prependExternals: z.boolean().optional(), jsExtensions: z.union([z.string(), z.array(z.string())]).optional(), cssExtensions: z.union([z.string(), z.array(z.string())]).optional(), - tags: z - .union([ - z.string(), - HtmlMaybeLinkTagOptionsSchema, - HtmlMaybeScriptTagOptionsSchema, - z.array(z.union([z.string(), HtmlMaybeLinkTagOptionsSchema, HtmlMaybeScriptTagOptionsSchema])), - ]) - .optional(), - links: z - .union([z.string(), HtmlLinkTagOptionsSchema, z.array(z.union([z.string(), HtmlLinkTagOptionsSchema]))]) - .optional(), - scripts: z - .union([z.string(), HtmlScriptTagOptionsSchema, z.array(z.union([z.string(), HtmlScriptTagOptionsSchema]))]) + files: z + .never({invalid_type_error: "The entrypoint selects its HTML file; configure files in config.html"}) .optional(), + links: z.union([LinkValueSchema, z.array(LinkValueSchema)]).optional(), + scripts: z.union([ScriptValueSchema, z.array(ScriptValueSchema)]).optional(), + tags: z.union([TagValueSchema, z.array(TagValueSchema)]).optional(), metas: z - .union([z.string(), HtmlMetaTagOptionsSchema, z.array(z.union([z.string(), HtmlMetaTagOptionsSchema]))]) + .union([MetaSchema, z.array(MetaSchema)], { + errorMap: () => ({ + message: "Expected a meta object with nonempty attributes, or an array of such objects", + }), + }) .optional(), }); -export const HtmlOptionKeys: readonly string[] = Object.keys(HtmlOptionsSchema.shape); +export const HtmlEntrypointOptionKeys = Object.keys(HtmlEntrypointOptionsSchema.shape); diff --git a/src/cli/entrypoint/parser/tests/fixtures/bookmarks/options/defaults/bookmarks.ts b/src/cli/entrypoint/parser/tests/fixtures/bookmarks/options/defaults/bookmarks.ts new file mode 100644 index 00000000..47bc2a9c --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/bookmarks/options/defaults/bookmarks.ts @@ -0,0 +1,3 @@ +import {defineBookmarks} from "adnbn"; + +export default defineBookmarks({}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/bookmarks/options/full/bookmarks.ts b/src/cli/entrypoint/parser/tests/fixtures/bookmarks/options/full/bookmarks.ts index 2b9ec7c1..89740b6c 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/bookmarks/options/full/bookmarks.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/bookmarks/options/full/bookmarks.ts @@ -24,6 +24,13 @@ export default defineBookmarks({ optionalHostPermissions: ["https://other.test/*"], scripts: "extra.js", links: "extra.css", + append: false, + useHash: true, + usePublicPath: false, + prependExternals: false, + jsExtensions: [".js", ".module"], + cssExtensions: ".css", + tags: [{path: "runtime.module", type: "js"}], metas: { attributes: { name: "bookmarks-test", diff --git a/src/cli/entrypoint/parser/tests/fixtures/history/options/defaults/history.ts b/src/cli/entrypoint/parser/tests/fixtures/history/options/defaults/history.ts new file mode 100644 index 00000000..e403cfd9 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/history/options/defaults/history.ts @@ -0,0 +1,3 @@ +import {defineHistory} from "adnbn"; + +export default defineHistory({}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/history/options/full/history.ts b/src/cli/entrypoint/parser/tests/fixtures/history/options/full/history.ts index dc301db5..1c59ca18 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/history/options/full/history.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/history/options/full/history.ts @@ -24,6 +24,13 @@ export default defineHistory({ optionalHostPermissions: ["https://other.test/*"], scripts: "extra.js", links: "extra.css", + append: false, + useHash: true, + usePublicPath: false, + prependExternals: false, + jsExtensions: [".js", ".module"], + cssExtensions: ".css", + tags: [{path: "runtime.module", type: "js"}], metas: { attributes: { name: "history-test", diff --git a/src/cli/entrypoint/parser/tests/fixtures/newtab/options/defaults/newtab.ts b/src/cli/entrypoint/parser/tests/fixtures/newtab/options/defaults/newtab.ts new file mode 100644 index 00000000..a44cb383 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/newtab/options/defaults/newtab.ts @@ -0,0 +1,3 @@ +import {defineNewtab} from "adnbn"; + +export default defineNewtab({}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/newtab/options/full/newtab.ts b/src/cli/entrypoint/parser/tests/fixtures/newtab/options/full/newtab.ts index bac4dfb3..2b6257d2 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/newtab/options/full/newtab.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/newtab/options/full/newtab.ts @@ -24,6 +24,13 @@ export default defineNewtab({ optionalHostPermissions: ["https://other.test/*"], scripts: "extra.js", links: "extra.css", + append: false, + useHash: true, + usePublicPath: false, + prependExternals: false, + jsExtensions: [".js", ".module"], + cssExtensions: ".css", + tags: [{path: "runtime.module", type: "js"}], metas: { attributes: { name: "newtab-test", diff --git a/src/cli/entrypoint/parser/tests/fixtures/offscreen/options/defaults/audio.offscreen.ts b/src/cli/entrypoint/parser/tests/fixtures/offscreen/options/defaults/audio.offscreen.ts new file mode 100644 index 00000000..c5a0b0ed --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/offscreen/options/defaults/audio.offscreen.ts @@ -0,0 +1,5 @@ +import {defineOffscreen} from "adnbn"; + +export default defineOffscreen({ + init: () => ({}), +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/offscreen/options/foreign-definition/audio.offscreen.ts b/src/cli/entrypoint/parser/tests/fixtures/offscreen/options/foreign-definition/audio.offscreen.ts new file mode 100644 index 00000000..9ee428e6 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/offscreen/options/foreign-definition/audio.offscreen.ts @@ -0,0 +1,3 @@ +import {definePage} from "adnbn"; + +export default definePage({title: "Other view"}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/offscreen/options/full/audio.offscreen.ts b/src/cli/entrypoint/parser/tests/fixtures/offscreen/options/full/audio.offscreen.ts index 3e33e7d2..546058af 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/offscreen/options/full/audio.offscreen.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/offscreen/options/full/audio.offscreen.ts @@ -10,5 +10,14 @@ export default defineOffscreen({ includeBrowser: [Browser.Chrome], csp: {sources: {connect: [CspSource.Self, "https://api.example.com"]}}, links: "extra.css", + append: false, + useHash: true, + usePublicPath: false, + prependExternals: false, + jsExtensions: [".js", ".module"], + cssExtensions: ".css", + tags: [{path: "runtime.module", type: "js"}], + scripts: "extra.js", + metas: {attributes: {name: "offscreen-test", content: "enabled"}}, init: () => ({play: (url: string): void => console.log(url)}), }); diff --git a/src/cli/entrypoint/parser/tests/fixtures/options/options/foreign-definition/options.ts b/src/cli/entrypoint/parser/tests/fixtures/options/options/foreign-definition/options.ts new file mode 100644 index 00000000..9ee428e6 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/options/options/foreign-definition/options.ts @@ -0,0 +1,3 @@ +import {definePage} from "adnbn"; + +export default definePage({title: "Other view"}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/options/options/full/options.ts b/src/cli/entrypoint/parser/tests/fixtures/options/options/full/options.ts index fb9ae3b5..a7b8674e 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/options/options/full/options.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/options/options/full/options.ts @@ -28,6 +28,13 @@ export default defineOptions({ optionalHostPermissions: ["https://other.test/*"], scripts: "extra.js", links: "extra.css", + append: false, + useHash: true, + usePublicPath: false, + prependExternals: false, + jsExtensions: [".js", ".module"], + cssExtensions: ".css", + tags: [{path: "runtime.module", type: "js"}], metas: { attributes: { name: "options-test", diff --git a/src/cli/entrypoint/parser/tests/fixtures/page/invalid/matches.ts b/src/cli/entrypoint/parser/tests/fixtures/page/invalid/matches.ts new file mode 100644 index 00000000..90a1421e --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/page/invalid/matches.ts @@ -0,0 +1,6 @@ +import {definePage} from "adnbn"; + +export default definePage({ + // @ts-expect-error: Page matches must be an array of patterns. + matches: "https://example.com/*", +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/page/invalid/name.ts b/src/cli/entrypoint/parser/tests/fixtures/page/invalid/name.ts new file mode 100644 index 00000000..e836a23b --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/page/invalid/name.ts @@ -0,0 +1,6 @@ +import {definePage} from "adnbn"; + +export default definePage({ + // @ts-expect-error: A page name must be a string. + name: 42, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/page/options/csp/page.ts b/src/cli/entrypoint/parser/tests/fixtures/page/options/csp/page.ts deleted file mode 100644 index 3500e9bc..00000000 --- a/src/cli/entrypoint/parser/tests/fixtures/page/options/csp/page.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {CspSource, definePage} from "adnbn"; - -export default definePage({ - name: "help", - csp: { - wasm: true, - sources: { - connect: [CspSource.Self, "https://api.example.com"], - image: [CspSource.Self, CspSource.Data, CspSource.Blob], - style: [CspSource.Self, CspSource.UnsafeInline], - worker: [CspSource.Blob], - frame: ["https://frame.example.com"], - }, - }, -}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/page/options/defaults/page.ts b/src/cli/entrypoint/parser/tests/fixtures/page/options/defaults/page.ts new file mode 100644 index 00000000..98b0846d --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/page/options/defaults/page.ts @@ -0,0 +1,5 @@ +import {definePage} from "adnbn"; + +export default definePage({ + render: () => "View", +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/page/options/foreign-definition/page.ts b/src/cli/entrypoint/parser/tests/fixtures/page/options/foreign-definition/page.ts new file mode 100644 index 00000000..5e821241 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/page/options/foreign-definition/page.ts @@ -0,0 +1,6 @@ +import {definePopup} from "adnbn"; + +export default definePopup({ + title: "Other view", + render: () => "View", +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/page/options/full/page.ts b/src/cli/entrypoint/parser/tests/fixtures/page/options/full/page.ts new file mode 100644 index 00000000..0d8de9f5 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/page/options/full/page.ts @@ -0,0 +1,36 @@ +import {Browser, CspSource, definePage, Mode} from "adnbn"; + +export default definePage({ + name: "help", + matches: ["https://example.com/*"], + as: "help-view", + title: "Help", + template: "./template.html", + includeApp: ["app"], + excludeApp: ["legacy"], + includeBrowser: [Browser.Chrome], + excludeBrowser: [Browser.Firefox], + mode: Mode.Production, + debug: true, + manifestVersion: 3, + scripts: "extra.js", + links: "extra.css", + append: false, + useHash: true, + usePublicPath: false, + prependExternals: false, + jsExtensions: [".js", ".module"], + cssExtensions: ".css", + tags: [{path: "runtime.module", type: "js"}], + metas: {attributes: {name: "page-test", content: "enabled"}}, + csp: { + wasm: true, + sources: { + connect: [CspSource.Self, "https://api.example.com"], + image: [CspSource.Self, CspSource.Data, CspSource.Blob], + style: [CspSource.Self, CspSource.UnsafeInline], + worker: [CspSource.Blob], + frame: ["https://frame.example.com"], + }, + }, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/popup/options/defaults/popup.ts b/src/cli/entrypoint/parser/tests/fixtures/popup/options/defaults/popup.ts new file mode 100644 index 00000000..4bdd0ef5 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/popup/options/defaults/popup.ts @@ -0,0 +1,3 @@ +import {definePopup} from "adnbn"; + +export default definePopup({}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/popup/options/foreign-definition/popup.ts b/src/cli/entrypoint/parser/tests/fixtures/popup/options/foreign-definition/popup.ts new file mode 100644 index 00000000..9ee428e6 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/popup/options/foreign-definition/popup.ts @@ -0,0 +1,3 @@ +import {definePage} from "adnbn"; + +export default definePage({title: "Other view"}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/popup/options/full/popup.ts b/src/cli/entrypoint/parser/tests/fixtures/popup/options/full/popup.ts index f291ec63..a3c0593d 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/popup/options/full/popup.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/popup/options/full/popup.ts @@ -13,5 +13,14 @@ export default definePopup({ hostPermissions: ["https://*.example.com/*"], optionalHostPermissions: ["https://other.test/*"], links: "extra.css", + append: false, + useHash: true, + usePublicPath: false, + prependExternals: false, + jsExtensions: [".js", ".module"], + cssExtensions: ".css", + tags: [{path: "runtime.module", type: "js"}], + scripts: "extra.js", + metas: {attributes: {name: "popup-test", content: "enabled"}}, render: ({title}) => title, }); diff --git a/src/cli/entrypoint/parser/tests/fixtures/sandbox/options/defaults/sandbox.ts b/src/cli/entrypoint/parser/tests/fixtures/sandbox/options/defaults/sandbox.ts new file mode 100644 index 00000000..d07e80e5 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/sandbox/options/defaults/sandbox.ts @@ -0,0 +1,5 @@ +import {defineSandbox} from "adnbn"; + +export default defineSandbox({ + init: () => ({}), +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/sandbox/options/foreign-definition/sandbox.ts b/src/cli/entrypoint/parser/tests/fixtures/sandbox/options/foreign-definition/sandbox.ts new file mode 100644 index 00000000..9ee428e6 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/sandbox/options/foreign-definition/sandbox.ts @@ -0,0 +1,3 @@ +import {definePage} from "adnbn"; + +export default definePage({title: "Other view"}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/sandbox/options/full/sandbox.ts b/src/cli/entrypoint/parser/tests/fixtures/sandbox/options/full/sandbox.ts index 2339d5d9..09bf82f5 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/sandbox/options/full/sandbox.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/sandbox/options/full/sandbox.ts @@ -31,6 +31,13 @@ export default defineSandbox({ }, scripts: "extra.js", links: "extra.css", + append: false, + useHash: true, + usePublicPath: false, + prependExternals: false, + jsExtensions: [".js", ".module"], + cssExtensions: ".css", + tags: [{path: "runtime.module", type: "js"}], metas: { attributes: { name: "sandbox-test", diff --git a/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/defaults/sidebar.ts b/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/defaults/sidebar.ts new file mode 100644 index 00000000..7a280608 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/defaults/sidebar.ts @@ -0,0 +1,3 @@ +import {defineSidebar} from "adnbn"; + +export default defineSidebar({}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/foreign-definition/sidebar.ts b/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/foreign-definition/sidebar.ts new file mode 100644 index 00000000..9ee428e6 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/foreign-definition/sidebar.ts @@ -0,0 +1,3 @@ +import {definePage} from "adnbn"; + +export default definePage({title: "Other view"}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/full/sidebar.ts b/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/full/sidebar.ts index e7e1eb80..0843ee54 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/full/sidebar.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/full/sidebar.ts @@ -13,5 +13,14 @@ export default defineSidebar({ hostPermissions: ["https://*.example.com/*"], optionalHostPermissions: ["https://other.test/*"], links: "extra.css", + append: false, + useHash: true, + usePublicPath: false, + prependExternals: false, + jsExtensions: [".js", ".module"], + cssExtensions: ".css", + tags: [{path: "runtime.module", type: "js"}], + scripts: "extra.js", + metas: {attributes: {name: "sidebar-test", content: "enabled"}}, render: ({title}) => title, }); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/invalid/add-hash.ts b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/add-hash.ts new file mode 100644 index 00000000..391869dc --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/add-hash.ts @@ -0,0 +1,6 @@ +import {definePage} from "adnbn"; + +export default definePage({ + // @ts-expect-error: HTML callbacks belong to config.html, not static entrypoint options. + addHash: (asset: string) => asset, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/invalid/add-public-path.ts b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/add-public-path.ts new file mode 100644 index 00000000..0f3da2c4 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/add-public-path.ts @@ -0,0 +1,6 @@ +import {definePage} from "adnbn"; + +export default definePage({ + // @ts-expect-error: HTML callbacks belong to config.html, not static entrypoint options. + addPublicPath: (asset: string) => asset, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/invalid/as.ts b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/as.ts new file mode 100644 index 00000000..1036070f --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/as.ts @@ -0,0 +1,5 @@ +import {definePage} from "adnbn"; + +export default definePage({ + as: "", +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/invalid/files.ts b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/files.ts new file mode 100644 index 00000000..ba066153 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/files.ts @@ -0,0 +1,6 @@ +import {definePage} from "adnbn"; + +export default definePage({ + // @ts-expect-error: The entrypoint selects its HTML file; this fixture verifies rejection. + files: ["another.html"], +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/invalid/hash.ts b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/hash.ts new file mode 100644 index 00000000..ad8ec07d --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/hash.ts @@ -0,0 +1,6 @@ +import {definePage} from "adnbn"; + +export default definePage({ + // @ts-expect-error: Entrypoint hash must be a static boolean or string. + hash: (asset: string) => asset, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-empty.ts b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-empty.ts new file mode 100644 index 00000000..c331de57 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-empty.ts @@ -0,0 +1,5 @@ +import {definePage} from "adnbn"; + +export default definePage({ + metas: {attributes: {}}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-invalid-attribute.ts b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-invalid-attribute.ts new file mode 100644 index 00000000..eb278d6e --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-invalid-attribute.ts @@ -0,0 +1,6 @@ +import {definePage} from "adnbn"; + +export default definePage({ + // @ts-expect-error: Meta attribute values must be strings, booleans or numbers. + metas: {attributes: {content: {value: "test"}}}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-missing.ts b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-missing.ts new file mode 100644 index 00000000..76d29ace --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-missing.ts @@ -0,0 +1,6 @@ +import {definePage} from "adnbn"; + +export default definePage({ + // @ts-expect-error: A meta object must declare attributes. + metas: {}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-mixed.ts b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-mixed.ts new file mode 100644 index 00000000..4139e0d6 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-mixed.ts @@ -0,0 +1,6 @@ +import {definePage} from "adnbn"; + +export default definePage({ + // @ts-expect-error: Every meta array item must be an object with attributes. + metas: [{attributes: {charset: "utf-8"}}, "utf-8"], +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-nonfinite-attribute.ts b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-nonfinite-attribute.ts new file mode 100644 index 00000000..3a3ac427 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-nonfinite-attribute.ts @@ -0,0 +1,5 @@ +import {definePage} from "adnbn"; + +export default definePage({ + metas: {attributes: {content: 1e309}}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-null.ts b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-null.ts new file mode 100644 index 00000000..7a2d7892 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-null.ts @@ -0,0 +1,6 @@ +import {definePage} from "adnbn"; + +export default definePage({ + // @ts-expect-error: Meta attributes must be an object, not null. + metas: {attributes: null}, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-string.ts b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-string.ts new file mode 100644 index 00000000..e59756ef --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-string.ts @@ -0,0 +1,6 @@ +import {definePage} from "adnbn"; + +export default definePage({ + // @ts-expect-error: Meta tags require objects with attributes, not string paths. + metas: "utf-8", +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-strings.ts b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-strings.ts new file mode 100644 index 00000000..1d02d586 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/meta-strings.ts @@ -0,0 +1,6 @@ +import {definePage} from "adnbn"; + +export default definePage({ + // @ts-expect-error: Meta tags require objects with attributes, not string paths. + metas: ["utf-8"], +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/invalid/nested-function.ts b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/nested-function.ts new file mode 100644 index 00000000..5b639075 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/nested-function.ts @@ -0,0 +1,6 @@ +import {definePage} from "adnbn"; + +export default definePage({ + // @ts-expect-error: Nested HTML callbacks are also forbidden in static entrypoint options. + scripts: [{path: "script.js", hash: (asset: string) => asset}], +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/invalid/public-path.ts b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/public-path.ts new file mode 100644 index 00000000..c85bbb07 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/public-path.ts @@ -0,0 +1,6 @@ +import {definePage} from "adnbn"; + +export default definePage({ + // @ts-expect-error: Entrypoint publicPath must be a static boolean or string. + publicPath: (asset: string) => asset, +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/invalid/template.ts b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/template.ts new file mode 100644 index 00000000..047dc598 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/template.ts @@ -0,0 +1,5 @@ +import {definePage} from "adnbn"; + +export default definePage({ + template: "", +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/invalid/title.ts b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/title.ts new file mode 100644 index 00000000..ac9851ad --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/invalid/title.ts @@ -0,0 +1,5 @@ +import {definePage} from "adnbn"; + +export default definePage({ + title: "", +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/options/array/page.ts b/src/cli/entrypoint/parser/tests/fixtures/view/options/array/page.ts new file mode 100644 index 00000000..e525da60 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/options/array/page.ts @@ -0,0 +1,6 @@ +import {definePage} from "adnbn"; + +export default definePage({ + metas: [{attributes: {name: "description", content: "Static metadata"}}], + render: () => "Page", +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/options/boolean-shortcuts/page.ts b/src/cli/entrypoint/parser/tests/fixtures/view/options/boolean-shortcuts/page.ts new file mode 100644 index 00000000..ba02b667 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/options/boolean-shortcuts/page.ts @@ -0,0 +1,7 @@ +import {definePage} from "adnbn"; + +export default definePage({ + hash: false, + publicPath: true, + render: () => "View", +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/options/defaults/page.ts b/src/cli/entrypoint/parser/tests/fixtures/view/options/defaults/page.ts new file mode 100644 index 00000000..98b0846d --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/options/defaults/page.ts @@ -0,0 +1,5 @@ +import {definePage} from "adnbn"; + +export default definePage({ + render: () => "View", +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/options/empty-array/page.ts b/src/cli/entrypoint/parser/tests/fixtures/view/options/empty-array/page.ts new file mode 100644 index 00000000..21127c56 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/options/empty-array/page.ts @@ -0,0 +1,6 @@ +import {definePage} from "adnbn"; + +export default definePage({ + metas: [], + render: () => "View", +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/options/full/page.ts b/src/cli/entrypoint/parser/tests/fixtures/view/options/full/page.ts new file mode 100644 index 00000000..91e33966 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/options/full/page.ts @@ -0,0 +1,24 @@ +import {definePage} from "adnbn"; + +export default definePage({ + as: "panel", + title: "HTML options", + template: "./template.html", + append: false, + useHash: true, + usePublicPath: false, + prependExternals: false, + jsExtensions: [".js", ".module"], + cssExtensions: ".css", + links: ["theme.css", {path: "icon.ico", sourcePath: "icon.ico", attributes: {rel: "icon"}}], + scripts: ["script.js", {path: "vendor.js", external: {packageName: "vendor", variableName: "Vendor"}}], + tags: ["extra.css", {path: "extra.module", type: "js"}], + metas: { + attributes: {name: "description", content: "test", hidden: false, priority: 1}, + path: "meta.png", + glob: "*.png", + globPath: "images", + globFlatten: true, + }, + render: () => "View", +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/options/object/page.ts b/src/cli/entrypoint/parser/tests/fixtures/view/options/object/page.ts new file mode 100644 index 00000000..46be4b25 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/options/object/page.ts @@ -0,0 +1,6 @@ +import {definePage} from "adnbn"; + +export default definePage({ + metas: {attributes: {charset: "utf-8"}}, + render: () => "Page", +}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/view/options/shortcuts/page.ts b/src/cli/entrypoint/parser/tests/fixtures/view/options/shortcuts/page.ts new file mode 100644 index 00000000..cf1c7aa1 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/view/options/shortcuts/page.ts @@ -0,0 +1,7 @@ +import {definePage} from "adnbn"; + +export default definePage({ + hash: "v1", + publicPath: "/", + render: () => "View", +}); diff --git a/src/cli/plugins/html/html.test.ts b/src/cli/plugins/html/html.test.ts new file mode 100644 index 00000000..2b2b1cd7 --- /dev/null +++ b/src/cli/plugins/html/html.test.ts @@ -0,0 +1,74 @@ +import {HtmlRspackPlugin} from "@rspack/core"; + +import htmlPlugin from "./index"; + +import type {ReadonlyConfig} from "@typing/config"; + +const configure = (html: unknown, withHtml = true) => { + const bundler = htmlPlugin().bundler; + + if (typeof bundler !== "function") { + throw new Error("Expected an HTML bundler handler"); + } + + return bundler({ + config: {html} as ReadonlyConfig, + rspack: {plugins: withHtml ? [new HtmlRspackPlugin()] : []}, + }); +}; + +const valid = {metas: {attributes: {name: "test", content: "ok"}}}; + +test.each([valid, [valid], () => valid, () => [valid]].map(html => ({html})))( + "accepts every html configuration form", + async ({html}) => { + expect(await configure(html)).toMatchObject({plugins: [expect.anything()]}); + } +); + +test("evaluates the html factory once", () => { + let calls = 0; + + configure(() => { + calls += 1; + + return valid; + }); + + expect(calls).toBe(1); +}); + +test("passes HTML callbacks to the package without executing them during configuration", () => { + let calls = 0; + const handler = (asset: string, suffix: string): string => { + calls += 1; + + return `${asset}${suffix}`; + }; + + configure({ + ...valid, + files: ["settings.html"], + hash: handler, + addPublicPath: handler, + usePublicPath: true, + links: {path: "style.css", publicPath: handler}, + scripts: {path: "script.js", useHash: true, addHash: handler}, + metas: {...valid.metas, hash: handler}, + tags: {path: "extra.js", addPublicPath: handler}, + }); + + expect(calls).toBe(0); +}); + +test("does not evaluate html when there are no HTML outputs", async () => { + expect( + await configure(() => { + throw new Error("This factory must not run"); + }, false) + ).toEqual({}); +}); + +test("adds no plugins when html is omitted", async () => { + expect(await configure(undefined)).toEqual({}); +}); diff --git a/src/cli/plugins/html.ts b/src/cli/plugins/html/index.ts similarity index 63% rename from src/cli/plugins/html.ts rename to src/cli/plugins/html/index.ts index 89175fb2..0831010a 100644 --- a/src/cli/plugins/html.ts +++ b/src/cli/plugins/html/index.ts @@ -1,7 +1,7 @@ import _ from "lodash"; import {Configuration as RspackConfig, HtmlRspackPlugin} from "@rspack/core"; -import HtmlRspackTagsPlugin from "html-rspack-tags-plugin"; +import HtmlTagsRspackPlugin from "@rspackjs/plugin-html-tags"; import {definePlugin} from "@main/plugin"; @@ -15,17 +15,14 @@ export default definePlugin(() => { return {}; } - let options = _.isFunction(config.html) ? config.html() : config.html; + const value = _.isFunction(config.html) ? config.html() : config.html; - if (_.isEmpty(options)) { + if (value === undefined) { return {}; } - if (!_.isArray(options)) { - options = [options]; - } - - const plugins = options.map(options => new HtmlRspackTagsPlugin(options)); + const options = Array.isArray(value) ? value : [value]; + const plugins = options.map(options => new HtmlTagsRspackPlugin(options)); return {plugins} satisfies RspackConfig; }, diff --git a/src/cli/plugins/offscreen/index.ts b/src/cli/plugins/offscreen/index.ts index 3d2ab435..889f1c40 100644 --- a/src/cli/plugins/offscreen/index.ts +++ b/src/cli/plugins/offscreen/index.ts @@ -1,6 +1,6 @@ import {Configuration as RspackConfig, HtmlRspackPlugin, Plugins} from "@rspack/core"; import {RspackVirtualModulePlugin} from "rspack-plugin-virtual-module"; -import HtmlRspackTagsPlugin from "html-rspack-tags-plugin"; +import HtmlTagsRspackPlugin from "@rspackjs/plugin-html-tags"; import {definePlugin} from "@main/plugin"; @@ -63,7 +63,7 @@ export default definePlugin(() => { } const htmlPlugins = (await offscreen.view().html()).map(options => new HtmlRspackPlugin(options)); - const tagsPlugins = (await offscreen.view().tags()).map(options => new HtmlRspackTagsPlugin(options)); + const tagsPlugins = (await offscreen.view().tags()).map(options => new HtmlTagsRspackPlugin(options)); plugins.push(plugin, ...htmlPlugins, ...tagsPlugins); diff --git a/src/cli/plugins/options/index.ts b/src/cli/plugins/options/index.ts index f0d5ab4f..8151282c 100644 --- a/src/cli/plugins/options/index.ts +++ b/src/cli/plugins/options/index.ts @@ -1,5 +1,5 @@ import {Configuration as RspackConfig, HtmlRspackPlugin} from "@rspack/core"; -import HtmlRspackTagsPlugin from "html-rspack-tags-plugin"; +import HtmlTagsRspackPlugin from "@rspackjs/plugin-html-tags"; import Options from "./Options"; @@ -34,7 +34,7 @@ export default definePlugin(() => { } const htmlPlugins = (await options.view().html()).map(options => new HtmlRspackPlugin(options)); - const tagsPlugins = (await options.view().tags()).map(options => new HtmlRspackTagsPlugin(options)); + const tagsPlugins = (await options.view().tags()).map(options => new HtmlTagsRspackPlugin(options)); return { plugins: [plugin, ...htmlPlugins, ...tagsPlugins], diff --git a/src/cli/plugins/override/index.ts b/src/cli/plugins/override/index.ts index 25495b0a..a3755681 100644 --- a/src/cli/plugins/override/index.ts +++ b/src/cli/plugins/override/index.ts @@ -1,5 +1,5 @@ import {Configuration as RspackConfig, HtmlRspackPlugin} from "@rspack/core"; -import HtmlRspackTagsPlugin from "html-rspack-tags-plugin"; +import HtmlTagsRspackPlugin from "@rspackjs/plugin-html-tags"; import Override from "./Override"; @@ -39,7 +39,7 @@ export default definePlugin(() => { } const htmlPlugins = (await view.html()).map(options => new HtmlRspackPlugin(options)); - const tagsPlugins = (await view.tags()).map(options => new HtmlRspackTagsPlugin(options)); + const tagsPlugins = (await view.tags()).map(options => new HtmlTagsRspackPlugin(options)); return { plugins: [plugin, ...htmlPlugins, ...tagsPlugins], diff --git a/src/cli/plugins/page/index.ts b/src/cli/plugins/page/index.ts index efed19b5..2c52648c 100644 --- a/src/cli/plugins/page/index.ts +++ b/src/cli/plugins/page/index.ts @@ -1,5 +1,5 @@ import {Configuration as RspackConfig, HtmlRspackPlugin, Plugins} from "@rspack/core"; -import HtmlRspackTagsPlugin from "html-rspack-tags-plugin"; +import HtmlTagsRspackPlugin from "@rspackjs/plugin-html-tags"; import Page from "./Page"; @@ -31,6 +31,7 @@ export default definePlugin(() => { Object.fromEntries(Array.from((await page.views()).values(), item => [item.alias, item.filename])) ), }); + const modulePlugin = new GenerateModulePlugin(await getModules()); if (config.command === Command.Watch) { @@ -57,7 +58,7 @@ export default definePlugin(() => { } const htmlPlugins = (await page.view().html()).map(options => new HtmlRspackPlugin(options)); - const tagsPlugins = (await page.view().tags()).map(options => new HtmlRspackTagsPlugin(options)); + const tagsPlugins = (await page.view().tags()).map(options => new HtmlTagsRspackPlugin(options)); plugins.push(plugin, ...htmlPlugins, ...tagsPlugins); } diff --git a/src/cli/plugins/popup/index.ts b/src/cli/plugins/popup/index.ts index 7ed53b55..63882ee1 100644 --- a/src/cli/plugins/popup/index.ts +++ b/src/cli/plugins/popup/index.ts @@ -1,5 +1,5 @@ import {Configuration as RspackConfig, HtmlRspackPlugin, Plugins} from "@rspack/core"; -import HtmlRspackTagsPlugin from "html-rspack-tags-plugin"; +import HtmlTagsRspackPlugin from "@rspackjs/plugin-html-tags"; import {definePlugin} from "@main/plugin"; @@ -51,7 +51,7 @@ export default definePlugin(() => { } const htmlPlugins = (await popup.view().html()).map(options => new HtmlRspackPlugin(options)); - const tagsPlugins = (await popup.view().tags()).map(options => new HtmlRspackTagsPlugin(options)); + const tagsPlugins = (await popup.view().tags()).map(options => new HtmlTagsRspackPlugin(options)); plugins.push(plugin, ...htmlPlugins, ...tagsPlugins); } diff --git a/src/cli/plugins/sandbox/index.ts b/src/cli/plugins/sandbox/index.ts index a7528968..a6071023 100644 --- a/src/cli/plugins/sandbox/index.ts +++ b/src/cli/plugins/sandbox/index.ts @@ -1,5 +1,5 @@ import {Configuration as RspackConfig, HtmlRspackPlugin, Plugins} from "@rspack/core"; -import HtmlRspackTagsPlugin from "html-rspack-tags-plugin"; +import HtmlTagsRspackPlugin from "@rspackjs/plugin-html-tags"; import {definePlugin} from "@main/plugin"; import {EntrypointPlugin, GenerateModulePlugin} from "@cli/bundler"; @@ -54,7 +54,7 @@ export default definePlugin(() => { } const htmlPlugins = (await sandbox.view().html()).map(options => new HtmlRspackPlugin(options)); - const tagsPlugins = (await sandbox.view().tags()).map(options => new HtmlRspackTagsPlugin(options)); + const tagsPlugins = (await sandbox.view().tags()).map(options => new HtmlTagsRspackPlugin(options)); plugins.push(plugin, ...htmlPlugins, ...tagsPlugins); } diff --git a/src/cli/plugins/sidebar/index.ts b/src/cli/plugins/sidebar/index.ts index 265a33fc..c5fbeda6 100644 --- a/src/cli/plugins/sidebar/index.ts +++ b/src/cli/plugins/sidebar/index.ts @@ -1,5 +1,5 @@ import {Configuration as RspackConfig, HtmlRspackPlugin, Plugins} from "@rspack/core"; -import HtmlRspackTagsPlugin from "html-rspack-tags-plugin"; +import HtmlTagsRspackPlugin from "@rspackjs/plugin-html-tags"; import {definePlugin} from "@main/plugin"; @@ -68,7 +68,7 @@ export default definePlugin(() => { } const htmlPlugins = (await sidebar.view().html()).map(options => new HtmlRspackPlugin(options)); - const tagsPlugins = (await sidebar.view().tags()).map(options => new HtmlRspackTagsPlugin(options)); + const tagsPlugins = (await sidebar.view().tags()).map(options => new HtmlTagsRspackPlugin(options)); plugins.push(plugin, ...htmlPlugins, ...tagsPlugins); } diff --git a/src/cli/plugins/view/View.ts b/src/cli/plugins/view/View.ts index 9e6405bc..918fee9b 100644 --- a/src/cli/plugins/view/View.ts +++ b/src/cli/plugins/view/View.ts @@ -2,15 +2,15 @@ import path from "path"; import _ from "lodash"; import type {HtmlRspackPluginOptions} from "@rspack/core"; -import type {Options as HtmlRspackTagsPluginOptions} from "html-rspack-tags-plugin"; +import type {HtmlOptions} from "@typing/html"; -import {AbstractViewFinder, HtmlOptionKeys} from "@cli/entrypoint"; +import {AbstractViewFinder, HtmlEntrypointOptionKeys} from "@cli/entrypoint"; import {EntrypointEntries} from "@typing/entrypoint"; import {ViewEntrypointOptions} from "@typing/view"; import {ReadonlyConfig} from "@typing/config"; -export default class { +export default class View { public constructor( protected readonly config: ReadonlyConfig, protected readonly finder: AbstractViewFinder @@ -45,14 +45,14 @@ export default class { return html; } - public async tags(): Promise { - const tags: HtmlRspackTagsPluginOptions[] = []; + public async tags(): Promise { + const tags: HtmlOptions[] = []; const views = await this.finder.views(); for (const {filename, options} of views.values()) { // Only HTML options reach the tags plugin; view, build and manifest options stay out of it. - const tagOptions = _.pick(options, HtmlOptionKeys); + const tagOptions = _.pick(options, HtmlEntrypointOptionKeys); if (!_.isEmpty(tagOptions)) { tags.push({ diff --git a/src/main/html.ts b/src/main/html.ts new file mode 100644 index 00000000..c567b9c2 --- /dev/null +++ b/src/main/html.ts @@ -0,0 +1,7 @@ +export type { + HtmlEntrypointOptions, + HtmlHashHandler, + HtmlMetaTagOptions, + HtmlOptions, + HtmlPublicPathHandler, +} from "@typing/html"; diff --git a/src/main/index.ts b/src/main/index.ts index c92906e3..f971a1d7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -7,6 +7,7 @@ export * from "./content"; export * from "./csp"; export * from "./entrypoint"; export * from "./env"; +export * from "./html"; export * from "./icon"; export * from "./manifest"; export * from "./offscreen"; diff --git a/src/types/config.ts b/src/types/config.ts index 0afde210..21abb56e 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,5 +1,4 @@ import type {Configuration as RspackConfig, Filename} from "@rspack/core"; -import type {Options as HtmlOptions} from "html-rspack-tags-plugin"; import {Command, Mode, Workspace} from "@typing/app"; import {Browser, BrowserSpecific} from "@typing/browser"; @@ -9,6 +8,7 @@ import {Language} from "@typing/locale"; import {Awaiter} from "@typing/helpers"; import {EnvFilterOptions, EnvFilterVariant} from "@typing/env"; import {ActionOptions} from "@typing/action"; +import type {HtmlOptions} from "@typing/html"; /** * Configuration options for building a browser extension. This interface defines diff --git a/src/types/helpers.ts b/src/types/helpers.ts index 4e420431..ea2f2eb3 100644 --- a/src/types/helpers.ts +++ b/src/types/helpers.ts @@ -1,11 +1,5 @@ -type ExcludeFunction = T extends Function ? never : T; - export type Awaiter = T | Promise; export type PickNonFunctionProperties = { [K in keyof T as T[K] extends Function ? never : K]: T[K]; }; - -export type ExcludeFunctionsFromProperties = { - [K in keyof T]: ExcludeFunction; -}; diff --git a/src/types/html.ts b/src/types/html.ts new file mode 100644 index 00000000..585cbfe7 --- /dev/null +++ b/src/types/html.ts @@ -0,0 +1,29 @@ +import type { + AddHashFunction, + AddPublicPathFunction, + HtmlTagsPluginOptions, + MetaTagOptions, +} from "@rspackjs/plugin-html-tags"; + +export type HtmlHashHandler = AddHashFunction; +export type HtmlPublicPathHandler = AddPublicPathFunction; +export type HtmlMetaTagOptions = MetaTagOptions; + +/** HTML tag options evaluated in the build configuration. */ +export type HtmlOptions = Omit & { + /** A meta tag needs a nonempty attributes object; string paths are not supported. */ + metas?: HtmlMetaTagOptions | HtmlMetaTagOptions[]; +}; + +type HtmlStaticOptions = T extends (...args: never[]) => unknown + ? never + : T extends (infer Item)[] + ? HtmlStaticOptions[] + : T extends object + ? {[Key in keyof T]: HtmlStaticOptions} + : T; + +/** Statically read HTML options. The framework selects the entrypoint's output file. */ +export type HtmlEntrypointOptions = HtmlStaticOptions> & { + files?: never; +}; diff --git a/src/types/view/common.ts b/src/types/view/common.ts index ebb104cf..4cc81ea2 100644 --- a/src/types/view/common.ts +++ b/src/types/view/common.ts @@ -1,7 +1,7 @@ import type {Optional} from "utility-types"; -import type {Options as HtmlOptions} from "html-rspack-tags-plugin"; +import type {HtmlEntrypointOptions} from "@typing/html"; -import {Awaiter, ExcludeFunctionsFromProperties, PickNonFunctionProperties} from "@typing/helpers"; +import {Awaiter, PickNonFunctionProperties} from "@typing/helpers"; import {EntrypointBuilder, EntrypointOptions} from "@typing/entrypoint"; export interface ViewConfig { @@ -10,7 +10,7 @@ export interface ViewConfig { template?: string; } -export type ViewOptions = ViewConfig & EntrypointOptions & ExcludeFunctionsFromProperties; +export type ViewOptions = ViewConfig & EntrypointOptions & HtmlEntrypointOptions; export type ViewEntrypointOptions = ViewOptions; diff --git a/tests/integration/README.md b/tests/integration/README.md index 33316819..f973dd5c 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -23,6 +23,10 @@ tests/integration/ │ │ │ ├── root-dir/ │ │ │ └── root-dir-b/ │ │ └── scripts/ +│ ├── html/ +│ │ ├── html.integration.test.ts +│ │ ├── fixture/ +│ │ └── scenarios/ │ ├── locale/ │ │ ├── locale.integration.test.ts │ │ ├── dynamic.integration.test.ts @@ -102,11 +106,13 @@ tests/integration/ │ └── … ├── types/ │ ├── content.integration.test.ts +│ ├── html.integration.test.ts │ ├── registries.integration.test.ts │ ├── relay.integration.test.ts │ ├── view.integration.test.ts │ └── fixtures/ │ ├── content/ +│ ├── html/ │ ├── registries/ │ ├── relay/ │ └── view/ @@ -240,8 +246,10 @@ creation/close race. The diagnostic expects the second build to fail and is deli Parallel test sessions use distinct working directories; production directory naming is unchanged. - `types/content`: shared Content and adapter render types through the public source and built package APIs, callback props inference, and iframe-navigation restrictions for both define functions. +- [types/html.integration.test.ts](types/html.integration.test.ts): static entrypoint options, configuration callbacks, object metadata, and rejected string metadata through both source and built declarations. Fixtures live in `types/fixtures/html`. - `types/view`: shared View and adapter render types through the public source and built package APIs, render and container props inference, the render contract adopted by Offscreen and Sandbox, and the rejection of Promise and plain-object render values. - `types/registries`: generated registry augmentation, empty fallbacks, public and internal type agreement, and message contracts against source and built package APIs. Compiler-host path checks cover both slash styles. +- [build/html](build/html): emitted metadata and asset tags, all four `config.html` forms, page-specific tag selection, and representative failures from configuration, entrypoint parsing, and plugin validation. Invalid inputs live in its own `scenarios` directory. Parser field matrices belong beside `ViewParser`; validation rules owned by `@rspackjs/plugin-html-tags` belong in that package's tests. No browser is launched. - `build/options/embedded`: ten manifest checks covering explicit `openInTab: false` across Chrome, Edge, Opera, Safari, and Firefox in MV2 and MV3. No browser is launched. - `browser/view`: one Chrome MV3 case covering React offscreen and sandbox views rendered by the injected builder with their props and titles, a headless offscreen without a view container, and strings rendered as text by the React view, Vanilla view and React content adapters. - `browser/options`: two Chrome MV3 cases covering Vanilla and React rendering, CSS, state/events, opening Options from background, and a View chunk shared with a Page. diff --git a/tests/integration/build/html/fixture/adnbn.config.ts b/tests/integration/build/html/fixture/adnbn.config.ts new file mode 100644 index 00000000..7c19781a --- /dev/null +++ b/tests/integration/build/html/fixture/adnbn.config.ts @@ -0,0 +1,9 @@ +import {defineConfig} from "adnbn"; + +import {htmlOptions} from "./html-options"; + +export default defineConfig({ + name: "HTML Options Integration", + version: "1.0.0", + html: htmlOptions, +}); diff --git a/tests/integration/build/html/fixture/assets/global.js b/tests/integration/build/html/fixture/assets/global.js new file mode 100644 index 00000000..dec6e39f --- /dev/null +++ b/tests/integration/build/html/fixture/assets/global.js @@ -0,0 +1 @@ +globalThis.htmlGlobal = true; diff --git a/tests/integration/build/html/fixture/assets/local.js b/tests/integration/build/html/fixture/assets/local.js new file mode 100644 index 00000000..99ad86dc --- /dev/null +++ b/tests/integration/build/html/fixture/assets/local.js @@ -0,0 +1 @@ +globalThis.htmlLocal = true; diff --git a/tests/integration/build/html/fixture/assets/style.css b/tests/integration/build/html/fixture/assets/style.css new file mode 100644 index 00000000..091ce70d --- /dev/null +++ b/tests/integration/build/html/fixture/assets/style.css @@ -0,0 +1,3 @@ +body { + color: #123456; +} diff --git a/tests/integration/build/html/fixture/html-options.ts b/tests/integration/build/html/fixture/html-options.ts new file mode 100644 index 00000000..136ceb5f --- /dev/null +++ b/tests/integration/build/html/fixture/html-options.ts @@ -0,0 +1,12 @@ +import type {HtmlOptions} from "adnbn"; + +export const htmlOptions: HtmlOptions = { + files: ["**/object.html"], + metas: [{attributes: {name: "global-meta", content: "global"}}], + scripts: { + path: "global.js", + sourcePath: "assets/global.js", + publicPath: assetPath => `/global/${assetPath}`, + }, + hash: (assetPath, hash) => `${assetPath}?build=${hash}`, +}; diff --git a/tests/integration/build/html/fixture/package.json b/tests/integration/build/html/fixture/package.json new file mode 100644 index 00000000..88402df7 --- /dev/null +++ b/tests/integration/build/html/fixture/package.json @@ -0,0 +1,9 @@ +{ + "name": "adnbn-test-html", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../.." + } +} diff --git a/tests/integration/build/html/fixture/src/array.page.ts b/tests/integration/build/html/fixture/src/array.page.ts new file mode 100644 index 00000000..fa1b680d --- /dev/null +++ b/tests/integration/build/html/fixture/src/array.page.ts @@ -0,0 +1,10 @@ +import {definePage} from "adnbn"; + +export default definePage({ + title: "Array metadata", + metas: [ + {attributes: {name: "entry-array", content: "array"}}, + {attributes: {name: "theme-color", content: "#123456"}}, + ], + render: () => "Array page", +}); diff --git a/tests/integration/build/html/fixture/src/object.page.ts b/tests/integration/build/html/fixture/src/object.page.ts new file mode 100644 index 00000000..4ac70d74 --- /dev/null +++ b/tests/integration/build/html/fixture/src/object.page.ts @@ -0,0 +1,32 @@ +import {definePage} from "adnbn"; + +export default definePage({ + title: "Object metadata", + metas: {attributes: {name: "entry-object", content: "object"}}, + links: [ + "string.css", + { + path: "styles", + glob: "*.css", + globPath: "assets", + sourcePath: "assets/style.css", + attributes: {media: "screen"}, + }, + ], + scripts: [ + "string.js", + { + path: "local.js", + sourcePath: "assets/local.js", + attributes: {defer: true}, + }, + { + path: "vendor.js", + external: {packageName: "html-test-external", variableName: "HtmlTestExternal"}, + }, + ], + tags: ["tag.css", "tag.js", {path: "custom.module", type: "js"}], + publicPath: "/", + hash: "static", + render: () => "Object page", +}); diff --git a/tests/integration/build/html/fixture/tsconfig.json b/tests/integration/build/html/fixture/tsconfig.json new file mode 100644 index 00000000..eadf1be8 --- /dev/null +++ b/tests/integration/build/html/fixture/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.adnbn/tsconfig.json" +} diff --git a/tests/integration/build/html/html.integration.test.ts b/tests/integration/build/html/html.integration.test.ts new file mode 100644 index 00000000..a65474fe --- /dev/null +++ b/tests/integration/build/html/html.integration.test.ts @@ -0,0 +1,98 @@ +import {readFile, writeFile} from "fs/promises"; +import path from "path"; + +import {createIntegrationFixture} from "../../utils/fixture"; + +const rootDir = path.resolve(__dirname, "../../../.."); +const fixtureDir = path.join(__dirname, "fixture"); +const scenarios = path.join(__dirname, "scenarios"); +const forms = ["object", "array", "function-object", "function-array"]; + +jest.setTimeout(90_000); + +test.each(forms)("builds entrypoint metadata and %s config without leaking tags to other pages", async form => { + const fixture = await createIntegrationFixture(rootDir, fixtureDir); + + try { + await writeFile( + path.join(fixture.directory, "adnbn.config.ts"), + await readFile(path.join(scenarios, `config-${form}.ts`)) + ); + + const browser = form.includes("array") ? "firefox" : "chrome"; + const output = await fixture.build({browser}); + const object = await readFile(path.join(output, "object.html"), "utf8"); + const array = await readFile(path.join(output, "array.html"), "utf8"); + + expect(object).toMatch(/]*name="entry-object")(?=[^>]*content="object")[^>]*>/); + expect(object).toMatch(/]*name="global-meta")(?=[^>]*content="global")[^>]*>/); + expect(array).toMatch(/]*name="entry-array")(?=[^>]*content="array")[^>]*>/); + expect(array).toMatch(/]*name="theme-color")(?=[^>]*content="#123456")[^>]*>/); + expect(object).not.toContain("entry-array"); + expect(array).not.toMatch(/entry-object|global-meta|global.js|string.css|local.js/); + + for (const asset of [ + "string.css", + "string.js", + "tag.css", + "tag.js", + "custom.module", + "styles/style.css", + "local.js", + "vendor.js", + ]) { + expect(object).toContain(`/${asset}?static`); + } + + expect(object).toMatch(/src="\/global\/global.js\?build=[a-f0-9]+"/); + + for (const [emitted, original] of [ + ["local.js", "local.js"], + ["global.js", "global.js"], + ["styles/style.css", "style.css"], + ]) { + expect(await readFile(path.join(output, emitted), "utf8")).toBe( + await readFile(path.join(fixture.directory, "assets", original), "utf8") + ); + } + } finally { + await fixture.dispose(); + } +}); + +test("reports invalid metas returned by the html factory during a real build", async () => { + const fixture = await createIntegrationFixture(rootDir, fixtureDir); + + try { + await writeFile( + path.join(fixture.directory, "adnbn.config.ts"), + await readFile(path.join(scenarios, "config-function-array.ts")) + ); + await writeFile( + path.join(fixture.directory, "html-options.ts"), + await readFile(path.join(scenarios, "options-mixed.ts")) + ); + + await expect(fixture.build()).rejects.toThrow(/metas.*object/); + } finally { + await fixture.dispose(); + } +}); + +test.each([ + ["invalid-metas", /Invalid options metas.*object\.page\.ts.*attributes/], + ["conflicting-hash", /metas\.hash/], +])("rejects entrypoint %s during a real build", async (scenario, message) => { + const fixture = await createIntegrationFixture(rootDir, fixtureDir); + + try { + await writeFile( + path.join(fixture.directory, "src", "object.page.ts"), + await readFile(path.join(scenarios, `page-${scenario}.ts`)) + ); + + await expect(fixture.build()).rejects.toThrow(message); + } finally { + await fixture.dispose(); + } +}); diff --git a/tests/integration/build/html/scenarios/config-array.ts b/tests/integration/build/html/scenarios/config-array.ts new file mode 100644 index 00000000..ccfe329f --- /dev/null +++ b/tests/integration/build/html/scenarios/config-array.ts @@ -0,0 +1,9 @@ +import {defineConfig} from "adnbn"; + +import {htmlOptions} from "./html-options"; + +export default defineConfig({ + name: "HTML Options Integration", + version: "1.0.0", + html: [htmlOptions], +}); diff --git a/tests/integration/build/html/scenarios/config-function-array.ts b/tests/integration/build/html/scenarios/config-function-array.ts new file mode 100644 index 00000000..74f9a5ca --- /dev/null +++ b/tests/integration/build/html/scenarios/config-function-array.ts @@ -0,0 +1,9 @@ +import {defineConfig} from "adnbn"; + +import {htmlOptions} from "./html-options"; + +export default defineConfig({ + name: "HTML Options Integration", + version: "1.0.0", + html: () => [htmlOptions], +}); diff --git a/tests/integration/build/html/scenarios/config-function-object.ts b/tests/integration/build/html/scenarios/config-function-object.ts new file mode 100644 index 00000000..d6815cc7 --- /dev/null +++ b/tests/integration/build/html/scenarios/config-function-object.ts @@ -0,0 +1,9 @@ +import {defineConfig} from "adnbn"; + +import {htmlOptions} from "./html-options"; + +export default defineConfig({ + name: "HTML Options Integration", + version: "1.0.0", + html: () => htmlOptions, +}); diff --git a/tests/integration/build/html/scenarios/config-object.ts b/tests/integration/build/html/scenarios/config-object.ts new file mode 100644 index 00000000..7c19781a --- /dev/null +++ b/tests/integration/build/html/scenarios/config-object.ts @@ -0,0 +1,9 @@ +import {defineConfig} from "adnbn"; + +import {htmlOptions} from "./html-options"; + +export default defineConfig({ + name: "HTML Options Integration", + version: "1.0.0", + html: htmlOptions, +}); diff --git a/tests/integration/build/html/scenarios/options-mixed.ts b/tests/integration/build/html/scenarios/options-mixed.ts new file mode 100644 index 00000000..cde1beac --- /dev/null +++ b/tests/integration/build/html/scenarios/options-mixed.ts @@ -0,0 +1,3 @@ +export const htmlOptions = { + metas: [{attributes: {charset: "utf-8"}}, "utf-8"], +}; diff --git a/tests/integration/build/html/scenarios/page-conflicting-hash.ts b/tests/integration/build/html/scenarios/page-conflicting-hash.ts new file mode 100644 index 00000000..f9a66d45 --- /dev/null +++ b/tests/integration/build/html/scenarios/page-conflicting-hash.ts @@ -0,0 +1,5 @@ +import {definePage} from "adnbn"; + +export default definePage({ + metas: {attributes: {name: "x"}, hash: "v1", useHash: true}, +}); diff --git a/tests/integration/build/html/scenarios/page-invalid-metas.ts b/tests/integration/build/html/scenarios/page-invalid-metas.ts new file mode 100644 index 00000000..da9100cd --- /dev/null +++ b/tests/integration/build/html/scenarios/page-invalid-metas.ts @@ -0,0 +1,5 @@ +import {definePage} from "adnbn"; + +export default definePage({ + metas: "utf-8", +}); diff --git a/tests/integration/types/fixtures/html/options.ts b/tests/integration/types/fixtures/html/options.ts new file mode 100644 index 00000000..71609a41 --- /dev/null +++ b/tests/integration/types/fixtures/html/options.ts @@ -0,0 +1,120 @@ +import {defineConfig, definePage} from "adnbn"; +import type {HtmlEntrypointOptions, HtmlMetaTagOptions, HtmlOptions, ViewOptions} from "adnbn"; + +const meta = {attributes: {charset: "utf-8"}} satisfies HtmlMetaTagOptions; +const handler = (asset: string, suffix: string): string => `${asset}${suffix}`; +const options: HtmlEntrypointOptions = { + metas: [meta], + links: ["extra.css", {path: "style.css", sourcePath: "style.css", hash: "v1"}], + scripts: ["extra.js", {path: "vendor.js", external: {packageName: "vendor", variableName: "Vendor"}}], + tags: ["tag.js", {path: "custom", type: "css"}], + publicPath: "/", +}; + +definePage({...options, render: async () => "Page"}); +definePage({metas: meta}); +definePage({metas: [meta]}); + +const full: HtmlOptions = { + files: ["settings.html"], + hash: handler, + addPublicPath: handler, + usePublicPath: true, + metas: {...meta, publicPath: handler}, + links: {path: "style.css", addHash: handler}, + scripts: {path: "script.js", addPublicPath: handler}, + tags: {path: "tag.js", hash: handler}, +}; + +defineConfig({html: full}); +defineConfig({html: [full]}); +defineConfig({html: () => full}); +defineConfig({html: () => [full]}); +defineConfig(() => ({html: () => [full]})); + +// @ts-expect-error: Only meta objects are accepted. +definePage({metas: "utf-8"}); +// @ts-expect-error: Every global configuration form has the same meta contract. +defineConfig({html: {metas: "utf-8"}}); +// @ts-expect-error: Every global configuration form has the same meta contract. +defineConfig({html: [{metas: "utf-8"}]}); +// @ts-expect-error: Every global configuration form has the same meta contract. +defineConfig({html: () => ({metas: "utf-8"})}); +// @ts-expect-error: Every global configuration form has the same meta contract. +defineConfig({html: () => [{metas: "utf-8"}]}); +// @ts-expect-error: Only meta objects are accepted. +definePage({metas: ["utf-8"]}); +// @ts-expect-error: Every global configuration form has the same meta contract. +defineConfig({html: {metas: ["utf-8"]}}); +// @ts-expect-error: Every global configuration form has the same meta contract. +defineConfig({html: [{metas: ["utf-8"]}]}); +// @ts-expect-error: Every global configuration form has the same meta contract. +defineConfig({html: () => ({metas: ["utf-8"]})}); +// @ts-expect-error: Every global configuration form has the same meta contract. +defineConfig({html: () => [{metas: ["utf-8"]}]}); +// @ts-expect-error: Only meta objects are accepted. +definePage({metas: [meta, "utf-8"]}); +// @ts-expect-error: Every global configuration form has the same meta contract. +defineConfig({html: {metas: [meta, "utf-8"]}}); +// @ts-expect-error: Every global configuration form has the same meta contract. +defineConfig({html: [{metas: [meta, "utf-8"]}]}); +// @ts-expect-error: Every global configuration form has the same meta contract. +defineConfig({html: () => ({metas: [meta, "utf-8"]})}); +// @ts-expect-error: Every global configuration form has the same meta contract. +defineConfig({html: () => [{metas: [meta, "utf-8"]}]}); + +// @ts-expect-error: HTML functions belong to config.html. +definePage({hash: handler}); +// @ts-expect-error: Nested HTML options must also be static. +definePage({links: [{path: "asset.js", hash: handler}]}); +// @ts-expect-error: Nested HTML options must also be static. +definePage({scripts: [{path: "asset.js", hash: handler}]}); +// @ts-expect-error: Nested HTML options must also be static. +definePage({tags: [{path: "asset.js", hash: handler}]}); +// @ts-expect-error: Nested HTML options must also be static. +definePage({metas: [{...meta, hash: handler}]}); + +// @ts-expect-error: HTML functions belong to config.html. +definePage({publicPath: handler}); +// @ts-expect-error: Nested HTML options must also be static. +definePage({links: [{path: "asset.js", publicPath: handler}]}); +// @ts-expect-error: Nested HTML options must also be static. +definePage({scripts: [{path: "asset.js", publicPath: handler}]}); +// @ts-expect-error: Nested HTML options must also be static. +definePage({tags: [{path: "asset.js", publicPath: handler}]}); +// @ts-expect-error: Nested HTML options must also be static. +definePage({metas: [{...meta, publicPath: handler}]}); + +// @ts-expect-error: HTML functions belong to config.html. +definePage({addHash: handler}); +// @ts-expect-error: Nested HTML options must also be static. +definePage({links: [{path: "asset.js", addHash: handler}]}); +// @ts-expect-error: Nested HTML options must also be static. +definePage({scripts: [{path: "asset.js", addHash: handler}]}); +// @ts-expect-error: Nested HTML options must also be static. +definePage({tags: [{path: "asset.js", addHash: handler}]}); +// @ts-expect-error: Nested HTML options must also be static. +definePage({metas: [{...meta, addHash: handler}]}); + +// @ts-expect-error: HTML functions belong to config.html. +definePage({addPublicPath: handler}); +// @ts-expect-error: Nested HTML options must also be static. +definePage({links: [{path: "asset.js", addPublicPath: handler}]}); +// @ts-expect-error: Nested HTML options must also be static. +definePage({scripts: [{path: "asset.js", addPublicPath: handler}]}); +// @ts-expect-error: Nested HTML options must also be static. +definePage({tags: [{path: "asset.js", addPublicPath: handler}]}); +// @ts-expect-error: Nested HTML options must also be static. +definePage({metas: [{...meta, addPublicPath: handler}]}); + +// @ts-expect-error: Missing attributes. +const invalidMeta: HtmlMetaTagOptions = {}; + +// @ts-expect-error: The entrypoint chooses its own output file. +definePage({files: ["other.html"]}); + +// @ts-expect-error: Typed variables must not smuggle callbacks into entrypoints. +const staticOptions: HtmlEntrypointOptions = full; + +// @ts-expect-error: ViewOptions adopts the static contract too. +const viewOptions: ViewOptions = {metas: {...meta, hash: handler}}; diff --git a/tests/integration/types/html.integration.test.ts b/tests/integration/types/html.integration.test.ts new file mode 100644 index 00000000..5781fdd8 --- /dev/null +++ b/tests/integration/types/html.integration.test.ts @@ -0,0 +1,48 @@ +import path from "path"; +import ts from "typescript"; + +describe("HTML contracts", () => { + const projectDir = path.resolve(__dirname, "../../.."); + const fixture = path.join(__dirname, "fixtures/html/options.ts"); + + test.each(["source", "package"])( + "checks static entrypoint and full configuration options through the %s API", + mode => { + const config = ts.readConfigFile(path.join(projectDir, "tsconfig.json"), ts.sys.readFile); + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, projectDir); + expect(config.error).toBeUndefined(); + expect(parsed.errors).toEqual([]); + + const options: ts.CompilerOptions = { + ...(mode === "source" ? parsed.options : {}), + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + target: ts.ScriptTarget.ESNext, + jsx: ts.JsxEmit.ReactJSX, + strict: true, + noEmit: true, + skipLibCheck: mode === "source", + types: ["node", "chrome"], + }; + + const host = ts.createCompilerHost(options); + const program = ts.createProgram([fixture], options, host); + + const diagnostics = ts.getPreEmitDiagnostics(program).map(diagnostic => { + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); + const position = diagnostic.file?.getLineAndCharacterOfPosition(diagnostic.start ?? 0); + + return `${diagnostic.file?.fileName ?? "compiler"}:${(position?.line ?? 0) + 1}: ${message}`; + }); + + expect(diagnostics).toEqual([]); + + const apiFile = ts.resolveModuleName("adnbn", fixture, options, host).resolvedModule?.resolvedFileName; + + expect(apiFile).toBe( + path.join(projectDir, mode === "source" ? "src/index.ts" : "dist/index.d.ts").replace(/\\/g, "/") + ); + }, + 30_000 + ); +}); From 52d6a0f2895b8fddc605bcef867e84b893dbdf65 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:11:27 +0300 Subject: [PATCH 06/24] feat(page): support entrypoint permissions --- src/cli/entrypoint/finder/PageFinder.ts | 4 +- src/cli/entrypoint/parser/PageParser.test.ts | 6 +- src/cli/entrypoint/parser/PageParser.ts | 14 ++- .../tests/fixtures/page/options/full/page.ts | 4 + src/cli/plugins/page/index.ts | 8 +- src/types/page.ts | 7 +- tests/integration/README.md | 7 +- .../page-watch.integration.test.ts | 119 ++++++++++++++++++ .../permissions.integration.test.ts | 98 +++++++++++---- .../build/permissions/states/reports.ts | 13 ++ .../build/permissions/views/adnbn.config.ts | 2 +- .../views/src/notifications.page.ts | 12 ++ .../permissions/views/src/reports.page.ts | 13 ++ .../types/fixtures/page/definition.ts | 21 ++++ .../types/page.integration.test.ts | 48 +++++++ 15 files changed, 343 insertions(+), 33 deletions(-) create mode 100644 tests/integration/build/permissions/page-watch.integration.test.ts create mode 100644 tests/integration/build/permissions/states/reports.ts create mode 100644 tests/integration/build/permissions/views/src/notifications.page.ts create mode 100644 tests/integration/build/permissions/views/src/reports.page.ts create mode 100644 tests/integration/types/fixtures/page/definition.ts create mode 100644 tests/integration/types/page.integration.test.ts diff --git a/src/cli/entrypoint/finder/PageFinder.ts b/src/cli/entrypoint/finder/PageFinder.ts index 43f2e337..4123d28f 100644 --- a/src/cli/entrypoint/finder/PageFinder.ts +++ b/src/cli/entrypoint/finder/PageFinder.ts @@ -1,4 +1,4 @@ -import ViewCspFinder from "./ViewCspFinder"; +import ViewPermissionsFinder from "./ViewPermissionsFinder"; import PluginFinder from "./PluginFinder"; import {PageParser} from "../parser"; @@ -14,7 +14,7 @@ import { EntrypointType, } from "@typing/entrypoint"; -export default class extends ViewCspFinder { +export default class PageFinder extends ViewPermissionsFinder { constructor(config: ReadonlyConfig) { super(config); } diff --git a/src/cli/entrypoint/parser/PageParser.test.ts b/src/cli/entrypoint/parser/PageParser.test.ts index 11994b31..3642a1ae 100644 --- a/src/cli/entrypoint/parser/PageParser.test.ts +++ b/src/cli/entrypoint/parser/PageParser.test.ts @@ -21,10 +21,14 @@ const file = (...parts: string[]) => { const parseOptions = (...parts: string[]) => parser.options(file(...parts)); describe("PageParser", () => { - test("parses definePage with its name, matches and inherited view, HTML, CSP and build options", () => { + test("parses definePage with its name, matches, permissions and inherited view, HTML, CSP and build options", () => { expect(parseOptions("options", "full", "page.ts")).toEqual({ name: "help", matches: ["https://example.com/*"], + permissions: ["storage", "tabs"], + optionalPermissions: ["topSites"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], as: "help-view", title: "Help", template: "./template.html", diff --git a/src/cli/entrypoint/parser/PageParser.ts b/src/cli/entrypoint/parser/PageParser.ts index d19a48ac..fa5a730a 100644 --- a/src/cli/entrypoint/parser/PageParser.ts +++ b/src/cli/entrypoint/parser/PageParser.ts @@ -1,18 +1,22 @@ import z from "zod"; import ViewCspParser from "./ViewCspParser"; +import {PermissionsSchema} from "./schemas/permissions"; import {PageEntrypointOptions} from "@typing/page"; -export default class extends ViewCspParser { +export default class PageParser extends ViewCspParser { protected definition(): string { return "definePage"; } protected schema(): typeof this.CommonPropertiesSchema { - return super.schema().extend({ - name: z.string().nonempty().optional(), - matches: z.array(z.string()).optional(), - }); + return super + .schema() + .merge(PermissionsSchema) + .extend({ + name: z.string().nonempty().optional(), + matches: z.array(z.string()).optional(), + }); } } diff --git a/src/cli/entrypoint/parser/tests/fixtures/page/options/full/page.ts b/src/cli/entrypoint/parser/tests/fixtures/page/options/full/page.ts index 0d8de9f5..d9326ec4 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/page/options/full/page.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/page/options/full/page.ts @@ -3,6 +3,10 @@ import {Browser, CspSource, definePage, Mode} from "adnbn"; export default definePage({ name: "help", matches: ["https://example.com/*"], + permissions: ["storage", "tabs"], + optionalPermissions: ["topSites"], + hostPermissions: ["https://*.example.com/*"], + optionalHostPermissions: ["https://other.test/*"], as: "help-view", title: "Help", template: "./template.html", diff --git a/src/cli/plugins/page/index.ts b/src/cli/plugins/page/index.ts index 2c52648c..15dd1d74 100644 --- a/src/cli/plugins/page/index.ts +++ b/src/cli/plugins/page/index.ts @@ -69,7 +69,13 @@ export default definePlugin(() => { } satisfies RspackConfig; }, manifest: async ({manifest}) => { - manifest.appendAccessibleResources(await page.accessibleResources()).appendCsp(await page.csp()); + manifest + .appendAccessibleResources(await page.accessibleResources()) + .appendCsp(await page.csp()) + .appendPermissions(await page.permissions()) + .appendOptionalPermissions(await page.optionalPermissions()) + .appendHostPermissions(await page.hostPermissions()) + .appendOptionalHostPermissions(await page.optionalHostPermissions()); }, }; }); diff --git a/src/types/page.ts b/src/types/page.ts index aaff02f2..8fe9e7ea 100644 --- a/src/types/page.ts +++ b/src/types/page.ts @@ -1,5 +1,6 @@ import {ViewDefinition, ViewOptions} from "@typing/view"; import {CspOptions} from "@typing/csp"; +import type {PermissionsOptions} from "@typing/permissions"; /** * Empty because page aliases depend on the consuming application's entrypoints. @@ -16,7 +17,11 @@ export interface PageConfig { matches?: string[]; } -export type PageEntrypointOptions = PageConfig & CspOptions & ViewOptions; +/** + * Each page included in a build contributes its permissions and CSP to the extension manifest, + * even if the page is never opened. Permissions and CSP apply to the extension, not just this page. + */ +export type PageEntrypointOptions = PageConfig & PermissionsOptions & CspOptions & ViewOptions; export type PageProps = PageEntrypointOptions; diff --git a/tests/integration/README.md b/tests/integration/README.md index f973dd5c..e7ea25af 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -51,6 +51,8 @@ tests/integration/ │ │ └── history/ │ ├── permissions/ │ │ ├── permissions.integration.test.ts +│ │ ├── page-watch.integration.test.ts +│ │ ├── states/ │ │ └── views/ │ ├── style/ │ │ ├── style-merge.integration.test.ts @@ -107,12 +109,14 @@ tests/integration/ ├── types/ │ ├── content.integration.test.ts │ ├── html.integration.test.ts +│ ├── page.integration.test.ts │ ├── registries.integration.test.ts │ ├── relay.integration.test.ts │ ├── view.integration.test.ts │ └── fixtures/ │ ├── content/ │ ├── html/ +│ ├── page/ │ ├── registries/ │ ├── relay/ │ └── view/ @@ -248,13 +252,14 @@ Parallel test sessions use distinct working directories; production directory na - `types/content`: shared Content and adapter render types through the public source and built package APIs, callback props inference, and iframe-navigation restrictions for both define functions. - [types/html.integration.test.ts](types/html.integration.test.ts): static entrypoint options, configuration callbacks, object metadata, and rejected string metadata through both source and built declarations. Fixtures live in `types/fixtures/html`. - `types/view`: shared View and adapter render types through the public source and built package APIs, render and container props inference, the render contract adopted by Offscreen and Sandbox, and the rejection of Promise and plain-object render values. +- `types/page`: Page accepts the shared permissions contract alongside CSP and render props through the public source and built package APIs. - `types/registries`: generated registry augmentation, empty fallbacks, public and internal type agreement, and message contracts against source and built package APIs. Compiler-host path checks cover both slash styles. - [build/html](build/html): emitted metadata and asset tags, all four `config.html` forms, page-specific tag selection, and representative failures from configuration, entrypoint parsing, and plugin validation. Invalid inputs live in its own `scenarios` directory. Parser field matrices belong beside `ViewParser`; validation rules owned by `@rspackjs/plugin-html-tags` belong in that package's tests. No browser is launched. - `build/options/embedded`: ten manifest checks covering explicit `openInTab: false` across Chrome, Edge, Opera, Safari, and Firefox in MV2 and MV3. No browser is launched. - `browser/view`: one Chrome MV3 case covering React offscreen and sandbox views rendered by the injected builder with their props and titles, a headless offscreen without a view container, and strings rendered as text by the React view, Vanilla view and React content adapters. - `browser/options`: two Chrome MV3 cases covering Vanilla and React rendering, CSS, state/events, opening Options from background, and a View chunk shared with a Page. - `build/override`: separate New Tab, Bookmarks, History and competing-entrypoint files let Jest schedule the scenarios across workers. The files share assertions, while each case keeps its own application copy and build. Thirty manifest checks cover the New Tab, Bookmarks, and History applications across Chrome, Edge, Opera, Safari, and Firefox in MV2 and MV3. Supporting browsers receive `chrome_url_overrides`, the page HTML, its CSP, and the permission declared by the entrypoint; the others receive none of them. Three more cases add the History entrypoint to a copy of the New Tab application: Chrome and Edge builds fail naming both entrypoints, while Firefox keeps the New Tab with its own permission and without the History one. No browser is launched. -- `build/permissions/views`: four manifest checks of one application with two popups, two sidebars, and an options page that each declare permissions. Every built view contributes, including a popup and a sidebar that are not applied by default; Chrome MV2 has no sidebar, so its permissions are not requested; Firefox builds the sidebar action without `sidePanel`; MV2 declares hosts as permissions. No browser is launched. +- `build/permissions/views`: four manifest checks of one application with two popups, two sidebars, an options page and two Pages that declare permissions. Every built view contributes, including a popup and a sidebar that are not applied by default and Pages that are never opened. Chrome MV2 has no sidebar, so its permissions are not requested; Firefox builds the sidebar action without `sidePanel`. A Firefox-only Page contributes no permissions or CSP to Chrome. Tests cover required and optional API/host permissions, MV2 host placement, Page CSP and independent WAR matches. `page-watch.integration.test.ts` checks that editing a Page replaces its permissions and CSP during watch. It then stops the watcher, deletes the Page and checks a fresh build, preserving other views' requirements. Entrypoint deletion during watch is not covered: `EntrypointPlugin` currently checks `modifiedFiles` without `removedFiles`. No browser is launched. - `build/style`: shared and app stylesheet merging with `mergeStyles` through the production style and asset rules. Two applications with the same layout and different fonts each receive one CSS module per destination, the same class identifier in ordinary and `?unisolated` output, and their own font. Tests cover resources declared in partials, passed through `with`, and reused through a complete font-face mixin; standalone styles with merging disabled; and production/development source maps. Generated styles exercise aliases, `?asis`, and an existing external `resolve-url-loader`. A native Node build checks the built framework and ESM loader. App overrides with a namespace conflict, a missing module, or invalid SCSS fail the build. Watch mode tracks override creation, edits, imported partials, resources, removal, recreation, the appearance of a local module for a bare Sass request, and recovery after creating a missing module for an explicit relative request. No browser is launched. - `browser/override`: three Chrome MV3 cases opening `chrome://newtab`, `chrome://bookmarks`, and `chrome://history`. They verify that Chrome serves the extension page, React and Vanilla rendering, CSS, state/events, custom `as` and `htmlDir` naming, and a View chunk shared with a Page. - `browser/offscreen/service`: one Chrome MV3 round trip from background through Offscreen to a registered background service. diff --git a/tests/integration/build/permissions/page-watch.integration.test.ts b/tests/integration/build/permissions/page-watch.integration.test.ts new file mode 100644 index 00000000..cd3d8c4d --- /dev/null +++ b/tests/integration/build/permissions/page-watch.integration.test.ts @@ -0,0 +1,119 @@ +import {spawn, type ChildProcess} from "child_process"; +import {readFile, rm, writeFile} from "fs/promises"; +import path from "path"; + +import {createIntegrationFixture} from "../../utils/fixture"; +import {stop, waitFor} from "../../browser/utils/browser"; + +jest.setTimeout(90_000); + +test("CLI watch replaces page permissions and a fresh build drops a removed page", async () => { + const root = path.resolve(__dirname, "../../../.."); + const fixture = await createIntegrationFixture(root, path.join(__dirname, "views")); + let watcher: ChildProcess | undefined; + let output = ""; + + try { + const directory = await fixture.build({browser: "chrome"}); + const manifestPath = path.join(directory, "manifest.json"); + const entry = path.join(fixture.directory, "src", "reports.page.ts"); + await rm(manifestPath); + + watcher = spawn(process.execPath, [path.join(root, "bin", "adnbn.js"), "watch", ".", "-b", "chrome"], { + cwd: fixture.directory, + stdio: ["ignore", "pipe", "pipe"], + }); + + watcher.stdout?.on("data", chunk => { + output += chunk; + }); + + watcher.stderr?.on("data", chunk => { + output += chunk; + }); + + const completedBuilds = () => + (output.replace(/\x1b\[[0-9;]*m/g, "").match(/Rspack [^\n]* compiled successfully/g) ?? []).length; + + const observe = ( + state: string, + after: number, + permissions: string[], + optionalPermissions: string[], + hosts: string[], + optionalHosts: string[] | undefined + ) => + waitFor( + async () => { + if (completedBuilds() <= after) { + return undefined; + } + + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + + expect([...manifest.permissions].sort()).toEqual([...permissions].sort()); + expect([...manifest.optional_permissions].sort()).toEqual([...optionalPermissions].sort()); + expect([...manifest.host_permissions].sort()).toEqual([...hosts].sort()); + expect(manifest.optional_host_permissions?.sort()).toEqual( + optionalHosts && [...optionalHosts].sort() + ); + + return manifest; + }, + 15_000, + `CLI watch page permissions after ${state}` + ); + + const retained = ["bookmarks", "downloads", "history", "sidePanel", "storage", "tabs"]; + const retainedHost = "https://*.example.com/*"; + + await observe( + "startup", + 0, + [...retained, "alarms"], + ["clipboardWrite", "topSites"], + [retainedHost, "https://api.example.net/*"], + ["https://export.example.org/*"] + ); + + const beforeEdit = completedBuilds(); + await writeFile(entry, await readFile(path.join(__dirname, "states", "reports.ts"))); + + const changed = await observe( + "edit", + beforeEdit, + [...retained, "idle"], + ["clipboardRead", "topSites"], + [retainedHost, "https://changed.example.net/*"], + ["https://changed.example.org/*"] + ); + + expect(changed.content_security_policy.extension_pages).toContain("connect-src https://changed.example.net;"); + expect(changed.content_security_policy.extension_pages).not.toContain("https://api.example.net"); + + // Entrypoint deletion is checked after stopping watch, as in the Relay integration. + await stop(watcher); + watcher = undefined; + await rm(entry); + await fixture.build({browser: "chrome"}); + + const removed = JSON.parse(await readFile(manifestPath, "utf8")); + + expect([...removed.permissions].sort()).toEqual([...retained].sort()); + expect(removed.optional_permissions).toEqual(["topSites"]); + expect(removed.host_permissions).toEqual([retainedHost]); + expect(removed.optional_host_permissions).toBeUndefined(); + expect(removed.content_security_policy).toBeUndefined(); + expect(removed.web_accessible_resources).toBeUndefined(); + } catch (error) { + throw new Error(`${String(error)}\n${output}`, {cause: error}); + } finally { + try { + if (watcher) { + await stop(watcher); + } + } finally { + await fixture.dispose(); + } + } +}); diff --git a/tests/integration/build/permissions/permissions.integration.test.ts b/tests/integration/build/permissions/permissions.integration.test.ts index f0b76d85..772e5c88 100644 --- a/tests/integration/build/permissions/permissions.integration.test.ts +++ b/tests/integration/build/permissions/permissions.integration.test.ts @@ -9,52 +9,108 @@ const fixtureDir = path.join(__dirname, "views"); jest.setTimeout(90_000); const host = "https://*.example.com/*"; +const pageHost = "https://api.example.net/*"; +const firefoxHost = "https://firefox.example.net/*"; +const optionalHost = "https://export.example.org/*"; +const optionalFirefoxHost = "https://optional-firefox.example.org/*"; /** * The application builds two popups and two sidebars (one of each is not applied by default) - * and an options page, and every entrypoint declares its own permissions. + * an options page and two pages, one of which is Firefox-only. Pages are never opened; + * their inclusion in the build is enough to contribute permissions and CSP. */ test.each([ { target: "chrome MV3 collects every built view and the side panel permission", browser: "chrome", manifestVersion: 3, - permissions: ["bookmarks", "downloads", "history", "sidePanel", "storage", "tabs"], - hostPermissions: [host], + permissions: ["alarms", "bookmarks", "downloads", "history", "sidePanel", "storage", "tabs"], + optionalPermissions: ["clipboardWrite", "topSites"], + hostPermissions: [host, pageHost], + optionalHostPermissions: [optionalHost], }, { target: "chrome MV2 has no sidebar, so sidebar permissions are not requested", browser: "chrome", manifestVersion: 2, - permissions: ["downloads", host, "storage", "tabs"], + permissions: ["alarms", "downloads", host, pageHost, "storage", "tabs"], + optionalPermissions: ["clipboardWrite", "topSites", optionalHost], hostPermissions: undefined, + optionalHostPermissions: undefined, }, { target: "firefox MV3 builds the sidebar action without the side panel permission", browser: "firefox", manifestVersion: 3, - permissions: ["bookmarks", "downloads", "history", "storage", "tabs"], - hostPermissions: [host], + permissions: ["alarms", "bookmarks", "downloads", "history", "notifications", "storage", "tabs"], + optionalPermissions: ["clipboardWrite", "idle", "topSites"], + hostPermissions: [host, pageHost, firefoxHost], + optionalHostPermissions: [optionalHost, optionalFirefoxHost], }, { target: "firefox MV2 keeps sidebar permissions and declares hosts as permissions", browser: "firefox", manifestVersion: 2, - permissions: ["bookmarks", "downloads", "history", host, "storage", "tabs"], + permissions: [ + "alarms", + "bookmarks", + "downloads", + "history", + host, + pageHost, + firefoxHost, + "notifications", + "storage", + "tabs", + ], + optionalPermissions: ["clipboardWrite", "idle", "topSites", optionalHost, optionalFirefoxHost], hostPermissions: undefined, + optionalHostPermissions: undefined, }, -] as const)("$target", async ({browser, manifestVersion, permissions, hostPermissions}) => { - const fixture = await createIntegrationFixture(rootDir, fixtureDir); - - try { - const extensionDir = await fixture.build({browser, manifestVersion}); - const manifest = JSON.parse(await readFile(path.join(extensionDir, "manifest.json"), "utf8")); - - expect(manifest.manifest_version).toBe(manifestVersion); - expect([...manifest.permissions].sort()).toEqual([...permissions].sort()); - expect(manifest.optional_permissions).toEqual(["topSites"]); - expect(manifest.host_permissions).toEqual(hostPermissions); - } finally { - await fixture.dispose(); +] as const)( + "$target", + async ({browser, manifestVersion, permissions, optionalPermissions, hostPermissions, optionalHostPermissions}) => { + const fixture = await createIntegrationFixture(rootDir, fixtureDir); + + try { + const extensionDir = await fixture.build({browser, manifestVersion}); + const manifest = JSON.parse(await readFile(path.join(extensionDir, "manifest.json"), "utf8")); + + expect(manifest.manifest_version).toBe(manifestVersion); + expect([...manifest.permissions].sort()).toEqual([...permissions].sort()); + expect([...manifest.optional_permissions].sort()).toEqual([...optionalPermissions].sort()); + expect(manifest.host_permissions?.sort()).toEqual(hostPermissions && [...hostPermissions].sort()); + expect(manifest.optional_host_permissions?.sort()).toEqual( + optionalHostPermissions && [...optionalHostPermissions].sort() + ); + + const csp = + manifestVersion === 2 + ? manifest.content_security_policy + : manifest.content_security_policy.extension_pages; + + const sources = + browser === "firefox" + ? ["https://api.example.net", "https://firefox.example.net"] + : ["https://api.example.net"]; + + const connect = csp + .split(";") + .map((directive: string) => directive.trim().split(/\s+/)) + .find((parts: string[]) => parts[0] === "connect-src"); + + expect(connect?.slice(1).sort()).toEqual(sources.sort()); + expect(await readFile(path.join(extensionDir, "reports.html"), "utf8")).toContain("Reports"); + + if (manifestVersion === 2) { + expect(manifest.web_accessible_resources).toEqual(["reports.html"]); + } else { + expect(manifest.web_accessible_resources).toEqual([ + {resources: ["reports.html"], matches: ["https://site.example.org/*"]}, + ]); + } + } finally { + await fixture.dispose(); + } } -}); +); diff --git a/tests/integration/build/permissions/states/reports.ts b/tests/integration/build/permissions/states/reports.ts new file mode 100644 index 00000000..ff44b0c3 --- /dev/null +++ b/tests/integration/build/permissions/states/reports.ts @@ -0,0 +1,13 @@ +import {definePage} from "adnbn"; + +export default definePage({ + name: "reports", + title: "Reports", + matches: ["https://site.example.org/*"], + permissions: ["idle", "storage"], + optionalPermissions: ["clipboardRead"], + hostPermissions: ["https://changed.example.net/*"], + optionalHostPermissions: ["https://changed.example.org/*"], + csp: {sources: {connect: ["https://changed.example.net"]}}, + render: "Updated reports", +}); diff --git a/tests/integration/build/permissions/views/adnbn.config.ts b/tests/integration/build/permissions/views/adnbn.config.ts index d417f2a4..9b705ac1 100644 --- a/tests/integration/build/permissions/views/adnbn.config.ts +++ b/tests/integration/build/permissions/views/adnbn.config.ts @@ -2,7 +2,7 @@ import {defineConfig} from "adnbn"; export default defineConfig({ name: "View Permissions Integration", - description: "Build fixture collecting permissions declared by popup, sidebar and options entrypoints.", + description: "Build fixture collecting permissions declared by popup, sidebar, options and page entrypoints.", version: "1.0.0", multiplePopup: true, multipleSidebar: true, diff --git a/tests/integration/build/permissions/views/src/notifications.page.ts b/tests/integration/build/permissions/views/src/notifications.page.ts new file mode 100644 index 00000000..e3ab866b --- /dev/null +++ b/tests/integration/build/permissions/views/src/notifications.page.ts @@ -0,0 +1,12 @@ +import {Browser, definePage} from "adnbn"; + +export default definePage({ + name: "notifications", + includeBrowser: [Browser.Firefox], + permissions: ["notifications"], + optionalPermissions: ["idle"], + hostPermissions: ["https://firefox.example.net/*"], + optionalHostPermissions: ["https://optional-firefox.example.org/*"], + csp: {sources: {connect: ["https://firefox.example.net"]}}, + render: "Notifications", +}); diff --git a/tests/integration/build/permissions/views/src/reports.page.ts b/tests/integration/build/permissions/views/src/reports.page.ts new file mode 100644 index 00000000..6253fdf2 --- /dev/null +++ b/tests/integration/build/permissions/views/src/reports.page.ts @@ -0,0 +1,13 @@ +import {definePage} from "adnbn"; + +export default definePage({ + name: "reports", + title: "Reports", + matches: ["https://site.example.org/*"], + permissions: ["alarms", "storage"], + optionalPermissions: ["clipboardWrite"], + hostPermissions: ["https://api.example.net/*"], + optionalHostPermissions: ["https://export.example.org/*"], + csp: {sources: {connect: ["https://api.example.net"]}}, + render: "Reports", +}); diff --git a/tests/integration/types/fixtures/page/definition.ts b/tests/integration/types/fixtures/page/definition.ts new file mode 100644 index 00000000..d6c8d4a4 --- /dev/null +++ b/tests/integration/types/fixtures/page/definition.ts @@ -0,0 +1,21 @@ +import {definePage} from "adnbn"; +import type {PageDefinition, PageProps, PermissionsOptions} from "adnbn"; + +const definition: PageDefinition = { + name: "reports", + permissions: ["storage"], + optionalPermissions: ["downloads"], + hostPermissions: ["https://api.example.com/*"], + optionalHostPermissions: ["https://export.example.com/*"], + csp: {sources: {connect: ["https://api.example.com"]}}, + render: props => { + const permissions: PermissionsOptions = props; + + return permissions.permissions?.join(", ") ?? "Reports"; + }, +}; + +definePage(definition); + +const props: PageProps = {permissions: ["storage"], hostPermissions: ["https://api.example.com/*"]}; +definePage(props); diff --git a/tests/integration/types/page.integration.test.ts b/tests/integration/types/page.integration.test.ts new file mode 100644 index 00000000..2a8a0cf6 --- /dev/null +++ b/tests/integration/types/page.integration.test.ts @@ -0,0 +1,48 @@ +import path from "path"; +import ts from "typescript"; + +describe("page contracts", () => { + const projectDir = path.resolve(__dirname, "../../.."); + const fixture = path.join(__dirname, "fixtures/page/definition.ts"); + + test.each(["source", "package"])( + "checks page permissions, CSP and render props through the %s API", + mode => { + const config = ts.readConfigFile(path.join(projectDir, "tsconfig.json"), ts.sys.readFile); + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, projectDir); + expect(config.error).toBeUndefined(); + expect(parsed.errors).toEqual([]); + + const options: ts.CompilerOptions = { + ...(mode === "source" ? parsed.options : {}), + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + target: ts.ScriptTarget.ESNext, + jsx: ts.JsxEmit.ReactJSX, + strict: true, + noEmit: true, + skipLibCheck: mode === "source", + types: ["node", "chrome"], + }; + + const host = ts.createCompilerHost(options); + const program = ts.createProgram([fixture], options, host); + + const diagnostics = ts.getPreEmitDiagnostics(program).map(diagnostic => { + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); + const position = diagnostic.file?.getLineAndCharacterOfPosition(diagnostic.start ?? 0); + + return `${diagnostic.file?.fileName ?? "compiler"}:${(position?.line ?? 0) + 1}: ${message}`; + }); + + expect(diagnostics).toEqual([]); + + const apiFile = ts.resolveModuleName("adnbn", fixture, options, host).resolvedModule?.resolvedFileName; + + expect(apiFile).toBe( + path.join(projectDir, mode === "source" ? "src/index.ts" : "dist/index.d.ts").replace(/\\/g, "/") + ); + }, + 30_000 + ); +}); From 5f01f88b60914a259e5d2e802620313a80d9a2e5 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:02:40 +0300 Subject: [PATCH 07/24] fix(ci): update actions and synchronize Sass watch tests --- .github/workflows/ci.yml | 66 +++++++++---------- .github/workflows/main-pr.yml | 4 +- .github/workflows/release.yml | 10 +-- tests/README.md | 4 +- .../module-resolution.integration.test.ts | 14 +++- tests/main-source-policy.test.ts | 2 +- 6 files changed, 55 insertions(+), 45 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7460c5f0..36b28bb9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ on: jobs: compute-matrix: name: Compute matrix - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: matrix: ${{ steps.set.outputs.matrix }} name_suffix: ${{ steps.set.outputs.name_suffix }} @@ -29,10 +29,10 @@ jobs: - id: set run: | if [[ "${{ github.event_name }}" != "workflow_call" || "${{ inputs.full }}" == "true" ]]; then - echo 'matrix={"os":["ubuntu-latest","windows-latest"],"node":[22,24]}' >> "$GITHUB_OUTPUT" + echo 'matrix={"os":["ubuntu-24.04","windows-latest"],"node":[22,24]}' >> "$GITHUB_OUTPUT" echo 'name_suffix=(full matrix)' >> "$GITHUB_OUTPUT" else - echo 'matrix={"os":["ubuntu-latest"],"node":[22]}' >> "$GITHUB_OUTPUT" + echo 'matrix={"os":["ubuntu-24.04"],"node":[22]}' >> "$GITHUB_OUTPUT" echo 'name_suffix=' >> "$GITHUB_OUTPUT" fi @@ -47,9 +47,9 @@ jobs: matrix: ${{ fromJSON(needs.compute-matrix.outputs.matrix) }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7.0.0 with: node-version: ${{ matrix.node }} cache: npm @@ -60,14 +60,14 @@ jobs: - name: Verify test groups run: npm run test:inventory - name: Units with coverage (no package build) - if: matrix.os == 'ubuntu-latest' && matrix.node == 22 + if: matrix.os == 'ubuntu-24.04' && matrix.node == 22 run: npm run test:run -- --selectProjects unit-node unit-dom --ci --coverage --coverageDirectory=coverage/unit - name: Units (no package build) - if: matrix.os != 'ubuntu-latest' || matrix.node != 22 + if: matrix.os != 'ubuntu-24.04' || matrix.node != 22 run: npm run test:run -- --selectProjects unit-node unit-dom --ci - name: Upload unit coverage - if: matrix.os == 'ubuntu-latest' && matrix.node == 22 - uses: actions/upload-artifact@v4 + if: matrix.os == 'ubuntu-24.04' && matrix.node == 22 + uses: actions/upload-artifact@v7.0.1 with: name: coverage-unit-${{ matrix.os }}-node${{ matrix.node }} path: coverage/unit/coverage-final.json @@ -83,9 +83,9 @@ jobs: matrix: ${{ fromJSON(needs.compute-matrix.outputs.matrix) }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7.0.0 with: node-version: ${{ matrix.node }} cache: npm @@ -94,18 +94,18 @@ jobs: - name: Build package run: npm run build - name: Compiler, CLI and declaration integrations with coverage - if: matrix.os == 'ubuntu-latest' && matrix.node == 22 + if: matrix.os == 'ubuntu-24.04' && matrix.node == 22 run: npm run test:run -- --selectProjects build types --ci --maxWorkers=2 --coverage --coverageDirectory=coverage/build - name: Compiler, CLI and declaration integrations - if: matrix.os != 'ubuntu-latest' || matrix.node != 22 + if: matrix.os != 'ubuntu-24.04' || matrix.node != 22 run: npm run test:run -- --selectProjects build types --ci --maxWorkers=2 - name: Override builds in isolated sessions run: npm run test:run -- --selectProjects build --testPathPatterns=tests/integration/build/override --ci --maxWorkers=2 env: ADNBN_OVERRIDE_BUILD_MODE: session - name: Upload build coverage - if: matrix.os == 'ubuntu-latest' && matrix.node == 22 - uses: actions/upload-artifact@v4 + if: matrix.os == 'ubuntu-24.04' && matrix.node == 22 + uses: actions/upload-artifact@v7.0.1 with: name: coverage-build-${{ matrix.os }}-node${{ matrix.node }} path: coverage/build/coverage-final.json @@ -121,9 +121,9 @@ jobs: matrix: ${{ fromJSON(needs.compute-matrix.outputs.matrix) }} steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7.0.0 with: node-version: ${{ matrix.node }} cache: npm @@ -135,7 +135,7 @@ jobs: browser: name: Browser (${{ matrix.browser }}, Node ${{ matrix.node }}) needs: compute-matrix - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read strategy: @@ -145,9 +145,9 @@ jobs: browser: [chrome, firefox] steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7.0.0 with: node-version: ${{ matrix.node }} cache: npm @@ -174,7 +174,7 @@ jobs: ADNBN_FIREFOX_BIN: ${{ steps.firefox.outputs.firefox-path }} - name: Upload browser startup timings if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 with: name: browser-startup-${{ matrix.browser }}-node-${{ matrix.node }} path: .cache/integration/browser-startup.json @@ -183,45 +183,45 @@ jobs: retention-days: 7 coverage: - name: Coverage (ubuntu-latest, Node 22) + name: Coverage (ubuntu-24.04, Node 22) needs: [compute-matrix, unit, build] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7.0.0 with: node-version: 22 cache: npm - name: Install dependencies run: npm ci - name: Download unit coverage - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8.0.1 with: - name: coverage-unit-ubuntu-latest-node22 + name: coverage-unit-ubuntu-24.04-node22 path: coverage/unit - name: Download build coverage - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8.0.1 with: - name: coverage-build-ubuntu-latest-node22 + name: coverage-build-ubuntu-24.04-node22 path: coverage/build - name: Merge source coverage from both groups run: node scripts/merge-coverage.mjs coverage/unit/coverage-final.json coverage/build/coverage-final.json - name: Upload coverage - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 with: - name: coverage-ubuntu-latest-node22 + name: coverage-ubuntu-24.04-node22 path: coverage/merged # Keep the existing required check names while the work runs in independent jobs. build-and-test: - name: Test and Build (${{ matrix.os }}, Node ${{ matrix.node }}) ${{ needs.compute-matrix.outputs.name_suffix }} + name: Test and Build (${{ matrix.os == 'ubuntu-24.04' && 'ubuntu-latest' || matrix.os }}, Node ${{ matrix.node }}) ${{ needs.compute-matrix.outputs.name_suffix }} if: ${{ always() && needs.compute-matrix.result == 'success' }} needs: [compute-matrix, unit, build, fixtures, browser, coverage] - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 strategy: fail-fast: false matrix: ${{ fromJSON(needs.compute-matrix.outputs.matrix) }} diff --git a/.github/workflows/main-pr.yml b/.github/workflows/main-pr.yml index f0ae4a37..06f90346 100644 --- a/.github/workflows/main-pr.yml +++ b/.github/workflows/main-pr.yml @@ -12,11 +12,11 @@ permissions: jobs: source-policy: name: Main PR source (develop only) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 timeout-minutes: 5 steps: - name: Checkout trusted policy - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.workflow_sha }} persist-credentials: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 785f6b91..60fde19e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,12 +29,12 @@ jobs: if: >- github.ref == 'refs/heads/main' && github.actor != format('{0}[bot]', vars.RELEASE_APP_SLUG) - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 outputs: should_release: ${{ steps.policy.outputs.should_release }} steps: - name: Checkout trusted workflow revision - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 with: ref: ${{ github.workflow_sha }} persist-credentials: false @@ -55,7 +55,7 @@ jobs: release: name: Release & Publish - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 needs: [policy, ci] if: needs.policy.outputs.should_release == 'true' environment: release @@ -73,14 +73,14 @@ jobs: permission-contents: write - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 with: fetch-depth: 0 fetch-tags: true token: ${{ steps.release-app-token.outputs.token }} - name: Use Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7.0.0 with: node-version: 22 cache: 'npm' diff --git a/tests/README.md b/tests/README.md index 758798b3..053a1dd3 100644 --- a/tests/README.md +++ b/tests/README.md @@ -169,9 +169,9 @@ Pre-push runs `npm run check`, without browser launches or coverage. It includes ## CI and coverage -CI runs units, compiler/build/declaration tests, fixture typechecks, Chrome and Firefox in separate jobs. All non-browser groups retain the Linux/Windows × Node 22/24 matrix; both browsers run on Linux for each Node version. Existing `Test and Build` check names now aggregate the entire matrix, not only the OS/Node named in each check; they fail if any required job fails, is cancelled or is skipped. A full workflow contains 22 jobs including matrix computation and aggregation, with at most 16 test jobs ready together before coverage/aggregation dependencies finish. +CI runs units, compiler/build/declaration tests, fixture typechecks, Chrome and Firefox in separate jobs. All non-browser groups retain the Linux/Windows × Node 22/24 matrix; both browsers run on Linux for each Node version. Linux jobs use `ubuntu-24.04`. Existing `Test and Build` check names retain their `ubuntu-latest` label for compatibility and aggregate the entire matrix, not only the OS/Node named in each check; they fail if any required job fails, is cancelled or is skipped. A full workflow contains 22 jobs including matrix computation and aggregation, with at most 16 test jobs ready together before coverage/aggregation dependencies finish. -Babel coverage is collected in the Ubuntu/Node 22 unit and build jobs, then merged once with `scripts/merge-coverage.mjs`. The other matrix cells run the same tests without coverage instrumentation. The published report is `coverage-ubuntu-latest-node22`; platform-specific coverage reports are no longer produced. Moving compiler tests between groups must not remove their source coverage. Keep both `coverage-final.json` inputs when merging locally: +Babel coverage is collected in the Ubuntu/Node 22 unit and build jobs, then merged once with `scripts/merge-coverage.mjs`. The other matrix cells run the same tests without coverage instrumentation. The published report is `coverage-ubuntu-24.04-node22`; platform-specific coverage reports are no longer produced. Moving compiler tests between groups must not remove their source coverage. Keep both `coverage-final.json` inputs when merging locally: ```sh npm run test:run -- --selectProjects unit-node unit-dom --coverage --coverageDirectory=coverage/unit diff --git a/tests/integration/build/style/module-resolution.integration.test.ts b/tests/integration/build/style/module-resolution.integration.test.ts index af92fbf6..b2dd5b50 100644 --- a/tests/integration/build/style/module-resolution.integration.test.ts +++ b/tests/integration/build/style/module-resolution.integration.test.ts @@ -149,8 +149,18 @@ test.each([ }, 10_000); complete = (error, stats) => { - if (!error && stats && stats.hasErrors() !== expectErrors) { - return; + if (!error && stats) { + if (stats.hasErrors() !== expectErrors) { + return; + } + + // A setup-triggered rebuild may succeed before polling observes the new file. + // Advance only after the watcher reports this phase's actual file change. + const changes = expectErrors ? compiler?.removedFiles : compiler?.modifiedFiles; + + if (update && !changes?.has(index)) { + return; + } } clearTimeout(timer); diff --git a/tests/main-source-policy.test.ts b/tests/main-source-policy.test.ts index dcbdfb72..73247702 100644 --- a/tests/main-source-policy.test.ts +++ b/tests/main-source-policy.test.ts @@ -87,7 +87,7 @@ describe("main source policy workflow", () => { expect(policy.permissions).toEqual({contents: "read"}); expect(job.steps).toEqual([ expect.objectContaining({ - uses: "actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683", + uses: "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1", with: {ref: "${{ github.workflow_sha }}", "persist-credentials": false}, }), expect.objectContaining({ From 752f9ec944a5ec144b7ff9c23ee7136f3da54031 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:36:38 +0300 Subject: [PATCH 08/24] refactor(view)!: require synchronous render handlers Keep asynchronous containers and offscreen/sandbox init and main unchanged. Warn when an untyped Vanilla render returns a Promise or thenable. BREAKING CHANGE: ViewRenderHandler no longer accepts Promise results and ViewRenderReactComponent now requires a synchronous component instead of FC. Move asynchronous loading to a separate function or a React effect and return the initial UI synchronously. Type reusable components with ViewRenderReactComponent, keep their inferred return type, or wrap an existing synchronous React.FC with props => . --- src/entry/view/Builder.ts | 2 +- src/entry/view/README.md | 60 +++++++++++++++++-- src/entry/view/adapters/react/Builder.test.ts | 37 +++++++++++- src/entry/view/adapters/react/Builder.ts | 2 +- .../view/adapters/vanilla/Builder.test.ts | 55 ++++++++++++++++- src/entry/view/adapters/vanilla/Builder.ts | 18 +++++- src/types/view/adapters/react.ts | 5 +- src/types/view/render.ts | 6 +- tests/integration/README.md | 2 +- .../types/fixtures/html/options.ts | 2 +- .../types/fixtures/view/definition.tsx | 38 ++++++++++-- .../types/view.integration.test.ts | 2 +- 12 files changed, 201 insertions(+), 28 deletions(-) diff --git a/src/entry/view/Builder.ts b/src/entry/view/Builder.ts index 90e164ba..d73eff7a 100644 --- a/src/entry/view/Builder.ts +++ b/src/entry/view/Builder.ts @@ -71,7 +71,7 @@ export default abstract class Builder extends EntrypointBu } const props = this.getProps(); - const value = await render(props); + const value = render(props); if (value === undefined) { return; diff --git a/src/entry/view/README.md b/src/entry/view/README.md index 41b8e39e..22a16f63 100644 --- a/src/entry/view/README.md +++ b/src/entry/view/README.md @@ -25,16 +25,34 @@ export default definePopup({ ```ts title="src/help.page.ts" export const title = "Help"; -export default async () => { - const response = await fetch("/help.json"); +const loadTitle = async (heading: HTMLHeadingElement) => { + try { + const response = await fetch("/help.json"); + + if (!response.ok) { + throw new Error(`Loading help failed: ${response.status}`); + } + + heading.textContent = (await response.json()).title; + } catch { + heading.textContent = "Help is unavailable"; + } +}; + +export default () => { const heading = document.createElement("h1"); + heading.textContent = "Loading help"; - heading.textContent = (await response.json()).title; + void loadTitle(heading); return heading; }; ``` +`render` returns synchronously. Start asynchronous work separately and update the returned DOM element, +or use state and effects in a React component. The framework does not await a render result and provides +no `prepare` hook for views. A container factory may still be asynchronous. + ## Adapter selection and generated modules The entrypoint filename selects the renderer adapter: `.tsx` and `.jsx` use React @@ -71,19 +89,23 @@ container through [`resolvers/container.ts`](resolvers/container.ts) and the ren 1. destroys the previous run and sets `document.title` from `title`; 2. calls the render with the definition options as props (the definition without `render` and - `container`); a view renders once, so the handler may be asynchronous; + `container`); the normalized handler returns its value synchronously; 3. creates nothing when the render or its value is absent; -4. creates the container, prepends it to `document.body`, and only then calls the adapter's `mount()`, so a +4. awaits the container, prepends it to `document.body`, and only then calls the adapter's `mount()`, so a renderer may measure connected DOM. `destroy()` calls the adapter's `unmount()` and removes the container, even when `unmount()` throws. An adapter implements only what depends on its framework: - `resolveRender()` decides what the render input means and drops values the adapter cannot render. Vanilla - calls a function and uses its (awaited) result; React treats a function as a component invoked by React, + calls a function and uses its immediate result; React treats a function as a component invoked by React, so it may use hooks. - `mount()` puts the value into the container. React creates a root for a React node; `unmount()` unmounts it. +`build()` and `destroy()` retain their asynchronous lifecycle contract. Offscreen and sandbox still await +their transport's `init` and `main` before building the view. A synchronous render does not bypass those +steps or force React to commit immediately. + Every adapter renders the framework-independent values the same way, shared with the content adapters through [`src/entry/core/render.ts`](../core/render.ts): @@ -110,6 +132,32 @@ View contracts live in `src/types/view`, organized like the content contracts: sandbox), `ViewDefinition`, the resolved definition and the injected builder constructor. - `index.ts` exports the contracts through `@typing/view`. +Use the public generic types to annotate reusable functions with the entrypoint's props: + +```tsx title="src/report.page.tsx" +import {definePage, type PageProps, type ViewRenderReactComponent} from "adnbn"; + +import React from "react"; + +const Report: ViewRenderReactComponent = ({title}) =>

{title}

; + +export default definePage({title: "Report", render: Report}); +``` + +For a Vanilla handler, use `ViewRenderHandler`; for another entrypoint, use its corresponding +props type, such as `PopupProps`. Both render contracts reject Promise results, including `Promise`. +Ordinary synchronous functions also work without an explicit annotation: TypeScript infers their result. + +React 19's `React.FC` permits a Promise result, so that annotation is too broad for a view render. +Use `ViewRenderReactComponent`, keep the inferred function type, or wrap an existing synchronous +component with `render: props => `. A JSX wrapper does not make an async client +component supported. React owns component invocation and hooks. + +These restrictions are checked through the public TypeScript contracts. JavaScript without type checking, +untyped exports and `any` can bypass them. If Vanilla receives a Promise or thenable result, it warns in +the console and ignores that result without awaiting it or creating a container. React owns component +invocation and its diagnostics; Addon Bone does not inspect async functions before invoking them. + ## Adding an adapter Add `adapters//` with `Builder.ts` (extending the common `Builder` with `resolveRender()`, `mount()` and, diff --git a/src/entry/view/adapters/react/Builder.test.ts b/src/entry/view/adapters/react/Builder.test.ts index ec9973b5..093ba5b4 100644 --- a/src/entry/view/adapters/react/Builder.test.ts +++ b/src/entry/view/adapters/react/Builder.test.ts @@ -1,10 +1,10 @@ import {act} from "@testing-library/react"; -import {createElement, Fragment, useEffect} from "react"; +import {createElement, Fragment, useEffect, useState} from "react"; import {createPortal} from "react-dom"; import Builder from "./Builder"; -import type {ViewConfig} from "@typing/view"; +import type {ViewConfig, ViewRenderReactComponent} from "@typing/view"; describe("React view Builder", () => { afterEach(() => { @@ -36,6 +36,39 @@ describe("React view Builder", () => { await act(() => builder.destroy()); }); + test("renders typed props before data loads and updates state from an asynchronous effect", async () => { + const response = Promise.withResolvers(); + + const Component: ViewRenderReactComponent = ({title}) => { + const [text, setText] = useState("Loading"); + + useEffect(() => { + const load = async () => { + setText(await response.promise); + }; + + void load(); + }, []); + + return createElement("p", null, `${title}: ${text}`); + }; + + const builder = new Builder({title: "Report", render: Component}); + + await act(() => builder.build()); + + expect(document.body.textContent).toBe("Report: Loading"); + + await act(async () => { + response.resolve("Loaded"); + await response.promise; + }); + + expect(document.body.textContent).toBe("Report: Loaded"); + + await act(() => builder.destroy()); + }); + test("destroy unmounts the React tree before removing the container", async () => { const events: string[] = []; diff --git a/src/entry/view/adapters/react/Builder.ts b/src/entry/view/adapters/react/Builder.ts index ddd1800b..938fe97e 100644 --- a/src/entry/view/adapters/react/Builder.ts +++ b/src/entry/view/adapters/react/Builder.ts @@ -20,7 +20,7 @@ export default class Builder extends ViewBuilder { return; } - return async props => { + return props => { // Functions in React entrypoints are components; React owns their invocation and hooks. const value = typeof render === "function" ? createElement(render as ViewRenderReactComponent, props) : render; diff --git a/src/entry/view/adapters/vanilla/Builder.test.ts b/src/entry/view/adapters/vanilla/Builder.test.ts index eb8ab57a..93ea19f7 100644 --- a/src/entry/view/adapters/vanilla/Builder.test.ts +++ b/src/entry/view/adapters/vanilla/Builder.test.ts @@ -1,6 +1,6 @@ import Builder from "./Builder"; -import type {ViewConfig} from "@typing/view"; +import type {ViewConfig, ViewRenderHandler} from "@typing/view"; describe("Vanilla view Builder", () => { afterEach(() => { @@ -47,6 +47,59 @@ describe("Vanilla view Builder", () => { expect(document.body.firstElementChild?.outerHTML).toBe('
content
'); }); + test("awaits an asynchronous container before mounting the synchronous render value", async () => { + const started = Promise.withResolvers(); + const ready = Promise.withResolvers(); + const container = document.createElement("section"); + + const builder = new Builder({ + title: "Async container", + render: "content", + container: async ({title}) => { + container.title = title ?? ""; + started.resolve(); + + return await ready.promise; + }, + }); + + const building = builder.build(); + await started.promise; + + expect(document.body.children).toHaveLength(0); + + ready.resolve(container); + await building; + + expect(document.body.firstElementChild).toBe(container); + expect(container.title).toBe("Async container"); + expect(container.textContent).toBe("content"); + + await builder.destroy(); + }); + + test.each([ + ["a Promise", Promise.withResolvers().promise], + ["a thenable", {then: jest.fn()}], + ])("warns and skips the container without awaiting %s from an untyped render", async (_, value) => { + const warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + const container = jest.fn(() => document.createElement("section")); + const render = (() => value) as unknown as ViewRenderHandler; + const builder = new Builder({render, container}); + + try { + await builder.build(); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("Vanilla view render must be synchronous")); + expect(container).not.toHaveBeenCalled(); + expect(document.body.children).toHaveLength(0); + } finally { + warn.mockRestore(); + await builder.destroy(); + } + }); + test.each([ ["without render", undefined], ["for an unsupported value", () => ({}) as unknown as string], diff --git a/src/entry/view/adapters/vanilla/Builder.ts b/src/entry/view/adapters/vanilla/Builder.ts index 1c0beeb2..42ad967a 100644 --- a/src/entry/view/adapters/vanilla/Builder.ts +++ b/src/entry/view/adapters/vanilla/Builder.ts @@ -14,8 +14,22 @@ export default class Builder extends ViewBuilder { return; } - return async props => { - const value = typeof render === "function" ? await render(props) : render; + return props => { + const value = typeof render === "function" ? render(props) : render; + + if ( + value !== null && + (typeof value === "object" || typeof value === "function") && + "then" in value && + typeof value.then === "function" + ) { + console.warn( + "Vanilla view render must be synchronous. The Promise or thenable result was ignored. " + + "Load data in a separate async function and return the initial UI immediately." + ); + + return; + } return isDomRenderValue(value) ? value : undefined; }; diff --git a/src/types/view/adapters/react.ts b/src/types/view/adapters/react.ts index 319f0d65..a82e4ec6 100644 --- a/src/types/view/adapters/react.ts +++ b/src/types/view/adapters/react.ts @@ -1,8 +1,9 @@ -import type {FC, ReactNode} from "react"; +import type {ReactNode} from "react"; import type {ViewConfig} from "../common"; -export type ViewRenderReactComponent = FC; +/** A synchronous client component with the view options as props, invoked by React. */ +export type ViewRenderReactComponent = (props: T) => Exclude>; export type ViewReactRenderValue = | Exclude> diff --git a/src/types/view/render.ts b/src/types/view/render.ts index 2ac67b0e..cdf5a3e3 100644 --- a/src/types/view/render.ts +++ b/src/types/view/render.ts @@ -1,10 +1,8 @@ -import type {Awaiter} from "@typing/helpers"; - import type {ViewConfig} from "./common"; import type {ViewReactRenderValue, ViewVanillaRenderValue} from "./adapters"; /** All supported render values; the entrypoint's filename selects the runtime adapter. */ export type ViewRenderValue = ViewVanillaRenderValue | ViewReactRenderValue; -/** A view document renders once, so its handler may await data before returning the value. */ -export type ViewRenderHandler = (props: T) => Awaiter>; +/** Synchronous rendering with the view options as props. Load data separately and update the rendered UI. */ +export type ViewRenderHandler = (props: T) => void | ViewRenderValue; diff --git a/tests/integration/README.md b/tests/integration/README.md index e7ea25af..e09cc6d5 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -251,7 +251,7 @@ Parallel test sessions use distinct working directories; production directory na - `types/content`: shared Content and adapter render types through the public source and built package APIs, callback props inference, and iframe-navigation restrictions for both define functions. - [types/html.integration.test.ts](types/html.integration.test.ts): static entrypoint options, configuration callbacks, object metadata, and rejected string metadata through both source and built declarations. Fixtures live in `types/fixtures/html`. -- `types/view`: shared View and adapter render types through the public source and built package APIs, render and container props inference, the render contract adopted by Offscreen and Sandbox, and the rejection of Promise and plain-object render values. +- `types/view`: shared synchronous View and adapter render types through the public source and built package APIs, reusable component and handler types with props, asynchronous container factories, the render contract adopted by Offscreen and Sandbox alongside asynchronous transport hooks, and the rejection of Promise-returning handlers/components, broad React 19 `FC` types, Promise values and plain objects. - `types/page`: Page accepts the shared permissions contract alongside CSP and render props through the public source and built package APIs. - `types/registries`: generated registry augmentation, empty fallbacks, public and internal type agreement, and message contracts against source and built package APIs. Compiler-host path checks cover both slash styles. - [build/html](build/html): emitted metadata and asset tags, all four `config.html` forms, page-specific tag selection, and representative failures from configuration, entrypoint parsing, and plugin validation. Invalid inputs live in its own `scenarios` directory. Parser field matrices belong beside `ViewParser`; validation rules owned by `@rspackjs/plugin-html-tags` belong in that package's tests. No browser is launched. diff --git a/tests/integration/types/fixtures/html/options.ts b/tests/integration/types/fixtures/html/options.ts index 71609a41..0c38165b 100644 --- a/tests/integration/types/fixtures/html/options.ts +++ b/tests/integration/types/fixtures/html/options.ts @@ -11,7 +11,7 @@ const options: HtmlEntrypointOptions = { publicPath: "/", }; -definePage({...options, render: async () => "Page"}); +definePage({...options, render: () => "Page"}); definePage({metas: meta}); definePage({metas: [meta]}); diff --git a/tests/integration/types/fixtures/view/definition.tsx b/tests/integration/types/fixtures/view/definition.tsx index 24d7df7e..94c3b84b 100644 --- a/tests/integration/types/fixtures/view/definition.tsx +++ b/tests/integration/types/fixtures/view/definition.tsx @@ -1,4 +1,5 @@ import {createPortal} from "react-dom"; +import type {FC} from "react"; import {defineOffscreen, definePopup, defineSandbox} from "adnbn"; import type { @@ -41,20 +42,20 @@ for (const render of values) { definePopup(definition); } -const handler: ViewRenderHandler = async ({title}) => title ?? document.createElement("p"); +const handler: ViewRenderHandler = ({title}) => title ?? document.createElement("p"); definePopup({render: handler}); definePopup({ title: "Popup", - container: props => { + container: async props => { const title: string | undefined = props.title; return {tagName: "section", title: title ?? ""}; }, - render: async props => { + render: props => { // @ts-expect-error: Render handlers receive the view options as props. props.missing; @@ -63,10 +64,35 @@ definePopup({ }); // Entrypoints that host a view adopt the same render contract. -defineOffscreen({init: () => ({}), render: () => "Offscreen"}); -defineSandbox({init: () => ({}), render: Sandbox}); +defineOffscreen({init: async () => ({}), main: async () => {}, render: () => "Offscreen"}); +defineSandbox({init: async () => ({}), main: async () => {}, render: Sandbox}); -// @ts-expect-error: A Promise is not a render value; await data inside a render handler instead. +const emptyHandler: ViewRenderHandler = () => {}; +definePopup({render: emptyHandler}); + +// @ts-expect-error: Render handlers return their value synchronously. +const asyncHandler: ViewRenderHandler = async () => "text"; + +// @ts-expect-error: Returning a Promise is also invalid without an async modifier. +const promiseHandler: ViewRenderHandler = () => Promise.resolve("text"); + +// @ts-expect-error: A handler that returns no content must still be synchronous. +const asyncEmptyHandler: ViewRenderHandler = async () => {}; + +// @ts-expect-error: View components are synchronous client components. +const AsyncPanel: ViewRenderReactComponent = async ({title}) => {title}; + +// @ts-expect-error: The component branch must not allow async render through a define helper. +definePopup({render: async () => Async}); + +const WidePanel: FC = Panel; + +// @ts-expect-error: React 19 FC can return a Promise, so it does not guarantee synchronous rendering. +definePopup({render: WidePanel}); + +definePopup({render: props => }); + +// @ts-expect-error: A Promise is not a render value; load data separately from render. definePopup({render: Promise.resolve("text")}); // @ts-expect-error: A plain object is neither a DOM nor a React render value. diff --git a/tests/integration/types/view.integration.test.ts b/tests/integration/types/view.integration.test.ts index 3df8802a..106f2fba 100644 --- a/tests/integration/types/view.integration.test.ts +++ b/tests/integration/types/view.integration.test.ts @@ -6,7 +6,7 @@ describe("view contracts", () => { const fixture = path.join(__dirname, "fixtures/view/definition.tsx"); test.each(["source", "package"])( - "checks view definitions and adapter render values through the %s API", + "checks synchronous view render contracts and asynchronous containers through the %s API", mode => { const config = ts.readConfigFile(path.join(projectDir, "tsconfig.json"), ts.sys.readFile); const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, projectDir); From 70d249f58c72a8996bf7912505c2dfc191374ac3 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:28:35 +0300 Subject: [PATCH 09/24] fix(manifest): preserve explicit background persistence in MV2 --- src/cli/builders/manifest/ManifestBase.ts | 2 +- src/cli/builders/manifest/ManifestV2.test.ts | 14 ++++++ src/cli/builders/manifest/ManifestV3.test.ts | 16 ++++++ .../parser/BackgroundParser.test.ts | 8 +++ .../background/options/defaults/background.ts | 3 ++ .../options/event-page/background.ts | 5 ++ .../background/BackgroundEntry.test.ts | 28 +++++++++++ src/cli/plugins/background/BackgroundEntry.ts | 9 +++- .../background/BackgroundManifest.test.ts | 49 +++++++++++++++++++ .../plugins/background/BackgroundManifest.ts | 14 ++++-- .../background/tests/TestBackgroundFinder.ts | 27 ++++++++++ .../build/background/fixture/adnbn.config.ts | 7 +++ .../build/background/fixture/package.json | 9 ++++ .../background/fixture/src/background.ts | 5 ++ .../build/background/fixture/tsconfig.json | 3 ++ .../background/persistent.integration.test.ts | 41 ++++++++++++++++ 16 files changed, 234 insertions(+), 6 deletions(-) create mode 100644 src/cli/entrypoint/parser/tests/fixtures/background/options/defaults/background.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/background/options/event-page/background.ts create mode 100644 src/cli/plugins/background/BackgroundEntry.test.ts create mode 100644 src/cli/plugins/background/BackgroundManifest.test.ts create mode 100644 src/cli/plugins/background/tests/TestBackgroundFinder.ts create mode 100644 tests/integration/build/background/fixture/adnbn.config.ts create mode 100644 tests/integration/build/background/fixture/package.json create mode 100644 tests/integration/build/background/fixture/src/background.ts create mode 100644 tests/integration/build/background/fixture/tsconfig.json create mode 100644 tests/integration/build/background/persistent.integration.test.ts diff --git a/src/cli/builders/manifest/ManifestBase.ts b/src/cli/builders/manifest/ManifestBase.ts index a4075a11..d9b06abd 100644 --- a/src/cli/builders/manifest/ManifestBase.ts +++ b/src/cli/builders/manifest/ManifestBase.ts @@ -569,7 +569,7 @@ export default abstract class implements ManifestBuilder const scripts = Array.from(dependencies.js); - return {background: {scripts, persistent: persistent || undefined}}; + return {background: {scripts, persistent}}; } } diff --git a/src/cli/builders/manifest/ManifestV2.test.ts b/src/cli/builders/manifest/ManifestV2.test.ts index da7b2d45..6aa25df6 100644 --- a/src/cli/builders/manifest/ManifestV2.test.ts +++ b/src/cli/builders/manifest/ManifestV2.test.ts @@ -28,6 +28,20 @@ describe("ManifestV2", () => { }); }); + it.each([true, false, undefined])("serializes background persistence %s without coercion", persistent => { + const manifest = new ManifestV2(Browser.Chrome) + .setDependencies(new Map([["background", dependency(["background.js"])]])) + .setBackground({entry: "background", ...(persistent === undefined ? {} : {persistent})}) + .build(); + + const serialized = JSON.parse(JSON.stringify(manifest)); + + expect(serialized.background).toEqual({ + scripts: ["background.js"], + ...(persistent === undefined ? {} : {persistent}), + }); + }); + it("builds browser_action from popup and selected icons", () => { const manifest: any = new ManifestV2(Browser.Chrome) .setName("Popup Addon") diff --git a/src/cli/builders/manifest/ManifestV3.test.ts b/src/cli/builders/manifest/ManifestV3.test.ts index de8f958c..acff05a6 100644 --- a/src/cli/builders/manifest/ManifestV3.test.ts +++ b/src/cli/builders/manifest/ManifestV3.test.ts @@ -35,6 +35,22 @@ describe("ManifestV3", () => { expect(manifest.background).toEqual({scripts: ["background.js"], persistent: undefined}); }); + describe.each([Browser.Chrome, Browser.Firefox])("%s background serialization", browser => { + it.each([true, false, undefined])("omits persistence %s from MV3", persistent => { + const manifest = new ManifestV3(browser) + .setDependencies(new Map([["background", dependency(["background.js"])]])) + .setBackground({entry: "background", ...(persistent === undefined ? {} : {persistent})}) + .build(); + + const serialized = JSON.parse(JSON.stringify(manifest)); + + expect(serialized.background).toEqual( + browser === Browser.Firefox ? {scripts: ["background.js"]} : {service_worker: "background.js"} + ); + expect(serialized.background).not.toHaveProperty("persistent"); + }); + }); + it("builds action from popup and selected icons", () => { const manifest: any = new ManifestV3(Browser.Chrome) .setName("Popup Addon") diff --git a/src/cli/entrypoint/parser/BackgroundParser.test.ts b/src/cli/entrypoint/parser/BackgroundParser.test.ts index 154667f0..ffb5f85a 100644 --- a/src/cli/entrypoint/parser/BackgroundParser.test.ts +++ b/src/cli/entrypoint/parser/BackgroundParser.test.ts @@ -32,4 +32,12 @@ describe("BackgroundParser", () => { test("rejects a non-boolean persistent value", () => { expect(() => parseOptions("invalid", "persistent.ts")).toThrow("Invalid options persistent"); }); + + test("preserves explicit false", () => { + expect(parseOptions("options", "event-page", "background.ts")).toEqual({persistent: false}); + }); + + test("leaves persistence unspecified by default", () => { + expect(parseOptions("options", "defaults", "background.ts")).toEqual({}); + }); }); diff --git a/src/cli/entrypoint/parser/tests/fixtures/background/options/defaults/background.ts b/src/cli/entrypoint/parser/tests/fixtures/background/options/defaults/background.ts new file mode 100644 index 00000000..ddce2b70 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/background/options/defaults/background.ts @@ -0,0 +1,3 @@ +import {defineBackground} from "adnbn"; + +export default defineBackground({}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/background/options/event-page/background.ts b/src/cli/entrypoint/parser/tests/fixtures/background/options/event-page/background.ts new file mode 100644 index 00000000..6f223a32 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/background/options/event-page/background.ts @@ -0,0 +1,5 @@ +import {defineBackground} from "adnbn"; + +export default defineBackground({ + persistent: false, +}); diff --git a/src/cli/plugins/background/BackgroundEntry.test.ts b/src/cli/plugins/background/BackgroundEntry.test.ts new file mode 100644 index 00000000..2d735ff0 --- /dev/null +++ b/src/cli/plugins/background/BackgroundEntry.test.ts @@ -0,0 +1,28 @@ +import BackgroundEntry from "./BackgroundEntry"; +import TestBackgroundFinder from "./tests/TestBackgroundFinder"; + +import type {BackgroundEntrypointOptions} from "@typing/background"; + +interface PersistenceScenario { + name: string; + options: BackgroundEntrypointOptions[]; + expected: boolean | undefined; +} + +describe("BackgroundEntry", () => { + test.each([ + {name: "no entries", options: [], expected: undefined}, + {name: "explicit true", options: [{persistent: true}], expected: true}, + {name: "explicit false", options: [{persistent: false}], expected: false}, + {name: "omitted option", options: [{}], expected: undefined}, + {name: "false before omitted", options: [{persistent: false}, {}], expected: false}, + {name: "false after omitted", options: [{}, {persistent: false}], expected: false}, + {name: "true before false", options: [{persistent: true}, {persistent: false}], expected: true}, + {name: "true after false", options: [{persistent: false}, {persistent: true}], expected: true}, + {name: "all options omitted", options: [{}, {}], expected: undefined}, + ])("resolves persistence for $name", async ({options, expected}) => { + const entry = new BackgroundEntry(new TestBackgroundFinder(options)); + + await expect(entry.isPersistent()).resolves.toBe(expected); + }); +}); diff --git a/src/cli/plugins/background/BackgroundEntry.ts b/src/cli/plugins/background/BackgroundEntry.ts index d625be8c..b3b9a7bf 100644 --- a/src/cli/plugins/background/BackgroundEntry.ts +++ b/src/cli/plugins/background/BackgroundEntry.ts @@ -15,10 +15,15 @@ export default class BackgroundEntry { return new Map([[BackgroundEntry.name, new Set(options.keys())]]); } - public async isPersistent(): Promise { + public async isPersistent(): Promise { const options = await this.finder.plugin().options(); + const persistent = Array.from(options.values(), ({persistent}) => persistent); - return Array.from(options.values()).some(({persistent}) => persistent); + if (persistent.includes(true)) { + return true; + } + + return persistent.includes(false) ? false : undefined; } public async getPermissions(): Promise { diff --git a/src/cli/plugins/background/BackgroundManifest.test.ts b/src/cli/plugins/background/BackgroundManifest.test.ts new file mode 100644 index 00000000..7815479d --- /dev/null +++ b/src/cli/plugins/background/BackgroundManifest.test.ts @@ -0,0 +1,49 @@ +import BackgroundEntry from "./BackgroundEntry"; +import BackgroundManifest from "./BackgroundManifest"; +import TestBackgroundFinder from "./tests/TestBackgroundFinder"; + +import type {BackgroundEntrypointOptions} from "@typing/background"; + +interface PersistenceScenario { + name: string; + groups: BackgroundEntrypointOptions[][]; + expected: boolean | undefined; +} + +describe("BackgroundManifest", () => { + test.each([ + {name: "no groups", groups: [], expected: undefined}, + {name: "empty groups", groups: [[], [], []], expected: undefined}, + {name: "explicit true", groups: [[{persistent: true}]], expected: true}, + {name: "explicit false", groups: [[{persistent: false}]], expected: false}, + {name: "omitted option", groups: [[{}]], expected: undefined}, + {name: "false in Background", groups: [[{persistent: false}], [{}], [{}]], expected: false}, + {name: "false in Command", groups: [[{}], [{persistent: false}], [{}]], expected: false}, + {name: "false in Service", groups: [[{}], [{}], [{persistent: false}]], expected: false}, + {name: "false with empty groups", groups: [[], [{persistent: false}], []], expected: false}, + { + name: "true in Background overrides false", + groups: [[{persistent: true}], [{persistent: false}], [{persistent: false}]], + expected: true, + }, + { + name: "true in Command overrides false", + groups: [[{persistent: false}], [{persistent: true}], [{persistent: false}]], + expected: true, + }, + { + name: "true in Service overrides false", + groups: [[{persistent: false}], [{persistent: false}], [{persistent: true}]], + expected: true, + }, + {name: "all options omitted", groups: [[{}], [{}], [{}]], expected: undefined}, + ])("resolves persistence for $name", async ({groups, expected}) => { + const manifest = new BackgroundManifest(); + + for (const options of groups) { + manifest.add(new BackgroundEntry(new TestBackgroundFinder(options))); + } + + await expect(manifest.isPersistent()).resolves.toBe(expected); + }); +}); diff --git a/src/cli/plugins/background/BackgroundManifest.ts b/src/cli/plugins/background/BackgroundManifest.ts index 043e7134..b7c15759 100644 --- a/src/cli/plugins/background/BackgroundManifest.ts +++ b/src/cli/plugins/background/BackgroundManifest.ts @@ -22,14 +22,22 @@ export default class { return false; } - public async isPersistent(): Promise { + public async isPersistent(): Promise { + let persistent: boolean | undefined; + for await (const entry of this.entries) { - if (await entry.isPersistent()) { + const value = await entry.isPersistent(); + + if (value === true) { return true; } + + if (value === false) { + persistent = false; + } } - return false; + return persistent; } public async getPermissions(): Promise { diff --git a/src/cli/plugins/background/tests/TestBackgroundFinder.ts b/src/cli/plugins/background/tests/TestBackgroundFinder.ts new file mode 100644 index 00000000..c42b4d34 --- /dev/null +++ b/src/cli/plugins/background/tests/TestBackgroundFinder.ts @@ -0,0 +1,27 @@ +import path from "path"; + +import {BackgroundFinder} from "@cli/entrypoint"; + +import type {BackgroundEntrypointOptions} from "@typing/background"; +import type {ReadonlyConfig} from "@typing/config"; +import type {EntrypointFile, EntrypointOptionsFinder} from "@typing/entrypoint"; + +export default class TestBackgroundFinder extends BackgroundFinder { + public constructor(private readonly entries: BackgroundEntrypointOptions[]) { + super({rootDir: path.resolve(__dirname)} as ReadonlyConfig); + } + + public plugin(): EntrypointOptionsFinder { + return this; + } + + public async options(): Promise> { + return new Map( + this.entries.map((options, index) => { + const file = path.resolve(__dirname, `${index}.background.ts`); + + return [{file, import: file}, options]; + }) + ); + } +} diff --git a/tests/integration/build/background/fixture/adnbn.config.ts b/tests/integration/build/background/fixture/adnbn.config.ts new file mode 100644 index 00000000..131e780f --- /dev/null +++ b/tests/integration/build/background/fixture/adnbn.config.ts @@ -0,0 +1,7 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "Background Persistence Integration", + description: "Build fixture preserving explicit background persistence.", + version: "1.0.0", +}); diff --git a/tests/integration/build/background/fixture/package.json b/tests/integration/build/background/fixture/package.json new file mode 100644 index 00000000..fd6cac52 --- /dev/null +++ b/tests/integration/build/background/fixture/package.json @@ -0,0 +1,9 @@ +{ + "name": "adnbn-test-background-persistence", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../.." + } +} diff --git a/tests/integration/build/background/fixture/src/background.ts b/tests/integration/build/background/fixture/src/background.ts new file mode 100644 index 00000000..6f223a32 --- /dev/null +++ b/tests/integration/build/background/fixture/src/background.ts @@ -0,0 +1,5 @@ +import {defineBackground} from "adnbn"; + +export default defineBackground({ + persistent: false, +}); diff --git a/tests/integration/build/background/fixture/tsconfig.json b/tests/integration/build/background/fixture/tsconfig.json new file mode 100644 index 00000000..eadf1be8 --- /dev/null +++ b/tests/integration/build/background/fixture/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.adnbn/tsconfig.json" +} diff --git a/tests/integration/build/background/persistent.integration.test.ts b/tests/integration/build/background/persistent.integration.test.ts new file mode 100644 index 00000000..b60e2afb --- /dev/null +++ b/tests/integration/build/background/persistent.integration.test.ts @@ -0,0 +1,41 @@ +import {readFile} from "fs/promises"; +import path from "path"; + +import {createIntegrationFixture} from "../../utils/fixture"; + +const rootDir = path.resolve(__dirname, "../../../.."); +const fixtureDir = path.join(__dirname, "fixture"); + +jest.setTimeout(90_000); + +test.each([ + {browser: "chrome", manifestVersion: 2 as const}, + {browser: "chrome", manifestVersion: 3 as const}, + {browser: "firefox", manifestVersion: 3 as const}, +])("builds persistent false for $browser MV$manifestVersion", async ({browser, manifestVersion}) => { + const fixture = await createIntegrationFixture(rootDir, fixtureDir); + + try { + const extensionDir = await fixture.build({browser, manifestVersion}); + const manifest = JSON.parse(await readFile(path.join(extensionDir, "manifest.json"), "utf8")); + + expect(manifest.manifest_version).toBe(manifestVersion); + expect(manifest.background).toEqual( + manifestVersion === 2 + ? {scripts: [expect.stringMatching(/\.js$/)], persistent: false} + : browser === "firefox" + ? {scripts: [expect.stringMatching(/\.js$/)]} + : {service_worker: expect.stringMatching(/\.js$/)} + ); + + if (manifestVersion === 3) { + expect(manifest.background).not.toHaveProperty("persistent"); + } + + const script = manifest.background.service_worker ?? manifest.background.scripts[0]; + + expect(await readFile(path.join(extensionDir, script), "utf8")).not.toBe(""); + } finally { + await fixture.dispose(); + } +}); From e9691da62a2ec8ecc851e1bc9c40363aafd4deed Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:50:25 +0300 Subject: [PATCH 10/24] feat(offscreen): support entrypoint permissions --- .../entrypoint/finder/OffscreenViewFinder.ts | 4 +- .../finder/ViewPermissionsFinder.ts | 2 +- .../entrypoint/parser/OffscreenParser.test.ts | 6 +- src/cli/entrypoint/parser/OffscreenParser.ts | 54 +++++++------ .../offscreen/options/full/audio.offscreen.ts | 4 + src/cli/plugins/offscreen/index.ts | 9 ++- src/types/offscreen.ts | 11 ++- tests/integration/README.md | 6 +- .../permissions.integration.test.ts | 78 +++++++++++++++---- .../build/permissions/states/processor.ts | 10 +++ ...test.ts => view-watch.integration.test.ts} | 47 ++++++++--- .../build/permissions/views/adnbn.config.ts | 3 +- .../views/src/firefox.offscreen.ts | 11 +++ .../views/src/processor.offscreen.ts | 10 +++ .../types/fixtures/view/definition.tsx | 17 ++++ .../types/view.integration.test.ts | 2 +- 16 files changed, 211 insertions(+), 63 deletions(-) create mode 100644 tests/integration/build/permissions/states/processor.ts rename tests/integration/build/permissions/{page-watch.integration.test.ts => view-watch.integration.test.ts} (65%) create mode 100644 tests/integration/build/permissions/views/src/firefox.offscreen.ts create mode 100644 tests/integration/build/permissions/views/src/processor.offscreen.ts diff --git a/src/cli/entrypoint/finder/OffscreenViewFinder.ts b/src/cli/entrypoint/finder/OffscreenViewFinder.ts index 3a12aa17..a8d0ccce 100644 --- a/src/cli/entrypoint/finder/OffscreenViewFinder.ts +++ b/src/cli/entrypoint/finder/OffscreenViewFinder.ts @@ -1,11 +1,11 @@ -import ViewCspFinder from "./ViewCspFinder"; +import ViewPermissionsFinder from "./ViewPermissionsFinder"; import AbstractTransportFinder from "./AbstractTransportFinder"; import {ReadonlyConfig} from "@typing/config"; import {OffscreenEntrypointOptions} from "@typing/offscreen"; import {EntrypointOptionsFinder, EntrypointParser, EntrypointType} from "@typing/entrypoint"; -export default class extends ViewCspFinder { +export default class OffscreenViewFinder extends ViewPermissionsFinder { constructor( config: ReadonlyConfig, protected readonly finder: AbstractTransportFinder diff --git a/src/cli/entrypoint/finder/ViewPermissionsFinder.ts b/src/cli/entrypoint/finder/ViewPermissionsFinder.ts index 47b9aaf6..fbba1bb0 100644 --- a/src/cli/entrypoint/finder/ViewPermissionsFinder.ts +++ b/src/cli/entrypoint/finder/ViewPermissionsFinder.ts @@ -10,7 +10,7 @@ type PermissionsEntrypointOptions = CspEntrypointOptions & PermissionsOptions; * Views that opt into the permissions contract. * * Only views that reach the build contribute: every built view when several are allowed, - * because any of them can be switched to at runtime, and the single winner otherwise. + * and the single winner otherwise. Their contribution does not depend on runtime use. */ export default abstract class ViewPermissionsFinder< O extends PermissionsEntrypointOptions, diff --git a/src/cli/entrypoint/parser/OffscreenParser.test.ts b/src/cli/entrypoint/parser/OffscreenParser.test.ts index d9bcbc64..2d490e13 100644 --- a/src/cli/entrypoint/parser/OffscreenParser.test.ts +++ b/src/cli/entrypoint/parser/OffscreenParser.test.ts @@ -25,7 +25,7 @@ describe("OffscreenParser", () => { expect(parser.options(file("options", "defaults", "audio.offscreen.ts"))).toEqual({}); }); - test("parses defineOffscreen with its name, reasons and inherited view and CSP options", () => { + test("parses defineOffscreen with its name, reasons, permissions and inherited view and CSP options", () => { expect(parser.options(file("options", "full", "audio.offscreen.ts"))).toEqual({ name: "audio", reasons: ["AUDIO_PLAYBACK", "BLOBS"], @@ -34,6 +34,10 @@ describe("OffscreenParser", () => { title: "Extension panel", template: "./template.html", includeBrowser: ["chrome"], + permissions: ["clipboardRead"], + optionalPermissions: ["clipboardWrite"], + hostPermissions: ["https://api.example.com/*"], + optionalHostPermissions: ["https://optional.example.com/*"], csp: {sources: {connect: ["'self'", "https://api.example.com"]}}, links: "extra.css", append: false, diff --git a/src/cli/entrypoint/parser/OffscreenParser.ts b/src/cli/entrypoint/parser/OffscreenParser.ts index 0f6dcb0d..e32ef363 100644 --- a/src/cli/entrypoint/parser/OffscreenParser.ts +++ b/src/cli/entrypoint/parser/OffscreenParser.ts @@ -1,10 +1,11 @@ import z from "zod"; import ViewCspParser from "./ViewCspParser"; +import {PermissionsSchema} from "./schemas/permissions"; import {OffscreenEntrypointOptions, OffscreenReason} from "@typing/offscreen"; -export default class extends ViewCspParser { +export default class OffscreenParser extends ViewCspParser { protected definition(): string { return "defineOffscreen"; } @@ -16,30 +17,33 @@ export default class extends ViewCspParser { protected schema(): typeof this.CommonPropertiesSchema { const reasonEnumValues = Object.values(OffscreenReason); - return super.schema().extend({ - name: z - .string() - .trim() - .min(1) - .max(100) - .regex(/^[\p{L}_$][\p{L}\p{N}_$]*$/u, { - message: - "Key must start with a Unicode letter, `$` or `_`, and may only contain letters, digits, `$` or `_`", - }) - .optional(), - reasons: z - .union( - [ - z.enum(reasonEnumValues as [string, ...string[]]), - z.array(z.enum(reasonEnumValues as [string, ...string[]])), - ], - { + return super + .schema() + .merge(PermissionsSchema) + .extend({ + name: z + .string() + .trim() + .min(1) + .max(100) + .regex(/^[\p{L}_$][\p{L}\p{N}_$]*$/u, { message: - 'The "reasons" field must be a valid OffscreenReason enum value or array of enum values', - } - ) - .optional(), - justification: z.string().trim().optional(), - }); + "Key must start with a Unicode letter, `$` or `_`, and may only contain letters, digits, `$` or `_`", + }) + .optional(), + reasons: z + .union( + [ + z.enum(reasonEnumValues as [string, ...string[]]), + z.array(z.enum(reasonEnumValues as [string, ...string[]])), + ], + { + message: + 'The "reasons" field must be a valid OffscreenReason enum value or array of enum values', + } + ) + .optional(), + justification: z.string().trim().optional(), + }); } } diff --git a/src/cli/entrypoint/parser/tests/fixtures/offscreen/options/full/audio.offscreen.ts b/src/cli/entrypoint/parser/tests/fixtures/offscreen/options/full/audio.offscreen.ts index 546058af..db3addc4 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/offscreen/options/full/audio.offscreen.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/offscreen/options/full/audio.offscreen.ts @@ -8,6 +8,10 @@ export default defineOffscreen({ title: "Extension panel", template: "./template.html", includeBrowser: [Browser.Chrome], + permissions: ["clipboardRead"], + optionalPermissions: ["clipboardWrite"], + hostPermissions: ["https://api.example.com/*"], + optionalHostPermissions: ["https://optional.example.com/*"], csp: {sources: {connect: [CspSource.Self, "https://api.example.com"]}}, links: "extra.css", append: false, diff --git a/src/cli/plugins/offscreen/index.ts b/src/cli/plugins/offscreen/index.ts index 889f1c40..15aa1696 100644 --- a/src/cli/plugins/offscreen/index.ts +++ b/src/cli/plugins/offscreen/index.ts @@ -98,7 +98,14 @@ export default definePlugin(() => { } satisfies RspackConfig; }, manifest: async ({manifest, config}) => { - manifest.appendCsp(await offscreen.views().csp()); + const views = offscreen.views(); + + manifest + .appendCsp(await views.csp()) + .appendPermissions(await views.permissions()) + .appendOptionalPermissions(await views.optionalPermissions()) + .appendHostPermissions(await views.hostPermissions()) + .appendOptionalHostPermissions(await views.optionalHostPermissions()); if (config.manifestVersion !== 2 && config.browser !== Browser.Firefox && (await offscreen.exists())) { manifest.addPermission("offscreen"); diff --git a/src/types/offscreen.ts b/src/types/offscreen.ts index 50e6eac1..5f02d645 100644 --- a/src/types/offscreen.ts +++ b/src/types/offscreen.ts @@ -8,6 +8,7 @@ import { import {ViewOptions, ViewRenderDefinition} from "@typing/view"; import {Awaiter} from "@typing/helpers"; import {CspOptions} from "@typing/csp"; +import type {PermissionsOptions} from "@typing/permissions"; export const OffscreenGlobalKey = "adnbnOffscreen"; @@ -73,7 +74,15 @@ export interface OffscreenConfig extends TransportConfig { justification?: string; } -export type OffscreenOptions = OffscreenConfig & CspOptions & ViewOptions; +/** + * Each offscreen document included in a build contributes its permissions and CSP to the extension manifest, + * even if it is never created. Permissions and CSP apply to the extension, not just this document. + * + * A native Chrome offscreen document can call only the `runtime` extension API. Declare what its feature needs, + * such as `clipboardWrite` or the hosts it fetches. The framework adds the `offscreen` permission automatically + * according to the target browser and manifest version; do not declare it manually in cross-browser builds. + */ +export type OffscreenOptions = OffscreenConfig & PermissionsOptions & CspOptions & ViewOptions; export type OffscreenEntrypointOptions = Partial; diff --git a/tests/integration/README.md b/tests/integration/README.md index e09cc6d5..b2bc45cc 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -51,7 +51,7 @@ tests/integration/ │ │ └── history/ │ ├── permissions/ │ │ ├── permissions.integration.test.ts -│ │ ├── page-watch.integration.test.ts +│ │ ├── view-watch.integration.test.ts │ │ ├── states/ │ │ └── views/ │ ├── style/ @@ -251,7 +251,7 @@ Parallel test sessions use distinct working directories; production directory na - `types/content`: shared Content and adapter render types through the public source and built package APIs, callback props inference, and iframe-navigation restrictions for both define functions. - [types/html.integration.test.ts](types/html.integration.test.ts): static entrypoint options, configuration callbacks, object metadata, and rejected string metadata through both source and built declarations. Fixtures live in `types/fixtures/html`. -- `types/view`: shared synchronous View and adapter render types through the public source and built package APIs, reusable component and handler types with props, asynchronous container factories, the render contract adopted by Offscreen and Sandbox alongside asynchronous transport hooks, and the rejection of Promise-returning handlers/components, broad React 19 `FC` types, Promise values and plain objects. +- `types/view`: shared synchronous View and adapter render types through the public source and built package APIs, reusable component and handler types with props, asynchronous container factories, the render contract adopted by Offscreen and Sandbox alongside asynchronous transport hooks, Offscreen permissions alongside CSP and callback options, the rejection of permissions in Sandbox, and the rejection of Promise-returning handlers/components, broad React 19 `FC` types, Promise values and plain objects. - `types/page`: Page accepts the shared permissions contract alongside CSP and render props through the public source and built package APIs. - `types/registries`: generated registry augmentation, empty fallbacks, public and internal type agreement, and message contracts against source and built package APIs. Compiler-host path checks cover both slash styles. - [build/html](build/html): emitted metadata and asset tags, all four `config.html` forms, page-specific tag selection, and representative failures from configuration, entrypoint parsing, and plugin validation. Invalid inputs live in its own `scenarios` directory. Parser field matrices belong beside `ViewParser`; validation rules owned by `@rspackjs/plugin-html-tags` belong in that package's tests. No browser is launched. @@ -259,7 +259,7 @@ Parallel test sessions use distinct working directories; production directory na - `browser/view`: one Chrome MV3 case covering React offscreen and sandbox views rendered by the injected builder with their props and titles, a headless offscreen without a view container, and strings rendered as text by the React view, Vanilla view and React content adapters. - `browser/options`: two Chrome MV3 cases covering Vanilla and React rendering, CSS, state/events, opening Options from background, and a View chunk shared with a Page. - `build/override`: separate New Tab, Bookmarks, History and competing-entrypoint files let Jest schedule the scenarios across workers. The files share assertions, while each case keeps its own application copy and build. Thirty manifest checks cover the New Tab, Bookmarks, and History applications across Chrome, Edge, Opera, Safari, and Firefox in MV2 and MV3. Supporting browsers receive `chrome_url_overrides`, the page HTML, its CSP, and the permission declared by the entrypoint; the others receive none of them. Three more cases add the History entrypoint to a copy of the New Tab application: Chrome and Edge builds fail naming both entrypoints, while Firefox keeps the New Tab with its own permission and without the History one. No browser is launched. -- `build/permissions/views`: four manifest checks of one application with two popups, two sidebars, an options page and two Pages that declare permissions. Every built view contributes, including a popup and a sidebar that are not applied by default and Pages that are never opened. Chrome MV2 has no sidebar, so its permissions are not requested; Firefox builds the sidebar action without `sidePanel`. A Firefox-only Page contributes no permissions or CSP to Chrome. Tests cover required and optional API/host permissions, MV2 host placement, Page CSP and independent WAR matches. `page-watch.integration.test.ts` checks that editing a Page replaces its permissions and CSP during watch. It then stops the watcher, deletes the Page and checks a fresh build, preserving other views' requirements. Entrypoint deletion during watch is not covered: `EntrypointPlugin` currently checks `modifiedFiles` without `removedFiles`. No browser is launched. +- `build/permissions/views`: four manifest checks of one application with two popups, two sidebars, an options page, two Pages and two Offscreens that declare permissions. Every built view contributes, including a popup and a sidebar that are not applied by default and Pages and Offscreens that are never opened. Chrome MV2 has no sidebar, so its permissions are not requested; Firefox builds the sidebar action without `sidePanel`. A Firefox-only Page and Offscreen contribute no permissions or CSP to Chrome. Tests cover required and optional API/host permissions, MV2 host placement, merged CSP and independent Page WAR matches. The automatic `offscreen` permission is present only in Chrome MV3. `view-watch.integration.test.ts` checks that editing a Page and then an Offscreen replaces their permissions and CSP during watch. It then stops the watcher, deletes both entries and checks a fresh build, preserving other views' requirements and dropping the automatic `offscreen` permission. Entrypoint deletion during watch is not covered: `EntrypointPlugin` currently checks `modifiedFiles` without `removedFiles`. No browser is launched. - `build/style`: shared and app stylesheet merging with `mergeStyles` through the production style and asset rules. Two applications with the same layout and different fonts each receive one CSS module per destination, the same class identifier in ordinary and `?unisolated` output, and their own font. Tests cover resources declared in partials, passed through `with`, and reused through a complete font-face mixin; standalone styles with merging disabled; and production/development source maps. Generated styles exercise aliases, `?asis`, and an existing external `resolve-url-loader`. A native Node build checks the built framework and ESM loader. App overrides with a namespace conflict, a missing module, or invalid SCSS fail the build. Watch mode tracks override creation, edits, imported partials, resources, removal, recreation, the appearance of a local module for a bare Sass request, and recovery after creating a missing module for an explicit relative request. No browser is launched. - `browser/override`: three Chrome MV3 cases opening `chrome://newtab`, `chrome://bookmarks`, and `chrome://history`. They verify that Chrome serves the extension page, React and Vanilla rendering, CSS, state/events, custom `as` and `htmlDir` naming, and a View chunk shared with a Page. - `browser/offscreen/service`: one Chrome MV3 round trip from background through Offscreen to a registered background service. diff --git a/tests/integration/build/permissions/permissions.integration.test.ts b/tests/integration/build/permissions/permissions.integration.test.ts index 772e5c88..55be195b 100644 --- a/tests/integration/build/permissions/permissions.integration.test.ts +++ b/tests/integration/build/permissions/permissions.integration.test.ts @@ -13,28 +13,42 @@ const pageHost = "https://api.example.net/*"; const firefoxHost = "https://firefox.example.net/*"; const optionalHost = "https://export.example.org/*"; const optionalFirefoxHost = "https://optional-firefox.example.org/*"; +const offscreenHost = "https://offscreen.example.net/*"; +const optionalOffscreenHost = "https://optional-offscreen.example.org/*"; +const firefoxOffscreenHost = "https://firefox-offscreen.example.net/*"; +const optionalFirefoxOffscreenHost = "https://optional-firefox-offscreen.example.org/*"; /** * The application builds two popups and two sidebars (one of each is not applied by default) - * an options page and two pages, one of which is Firefox-only. Pages are never opened; - * their inclusion in the build is enough to contribute permissions and CSP. + * an options page, two pages and two offscreens. One page and one offscreen are Firefox-only. + * Pages and offscreens are never opened; their inclusion in the build is enough to contribute permissions and CSP. */ test.each([ { target: "chrome MV3 collects every built view and the side panel permission", browser: "chrome", manifestVersion: 3, - permissions: ["alarms", "bookmarks", "downloads", "history", "sidePanel", "storage", "tabs"], - optionalPermissions: ["clipboardWrite", "topSites"], - hostPermissions: [host, pageHost], - optionalHostPermissions: [optionalHost], + permissions: [ + "alarms", + "bookmarks", + "downloads", + "history", + "offscreen", + "sidePanel", + "storage", + "tabs", + "geolocation", + ], + optionalPermissions: ["clipboardWrite", "topSites", "contextMenus"], + hostPermissions: [host, pageHost, offscreenHost], + optionalHostPermissions: [optionalHost, optionalOffscreenHost], }, { target: "chrome MV2 has no sidebar, so sidebar permissions are not requested", browser: "chrome", manifestVersion: 2, - permissions: ["alarms", "downloads", host, pageHost, "storage", "tabs"], - optionalPermissions: ["clipboardWrite", "topSites", optionalHost], + permissions: ["alarms", "downloads", host, pageHost, offscreenHost, "storage", "tabs", "geolocation"], + optionalPermissions: ["clipboardWrite", "topSites", "contextMenus", optionalHost, optionalOffscreenHost], hostPermissions: undefined, optionalHostPermissions: undefined, }, @@ -42,10 +56,25 @@ test.each([ target: "firefox MV3 builds the sidebar action without the side panel permission", browser: "firefox", manifestVersion: 3, - permissions: ["alarms", "bookmarks", "downloads", "history", "notifications", "storage", "tabs"], - optionalPermissions: ["clipboardWrite", "idle", "topSites"], - hostPermissions: [host, pageHost, firefoxHost], - optionalHostPermissions: [optionalHost, optionalFirefoxHost], + permissions: [ + "alarms", + "bookmarks", + "downloads", + "history", + "notifications", + "storage", + "tabs", + "geolocation", + "webRequest", + ], + optionalPermissions: ["clipboardWrite", "sessions", "idle", "topSites", "contextMenus"], + hostPermissions: [host, pageHost, firefoxHost, offscreenHost, firefoxOffscreenHost], + optionalHostPermissions: [ + optionalHost, + optionalFirefoxHost, + optionalOffscreenHost, + optionalFirefoxOffscreenHost, + ], }, { target: "firefox MV2 keeps sidebar permissions and declares hosts as permissions", @@ -59,11 +88,25 @@ test.each([ host, pageHost, firefoxHost, + offscreenHost, + firefoxOffscreenHost, "notifications", "storage", "tabs", + "geolocation", + "webRequest", + ], + optionalPermissions: [ + "clipboardWrite", + "sessions", + "idle", + "topSites", + "contextMenus", + optionalHost, + optionalFirefoxHost, + optionalOffscreenHost, + optionalFirefoxOffscreenHost, ], - optionalPermissions: ["clipboardWrite", "idle", "topSites", optionalHost, optionalFirefoxHost], hostPermissions: undefined, optionalHostPermissions: undefined, }, @@ -91,8 +134,13 @@ test.each([ const sources = browser === "firefox" - ? ["https://api.example.net", "https://firefox.example.net"] - : ["https://api.example.net"]; + ? [ + "https://api.example.net", + "https://firefox.example.net", + "https://offscreen.example.net", + "https://firefox-offscreen.example.net", + ] + : ["https://api.example.net", "https://offscreen.example.net"]; const connect = csp .split(";") diff --git a/tests/integration/build/permissions/states/processor.ts b/tests/integration/build/permissions/states/processor.ts new file mode 100644 index 00000000..5440f115 --- /dev/null +++ b/tests/integration/build/permissions/states/processor.ts @@ -0,0 +1,10 @@ +import {defineOffscreen} from "adnbn"; + +export default defineOffscreen({ + permissions: ["management", "storage"], + optionalPermissions: ["cookies"], + hostPermissions: ["https://changed-offscreen.example.net/*"], + optionalHostPermissions: ["https://optional-changed-offscreen.example.org/*"], + csp: {sources: {connect: ["https://changed-offscreen.example.net"]}}, + init: () => ({version: 1}), +}); diff --git a/tests/integration/build/permissions/page-watch.integration.test.ts b/tests/integration/build/permissions/view-watch.integration.test.ts similarity index 65% rename from tests/integration/build/permissions/page-watch.integration.test.ts rename to tests/integration/build/permissions/view-watch.integration.test.ts index cd3d8c4d..34355cbe 100644 --- a/tests/integration/build/permissions/page-watch.integration.test.ts +++ b/tests/integration/build/permissions/view-watch.integration.test.ts @@ -7,7 +7,7 @@ import {stop, waitFor} from "../../browser/utils/browser"; jest.setTimeout(90_000); -test("CLI watch replaces page permissions and a fresh build drops a removed page", async () => { +test("CLI watch replaces page and offscreen permissions and a fresh build drops removed views", async () => { const root = path.resolve(__dirname, "../../../.."); const fixture = await createIntegrationFixture(root, path.join(__dirname, "views")); let watcher: ChildProcess | undefined; @@ -17,6 +17,7 @@ test("CLI watch replaces page permissions and a fresh build drops a removed page const directory = await fixture.build({browser: "chrome"}); const manifestPath = path.join(directory, "manifest.json"); const entry = path.join(fixture.directory, "src", "reports.page.ts"); + const offscreen = path.join(fixture.directory, "src", "processor.offscreen.ts"); await rm(manifestPath); watcher = spawn(process.execPath, [path.join(root, "bin", "adnbn.js"), "watch", ".", "-b", "chrome"], { @@ -61,7 +62,7 @@ test("CLI watch replaces page permissions and a fresh build drops a removed page return manifest; }, 15_000, - `CLI watch page permissions after ${state}` + `CLI watch view permissions after ${state}` ); const retained = ["bookmarks", "downloads", "history", "sidePanel", "storage", "tabs"]; @@ -70,31 +71,53 @@ test("CLI watch replaces page permissions and a fresh build drops a removed page await observe( "startup", 0, - [...retained, "alarms"], - ["clipboardWrite", "topSites"], - [retainedHost, "https://api.example.net/*"], - ["https://export.example.org/*"] + [...retained, "alarms", "offscreen", "geolocation"], + ["clipboardWrite", "topSites", "contextMenus"], + [retainedHost, "https://api.example.net/*", "https://offscreen.example.net/*"], + ["https://export.example.org/*", "https://optional-offscreen.example.org/*"] ); const beforeEdit = completedBuilds(); await writeFile(entry, await readFile(path.join(__dirname, "states", "reports.ts"))); const changed = await observe( - "edit", + "page edit", beforeEdit, - [...retained, "idle"], - ["clipboardRead", "topSites"], - [retainedHost, "https://changed.example.net/*"], - ["https://changed.example.org/*"] + [...retained, "idle", "offscreen", "geolocation"], + ["clipboardRead", "topSites", "contextMenus"], + [retainedHost, "https://changed.example.net/*", "https://offscreen.example.net/*"], + ["https://changed.example.org/*", "https://optional-offscreen.example.org/*"] ); - expect(changed.content_security_policy.extension_pages).toContain("connect-src https://changed.example.net;"); + expect(changed.content_security_policy.extension_pages).toContain("https://changed.example.net"); + expect(changed.content_security_policy.extension_pages).toContain("https://offscreen.example.net"); expect(changed.content_security_policy.extension_pages).not.toContain("https://api.example.net"); + const beforeOffscreenEdit = completedBuilds(); + await writeFile(offscreen, await readFile(path.join(__dirname, "states", "processor.ts"))); + + const changedOffscreen = await observe( + "offscreen edit", + beforeOffscreenEdit, + [...retained, "idle", "offscreen", "management"], + ["clipboardRead", "topSites", "cookies"], + [retainedHost, "https://changed.example.net/*", "https://changed-offscreen.example.net/*"], + ["https://changed.example.org/*", "https://optional-changed-offscreen.example.org/*"] + ); + + expect(changedOffscreen.content_security_policy.extension_pages).toContain("https://changed.example.net"); + + expect(changedOffscreen.content_security_policy.extension_pages).toContain( + "https://changed-offscreen.example.net" + ); + + expect(changedOffscreen.content_security_policy.extension_pages).not.toContain("https://offscreen.example.net"); + // Entrypoint deletion is checked after stopping watch, as in the Relay integration. await stop(watcher); watcher = undefined; await rm(entry); + await rm(offscreen); await fixture.build({browser: "chrome"}); const removed = JSON.parse(await readFile(manifestPath, "utf8")); diff --git a/tests/integration/build/permissions/views/adnbn.config.ts b/tests/integration/build/permissions/views/adnbn.config.ts index 9b705ac1..de2af0d0 100644 --- a/tests/integration/build/permissions/views/adnbn.config.ts +++ b/tests/integration/build/permissions/views/adnbn.config.ts @@ -2,7 +2,8 @@ import {defineConfig} from "adnbn"; export default defineConfig({ name: "View Permissions Integration", - description: "Build fixture collecting permissions declared by popup, sidebar, options and page entrypoints.", + description: + "Build fixture collecting permissions declared by popup, sidebar, options, page and offscreen entrypoints.", version: "1.0.0", multiplePopup: true, multipleSidebar: true, diff --git a/tests/integration/build/permissions/views/src/firefox.offscreen.ts b/tests/integration/build/permissions/views/src/firefox.offscreen.ts new file mode 100644 index 00000000..4bb0bfbe --- /dev/null +++ b/tests/integration/build/permissions/views/src/firefox.offscreen.ts @@ -0,0 +1,11 @@ +import {Browser, defineOffscreen} from "adnbn"; + +export default defineOffscreen({ + includeBrowser: [Browser.Firefox], + permissions: ["webRequest"], + optionalPermissions: ["sessions"], + hostPermissions: ["https://firefox-offscreen.example.net/*"], + optionalHostPermissions: ["https://optional-firefox-offscreen.example.org/*"], + csp: {sources: {connect: ["https://firefox-offscreen.example.net"]}}, + init: () => ({version: 1}), +}); diff --git a/tests/integration/build/permissions/views/src/processor.offscreen.ts b/tests/integration/build/permissions/views/src/processor.offscreen.ts new file mode 100644 index 00000000..3f714e83 --- /dev/null +++ b/tests/integration/build/permissions/views/src/processor.offscreen.ts @@ -0,0 +1,10 @@ +import {defineOffscreen} from "adnbn"; + +export default defineOffscreen({ + permissions: ["geolocation", "storage"], + optionalPermissions: ["contextMenus"], + hostPermissions: ["https://offscreen.example.net/*"], + optionalHostPermissions: ["https://optional-offscreen.example.org/*"], + csp: {sources: {connect: ["https://offscreen.example.net"]}}, + init: () => ({version: 1}), +}); diff --git a/tests/integration/types/fixtures/view/definition.tsx b/tests/integration/types/fixtures/view/definition.tsx index 94c3b84b..3456712d 100644 --- a/tests/integration/types/fixtures/view/definition.tsx +++ b/tests/integration/types/fixtures/view/definition.tsx @@ -67,6 +67,23 @@ definePopup({ defineOffscreen({init: async () => ({}), main: async () => {}, render: () => "Offscreen"}); defineSandbox({init: async () => ({}), main: async () => {}, render: Sandbox}); +defineOffscreen({ + permissions: ["clipboardRead"], + optionalPermissions: ["clipboardWrite"], + hostPermissions: ["https://api.example.com/*"], + optionalHostPermissions: ["https://optional.example.com/*"], + csp: {sources: {connect: ["https://api.example.com"]}}, + init: options => ({hosts: options.hostPermissions ?? []}), + main: (instance, options) => { + const hosts: string[] = instance.hosts; + const optionalHosts: string[] | undefined = options.optionalHostPermissions; + }, + render: props => props.permissions?.join(", "), +}); + +// @ts-expect-error: Sandbox does not adopt the permissions contract. +defineSandbox({permissions: ["storage"], init: () => ({})}); + const emptyHandler: ViewRenderHandler = () => {}; definePopup({render: emptyHandler}); diff --git a/tests/integration/types/view.integration.test.ts b/tests/integration/types/view.integration.test.ts index 106f2fba..85e2fa91 100644 --- a/tests/integration/types/view.integration.test.ts +++ b/tests/integration/types/view.integration.test.ts @@ -6,7 +6,7 @@ describe("view contracts", () => { const fixture = path.join(__dirname, "fixtures/view/definition.tsx"); test.each(["source", "package"])( - "checks synchronous view render contracts and asynchronous containers through the %s API", + "checks synchronous view renders, asynchronous containers and entrypoint permissions through the %s API", mode => { const config = ts.readConfigFile(path.join(projectDir, "tsconfig.json"), ts.sys.readFile); const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, projectDir); From 764a7163cdd8f159fef05416a24c0dc909d7bd52 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Wed, 7 Oct 2026 19:06:39 +0300 Subject: [PATCH 11/24] fix(config): pass initial config to user callback --- src/cli/resolvers/config.ts | 1 + .../build/cli/config.integration.test.ts | 140 ++++++++++++++++++ .../build/cli/fixtures/config/adnbn.config.ts | 29 ++++ .../cli/fixtures/config/invalid.config.ts | 3 + .../cli/fixtures/config/no-argument.config.ts | 6 + .../cli/fixtures/config/object.config.ts | 6 + .../build/cli/scripts/inspect-config.mjs | 7 + 7 files changed, 192 insertions(+) create mode 100644 tests/integration/build/cli/config.integration.test.ts create mode 100644 tests/integration/build/cli/fixtures/config/adnbn.config.ts create mode 100644 tests/integration/build/cli/fixtures/config/invalid.config.ts create mode 100644 tests/integration/build/cli/fixtures/config/no-argument.config.ts create mode 100644 tests/integration/build/cli/fixtures/config/object.config.ts create mode 100644 tests/integration/build/cli/scripts/inspect-config.mjs diff --git a/src/cli/resolvers/config.ts b/src/cli/resolvers/config.ts index 3191a733..5a0c7124 100644 --- a/src/cli/resolvers/config.ts +++ b/src/cli/resolvers/config.ts @@ -77,6 +77,7 @@ const getUserConfig = async (config: ReadonlyConfig): Promise => { const {config: userConfig} = await loadConfig({ configFile: configFilePath, dotenv: false, + context: config, }); if (config.debug) { diff --git a/tests/integration/build/cli/config.integration.test.ts b/tests/integration/build/cli/config.integration.test.ts new file mode 100644 index 00000000..e62937c3 --- /dev/null +++ b/tests/integration/build/cli/config.integration.test.ts @@ -0,0 +1,140 @@ +import {spawnSync} from "node:child_process"; +import {readFile} from "node:fs/promises"; +import path from "node:path"; + +import {createIntegrationFixture, type IntegrationFixture} from "../../utils/fixture"; +import type {OptionalConfig} from "@typing/config"; +import {Browser} from "@typing/browser"; + +const projectRoot = path.resolve(__dirname, "../../../.."); +const resolver = path.join(projectRoot, "dist", "cli", "resolvers", "config.js"); +const cli = path.join(projectRoot, "bin", "adnbn.js"); + +describe("user configuration through real c12", () => { + let fixture: IntegrationFixture; + + beforeEach(async () => { + fixture = await createIntegrationFixture(projectRoot, path.join(__dirname, "fixtures", "config")); + }); + + afterEach(async () => { + await fixture.dispose(); + }); + + const run = (args: string[]) => { + const result = spawnSync(process.execPath, args, { + cwd: fixture.directory, + encoding: "utf8", + timeout: 30_000, + maxBuffer: 4 * 1024 * 1024, + }); + + expect(result.error).toBeUndefined(); + expect(result.signal).toBeNull(); + + return result; + }; + + const inspect = (options: OptionalConfig = {}, configFile = "adnbn.config.ts") => { + return run([ + path.join(__dirname, "scripts", "inspect-config.mjs"), + resolver, + fixture.directory, + configFile, + JSON.stringify(options), + ]); + }; + + const readJson = async (...parts: string[]) => { + return JSON.parse(await readFile(path.join(fixture.directory, ...parts), "utf8")); + }; + + test.each([ + {browser: undefined, expectedBrowser: "chrome", name: "My Extension"}, + {browser: Browser.Firefox, expectedBrowser: "firefox", name: "My Extension for Firefox"}, + ])( + "passes the initial $expectedBrowser config and merges the callback result", + async ({browser, expectedBrowser, name}) => { + const result = inspect({ + browser, + name: "Launch name", + version: "9.0.0", + srcDir: "addon", + plugins: [{name: "option-plugin"}], + }); + + expect({status: result.status, stderr: result.stderr}).toEqual({status: 0, stderr: ""}); + + const initial = await readJson("callback-input.json"); + + expect(initial).toMatchObject({ + rootDir: fixture.directory, + configFile: "adnbn.config.ts", + browser: expectedBrowser, + name: "Launch name", + version: "9.0.0", + command: "build", + mode: "development", + app: "myapp", + manifestVersion: 3, + lang: "en", + workspace: "single", + sharedDir: "shared", + srcDir: "addon", + outDir: "dist", + mergeStyles: true, + plugins: ["option-plugin"], + }); + + const baseline = inspect({}, "object.config.ts"); + + expect({status: baseline.status, stderr: baseline.stderr}).toEqual({status: 0, stderr: ""}); + expect(JSON.parse(result.stdout)).toEqual({ + ...initial, + name, + version: "1.2.3", + lang: "fr", + sharedDir: ".", + plugins: ["option-plugin", "config-fixture", ...JSON.parse(baseline.stdout).plugins], + }); + } + ); + + test.each([ + {configFile: "object.config.ts", name: "Object configuration"}, + {configFile: "no-argument.config.ts", name: "Callback without an argument"}, + ])("continues to load $configFile", ({configFile, name}) => { + const result = inspect({browser: Browser.Firefox}, configFile); + + expect({status: result.status, stderr: result.stderr}).toEqual({status: 0, stderr: ""}); + expect(JSON.parse(result.stdout)).toMatchObject({browser: "firefox", name, version: "1.2.3", srcDir: "src"}); + }); + + test("validates the returned callback settings", () => { + const result = inspect({}, "invalid.config.ts"); + + expect(result.status).toBe(1); + expect(result.stderr).toContain( + "Source directory (srcDir) and destination directory (outputDir) cannot be the same." + ); + }); + + test("build --browser firefox emits the callback name and runs its user plugin", async () => { + const result = run([cli, "build", ".", "--browser", "firefox"]); + + expect({status: result.status, stderr: result.stderr}).toEqual({status: 0, stderr: ""}); + expect(await readJson("callback-input.json")).toMatchObject({ + browser: "firefox", + command: "build", + mode: "production", + name: "myapp", + manifestVersion: 3, + }); + expect(await readJson("dist", "myapp-firefox-mv3", "manifest.json")).toMatchObject({ + name: "My Extension for Firefox", + version: "1.2.3", + manifest_version: 3, + }); + expect(await readJson("user-plugin.json")).toEqual({name: "My Extension for Firefox", browser: "firefox"}); + }); +}); diff --git a/tests/integration/build/cli/fixtures/config/adnbn.config.ts b/tests/integration/build/cli/fixtures/config/adnbn.config.ts new file mode 100644 index 00000000..3d9813d8 --- /dev/null +++ b/tests/integration/build/cli/fixtures/config/adnbn.config.ts @@ -0,0 +1,29 @@ +import {writeFileSync} from "node:fs"; +import path from "node:path"; +import {Browser, defineConfig} from "adnbn"; + +export default defineConfig(config => { + const name = config.browser === Browser.Firefox ? "My Extension for Firefox" : "My Extension"; + + writeFileSync( + path.join(config.rootDir, "callback-input.json"), + JSON.stringify({...config, plugins: config.plugins.map(plugin => plugin.name)}) + ); + + return { + name, + version: "1.2.3", + lang: "fr", + plugins: [ + { + name: "config-fixture", + startup({config}) { + writeFileSync( + path.join(config.rootDir, "user-plugin.json"), + JSON.stringify({name: config.name, browser: config.browser}) + ); + }, + }, + ], + }; +}); diff --git a/tests/integration/build/cli/fixtures/config/invalid.config.ts b/tests/integration/build/cli/fixtures/config/invalid.config.ts new file mode 100644 index 00000000..288d94c7 --- /dev/null +++ b/tests/integration/build/cli/fixtures/config/invalid.config.ts @@ -0,0 +1,3 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig(config => ({srcDir: config.outDir})); diff --git a/tests/integration/build/cli/fixtures/config/no-argument.config.ts b/tests/integration/build/cli/fixtures/config/no-argument.config.ts new file mode 100644 index 00000000..601c15ec --- /dev/null +++ b/tests/integration/build/cli/fixtures/config/no-argument.config.ts @@ -0,0 +1,6 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig(() => ({ + name: "Callback without an argument", + version: "1.2.3", +})); diff --git a/tests/integration/build/cli/fixtures/config/object.config.ts b/tests/integration/build/cli/fixtures/config/object.config.ts new file mode 100644 index 00000000..c644fbaf --- /dev/null +++ b/tests/integration/build/cli/fixtures/config/object.config.ts @@ -0,0 +1,6 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "Object configuration", + version: "1.2.3", +}); diff --git a/tests/integration/build/cli/scripts/inspect-config.mjs b/tests/integration/build/cli/scripts/inspect-config.mjs new file mode 100644 index 00000000..de473caf --- /dev/null +++ b/tests/integration/build/cli/scripts/inspect-config.mjs @@ -0,0 +1,7 @@ +import {pathToFileURL} from "node:url"; + +const [modulePath, rootDir, configFile, options] = process.argv.slice(2); +const {default: resolveConfig} = await import(pathToFileURL(modulePath).href); +const config = await resolveConfig({...JSON.parse(options), rootDir, configFile}); + +console.log(JSON.stringify({...config, plugins: config.plugins.map(plugin => plugin.name)})); From 060935ab3f889bd5e009a4709a5116119e912788 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:34:37 +0300 Subject: [PATCH 12/24] fix(config)!: recalculate dependent defaults after user overrides Recalculate automatic name, manifest version, and filename defaults from final configuration inputs while preserving explicit values and the single callback invocation. Let Safari default to MV2 when --mv2 is absent. Separate environment and plugin-list resolution, move workspace paths and plugin handlers into dedicated modules, remove unused string helpers, and document CLI ownership in an English README. Add real-c12 and CLI build regression coverage, preserve explicit manifest targets in integration fixtures, and update default app expectations. BREAKING CHANGE: The default app changes from "myapp" to "addon", affecting the default manifest name, output directory, APP environment variable, and multi-workspace application path. Set app: "myapp" or pass --app myapp to retain the previous layout, or rename the application directory to addon. --- src/cli/README.md | 127 ++++++ src/cli/builders/app/index.ts | 2 +- .../entrypoint/finder/AbstractAssetFinder.ts | 2 +- .../finder/AbstractEntrypointFinder.ts | 2 +- src/cli/entrypoint/finder/AbstractFinder.ts | 2 +- .../entrypoint/finder/AssetPluginFinder.ts | 2 +- src/cli/entrypoint/finder/PluginFinder.ts | 2 +- src/cli/handlers/index.ts | 1 + src/cli/{resolvers => handlers}/plugin.ts | 2 +- src/cli/index.ts | 8 +- src/cli/plugins/locale/Locale.ts | 2 +- src/cli/plugins/manifest.ts | 5 +- src/cli/plugins/optimization.ts | 2 +- src/cli/plugins/output/index.ts | 2 +- src/cli/plugins/override/Override.ts | 2 +- src/cli/plugins/public.ts | 2 +- src/cli/plugins/react.ts | 2 +- src/cli/plugins/style/index.ts | 2 +- src/cli/plugins/typescript/FileBuilder.ts | 2 +- .../plugins/typescript/TypescriptConfig.ts | 2 +- src/cli/plugins/version/AddonVersion.ts | 2 +- src/cli/plugins/view/index.ts | 2 +- src/cli/resolvers/bundler.ts | 5 +- src/cli/resolvers/config.test.ts | 241 ++++++++++- src/cli/resolvers/config.ts | 395 ++++++------------ src/cli/resolvers/dotenv.ts | 44 ++ src/cli/resolvers/plugins.ts | 70 ++++ src/cli/utils/string.ts | 13 - src/cli/workspace/index.ts | 12 + .../{resolvers/path.ts => workspace/paths.ts} | 0 .../cli/BuildSession.integration.test.ts | 4 +- .../build/cli/config.integration.test.ts | 145 ++++++- .../config/defaults/callback.config.ts | 13 + .../fixtures/config/defaults/debug.config.ts | 4 + .../defaults/explicit-callback.config.ts | 4 + .../config/defaults/explicit.config.ts | 12 + .../fixtures/config/defaults/object.config.ts | 4 + .../config/defaults/production.config.ts | 4 + .../cli/fixtures/config/defaults/settings.ts | 10 + .../fixtures/config/defaults/src/marker.svg | 1 + .../config/defaults/src/probe.module.css | 4 + .../config/defaults/src/probe.page.ts | 11 + .../config/defaults/undefined.config.ts | 12 + .../build/cli/fixtures/config/tsconfig.json | 3 + .../build/cli/scripts/verify-root-dir.mjs | 2 +- .../build/options/embedded/adnbn.config.ts | 2 + .../options/embedded/manifest-version.json | 3 + .../build/options/options.integration.test.ts | 7 +- .../build/override/bookmarks/adnbn.config.ts | 2 + .../override/bookmarks/manifest-version.json | 3 + .../override/competing.integration.test.ts | 2 +- .../build/override/history/adnbn.config.ts | 2 + .../override/history/manifest-version.json | 3 + .../build/override/newtab/adnbn.config.ts | 2 + .../override/newtab/manifest-version.json | 3 + .../build/override/override-utils.ts | 12 +- tests/integration/utils/fixture.ts | 2 +- 57 files changed, 924 insertions(+), 309 deletions(-) create mode 100644 src/cli/README.md create mode 100644 src/cli/handlers/index.ts rename src/cli/{resolvers => handlers}/plugin.ts (94%) create mode 100644 src/cli/resolvers/dotenv.ts create mode 100644 src/cli/resolvers/plugins.ts delete mode 100644 src/cli/utils/string.ts create mode 100644 src/cli/workspace/index.ts rename src/cli/{resolvers/path.ts => workspace/paths.ts} (100%) create mode 100644 tests/integration/build/cli/fixtures/config/defaults/callback.config.ts create mode 100644 tests/integration/build/cli/fixtures/config/defaults/debug.config.ts create mode 100644 tests/integration/build/cli/fixtures/config/defaults/explicit-callback.config.ts create mode 100644 tests/integration/build/cli/fixtures/config/defaults/explicit.config.ts create mode 100644 tests/integration/build/cli/fixtures/config/defaults/object.config.ts create mode 100644 tests/integration/build/cli/fixtures/config/defaults/production.config.ts create mode 100644 tests/integration/build/cli/fixtures/config/defaults/settings.ts create mode 100644 tests/integration/build/cli/fixtures/config/defaults/src/marker.svg create mode 100644 tests/integration/build/cli/fixtures/config/defaults/src/probe.module.css create mode 100644 tests/integration/build/cli/fixtures/config/defaults/src/probe.page.ts create mode 100644 tests/integration/build/cli/fixtures/config/defaults/undefined.config.ts create mode 100644 tests/integration/build/cli/fixtures/config/tsconfig.json create mode 100644 tests/integration/build/options/embedded/manifest-version.json create mode 100644 tests/integration/build/override/bookmarks/manifest-version.json create mode 100644 tests/integration/build/override/history/manifest-version.json create mode 100644 tests/integration/build/override/newtab/manifest-version.json diff --git a/src/cli/README.md b/src/cli/README.md new file mode 100644 index 00000000..78d08821 --- /dev/null +++ b/src/cli/README.md @@ -0,0 +1,127 @@ +# CLI responsibilities + +This directory owns configuration loading, extension source analysis, and build orchestration. +The rules below help place new code and preserve responsibility boundaries during refactoring. +See [AGENTS.md](../../AGENTS.md) for general code and testing conventions. +Write all technical documentation in this repository, including READMEs, in English. + +[index.ts](./index.ts) declares CLI commands and options, passes them to the application builder, +and reports results or errors. The configuration resolver computes dependent defaults. The CLI +must preserve the distinction between an explicitly supplied option and an absent value. + +## Directories + +| Directory | Responsibility | +| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [builders](./builders) | Build orchestration and output data. `app` manages compiler startup, one-shot builds, and watch mode; `manifest`, `csp`, and `locale` produce their respective results from prepared data. | +| [bundler](./bundler) | Rspack integration: compiler plugins, loaders, asset processing, and build infrastructure. | +| [entrypoint](./entrypoint) | Entrypoint discovery, source reading, parsing, and naming; normalized descriptions for the rest of the CLI. | +| [handlers](./handlers) | Handler execution. `plugin.ts` invokes plugin handlers sequentially, supports synchronous and asynchronous results, and yields them to the caller. | +| [plugins](./plugins) | Framework features: interpreting entrypoint options, configuring builds, preparing data, and declaring manifest requirements. Examples include content, background, style, and locale. | +| [resolvers](./resolvers) | Resolving one target result from input parameters. Each module exposes one function through `export default`. | +| [utils](./utils) | Basic filesystem and path helpers. They do not own configuration rules, application layout, or plugin execution order. | +| [virtual](./virtual) | Templates and generated modules that connect user entrypoints to the framework runtime. | +| [workspace](./workspace) | Project, application, shared-source, and output paths based on configuration and workspace mode; artifact name resolution. | + +### Entrypoint analysis + +- `entrypoint/file` reads source files and analyzes the TypeScript AST: exports, expressions, and static values. +- `entrypoint/parser` defines the recognized definition, schema, defaults, and interpretation of the data read. + Shared schema fragments live in `parser/schemas`. Parsers use the file reader and do not traverse the AST themselves. +- `entrypoint/finder` discovers and selects entrypoints using configuration and source precedence. + Reuse these finders when a feature needs information about other entrypoints. +- `entrypoint/name` owns entrypoint names, and `entrypoint/utils` contains helpers shared within this layer. + Finder-specific helpers stay in `entrypoint/finder/utils`. + +### Rspack integration + +- `bundler/plugins` contains classes that work with compiler hooks, runtime modules, emitted assets, + and compilation validation. Such a class belongs here even when only one feature uses it. +- `bundler/plugins/utils` and `bundler/plugins/types.ts` hold shared infrastructure and contracts for these plugins. + `bundler/utils` contains general algorithms for asset graphs, classification, and filenames. +- `bundler/loaders` contains source transformations used in the Rspack pipeline. + `bundler/styles` owns style source preparation and related path and URL resolution. +- Each Rspack plugin keeps its own helpers, tests, and runtime templates beside its implementation. + Plugin runtime templates do not move to `virtual`, which serves entrypoint wrappers. + +## Resolvers + +| Module | Target result | +| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [config.ts](./resolvers/config.ts) | Final application configuration: defaults, the user file, merging, normalization, and validation. Coordinates environment resolution and plugin list composition. | +| [dotenv.ts](./resolvers/dotenv.ts) | Environment variables for the supplied configuration. Loads the applicable `.env` files and updates `process.env`, including application-specific reserved variables. | +| [plugins.ts](./resolvers/plugins.ts) | Final plugin list: launch options → user configuration → built-in plugins. Creates built-in plugins in the established order. | +| [bundler.ts](./resolvers/bundler.ts) | Final Rspack configuration: base settings and plugin handler results. | + +A resolver has one exported function and one responsibility. Use arrow functions for ordinary functions, +including `export default (...) => ...` and `export default async (...) => ...`. +Generators use `function*` or `async function*` because JavaScript has no arrow generator syntax. + +Private types and helpers serving the same responsibility stay in the resolver file. For example, +initial defaults, their recalculation, merging, and validation belong in `config.ts`. Do not turn each +step into a separate exported resolver or move it to `utils` merely to shorten the file. +An independent responsibility, such as environment loading or plugin list composition, warrants its own module. + +### Configuration resolution order + +Preserve this sequence when changing `config.ts`: + +1. Create the populated initial configuration from launch options and defaults, recording which + dependent values were selected automatically. +2. Load the initial environment. Read the user file through `c12`, passing the initial configuration + as `context`. Invoke the callback once per configuration resolution, before applying its result. +3. Apply user settings, normalize `lang` and `workspace`, recalculate automatically selected dependent + defaults from the final inputs, normalize `sharedDir`, and validate the configuration. +4. Load the environment for the final configuration and merge it with the initial environment. Compose the plugin list. + +Initial default calculation and recalculation share one implementation inside `config.ts`. +Preserve explicit values even when they equal the initial default: value comparison cannot determine +where a setting came from. Preserve the existing semantics of absent fields, `undefined`, empty strings, and `false`. +Recalculation must not reload the file, invoke the callback again, or create plugins again. + +Template substitutions that depend on build output stay with their owners. For example, final asset +filenames and hashes are resolved by the bundler rather than during configuration loading. + +## Three plugin responsibilities + +`resolvers/plugins.ts` **composes the list**, `handlers/plugin.ts` **executes handlers**, +and `plugins/` **defines feature behavior**. Handler execution does not belong in resolvers, +basic `utils`, or `src/shared`, the layer shared with the extension runtime. + +A feature uses entrypoint finders and builders, then passes data, options, or callbacks to Rspack plugins. +It does not import another feature's manager or implementation. A Rspack plugin must not receive a feature +manager, including a type disguised through `Pick`: its contract describes the required data and operations. +Feature policy and diagnostic context stay with the feature; hooks, emitted assets, and general compilation +validation belong to the Rspack plugin. Extract algorithms shared by sibling Rspack plugins below both consumers. + +## Exports and imports + +A directory representing an independent module exposes the interface its external consumers need +through `index.ts`. For example, use `@cli/workspace` and `@cli/handlers` instead of deep imports from +`@cli/workspace/paths` and `@cli/handlers/plugin`. Relative imports between files within a module are allowed. +List the required exports explicitly in new `index.ts` files, keeping private helpers inside the module. + +Import resolvers by their target task: `@cli/resolvers/config` or `@cli/resolvers/bundler`. +The current `resolvers` directory has no shared `index.ts`. Directories that group modules, tests, or resources +do not need an `index.ts` solely for consistency. + +A class that is the primary export of its file uses an explicitly named `export default` declaration; +its `index.ts` provides a named re-export, such as `export {default as LocaleBuilder} from "./LocaleBuilder"`. +Internal TypeScript imports use aliases or relative paths without the future `.js` output extension. +Imports of actual JavaScript files and raw templates retain the syntax required by their owner. + +## Boundaries with other layers + +- Shared domain contracts, enums, and constants belong in `src/types`; the CLI imports them through `@typing/*`. + The public `src/main` layer exposes APIs and re-exports but does not own internal helpers. +- `src/shared` is for pure implementation needed by both the CLI and the extension runtime. + Use by several parts of the CLI alone does not make a helper shared with the runtime. +- `src/entry` and the other runtime modules own extension execution and lifecycle behavior. + The CLI does not import their implementations to obtain types, constants, or shared helpers. + +Before moving code, identify who owns the behavior and who consumes it. Moving a file or re-exporting it +through `index.ts` does not fix an incorrect dependency direction. Refactoring preserves setting precedence, +environment and plugin order, user filename templates, manifest behavior, and watch updates. +Verify behavior at its owner and through existing integration boundaries. For configuration, these are the +[resolver tests](./resolvers/config.test.ts) and [CLI integrations](../../tests/integration/build/cli/config.integration.test.ts) +using real `c12`, a configuration file, and build output. diff --git a/src/cli/builders/app/index.ts b/src/cli/builders/app/index.ts index 7dae53b5..3aaf085e 100644 --- a/src/cli/builders/app/index.ts +++ b/src/cli/builders/app/index.ts @@ -7,7 +7,7 @@ import {watch} from "./watch"; import configResolver from "@cli/resolvers/config"; import bundlerResolver from "@cli/resolvers/bundler"; -import {processPluginHandler} from "@cli/resolvers/plugin"; +import {processPluginHandler} from "@cli/handlers"; import {OptionalConfig, ReadonlyConfig} from "@typing/config"; import {Command} from "@typing/app"; diff --git a/src/cli/entrypoint/finder/AbstractAssetFinder.ts b/src/cli/entrypoint/finder/AbstractAssetFinder.ts index b1bed119..329ed5b3 100644 --- a/src/cli/entrypoint/finder/AbstractAssetFinder.ts +++ b/src/cli/entrypoint/finder/AbstractAssetFinder.ts @@ -1,7 +1,7 @@ import path from "path"; import fs from "fs"; -import {getAppPath, getAppSourcePath, getResolvePath, getSharedPath, getSourcePath} from "@cli/resolvers/path"; +import {getAppPath, getAppSourcePath, getResolvePath, getSharedPath, getSourcePath} from "@cli/workspace"; import AbstractFinder from "./AbstractFinder"; import {FileLayer, getWorkspaceFileLayers, setFilePrecedence, type WorkspaceFileLayer} from "./utils/filePrecedence"; diff --git a/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts b/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts index c9574629..6946ac07 100644 --- a/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts +++ b/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts @@ -5,7 +5,7 @@ import pluralize from "pluralize"; import AbstractParsedFinder from "./AbstractParsedFinder"; import {FileLayer, setFilePrecedence} from "./utils/filePrecedence"; -import {getAppSourcePath, getSharedPath} from "@cli/resolvers/path"; +import {getAppSourcePath, getSharedPath} from "@cli/workspace"; import {EntrypointFile, EntrypointFileExtensions, EntrypointOptions} from "@typing/entrypoint"; import {ReadonlyConfig} from "@typing/config"; diff --git a/src/cli/entrypoint/finder/AbstractFinder.ts b/src/cli/entrypoint/finder/AbstractFinder.ts index 50a3306b..94741629 100644 --- a/src/cli/entrypoint/finder/AbstractFinder.ts +++ b/src/cli/entrypoint/finder/AbstractFinder.ts @@ -7,7 +7,7 @@ import {compareFilePrecedence} from "./utils/filePrecedence"; import {toPosixPath} from "@cli/utils/path"; import {isFile} from "@cli/utils/fs"; -import {getResolvePath, getSourcePath, resolveRootPath} from "@cli/resolvers/path"; +import {getResolvePath, getSourcePath, resolveRootPath} from "@cli/workspace"; import {resolveAssetsPath, resolveEntrypointPath} from "@cli/entrypoint/utils"; import {ReadonlyConfig} from "@typing/config"; diff --git a/src/cli/entrypoint/finder/AssetPluginFinder.ts b/src/cli/entrypoint/finder/AssetPluginFinder.ts index e934c798..132be397 100644 --- a/src/cli/entrypoint/finder/AssetPluginFinder.ts +++ b/src/cli/entrypoint/finder/AssetPluginFinder.ts @@ -2,7 +2,7 @@ import _ from "lodash"; import path from "path"; import pluralize from "pluralize"; -import {processPluginHandler} from "@cli/resolvers/plugin"; +import {processPluginHandler} from "@cli/handlers"; import AbstractFinder from "./AbstractFinder"; import AbstractAssetFinder from "./AbstractAssetFinder"; diff --git a/src/cli/entrypoint/finder/PluginFinder.ts b/src/cli/entrypoint/finder/PluginFinder.ts index f2704390..e40209a3 100644 --- a/src/cli/entrypoint/finder/PluginFinder.ts +++ b/src/cli/entrypoint/finder/PluginFinder.ts @@ -3,7 +3,7 @@ import _ from "lodash"; import AbstractParsedFinder from "./AbstractParsedFinder"; import {FileLayer, setFilePrecedence} from "./utils/filePrecedence"; -import {processPluginHandler} from "@cli/resolvers/plugin"; +import {processPluginHandler} from "@cli/handlers"; import {ReadonlyConfig} from "@typing/config"; import {PluginHandlerKeys} from "@typing/plugin"; diff --git a/src/cli/handlers/index.ts b/src/cli/handlers/index.ts new file mode 100644 index 00000000..d7d6a628 --- /dev/null +++ b/src/cli/handlers/index.ts @@ -0,0 +1 @@ +export {processPluginHandler} from "./plugin"; diff --git a/src/cli/resolvers/plugin.ts b/src/cli/handlers/plugin.ts similarity index 94% rename from src/cli/resolvers/plugin.ts rename to src/cli/handlers/plugin.ts index 61e9fe54..66b7a13a 100644 --- a/src/cli/resolvers/plugin.ts +++ b/src/cli/handlers/plugin.ts @@ -2,7 +2,7 @@ import _ from "lodash"; import {Plugin, PluginHandler, PluginHandlerKeys, PluginHandlerOptions, PluginNameHandlerResult} from "@typing/plugin"; -export const resolvePluginHandler = async ( +const resolvePluginHandler = async ( handler: PluginHandler | undefined, options: O ): Promise => { diff --git a/src/cli/index.ts b/src/cli/index.ts index bbea1b82..e1e3b9d4 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -21,7 +21,7 @@ cli.command("init", "Initialize a new project").action(() => { cli.command("watch [root]", "Start watch mode") .option("-m, --mode ", "Set env mode", {default: "development"}) .option("-c, --config ", "Path to config file") - .option("-a, --app ", "Specify an app to run", {default: "myapp"}) + .option("-a, --app ", "Specify an app to run", {default: "addon"}) .option("-b, --browser ", "Specify a browser") .option("--mv2", "Target manifest v2") .action(async (root, options) => { @@ -32,7 +32,7 @@ cli.command("watch [root]", "Start watch mode") debug: options.debug, app: options.app, browser: options.browser, - manifestVersion: options.mv2 ? 2 : 3, + manifestVersion: options.mv2 ? 2 : undefined, rootDir: root, configFile: options.config, }); @@ -45,7 +45,7 @@ cli.command("watch [root]", "Start watch mode") cli.command("build [root]", "Build for production") .option("-m, --mode ", "Set env mode", {default: "production"}) .option("-c, --config ", "Path to config file") - .option("-a, --app ", "Specify an app to run", {default: "myapp"}) + .option("-a, --app ", "Specify an app to run", {default: "addon"}) .option("-b, --browser ", "Specify a browser", {default: Browser.Chrome}) .option("--mv2", "Target manifest v2") .option("--analyze", "Visualize extension bundle") @@ -57,7 +57,7 @@ cli.command("build [root]", "Build for production") debug: options.debug, app: options.app, browser: options.browser, - manifestVersion: options.mv2 ? 2 : 3, + manifestVersion: options.mv2 ? 2 : undefined, rootDir: root, configFile: options.config, analyze: options.analyze, diff --git a/src/cli/plugins/locale/Locale.ts b/src/cli/plugins/locale/Locale.ts index 16784c81..130e4d2e 100644 --- a/src/cli/plugins/locale/Locale.ts +++ b/src/cli/plugins/locale/Locale.ts @@ -1,7 +1,7 @@ import path from "path"; import {LocaleFinder} from "@cli/entrypoint"; import type {GenerateJsonPluginData, GenerateModulePluginDependencies} from "@cli/bundler"; -import {getSourcePath, getSharedPath, getAppPath, getAppSourcePath} from "@cli/resolvers/path"; +import {getSourcePath, getSharedPath, getAppPath, getAppSourcePath} from "@cli/workspace"; import {escapeLocaleMessages, flattenLocaleMessages, getLocaleFilename} from "@shared/locale"; diff --git a/src/cli/plugins/manifest.ts b/src/cli/plugins/manifest.ts index c0c9de1d..aebccdad 100644 --- a/src/cli/plugins/manifest.ts +++ b/src/cli/plugins/manifest.ts @@ -1,7 +1,8 @@ -import {definePlugin} from "@main/plugin"; -import {fromRootPath} from "@cli/resolvers/path"; import fs from "fs"; +import {definePlugin} from "@main/plugin"; +import {fromRootPath} from "@cli/workspace"; + export default definePlugin(() => { return { name: "adnbn:manifest", diff --git a/src/cli/plugins/optimization.ts b/src/cli/plugins/optimization.ts index b5ec445b..2ef574be 100644 --- a/src/cli/plugins/optimization.ts +++ b/src/cli/plugins/optimization.ts @@ -4,7 +4,7 @@ import {merge as mergeConfig} from "webpack-merge"; import {definePlugin} from "@main/plugin"; -import {resolveRootPath} from "@cli/resolvers/path"; +import {resolveRootPath} from "@cli/workspace"; export default definePlugin(() => { return { diff --git a/src/cli/plugins/output/index.ts b/src/cli/plugins/output/index.ts index 1fdbf4be..80a3de15 100644 --- a/src/cli/plugins/output/index.ts +++ b/src/cli/plugins/output/index.ts @@ -5,7 +5,7 @@ import {definePlugin} from "@main/plugin"; import {appFilenameResolver, BuildAssetsMapPlugin, GenerateModulePlugin} from "@cli/bundler"; import {createRuntimeModule} from "@cli/bundler/plugins/utils"; -import {getOutputPath, getResolvePath} from "@cli/resolvers/path"; +import {getOutputPath, getResolvePath} from "@cli/workspace"; import {RuntimeModuleRequest, RuntimeModuleReaders, EntrypointAssetsModule} from "./runtime"; diff --git a/src/cli/plugins/override/Override.ts b/src/cli/plugins/override/Override.ts index 635c3483..b6d7ec25 100644 --- a/src/cli/plugins/override/Override.ts +++ b/src/cli/plugins/override/Override.ts @@ -3,7 +3,7 @@ import path from "path"; import View from "../view/View"; import {AbstractOverrideFinder, BookmarksFinder, HistoryFinder, NewtabFinder} from "@cli/entrypoint"; -import {resolveRootPath} from "@cli/resolvers/path"; +import {resolveRootPath} from "@cli/workspace"; import {toPosix} from "@cli/utils/path"; import {Browser} from "@typing/browser"; diff --git a/src/cli/plugins/public.ts b/src/cli/plugins/public.ts index 1fac434b..1bdf6930 100644 --- a/src/cli/plugins/public.ts +++ b/src/cli/plugins/public.ts @@ -4,7 +4,7 @@ import {RawCopyPattern} from "@rspack/binding"; import {definePlugin} from "@main/plugin"; -import {getAppPath, getAppSourcePath, getSharedPath, getSourcePath} from "@cli/resolvers/path"; +import {getAppPath, getAppSourcePath, getSharedPath, getSourcePath} from "@cli/workspace"; type CopyPatterns = Array>; diff --git a/src/cli/plugins/react.ts b/src/cli/plugins/react.ts index 365d565d..6ecefbe5 100644 --- a/src/cli/plugins/react.ts +++ b/src/cli/plugins/react.ts @@ -3,7 +3,7 @@ import {Configuration as RspackConfig} from "@rspack/core"; import {definePlugin} from "@main/plugin"; -import {getResolvePath} from "@cli/resolvers/path"; +import {getResolvePath} from "@cli/workspace"; export default definePlugin(() => { return { diff --git a/src/cli/plugins/style/index.ts b/src/cli/plugins/style/index.ts index 2a93fe47..96e041ee 100644 --- a/src/cli/plugins/style/index.ts +++ b/src/cli/plugins/style/index.ts @@ -16,7 +16,7 @@ import {DocumentStylesLayer, DefaultStylesLayer} from "@cli/bundler/layers"; import {definePlugin} from "@main/plugin"; import {appFilenameResolver} from "@cli/bundler"; -import {getAppSourcePath, getResolvePath, getSharedPath} from "@cli/resolvers/path"; +import {getAppSourcePath, getResolvePath, getSharedPath} from "@cli/workspace"; import {ReadonlyConfig} from "@typing/config"; diff --git a/src/cli/plugins/typescript/FileBuilder.ts b/src/cli/plugins/typescript/FileBuilder.ts index 69d9c51b..7ab8b8df 100644 --- a/src/cli/plugins/typescript/FileBuilder.ts +++ b/src/cli/plugins/typescript/FileBuilder.ts @@ -1,7 +1,7 @@ import fs from "fs"; import path from "path"; -import {fromRootPath, getResolvePath} from "@cli/resolvers/path"; +import {fromRootPath, getResolvePath} from "@cli/workspace"; import {PackageName, SystemDir} from "@typing/app"; import {ReadonlyConfig} from "@typing/config"; diff --git a/src/cli/plugins/typescript/TypescriptConfig.ts b/src/cli/plugins/typescript/TypescriptConfig.ts index 3544aa56..fdd2a33e 100644 --- a/src/cli/plugins/typescript/TypescriptConfig.ts +++ b/src/cli/plugins/typescript/TypescriptConfig.ts @@ -4,7 +4,7 @@ import {TsConfigJson} from "type-fest"; import FileBuilder from "./FileBuilder"; -import {resolveRootPath} from "@cli/resolvers/path"; +import {resolveRootPath} from "@cli/workspace"; import {ReadonlyConfig} from "@typing/config"; import {PackageName} from "@typing/app"; diff --git a/src/cli/plugins/version/AddonVersion.ts b/src/cli/plugins/version/AddonVersion.ts index 341d6290..e83dcb88 100644 --- a/src/cli/plugins/version/AddonVersion.ts +++ b/src/cli/plugins/version/AddonVersion.ts @@ -2,7 +2,7 @@ import fs from "fs"; import semver from "semver"; import {getEnv} from "@main/env"; -import {fromRootPath} from "@cli/resolvers/path"; +import {fromRootPath} from "@cli/workspace"; import AbstractVersion from "./AbstractVersion"; diff --git a/src/cli/plugins/view/index.ts b/src/cli/plugins/view/index.ts index 18d7f673..aa5f9ea8 100644 --- a/src/cli/plugins/view/index.ts +++ b/src/cli/plugins/view/index.ts @@ -3,7 +3,7 @@ import {NormalModule} from "@rspack/core"; import {definePlugin} from "@main/plugin"; import {onlyViaTopLevelEntry} from "@cli/bundler"; -import {getResolvePath, getSourcePath} from "@cli/resolvers/path"; +import {getResolvePath, getSourcePath} from "@cli/workspace"; export {default as View} from "./View"; diff --git a/src/cli/resolvers/bundler.ts b/src/cli/resolvers/bundler.ts index 5b35dd9a..09be6689 100644 --- a/src/cli/resolvers/bundler.ts +++ b/src/cli/resolvers/bundler.ts @@ -4,8 +4,9 @@ import {merge as mergeConfig} from "webpack-merge"; import path from "node:path"; import manifestFactory from "../builders/manifest"; -import {processPluginHandler} from "./plugin"; -import {resolveRootPath} from "./path"; + +import {processPluginHandler} from "@cli/handlers"; +import {resolveRootPath} from "@cli/workspace"; import ManifestPlugin from "@cli/bundler/plugins/manifest"; import WatchPlugin from "@cli/bundler/plugins/watch"; diff --git a/src/cli/resolvers/config.test.ts b/src/cli/resolvers/config.test.ts index a9bf835e..dd4eb1f5 100644 --- a/src/cli/resolvers/config.test.ts +++ b/src/cli/resolvers/config.test.ts @@ -39,16 +39,255 @@ import {loadConfig} from "c12"; import resolveConfig from "./config"; -import {Workspace} from "@typing/app"; +import {Command, Mode, Workspace} from "@typing/app"; +import {Browser} from "@typing/browser"; +import type {OptionalConfig, UserConfig} from "@typing/config"; import {Language} from "@typing/locale"; const mockedLoadConfig = jest.mocked(loadConfig); +const productionFilenames = { + assetsFilename: "[contenthash:4][ext]", + jsFilename: "[contenthash:5].js", + cssFilename: "[contenthash:5].css", + cssIdentName: "[app]-[hash:base64:5]", +}; + +const readableFilenames = { + assetsFilename: "[name]-[contenthash:4][ext]", + jsFilename: "[name].js", + cssFilename: "[name].css", + cssIdentName: "[local]-[hash:base64:5]", +}; + +interface DefaultsScenario { + label: string; + options: OptionalConfig; + user: UserConfig; + expected: OptionalConfig; +} + describe("config resolver", () => { beforeEach(() => { mockedLoadConfig.mockResolvedValue({config: {}}); }); + test.each([ + {label: "app", options: {}, user: {app: "reader"}, expected: {name: "reader"}}, + {label: "empty app", options: {}, user: {app: ""}, expected: {name: ""}}, + {label: "Safari", options: {}, user: {browser: Browser.Safari}, expected: {manifestVersion: 2}}, + { + label: "Chrome after Safari", + options: {browser: Browser.Safari}, + user: {browser: Browser.Chrome}, + expected: {manifestVersion: 3}, + }, + { + label: "development mode", + options: {mode: Mode.Production}, + user: {mode: Mode.Development}, + expected: readableFilenames, + }, + { + label: "production mode", + options: {}, + user: {mode: Mode.Production}, + expected: productionFilenames, + }, + { + label: "none mode", + options: {mode: Mode.Production}, + user: {mode: Mode.None}, + expected: readableFilenames, + }, + { + label: "debug enabled", + options: {mode: Mode.Production}, + user: {debug: true}, + expected: readableFilenames, + }, + { + label: "debug disabled", + options: {mode: Mode.Production, debug: true}, + user: {debug: false}, + expected: productionFilenames, + }, + { + label: "watch with production mode", + options: {command: Command.Watch}, + user: {mode: Mode.Production}, + expected: readableFilenames, + }, + ])("recalculates automatic defaults for $label", async ({options, user, expected}) => { + mockedLoadConfig.mockResolvedValue({config: user}); + + const config = await resolveConfig({configFile: "package.json", ...options}); + + expect(config).toMatchObject({...user, ...expected}); + }); + + test.each([ + {label: "resolver defaults", options: {}, user: {}, expected: readableFilenames}, + { + label: "production build", + options: {mode: Mode.Production}, + user: {}, + expected: productionFilenames, + }, + { + label: "production watch", + options: {command: Command.Watch, mode: Mode.Production}, + user: {}, + expected: readableFilenames, + }, + ])("preserves $label without overrides", async ({options, expected}) => { + const config = await resolveConfig({configFile: "package.json", ...options}); + + expect(config).toMatchObject({name: "addon", manifestVersion: 3, ...expected}); + }); + + test.each(["launch", "file"])("preserves explicit initial defaults from %s", async source => { + const explicit: UserConfig = {name: "addon", manifestVersion: 3, ...productionFilenames}; + const overrides: UserConfig = {app: "reader", browser: Browser.Safari, mode: Mode.Development}; + + mockedLoadConfig.mockResolvedValue({config: {...overrides, ...(source === "file" ? explicit : {})}}); + + const config = await resolveConfig({ + configFile: "package.json", + mode: Mode.Production, + ...(source === "launch" ? explicit : {}), + }); + + expect(config).toMatchObject({...overrides, ...explicit}); + }); + + test("keeps file settings above launch settings, including filename functions", async () => { + const filename = jest.fn(() => "custom.js"); + const user: UserConfig = { + app: "reader", + name: "File name", + manifestVersion: 2, + jsFilename: filename, + cssFilename: "custom.css", + assetsFilename: "custom[ext]", + cssIdentName: "custom-[local]", + }; + + mockedLoadConfig.mockResolvedValue({config: user}); + + const config = await resolveConfig({ + configFile: "package.json", + name: "Launch name", + manifestVersion: 3, + ...productionFilenames, + }); + + expect(config).toMatchObject(user); + expect(config.jsFilename).toBe(filename); + expect(filename).not.toHaveBeenCalled(); + }); + + test("recalculates only omitted fields when explicit settings come from both sources", async () => { + mockedLoadConfig.mockResolvedValue({ + config: {app: "reader", mode: Mode.Development, cssFilename: "custom.css"}, + }); + + const config = await resolveConfig({ + configFile: "package.json", + mode: Mode.Production, + jsFilename: "custom.js", + }); + + expect(config).toMatchObject({ + ...readableFilenames, + name: "reader", + jsFilename: "custom.js", + cssFilename: "custom.css", + }); + }); + + test("treats undefined launch settings as automatic defaults", async () => { + mockedLoadConfig.mockResolvedValue({config: {app: "reader", browser: Browser.Safari, debug: true}}); + + const config = await resolveConfig({ + configFile: "package.json", + mode: Mode.Production, + name: undefined, + manifestVersion: undefined, + assetsFilename: undefined, + jsFilename: undefined, + cssFilename: undefined, + cssIdentName: undefined, + }); + + expect(config).toMatchObject({name: "reader", manifestVersion: 2, ...readableFilenames}); + }); + + test("preserves own undefined fields returned by the loader", async () => { + const user = {app: "reader", name: undefined, jsFilename: undefined, description: undefined}; + + mockedLoadConfig.mockResolvedValue({config: user}); + + const config = await resolveConfig({configFile: "package.json", description: "Launch description"}); + + expect(config).toMatchObject(user); + }); + + test("selects known launch fields without mutating options or copying their values", async () => { + const options = { + configFile: "package.json", + description: undefined, + html: [], + bundler: {}, + env: {}, + unknown: "ignored", + }; + + const original = {...options}; + const config = await resolveConfig(options); + + expect(options).toEqual(original); + expect(config).not.toHaveProperty("unknown"); + expect(config).toHaveProperty("description", undefined); + expect(config.html).toBe(options.html); + expect(config.bundler).toBe(options.bundler); + expect(config.env).toBe(options.env); + }); + + test("creates fresh mutable defaults for each resolution", async () => { + const first = await resolveConfig({configFile: "package.json"}); + const second = await resolveConfig({configFile: "package.json"}); + + expect(first.html).toEqual([]); + expect(second.html).toEqual([]); + expect(second.html).not.toBe(first.html); + expect(second.bundler).not.toBe(first.bundler); + expect(second.env).not.toBe(first.env); + expect(second.plugins).not.toBe(first.plugins); + }); + + test.each(["launch", "file"])("preserves empty strings and false from %s", async source => { + const explicit: UserConfig = { + name: "", + assetsFilename: "", + jsFilename: "", + cssFilename: "", + cssIdentName: "", + debug: false, + mergeStyles: false, + }; + + mockedLoadConfig.mockResolvedValue({config: {app: "reader", ...(source === "file" ? explicit : {})}}); + + const config = await resolveConfig({ + configFile: "package.json", + mode: Mode.Production, + ...(source === "launch" ? explicit : {}), + }); + + expect(config).toMatchObject(explicit); + }); + test("uses single workspace by default", async () => { const config = await resolveConfig({configFile: "package.json"}); diff --git a/src/cli/resolvers/config.ts b/src/cli/resolvers/config.ts index 5a0c7124..b23a2b86 100644 --- a/src/cli/resolvers/config.ts +++ b/src/cli/resolvers/config.ts @@ -1,47 +1,44 @@ import {existsSync} from "fs"; -import dotenv, {type DotenvParseOutput} from "dotenv"; import {loadConfig} from "c12"; import _ from "lodash"; -import { - pluginAction, - pluginAsset, - pluginBackground, - pluginBundler, - pluginContent, - pluginDotenv, - pluginHtml, - pluginIcon, - pluginLocale, - pluginMeta, - pluginOffscreen, - pluginManifest, - pluginOptimization, - pluginOptions, - pluginOutput, - pluginOverride, - pluginPage, - pluginPopup, - pluginPublic, - pluginReact, - pluginSandbox, - pluginSidebar, - pluginStyle, - pluginTypescript, - pluginVersion, - pluginView, -} from "../plugins"; - -import {fromRootPath, getAppPath, getAppSourcePath, getConfigFile} from "../resolvers/path"; +import resolveDotenv from "./dotenv"; +import resolvePlugins from "./plugins"; +import {getConfigFile} from "@cli/workspace/paths"; import type {Config, OptionalConfig, ReadonlyConfig, UserConfig} from "@typing/config"; import {Command, Mode, Workspace} from "@typing/app"; import {Browser} from "@typing/browser"; -import {Plugin} from "@typing/plugin"; -import {ManifestVersion} from "@typing/manifest"; import {Language, LanguageCodes} from "@typing/locale"; import {DefaultIconGroupName} from "@typing/icon"; +type ConfigDefaultSources = Pick; + +type ConfigDependentDefaults = Pick< + Config, + "name" | "manifestVersion" | "assetsFilename" | "jsFilename" | "cssFilename" | "cssIdentName" +>; + +type ConfigDependentDefaultKey = keyof ConfigDependentDefaults; + +interface ConfigInitialState { + config: Config; + automaticDefaults: ConfigDependentDefaultKey[]; +} + +const resolveDefaults = ({app, browser, mode, command, debug}: ConfigDefaultSources): ConfigDependentDefaults => { + const production = mode === Mode.Production && command === Command.Build && !debug; + + return { + name: app, + manifestVersion: browser === Browser.Safari ? 2 : 3, + assetsFilename: production ? "[contenthash:4][ext]" : "[name]-[contenthash:4][ext]", + jsFilename: production ? "[contenthash:5].js" : "[name].js", + cssFilename: production ? "[contenthash:5].css" : "[name].css", + cssIdentName: production ? "[app]-[hash:base64:5]" : "[local]-[hash:base64:5]", + }; +}; + const resolveLanguage = (lang?: `${Language}` | Language): Language => { if (!lang) { return Language.English; @@ -152,245 +149,129 @@ const validateConfig = (config: ReadonlyConfig): ReadonlyConfig => { return config; }; -const updateLocalDotenv = (config: ReadonlyConfig): DotenvParseOutput => { - const {mode, app, browser, manifestVersion} = config; - - const localVars: DotenvParseOutput = { - APP: app, - BROWSER: browser, - MODE: mode, - MANIFEST_VERSION: String(manifestVersion), - }; - - Object.assign(process.env, localVars); - - return localVars; -}; - -const loadDotenv = (config: ReadonlyConfig): DotenvParseOutput => { - const {mode, browser, debug} = config; - - const preset = [ - `.env.${mode}.${browser}.local`, - `.env.${mode}.${browser}`, - `.env.${browser}.local`, - `.env.${browser}`, - `.env.${mode}.local`, - `.env.${mode}`, - `.env.local`, - `.env`, - ]; - - const appSourcePaths = preset.map(file => getAppSourcePath(config, file)); - const appPaths = preset.map(file => getAppPath(config, file)); - const rootPaths = preset.map(file => fromRootPath(config, file)); - - const paths = [...appSourcePaths, ...appPaths, ...rootPaths]; - - const {parsed: fileVars = {}} = dotenv.config({path: paths, quiet: !debug}); - - return {...fileVars, ...updateLocalDotenv(config)}; -}; - -export default async (config: OptionalConfig): Promise => { - let { +const createInitialConfig = (options: OptionalConfig): ConfigInitialState => { + const { command = Command.Build, debug = false, - configFile = "adnbn.config.ts", browser = Browser.Chrome, - app = "myapp", - name = app, - description, - shortName, - version = "VERSION", - minimumVersion = "MINIMUM_VERSION", - author = undefined, - homepage = "HOMEPAGE", - icon = DefaultIconGroupName, - action, - lang = Language.English, - incognito, - specific, - workspace = Workspace.Single, - rootDir = ".", - outDir = "dist", - srcDir = "src", - sharedDir = "shared", - appsDir = "apps", - appSrcDir = ".", - localeDir = "locales", - iconSrcDir = "icons", - iconOutDir = "icons", - jsDir = "js", - cssDir = "css", - assetsDir = "assets", - publicDir = "public", - htmlDir = ".", - html = [], - bundler = {}, - env = {}, - manifest, - manifestVersion = (new Set([Browser.Safari]).has(browser) ? 2 : 3) as ManifestVersion, + app = "addon", mode = Mode.Development, - analyze = false, - plugins = [], - mergeBackground = false, - mergeCommands = false, - mergeContentScripts = false, - concatContentScripts = true, - mergeStyles = true, - mergeIcons = false, - mergeLocales = true, - mergePages = false, - mergePopup = false, - mergePublic = false, - multiplePopup = false, - mergeSidebar = false, - multipleSidebar = false, - mergeRelay = false, - mergeService = false, - mergeOffscreen = false, - mergeSandbox = false, - commonChunks = true, - artifactName = "[name]-[browser]-[mv]", - assetsFilename = mode === Mode.Production && command === Command.Build && !debug - ? "[contenthash:4][ext]" - : "[name]-[contenthash:4][ext]", - jsFilename = mode === Mode.Production && command === Command.Build && !debug - ? "[contenthash:5].js" - : "[name].js", - cssFilename = mode === Mode.Production && command === Command.Build && !debug - ? "[contenthash:5].css" - : "[name].css", - cssIdentName = mode === Mode.Production && command === Command.Build && !debug - ? "[app]-[hash:base64:5]" - : "[local]-[hash:base64:5]", - } = config; + } = options; + + const initialDefaults = resolveDefaults({app, browser, mode, command, debug}); - let resolvedConfig: Config = { + const defaults: Config = { command, debug, mode, browser, app, - name, - description, - shortName, - version, - minimumVersion, - author, - homepage, - lang: resolveLanguage(lang), - icon, - action, - incognito, - specific, - manifest, - manifestVersion, - workspace: resolveWorkspace(workspace), - rootDir, - outDir, - srcDir, - sharedDir, - appsDir, - appSrcDir, - jsDir, - cssDir, - assetsDir, - publicDir, - htmlDir, - localeDir, - iconSrcDir, - iconOutDir, - html, - bundler, - env, - plugins, - analyze, - configFile, - mergeBackground, - mergeCommands, - mergeContentScripts, - concatContentScripts, - mergeStyles, - mergeIcons, - mergeLocales, - mergePages, - mergePopup, - mergePublic, - multiplePopup, - mergeSidebar, - multipleSidebar, - mergeRelay, - mergeService, - mergeOffscreen, - mergeSandbox, - commonChunks, - artifactName, - assetsFilename, - jsFilename, - cssFilename, - cssIdentName, + ...initialDefaults, + description: undefined, + shortName: undefined, + version: "VERSION", + minimumVersion: "MINIMUM_VERSION", + author: undefined, + homepage: "HOMEPAGE", + lang: Language.English, + icon: DefaultIconGroupName, + action: undefined, + incognito: undefined, + specific: undefined, + manifest: undefined, + workspace: Workspace.Single, + rootDir: ".", + outDir: "dist", + srcDir: "src", + sharedDir: "shared", + appsDir: "apps", + appSrcDir: ".", + jsDir: "js", + cssDir: "css", + assetsDir: "assets", + publicDir: "public", + htmlDir: ".", + localeDir: "locales", + iconSrcDir: "icons", + iconOutDir: "icons", + html: [], + bundler: {}, + env: {}, + plugins: [], + analyze: false, + configFile: "adnbn.config.ts", + mergeBackground: false, + mergeCommands: false, + mergeContentScripts: false, + concatContentScripts: true, + mergeStyles: true, + mergeIcons: false, + mergeLocales: true, + mergePages: false, + mergePopup: false, + mergePublic: false, + multiplePopup: false, + mergeSidebar: false, + multipleSidebar: false, + mergeRelay: false, + mergeService: false, + mergeOffscreen: false, + mergeSandbox: false, + commonChunks: true, + artifactName: "[name]-[browser]-[mv]", }; - let vars = loadDotenv(resolvedConfig); + // Launch options accept known fields and fall back only for undefined values. + const config = _.defaults({}, _.pick(options, Object.keys(defaults)), defaults); - const { - plugins: userPlugins = [], - lang: userLang, - workspace: userWorkspace, - ...userConfig - } = await getUserConfig(resolvedConfig); - - resolvedConfig = { - ...resolvedConfig, - ...userConfig, - lang: resolveLanguage(userLang ?? resolvedConfig.lang), - workspace: resolveWorkspace(userWorkspace ?? resolvedConfig.workspace), + const automaticDefaults = (Object.keys(initialDefaults) as ConfigDependentDefaultKey[]).filter( + key => options[key] === undefined + ); + + return { + config: {...config, lang: resolveLanguage(config.lang), workspace: resolveWorkspace(config.workspace)}, + automaticDefaults, + }; +}; + +const applyUserConfig = ( + initialConfig: Config, + userConfig: UserConfig, + automaticDefaults: ConfigDependentDefaultKey[] +): Config => { + const {lang, workspace, ...overrides} = userConfig; + + let config: Config = { + ...initialConfig, + ...overrides, + lang: resolveLanguage(lang ?? initialConfig.lang), + workspace: resolveWorkspace(workspace ?? initialConfig.workspace), }; - resolvedConfig.sharedDir = resolveSharedDir(resolvedConfig.workspace, resolvedConfig.sharedDir); - - resolvedConfig = validateConfig(resolvedConfig); - - vars = {...vars, ...loadDotenv(resolvedConfig)}; - - /** - * IMPORTANT: the order of plugins matters. Early plugins prepare the environment and artifacts for the following ones - * (e.g., environment variables/output/transpilation/assets → page/version generation → bundling). - * Reordering may result in missing artifacts, incorrect configuration, or build failures. - */ - const corePlugins: Plugin[] = [ - pluginDotenv(vars), - pluginOutput(), - pluginOptimization(), - pluginTypescript(), - pluginReact(), - pluginIcon(), - pluginAsset(), - pluginStyle(), - pluginLocale(), - pluginMeta(), - pluginAction(), - pluginContent(), - pluginBackground(), - pluginOptions(), - pluginOverride(), - pluginPopup(), - pluginPublic(), - pluginSidebar(), - pluginOffscreen(), - pluginSandbox(), - pluginPage(), - pluginView(), - pluginHtml(), - pluginVersion(), - pluginBundler(), - pluginManifest(), - ]; + const defaults = resolveDefaults(config); - return { - ...resolvedConfig, - plugins: [...plugins, ...userPlugins, ...corePlugins], + config = { + ...config, + ...Object.fromEntries( + automaticDefaults.filter(key => !Object.hasOwn(overrides, key)).map(key => [key, defaults[key]]) + ), }; + + config.sharedDir = resolveSharedDir(config.workspace, config.sharedDir); + + return validateConfig(config); }; + +export default async function resolveConfig(options: OptionalConfig): Promise { + const {config: initialConfig, automaticDefaults} = createInitialConfig(options); + const optionPlugins = initialConfig.plugins; + const initialVars = resolveDotenv(initialConfig); + const {plugins: userPlugins = [], ...userConfig} = await getUserConfig(initialConfig); + const config = applyUserConfig(initialConfig, userConfig, automaticDefaults); + const vars = {...initialVars, ...resolveDotenv(config)}; + + return { + ...config, + plugins: resolvePlugins(optionPlugins, userPlugins, vars), + }; +} diff --git a/src/cli/resolvers/dotenv.ts b/src/cli/resolvers/dotenv.ts new file mode 100644 index 00000000..993a0513 --- /dev/null +++ b/src/cli/resolvers/dotenv.ts @@ -0,0 +1,44 @@ +import dotenv, {type DotenvParseOutput} from "dotenv"; + +import {fromRootPath, getAppPath, getAppSourcePath} from "@cli/workspace"; +import type {ReadonlyConfig} from "@typing/config"; + +const updateLocalDotenv = (config: ReadonlyConfig): DotenvParseOutput => { + const {mode, app, browser, manifestVersion} = config; + + const localVars: DotenvParseOutput = { + APP: app, + BROWSER: browser, + MODE: mode, + MANIFEST_VERSION: String(manifestVersion), + }; + + Object.assign(process.env, localVars); + + return localVars; +}; + +export default (config: ReadonlyConfig): DotenvParseOutput => { + const {mode, browser, debug} = config; + + const preset = [ + `.env.${mode}.${browser}.local`, + `.env.${mode}.${browser}`, + `.env.${browser}.local`, + `.env.${browser}`, + `.env.${mode}.local`, + `.env.${mode}`, + `.env.local`, + `.env`, + ]; + + const appSourcePaths = preset.map(file => getAppSourcePath(config, file)); + const appPaths = preset.map(file => getAppPath(config, file)); + const rootPaths = preset.map(file => fromRootPath(config, file)); + + const paths = [...appSourcePaths, ...appPaths, ...rootPaths]; + + const {parsed: fileVars = {}} = dotenv.config({path: paths, quiet: !debug}); + + return {...fileVars, ...updateLocalDotenv(config)}; +}; diff --git a/src/cli/resolvers/plugins.ts b/src/cli/resolvers/plugins.ts new file mode 100644 index 00000000..8715ed97 --- /dev/null +++ b/src/cli/resolvers/plugins.ts @@ -0,0 +1,70 @@ +import type {DotenvParseOutput} from "dotenv"; + +import { + pluginAction, + pluginAsset, + pluginBackground, + pluginBundler, + pluginContent, + pluginDotenv, + pluginHtml, + pluginIcon, + pluginLocale, + pluginMeta, + pluginOffscreen, + pluginManifest, + pluginOptimization, + pluginOptions, + pluginOutput, + pluginOverride, + pluginPage, + pluginPopup, + pluginPublic, + pluginReact, + pluginSandbox, + pluginSidebar, + pluginStyle, + pluginTypescript, + pluginVersion, + pluginView, +} from "../plugins"; + +import type {Plugin} from "@typing/plugin"; + +export default (optionPlugins: Plugin[], userPlugins: Plugin[], vars: DotenvParseOutput): Plugin[] => { + /** + * IMPORTANT: the order of plugins matters. Early plugins prepare the environment and artifacts for the following ones + * (e.g., environment variables/output/transpilation/assets → page/version generation → bundling). + * Reordering may result in missing artifacts, incorrect configuration, or build failures. + */ + const corePlugins: Plugin[] = [ + pluginDotenv(vars), + pluginOutput(), + pluginOptimization(), + pluginTypescript(), + pluginReact(), + pluginIcon(), + pluginAsset(), + pluginStyle(), + pluginLocale(), + pluginMeta(), + pluginAction(), + pluginContent(), + pluginBackground(), + pluginOptions(), + pluginOverride(), + pluginPopup(), + pluginPublic(), + pluginSidebar(), + pluginOffscreen(), + pluginSandbox(), + pluginPage(), + pluginView(), + pluginHtml(), + pluginVersion(), + pluginBundler(), + pluginManifest(), + ]; + + return [...optionPlugins, ...userPlugins, ...corePlugins]; +}; diff --git a/src/cli/utils/string.ts b/src/cli/utils/string.ts deleted file mode 100644 index 676abc58..00000000 --- a/src/cli/utils/string.ts +++ /dev/null @@ -1,13 +0,0 @@ -import _ from "lodash"; - -export const removeAllSpaces = (str: string): string => { - return str.replace(/\s/g, ""); -}; - -export const hasSymbols = (str?: string | number | null): boolean => { - if (!_.isString(str)) { - return false; - } - - return removeAllSpaces(str).length > 0; -}; diff --git a/src/cli/workspace/index.ts b/src/cli/workspace/index.ts new file mode 100644 index 00000000..3b7d31ee --- /dev/null +++ b/src/cli/workspace/index.ts @@ -0,0 +1,12 @@ +export { + getResolvePath, + resolveRootPath, + fromRootPath, + getSourcePath, + getSharedPath, + getAppPath, + getAppSourcePath, + getOutputPath, + getConfigFile, + getArtifactName, +} from "./paths"; diff --git a/src/cli/resolvers/path.ts b/src/cli/workspace/paths.ts similarity index 100% rename from src/cli/resolvers/path.ts rename to src/cli/workspace/paths.ts diff --git a/tests/integration/build/cli/BuildSession.integration.test.ts b/tests/integration/build/cli/BuildSession.integration.test.ts index bb9e343e..e5d9926e 100644 --- a/tests/integration/build/cli/BuildSession.integration.test.ts +++ b/tests/integration/build/cli/BuildSession.integration.test.ts @@ -55,8 +55,8 @@ test("reuses one child with fixed cwd across A-B-A, rejects overlap and restores expect(samples.map(sample => sample.build)).toEqual([1, 2, 3]); expect(samples.map(sample => sample.cwd)).toEqual(Array(3).fill(session.directory)); expect(samples.every(sample => sample.rss > 0 && sample.heapUsed > 0)).toBe(true); - expect(await readFile(path.join(root, "dist/myapp-chrome-mv3/newtab.html"), "utf8")).toContain("Absolute root A"); - expect(await readFile(path.join(rootB, "dist/myapp-firefox-mv3/newtab.html"), "utf8")).toContain("Absolute root B"); + expect(await readFile(path.join(root, "dist/addon-chrome-mv3/newtab.html"), "utf8")).toContain("Absolute root A"); + expect(await readFile(path.join(rootB, "dist/addon-firefox-mv3/newtab.html"), "utf8")).toContain("Absolute root B"); expect(process.cwd()).toBe(cwd); expect({...process.env}).toEqual(environment); await session.dispose(); diff --git a/tests/integration/build/cli/config.integration.test.ts b/tests/integration/build/cli/config.integration.test.ts index e62937c3..6c0462dc 100644 --- a/tests/integration/build/cli/config.integration.test.ts +++ b/tests/integration/build/cli/config.integration.test.ts @@ -1,15 +1,34 @@ import {spawnSync} from "node:child_process"; -import {readFile} from "node:fs/promises"; +import {readFile, readdir, rm} from "node:fs/promises"; import path from "node:path"; import {createIntegrationFixture, type IntegrationFixture} from "../../utils/fixture"; import type {OptionalConfig} from "@typing/config"; import {Browser} from "@typing/browser"; +import {Mode} from "@typing/app"; const projectRoot = path.resolve(__dirname, "../../../.."); const resolver = path.join(projectRoot, "dist", "cli", "resolvers", "config.js"); const cli = path.join(projectRoot, "bin", "adnbn.js"); +const productionDefaults: OptionalConfig = { + name: "addon", + manifestVersion: 3, + assetsFilename: "[contenthash:4][ext]", + jsFilename: "[contenthash:5].js", + cssFilename: "[contenthash:5].css", + cssIdentName: "[app]-[hash:base64:5]", +}; + +const updatedDefaults = { + name: "reader", + manifestVersion: 2, + assetsFilename: "[name]-[contenthash:4][ext]", + jsFilename: "[name].js", + cssFilename: "[name].css", + cssIdentName: "[local]-[hash:base64:5]", +}; + describe("user configuration through real c12", () => { let fixture: IntegrationFixture; @@ -21,9 +40,10 @@ describe("user configuration through real c12", () => { await fixture.dispose(); }); - const run = (args: string[]) => { + const run = (args: string[], env = process.env) => { const result = spawnSync(process.execPath, args, { cwd: fixture.directory, + env, encoding: "utf8", timeout: 30_000, maxBuffer: 4 * 1024 * 1024, @@ -75,7 +95,7 @@ describe("user configuration through real c12", () => { version: "9.0.0", command: "build", mode: "development", - app: "myapp", + app: "addon", manifestVersion: 3, lang: "en", workspace: "single", @@ -127,14 +147,129 @@ describe("user configuration through real c12", () => { browser: "firefox", command: "build", mode: "production", - name: "myapp", + name: "addon", manifestVersion: 3, }); - expect(await readJson("dist", "myapp-firefox-mv3", "manifest.json")).toMatchObject({ + expect(await readJson("dist", "addon-firefox-mv3", "manifest.json")).toMatchObject({ name: "My Extension for Firefox", version: "1.2.3", manifest_version: 3, }); expect(await readJson("user-plugin.json")).toEqual({name: "My Extension for Firefox", browser: "firefox"}); }); + + test("object and callback settings recalculate the same defaults after one callback invocation", async () => { + const configs = ["object", "callback"].map(kind => { + const result = inspect({mode: Mode.Production}, path.join("defaults", `${kind}.config.ts`)); + + expect({status: result.status, stderr: result.stderr}).toEqual({status: 0, stderr: ""}); + + return JSON.parse(result.stdout); + }); + + expect(configs[0]).toMatchObject({ + ...updatedDefaults, + app: "reader", + browser: "safari", + mode: "development", + }); + expect(configs[1]).toEqual({...configs[0], configFile: path.join("defaults", "callback.config.ts")}); + + const calls = (await readFile(path.join(fixture.directory, "callback-calls.jsonl"), "utf8")) + .trim() + .split("\n") + .map(line => JSON.parse(line)); + + expect(calls).toEqual([ + expect.objectContaining({ + ...productionDefaults, + app: "addon", + browser: "chrome", + mode: "production", + sharedDir: "shared", + plugins: [], + }), + ]); + }); + + test("undefined fields discarded by c12 keep automatic defaults eligible for recalculation", () => { + const result = inspect({mode: Mode.Production}, path.join("defaults", "undefined.config.ts")); + + expect({status: result.status, stderr: result.stderr}).toEqual({status: 0, stderr: ""}); + expect(JSON.parse(result.stdout)).toMatchObject(updatedDefaults); + }); + + test("defaults explicitly returned by a callback remain overrides", () => { + const result = inspect({mode: Mode.Production}, path.join("defaults", "explicit-callback.config.ts")); + + expect({status: result.status, stderr: result.stderr}).toEqual({status: 0, stderr: ""}); + expect(JSON.parse(result.stdout)).toMatchObject({ + ...productionDefaults, + app: "reader", + browser: "safari", + mode: "development", + }); + }); + + test.each([ + {kind: "object", app: "reader", browser: "safari", name: "reader", readable: true, manifestVersion: 2}, + {kind: "debug", app: "reader", browser: "chrome", name: "reader", readable: true, manifestVersion: 3}, + {kind: "explicit", app: "reader", browser: "safari", name: "addon", readable: false, manifestVersion: 3}, + {kind: "production", app: "addon", browser: "chrome", name: "addon", readable: false, manifestVersion: 3}, + ])( + "CLI build emits the $kind manifest and filename defaults", + async ({kind, app, browser, name, readable, manifestVersion}) => { + const result = run([ + cli, + "build", + ".", + "--config", + path.join("defaults", `${kind}.config.ts`), + ...(kind === "explicit" ? ["--mv2"] : []), + ]); + + expect(result.status).toBe(0); + + if (kind !== "debug") { + expect(result.stderr).toBe(""); + } + + const output = path.join(fixture.directory, "dist", `${app}-${browser}-mv${manifestVersion}`); + const manifest = JSON.parse(await readFile(path.join(output, "manifest.json"), "utf8")); + const js = await readdir(path.join(output, "js")); + const css = await readdir(path.join(output, "css")); + const assets = await readdir(path.join(output, "assets")); + + expect(manifest).toMatchObject({name, version: "1.2.3", manifest_version: manifestVersion}); + expect(js).toEqual([readable ? "probe.page.js" : expect.stringMatching(/^[a-f0-9]{5}\.js$/)]); + expect(css).toEqual([readable ? "probe.page.css" : expect.stringMatching(/^[a-f0-9]{5}\.css$/)]); + expect(assets).toEqual([ + expect.stringMatching(readable ? /^marker-[a-f0-9]{4}\.svg$/ : /^[a-f0-9]{4}\.svg$/), + ]); + expect(await readFile(path.join(output, "css", css[0]), "utf8")).toContain(`.${readable ? "probe" : app}-`); + } + ); + + test.each([ + {browser: "safari", mv2: false, manifestVersion: 2}, + {browser: "chrome", mv2: false, manifestVersion: 3}, + {browser: "chrome", mv2: true, manifestVersion: 2}, + ])( + "CLI build without a config targets $browser MV$manifestVersion (mv2 flag: $mv2)", + async ({browser, mv2, manifestVersion}) => { + await rm(path.join(fixture.directory, "adnbn.config.ts")); + + const result = run([cli, "build", ".", "-b", browser, ...(mv2 ? ["--mv2"] : [])], { + ...process.env, + VERSION: "1.2.3", + }); + + expect({status: result.status, stderr: result.stderr}).toEqual({status: 0, stderr: ""}); + expect(await readJson("dist", `addon-${browser}-mv${manifestVersion}`, "manifest.json")).toMatchObject({ + name: "addon", + version: "1.2.3", + manifest_version: manifestVersion, + }); + } + ); }); diff --git a/tests/integration/build/cli/fixtures/config/defaults/callback.config.ts b/tests/integration/build/cli/fixtures/config/defaults/callback.config.ts new file mode 100644 index 00000000..6e717756 --- /dev/null +++ b/tests/integration/build/cli/fixtures/config/defaults/callback.config.ts @@ -0,0 +1,13 @@ +import {appendFileSync} from "node:fs"; +import path from "node:path"; +import {defineConfig} from "adnbn"; +import {settings} from "./settings"; + +export default defineConfig(config => { + appendFileSync( + path.join(config.rootDir, "callback-calls.jsonl"), + JSON.stringify({...config, plugins: config.plugins.map(plugin => plugin.name)}) + "\n" + ); + + return settings; +}); diff --git a/tests/integration/build/cli/fixtures/config/defaults/debug.config.ts b/tests/integration/build/cli/fixtures/config/defaults/debug.config.ts new file mode 100644 index 00000000..9d22f8d3 --- /dev/null +++ b/tests/integration/build/cli/fixtures/config/defaults/debug.config.ts @@ -0,0 +1,4 @@ +import path from "node:path"; +import {defineConfig} from "adnbn"; + +export default defineConfig({app: "reader", debug: true, version: "1.2.3", srcDir: path.join("defaults", "src")}); diff --git a/tests/integration/build/cli/fixtures/config/defaults/explicit-callback.config.ts b/tests/integration/build/cli/fixtures/config/defaults/explicit-callback.config.ts new file mode 100644 index 00000000..0ca45ba9 --- /dev/null +++ b/tests/integration/build/cli/fixtures/config/defaults/explicit-callback.config.ts @@ -0,0 +1,4 @@ +import {defineConfig} from "adnbn"; +import {settings} from "./settings"; + +export default defineConfig(config => ({...config, ...settings})); diff --git a/tests/integration/build/cli/fixtures/config/defaults/explicit.config.ts b/tests/integration/build/cli/fixtures/config/defaults/explicit.config.ts new file mode 100644 index 00000000..546f7cac --- /dev/null +++ b/tests/integration/build/cli/fixtures/config/defaults/explicit.config.ts @@ -0,0 +1,12 @@ +import {defineConfig} from "adnbn"; +import {settings} from "./settings"; + +export default defineConfig({ + ...settings, + name: "addon", + manifestVersion: 3, + assetsFilename: "[contenthash:4][ext]", + jsFilename: "[contenthash:5].js", + cssFilename: "[contenthash:5].css", + cssIdentName: "[app]-[hash:base64:5]", +}); diff --git a/tests/integration/build/cli/fixtures/config/defaults/object.config.ts b/tests/integration/build/cli/fixtures/config/defaults/object.config.ts new file mode 100644 index 00000000..ee03c4fb --- /dev/null +++ b/tests/integration/build/cli/fixtures/config/defaults/object.config.ts @@ -0,0 +1,4 @@ +import {defineConfig} from "adnbn"; +import {settings} from "./settings"; + +export default defineConfig(settings); diff --git a/tests/integration/build/cli/fixtures/config/defaults/production.config.ts b/tests/integration/build/cli/fixtures/config/defaults/production.config.ts new file mode 100644 index 00000000..bd59f233 --- /dev/null +++ b/tests/integration/build/cli/fixtures/config/defaults/production.config.ts @@ -0,0 +1,4 @@ +import path from "node:path"; +import {defineConfig} from "adnbn"; + +export default defineConfig({version: "1.2.3", srcDir: path.join("defaults", "src")}); diff --git a/tests/integration/build/cli/fixtures/config/defaults/settings.ts b/tests/integration/build/cli/fixtures/config/defaults/settings.ts new file mode 100644 index 00000000..564175f0 --- /dev/null +++ b/tests/integration/build/cli/fixtures/config/defaults/settings.ts @@ -0,0 +1,10 @@ +import path from "node:path"; +import {Browser, Mode, type UserConfig} from "adnbn"; + +export const settings: UserConfig = { + app: "reader", + browser: Browser.Safari, + mode: Mode.Development, + version: "1.2.3", + srcDir: path.join("defaults", "src"), +}; diff --git a/tests/integration/build/cli/fixtures/config/defaults/src/marker.svg b/tests/integration/build/cli/fixtures/config/defaults/src/marker.svg new file mode 100644 index 00000000..92f29617 --- /dev/null +++ b/tests/integration/build/cli/fixtures/config/defaults/src/marker.svg @@ -0,0 +1 @@ + diff --git a/tests/integration/build/cli/fixtures/config/defaults/src/probe.module.css b/tests/integration/build/cli/fixtures/config/defaults/src/probe.module.css new file mode 100644 index 00000000..47d6633e --- /dev/null +++ b/tests/integration/build/cli/fixtures/config/defaults/src/probe.module.css @@ -0,0 +1,4 @@ +.probe { + background: url("./marker.svg"); + color: red; +} diff --git a/tests/integration/build/cli/fixtures/config/defaults/src/probe.page.ts b/tests/integration/build/cli/fixtures/config/defaults/src/probe.page.ts new file mode 100644 index 00000000..c2733cf5 --- /dev/null +++ b/tests/integration/build/cli/fixtures/config/defaults/src/probe.page.ts @@ -0,0 +1,11 @@ +import {definePage} from "adnbn"; +import styles from "./probe.module.css"; + +export default definePage({ + render: () => { + const element = document.createElement("div"); + element.className = styles.probe; + + return element; + }, +}); diff --git a/tests/integration/build/cli/fixtures/config/defaults/undefined.config.ts b/tests/integration/build/cli/fixtures/config/defaults/undefined.config.ts new file mode 100644 index 00000000..e94cc386 --- /dev/null +++ b/tests/integration/build/cli/fixtures/config/defaults/undefined.config.ts @@ -0,0 +1,12 @@ +import {defineConfig} from "adnbn"; +import {settings} from "./settings"; + +export default defineConfig({ + ...settings, + name: undefined, + manifestVersion: undefined, + assetsFilename: undefined, + jsFilename: undefined, + cssFilename: undefined, + cssIdentName: undefined, +}); diff --git a/tests/integration/build/cli/fixtures/config/tsconfig.json b/tests/integration/build/cli/fixtures/config/tsconfig.json new file mode 100644 index 00000000..eadf1be8 --- /dev/null +++ b/tests/integration/build/cli/fixtures/config/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.adnbn/tsconfig.json" +} diff --git a/tests/integration/build/cli/scripts/verify-root-dir.mjs b/tests/integration/build/cli/scripts/verify-root-dir.mjs index f6b0ce86..df07bca1 100644 --- a/tests/integration/build/cli/scripts/verify-root-dir.mjs +++ b/tests/integration/build/cli/scripts/verify-root-dir.mjs @@ -91,7 +91,7 @@ for (const [root, label, browser] of mode === "parallel" ? sequence.slice(1, 2) assert.equal(process.cwd(), workingDirectory); assert.equal(process.env, environment); assert.deepEqual({...process.env}, snapshot); - const output = path.join(root, `dist/myapp-${browser}-mv3`); + const output = path.join(root, `dist/addon-${browser}-mv3`); const html = await readFile(path.join(output, "newtab.html"), "utf8"); const manifest = JSON.parse(await readFile(path.join(output, "manifest.json"), "utf8")); assert.ok(html.includes(`Absolute root ${label}`)); diff --git a/tests/integration/build/options/embedded/adnbn.config.ts b/tests/integration/build/options/embedded/adnbn.config.ts index e9a0607a..36e2544d 100644 --- a/tests/integration/build/options/embedded/adnbn.config.ts +++ b/tests/integration/build/options/embedded/adnbn.config.ts @@ -1,6 +1,8 @@ import {defineConfig} from "adnbn"; +import target from "./manifest-version.json"; export default defineConfig({ + manifestVersion: target.mv2 ? 2 : 3, name: "Embedded Options Integration", description: "Build fixture preserving explicit openInTab false.", version: "1.0.0", diff --git a/tests/integration/build/options/embedded/manifest-version.json b/tests/integration/build/options/embedded/manifest-version.json new file mode 100644 index 00000000..de188121 --- /dev/null +++ b/tests/integration/build/options/embedded/manifest-version.json @@ -0,0 +1,3 @@ +{ + "mv2": false +} diff --git a/tests/integration/build/options/options.integration.test.ts b/tests/integration/build/options/options.integration.test.ts index 320fb97d..3c0c152d 100644 --- a/tests/integration/build/options/options.integration.test.ts +++ b/tests/integration/build/options/options.integration.test.ts @@ -1,4 +1,4 @@ -import {readFile} from "fs/promises"; +import {readFile, writeFile} from "fs/promises"; import path from "path"; import {createIntegrationFixture} from "../../utils/fixture"; @@ -13,6 +13,11 @@ describe.each(["chrome", "edge", "opera", "safari", "firefox"])("%s options mani const fixture = await createIntegrationFixture(rootDir, fixtureDir); try { + await writeFile( + path.join(fixture.directory, "manifest-version.json"), + JSON.stringify({mv2: manifestVersion === 2}) + ); + const extensionDir = await fixture.build({browser, manifestVersion}); const manifest = JSON.parse(await readFile(path.join(extensionDir, "manifest.json"), "utf8")); diff --git a/tests/integration/build/override/bookmarks/adnbn.config.ts b/tests/integration/build/override/bookmarks/adnbn.config.ts index fe0eef49..c8f4433d 100644 --- a/tests/integration/build/override/bookmarks/adnbn.config.ts +++ b/tests/integration/build/override/bookmarks/adnbn.config.ts @@ -1,6 +1,8 @@ import {defineConfig} from "adnbn"; +import target from "./manifest-version.json"; export default defineConfig({ + manifestVersion: target.mv2 ? 2 : 3, name: "Bookmarks Override Integration", description: "Build fixture replacing the browser bookmarks page.", version: "1.0.0", diff --git a/tests/integration/build/override/bookmarks/manifest-version.json b/tests/integration/build/override/bookmarks/manifest-version.json new file mode 100644 index 00000000..de188121 --- /dev/null +++ b/tests/integration/build/override/bookmarks/manifest-version.json @@ -0,0 +1,3 @@ +{ + "mv2": false +} diff --git a/tests/integration/build/override/competing.integration.test.ts b/tests/integration/build/override/competing.integration.test.ts index 0dfe0333..1f01dab3 100644 --- a/tests/integration/build/override/competing.integration.test.ts +++ b/tests/integration/build/override/competing.integration.test.ts @@ -34,7 +34,7 @@ describe("competing overrides", () => { const build = fixture.build({browser}); await expect(build).rejects.toThrow( - `An extension can override only one browser page, but app "myapp" enables 2 override entrypoints for ${browser}:` + `An extension can override only one browser page, but app "addon" enables 2 override entrypoints for ${browser}:` ); await expect(build).rejects.toThrow(" - newtab: src/newtab.ts"); await expect(build).rejects.toThrow(" - history: src/history.ts"); diff --git a/tests/integration/build/override/history/adnbn.config.ts b/tests/integration/build/override/history/adnbn.config.ts index 45b03b06..bc1660e0 100644 --- a/tests/integration/build/override/history/adnbn.config.ts +++ b/tests/integration/build/override/history/adnbn.config.ts @@ -1,6 +1,8 @@ import {defineConfig} from "adnbn"; +import target from "./manifest-version.json"; export default defineConfig({ + manifestVersion: target.mv2 ? 2 : 3, name: "History Override Integration", description: "Build fixture replacing the browser history page.", version: "1.0.0", diff --git a/tests/integration/build/override/history/manifest-version.json b/tests/integration/build/override/history/manifest-version.json new file mode 100644 index 00000000..de188121 --- /dev/null +++ b/tests/integration/build/override/history/manifest-version.json @@ -0,0 +1,3 @@ +{ + "mv2": false +} diff --git a/tests/integration/build/override/newtab/adnbn.config.ts b/tests/integration/build/override/newtab/adnbn.config.ts index fb8e8feb..f783c796 100644 --- a/tests/integration/build/override/newtab/adnbn.config.ts +++ b/tests/integration/build/override/newtab/adnbn.config.ts @@ -1,6 +1,8 @@ import {defineConfig} from "adnbn"; +import target from "./manifest-version.json"; export default defineConfig({ + manifestVersion: target.mv2 ? 2 : 3, name: "New Tab Override Integration", description: "Build fixture replacing the browser new tab page.", version: "1.0.0", diff --git a/tests/integration/build/override/newtab/manifest-version.json b/tests/integration/build/override/newtab/manifest-version.json new file mode 100644 index 00000000..de188121 --- /dev/null +++ b/tests/integration/build/override/newtab/manifest-version.json @@ -0,0 +1,3 @@ +{ + "mv2": false +} diff --git a/tests/integration/build/override/override-utils.ts b/tests/integration/build/override/override-utils.ts index a604bc7b..a01a5185 100644 --- a/tests/integration/build/override/override-utils.ts +++ b/tests/integration/build/override/override-utils.ts @@ -1,4 +1,4 @@ -import {readdir, readFile} from "fs/promises"; +import {readdir, readFile, writeFile} from "fs/promises"; import path from "path"; import BuildSession from "../../utils/BuildSession"; @@ -45,6 +45,11 @@ export const testOverridePage = ({page, permission, supported, unsupported}: Ove const fixture = await createIntegrationFixture(rootDir, fixtureDir, session); try { + await writeFile( + path.join(fixture.directory, "manifest-version.json"), + JSON.stringify({mv2: manifestVersion === 2}) + ); + const extensionDir = await fixture.build({browser, manifestVersion}); const manifest = await readOverrideManifest(extensionDir); const html = await readFile(path.join(extensionDir, `${page}.html`), "utf8"); @@ -66,6 +71,11 @@ export const testOverridePage = ({page, permission, supported, unsupported}: Ove const fixture = await createIntegrationFixture(rootDir, fixtureDir, session); try { + await writeFile( + path.join(fixture.directory, "manifest-version.json"), + JSON.stringify({mv2: manifestVersion === 2}) + ); + const extensionDir = await fixture.build({browser, manifestVersion}); const manifest = await readOverrideManifest(extensionDir); diff --git a/tests/integration/utils/fixture.ts b/tests/integration/utils/fixture.ts index 276f9612..2220d427 100644 --- a/tests/integration/utils/fixture.ts +++ b/tests/integration/utils/fixture.ts @@ -75,7 +75,7 @@ export const prepareIntegrationFixture = async ( ); } - return path.join(directory, "dist", `myapp-${browser}-mv${manifestVersion}`); + return path.join(directory, "dist", `addon-${browser}-mv${manifestVersion}`); }; export const createIntegrationFixture = async ( From 972dee763df8259c66aae542b7b53d97c32d4a30 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:28:29 +0300 Subject: [PATCH 13/24] fix(manifest): keep raw fields current after builder reads Invalidate the merged raw cache on raw() and merge generated commands into a fresh object to preserve the cached raw values. Cover MV2 and MV3 updates after reads, clearing internal commands, and a real CLI build using a manifest callback. --- src/cli/builders/manifest/Manifest.test.ts | 76 +++++++++++++++++++ src/cli/builders/manifest/ManifestBase.ts | 3 +- .../build/cli/config.integration.test.ts | 11 +++ .../config/manifest-callback.config.ts | 12 +++ 4 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 tests/integration/build/cli/fixtures/config/manifest-callback.config.ts diff --git a/src/cli/builders/manifest/Manifest.test.ts b/src/cli/builders/manifest/Manifest.test.ts index 635c2cd3..e6843a12 100644 --- a/src/cli/builders/manifest/Manifest.test.ts +++ b/src/cli/builders/manifest/Manifest.test.ts @@ -141,6 +141,82 @@ describe("Manifest common builder methods", () => { expect(builder.get()).toEqual(builder.build()); }); + describe.each([ + {version: 2, Builder: ManifestV2}, + {version: 3, Builder: ManifestV3}, + ])("MV$version raw updates", ({Builder}) => { + it("merges later raw fields after reading the manifest", () => { + const builder = new Builder(Browser.Chrome).raw({ + version_name: "before", + commands: {open: {description: "Before", suggested_key: {default: "Ctrl+Shift+U"}}}, + externally_connectable: {matches: ["https://before.example/*"]}, + }); + + expect(builder.get().version_name).toBe("before"); + + builder.raw({ + version_name: "after", + commands: {open: {description: "After", suggested_key: {mac: "Command+Shift+U"}}}, + externally_connectable: {matches: ["https://after.example/*"]}, + }); + + const manifest = builder.get(); + + expect(manifest).toMatchObject({ + version_name: "after", + commands: { + open: { + description: "After", + suggested_key: {default: "Ctrl+Shift+U", mac: "Command+Shift+U"}, + }, + }, + externally_connectable: {matches: ["https://before.example/*", "https://after.example/*"]}, + }); + + expect(builder.get()).toEqual(manifest); + }); + + it("restores raw commands after clearing internal commands", () => { + const rawCommands = { + shared: { + description: "Raw description", + suggested_key: {default: "Ctrl+Shift+Y", mac: "Command+Shift+Y"}, + }, + }; + + const builder = new Builder(Browser.Chrome) + .raw({commands: rawCommands}) + .setCommands( + new Set([ + {name: "shared", description: "Internal description", macKey: "Command+Shift+U"}, + {name: "temporary"}, + ]) + ); + + expect(builder.get().commands).toMatchObject({ + shared: { + description: "Internal description", + suggested_key: {default: "Ctrl+Shift+Y", mac: "Command+Shift+U"}, + }, + temporary: {description: "temporary"}, + }); + + builder.setCommands(); + + expect(builder.get().commands).toEqual(rawCommands); + }); + + it("includes raw fields after reading web accessible resources", () => { + const builder = new Builder(Browser.Chrome); + + expect(builder.getWebAccessibleResources()).toEqual([]); + + builder.raw({version_name: "after"}); + + expect(builder.get().version_name).toBe("after"); + }); + }); + it("merges raw objects and arrays and keeps unknown raw fields", () => { const builder = new ManifestV3(Browser.Chrome); diff --git a/src/cli/builders/manifest/ManifestBase.ts b/src/cli/builders/manifest/ManifestBase.ts index d9b06abd..4b86b84e 100644 --- a/src/cli/builders/manifest/ManifestBase.ts +++ b/src/cli/builders/manifest/ManifestBase.ts @@ -440,6 +440,7 @@ export default abstract class implements ManifestBuilder public raw(manifest: OptionalManifest): this { this.raws.add(manifest); + this.mergedRaws = undefined; return this; } @@ -596,7 +597,7 @@ export default abstract class implements ManifestBuilder {} as CoreManifest["commands"] ); - const commands = _.merge(this.combinedRaws.commands, internalCommands); + const commands = _.merge({}, this.combinedRaws.commands, internalCommands); if (Object.keys(commands).length) { return {commands}; diff --git a/tests/integration/build/cli/config.integration.test.ts b/tests/integration/build/cli/config.integration.test.ts index 6c0462dc..d38066c3 100644 --- a/tests/integration/build/cli/config.integration.test.ts +++ b/tests/integration/build/cli/config.integration.test.ts @@ -158,6 +158,17 @@ describe("user configuration through real c12", () => { expect(await readJson("user-plugin.json")).toEqual({name: "My Extension for Firefox", browser: "firefox"}); }); + test("CLI build preserves manifest callback fields after reading web accessible resources", async () => { + const result = run([cli, "build", ".", "--config", "manifest-callback.config.ts"]); + + expect({status: result.status, stderr: result.stderr}).toEqual({status: 0, stderr: ""}); + expect(await readJson("dist", "addon-chrome-mv3", "manifest.json")).toMatchObject({ + manifest_version: 3, + version: "1.2.3", + version_name: "1.2 beta", + }); + }); + test("object and callback settings recalculate the same defaults after one callback invocation", async () => { const configs = ["object", "callback"].map(kind => { const result = inspect({mode: Mode.Production}, path.join("defaults", `${kind}.config.ts`)); diff --git a/tests/integration/build/cli/fixtures/config/manifest-callback.config.ts b/tests/integration/build/cli/fixtures/config/manifest-callback.config.ts new file mode 100644 index 00000000..6782da88 --- /dev/null +++ b/tests/integration/build/cli/fixtures/config/manifest-callback.config.ts @@ -0,0 +1,12 @@ +import path from "node:path"; +import {defineConfig} from "adnbn"; + +export default defineConfig({ + version: "1.2.3", + srcDir: path.join("defaults", "src"), + manifest(builder) { + builder.getWebAccessibleResources(); + + return {version_name: "1.2 beta"}; + }, +}); From 335d575bcfddd9d2313dea4ce32abc7e6a96aa23 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:20:43 +0300 Subject: [PATCH 14/24] feat(manifest)!: expose types and narrow the builder contract Export named manifest and browser-specific types from adnbn. Keep compilation dependency types internal and use ManifestBase in the factory and Rspack plugin without changing manifest generation or watch behavior. Rename the private Chrome manifest base alias to distinguish it from the builder. Verify public exports and builder boundaries against source and package declarations. BREAKING CHANGE: ManifestBuilder no longer exposes setDependencies(), including configuration callbacks and plugin handlers. Compilation dependency injection is reserved for framework internals. --- src/cli/builders/manifest/ManifestBase.ts | 2 +- src/cli/builders/manifest/index.ts | 7 +- .../plugins/manifest/ManifestPlugin.ts | 5 +- src/main/browser.ts | 1 + src/main/manifest.ts | 32 +++- src/types/manifest.ts | 9 +- .../types/fixtures/manifest/builder.ts | 178 ++++++++++++++++++ .../types/manifest.integration.test.ts | 48 +++++ 8 files changed, 270 insertions(+), 12 deletions(-) create mode 100644 tests/integration/types/fixtures/manifest/builder.ts create mode 100644 tests/integration/types/manifest.integration.test.ts diff --git a/src/cli/builders/manifest/ManifestBase.ts b/src/cli/builders/manifest/ManifestBase.ts index 4b86b84e..c0d386ec 100644 --- a/src/cli/builders/manifest/ManifestBase.ts +++ b/src/cli/builders/manifest/ManifestBase.ts @@ -50,7 +50,7 @@ export class ManifestError extends Error { } } -export default abstract class implements ManifestBuilder { +export default abstract class ManifestBase implements ManifestBuilder { protected name?: string; protected author?: string; protected homepage?: string; diff --git a/src/cli/builders/manifest/index.ts b/src/cli/builders/manifest/index.ts index 80f18e95..491ce9fe 100644 --- a/src/cli/builders/manifest/index.ts +++ b/src/cli/builders/manifest/index.ts @@ -1,10 +1,13 @@ +import type ManifestBase from "./ManifestBase"; import ManifestV2 from "./ManifestV2"; import ManifestV3 from "./ManifestV3"; -import {ManifestBuilder} from "@typing/manifest"; +import type {Manifest} from "@typing/manifest"; import {ReadonlyConfig} from "@typing/config"; -export default (config: ReadonlyConfig): ManifestBuilder => { +export {default as ManifestBase} from "./ManifestBase"; + +export default (config: ReadonlyConfig): ManifestBase => { const {manifestVersion, browser} = config; if (manifestVersion === 2) { diff --git a/src/cli/bundler/plugins/manifest/ManifestPlugin.ts b/src/cli/bundler/plugins/manifest/ManifestPlugin.ts index 52016063..ec32e9eb 100644 --- a/src/cli/bundler/plugins/manifest/ManifestPlugin.ts +++ b/src/cli/bundler/plugins/manifest/ManifestPlugin.ts @@ -2,7 +2,8 @@ import rspack, {Compilation, Compiler} from "@rspack/core"; import {getCompilationBuildAssets, getManifestHooks} from "../utils"; -import {ManifestBuilder, ManifestDependencies, ManifestDependency} from "@typing/manifest"; +import type {ManifestBase} from "@cli/builders/manifest"; +import {Manifest, ManifestDependencies, ManifestDependency} from "@typing/manifest"; import {EntrypointAssetsMap} from "@typing/entrypoint"; export const createManifestDependencies = (buildAssets: EntrypointAssetsMap): ManifestDependencies => { @@ -22,7 +23,7 @@ export const createManifestDependencies = (buildAssets: EntrypointAssetsMap): Ma }; class ManifestPlugin { - constructor(private readonly manifest: ManifestBuilder | (() => ManifestBuilder)) {} + constructor(private readonly manifest: ManifestBase | (() => ManifestBase)) {} apply(compiler: Compiler): void { compiler.hooks.compilation.tap("ManifestPlugin", compilation => { diff --git a/src/main/browser.ts b/src/main/browser.ts index 43c31c17..dac8a5c3 100644 --- a/src/main/browser.ts +++ b/src/main/browser.ts @@ -1 +1,2 @@ export {Browser, DataCollectionPermission} from "@typing/browser"; +export type {BrowserSpecific, GeckoSpecific, VersionSpecific, DataCollectionPermissions} from "@typing/browser"; diff --git a/src/main/manifest.ts b/src/main/manifest.ts index 494c2a38..e1906156 100644 --- a/src/main/manifest.ts +++ b/src/main/manifest.ts @@ -1 +1,31 @@ -export {type ManifestBuilder, ManifestIncognito} from "@typing/manifest"; +export {ManifestIncognito} from "@typing/manifest"; + +export type { + Manifest, + CoreManifest, + OptionalManifest, + ManifestVersion, + ManifestIncognitoValue, + ManifestBuilder, + ManifestEntry, + ManifestBackground, + ManifestCommand, + ManifestCommands, + ManifestContentScript, + ManifestContentScripts, + ManifestPopup, + ManifestSidebar, + ManifestOptions, + ManifestOverride, + ManifestOverridePage, + ManifestSandbox, + ManifestIcon, + ManifestIcons, + ManifestPermission, + ManifestPermissions, + ManifestOptionalPermission, + ManifestOptionalPermissions, + ManifestHostPermissions, + ManifestAccessibleResource, + ManifestAccessibleResources, +} from "@typing/manifest"; diff --git a/src/types/manifest.ts b/src/types/manifest.ts index 1913a1ee..d8196344 100644 --- a/src/types/manifest.ts +++ b/src/types/manifest.ts @@ -28,7 +28,7 @@ export enum ManifestIncognito { type ManifestCommon = chrome.runtime.Manifest; -type ManifestBase = chrome.runtime.ManifestBase; +type ChromeManifestBase = chrome.runtime.ManifestBase; export type ManifestPermission = chrome.runtime.ManifestPermission; @@ -36,7 +36,7 @@ export type ManifestOptionalPermission = chrome.runtime.ManifestOptionalPermissi export type ManifestAction = NonNullable; -type ManifestFixed = Omit & { +type ManifestFixed = Omit & { manifest_version: ManifestVersion; }; @@ -49,7 +49,7 @@ export type ManifestVersion = 2 | 3; export type ManifestIncognitoValue = ManifestIncognito | `${ManifestIncognito}`; -export type CoreManifest = ManifestFixed; +export type CoreManifest = ManifestFixed; export type ChromeManifest = ManifestFixed; @@ -155,9 +155,6 @@ export interface ManifestBuilder { appendCsp(csps: Iterable): this; - // System - setDependencies(dependencies: ManifestDependencies): this; - // Permissions addPermission(permission: ManifestPermission): this; diff --git a/tests/integration/types/fixtures/manifest/builder.ts b/tests/integration/types/fixtures/manifest/builder.ts new file mode 100644 index 00000000..f4a260f5 --- /dev/null +++ b/tests/integration/types/fixtures/manifest/builder.ts @@ -0,0 +1,178 @@ +import {Browser, DataCollectionPermission, ManifestIncognito, defineConfig, definePlugin} from "adnbn"; +import type { + ActionOptions, + BrowserSpecific, + CoreManifest, + CspConfig, + DataCollectionPermissions, + GeckoSpecific, + Manifest, + ManifestAccessibleResource, + ManifestAccessibleResources, + ManifestBackground, + ManifestBuilder, + ManifestCommand, + ManifestCommands, + ManifestContentScript, + ManifestContentScripts, + ManifestEntry, + ManifestHostPermissions, + ManifestIcon, + ManifestIcons, + ManifestIncognitoValue, + ManifestOptionalPermission, + ManifestOptionalPermissions, + ManifestOptions, + ManifestOverride, + ManifestOverridePage, + ManifestPermission, + ManifestPermissions, + ManifestPopup, + ManifestSandbox, + ManifestSidebar, + ManifestVersion, + OptionalManifest, + VersionSpecific, +} from "adnbn"; + +// @ts-expect-error: Compilation dependencies are not a root export. +import type {ManifestDependencies} from "adnbn"; +// @ts-expect-error: Compilation dependency entries are not a root export. +import type {ManifestDependency} from "adnbn"; + +const version: ManifestVersion = 3; +const core: CoreManifest = {manifest_version: version, name: "Example", version: "1.0.0"}; +const manifest: Manifest = core; +const raw: OptionalManifest = {version_name: "1.0 beta"}; +const incognito: ManifestIncognitoValue = ManifestIncognito.Split; +const entry: ManifestEntry = {entry: "background"}; +const background: ManifestBackground = {...entry, persistent: false}; +const command: ManifestCommand = {name: "open", defaultKey: "Ctrl+Shift+Y"}; +const commands: ManifestCommands = new Set([command]); +const content: ManifestContentScript = {entry: "content", matches: ["https://example.com/*"], allFrames: false}; +const contents: ManifestContentScripts = new Set([content]); +const popup: ManifestPopup = {path: "popup.html", title: "Open"}; +const sidebar: ManifestSidebar = {path: "sidebar.html", title: "Panel", icon: "default"}; +const options: ManifestOptions = {path: "options.html", openInTab: false}; +const page: ManifestOverridePage = "newtab"; +const override: ManifestOverride = {page, path: "newtab.html"}; +const sandbox: ManifestSandbox = "sandbox.html"; +const icon: ManifestIcon = new Map([[16, "icon-16.png"]]); +const icons: ManifestIcons = new Map([["default", icon]]); +const permission: ManifestPermission = "storage"; +const permissions: ManifestPermissions = new Set([permission]); +const optionalPermission: ManifestOptionalPermission = "bookmarks"; +const optionalPermissions: ManifestOptionalPermissions = new Set([optionalPermission]); +const hosts: ManifestHostPermissions = new Set(["https://example.com/*"]); + +const resource: ManifestAccessibleResource = { + resources: ["panel.html"], + matches: ["https://example.com/*"], + useDynamicUrl: false, +}; + +const resources: ManifestAccessibleResources = new Set([resource]); + +const dataCollection: DataCollectionPermissions = { + required: [DataCollectionPermission.BrowsingActivity], + optional: ["technicalAndInteraction"], +}; + +const versions: VersionSpecific = {strictMinVersion: "140.0"}; +const gecko: GeckoSpecific = {...versions, id: "example@example.com", dataCollectionPermissions: dataCollection}; +const specific: BrowserSpecific = {gecko, geckoAndroid: versions, safari: versions}; +const action: ActionOptions = {title: "Open", icon: "default"}; +const csp: CspConfig = {wasm: false}; + +const configure = (builder: ManifestBuilder): ManifestBuilder => { + return builder + .setIncognito(incognito) + .setBackground(background) + .setCommands(commands) + .setContentScripts(contents) + .setPopup(popup) + .setSidebar(sidebar) + .setOptions(options) + .setOverride(override) + .addSandbox(sandbox) + .setIcons(icons) + .setPermissions(permissions) + .setOptionalPermissions(optionalPermissions) + .setHostPermissions(hosts) + .setAccessibleResource(resources) + .addAccessibleResource(resource) + .setSpecific(specific) + .mergeSpecific(specific) + .setAction(action) + .addCsp(csp) + .raw(raw); +}; + +declare const builder: ManifestBuilder; +const result: Manifest = configure(builder).get(); +const accessible: ManifestAccessibleResource[] = builder.getWebAccessibleResources(); +declare const coreBuilder: ManifestBuilder; +const coreResult: CoreManifest = coreBuilder.get(); + +// @ts-expect-error: Dependency injection belongs to the internal builder. +builder.setDependencies(new Map()); +// @ts-expect-error: Fluent calls must keep the public boundary. +builder.raw(raw).setName("Example").setDependencies(new Map()); + +defineConfig({ + browser: Browser.Firefox, + specific, + manifest(builder) { + configure(builder); + const current: Manifest = builder.get(); + const accessible: ManifestAccessibleResource[] = builder.getWebAccessibleResources(); + // @ts-expect-error: Configuration callbacks receive the public builder. + builder.setDependencies(new Map()); + // @ts-expect-error: Fluent calls do not expose the implementation. + builder.raw(raw).setSidebar(sidebar).setDependencies(new Map()); + + return raw; + }, +}); + +definePlugin(() => ({ + name: "manifest-contract", + manifest({manifest: builder}) { + configure(builder); + const current: Manifest = builder.get(); + const accessible: ManifestAccessibleResource[] = builder.getWebAccessibleResources(); + // @ts-expect-error: Plugin handlers receive the public builder. + builder.setDependencies(new Map()); + // @ts-expect-error: Fluent calls do not expose the implementation. + builder.raw(raw).setOverride(override).setDependencies(new Map()); + }, +})); + +// @ts-expect-error: Only Manifest V2 and V3 are supported. +const invalidVersion: ManifestVersion = 4; +// @ts-expect-error: Incognito values are restricted. +const invalidIncognito: ManifestIncognitoValue = "private"; +// @ts-expect-error: Entry names are strings. +const invalidEntry: ManifestEntry = {entry: 123}; +// @ts-expect-error: Background persistence is boolean. +const invalidBackground: ManifestBackground = {...entry, persistent: "false"}; +// @ts-expect-error: Command names are required. +const invalidCommand: ManifestCommand = {description: "Open"}; +// @ts-expect-error: Content script matches must be an array. +const invalidContent: ManifestContentScript = {entry: "content", matches: "https://example.com/*"}; +// @ts-expect-error: Sidebar paths are strings. +const invalidSidebar: ManifestSidebar = {path: 123}; +// @ts-expect-error: Options pages require a path. +const invalidOptions: ManifestOptions = {openInTab: false}; +// @ts-expect-error: Only supported override pages are accepted. +const invalidOverride: ManifestOverride = {page: "downloads", path: "downloads.html"}; +// @ts-expect-error: Icon sizes are numbers. +const invalidIcon: ManifestIcon = new Map([["16", "icon.png"]]); +// @ts-expect-error: Permission values are strings. +const invalidPermissions: ManifestPermissions = new Set([123]); +// @ts-expect-error: Dynamic resource URLs use a boolean flag. +const invalidResource: ManifestAccessibleResource = {resources: ["panel.html"], useDynamicUrl: "false"}; +// @ts-expect-error: Browser version limits are strings. +const invalidSpecific: BrowserSpecific = {gecko: {strictMinVersion: 140}}; +// @ts-expect-error: Data collection categories use the existing restricted contract. +const invalidDataCollection: DataCollectionPermissions = {required: ["unknown"]}; diff --git a/tests/integration/types/manifest.integration.test.ts b/tests/integration/types/manifest.integration.test.ts new file mode 100644 index 00000000..01b0e44a --- /dev/null +++ b/tests/integration/types/manifest.integration.test.ts @@ -0,0 +1,48 @@ +import path from "node:path"; +import ts from "typescript"; + +describe("manifest contracts", () => { + const projectDir = path.resolve(__dirname, "../../.."); + const fixture = path.join(__dirname, "fixtures", "manifest", "builder.ts"); + + test.each(["source", "package"])( + "checks public manifest types and builder boundaries through the %s API", + mode => { + const config = ts.readConfigFile(path.join(projectDir, "tsconfig.json"), ts.sys.readFile); + const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, projectDir); + expect(config.error).toBeUndefined(); + expect(parsed.errors).toEqual([]); + + const options: ts.CompilerOptions = { + ...(mode === "source" ? parsed.options : {}), + module: ts.ModuleKind.ESNext, + moduleResolution: ts.ModuleResolutionKind.Bundler, + target: ts.ScriptTarget.ESNext, + jsx: ts.JsxEmit.ReactJSX, + strict: true, + noEmit: true, + skipLibCheck: mode === "source", + types: ["node", "chrome"], + }; + + const host = ts.createCompilerHost(options); + const program = ts.createProgram([fixture], options, host); + + const diagnostics = ts.getPreEmitDiagnostics(program).map(diagnostic => { + const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"); + const position = diagnostic.file?.getLineAndCharacterOfPosition(diagnostic.start ?? 0); + + return `${diagnostic.file?.fileName ?? "compiler"}:${(position?.line ?? 0) + 1}: ${message}`; + }); + + expect(diagnostics).toEqual([]); + + const apiFile = ts.resolveModuleName("adnbn", fixture, options, host).resolvedModule?.resolvedFileName; + + expect(apiFile).toBe( + path.join(projectDir, mode === "source" ? "src/index.ts" : "dist/index.d.ts").replace(/\\/g, "/") + ); + }, + 30_000 + ); +}); From 723f111e4b046c70476ad6ab98a15a2b06b38f3f Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:35:09 +0300 Subject: [PATCH 15/24] feat(entrypoint)!: restrict workspace discovery for single-page entrypoints Discover options, newtab, history and bookmarks only as ., .., /index. and ./index. in each scanned app or shared source directory. Declare this policy through allowGroupedDirectories(), independently of allowMultiple(). Preserve grouping and nested fallback discovery for other entrypoints. Files in former locations remain available as ordinary imports. Keep build filters, workspace precedence and explicit plugin entrypoints unchanged. Cover the policy in the base finder, Options, override and Popup tests, and add a build integration for adjacent modules, templates, styles and resources. Update the integration test inventory documentation. BREAKING CHANGE: Automatic workspace discovery for options, newtab, history and bookmarks no longer scans grouping directories such as newtabs/ and histories/, or searches for additional candidates inside /. Direct /index. entrypoints remain supported. Ignored nested layouts are /., /.., //index. and /./index.. The same four candidate forms under newtabs/ for newtab and histories/ for history are also ignored. Move options/options.tsx to options/index.tsx, options/account.options.tsx to account.options.tsx, and newtabs/main.newtab.tsx to main.newtab.tsx. Named directory variants such as main.newtab/index.tsx remain supported. All paths are relative to the scanned source directory. Explicitly supplied plugin entrypoints are unaffected. --- .../finder/AbstractEntrypointFinder.test.ts | 59 ++++++++++++++++++- .../finder/AbstractEntrypointFinder.ts | 14 ++++- .../finder/AbstractOverrideFinder.test.ts | 14 ++++- .../finder/AbstractOverrideFinder.ts | 4 ++ .../entrypoint/finder/OptionsFinder.test.ts | 7 +++ src/cli/entrypoint/finder/OptionsFinder.ts | 6 +- src/cli/entrypoint/finder/PopupFinder.test.ts | 12 ++++ .../discovery/extensions/src/named.options.js | 1 + .../extensions/src/named.options/index.jsx | 1 + .../discovery/extensions/src/options.ts | 1 + .../extensions/src/options/index.tsx | 1 + .../fallback/src/background/background.ts | 1 + .../fixtures/discovery/grouped/src/popup.ts | 1 + .../discovery/grouped/src/popup/index.ts | 1 + .../grouped/src/popups/main.popup.ts | 1 + .../grouped/src/popups/named.popup/index.ts | 1 + .../nested/src/bookmarks/bookmarks.ts | 1 + .../discovery/nested/src/histories/history.ts | 1 + .../discovery/nested/src/history/history.ts | 1 + .../discovery/nested/src/newtab/newtab.ts | 1 + .../src/newtabs/account.newtab/index.jsx | 1 + .../discovery/nested/src/newtabs/newtab.ts | 1 + .../nested/src/options/account.options.tsx | 1 + .../src/options/account.options/index.jsx | 1 + .../discovery/nested/src/options/options.ts | 1 + .../nested/src/options/options/index.js | 1 + .../third-party/{ => bookmarks}/bookmarks.ts | 0 .../third-party/{ => history}/history.ts | 0 .../third-party/{ => newtab}/newtab.ts | 0 tests/integration/README.md | 3 + .../entrypoint-discovery.integration.test.ts | 55 +++++++++++++++++ .../fixture/adnbn.config.ts | 7 +++ .../entrypoint-discovery/fixture/package.json | 12 ++++ .../src/bookmarks/account.bookmarks.ts | 1 + .../fixture/src/histories/history.ts | 1 + .../fixture/src/newtab/index.ts | 9 +++ .../fixture/src/newtab/styles.scss | 3 + .../fixture/src/newtab/template.html | 9 +++ .../fixture/src/newtabs/account.newtab.ts | 1 + .../fixture/src/options/SettingsForm.tsx | 14 +++++ .../fixture/src/options/account.options.tsx | 3 + .../fixture/src/options/icon.svg | 3 + .../fixture/src/options/index.tsx | 12 ++++ .../fixture/src/options/options.ts | 1 + .../fixture/src/options/styles.scss | 4 ++ .../fixture/src/options/template.html | 9 +++ .../fixture/tsconfig.json | 6 ++ 47 files changed, 280 insertions(+), 8 deletions(-) create mode 100644 src/cli/entrypoint/finder/tests/fixtures/discovery/extensions/src/named.options.js create mode 100644 src/cli/entrypoint/finder/tests/fixtures/discovery/extensions/src/named.options/index.jsx create mode 100644 src/cli/entrypoint/finder/tests/fixtures/discovery/extensions/src/options.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/discovery/extensions/src/options/index.tsx create mode 100644 src/cli/entrypoint/finder/tests/fixtures/discovery/fallback/src/background/background.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/discovery/grouped/src/popup.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/discovery/grouped/src/popup/index.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/discovery/grouped/src/popups/main.popup.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/discovery/grouped/src/popups/named.popup/index.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/bookmarks/bookmarks.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/histories/history.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/history/history.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/newtab/newtab.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/newtabs/account.newtab/index.jsx create mode 100644 src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/newtabs/newtab.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/options/account.options.tsx create mode 100644 src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/options/account.options/index.jsx create mode 100644 src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/options/options.ts create mode 100644 src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/options/options/index.js rename src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/{ => bookmarks}/bookmarks.ts (100%) rename src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/{ => history}/history.ts (100%) rename src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/{ => newtab}/newtab.ts (100%) create mode 100644 tests/integration/build/entrypoint-discovery/entrypoint-discovery.integration.test.ts create mode 100644 tests/integration/build/entrypoint-discovery/fixture/adnbn.config.ts create mode 100644 tests/integration/build/entrypoint-discovery/fixture/package.json create mode 100644 tests/integration/build/entrypoint-discovery/fixture/src/bookmarks/account.bookmarks.ts create mode 100644 tests/integration/build/entrypoint-discovery/fixture/src/histories/history.ts create mode 100644 tests/integration/build/entrypoint-discovery/fixture/src/newtab/index.ts create mode 100644 tests/integration/build/entrypoint-discovery/fixture/src/newtab/styles.scss create mode 100644 tests/integration/build/entrypoint-discovery/fixture/src/newtab/template.html create mode 100644 tests/integration/build/entrypoint-discovery/fixture/src/newtabs/account.newtab.ts create mode 100644 tests/integration/build/entrypoint-discovery/fixture/src/options/SettingsForm.tsx create mode 100644 tests/integration/build/entrypoint-discovery/fixture/src/options/account.options.tsx create mode 100644 tests/integration/build/entrypoint-discovery/fixture/src/options/icon.svg create mode 100644 tests/integration/build/entrypoint-discovery/fixture/src/options/index.tsx create mode 100644 tests/integration/build/entrypoint-discovery/fixture/src/options/options.ts create mode 100644 tests/integration/build/entrypoint-discovery/fixture/src/options/styles.scss create mode 100644 tests/integration/build/entrypoint-discovery/fixture/src/options/template.html create mode 100644 tests/integration/build/entrypoint-discovery/fixture/tsconfig.json diff --git a/src/cli/entrypoint/finder/AbstractEntrypointFinder.test.ts b/src/cli/entrypoint/finder/AbstractEntrypointFinder.test.ts index 3df42043..29d9599c 100644 --- a/src/cli/entrypoint/finder/AbstractEntrypointFinder.test.ts +++ b/src/cli/entrypoint/finder/AbstractEntrypointFinder.test.ts @@ -8,8 +8,16 @@ import {toPosix} from "@cli/utils/path"; import {ReadonlyConfig} from "@typing/config"; import {EntrypointFile, EntrypointOptions, EntrypointParser, EntrypointType} from "@typing/entrypoint"; +interface TestFinderOptions { + grouped?: boolean; + type?: EntrypointType; +} + class TestFinder extends AbstractEntrypointFinder { - public constructor() { + public constructor( + config: Partial = {}, + private readonly settings: TestFinderOptions = {} + ) { super({ app: "app", appsDir: "apps", @@ -18,11 +26,16 @@ class TestFinder extends AbstractEntrypointFinder { rootDir: ".", sharedDir: "shared", srcDir: "src", + ...config, } as ReadonlyConfig); } public type(): EntrypointType { - return EntrypointType.Background; + return this.settings.type ?? EntrypointType.Background; + } + + protected allowGroupedDirectories(): boolean { + return this.settings.grouped ?? true; } public scan(directory: string): Set { @@ -50,7 +63,49 @@ const relativeFiles = (root: string, files: Set): string[] => { return Array.from(files, ({file}) => toPosix(path.relative(root, file))).sort(); }; +const fixtures = path.resolve(__dirname, "tests", "fixtures", "discovery"); + describe("AbstractEntrypointFinder", () => { + test.each([ + {grouped: true, expected: ["popups/main.popup.ts", "popups/named.popup/index.ts"]}, + {grouped: false, expected: ["popup.ts", "popup/index.ts"]}, + ])("applies grouped directory policy $grouped before choosing root candidates", async ({grouped, expected}) => { + const rootDir = path.join(fixtures, "grouped"); + const finder = new TestFinder({rootDir, sharedDir: "."}, {grouped, type: EntrypointType.Popup}); + + expect(relativeFiles(path.join(rootDir, "src"), await finder.files())).toEqual(expected); + }); + + test.each([true, false])("applies grouped directory policy %s to the nested fallback", async grouped => { + const rootDir = path.join(fixtures, "fallback"); + const finder = new TestFinder({rootDir, sharedDir: "."}, {grouped}); + + expect(relativeFiles(path.join(rootDir, "src"), await finder.files())).toEqual( + grouped ? ["background/background.ts"] : [] + ); + }); + + test("keeps TS, TSX, JS and JSX across the four root layouts when grouping is disabled", async () => { + const rootDir = path.join(fixtures, "extensions"); + const finder = new TestFinder({rootDir, sharedDir: "."}, {grouped: false, type: EntrypointType.Options}); + + expect(relativeFiles(path.join(rootDir, "src"), await finder.files())).toEqual([ + "named.options.js", + "named.options/index.jsx", + "options.ts", + "options/index.tsx", + ]); + }); + + test("ignores nested files and index directories when grouping is disabled", async () => { + const finder = new TestFinder( + {rootDir: path.join(fixtures, "nested"), sharedDir: "."}, + {grouped: false, type: EntrypointType.Options} + ); + + await expect(finder.files()).resolves.toEqual(new Set()); + }); + test("uses grouped entrypoint directory instead of root-level entrypoint files", () => { const root = createTempDir(); diff --git a/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts b/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts index 6946ac07..5bed8231 100644 --- a/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts +++ b/src/cli/entrypoint/finder/AbstractEntrypointFinder.ts @@ -10,7 +10,7 @@ import {getAppSourcePath, getSharedPath} from "@cli/workspace"; import {EntrypointFile, EntrypointFileExtensions, EntrypointOptions} from "@typing/entrypoint"; import {ReadonlyConfig} from "@typing/config"; -export default abstract class extends AbstractParsedFinder { +export default abstract class AbstractEntrypointFinder extends AbstractParsedFinder { protected fileExtensionsPattern: string; protected possibleIndexFiles: Set; @@ -32,6 +32,11 @@ export default abstract class extends AbstractParse return false; } + /** Whether workspace discovery may also collect candidates inside plural or type-named directories. */ + protected allowGroupedDirectories(): boolean { + return true; + } + protected async getFiles(): Promise> { const entrypoint = this.type(); @@ -72,6 +77,7 @@ export default abstract class extends AbstractParse protected findFiles(directory: string): Set { const entrypoint = this.type(); + const allowGroupedDirectories = this.allowGroupedDirectories(); const entrypointPluralize = pluralize(entrypoint); const rootFiles: EntrypointFile[] = []; @@ -121,7 +127,11 @@ export default abstract class extends AbstractParse } }; - collect(directory, rootFiles, true); + collect(directory, rootFiles, allowGroupedDirectories); + + if (!allowGroupedDirectories) { + return new Set(rootFiles); + } if (groupedFiles.length > 0) { return new Set(groupedFiles); diff --git a/src/cli/entrypoint/finder/AbstractOverrideFinder.test.ts b/src/cli/entrypoint/finder/AbstractOverrideFinder.test.ts index 98d08d43..81f07008 100644 --- a/src/cli/entrypoint/finder/AbstractOverrideFinder.test.ts +++ b/src/cli/entrypoint/finder/AbstractOverrideFinder.test.ts @@ -44,7 +44,7 @@ describe.each([ { name: "third-party", [type]: { - file: path.join(workspace, "plugins", "third-party", `${type}.ts`), + file: path.join(workspace, "plugins", "third-party", type, `${type}.ts`), import: `third-party/${type}`, external: "third-party", }, @@ -74,6 +74,13 @@ describe.each([ expect(relativeFiles(workspace, await makeFinder().files())).toEqual([`src/apps/app/${type}.ts`]); }); + test("ignores grouped and nested candidates when there is no root entrypoint", async () => { + const rootDir = path.join(fixtures, "..", "discovery", "nested"); + const finder = new Finder(makeConfig({rootDir, sharedDir: "."})); + + await expect(finder.files()).resolves.toEqual(new Set()); + }); + test("falls back to the shared candidate for an app without its own", async () => { expect(relativeFiles(workspace, await makeFinder({app: "other"}).files())).toEqual([`src/shared/${type}.ts`]); }); @@ -82,7 +89,7 @@ describe.each([ const finder = makeFinder(); expect(relativeFiles(workspace, await finder.plugin().files())).toEqual([ - `plugins/third-party/${type}.ts`, + `plugins/third-party/${type}/${type}.ts`, `src/apps/app/${type}.ts`, ]); @@ -108,11 +115,12 @@ describe.each([ ]); }); - test("uses the plugin candidate when the workspace has none", async () => { + test("uses the explicitly supplied nested plugin candidate when the workspace has none", async () => { const finder = makeFinder({srcDir: "missing"}); const [view] = (await finder.views()).values(); expect(view).toMatchObject({ + file: {file: path.join(workspace, "plugins", "third-party", type, `${type}.ts`)}, alias: `third-party/${type}`, filename: `${type}.html`, options: {title: `Plugin ${type}`}, diff --git a/src/cli/entrypoint/finder/AbstractOverrideFinder.ts b/src/cli/entrypoint/finder/AbstractOverrideFinder.ts index 9e6a9a87..6ffe02a5 100644 --- a/src/cli/entrypoint/finder/AbstractOverrideFinder.ts +++ b/src/cli/entrypoint/finder/AbstractOverrideFinder.ts @@ -30,4 +30,8 @@ export default abstract class AbstractOverrideFinder< public allowMultiple(): boolean { return false; } + + protected allowGroupedDirectories(): boolean { + return false; + } } diff --git a/src/cli/entrypoint/finder/OptionsFinder.test.ts b/src/cli/entrypoint/finder/OptionsFinder.test.ts index 3792da9f..a6b77a75 100644 --- a/src/cli/entrypoint/finder/OptionsFinder.test.ts +++ b/src/cli/entrypoint/finder/OptionsFinder.test.ts @@ -67,6 +67,13 @@ describe("OptionsFinder", () => { expect(files).toEqual(["account.options.tsx", "advanced.options/index.tsx", "options.ts", "options/index.ts"]); }); + test("ignores nested candidates when there is no root entrypoint", async () => { + const rootDir = path.resolve(__dirname, "tests", "fixtures", "discovery", "nested"); + const finder = new OptionsFinder(makeConfig({rootDir, sharedDir: "."})); + + await expect(finder.files()).resolves.toEqual(new Set()); + }); + test("selects the highest-priority options page and only collects its CSP", async () => { const plugin = file("/plugins/default/options.ts"); const shared = file("/project/src/shared/options.ts"); diff --git a/src/cli/entrypoint/finder/OptionsFinder.ts b/src/cli/entrypoint/finder/OptionsFinder.ts index 5c142145..cd8b15a8 100644 --- a/src/cli/entrypoint/finder/OptionsFinder.ts +++ b/src/cli/entrypoint/finder/OptionsFinder.ts @@ -8,7 +8,7 @@ import type {ReadonlyConfig} from "@typing/config"; import type {OptionsEntrypointOptions} from "@typing/options"; import {EntrypointOptionsFinder, EntrypointParser, EntrypointType} from "@typing/entrypoint"; -export default class extends ViewPermissionsFinder { +export default class OptionsFinder extends ViewPermissionsFinder { public constructor(config: ReadonlyConfig) { super(config); } @@ -17,6 +17,10 @@ export default class extends ViewPermissionsFinder { return EntrypointType.Options; } + protected allowGroupedDirectories(): boolean { + return false; + } + protected getParser(): EntrypointParser { return new OptionsParser(this.config); } diff --git a/src/cli/entrypoint/finder/PopupFinder.test.ts b/src/cli/entrypoint/finder/PopupFinder.test.ts index 40e08d03..23edb6a7 100644 --- a/src/cli/entrypoint/finder/PopupFinder.test.ts +++ b/src/cli/entrypoint/finder/PopupFinder.test.ts @@ -1,4 +1,7 @@ +import path from "path"; + import PopupFinder from "./PopupFinder"; +import {toPosix} from "@cli/utils/path"; import {ReadonlyConfig} from "@typing/config"; import {EntrypointFile, EntrypointOptionsFinder, EntrypointType} from "@typing/entrypoint"; @@ -52,6 +55,15 @@ const createPlugin = ( }); describe("PopupFinder", () => { + test("keeps grouped directory priority when multiple popups are disabled", async () => { + const rootDir = path.resolve(__dirname, "tests", "fixtures", "discovery", "grouped"); + const finder = new PopupFinder({...config, rootDir, sharedDir: ".", multiplePopup: false}); + const files = Array.from(await finder.files(), ({file}) => toPosix(path.relative(rootDir, file))); + + expect(finder.allowMultiple()).toBe(false); + expect(files).toEqual(["src/popups/main.popup.ts", "src/popups/named.popup/index.ts"]); + }); + test("selects the highest-priority popup when multiple popups are disabled", async () => { const plugin = file("/plugins/default/popup.ts"); const shared = file("/project/src/shared/popup.ts"); diff --git a/src/cli/entrypoint/finder/tests/fixtures/discovery/extensions/src/named.options.js b/src/cli/entrypoint/finder/tests/fixtures/discovery/extensions/src/named.options.js new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/discovery/extensions/src/named.options.js @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/discovery/extensions/src/named.options/index.jsx b/src/cli/entrypoint/finder/tests/fixtures/discovery/extensions/src/named.options/index.jsx new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/discovery/extensions/src/named.options/index.jsx @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/discovery/extensions/src/options.ts b/src/cli/entrypoint/finder/tests/fixtures/discovery/extensions/src/options.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/discovery/extensions/src/options.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/discovery/extensions/src/options/index.tsx b/src/cli/entrypoint/finder/tests/fixtures/discovery/extensions/src/options/index.tsx new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/discovery/extensions/src/options/index.tsx @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/discovery/fallback/src/background/background.ts b/src/cli/entrypoint/finder/tests/fixtures/discovery/fallback/src/background/background.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/discovery/fallback/src/background/background.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/discovery/grouped/src/popup.ts b/src/cli/entrypoint/finder/tests/fixtures/discovery/grouped/src/popup.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/discovery/grouped/src/popup.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/discovery/grouped/src/popup/index.ts b/src/cli/entrypoint/finder/tests/fixtures/discovery/grouped/src/popup/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/discovery/grouped/src/popup/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/discovery/grouped/src/popups/main.popup.ts b/src/cli/entrypoint/finder/tests/fixtures/discovery/grouped/src/popups/main.popup.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/discovery/grouped/src/popups/main.popup.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/discovery/grouped/src/popups/named.popup/index.ts b/src/cli/entrypoint/finder/tests/fixtures/discovery/grouped/src/popups/named.popup/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/discovery/grouped/src/popups/named.popup/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/bookmarks/bookmarks.ts b/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/bookmarks/bookmarks.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/bookmarks/bookmarks.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/histories/history.ts b/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/histories/history.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/histories/history.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/history/history.ts b/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/history/history.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/history/history.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/newtab/newtab.ts b/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/newtab/newtab.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/newtab/newtab.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/newtabs/account.newtab/index.jsx b/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/newtabs/account.newtab/index.jsx new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/newtabs/account.newtab/index.jsx @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/newtabs/newtab.ts b/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/newtabs/newtab.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/newtabs/newtab.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/options/account.options.tsx b/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/options/account.options.tsx new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/options/account.options.tsx @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/options/account.options/index.jsx b/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/options/account.options/index.jsx new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/options/account.options/index.jsx @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/options/options.ts b/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/options/options.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/options/options.ts @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/options/options/index.js b/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/options/options/index.js new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/src/cli/entrypoint/finder/tests/fixtures/discovery/nested/src/options/options/index.js @@ -0,0 +1 @@ +export {}; diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/bookmarks.ts b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/bookmarks/bookmarks.ts similarity index 100% rename from src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/bookmarks.ts rename to src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/bookmarks/bookmarks.ts diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/history.ts b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/history/history.ts similarity index 100% rename from src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/history.ts rename to src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/history/history.ts diff --git a/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/newtab.ts b/src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/newtab/newtab.ts similarity index 100% rename from src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/newtab.ts rename to src/cli/entrypoint/finder/tests/fixtures/override/workspace/plugins/third-party/newtab/newtab.ts diff --git a/tests/integration/README.md b/tests/integration/README.md index b2bc45cc..23f60cf1 100644 --- a/tests/integration/README.md +++ b/tests/integration/README.md @@ -23,6 +23,9 @@ tests/integration/ │ │ │ ├── root-dir/ │ │ │ └── root-dir-b/ │ │ └── scripts/ +│ ├── entrypoint-discovery/ +│ │ ├── entrypoint-discovery.integration.test.ts +│ │ └── fixture/ │ ├── html/ │ │ ├── html.integration.test.ts │ │ ├── fixture/ diff --git a/tests/integration/build/entrypoint-discovery/entrypoint-discovery.integration.test.ts b/tests/integration/build/entrypoint-discovery/entrypoint-discovery.integration.test.ts new file mode 100644 index 00000000..53b41930 --- /dev/null +++ b/tests/integration/build/entrypoint-discovery/entrypoint-discovery.integration.test.ts @@ -0,0 +1,55 @@ +import {readFile, readdir} from "fs/promises"; +import path from "path"; + +import {createIntegrationFixture} from "../../utils/fixture"; + +const rootDir = path.resolve(__dirname, "..", "..", "..", ".."); +const fixtureDir = path.join(__dirname, "fixture"); + +jest.setTimeout(90_000); + +describe("workspace page discovery", () => { + test("builds directory pages with adjacent templates, styles, components and entrypoint-named imports", async () => { + const fixture = await createIntegrationFixture(rootDir, fixtureDir); + + try { + const extensionDir = await fixture.build(); + const manifest = JSON.parse(await readFile(path.join(extensionDir, "manifest.json"), "utf8")); + + expect(manifest.options_ui).toEqual({page: "options.html", open_in_tab: true}); + expect(manifest.chrome_url_overrides).toEqual({newtab: "newtab.html"}); + + for (const type of ["options", "newtab"]) { + const html = await readFile(path.join(extensionDir, `${type}.html`), "utf8"); + + expect(html).toContain(`data-template="${type}"`); + expect(html).toContain(" => { + const contents = await Promise.all( + files + .filter(file => file.endsWith(extension)) + .map(file => readFile(path.join(extensionDir, file), "utf8")) + ); + + return contents.join("\n"); + }; + + const scripts = await readAssets(".js"); + const styles = await readAssets(".css"); + + expect(scripts).toContain("Imported account component"); + expect(scripts).toContain("Imported options module"); + expect(scripts).toContain("Imported newtabs module"); + expect(styles).toContain(".settings-form"); + expect(styles).toContain(".newtab-page"); + expect(files.some(file => file.endsWith(".svg"))).toBe(true); + expect(files.filter(file => file.endsWith(".html")).sort()).toEqual(["newtab.html", "options.html"]); + } finally { + await fixture.dispose(); + } + }); +}); diff --git a/tests/integration/build/entrypoint-discovery/fixture/adnbn.config.ts b/tests/integration/build/entrypoint-discovery/fixture/adnbn.config.ts new file mode 100644 index 00000000..df57cbb3 --- /dev/null +++ b/tests/integration/build/entrypoint-discovery/fixture/adnbn.config.ts @@ -0,0 +1,7 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "Entrypoint Discovery Integration", + description: "Directory pages with ordinary adjacent modules and resources.", + version: "1.0.0", +}); diff --git a/tests/integration/build/entrypoint-discovery/fixture/package.json b/tests/integration/build/entrypoint-discovery/fixture/package.json new file mode 100644 index 00000000..c0687270 --- /dev/null +++ b/tests/integration/build/entrypoint-discovery/fixture/package.json @@ -0,0 +1,12 @@ +{ + "name": "adnbn-test-entrypoint-discovery", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../..", + "react": "*", + "react-dom": "*", + "scheduler": "*" + } +} diff --git a/tests/integration/build/entrypoint-discovery/fixture/src/bookmarks/account.bookmarks.ts b/tests/integration/build/entrypoint-discovery/fixture/src/bookmarks/account.bookmarks.ts new file mode 100644 index 00000000..e53e18a7 --- /dev/null +++ b/tests/integration/build/entrypoint-discovery/fixture/src/bookmarks/account.bookmarks.ts @@ -0,0 +1 @@ +export const label = "Unused bookmarks module"; diff --git a/tests/integration/build/entrypoint-discovery/fixture/src/histories/history.ts b/tests/integration/build/entrypoint-discovery/fixture/src/histories/history.ts new file mode 100644 index 00000000..84d39842 --- /dev/null +++ b/tests/integration/build/entrypoint-discovery/fixture/src/histories/history.ts @@ -0,0 +1 @@ +export const label = "Unused histories module"; diff --git a/tests/integration/build/entrypoint-discovery/fixture/src/newtab/index.ts b/tests/integration/build/entrypoint-discovery/fixture/src/newtab/index.ts new file mode 100644 index 00000000..c660e29f --- /dev/null +++ b/tests/integration/build/entrypoint-discovery/fixture/src/newtab/index.ts @@ -0,0 +1,9 @@ +import {defineNewtab} from "adnbn"; + +import {moduleLabel} from "../newtabs/account.newtab"; +import "./styles.scss"; + +export default defineNewtab({ + template: "./template.html", + render: () => moduleLabel, +}); diff --git a/tests/integration/build/entrypoint-discovery/fixture/src/newtab/styles.scss b/tests/integration/build/entrypoint-discovery/fixture/src/newtab/styles.scss new file mode 100644 index 00000000..ec756a99 --- /dev/null +++ b/tests/integration/build/entrypoint-discovery/fixture/src/newtab/styles.scss @@ -0,0 +1,3 @@ +:global(.newtab-page) { + color: navy; +} diff --git a/tests/integration/build/entrypoint-discovery/fixture/src/newtab/template.html b/tests/integration/build/entrypoint-discovery/fixture/src/newtab/template.html new file mode 100644 index 00000000..641690bb --- /dev/null +++ b/tests/integration/build/entrypoint-discovery/fixture/src/newtab/template.html @@ -0,0 +1,9 @@ + + + + + + +
+ + diff --git a/tests/integration/build/entrypoint-discovery/fixture/src/newtabs/account.newtab.ts b/tests/integration/build/entrypoint-discovery/fixture/src/newtabs/account.newtab.ts new file mode 100644 index 00000000..beb0f4ec --- /dev/null +++ b/tests/integration/build/entrypoint-discovery/fixture/src/newtabs/account.newtab.ts @@ -0,0 +1 @@ +export const moduleLabel = "Imported newtabs module"; diff --git a/tests/integration/build/entrypoint-discovery/fixture/src/options/SettingsForm.tsx b/tests/integration/build/entrypoint-discovery/fixture/src/options/SettingsForm.tsx new file mode 100644 index 00000000..e1c33949 --- /dev/null +++ b/tests/integration/build/entrypoint-discovery/fixture/src/options/SettingsForm.tsx @@ -0,0 +1,14 @@ +import React from "react"; + +import {AccountSettings} from "./account.options"; + +interface SettingsFormProps { + label: string; +} + +export const SettingsForm = ({label}: SettingsFormProps) => ( +
+

{label}

+ +
+); diff --git a/tests/integration/build/entrypoint-discovery/fixture/src/options/account.options.tsx b/tests/integration/build/entrypoint-discovery/fixture/src/options/account.options.tsx new file mode 100644 index 00000000..598ea13f --- /dev/null +++ b/tests/integration/build/entrypoint-discovery/fixture/src/options/account.options.tsx @@ -0,0 +1,3 @@ +import React from "react"; + +export const AccountSettings = () =>

Imported account component

; diff --git a/tests/integration/build/entrypoint-discovery/fixture/src/options/icon.svg b/tests/integration/build/entrypoint-discovery/fixture/src/options/icon.svg new file mode 100644 index 00000000..5116551f --- /dev/null +++ b/tests/integration/build/entrypoint-discovery/fixture/src/options/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/tests/integration/build/entrypoint-discovery/fixture/src/options/index.tsx b/tests/integration/build/entrypoint-discovery/fixture/src/options/index.tsx new file mode 100644 index 00000000..f57e0bc1 --- /dev/null +++ b/tests/integration/build/entrypoint-discovery/fixture/src/options/index.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import {defineOptions} from "adnbn"; + +import {SettingsForm} from "./SettingsForm"; +import {moduleLabel} from "./options"; +import "./styles.scss"; + +export default defineOptions({ + template: "./template.html", + openInTab: true, + render: () => , +}); diff --git a/tests/integration/build/entrypoint-discovery/fixture/src/options/options.ts b/tests/integration/build/entrypoint-discovery/fixture/src/options/options.ts new file mode 100644 index 00000000..f76f43df --- /dev/null +++ b/tests/integration/build/entrypoint-discovery/fixture/src/options/options.ts @@ -0,0 +1 @@ +export const moduleLabel = "Imported options module"; diff --git a/tests/integration/build/entrypoint-discovery/fixture/src/options/styles.scss b/tests/integration/build/entrypoint-discovery/fixture/src/options/styles.scss new file mode 100644 index 00000000..6067e527 --- /dev/null +++ b/tests/integration/build/entrypoint-discovery/fixture/src/options/styles.scss @@ -0,0 +1,4 @@ +:global(.settings-form) { + color: teal; + background-image: url("./icon.svg"); +} diff --git a/tests/integration/build/entrypoint-discovery/fixture/src/options/template.html b/tests/integration/build/entrypoint-discovery/fixture/src/options/template.html new file mode 100644 index 00000000..eccd0f7b --- /dev/null +++ b/tests/integration/build/entrypoint-discovery/fixture/src/options/template.html @@ -0,0 +1,9 @@ + + + + + + +
+ + diff --git a/tests/integration/build/entrypoint-discovery/fixture/tsconfig.json b/tests/integration/build/entrypoint-discovery/fixture/tsconfig.json new file mode 100644 index 00000000..0be5b764 --- /dev/null +++ b/tests/integration/build/entrypoint-discovery/fixture/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "./.adnbn/tsconfig.json", + "compilerOptions": { + "jsx": "react-jsx" + } +} From b4d15c6bca5f50a29bda4c037d78fb07be0fa3ed Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:54:18 +0300 Subject: [PATCH 16/24] fix(transport)!: throw when a local instance is missing Check direct instance lookup in BaseTransport while preserving object identity, context checks, optional manager lookup, and remote proxy behavior. Add runtime regressions and concrete source/package type assertions for direct getters and registration providers. Document the local Relay exception. BREAKING CHANGE: Direct getters now throw synchronously when an instance is missing instead of returning undefined. Callers that checked for undefined must handle the exception. --- .../tests/fixtures/relay-types/consumer.ts | 7 +++ src/offscreen/index.test.ts | 39 ++++++++++++++ .../providers/RegisterOffscreen.test.ts | 8 +-- src/relay/README.md | 2 + src/relay/index.test.ts | 35 +++++++++++++ src/relay/providers/RegisterRelay.test.ts | 6 +-- src/sandbox/providers/RegisterSandbox.test.ts | 10 ++++ src/service/index.test.ts | 41 +++++++++++++++ src/service/providers/RegisterService.test.ts | 6 +-- src/transport/BaseTransport.test.ts | 51 +++++++++++++++++++ src/transport/BaseTransport.ts | 8 ++- src/transport/ProxyTransport.test.ts | 24 +++++++++ src/transport/RegisterTransport.test.ts | 12 +++++ .../types/fixtures/registries/generated.ts | 19 +++++++ .../types/fixtures/relay/register.ts | 10 ++++ .../types/relay.integration.test.ts | 1 + 16 files changed, 268 insertions(+), 11 deletions(-) create mode 100644 src/offscreen/index.test.ts create mode 100644 src/relay/index.test.ts create mode 100644 src/sandbox/providers/RegisterSandbox.test.ts create mode 100644 src/service/index.test.ts create mode 100644 src/transport/BaseTransport.test.ts create mode 100644 src/transport/ProxyTransport.test.ts create mode 100644 tests/integration/types/fixtures/relay/register.ts diff --git a/src/cli/plugins/content/tests/fixtures/relay-types/consumer.ts b/src/cli/plugins/content/tests/fixtures/relay-types/consumer.ts index 9d55d85a..4fac5439 100644 --- a/src/cli/plugins/content/tests/fixtures/relay-types/consumer.ts +++ b/src/cli/plugins/content/tests/fixtures/relay-types/consumer.ts @@ -4,6 +4,12 @@ import * as entry from "adnbn/entry/relay"; type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; type Expect = T; +type Scanner = { + scan(text: string): number; + load(): Promise; + nested: {ready(): boolean; count: number}; +}; + const original = local.getRelay("scanner"); const scalar = api.getRelay("scanner", 1); const top = api.getRelay("scanner", {tabId: 1}); @@ -40,6 +46,7 @@ type NestedObjectResult = Expect< type ScalarAlias = Expect>>; type BatchAlias = Expect>>; type LocalAlias = Expect>>; +type LocalInstance = Expect>; type Names = Expect>; scalar.scan("text"); diff --git a/src/offscreen/index.test.ts b/src/offscreen/index.test.ts new file mode 100644 index 00000000..444ae7fb --- /dev/null +++ b/src/offscreen/index.test.ts @@ -0,0 +1,39 @@ +import {getBrowserTest} from "@tests/browser-harness/session"; +import {OffscreenGlobalAccess} from "@typing/offscreen"; +import {getOffscreen, RegisterOffscreen} from "./index"; + +// Runtime registration does not generate a registry; consumer type tests cover the generated names and results. +const name = "parser" as never; + +beforeEach(() => { + const session = getBrowserTest(); + + session.useContext(session.harness.contexts.create({kind: "offscreen"})); + globalThis[OffscreenGlobalAccess] = true; +}); + +describe("getOffscreen", () => { + test("returns the registered offscreen instance", () => { + const instance = {parse: (text: string) => text.length}; + const registration = new RegisterOffscreen(name, () => instance); + + registration.register(); + getBrowserTest().addCleanup(() => registration.destroy()); + + expect(getOffscreen(name)).toBe(instance); + }); + + test("throws when the instance is not registered in offscreen", () => { + expect(() => getOffscreen(name)).toThrow( + 'Transport instance "parser" is not registered in the current context.' + ); + }); + + test("reports the context error before a missing registration", () => { + globalThis[OffscreenGlobalAccess] = false; + + expect(() => getOffscreen(name)).toThrow( + 'Offscreen service "parser" can be getting only from offscreen context.' + ); + }); +}); diff --git a/src/offscreen/providers/RegisterOffscreen.test.ts b/src/offscreen/providers/RegisterOffscreen.test.ts index e97ae25e..ed134c06 100644 --- a/src/offscreen/providers/RegisterOffscreen.test.ts +++ b/src/offscreen/providers/RegisterOffscreen.test.ts @@ -19,13 +19,13 @@ describe("RegisterOffscreen", () => { globalThis[OffscreenGlobalAccess] = true; }); - test("throws an error when get() is called outside offscreen context", async () => { + test.each([offscreenName, "missing"])("rejects direct access to %s outside offscreen before lookup", name => { globalThis[OffscreenGlobalAccess] = false; - const proxy = new RegisterOffscreen(offscreenName, () => mathService); + const registration = new RegisterOffscreen(name, () => mathService); - expect(() => proxy.get()).toThrow( - `Offscreen service "${offscreenName}" can be getting only from offscreen context.` + expect(() => registration.get()).toThrow( + `Offscreen service "${name}" can be getting only from offscreen context.` ); }); diff --git a/src/relay/README.md b/src/relay/README.md index d291a77e..ab428d41 100644 --- a/src/relay/README.md +++ b/src/relay/README.md @@ -76,6 +76,8 @@ The two `getRelay` imports serve different contexts: The local accessor is not a way to address another frame. The remote accessor rejects use in a context containing the Relay manager; use the local instance there. +After the context check, a missing local instance throws synchronously with its name instead of returning `undefined`. + ### Export ownership - [`adnbn`](../main/relay.ts) exports `defineRelay`, the remote `getRelay`, and the public definition, call-target, proxy, result, and error contracts. Import `RelayAllFrames`, `RelayMethod`, `RelayFrameErrorKind`, `RelayDiscoveryError`, and `RelayProtocolError` here. diff --git a/src/relay/index.test.ts b/src/relay/index.test.ts new file mode 100644 index 00000000..27dc6237 --- /dev/null +++ b/src/relay/index.test.ts @@ -0,0 +1,35 @@ +import {getBrowserTest} from "@tests/browser-harness/session"; +import {RelayGlobalKey, RelayMethod} from "@typing/relay"; +import RelayManager from "./RelayManager"; +import RegisterRelay from "./providers/RegisterRelay"; +import {getRelay} from "./index"; + +// Runtime registration does not generate a registry; consumer type tests cover the generated names and results. +const name = "parser" as never; + +beforeEach(() => { + // The existing Relay context predicate uses the manager's presence, independently of its registrations. + RelayManager.getInstance(); +}); + +describe("getRelay", () => { + test("returns the registered relay", () => { + const instance = {parse: (text: string) => text.length}; + const registration = new RegisterRelay(name, RelayMethod.Scripting, () => instance); + + registration.register(); + getBrowserTest().addCleanup(() => registration.destroy()); + + expect(getRelay(name)).toBe(instance); + }); + + test("throws when the relay is not registered in the Relay context", () => { + expect(() => getRelay(name)).toThrow('Transport instance "parser" is not registered in the current context.'); + }); + + test("reports the context error before a missing registration", () => { + Reflect.deleteProperty(globalThis, RelayGlobalKey); + + expect(() => getRelay(name)).toThrow('Relay "parser" can be getting only from content script'); + }); +}); diff --git a/src/relay/providers/RegisterRelay.test.ts b/src/relay/providers/RegisterRelay.test.ts index 60817635..94c4c390 100644 --- a/src/relay/providers/RegisterRelay.test.ts +++ b/src/relay/providers/RegisterRelay.test.ts @@ -11,11 +11,11 @@ beforeEach(() => { new RegisterRelay("math", RelayMethod.Scripting, () => math).register(); }); -test("rejects access outside the Relay content context", () => { +test.each(["math", "missing"])("rejects direct access to %s outside the Relay context before lookup", name => { Reflect.deleteProperty(globalThis, RelayGlobalKey); - expect(() => new RegisterRelay("math", RelayMethod.Scripting, () => math).get()).toThrow( - 'Relay "math" can be getting only from content script' + expect(() => new RegisterRelay(name, RelayMethod.Scripting, () => math).get()).toThrow( + `Relay "${name}" can be getting only from content script` ); }); diff --git a/src/sandbox/providers/RegisterSandbox.test.ts b/src/sandbox/providers/RegisterSandbox.test.ts new file mode 100644 index 00000000..2548431b --- /dev/null +++ b/src/sandbox/providers/RegisterSandbox.test.ts @@ -0,0 +1,10 @@ +import {SandboxGlobalAccess} from "@typing/sandbox"; +import RegisterSandbox from "./RegisterSandbox"; + +test("reports the sandbox context error before a missing registration", () => { + globalThis[SandboxGlobalAccess] = false; + + const registration = new RegisterSandbox("parser", () => ({})); + + expect(() => registration.get()).toThrow('Sandbox "parser" can be getting only from sandbox context.'); +}); diff --git a/src/service/index.test.ts b/src/service/index.test.ts new file mode 100644 index 00000000..d5152942 --- /dev/null +++ b/src/service/index.test.ts @@ -0,0 +1,41 @@ +import {createManifestFixture, installGlobals} from "@addon-core/browser/testing"; +import {getBrowserTest} from "@tests/browser-harness/session"; +import {getService, RegisterService} from "./index"; + +// Runtime registration does not generate a registry; consumer type tests cover the generated names and results. +const name = "parser" as never; + +beforeEach(() => { + const session = getBrowserTest(); + + session.harness.runtime.setManifest( + createManifestFixture({manifest_version: 3, background: {service_worker: "background.js"}}) + ); + session.useContext(session.harness.contexts.create({kind: "background"})); + session.addCleanup(installGlobals({window: undefined})); +}); + +describe("getService", () => { + test("returns the registered service", () => { + const instance = {parse: (text: string) => text.length}; + const registration = new RegisterService(name, () => instance); + + registration.register(); + getBrowserTest().addCleanup(() => registration.destroy()); + + expect(getService(name)).toBe(instance); + }); + + test("throws when the service is not registered in background", () => { + expect(() => getService(name)).toThrow('Transport instance "parser" is not registered in the current context.'); + }); + + test("reports the context error before a missing registration", () => { + const session = getBrowserTest(); + + session.useContext(session.context); + session.addCleanup(installGlobals({window: {}, location: {pathname: "/popup.html"}})); + + expect(() => getService(name)).toThrow('Service "parser" can be getting only from background context.'); + }); +}); diff --git a/src/service/providers/RegisterService.test.ts b/src/service/providers/RegisterService.test.ts index ad6ba76c..d892b913 100644 --- a/src/service/providers/RegisterService.test.ts +++ b/src/service/providers/RegisterService.test.ts @@ -21,14 +21,14 @@ beforeEach(() => { }); describe("RegisterService", () => { - test("rejects direct access outside background", () => { + test.each([serviceName, "missing"])("rejects direct access to %s outside background before lookup", name => { const session = getBrowserTest(); session.useContext(session.context); session.addCleanup(installGlobals({window: {}, location: {pathname: "/popup.html"}})); - expect(() => registration.get()).toThrow( - `Service "${serviceName}" can be getting only from background context.` + expect(() => new RegisterService(name, () => mathService).get()).toThrow( + `Service "${name}" can be getting only from background context.` ); }); diff --git a/src/transport/BaseTransport.test.ts b/src/transport/BaseTransport.test.ts new file mode 100644 index 00000000..91b7fa29 --- /dev/null +++ b/src/transport/BaseTransport.test.ts @@ -0,0 +1,51 @@ +import BaseTransport from "./BaseTransport"; +import TransportManager from "./TransportManager"; + +class TestManager extends TransportManager {} + +class TestTransport extends BaseTransport<"parser", T> { + constructor(private readonly transportManager: TestManager) { + super("parser"); + } + + protected manager(): TestManager { + return this.transportManager; + } +} + +describe("BaseTransport", () => { + let manager: TestManager; + + beforeEach(() => { + manager = new TestManager(); + }); + + test.each([ + ["an object with methods", {parse: (text: string) => text.length}], + ["an empty object", {}], + ])("returns %s without copying it", (_description, instance) => { + manager.add("parser", instance); + + expect(new TestTransport(manager).get()).toBe(instance); + }); + + test("throws synchronously with the requested name when the instance is missing", () => { + const transport = new TestTransport(manager); + + expect(() => transport.get()).toThrow('Transport instance "parser" is not registered in the current context.'); + }); + + test("throws after destroy removes the instance", () => { + manager.add("parser", {}); + + const transport = new TestTransport(manager); + + transport.destroy(); + + expect(() => transport.get()).toThrow('Transport instance "parser" is not registered in the current context.'); + }); + + test("preserves optional lookup through the manager", () => { + expect(manager.get("parser")).toBeUndefined(); + }); +}); diff --git a/src/transport/BaseTransport.ts b/src/transport/BaseTransport.ts index f2b9ae8d..b1ae7242 100644 --- a/src/transport/BaseTransport.ts +++ b/src/transport/BaseTransport.ts @@ -6,7 +6,13 @@ export default abstract class { + constructor(private readonly transportManager: TestManager) { + super("parser"); + } + + protected manager(): TestManager { + return this.transportManager; + } + + protected apply(): void {} +} + +test("creates a proxy without a locally registered instance", () => { + const manager = new TestManager(); + const proxy = new TestProxy(manager).get(); + + expect(manager.has("parser")).toBe(false); + expect(Reflect.get(proxy, "__proxy")).toBe(true); +}); diff --git a/src/transport/RegisterTransport.test.ts b/src/transport/RegisterTransport.test.ts index 1344bc16..e6239905 100644 --- a/src/transport/RegisterTransport.test.ts +++ b/src/transport/RegisterTransport.test.ts @@ -64,6 +64,18 @@ describe("RegisterTransport", () => { message = new TestMessage(); }); + test("returns the registered instance until destroy removes it", () => { + const instance = {parse: (text: string) => text.length}; + const registration = new TestRegister(() => instance, message, manager); + + expect(registration.register()).toBe(instance); + expect(registration.get()).toBe(instance); + + registration.destroy(); + + expect(() => registration.get()).toThrow('Transport instance "test" is not registered in the current context.'); + }); + test("exposes sender on the call-scoped execution context", async () => { const instance = new TestRegister( () => ({ diff --git a/tests/integration/types/fixtures/registries/generated.ts b/tests/integration/types/fixtures/registries/generated.ts index 7e0e40a4..47dbb2dd 100644 --- a/tests/integration/types/fixtures/registries/generated.ts +++ b/tests/integration/types/fixtures/registries/generated.ts @@ -4,6 +4,10 @@ import * as offscreen from "adnbn/offscreen"; import * as sandbox from "adnbn/sandbox"; import type {Equal, Expect} from "./assert"; +type WorkerInstance = {run(input: string): number}; +type DocumentInstance = {parse(input: string): boolean}; +type FrameInstance = {render(input: string): Promise}; + type PopupNames = Expect>; type SidebarNames = Expect>; type IconNames = Expect>; @@ -43,6 +47,16 @@ const document = api.getOffscreen("document"); const frame = api.getSandbox("frame"); const localWorker = service.getService("worker"); const localDocument = offscreen.getOffscreen("document"); +const registeredWorker = new service.RegisterService("worker", () => localWorker).get(); +const registeredDocument = new offscreen.RegisterOffscreen("document", () => localDocument).get(); + +const registeredFrame = new sandbox.RegisterSandbox( + "frame", + (): FrameInstance => ({ + render: async input => input, + }) +).get(); + const workerResult = worker.run("input"); const documentResult = document.parse("input"); const frameResult = frame.render("input"); @@ -59,6 +73,11 @@ type DocumentProxy = Expect>>; type WorkerTarget = Expect>>; type DocumentTarget = Expect>>; +type WorkerInstanceType = Expect>; +type DocumentInstanceType = Expect>; +type RegisteredWorkerType = Expect>; +type RegisteredDocumentType = Expect>; +type RegisteredFrameType = Expect>; // @ts-expect-error: The generated popup names constrain the original API. api.changePopup("unknown"); diff --git a/tests/integration/types/fixtures/relay/register.ts b/tests/integration/types/fixtures/relay/register.ts new file mode 100644 index 00000000..cfab9a18 --- /dev/null +++ b/tests/integration/types/fixtures/relay/register.ts @@ -0,0 +1,10 @@ +import {RelayMethod} from "adnbn"; +import RegisterRelay from "@relay/providers/RegisterRelay"; + +type Equal = (() => T extends A ? 1 : 2) extends () => T extends B ? 1 : 2 ? true : false; +type Expect = T; +type Scanner = {scan(text: string): number}; + +const registration = new RegisterRelay("scanner", RelayMethod.Scripting, () => ({scan: (text: string) => text.length})); + +type RegisteredInstance = Expect, Scanner>>; diff --git a/tests/integration/types/relay.integration.test.ts b/tests/integration/types/relay.integration.test.ts index 4005ee28..90be4cde 100644 --- a/tests/integration/types/relay.integration.test.ts +++ b/tests/integration/types/relay.integration.test.ts @@ -29,6 +29,7 @@ describe("Relay entrypoint contracts", () => { if (mode === "source") { rootNames.push( + path.join(__dirname, "fixtures/relay/register.ts"), path.join(__dirname, "fixtures/relay/virtual.ts"), path.join(projectDir, "src/cli/virtual/relay.ts"), path.join(projectDir, "src/cli/virtual/virtual.d.ts") From e9b920ee73649e33b18f74928665f55b6560daa0 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 9 Oct 2026 16:56:40 +0300 Subject: [PATCH 17/24] fix(typescript): normalize module alias separators on Windows Use forward slashes for module alias keys and generated TypeScript paths while keeping filesystem targets native. Cover nested paths and relative prefixes, and include stderr in CLI build failures. --- .../typescript/TypescriptConfig.test.ts | 60 +++++++++++++++++++ .../plugins/typescript/TypescriptConfig.ts | 4 +- .../build/cli/config.integration.test.ts | 2 +- 3 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 src/cli/plugins/typescript/TypescriptConfig.test.ts diff --git a/src/cli/plugins/typescript/TypescriptConfig.test.ts b/src/cli/plugins/typescript/TypescriptConfig.test.ts new file mode 100644 index 00000000..2cee832c --- /dev/null +++ b/src/cli/plugins/typescript/TypescriptConfig.test.ts @@ -0,0 +1,60 @@ +import path from "node:path"; + +import TypescriptConfig from "./TypescriptConfig"; + +import type {ReadonlyConfig} from "@typing/config"; + +describe("TypescriptConfig", () => { + const rootDir = path.resolve(__dirname, "tests", "project"); + + test.each([ + {name: "default directories", srcDir: "src", sharedDir: ".", alias: "src", source: "src", shared: "src"}, + { + name: "native nested directories", + srcDir: path.join("defaults", "src"), + sharedDir: path.join("..", "shared", "ui"), + alias: "defaults/src", + source: "defaults/src", + shared: "defaults/shared/ui", + }, + { + name: "portable nested directories", + srcDir: "defaults/src", + sharedDir: "../shared/ui", + alias: "defaults/src", + source: "defaults/src", + shared: "defaults/shared/ui", + }, + { + name: "an explicit relative prefix", + srcDir: `.${path.sep}src`, + sharedDir: ".", + alias: "./src", + source: "src", + shared: "src", + }, + ])("keeps aliases and generated paths consistent for $name", ({srcDir, sharedDir, alias, source, shared}) => { + const config = {rootDir, srcDir, sharedDir, outDir: "dist"} as ReadonlyConfig; + const typescript = new TypescriptConfig(config); + + expect(typescript.aliases()).toEqual({ + "adnbn/browser": "@addon-core/browser", + "adnbn/storage": "@addon-core/storage", + [alias]: path.resolve(rootDir, source), + "@": path.resolve(rootDir, source), + "@shared": path.resolve(rootDir, shared), + "~": path.resolve(rootDir, shared), + }); + + const paths = { + [`${source}/*`]: [`../${source}/*`], + "@/*": [`../${source}/*`], + "@shared/*": [`../${shared}/*`], + "~/*": [`../${shared}/*`], + }; + + expect(typescript.paths()).toEqual(paths); + expect(typescript.json().compilerOptions?.paths).toEqual(paths); + expect(config).toEqual({rootDir, srcDir, sharedDir, outDir: "dist"}); + }); +}); diff --git a/src/cli/plugins/typescript/TypescriptConfig.ts b/src/cli/plugins/typescript/TypescriptConfig.ts index fdd2a33e..a0fd1c65 100644 --- a/src/cli/plugins/typescript/TypescriptConfig.ts +++ b/src/cli/plugins/typescript/TypescriptConfig.ts @@ -36,8 +36,8 @@ export default class extends FileBuilder { } protected alias(): Record { - const srcDir = this.config.srcDir; - const sharedDir = path.posix.join(srcDir, this.config.sharedDir); + const srcDir = this.config.srcDir.split(path.sep).join("/"); + const sharedDir = path.posix.join(srcDir, this.config.sharedDir.split(path.sep).join("/")); return { [srcDir]: srcDir, diff --git a/tests/integration/build/cli/config.integration.test.ts b/tests/integration/build/cli/config.integration.test.ts index d38066c3..e66968ba 100644 --- a/tests/integration/build/cli/config.integration.test.ts +++ b/tests/integration/build/cli/config.integration.test.ts @@ -239,7 +239,7 @@ describe("user configuration through real c12", () => { ...(kind === "explicit" ? ["--mv2"] : []), ]); - expect(result.status).toBe(0); + expect({status: result.status, stderr: result.stderr}).toEqual({status: 0, stderr: expect.any(String)}); if (kind !== "debug") { expect(result.stderr).toBe(""); From 08217e5a1fa2914dd33d52bac468fd6e4fb90054 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:13:26 +0300 Subject: [PATCH 18/24] feat(popup)!: separate document titles from toolbar tooltips Add the tooltip option for literal text and locale keys. Preserve View title behavior and the initial action title fallback. Resolve localized tooltips through browser i18n when changing popups, preserving the current tooltip when the option is omitted and allowing explicit empty values. Update generated runtime metadata, public types, examples, and focused parser, runtime, build, and browser coverage. Clarify ManifestPopup.title as manifest default_title data without referring to Popup configuration. BREAKING CHANGE: Popup title now controls only the document and View props. Move toolbar tooltip values to tooltip and read getPopups() metadata from tooltip instead of title. ManifestPopup.title remains unchanged. --- src/cli/builders/manifest/Manifest.test.ts | 1 + src/cli/entrypoint/parser/PopupParser.test.ts | 7 +- src/cli/entrypoint/parser/PopupParser.ts | 3 +- .../tests/fixtures/popup/invalid/tooltip.ts | 3 + .../popup/options/empty-tooltip/popup.ts | 3 + .../fixtures/popup/options/full/popup.ts | 1 + src/cli/plugins/popup/Popup.test.ts | 54 ++++++++ src/cli/plugins/popup/Popup.ts | 18 ++- src/cli/plugins/popup/index.ts | 7 +- src/cli/plugins/popup/popup-module.ts | 4 +- src/entry/view/README.md | 3 + src/main/popup.test.ts | 129 ++++++++++++++++++ src/main/popup.ts | 19 ++- src/types/manifest.ts | 2 +- src/types/popup.ts | 22 ++- src/virtual/popup.ts | 4 +- .../popup/popup.firefox.integration.test.ts | 7 + .../browser/popup/popup.integration.test.ts | 7 + tests/integration/browser/popup/popup.ts | 70 ++++++++++ .../build/action/action.integration.test.ts | 8 +- .../action/fixture/variants/full.popup.ts | 3 +- .../action/fixture/variants/title.popup.ts | 2 +- .../fixture/variants/unapplied.popup.ts | 3 +- .../build/popup/fixture/adnbn.config.ts | 9 ++ .../build/popup/fixture/package.json | 10 ++ .../build/popup/fixture/site/top.html | 9 ++ .../build/popup/fixture/src/background.ts | 95 +++++++++++++ .../build/popup/fixture/src/locales/en.json | 5 + .../build/popup/fixture/src/locales/fr.json | 5 + .../build/popup/fixture/src/popup.ts | 8 ++ .../build/popup/fixture/src/probe.content.ts | 17 +++ .../build/popup/fixture/src/report-view.ts | 7 + .../build/popup/fixture/src/settings.popup.ts | 7 + .../build/popup/fixture/tsconfig.json | 3 + .../variants/settings-missing-tooltip.ts | 3 + .../variants/settings-without-tooltip.ts | 15 ++ .../build/popup/popup.integration.test.ts | 54 ++++++++ .../virtual-modules/fixture/src/popup.ts | 2 +- .../virtual-modules.integration.test.ts | 2 +- .../types/fixtures/registries/generated.ts | 13 +- .../types/fixtures/view/definition.tsx | 3 + 41 files changed, 614 insertions(+), 33 deletions(-) create mode 100644 src/cli/entrypoint/parser/tests/fixtures/popup/invalid/tooltip.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/popup/options/empty-tooltip/popup.ts create mode 100644 src/cli/plugins/popup/Popup.test.ts create mode 100644 src/main/popup.test.ts create mode 100644 tests/integration/browser/popup/popup.firefox.integration.test.ts create mode 100644 tests/integration/browser/popup/popup.integration.test.ts create mode 100644 tests/integration/browser/popup/popup.ts create mode 100644 tests/integration/build/popup/fixture/adnbn.config.ts create mode 100644 tests/integration/build/popup/fixture/package.json create mode 100644 tests/integration/build/popup/fixture/site/top.html create mode 100644 tests/integration/build/popup/fixture/src/background.ts create mode 100644 tests/integration/build/popup/fixture/src/locales/en.json create mode 100644 tests/integration/build/popup/fixture/src/locales/fr.json create mode 100644 tests/integration/build/popup/fixture/src/popup.ts create mode 100644 tests/integration/build/popup/fixture/src/probe.content.ts create mode 100644 tests/integration/build/popup/fixture/src/report-view.ts create mode 100644 tests/integration/build/popup/fixture/src/settings.popup.ts create mode 100644 tests/integration/build/popup/fixture/tsconfig.json create mode 100644 tests/integration/build/popup/fixture/variants/settings-missing-tooltip.ts create mode 100644 tests/integration/build/popup/fixture/variants/settings-without-tooltip.ts create mode 100644 tests/integration/build/popup/popup.integration.test.ts diff --git a/src/cli/builders/manifest/Manifest.test.ts b/src/cli/builders/manifest/Manifest.test.ts index e6843a12..1bc77cea 100644 --- a/src/cli/builders/manifest/Manifest.test.ts +++ b/src/cli/builders/manifest/Manifest.test.ts @@ -468,6 +468,7 @@ describe.each([ it.each([ {options: {}, title: "Action", icon: "action"}, {options: {title: "Popup"}, title: "Popup", icon: "action"}, + {options: {title: ""}, title: "", icon: "action"}, {options: {icon: "popup"}, title: "Action", icon: "popup"}, {options: {title: "Popup", icon: "popup"}, title: "Popup", icon: "popup"}, ])("overrides popup fields independently: $options", ({options, title, icon}) => { diff --git a/src/cli/entrypoint/parser/PopupParser.test.ts b/src/cli/entrypoint/parser/PopupParser.test.ts index f8ea35b1..38166296 100644 --- a/src/cli/entrypoint/parser/PopupParser.test.ts +++ b/src/cli/entrypoint/parser/PopupParser.test.ts @@ -28,6 +28,7 @@ describe("PopupParser", () => { expect(parseOptions("options", "full", "popup.ts")).toEqual({ as: "panel", title: "Extension panel", + tooltip: "@popup.panel", template: "./template.html", icon: "toolbar", apply: false, @@ -50,7 +51,11 @@ describe("PopupParser", () => { }); }); - test.each(["icon", "apply"])("rejects an invalid %s value", field => { + test("preserves an explicitly empty tooltip", () => { + expect(parseOptions("options", "empty-tooltip", "popup.ts")).toEqual({tooltip: ""}); + }); + + test.each(["icon", "apply", "tooltip"])("rejects an invalid %s value", field => { expect(() => parseOptions("invalid", `${field}.ts`)).toThrow(`Invalid options ${field}`); }); }); diff --git a/src/cli/entrypoint/parser/PopupParser.ts b/src/cli/entrypoint/parser/PopupParser.ts index e0da962f..8031cb05 100644 --- a/src/cli/entrypoint/parser/PopupParser.ts +++ b/src/cli/entrypoint/parser/PopupParser.ts @@ -5,7 +5,7 @@ import {PermissionsSchema} from "./schemas/permissions"; import {PopupEntrypointOptions} from "@typing/popup"; -export default class extends ViewCspParser { +export default class PopupParser extends ViewCspParser { protected definition(): string { return "definePopup"; } @@ -14,6 +14,7 @@ export default class extends ViewCspParser { return super.schema().merge(PermissionsSchema).extend({ icon: z.string().nonempty().optional(), apply: z.boolean().optional(), + tooltip: z.string().optional(), }); } } diff --git a/src/cli/entrypoint/parser/tests/fixtures/popup/invalid/tooltip.ts b/src/cli/entrypoint/parser/tests/fixtures/popup/invalid/tooltip.ts new file mode 100644 index 00000000..148a5118 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/popup/invalid/tooltip.ts @@ -0,0 +1,3 @@ +export default { + tooltip: 42, +}; diff --git a/src/cli/entrypoint/parser/tests/fixtures/popup/options/empty-tooltip/popup.ts b/src/cli/entrypoint/parser/tests/fixtures/popup/options/empty-tooltip/popup.ts new file mode 100644 index 00000000..27cbcb9f --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/popup/options/empty-tooltip/popup.ts @@ -0,0 +1,3 @@ +import {definePopup} from "adnbn"; + +export default definePopup({tooltip: ""}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/popup/options/full/popup.ts b/src/cli/entrypoint/parser/tests/fixtures/popup/options/full/popup.ts index a3c0593d..6b0a7be8 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/popup/options/full/popup.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/popup/options/full/popup.ts @@ -3,6 +3,7 @@ import {Browser, CspSource, definePopup} from "adnbn"; export default definePopup({ as: "panel", title: "Extension panel", + tooltip: "@popup.panel", template: "./template.html", icon: "toolbar", apply: false, diff --git a/src/cli/plugins/popup/Popup.test.ts b/src/cli/plugins/popup/Popup.test.ts new file mode 100644 index 00000000..d7375bd9 --- /dev/null +++ b/src/cli/plugins/popup/Popup.test.ts @@ -0,0 +1,54 @@ +import path from "path"; + +import Popup from "./Popup"; +import type {ViewItems} from "@cli/entrypoint/finder/AbstractViewFinder"; +import type {ReadonlyConfig} from "@typing/config"; +import type {PopupEntrypointOptions} from "@typing/popup"; + +const rootDir = path.resolve(__dirname); + +class TestPopup extends Popup { + public constructor(private readonly entries: ViewItems) { + super({rootDir, app: "test-app"} as ReadonlyConfig); + } + + public async views(): Promise> { + return this.entries; + } +} + +const entry = (alias: string, options: PopupEntrypointOptions) => ({ + alias, + filename: `${alias}.html`, + file: {file: path.join(rootDir, `${alias}.ts`), import: `./${alias}.ts`}, + options, +}); + +test.each([ + {tooltip: undefined, expected: undefined}, + {tooltip: "Open account", expected: "Open account"}, + {tooltip: "@popup.account", expected: "__MSG_popup_account__"}, + {tooltip: "", expected: ""}, +])("prepares tooltip $tooltip independently of the document title", async ({tooltip, expected}) => { + const popup = new TestPopup(new Map([["account", entry("account", {title: "Account document", tooltip})]])); + + await expect(popup.manifest()).resolves.toEqual({path: "account.html", title: expected}); + await expect(popup.entriesByAlias()).resolves.toEqual({account: {path: "account.html", tooltip}}); + expect((await popup.view().html())[0].title).toBe("Account document"); +}); + +test("keeps unapplied popup metadata but excludes its tooltip from the initial manifest", async () => { + const popup = new TestPopup( + new Map([ + ["alternate", entry("alternate", {apply: false, tooltip: "Alternate"})], + ["account", entry("account", {tooltip: "Account"})], + ]) + ); + + await expect(popup.manifest()).resolves.toEqual({path: "account.html", title: "Account"}); + await expect(popup.entriesByAlias()).resolves.toEqual({ + alternate: {path: "alternate.html", tooltip: "Alternate"}, + account: {path: "account.html", tooltip: "Account"}, + }); + expect((await popup.view().html())[0].title).toBe("Test App"); +}); diff --git a/src/cli/plugins/popup/Popup.ts b/src/cli/plugins/popup/Popup.ts index ebd6c9e5..a158813b 100644 --- a/src/cli/plugins/popup/Popup.ts +++ b/src/cli/plugins/popup/Popup.ts @@ -5,12 +5,10 @@ import {modifyLocaleMessageKey} from "@shared/locale"; import {PopupFinder} from "@cli/entrypoint"; import {ReadonlyConfig} from "@typing/config"; -import {PopupEntrypointOptions} from "@typing/popup"; +import {PopupAliasMap, PopupEntrypointOptions} from "@typing/popup"; import {ManifestPopup} from "@typing/manifest"; -export type PopupNameToManifest = Record; - -export default class extends PopupFinder { +export default class Popup extends PopupFinder { protected _view?: View; public constructor(config: ReadonlyConfig) { @@ -25,32 +23,32 @@ export default class extends PopupFinder { const views = await this.views(); for (const {filename, options} of views.values()) { - const {apply = true, title, icon} = options; + const {apply = true, tooltip, icon} = options; if (apply) { return { path: filename, - title: modifyLocaleMessageKey(title), + title: modifyLocaleMessageKey(tooltip), icon, }; } } } - public async manifestByAlias(): Promise { + public async entriesByAlias(): Promise { return Array.from(await this.views()).reduce((aliases, [_, item]) => { const {options, filename} = item; - const {title, icon} = options; + const {tooltip, icon} = options; return { ...aliases, [item.alias]: { path: filename, - title, + tooltip, icon, }, }; - }, {} as PopupNameToManifest); + }, {} as PopupAliasMap); } public clear(): this { diff --git a/src/cli/plugins/popup/index.ts b/src/cli/plugins/popup/index.ts index 63882ee1..bdd5f401 100644 --- a/src/cli/plugins/popup/index.ts +++ b/src/cli/plugins/popup/index.ts @@ -6,12 +6,13 @@ import {definePlugin} from "@main/plugin"; import {EntrypointPlugin, GenerateModulePlugin} from "@cli/bundler"; import {virtualViewModule} from "@cli/virtual"; -import Popup, {PopupNameToManifest} from "./Popup"; +import Popup from "./Popup"; import {createPopupModule, PopupModuleName} from "./popup-module"; import {PopupDeclaration} from "./declaration"; import {Command} from "@typing/app"; +import type {PopupAliasMap} from "@typing/popup"; export default definePlugin(() => { let popup: Popup; @@ -29,14 +30,14 @@ export default definePlugin(() => { const plugins: Plugins = []; - let alias: PopupNameToManifest = {}; + let alias: PopupAliasMap = {}; if (await popup.empty()) { if (config.debug) { console.info("Popup entries not found"); } } else { - alias = await popup.manifestByAlias(); + alias = await popup.entriesByAlias(); // prettier-ignore const plugin = EntrypointPlugin.from(await popup.view().entries()) diff --git a/src/cli/plugins/popup/popup-module.ts b/src/cli/plugins/popup/popup-module.ts index dde1a99c..aeb4feaf 100644 --- a/src/cli/plugins/popup/popup-module.ts +++ b/src/cli/plugins/popup/popup-module.ts @@ -1,7 +1,7 @@ -import type {ManifestPopup} from "@typing/manifest"; +import type {PopupAliasMap} from "@typing/popup"; export const PopupModuleName = "#adnbn/popup"; -export const createPopupModule = (aliases: Record): string => +export const createPopupModule = (aliases: PopupAliasMap): string => // Preserve own __proto__ keys and allow unused data to be removed from optimized bundles. `export const aliases = /*#__PURE__*/ JSON.parse(${JSON.stringify(JSON.stringify(aliases))});\n`; diff --git a/src/entry/view/README.md b/src/entry/view/README.md index 22a16f63..2a54381c 100644 --- a/src/entry/view/README.md +++ b/src/entry/view/README.md @@ -18,10 +18,13 @@ import {definePopup} from "adnbn"; export default definePopup({ title: "Popup", + tooltip: "Open popup", render: ({title}) =>

{title}

, }); ``` +For Popup, `title` controls the document and render props; `tooltip` independently controls the toolbar button. + ```ts title="src/help.page.ts" export const title = "Help"; diff --git a/src/main/popup.test.ts b/src/main/popup.test.ts new file mode 100644 index 00000000..b8955aa0 --- /dev/null +++ b/src/main/popup.test.ts @@ -0,0 +1,129 @@ +jest.mock("#adnbn/popup", () => ({ + aliases: { + plain: {path: "plain.html", tooltip: "Open account", icon: "account"}, + localized: {path: "account.html", tooltip: "@popup.account"}, + empty: {path: "empty.html", tooltip: ""}, + emptyTranslation: {path: "empty-translation.html", tooltip: "@popup.empty"}, + missingTranslation: {path: "missing-translation.html", tooltip: "@popup.missing"}, + unchanged: {path: "unchanged.html", icon: "account"}, + invalid: {tooltip: "No path"}, + }, +})); + +jest.mock("#adnbn/icon", () => ({groups: {account: {16: "account-16.png"}}})); + +import {createTabFixture} from "@addon-core/browser/testing"; +import {getBrowserTest} from "@tests/browser-harness/session"; +import {changePopup, getPopups} from "./popup"; + +describe("changePopup", () => { + beforeEach(() => { + const {harness} = getBrowserTest(); + harness.runtime.setManifest({name: "Popup test", version: "1.0.0", manifest_version: 3}); + const action = harness.configurable.chrome.action; + action.setPopup.setResult(undefined); + action.setTitle.setResult(undefined); + action.setIcon.setResult(undefined); + harness.configurable.chrome.i18n.getMessage.setImplementation(key => (key === "popup_account" ? "Compte" : "")); + }); + + test.each([ + {target: "global", tab: undefined, tabId: undefined}, + {target: "tab ID", tab: 7, tabId: 7}, + ])("sets translated or literal tooltips for $target and preserves omitted ones", async ({tab, tabId}) => { + const {harness} = getBrowserTest(); + const action = harness.configurable.chrome.action; + const message = harness.configurable.chrome.i18n.getMessage; + + await changePopup("plain", tab); + + expect(action.setPopup.calls.at(-1)?.args[0]).toEqual({popup: "plain.html", tabId}); + expect(action.setTitle.calls.at(-1)?.args[0]).toEqual({title: "Open account", tabId}); + expect(action.setIcon.calls.at(-1)?.args[0]).toEqual({path: {16: "account-16.png"}, tabId}); + expect(action.setPopup.calls[0].sequence).toBeLessThan(action.setTitle.calls[0].sequence); + expect(action.setTitle.calls[0].sequence).toBeLessThan(action.setIcon.calls[0].sequence); + expect(message.calls).toHaveLength(0); + + await changePopup("localized", tab); + + expect(message.calls.at(-1)?.args).toEqual(["popup_account"]); + expect(action.setTitle.calls.at(-1)?.args[0]).toEqual({title: "Compte", tabId}); + + for (const alias of ["empty", "emptyTranslation", "missingTranslation"]) { + await changePopup(alias, tab); + + expect(action.setTitle.calls.at(-1)?.args[0]).toEqual({title: "", tabId}); + } + + const titleCalls = action.setTitle.calls.length; + const messageCalls = message.calls.length; + + await changePopup("unchanged", tab); + + expect(action.setPopup.calls.at(-1)?.args[0]).toEqual({popup: "unchanged.html", tabId}); + expect(action.setTitle.calls).toHaveLength(titleCalls); + expect(message.calls).toHaveLength(messageCalls); + expect(action.setIcon.calls).toHaveLength(2); + }); + + test.each([ + {target: "zero tab ID", tab: 0, tabId: 0}, + {target: "Tab object", tab: createTabFixture({id: 7}), tabId: 7}, + ])("normalizes $target for every action update", async ({tab, tabId}) => { + await changePopup("plain", tab); + + const action = getBrowserTest().harness.configurable.chrome.action; + + expect(action.setPopup.calls[0].args[0]).toEqual({popup: "plain.html", tabId}); + expect(action.setTitle.calls[0].args[0]).toEqual({title: "Open account", tabId}); + expect(action.setIcon.calls[0].args[0]).toEqual({path: {16: "account-16.png"}, tabId}); + }); + + test("uses browserAction for MV2", async () => { + const {harness} = getBrowserTest(); + harness.runtime.setManifest({name: "Popup test", version: "1.0.0", manifest_version: 2}); + const action = harness.configurable.chrome.browserAction; + action.setPopup.setResult(undefined); + action.setTitle.setResult(undefined); + action.setIcon.setResult(undefined); + + await changePopup("plain", 7); + + expect(action.setPopup.calls[0].args[0]).toEqual({popup: "plain.html", tabId: 7}); + expect(action.setTitle.calls[0].args[0]).toEqual({title: "Open account", tabId: 7}); + expect(action.setIcon.calls[0].args[0]).toEqual({path: {16: "account-16.png"}, tabId: 7}); + }); + + test.each([ + {method: "setPopup", titleCalls: 0, iconCalls: 0}, + {method: "setTitle", titleCalls: 1, iconCalls: 0}, + {method: "setIcon", titleCalls: 1, iconCalls: 1}, + ] as const)("propagates $method failures without continuing", async ({method, titleCalls, iconCalls}) => { + const action = getBrowserTest().harness.configurable.chrome.action; + action[method].failNext(new Error("Browser refused the update")); + + await expect(changePopup("plain", 7)).rejects.toThrow("Browser refused the update"); + + expect(action.setPopup.calls).toHaveLength(1); + expect(action.setTitle.calls).toHaveLength(titleCalls); + expect(action.setIcon.calls).toHaveLength(iconCalls); + }); + + test.each([ + {alias: "unknown", error: 'Not found popup: "unknown"'}, + {alias: "invalid", error: 'Not found popup path: "invalid"'}, + ])("rejects $alias before calling the browser", async ({alias, error}) => { + await expect(changePopup(alias)).rejects.toThrow(error); + + const action = getBrowserTest().harness.configurable.chrome.action; + + expect(action.setPopup.calls).toHaveLength(0); + expect(action.setTitle.calls).toHaveLength(0); + expect(action.setIcon.calls).toHaveLength(0); + }); +}); + +test("getPopups exposes unresolved tooltip metadata", () => { + expect(getPopups().get("localized")).toEqual({path: "account.html", tooltip: "@popup.account"}); + expect(getBrowserTest().harness.configurable.chrome.i18n.getMessage.calls).toHaveLength(0); +}); diff --git a/src/main/popup.ts b/src/main/popup.ts index 42df60ec..1fd2bedc 100644 --- a/src/main/popup.ts +++ b/src/main/popup.ts @@ -1,6 +1,8 @@ -import {setActionPopup, setActionTitle} from "@addon-core/browser"; +import {getI18nMessage, setActionPopup, setActionTitle} from "@addon-core/browser"; import {aliases} from "#adnbn/popup"; +import {convertLocaleKey, extractLocaleKey} from "@shared/locale/keys"; + import {changeActionIcon} from "./icon"; import type {PopupAlias, PopupDefinition, PopupMap} from "@typing/popup"; @@ -8,7 +10,7 @@ import type {IconName} from "@typing/icon"; type Tab = chrome.tabs.Tab; -export type {PopupAliasRegistry, PopupAlias, PopupMap} from "@typing/popup"; +export type {PopupAliasRegistry, PopupAlias, PopupMap, PopupMapEntry} from "@typing/popup"; export const definePopup = (options: PopupDefinition): PopupDefinition => { return options; @@ -28,6 +30,12 @@ export const getPopups = (): PopupMap => { return popups; }; +/** + * Selects a popup globally or for a tab, updating its configured tooltip and icon. + * An omitted tooltip preserves the current toolbar tooltip. Locale markers use native browser translations. + * Explicit empty strings, empty translations, and missing translations are passed to the browser as an empty title. + * The browser controls how an empty title is displayed; it does not guarantee a hidden tooltip. + */ export const changePopup = async (alias: PopupAlias, tab?: number | Tab): Promise => { const popup = getPopups().get(alias); @@ -39,7 +47,7 @@ export const changePopup = async (alias: PopupAlias, tab?: number | Tab): Promis tab = tab.id; } - const {path, title, icon} = popup; + const {path, tooltip, icon} = popup; if (!path) { throw new Error(`Not found popup path: "${alias}"`); @@ -47,7 +55,10 @@ export const changePopup = async (alias: PopupAlias, tab?: number | Tab): Promis await setActionPopup(path, tab); - if (title) { + if (tooltip !== undefined) { + const key = extractLocaleKey(tooltip); + const title = key ? (getI18nMessage(convertLocaleKey(key)) ?? "") : tooltip; + await setActionTitle(title, tab); } diff --git a/src/types/manifest.ts b/src/types/manifest.ts index d8196344..49614734 100644 --- a/src/types/manifest.ts +++ b/src/types/manifest.ts @@ -222,7 +222,7 @@ export interface ManifestPopup { */ icon?: string; /** - * Represents an optional title or name or locale key that can be assigned to an entity. + * Toolbar tooltip for default_title; may contain browser message references such as __MSG_popup_account__. */ title?: string; /** diff --git a/src/types/popup.ts b/src/types/popup.ts index 22d44fc3..9dd5e55c 100644 --- a/src/types/popup.ts +++ b/src/types/popup.ts @@ -1,7 +1,6 @@ import {ViewDefinition, ViewOptions} from "@typing/view"; import {CspOptions} from "@typing/csp"; import {PermissionsOptions} from "@typing/permissions"; -import type {ManifestPopup} from "@typing/manifest"; import type {IconName} from "@typing/icon"; /** @@ -12,11 +11,30 @@ export interface PopupAliasRegistry {} export type PopupAlias = keyof PopupAliasRegistry extends never ? string : Extract; -export type PopupMap = Map; +/** Runtime data for selecting a popup; document titles remain view options. */ +export interface PopupMapEntry { + path?: string; + icon?: string; + /** Toolbar tooltip text or an unresolved locale marker, such as `@popup.account`. */ + tooltip?: string; +} + +export type PopupMap = Map; + +/** Serialized popup entries supplied by the build-generated module. */ +export type PopupAliasMap = Record; export interface PopupConfig { icon?: IconName; apply?: boolean; + /** + * Toolbar tooltip text or a locale marker, such as `@popup.account`. Empty strings are allowed. + * When omitted, the initial popup uses `config.action.title` or the extension name; + * switching popups preserves the current tooltip. + * During switching, empty or missing translations are passed to the browser as an empty title. + * The browser controls its display, so an empty value does not guarantee a hidden tooltip. + */ + tooltip?: string; } export type PopupEntrypointOptions = PopupConfig & PermissionsOptions & CspOptions & ViewOptions; diff --git a/src/virtual/popup.ts b/src/virtual/popup.ts index 39731b69..4b934ae3 100644 --- a/src/virtual/popup.ts +++ b/src/virtual/popup.ts @@ -1,4 +1,4 @@ -import type {ManifestPopup} from "@typing/manifest"; +import type {PopupAliasMap} from "@typing/popup"; /** Package fallback; extension builds supply #adnbn/popup as a generated module. */ -export const aliases: Record = {}; +export const aliases: PopupAliasMap = {}; diff --git a/tests/integration/browser/popup/popup.firefox.integration.test.ts b/tests/integration/browser/popup/popup.firefox.integration.test.ts new file mode 100644 index 00000000..b95bfc64 --- /dev/null +++ b/tests/integration/browser/popup/popup.firefox.integration.test.ts @@ -0,0 +1,7 @@ +import {verifyPopup} from "./popup"; + +jest.setTimeout(90_000); + +test.each([2, 3] as const)("Firefox MV%i keeps document titles and global/tab tooltips independent", async version => { + await verifyPopup("firefox", version, "omitted"); +}); diff --git a/tests/integration/browser/popup/popup.integration.test.ts b/tests/integration/browser/popup/popup.integration.test.ts new file mode 100644 index 00000000..9ec2321e --- /dev/null +++ b/tests/integration/browser/popup/popup.integration.test.ts @@ -0,0 +1,7 @@ +import {verifyPopup} from "./popup"; + +jest.setTimeout(90_000); + +test("Chrome MV3 keeps document titles and global/tab tooltips independent", async () => { + await verifyPopup("chrome", 3, "literal"); +}); diff --git a/tests/integration/browser/popup/popup.ts b/tests/integration/browser/popup/popup.ts new file mode 100644 index 00000000..0401f60c --- /dev/null +++ b/tests/integration/browser/popup/popup.ts @@ -0,0 +1,70 @@ +import {copyFile} from "fs/promises"; +import path from "path"; +import {createIntegrationFixture} from "../../utils/fixture"; +import {waitFor} from "../utils/browser"; +import {startBrowserSession, type BrowserSession} from "../utils/session"; +import {startIntegrationSite, type IntegrationSite} from "../utils/site"; + +export const verifyPopup = async ( + browser: "chrome" | "firefox", + manifestVersion: 2 | 3, + tooltip: "literal" | "omitted" +): Promise => { + const fixture = await createIntegrationFixture( + ADNBN_TEST_ROOT, + path.join(ADNBN_TEST_ROOT, "tests/integration/build/popup/fixture") + ); + let session: BrowserSession | undefined; + let site: IntegrationSite | undefined; + + try { + if (tooltip === "omitted") { + await copyFile( + path.join(fixture.directory, "variants", "settings-without-tooltip.ts"), + path.join(fixture.directory, "src", "settings.popup.ts") + ); + } + + const directory = await fixture.build({browser, manifestVersion}); + site = await startIntegrationSite(path.join(fixture.directory, "site")); + session = await startBrowserSession(browser, ADNBN_TEST_ROOT, directory); + await session.navigate(site.origin); + + const text = await waitFor( + () => session!.evaluate('document.getElementById("popup-results")?.textContent || undefined'), + 15_000, + "popup switching and rendered document reports" + ); + const result = JSON.parse(text); + + expect(result.error).toBeUndefined(); + expect(["Open account", "Ouvrir le compte"]).toContain(result.translated); + expect(result.initial).toBe(result.translated); + expect(result.initialPopup).toMatch(/\/popup\.html$/); + expect(result.settings).toBe(tooltip === "literal" ? "Open settings" : result.translated); + expect(result.account).toBe(result.translated); + expect(result.tabSettings).toBe(tooltip === "literal" ? "Open settings" : "Keep this tab"); + expect(result.tabPath).toMatch(/\/settings\.popup\.html$/); + expect(result.globalAfterTab).toBe(result.translated); + expect(result.tabAccount).toBe(result.translated); + expect(result.entries).toEqual({ + popup: {path: "popup.html", tooltip: "@popup.account"}, + settings: { + path: "settings.popup.html", + ...(tooltip === "literal" ? {tooltip: "Open settings"} : {}), + }, + }); + expect(result.views).toEqual([ + {kind: "popup-view", alias: "popup", documentTitle: "Account document", propTitle: "Account document"}, + {kind: "popup-view", alias: "settings", documentTitle: "Settings document", propTitle: "Settings document"}, + ]); + expect(session.errors).toEqual([]); + } finally { + try { + await session?.close(); + } finally { + await site?.close(); + await fixture.dispose(); + } + } +}; diff --git a/tests/integration/build/action/action.integration.test.ts b/tests/integration/build/action/action.integration.test.ts index 134b107a..68dc79b3 100644 --- a/tests/integration/build/action/action.integration.test.ts +++ b/tests/integration/build/action/action.integration.test.ts @@ -46,7 +46,7 @@ describe.each([ popup: true, command: false, icon: "active", - title: "popup", + title: "action", }, { scenario: "unapplied popup", @@ -86,6 +86,12 @@ describe.each([ expect(messages.popup_title.message).toBe("Open the popup"); expect((await stat(path.join(extensionDir, "icons", `${icon}-16.png`))).isFile()).toBe(true); + if (popup) { + expect(await readFile(path.join(extensionDir, "popup.html"), "utf8")).toContain( + "Popup document" + ); + } + if (command) { const name = manifestVersion === 2 ? "_execute_browser_action" : "_execute_action"; diff --git a/tests/integration/build/action/fixture/variants/full.popup.ts b/tests/integration/build/action/fixture/variants/full.popup.ts index c00c4870..980266ff 100644 --- a/tests/integration/build/action/fixture/variants/full.popup.ts +++ b/tests/integration/build/action/fixture/variants/full.popup.ts @@ -2,6 +2,7 @@ import {definePopup} from "adnbn"; export default definePopup({ icon: "popup", - title: "@popup.title", + title: "Popup document", + tooltip: "@popup.title", render: "Popup", }); diff --git a/tests/integration/build/action/fixture/variants/title.popup.ts b/tests/integration/build/action/fixture/variants/title.popup.ts index 9cd28351..933def1b 100644 --- a/tests/integration/build/action/fixture/variants/title.popup.ts +++ b/tests/integration/build/action/fixture/variants/title.popup.ts @@ -1,6 +1,6 @@ import {definePopup} from "adnbn"; export default definePopup({ - title: "@popup.title", + title: "Popup document", render: "Popup", }); diff --git a/tests/integration/build/action/fixture/variants/unapplied.popup.ts b/tests/integration/build/action/fixture/variants/unapplied.popup.ts index f2148b90..44e420aa 100644 --- a/tests/integration/build/action/fixture/variants/unapplied.popup.ts +++ b/tests/integration/build/action/fixture/variants/unapplied.popup.ts @@ -3,6 +3,7 @@ import {definePopup} from "adnbn"; export default definePopup({ apply: false, icon: "popup", - title: "@popup.title", + title: "Popup document", + tooltip: "@popup.title", render: "Popup", }); diff --git a/tests/integration/build/popup/fixture/adnbn.config.ts b/tests/integration/build/popup/fixture/adnbn.config.ts new file mode 100644 index 00000000..10e54315 --- /dev/null +++ b/tests/integration/build/popup/fixture/adnbn.config.ts @@ -0,0 +1,9 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "Popup contract test", + version: "1.0.0", + multiplePopup: true, + action: {title: "Common tooltip"}, + specific: {gecko: {id: "popup-contract@adnbn.test"}}, +}); diff --git a/tests/integration/build/popup/fixture/package.json b/tests/integration/build/popup/fixture/package.json new file mode 100644 index 00000000..c2ea7dbd --- /dev/null +++ b/tests/integration/build/popup/fixture/package.json @@ -0,0 +1,10 @@ +{ + "name": "adnbn-test-popup", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../../..", + "@addon-core/browser": "*" + } +} diff --git a/tests/integration/build/popup/fixture/site/top.html b/tests/integration/build/popup/fixture/site/top.html new file mode 100644 index 00000000..8e5cc246 --- /dev/null +++ b/tests/integration/build/popup/fixture/site/top.html @@ -0,0 +1,9 @@ + + + + Popup test host + + + Popup contract test + + diff --git a/tests/integration/build/popup/fixture/src/background.ts b/tests/integration/build/popup/fixture/src/background.ts new file mode 100644 index 00000000..a64680fe --- /dev/null +++ b/tests/integration/build/popup/fixture/src/background.ts @@ -0,0 +1,95 @@ +import {changePopup, defineBackground, getPopups} from "adnbn"; +import { + createTab, + getActionPopup, + getActionTitle, + getI18nMessage, + getUrl, + removeTab, + setActionTitle, +} from "@addon-core/browser"; + +interface ViewReport { + kind: "popup-view"; + alias: string; + documentTitle: string; + propTitle: string; +} + +const views = new Map void>(); + +const inspectView = async (alias: "popup" | "settings") => { + const report = new Promise(resolve => views.set(alias, resolve)); + const tab = await createTab({url: getUrl(getPopups().get(alias)!.path!), active: false}); + + try { + return await report; + } finally { + views.delete(alias); + await removeTab(tab.id!); + } +}; + +const inspect = async (tabId: number) => { + const initial = await getActionTitle(); + const translated = getI18nMessage("popup_account"); + const initialPopup = await getActionPopup(); + + await changePopup("settings"); + + const settings = await getActionTitle(); + + await changePopup("popup"); + + const account = await getActionTitle(); + + await setActionTitle("Keep this tab", tabId); + await changePopup("settings", tabId); + + const tabSettings = await getActionTitle(tabId); + const tabPath = await getActionPopup(tabId); + const globalAfterTab = await getActionTitle(); + + await changePopup("popup", tabId); + + return { + initial, + translated, + initialPopup, + settings, + account, + tabSettings, + tabPath, + globalAfterTab, + tabAccount: await getActionTitle(tabId), + entries: Object.fromEntries(getPopups()), + views: [await inspectView("popup"), await inspectView("settings")], + }; +}; + +export default defineBackground({ + main() { + chrome.runtime.onMessage.addListener((message, sender, respond) => { + if (message.kind === "popup-view") { + views.get(message.alias)?.(message); + respond({ok: true}); + + return; + } + + if (message.kind === "inspect-popup") { + const run = async () => { + try { + respond(await inspect(sender.tab!.id!)); + } catch (error) { + respond({error: String(error)}); + } + }; + + void run(); + + return true; + } + }); + }, +}); diff --git a/tests/integration/build/popup/fixture/src/locales/en.json b/tests/integration/build/popup/fixture/src/locales/en.json new file mode 100644 index 00000000..c970a0fa --- /dev/null +++ b/tests/integration/build/popup/fixture/src/locales/en.json @@ -0,0 +1,5 @@ +{ + "popup": { + "account": "Open account" + } +} diff --git a/tests/integration/build/popup/fixture/src/locales/fr.json b/tests/integration/build/popup/fixture/src/locales/fr.json new file mode 100644 index 00000000..0129889c --- /dev/null +++ b/tests/integration/build/popup/fixture/src/locales/fr.json @@ -0,0 +1,5 @@ +{ + "popup": { + "account": "Ouvrir le compte" + } +} diff --git a/tests/integration/build/popup/fixture/src/popup.ts b/tests/integration/build/popup/fixture/src/popup.ts new file mode 100644 index 00000000..248d22c4 --- /dev/null +++ b/tests/integration/build/popup/fixture/src/popup.ts @@ -0,0 +1,8 @@ +import {definePopup} from "adnbn"; +import {reportView} from "./report-view"; + +export default definePopup({ + title: "Account document", + tooltip: "@popup.account", + render: props => reportView("popup", props.title), +}); diff --git a/tests/integration/build/popup/fixture/src/probe.content.ts b/tests/integration/build/popup/fixture/src/probe.content.ts new file mode 100644 index 00000000..5e58afc6 --- /dev/null +++ b/tests/integration/build/popup/fixture/src/probe.content.ts @@ -0,0 +1,17 @@ +import {defineContentScript} from "adnbn"; + +export default defineContentScript({ + matches: ["http://127.0.0.1/*"], + render: () => { + const element = document.createElement("pre"); + element.id = "popup-results"; + + chrome.runtime.sendMessage({kind: "inspect-popup"}, result => { + element.textContent = JSON.stringify( + chrome.runtime.lastError ? {error: chrome.runtime.lastError.message} : result + ); + }); + + return element; + }, +}); diff --git a/tests/integration/build/popup/fixture/src/report-view.ts b/tests/integration/build/popup/fixture/src/report-view.ts new file mode 100644 index 00000000..6710c7df --- /dev/null +++ b/tests/integration/build/popup/fixture/src/report-view.ts @@ -0,0 +1,7 @@ +export const reportView = (alias: string, title?: string): HTMLElement => { + chrome.runtime.sendMessage({kind: "popup-view", alias, documentTitle: document.title, propTitle: title}); + const element = document.createElement("h1"); + element.textContent = title ?? ""; + + return element; +}; diff --git a/tests/integration/build/popup/fixture/src/settings.popup.ts b/tests/integration/build/popup/fixture/src/settings.popup.ts new file mode 100644 index 00000000..5fefd2dd --- /dev/null +++ b/tests/integration/build/popup/fixture/src/settings.popup.ts @@ -0,0 +1,7 @@ +import {reportView} from "./report-view"; + +export const title = "Settings document"; +export const tooltip = "Open settings"; +export const apply = false; + +export default (props: {title?: string}) => reportView("settings", props.title); diff --git a/tests/integration/build/popup/fixture/tsconfig.json b/tests/integration/build/popup/fixture/tsconfig.json new file mode 100644 index 00000000..eadf1be8 --- /dev/null +++ b/tests/integration/build/popup/fixture/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.adnbn/tsconfig.json" +} diff --git a/tests/integration/build/popup/fixture/variants/settings-missing-tooltip.ts b/tests/integration/build/popup/fixture/variants/settings-missing-tooltip.ts new file mode 100644 index 00000000..15e31b64 --- /dev/null +++ b/tests/integration/build/popup/fixture/variants/settings-missing-tooltip.ts @@ -0,0 +1,3 @@ +import {definePopup} from "adnbn"; + +export default definePopup({title: "Settings document", tooltip: "@popup.missing", apply: false}); diff --git a/tests/integration/build/popup/fixture/variants/settings-without-tooltip.ts b/tests/integration/build/popup/fixture/variants/settings-without-tooltip.ts new file mode 100644 index 00000000..8fa20c5f --- /dev/null +++ b/tests/integration/build/popup/fixture/variants/settings-without-tooltip.ts @@ -0,0 +1,15 @@ +export const title = "Settings document"; +export const apply = false; + +export default (props: {title?: string}) => { + chrome.runtime.sendMessage({ + kind: "popup-view", + alias: "settings", + documentTitle: document.title, + propTitle: props.title, + }); + const element = document.createElement("h1"); + element.textContent = props.title ?? ""; + + return element; +}; diff --git a/tests/integration/build/popup/popup.integration.test.ts b/tests/integration/build/popup/popup.integration.test.ts new file mode 100644 index 00000000..ca7d7464 --- /dev/null +++ b/tests/integration/build/popup/popup.integration.test.ts @@ -0,0 +1,54 @@ +import {copyFile, readFile} from "fs/promises"; +import path from "path"; + +import {createIntegrationFixture} from "../../utils/fixture"; + +jest.setTimeout(90_000); + +test.each([2, 3] as const)("builds independent popup titles and tooltips in MV%i", async manifestVersion => { + const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, path.join(__dirname, "fixture")); + + try { + const directory = await fixture.build({manifestVersion}); + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + const field = manifestVersion === 2 ? "browser_action" : "action"; + + expect(manifest[field]).toMatchObject({default_popup: "popup.html", default_title: "__MSG_popup_account__"}); + + for (const [file, title] of [ + ["popup.html", "Account document"], + ["settings.popup.html", "Settings document"], + ]) { + expect(await readFile(path.join(directory, file), "utf8")).toContain(`${title}`); + } + + const messages = JSON.parse(await readFile(path.join(directory, "_locales", "en", "messages.json"), "utf8")); + + expect(messages.popup_account.message).toBe("Open account"); + } finally { + await fixture.dispose(); + } +}); + +test("builds an apply: false popup with a missing runtime translation without changing the initial tooltip", async () => { + const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, path.join(__dirname, "fixture")); + + try { + await copyFile( + path.join(fixture.directory, "variants", "settings-missing-tooltip.ts"), + path.join(fixture.directory, "src", "settings.popup.ts") + ); + + const directory = await fixture.build(); + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + const messages = JSON.parse( + await readFile(path.join(directory, "_locales", manifest.default_locale, "messages.json"), "utf8") + ); + + expect(manifest.action).toMatchObject({default_popup: "popup.html", default_title: "__MSG_popup_account__"}); + expect(messages.popup_account.message).toBe("Open account"); + expect(messages).not.toHaveProperty("popup_missing"); + } finally { + await fixture.dispose(); + } +}); diff --git a/tests/integration/build/virtual-modules/fixture/src/popup.ts b/tests/integration/build/virtual-modules/fixture/src/popup.ts index 30842fce..f61841b8 100644 --- a/tests/integration/build/virtual-modules/fixture/src/popup.ts +++ b/tests/integration/build/virtual-modules/fixture/src/popup.ts @@ -1,3 +1,3 @@ import {definePopup} from "adnbn"; -export default definePopup({title: "Popup data", icon: "dataicons"}); +export default definePopup({title: "Popup document", tooltip: "Popup data", icon: "dataicons"}); diff --git a/tests/integration/build/virtual-modules/virtual-modules.integration.test.ts b/tests/integration/build/virtual-modules/virtual-modules.integration.test.ts index 49238316..f87ff49b 100644 --- a/tests/integration/build/virtual-modules/virtual-modules.integration.test.ts +++ b/tests/integration/build/virtual-modules/virtual-modules.integration.test.ts @@ -17,7 +17,7 @@ test("generated entrypoint data reach public getters and disappear without consu const runtime = {readData: undefined as undefined | (() => unknown)}; vm.runInNewContext(source, runtime); expect(runtime.readData!()).toEqual({ - popup: {popup: {path: "popup.html", title: "Popup data", icon: "dataicons"}}, + popup: {popup: {path: "popup.html", tooltip: "Popup data", icon: "dataicons"}}, sidebar: {sidebar: {path: "sidebar.html", title: "Sidebar data"}}, offscreen: { dataOffscreen: {url: "offscreen.html", reasons: ["DOM_PARSER"], justification: "Offscreen data"}, diff --git a/tests/integration/types/fixtures/registries/generated.ts b/tests/integration/types/fixtures/registries/generated.ts index 47dbb2dd..8cedbb75 100644 --- a/tests/integration/types/fixtures/registries/generated.ts +++ b/tests/integration/types/fixtures/registries/generated.ts @@ -28,11 +28,22 @@ api.changeSidebarIcon("brand"); api.changeActionIcon(); api.changeSidebarIcon(); api.getPopups().get("popup"); +const popupEntry: api.PopupMapEntry | undefined = api.getPopups().get("popup"); +const tooltip: string | undefined = popupEntry?.tooltip; + +// @ts-expect-error: Runtime popup metadata no longer use the document title as a tooltip. +popupEntry?.title; + +const manifestPopup: api.ManifestPopup = {title: "__MSG_popup_title__", path: "popup.html"}; api.getSidebars().get("sidebar"); api.getIcons().get("brand"); api.defineConfig({action: {icon: "brand"}}); -api.definePopup({icon: "brand", render: "Popup"}); +api.definePopup({icon: "brand", title: "Popup document", tooltip: "@popup.title", render: "Popup"}); +api.definePopup({tooltip: ""}); + +// @ts-expect-error: Popup tooltips must be strings. +api.definePopup({tooltip: 42}); api.defineSidebar({icon: "brand", render: "Sidebar"}); // @ts-expect-error: Action defaults use the generated icon group names. diff --git a/tests/integration/types/fixtures/view/definition.tsx b/tests/integration/types/fixtures/view/definition.tsx index 3456712d..ac57bdca 100644 --- a/tests/integration/types/fixtures/view/definition.tsx +++ b/tests/integration/types/fixtures/view/definition.tsx @@ -48,6 +48,7 @@ definePopup({render: handler}); definePopup({ title: "Popup", + tooltip: "Open popup", container: async props => { const title: string | undefined = props.title; @@ -56,6 +57,8 @@ definePopup({ }, render: props => { + const tooltip: string | undefined = props.tooltip; + // @ts-expect-error: Render handlers receive the view options as props. props.missing; From 2b93d8bfeda3984b7fa1200dbc153d0c19a365ad Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:34:13 +0300 Subject: [PATCH 19/24] build(deps): align addon-core packages on browser 0.12 Update Browser to 0.12.0, Storage to 0.8.3 and Inject Script to 0.5.1 so they share one Browser dependency. Align Chrome types on 0.3.5. --- package-lock.json | 67 ++++++++++++++--------------------------------- package.json | 8 +++--- 2 files changed, 23 insertions(+), 52 deletions(-) diff --git a/package-lock.json b/package-lock.json index d3fde11a..333dfe92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,9 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@addon-core/browser": "^0.9.0", - "@addon-core/inject-script": "^0.5.0", - "@addon-core/storage": "^0.8.0", + "@addon-core/browser": "^0.12.0", + "@addon-core/inject-script": "^0.5.1", + "@addon-core/storage": "^0.8.3", "@rsdoctor/rspack-plugin": "^1.5.1", "@rspack/cli": "^1.7.5", "@rspack/core": "^1.7.5", @@ -57,7 +57,7 @@ "@swc/jest": "^0.2.36", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", - "@types/chrome": "^0.1.3", + "@types/chrome": "^0.3.5", "@types/jest": "^29.5.14", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.14", @@ -128,59 +128,30 @@ "license": "MIT" }, "node_modules/@addon-core/browser": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@addon-core/browser/-/browser-0.9.0.tgz", - "integrity": "sha512-EiZnYVOZoYi49+RqOQKBPYnYSNtUtjyVDn3rrKZD3/PF0hU2vGShioP7VbeqP1pL4eX1ZrpWkbBkaNHwwf9s+A==", + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@addon-core/browser/-/browser-0.12.0.tgz", + "integrity": "sha512-+mjbmhE87fX3dBa3lrlZ+UKfRoEyLqlrTEGPZ11U/bMSEqDjcDSYof5v95M+Bdl1ShHiRW3XiEDwCbnQS/GgWA==", "license": "MIT", "dependencies": { - "@types/chrome": "^0.3.0" - } - }, - "node_modules/@addon-core/browser/node_modules/@types/chrome": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.3.0.tgz", - "integrity": "sha512-HPAMKyJwUnNJdVsxgypwvIStlwRhhaLnxgEwKIB99oOcffJrC4UMeD5xseVoBwDinSkr690DCBaAtSYdK6M1qw==", - "license": "MIT", - "dependencies": { - "@types/filesystem": "*", - "@types/har-format": "*" + "@types/chrome": "^0.3.5" } }, "node_modules/@addon-core/inject-script": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@addon-core/inject-script/-/inject-script-0.5.0.tgz", - "integrity": "sha512-YoeMjLwZQ584jbv1rcPLgH0gchYbAXoodmSUZsX24E8MXvPNLUV4twhd6+R3WFf99wnuMafkigm0YaS35p6cNw==", - "license": "MIT", - "dependencies": { - "@addon-core/browser": "^0.7.2" - } - }, - "node_modules/@addon-core/inject-script/node_modules/@addon-core/browser": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@addon-core/browser/-/browser-0.7.2.tgz", - "integrity": "sha512-noDIPQktJOl7HVFUBZONL1iAVK4Spq1K+JgtN3OTsNPSvkgogrWUvpjq00p99R02y+YWORR70gu3tR+NDh09vw==", - "license": "MIT", - "dependencies": { - "@types/chrome": "^0.2.2" - } - }, - "node_modules/@addon-core/inject-script/node_modules/@types/chrome": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.2.9.tgz", - "integrity": "sha512-6rXrDPkkWv4D2Q23HqT+iED4voODU5CpOSc2VFgKyecSFItuEsIBvz9CY6jUQ3dd+Q7zo1bnCE9pKRMtkYut5g==", + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@addon-core/inject-script/-/inject-script-0.5.1.tgz", + "integrity": "sha512-zLBxQzAKfRhcnsuELHYT1e8cznntbRohpCvz3bwBCASyWOUMsO316/DsMAAZsA0ng2e9goJXa5Xhp7+nCgcERw==", "license": "MIT", "dependencies": { - "@types/filesystem": "*", - "@types/har-format": "*" + "@addon-core/browser": "^0.12.0" } }, "node_modules/@addon-core/storage": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@addon-core/storage/-/storage-0.8.0.tgz", - "integrity": "sha512-2luHeyty/r64XJh7Jh81kEs3wKUq3VdRGK8Aw2oO4OpBd2YI3AZSZIdrG1+6fEyaYaQTsdaGr0D+Cve8FiT/yQ==", + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@addon-core/storage/-/storage-0.8.3.tgz", + "integrity": "sha512-gXi87mU7pNwug9nIn1fMeUQNMbCdBXlYKZo9BbacZxt9kiEweHnOPrSC/9g6jiA0Rq2FrUynvc7jney59FY+2A==", "license": "MIT", "dependencies": { - "@addon-core/browser": "^0.9.0", + "@addon-core/browser": "^0.12.0", "dequal": "^2.0.3" }, "peerDependencies": { @@ -7764,9 +7735,9 @@ } }, "node_modules/@types/chrome": { - "version": "0.1.40", - "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.1.40.tgz", - "integrity": "sha512-UnfyRAe8ORu9HSuTH0EqyOEUin3JrWW9Nl/gDXezNfTUrfIoxw+WRZgKOxGz0t5BnjbfXBnS2eCYfW2PxH1wcA==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.3.5.tgz", + "integrity": "sha512-7HYsK7xalNxzLwGrP9lkCQPAA6V56VtPpOmv2CQnBnMzX5/Hpr+Iv5fqaIOIY/H/Q6ZBksLmurWh7zXPcZ4B7Q==", "license": "MIT", "dependencies": { "@types/filesystem": "*", diff --git a/package.json b/package.json index 6cfbca49..2a446d4f 100644 --- a/package.json +++ b/package.json @@ -133,9 +133,9 @@ "release:preview": "release-it --release-version --dry-run --no-git.requireBranch --no-git.requireCleanWorkingDir --no-git.push --no-github.release --no-npm.publish --ci && release-it --changelog --dry-run --no-git.requireBranch --no-git.requireCleanWorkingDir --no-git.push --no-github.release --no-npm.publish --ci" }, "dependencies": { - "@addon-core/browser": "^0.9.0", - "@addon-core/inject-script": "^0.5.0", - "@addon-core/storage": "^0.8.0", + "@addon-core/browser": "^0.12.0", + "@addon-core/inject-script": "^0.5.1", + "@addon-core/storage": "^0.8.3", "@rsdoctor/rspack-plugin": "^1.5.1", "@rspack/cli": "^1.7.5", "@rspack/core": "^1.7.5", @@ -177,7 +177,7 @@ "@swc/jest": "^0.2.36", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", - "@types/chrome": "^0.1.3", + "@types/chrome": "^0.3.5", "@types/jest": "^29.5.14", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.17.14", From 87afddfbfed8b2a99731c47ee08809d2d1d78104 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:34:31 +0300 Subject: [PATCH 20/24] feat(sidebar)!: separate UI labels from document titles Use label for the native sidebar UI and resolve locale keys through browser i18n when switching. Apply configured icons where supported, preserve omitted values and propagate native errors. Keep ManifestSidebar.title as manifest data and emit only default_path for Chrome side_panel. Update types, documentation and parser, runtime, build and browser checks. BREAKING CHANGE: Sidebar title now controls only the HTML document and render props. Use label for the browser sidebar title and tooltip. getSidebars() entries expose label instead of title. changeSidebar() also applies the selected sidebar icon when the browser supports it. --- src/cli/builders/manifest/ManifestBase.ts | 27 ++-- src/cli/builders/manifest/ManifestV3.test.ts | 4 +- .../entrypoint/parser/SidebarParser.test.ts | 7 +- src/cli/entrypoint/parser/SidebarParser.ts | 3 +- .../tests/fixtures/sidebar/invalid/label.ts | 3 + .../sidebar/options/empty-label/sidebar.ts | 3 + .../fixtures/sidebar/options/full/sidebar.ts | 1 + src/cli/plugins/sidebar/Sidebar.ts | 18 +-- src/cli/plugins/sidebar/index.ts | 8 +- src/cli/plugins/sidebar/sidebar-module.ts | 4 +- src/entry/view/README.md | 3 + src/main/sidebar.test.ts | 150 ++++++++++++++++++ src/main/sidebar.ts | 33 +++- src/types/manifest.ts | 2 +- src/types/sidebar.ts | 21 ++- src/virtual/sidebar.ts | 4 +- .../sidebar.firefox.integration.test.ts | 7 + .../sidebar/sidebar.integration.test.ts | 7 + tests/integration/browser/sidebar/sidebar.ts | 87 ++++++++++ .../build/sidebar/fixture/adnbn.config.ts | 9 ++ .../build/sidebar/fixture/package.json | 10 ++ .../build/sidebar/fixture/site/top.html | 9 ++ .../build/sidebar/fixture/src/background.ts | 107 +++++++++++++ .../sidebar/fixture/src/empty.sidebar.ts | 8 + .../sidebar/fixture/src/icons/account16.png | Bin 0 -> 82 bytes .../sidebar/fixture/src/icons/settings16.png | Bin 0 -> 82 bytes .../build/sidebar/fixture/src/locales/en.json | 5 + .../build/sidebar/fixture/src/locales/fr.json | 5 + .../sidebar/fixture/src/missing.sidebar.ts | 3 + .../sidebar/fixture/src/preserve.sidebar.ts | 8 + .../sidebar/fixture/src/probe.content.ts | 17 ++ .../build/sidebar/fixture/src/report-view.ts | 7 + .../sidebar/fixture/src/settings.sidebar.ts | 8 + .../build/sidebar/fixture/src/sidebar.ts | 9 ++ .../build/sidebar/fixture/tsconfig.json | 3 + .../fixture/variants/sidebar-empty-label.ts | 3 + .../fixture/variants/sidebar-without-label.ts | 3 + .../build/sidebar/sidebar.integration.test.ts | 80 ++++++++++ .../virtual-modules/fixture/src/sidebar.ts | 2 +- .../virtual-modules.integration.test.ts | 2 +- .../types/fixtures/registries/generated.ts | 11 +- .../types/fixtures/registries/internal.ts | 3 +- 42 files changed, 660 insertions(+), 44 deletions(-) create mode 100644 src/cli/entrypoint/parser/tests/fixtures/sidebar/invalid/label.ts create mode 100644 src/cli/entrypoint/parser/tests/fixtures/sidebar/options/empty-label/sidebar.ts create mode 100644 src/main/sidebar.test.ts create mode 100644 tests/integration/browser/sidebar/sidebar.firefox.integration.test.ts create mode 100644 tests/integration/browser/sidebar/sidebar.integration.test.ts create mode 100644 tests/integration/browser/sidebar/sidebar.ts create mode 100644 tests/integration/build/sidebar/fixture/adnbn.config.ts create mode 100644 tests/integration/build/sidebar/fixture/package.json create mode 100644 tests/integration/build/sidebar/fixture/site/top.html create mode 100644 tests/integration/build/sidebar/fixture/src/background.ts create mode 100644 tests/integration/build/sidebar/fixture/src/empty.sidebar.ts create mode 100644 tests/integration/build/sidebar/fixture/src/icons/account16.png create mode 100644 tests/integration/build/sidebar/fixture/src/icons/settings16.png create mode 100644 tests/integration/build/sidebar/fixture/src/locales/en.json create mode 100644 tests/integration/build/sidebar/fixture/src/locales/fr.json create mode 100644 tests/integration/build/sidebar/fixture/src/missing.sidebar.ts create mode 100644 tests/integration/build/sidebar/fixture/src/preserve.sidebar.ts create mode 100644 tests/integration/build/sidebar/fixture/src/probe.content.ts create mode 100644 tests/integration/build/sidebar/fixture/src/report-view.ts create mode 100644 tests/integration/build/sidebar/fixture/src/settings.sidebar.ts create mode 100644 tests/integration/build/sidebar/fixture/src/sidebar.ts create mode 100644 tests/integration/build/sidebar/fixture/tsconfig.json create mode 100644 tests/integration/build/sidebar/fixture/variants/sidebar-empty-label.ts create mode 100644 tests/integration/build/sidebar/fixture/variants/sidebar-without-label.ts create mode 100644 tests/integration/build/sidebar/sidebar.integration.test.ts diff --git a/src/cli/builders/manifest/ManifestBase.ts b/src/cli/builders/manifest/ManifestBase.ts index c0d386ec..0d8ff326 100644 --- a/src/cli/builders/manifest/ManifestBase.ts +++ b/src/cli/builders/manifest/ManifestBase.ts @@ -665,9 +665,13 @@ export default abstract class ManifestBase implements Ma const sidePanel = this.combinedRaws.side_panel; if (SidebarAlternativeBrowsers.has(this.browser)) { - if (sidebarAction) return {sidebar_action: sidebarAction}; + if (sidebarAction) { + return {sidebar_action: sidebarAction}; + } } else { - if (sidePanel) return {side_panel: sidePanel}; + if (sidePanel) { + return {side_panel: sidePanel}; + } } return; @@ -675,15 +679,18 @@ export default abstract class ManifestBase implements Ma const {path, icon, title} = this.sidebar; - const commonProps = { - open_at_install: this.browser === Browser.Firefox ? false : undefined, - default_title: title || this.name, - default_icon: this.getIconsByName(icon), - }; + if (SidebarAlternativeBrowsers.has(this.browser)) { + return { + sidebar_action: { + open_at_install: this.browser === Browser.Firefox ? false : undefined, + default_title: title ?? this.name, + default_icon: this.getIconsByName(icon), + default_panel: path, + }, + }; + } - return SidebarAlternativeBrowsers.has(this.browser) - ? {sidebar_action: {...commonProps, default_panel: path}} - : {side_panel: {...commonProps, default_path: path}}; + return {side_panel: {default_path: path}}; } protected buildOptions(): Partial { diff --git a/src/cli/builders/manifest/ManifestV3.test.ts b/src/cli/builders/manifest/ManifestV3.test.ts index acff05a6..be318ca6 100644 --- a/src/cli/builders/manifest/ManifestV3.test.ts +++ b/src/cli/builders/manifest/ManifestV3.test.ts @@ -83,9 +83,9 @@ describe("ManifestV3", () => { .setSidebar({path: "sidebar.html", title: "Sidebar"}) .build(); - expect(chrome.side_panel.default_path).toBe("sidebar.html"); - expect(chrome.side_panel.default_title).toBe("Sidebar"); + expect(chrome.side_panel).toEqual({default_path: "sidebar.html"}); expect(firefox.sidebar_action.default_panel).toBe("sidebar.html"); + expect(firefox.sidebar_action.default_title).toBe("Sidebar"); expect(firefox.sidebar_action.open_at_install).toBe(false); }); diff --git a/src/cli/entrypoint/parser/SidebarParser.test.ts b/src/cli/entrypoint/parser/SidebarParser.test.ts index ca793acb..05888c2a 100644 --- a/src/cli/entrypoint/parser/SidebarParser.test.ts +++ b/src/cli/entrypoint/parser/SidebarParser.test.ts @@ -28,6 +28,7 @@ describe("SidebarParser", () => { expect(parseOptions("options", "full", "sidebar.ts")).toEqual({ as: "panel", title: "Extension panel", + label: "@sidebar.panel", template: "./template.html", icon: "toolbar", apply: false, @@ -50,7 +51,11 @@ describe("SidebarParser", () => { }); }); - test.each(["icon", "apply"])("rejects an invalid %s value", field => { + test("preserves an explicitly empty label", () => { + expect(parseOptions("options", "empty-label", "sidebar.ts")).toEqual({label: ""}); + }); + + test.each(["icon", "apply", "label"])("rejects an invalid %s value", field => { expect(() => parseOptions("invalid", `${field}.ts`)).toThrow(`Invalid options ${field}`); }); }); diff --git a/src/cli/entrypoint/parser/SidebarParser.ts b/src/cli/entrypoint/parser/SidebarParser.ts index 72a36eda..80c52260 100644 --- a/src/cli/entrypoint/parser/SidebarParser.ts +++ b/src/cli/entrypoint/parser/SidebarParser.ts @@ -5,7 +5,7 @@ import {PermissionsSchema} from "./schemas/permissions"; import {SidebarEntrypointOptions} from "@typing/sidebar"; -export default class extends ViewCspParser { +export default class SidebarParser extends ViewCspParser { protected definition(): string { return "defineSidebar"; } @@ -14,6 +14,7 @@ export default class extends ViewCspParser { return super.schema().merge(PermissionsSchema).extend({ icon: z.string().nonempty().optional(), apply: z.boolean().optional(), + label: z.string().optional(), }); } } diff --git a/src/cli/entrypoint/parser/tests/fixtures/sidebar/invalid/label.ts b/src/cli/entrypoint/parser/tests/fixtures/sidebar/invalid/label.ts new file mode 100644 index 00000000..35b278be --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/sidebar/invalid/label.ts @@ -0,0 +1,3 @@ +import {defineSidebar} from "adnbn"; + +export default defineSidebar({label: 42}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/empty-label/sidebar.ts b/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/empty-label/sidebar.ts new file mode 100644 index 00000000..6160b6c3 --- /dev/null +++ b/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/empty-label/sidebar.ts @@ -0,0 +1,3 @@ +import {defineSidebar} from "adnbn"; + +export default defineSidebar({label: ""}); diff --git a/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/full/sidebar.ts b/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/full/sidebar.ts index 0843ee54..663a9677 100644 --- a/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/full/sidebar.ts +++ b/src/cli/entrypoint/parser/tests/fixtures/sidebar/options/full/sidebar.ts @@ -3,6 +3,7 @@ import {Browser, CspSource, defineSidebar} from "adnbn"; export default defineSidebar({ as: "panel", title: "Extension panel", + label: "@sidebar.panel", template: "./template.html", icon: "toolbar", apply: false, diff --git a/src/cli/plugins/sidebar/Sidebar.ts b/src/cli/plugins/sidebar/Sidebar.ts index cb73be00..3232768f 100644 --- a/src/cli/plugins/sidebar/Sidebar.ts +++ b/src/cli/plugins/sidebar/Sidebar.ts @@ -5,12 +5,10 @@ import {modifyLocaleMessageKey} from "@shared/locale"; import {SidebarFinder} from "@cli/entrypoint"; import {ReadonlyConfig} from "@typing/config"; -import {SidebarEntrypointOptions} from "@typing/sidebar"; +import {SidebarAliasMap, SidebarEntrypointOptions} from "@typing/sidebar"; import {ManifestSidebar} from "@typing/manifest"; -export type SidebarNameToManifest = Record; - -export default class extends SidebarFinder { +export default class Sidebar extends SidebarFinder { protected _view?: View; public constructor(config: ReadonlyConfig) { @@ -25,32 +23,32 @@ export default class extends SidebarFinder { const views = await this.views(); for (const {filename, options} of views.values()) { - const {apply = true, title, icon} = options; + const {apply = true, label, icon} = options; if (apply) { return { path: filename, - title: modifyLocaleMessageKey(title), + title: modifyLocaleMessageKey(label), icon, }; } } } - public async manifestByAlias(): Promise { + public async entriesByAlias(): Promise { return Array.from(await this.views()).reduce((aliases, [_, item]) => { const {options, filename} = item; - const {title, icon} = options; + const {label, icon} = options; return { ...aliases, [item.alias]: { path: filename, - title, + label, icon, }, }; - }, {} as SidebarNameToManifest); + }, {} as SidebarAliasMap); } public clear(): this { diff --git a/src/cli/plugins/sidebar/index.ts b/src/cli/plugins/sidebar/index.ts index c5fbeda6..0e37a83e 100644 --- a/src/cli/plugins/sidebar/index.ts +++ b/src/cli/plugins/sidebar/index.ts @@ -6,13 +6,13 @@ import {definePlugin} from "@main/plugin"; import {EntrypointPlugin, GenerateModulePlugin} from "@cli/bundler"; import {virtualViewModule} from "@cli/virtual"; -import Sidebar, {SidebarNameToManifest} from "./Sidebar"; +import Sidebar from "./Sidebar"; import {createSidebarModule, SidebarModuleName} from "./sidebar-module"; import {SidebarDeclaration} from "./declaration"; import {Command} from "@typing/app"; -import {SidebarAlternativeBrowsers} from "@typing/sidebar"; +import {SidebarAlternativeBrowsers, type SidebarAliasMap} from "@typing/sidebar"; export default definePlugin(() => { let sidebar: Sidebar; @@ -50,10 +50,10 @@ export default definePlugin(() => { const plugins: Plugins = []; - let alias: SidebarNameToManifest = {}; + let alias: SidebarAliasMap = {}; if (build) { - alias = await sidebar.manifestByAlias(); + alias = await sidebar.entriesByAlias(); // prettier-ignore const plugin = EntrypointPlugin.from(await sidebar.view().entries()) diff --git a/src/cli/plugins/sidebar/sidebar-module.ts b/src/cli/plugins/sidebar/sidebar-module.ts index e8e35deb..96a3734e 100644 --- a/src/cli/plugins/sidebar/sidebar-module.ts +++ b/src/cli/plugins/sidebar/sidebar-module.ts @@ -1,7 +1,7 @@ -import type {ManifestSidebar} from "@typing/manifest"; +import type {SidebarAliasMap} from "@typing/sidebar"; export const SidebarModuleName = "#adnbn/sidebar"; -export const createSidebarModule = (aliases: Record): string => +export const createSidebarModule = (aliases: SidebarAliasMap): string => // Preserve own __proto__ keys and allow unused data to be removed from optimized bundles. `export const aliases = /*#__PURE__*/ JSON.parse(${JSON.stringify(JSON.stringify(aliases))});\n`; diff --git a/src/entry/view/README.md b/src/entry/view/README.md index 2a54381c..aa8a35f3 100644 --- a/src/entry/view/README.md +++ b/src/entry/view/README.md @@ -25,6 +25,9 @@ export default definePopup({ For Popup, `title` controls the document and render props; `tooltip` independently controls the toolbar button. +For Sidebar, `title` controls the document and render props; `label` controls the browser UI where supported. +See `SidebarConfig.label` and `changeSidebar()` for localization, defaults and browser behavior. + ```ts title="src/help.page.ts" export const title = "Help"; diff --git a/src/main/sidebar.test.ts b/src/main/sidebar.test.ts new file mode 100644 index 00000000..35d8f250 --- /dev/null +++ b/src/main/sidebar.test.ts @@ -0,0 +1,150 @@ +jest.mock("#adnbn/sidebar", () => ({ + aliases: { + plain: {path: "plain.html", label: "Open account", icon: "account"}, + localized: {path: "account.html", label: "@sidebar.account", icon: "account"}, + empty: {path: "empty.html", label: ""}, + emptyTranslation: {path: "empty-translation.html", label: "@sidebar.empty"}, + missingTranslation: {path: "missing-translation.html", label: "@sidebar.missing"}, + unchanged: {path: "unchanged.html"}, + invalid: {label: "No path"}, + }, +})); + +jest.mock("#adnbn/icon", () => ({groups: {account: {16: "account-16.png"}}})); + +import {createTabFixture} from "@addon-core/browser/testing"; +import {getBrowserTest} from "@tests/browser-harness/session"; +import {changeSidebar, getSidebars} from "./sidebar"; + +describe("changeSidebar in Firefox", () => { + beforeEach(() => { + const session = getBrowserTest(); + session.useContext(session.context, "firefox"); + session.harness.sidebar.firefox.setPanel.setResult(undefined); + session.harness.sidebar.firefox.setTitle.setResult(undefined); + session.harness.sidebar.firefox.setIcon.setResult(undefined); + session.harness.configurable.active.i18n.getMessage.setImplementation(key => + key === "sidebar_account" ? "Compte" : "" + ); + }); + + test("updates global paths, labels and icons, resolving translations and preserving omitted values", async () => { + const api = getBrowserTest().harness.sidebar.firefox; + const message = getBrowserTest().harness.configurable.active.i18n.getMessage; + + expect(getSidebars().get("localized")).toEqual({ + path: "account.html", + label: "@sidebar.account", + icon: "account", + }); + expect(message.calls).toHaveLength(0); + + await changeSidebar("plain"); + + expect(api.setPanel.calls[0].args[0]).toEqual({panel: "plain.html", tabId: undefined}); + expect(api.setTitle.calls[0].args[0]).toEqual({title: "Open account", tabId: undefined}); + expect(api.setIcon.calls[0].args[0]).toEqual({path: {16: "account-16.png"}, tabId: undefined}); + expect(api.setPanel.calls[0].sequence).toBeLessThan(api.setTitle.calls[0].sequence); + expect(api.setTitle.calls[0].sequence).toBeLessThan(api.setIcon.calls[0].sequence); + expect(message.calls).toHaveLength(0); + + await changeSidebar("localized"); + + expect(message.calls.at(-1)?.args).toEqual(["sidebar_account"]); + expect(api.setTitle.calls.at(-1)?.args[0]).toEqual({title: "Compte", tabId: undefined}); + + for (const alias of ["empty", "emptyTranslation", "missingTranslation"]) { + await changeSidebar(alias); + + expect(api.setTitle.calls.at(-1)?.args[0]).toEqual({title: "", tabId: undefined}); + } + + const titleCalls = api.setTitle.calls.length; + const messageCalls = message.calls.length; + + await changeSidebar("unchanged"); + + expect(api.setPanel.calls.at(-1)?.args[0]).toEqual({panel: "unchanged.html", tabId: undefined}); + expect(api.setTitle.calls).toHaveLength(titleCalls); + expect(message.calls).toHaveLength(messageCalls); + expect(api.setIcon.calls).toHaveLength(2); + }); + + test.each([0, createTabFixture({id: 7})])("passes target %j to every sidebar update", async tab => { + const tabId = typeof tab === "object" ? tab.id : tab; + const api = getBrowserTest().harness.sidebar.firefox; + + await changeSidebar("plain", tab); + + expect(api.setPanel.calls[0].args[0]).toEqual({panel: "plain.html", tabId}); + expect(api.setTitle.calls[0].args[0]).toEqual({title: "Open account", tabId}); + expect(api.setIcon.calls[0].args[0]).toEqual({path: {16: "account-16.png"}, tabId}); + }); + + test("checks the title capability even in a browser with sidebarAction", async () => { + const {harness} = getBrowserTest(); + harness.capabilities.set("browser.sidebarAction.setTitle", false); + + await expect(changeSidebar("localized")).resolves.toBeUndefined(); + + expect(harness.sidebar.firefox.setPanel.calls).toHaveLength(1); + expect(harness.sidebar.firefox.setTitle.calls).toHaveLength(0); + expect(harness.sidebar.firefox.setIcon.calls).toHaveLength(1); + expect(harness.configurable.active.i18n.getMessage.calls).toHaveLength(0); + }); + + test("preserves path and label updates when the icon capability is unavailable", async () => { + const {harness} = getBrowserTest(); + harness.capabilities.set("browser.sidebarAction.setIcon", false); + + await expect(changeSidebar("plain")).resolves.toBeUndefined(); + + expect(harness.sidebar.firefox.setPanel.calls).toHaveLength(1); + expect(harness.sidebar.firefox.setTitle.calls).toHaveLength(1); + expect(harness.sidebar.firefox.setIcon.calls).toHaveLength(0); + }); + + test.each(["setPanel", "setTitle", "setIcon"] as const)("propagates %s errors", async method => { + const api = getBrowserTest().harness.sidebar.firefox; + api[method].failNext(new Error("Browser refused the update")); + + await expect(changeSidebar("plain")).rejects.toThrow("Browser refused the update"); + + expect(api.setPanel.calls).toHaveLength(1); + expect(api.setTitle.calls).toHaveLength(method === "setPanel" ? 0 : 1); + expect(api.setIcon.calls).toHaveLength(method === "setIcon" ? 1 : 0); + }); + + test.each([ + {alias: "unknown", error: 'Not found sidebar: "unknown"'}, + {alias: "invalid", error: 'Not found sidebar path: "invalid"'}, + ])("rejects $alias before calling the browser", async ({alias, error}) => { + await expect(changeSidebar(alias)).rejects.toThrow(error); + + expect(getBrowserTest().harness.sidebar.firefox.setPanel.calls).toHaveLength(0); + }); +}); + +test("updates Opera paths and labels while skipping unsupported icons", async () => { + const session = getBrowserTest(); + session.useContext(session.context, "opera"); + const api = session.harness.sidebar.opera; + api.setPanel.setResult(undefined); + api.setTitle.setResult(undefined); + + await changeSidebar("plain", 7); + + expect(api.setPanel.calls[0].args[0]).toEqual({panel: "plain.html", tabId: 7}); + expect(api.setTitle.calls[0].args[0]).toEqual({title: "Open account", tabId: 7}); + expect(api.setIcon.calls).toHaveLength(0); +}); + +test.each([undefined, 7])("switches Chrome paths without unsupported label or icon updates for tab %s", async tabId => { + const {harness} = getBrowserTest(); + harness.sidebar.sidePanel.setOptions.setResult(undefined); + + await expect(changeSidebar("localized", tabId)).resolves.toBeUndefined(); + + expect(harness.sidebar.sidePanel.setOptions.calls[0].args[0]).toEqual({path: "account.html", tabId}); + expect(harness.configurable.active.i18n.getMessage.calls).toHaveLength(0); +}); diff --git a/src/main/sidebar.ts b/src/main/sidebar.ts index b1e4acc3..61954c96 100644 --- a/src/main/sidebar.ts +++ b/src/main/sidebar.ts @@ -1,11 +1,22 @@ -import {setSidebarPath, setSidebarTitle} from "@addon-core/browser"; +import { + canSetSidebarIcon, + canSetSidebarTitle, + getI18nMessage, + setSidebarPath, + setSidebarTitle, +} from "@addon-core/browser"; import {aliases} from "#adnbn/sidebar"; +import {convertLocaleKey, extractLocaleKey} from "@shared/locale/keys"; + +import {changeSidebarIcon} from "./icon"; + +import type {IconName} from "@typing/icon"; import type {SidebarAlias, SidebarDefinition, SidebarMap} from "@typing/sidebar"; type Tab = chrome.tabs.Tab; -export type {SidebarAliasRegistry, SidebarAlias, SidebarMap} from "@typing/sidebar"; +export type {SidebarAliasRegistry, SidebarAlias, SidebarMap, SidebarMapEntry} from "@typing/sidebar"; export const defineSidebar = (options: SidebarDefinition): SidebarDefinition => { return options; @@ -25,6 +36,13 @@ export const getSidebars = (): SidebarMap => { return sidebars; }; +/** + * Selects a sidebar globally or for a tab, updating its configured label and icon where supported. + * An omitted icon preserves the current icon. Unsupported label and icon updates are skipped. + * An omitted label preserves the current title. Locale markers use native browser translations. + * Explicit empty strings, empty translations, and missing translations are passed as an empty title. + * The browser controls how an empty title is displayed. Labels do not set document titles. + */ export const changeSidebar = async (alias: SidebarAlias, tab?: number | Tab): Promise => { const sidebar = getSidebars().get(alias); @@ -36,7 +54,7 @@ export const changeSidebar = async (alias: SidebarAlias, tab?: number | Tab): Pr tab = tab.id; } - const {path, title} = sidebar; + const {path, label, icon} = sidebar; if (!path) { throw new Error(`Not found sidebar path: "${alias}"`); @@ -44,7 +62,14 @@ export const changeSidebar = async (alias: SidebarAlias, tab?: number | Tab): Pr await setSidebarPath(path, tab); - if (title) { + if (label !== undefined && canSetSidebarTitle()) { + const key = extractLocaleKey(label); + const title = key ? (getI18nMessage(convertLocaleKey(key)) ?? "") : label; + await setSidebarTitle(title, tab); } + + if (icon && canSetSidebarIcon()) { + await changeSidebarIcon(icon as IconName, tab); + } }; diff --git a/src/types/manifest.ts b/src/types/manifest.ts index 49614734..d9b07385 100644 --- a/src/types/manifest.ts +++ b/src/types/manifest.ts @@ -239,7 +239,7 @@ export interface ManifestSidebar { */ icon?: string; /** - * Represents an optional title or name or locale key that can be assigned to an entity. + * Sidebar UI title for default_title; may contain browser message references such as __MSG_sidebar_account__. */ title?: string; /** diff --git a/src/types/sidebar.ts b/src/types/sidebar.ts index 73b76995..e9b71499 100644 --- a/src/types/sidebar.ts +++ b/src/types/sidebar.ts @@ -2,7 +2,6 @@ import {ViewDefinition, ViewOptions} from "@typing/view"; import {Browser} from "@typing/browser"; import {CspOptions} from "@typing/csp"; import {PermissionsOptions} from "@typing/permissions"; -import type {ManifestSidebar} from "@typing/manifest"; import type {IconName} from "@typing/icon"; export const SidebarAlternativeBrowsers: ReadonlySet = new Set([Browser.Opera, Browser.Firefox]); @@ -17,11 +16,29 @@ export type SidebarAlias = keyof SidebarAliasRegistry extends never ? string : Extract; -export type SidebarMap = Map; +/** Runtime data for selecting a sidebar; document titles remain view options. */ +export interface SidebarMapEntry { + path?: string; + icon?: string; + /** Browser UI label or an unresolved locale marker, such as `@sidebar.account`. */ + label?: string; +} + +export type SidebarMap = Map; + +/** Serialized sidebar entries supplied by the build-generated module. */ +export type SidebarAliasMap = Record; export interface SidebarConfig { icon?: IconName; apply?: boolean; + /** + * Browser UI label or a locale marker, such as `@sidebar.account`; empty strings are allowed. + * Firefox uses it for the panel heading, menu label and sidebar icon tooltip; Opera uses it for its sidebar tooltip. + * Defaults to the extension name in the initial manifest. See changeSidebar() for switching behavior. + * Unsupported browsers ignore it. The ordinary toolbar button is configured separately. + */ + label?: string; } export type SidebarEntrypointOptions = SidebarConfig & PermissionsOptions & CspOptions & ViewOptions; diff --git a/src/virtual/sidebar.ts b/src/virtual/sidebar.ts index b037342f..32b99756 100644 --- a/src/virtual/sidebar.ts +++ b/src/virtual/sidebar.ts @@ -1,4 +1,4 @@ -import type {ManifestSidebar} from "@typing/manifest"; +import type {SidebarAliasMap} from "@typing/sidebar"; /** Package fallback; extension builds supply #adnbn/sidebar as a generated module. */ -export const aliases: Record = {}; +export const aliases: SidebarAliasMap = {}; diff --git a/tests/integration/browser/sidebar/sidebar.firefox.integration.test.ts b/tests/integration/browser/sidebar/sidebar.firefox.integration.test.ts new file mode 100644 index 00000000..59fc8c83 --- /dev/null +++ b/tests/integration/browser/sidebar/sidebar.firefox.integration.test.ts @@ -0,0 +1,7 @@ +import {verifySidebar} from "./sidebar"; + +jest.setTimeout(90_000); + +test.each([2, 3] as const)("updates sidebar API state without errors in Firefox MV%i", async version => { + await verifySidebar("firefox", version); +}); diff --git a/tests/integration/browser/sidebar/sidebar.integration.test.ts b/tests/integration/browser/sidebar/sidebar.integration.test.ts new file mode 100644 index 00000000..5bf97366 --- /dev/null +++ b/tests/integration/browser/sidebar/sidebar.integration.test.ts @@ -0,0 +1,7 @@ +import {verifySidebar} from "./sidebar"; + +jest.setTimeout(90_000); + +test("switches sidebars without title or icon APIs in Chrome MV3", async () => { + await verifySidebar("chrome", 3); +}); diff --git a/tests/integration/browser/sidebar/sidebar.ts b/tests/integration/browser/sidebar/sidebar.ts new file mode 100644 index 00000000..ad800983 --- /dev/null +++ b/tests/integration/browser/sidebar/sidebar.ts @@ -0,0 +1,87 @@ +import path from "path"; +import {createIntegrationFixture} from "../../utils/fixture"; +import {waitFor} from "../utils/browser"; +import {startBrowserSession, type BrowserSession} from "../utils/session"; +import {startIntegrationSite, type IntegrationSite} from "../utils/site"; + +export const verifySidebar = async (browser: "chrome" | "firefox", manifestVersion: 2 | 3): Promise => { + const fixture = await createIntegrationFixture( + ADNBN_TEST_ROOT, + path.join(ADNBN_TEST_ROOT, "tests/integration/build/sidebar/fixture") + ); + let session: BrowserSession | undefined; + let site: IntegrationSite | undefined; + + try { + const directory = await fixture.build({browser, manifestVersion}); + site = await startIntegrationSite(path.join(fixture.directory, "site")); + session = await startBrowserSession(browser, ADNBN_TEST_ROOT, directory); + await session.navigate(site.origin); + + const text = await waitFor( + () => session!.evaluate('document.getElementById("sidebar-results")?.textContent || undefined'), + 15_000, + "sidebar switching and rendered document reports" + ); + const result = JSON.parse(text); + const supported = browser === "firefox"; + + expect(result.error).toBeUndefined(); + expect(result.canSetTitle).toBe(supported); + expect(result.canGetTitle).toBe(supported); + expect(result.canSetIcon).toBe(supported); + expect(["Open account", "Ouvrir le compte"]).toContain(result.translated); + expect(result.initial).toBe(supported ? result.translated : null); + expect(result.initialPath).toMatch(/sidebar\.html$/); + expect(result.switches.map((item: {target: string}) => item.target)).toEqual(["global", "tab"]); + + for (const {target, states} of result.switches) { + expect(states).toHaveLength(5); + + for (const [index, alias, title] of [ + [0, "preserve", "Keep this label"], + [1, "settings", "Open settings"], + [2, "empty", ""], + [3, "missing", ""], + [4, "sidebar", result.translated], + ] as const) { + expect(states[index]).toEqual({ + alias, + title: supported ? title : null, + path: expect.stringMatching(alias === "sidebar" ? /sidebar\.html$/ : `${alias}\\.sidebar\\.html$`), + globalTitle: target === "tab" && supported ? result.translated : null, + globalPath: target === "tab" ? result.initialPath : null, + }); + } + } + + expect(result.globalAfterTab).toBe(supported ? result.translated : null); + expect(result.globalPathAfterTab).toMatch(/sidebar\.html$/); + expect(result.toolbarTitle).toBe("Independent toolbar"); + expect(result.entries).toEqual({ + sidebar: {path: "sidebar.html", label: "@sidebar.account", icon: "account"}, + settings: {path: "settings.sidebar.html", label: "Open settings", icon: "settings"}, + preserve: {path: "preserve.sidebar.html"}, + empty: {path: "empty.sidebar.html", label: ""}, + missing: {path: "missing.sidebar.html", label: "@sidebar.missing"}, + }); + expect(result.views).toEqual([ + {kind: "sidebar-view", alias: "sidebar", documentTitle: "Account document", propTitle: "Account document"}, + { + kind: "sidebar-view", + alias: "settings", + documentTitle: "Settings document", + propTitle: "Settings document", + }, + {kind: "sidebar-view", alias: "empty", documentTitle: "Addon"}, + ]); + expect(session.errors).toEqual([]); + } finally { + try { + await session?.close(); + } finally { + await site?.close(); + await fixture.dispose(); + } + } +}; diff --git a/tests/integration/build/sidebar/fixture/adnbn.config.ts b/tests/integration/build/sidebar/fixture/adnbn.config.ts new file mode 100644 index 00000000..f202821f --- /dev/null +++ b/tests/integration/build/sidebar/fixture/adnbn.config.ts @@ -0,0 +1,9 @@ +import {defineConfig} from "adnbn"; + +export default defineConfig({ + name: "Sidebar contract test", + version: "1.0.0", + multipleSidebar: true, + action: {title: "Independent toolbar"}, + specific: {gecko: {id: "sidebar-contract@adnbn.test"}}, +}); diff --git a/tests/integration/build/sidebar/fixture/package.json b/tests/integration/build/sidebar/fixture/package.json new file mode 100644 index 00000000..b0526a98 --- /dev/null +++ b/tests/integration/build/sidebar/fixture/package.json @@ -0,0 +1,10 @@ +{ + "name": "adnbn-test-sidebar", + "private": true, + "type": "module", + "version": "1.0.0", + "dependencies": { + "adnbn": "file:../../../../../..", + "@addon-core/browser": "*" + } +} diff --git a/tests/integration/build/sidebar/fixture/site/top.html b/tests/integration/build/sidebar/fixture/site/top.html new file mode 100644 index 00000000..6902b718 --- /dev/null +++ b/tests/integration/build/sidebar/fixture/site/top.html @@ -0,0 +1,9 @@ + + + + Sidebar test host + + + Sidebar contract test + + diff --git a/tests/integration/build/sidebar/fixture/src/background.ts b/tests/integration/build/sidebar/fixture/src/background.ts new file mode 100644 index 00000000..699fab8f --- /dev/null +++ b/tests/integration/build/sidebar/fixture/src/background.ts @@ -0,0 +1,107 @@ +import {changeSidebar, defineBackground, getSidebars, type SidebarAlias} from "adnbn"; +import { + canGetSidebarTitle, + canSetSidebarIcon, + canSetSidebarTitle, + createTab, + getActionTitle, + getI18nMessage, + getSidebarPath, + getSidebarTitle, + getUrl, + removeTab, + setSidebarTitle, +} from "@addon-core/browser"; + +interface ViewReport { + kind: "sidebar-view"; + alias: string; + documentTitle: string; + propTitle?: string; +} + +const views = new Map void>(); + +const inspectView = async (alias: SidebarAlias) => { + const report = new Promise(resolve => views.set(alias, resolve)); + const tab = await createTab({url: getUrl(getSidebars().get(alias)!.path!), active: false}); + + try { + return await report; + } finally { + views.delete(alias); + await removeTab(tab.id!); + } +}; + +const readTitle = (tabId?: number) => (canGetSidebarTitle() ? getSidebarTitle(tabId) : null); + +const inspect = async (tabId: number) => { + const initial = await readTitle(); + const initialPath = await getSidebarPath(); + const translated = getI18nMessage("sidebar_account"); + const switches = []; + + for (const target of [undefined, tabId]) { + if (canSetSidebarTitle()) { + await setSidebarTitle("Keep this label", target); + } + + const states = []; + + for (const alias of ["preserve", "settings", "empty", "missing", "sidebar"] as const) { + await changeSidebar(alias, target); + states.push({ + alias, + title: await readTitle(target), + path: await getSidebarPath(target), + globalTitle: target === undefined ? null : await readTitle(), + globalPath: target === undefined ? null : await getSidebarPath(), + }); + } + + switches.push({target: target === undefined ? "global" : "tab", states}); + } + + return { + canSetTitle: canSetSidebarTitle(), + canGetTitle: canGetSidebarTitle(), + canSetIcon: canSetSidebarIcon(), + initial, + initialPath, + translated, + switches, + globalAfterTab: await readTitle(), + globalPathAfterTab: await getSidebarPath(), + toolbarTitle: await getActionTitle(), + entries: Object.fromEntries(getSidebars()), + views: [await inspectView("sidebar"), await inspectView("settings"), await inspectView("empty")], + }; +}; + +export default defineBackground({ + main() { + chrome.runtime.onMessage.addListener((message, sender, respond) => { + if (message.kind === "sidebar-view") { + views.get(message.alias)?.(message); + respond({ok: true}); + + return; + } + + if (message.kind === "inspect-sidebar") { + const run = async () => { + try { + respond(await inspect(sender.tab!.id!)); + } catch (error) { + respond({error: String(error)}); + } + }; + + void run(); + + return true; + } + }); + }, +}); diff --git a/tests/integration/build/sidebar/fixture/src/empty.sidebar.ts b/tests/integration/build/sidebar/fixture/src/empty.sidebar.ts new file mode 100644 index 00000000..9ebd543c --- /dev/null +++ b/tests/integration/build/sidebar/fixture/src/empty.sidebar.ts @@ -0,0 +1,8 @@ +import {defineSidebar} from "adnbn"; +import {reportView} from "./report-view"; + +export default defineSidebar({ + label: "", + apply: false, + render: ({title}) => reportView("empty", title), +}); diff --git a/tests/integration/build/sidebar/fixture/src/icons/account16.png b/tests/integration/build/sidebar/fixture/src/icons/account16.png new file mode 100644 index 0000000000000000000000000000000000000000..0a3e580fb1504f3e4f6f312fbb18065955fca4f6 GIT binary patch literal 82 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`lAbP(Ar*6y4g6;RoG*07AnU*i f#>H$r;>--qc82ozUlbh#Dq`?-^>bP0l+XkK=Is@Z literal 0 HcmV?d00001 diff --git a/tests/integration/build/sidebar/fixture/src/icons/settings16.png b/tests/integration/build/sidebar/fixture/src/icons/settings16.png new file mode 100644 index 0000000000000000000000000000000000000000..0a3e580fb1504f3e4f6f312fbb18065955fca4f6 GIT binary patch literal 82 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`lAbP(Ar*6y4g6;RoG*07AnU*i f#>H$r;>--qc82ozUlbh#Dq`?-^>bP0l+XkK=Is@Z literal 0 HcmV?d00001 diff --git a/tests/integration/build/sidebar/fixture/src/locales/en.json b/tests/integration/build/sidebar/fixture/src/locales/en.json new file mode 100644 index 00000000..742ad508 --- /dev/null +++ b/tests/integration/build/sidebar/fixture/src/locales/en.json @@ -0,0 +1,5 @@ +{ + "sidebar": { + "account": "Open account" + } +} diff --git a/tests/integration/build/sidebar/fixture/src/locales/fr.json b/tests/integration/build/sidebar/fixture/src/locales/fr.json new file mode 100644 index 00000000..60c99561 --- /dev/null +++ b/tests/integration/build/sidebar/fixture/src/locales/fr.json @@ -0,0 +1,5 @@ +{ + "sidebar": { + "account": "Ouvrir le compte" + } +} diff --git a/tests/integration/build/sidebar/fixture/src/missing.sidebar.ts b/tests/integration/build/sidebar/fixture/src/missing.sidebar.ts new file mode 100644 index 00000000..36ee90f7 --- /dev/null +++ b/tests/integration/build/sidebar/fixture/src/missing.sidebar.ts @@ -0,0 +1,3 @@ +import {defineSidebar} from "adnbn"; + +export default defineSidebar({title: "Missing translation document", label: "@sidebar.missing", apply: false}); diff --git a/tests/integration/build/sidebar/fixture/src/preserve.sidebar.ts b/tests/integration/build/sidebar/fixture/src/preserve.sidebar.ts new file mode 100644 index 00000000..cdb289dd --- /dev/null +++ b/tests/integration/build/sidebar/fixture/src/preserve.sidebar.ts @@ -0,0 +1,8 @@ +import {defineSidebar} from "adnbn"; +import {reportView} from "./report-view"; + +export default defineSidebar({ + title: "Preserve document", + apply: false, + render: ({title}) => reportView("preserve", title), +}); diff --git a/tests/integration/build/sidebar/fixture/src/probe.content.ts b/tests/integration/build/sidebar/fixture/src/probe.content.ts new file mode 100644 index 00000000..7ef4842d --- /dev/null +++ b/tests/integration/build/sidebar/fixture/src/probe.content.ts @@ -0,0 +1,17 @@ +import {defineContentScript} from "adnbn"; + +export default defineContentScript({ + matches: ["http://127.0.0.1/*"], + render: () => { + const element = document.createElement("pre"); + element.id = "sidebar-results"; + + chrome.runtime.sendMessage({kind: "inspect-sidebar"}, result => { + element.textContent = JSON.stringify( + chrome.runtime.lastError ? {error: chrome.runtime.lastError.message} : result + ); + }); + + return element; + }, +}); diff --git a/tests/integration/build/sidebar/fixture/src/report-view.ts b/tests/integration/build/sidebar/fixture/src/report-view.ts new file mode 100644 index 00000000..f4cad399 --- /dev/null +++ b/tests/integration/build/sidebar/fixture/src/report-view.ts @@ -0,0 +1,7 @@ +export const reportView = (alias: string, title?: string): HTMLElement => { + chrome.runtime.sendMessage({kind: "sidebar-view", alias, documentTitle: document.title, propTitle: title}); + const element = document.createElement("h1"); + element.textContent = title ?? ""; + + return element; +}; diff --git a/tests/integration/build/sidebar/fixture/src/settings.sidebar.ts b/tests/integration/build/sidebar/fixture/src/settings.sidebar.ts new file mode 100644 index 00000000..c80e5314 --- /dev/null +++ b/tests/integration/build/sidebar/fixture/src/settings.sidebar.ts @@ -0,0 +1,8 @@ +import type {ViewOptions} from "adnbn"; +import {reportView} from "./report-view"; + +export const title = "Settings document"; +export const label = "Open settings"; +export const icon = "settings"; +export const apply = false; +export const render = (props: ViewOptions) => reportView("settings", props.title); diff --git a/tests/integration/build/sidebar/fixture/src/sidebar.ts b/tests/integration/build/sidebar/fixture/src/sidebar.ts new file mode 100644 index 00000000..5d5b48ba --- /dev/null +++ b/tests/integration/build/sidebar/fixture/src/sidebar.ts @@ -0,0 +1,9 @@ +import {defineSidebar} from "adnbn"; +import {reportView} from "./report-view"; + +export default defineSidebar({ + title: "Account document", + label: "@sidebar.account", + icon: "account", + render: ({title}) => reportView("sidebar", title), +}); diff --git a/tests/integration/build/sidebar/fixture/tsconfig.json b/tests/integration/build/sidebar/fixture/tsconfig.json new file mode 100644 index 00000000..eadf1be8 --- /dev/null +++ b/tests/integration/build/sidebar/fixture/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "./.adnbn/tsconfig.json" +} diff --git a/tests/integration/build/sidebar/fixture/variants/sidebar-empty-label.ts b/tests/integration/build/sidebar/fixture/variants/sidebar-empty-label.ts new file mode 100644 index 00000000..f18e05c9 --- /dev/null +++ b/tests/integration/build/sidebar/fixture/variants/sidebar-empty-label.ts @@ -0,0 +1,3 @@ +import {defineSidebar} from "adnbn"; + +export default defineSidebar({title: "Only the document", label: "", render: ({title}) => title}); diff --git a/tests/integration/build/sidebar/fixture/variants/sidebar-without-label.ts b/tests/integration/build/sidebar/fixture/variants/sidebar-without-label.ts new file mode 100644 index 00000000..e1a91f95 --- /dev/null +++ b/tests/integration/build/sidebar/fixture/variants/sidebar-without-label.ts @@ -0,0 +1,3 @@ +import {defineSidebar} from "adnbn"; + +export default defineSidebar({title: "Only the document", render: ({title}) => title}); diff --git a/tests/integration/build/sidebar/sidebar.integration.test.ts b/tests/integration/build/sidebar/sidebar.integration.test.ts new file mode 100644 index 00000000..631bc6fb --- /dev/null +++ b/tests/integration/build/sidebar/sidebar.integration.test.ts @@ -0,0 +1,80 @@ +import {copyFile, readFile, stat} from "fs/promises"; +import path from "path"; + +import {createIntegrationFixture} from "../../utils/fixture"; + +jest.setTimeout(90_000); + +test.each([ + {browser: "chrome", manifestVersion: 3}, + {browser: "firefox", manifestVersion: 2}, + {browser: "firefox", manifestVersion: 3}, + {browser: "opera", manifestVersion: 2}, + {browser: "opera", manifestVersion: 3}, +] as const)("builds independent sidebar documents and labels for $browser MV$manifestVersion", async options => { + const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, path.join(__dirname, "fixture")); + + try { + const directory = await fixture.build(options); + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + + if (options.browser === "chrome") { + expect(manifest.side_panel).toEqual({default_path: "sidebar.html"}); + expect(manifest).not.toHaveProperty("sidebar_action"); + } else { + expect(manifest.sidebar_action).toEqual({ + default_panel: "sidebar.html", + default_title: "__MSG_sidebar_account__", + default_icon: {16: "/icons/account-16.png"}, + ...(options.browser === "firefox" ? {open_at_install: false} : {}), + }); + expect(manifest).not.toHaveProperty("side_panel"); + } + + expect(manifest[options.manifestVersion === 2 ? "browser_action" : "action"].default_title).toBe( + "Independent toolbar" + ); + + for (const [file, title] of [ + ["sidebar.html", "Account document"], + ["settings.sidebar.html", "Settings document"], + ["empty.sidebar.html", "Addon"], + ]) { + expect(await readFile(path.join(directory, file), "utf8")).toContain(`${title}`); + } + + const messages = JSON.parse(await readFile(path.join(directory, "_locales", "en", "messages.json"), "utf8")); + + expect(messages.sidebar_account.message).toBe("Open account"); + + for (const icon of ["account", "settings"]) { + expect((await stat(path.join(directory, "icons", `${icon}-16.png`))).isFile()).toBe(true); + } + } finally { + await fixture.dispose(); + } +}); + +test.each([ + {variant: "without-label", expected: "Sidebar contract test"}, + {variant: "empty-label", expected: ""}, +])("keeps document titles out of the initial UI label: $variant", async ({variant, expected}) => { + const fixture = await createIntegrationFixture(ADNBN_TEST_ROOT, path.join(__dirname, "fixture")); + + try { + await copyFile( + path.join(fixture.directory, "variants", `sidebar-${variant}.ts`), + path.join(fixture.directory, "src", "sidebar.ts") + ); + + const directory = await fixture.build({browser: "firefox", manifestVersion: 3}); + const manifest = JSON.parse(await readFile(path.join(directory, "manifest.json"), "utf8")); + + expect(manifest.sidebar_action.default_title).toBe(expected); + expect(await readFile(path.join(directory, "sidebar.html"), "utf8")).toContain( + "Only the document" + ); + } finally { + await fixture.dispose(); + } +}); diff --git a/tests/integration/build/virtual-modules/fixture/src/sidebar.ts b/tests/integration/build/virtual-modules/fixture/src/sidebar.ts index 4330f221..6073730c 100644 --- a/tests/integration/build/virtual-modules/fixture/src/sidebar.ts +++ b/tests/integration/build/virtual-modules/fixture/src/sidebar.ts @@ -1,3 +1,3 @@ import {defineSidebar} from "adnbn"; -export default defineSidebar({title: "Sidebar data"}); +export default defineSidebar({title: "Sidebar document", label: "Sidebar data"}); diff --git a/tests/integration/build/virtual-modules/virtual-modules.integration.test.ts b/tests/integration/build/virtual-modules/virtual-modules.integration.test.ts index f87ff49b..afe15ca3 100644 --- a/tests/integration/build/virtual-modules/virtual-modules.integration.test.ts +++ b/tests/integration/build/virtual-modules/virtual-modules.integration.test.ts @@ -18,7 +18,7 @@ test("generated entrypoint data reach public getters and disappear without consu vm.runInNewContext(source, runtime); expect(runtime.readData!()).toEqual({ popup: {popup: {path: "popup.html", tooltip: "Popup data", icon: "dataicons"}}, - sidebar: {sidebar: {path: "sidebar.html", title: "Sidebar data"}}, + sidebar: {sidebar: {path: "sidebar.html", label: "Sidebar data"}}, offscreen: { dataOffscreen: {url: "offscreen.html", reasons: ["DOM_PARSER"], justification: "Offscreen data"}, }, diff --git a/tests/integration/types/fixtures/registries/generated.ts b/tests/integration/types/fixtures/registries/generated.ts index 8cedbb75..6e4dd7bf 100644 --- a/tests/integration/types/fixtures/registries/generated.ts +++ b/tests/integration/types/fixtures/registries/generated.ts @@ -35,7 +35,10 @@ const tooltip: string | undefined = popupEntry?.tooltip; popupEntry?.title; const manifestPopup: api.ManifestPopup = {title: "__MSG_popup_title__", path: "popup.html"}; -api.getSidebars().get("sidebar"); +const sidebarEntry: api.SidebarMapEntry | undefined = api.getSidebars().get("sidebar"); +const label: string | undefined = sidebarEntry?.label; +type SidebarEntryKeys = Expect>; +const manifestSidebar: api.ManifestSidebar = {title: "__MSG_sidebar_title__", path: "sidebar.html"}; api.getIcons().get("brand"); api.defineConfig({action: {icon: "brand"}}); @@ -44,7 +47,11 @@ api.definePopup({tooltip: ""}); // @ts-expect-error: Popup tooltips must be strings. api.definePopup({tooltip: 42}); -api.defineSidebar({icon: "brand", render: "Sidebar"}); +api.defineSidebar({icon: "brand", title: "Sidebar document", label: "@sidebar.title", render: "Sidebar"}); +api.defineSidebar({label: ""}); + +// @ts-expect-error: Sidebar labels must be strings. +api.defineSidebar({label: 42}); // @ts-expect-error: Action defaults use the generated icon group names. api.defineConfig({action: {icon: "unknown"}}); diff --git a/tests/integration/types/fixtures/registries/internal.ts b/tests/integration/types/fixtures/registries/internal.ts index 79288d7f..b79af3f0 100644 --- a/tests/integration/types/fixtures/registries/internal.ts +++ b/tests/integration/types/fixtures/registries/internal.ts @@ -1,5 +1,5 @@ import type {PopupAlias, PopupAliasRegistry, PopupMap} from "@typing/popup"; -import type {SidebarAlias, SidebarAliasRegistry, SidebarMap} from "@typing/sidebar"; +import type {SidebarAlias, SidebarAliasRegistry, SidebarMap, SidebarMapEntry} from "@typing/sidebar"; import type {IconName, IconNameRegistry, IconsMap} from "@typing/icon"; import type {ServiceName, ServiceRegistry} from "@typing/service"; import type {OffscreenName, OffscreenRegistry, OffscreenMap} from "@typing/offscreen"; @@ -22,6 +22,7 @@ type OffscreenNames = Expect>; type SandboxNames = Expect>; type PopupCollection = Expect>; type SidebarCollection = Expect>; +type SidebarEntry = Expect>; type IconCollection = Expect>; type OffscreenCollection = Expect>; type SandboxCollection = Expect>; From 7136abb1bff63e335934807597d71221941cadbb Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:26:11 +0200 Subject: [PATCH 21/24] fix(deps): reduce dependency security vulnerabilities Update vulnerable dependencies, remove the unused Rspack CLI, and pin release-it's undici to a patched version. Declare memfs explicitly for the existing bundler tests. Give the isolated styles watch fixture its own package.json so polling does not scan shared temporary directories. Keep all timeouts unchanged. Validation: npm run check passes on Node 22 and 24 (2490 tests each). The dependency audit is reduced from 45 findings to 8, with no critical findings. Deferred Zod, basic-ftp, and tsup esbuild changes are excluded. --- package-lock.json | 3023 +++-------------- package.json | 13 +- .../IsolatedStylesPlugin.test.ts | 1 + 3 files changed, 568 insertions(+), 2469 deletions(-) diff --git a/package-lock.json b/package-lock.json index 333dfe92..05ea90af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,7 @@ "@addon-core/browser": "^0.12.0", "@addon-core/inject-script": "^0.5.1", "@addon-core/storage": "^0.8.3", - "@rsdoctor/rspack-plugin": "^1.5.1", - "@rspack/cli": "^1.7.5", + "@rsdoctor/rspack-plugin": "^1.5.16", "@rspack/core": "^1.7.5", "@rspack/lite-tapable": "^1.1.0", "@rspackjs/plugin-html-tags": "^1.0.0", @@ -25,13 +24,13 @@ "css-loader": "^7.1.2", "file-loader": "^6.2.0", "get-value": "^4.0.1", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.2", "json-stringify-deterministic": "^1.0.12", "lodash": "4.18.1", "mini-css-extract-plugin": "^2.9.2", - "nanoid": "^5.1.4", + "nanoid": "^5.1.16", "pluralize": "^8.0.0", - "postcss": "^8.5.15", + "postcss": "^8.5.23", "postcss-scss": "^4.0.9", "postcss-value-parser": "^4.2.0", "resolve-url-loader": "^5.0.0", @@ -81,6 +80,7 @@ "istanbul-reports": "^3.2.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.0.0", + "memfs": "4.57.2", "prettier": "^3.6.2", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -237,43 +237,43 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -289,20 +289,6 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/core/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -313,13 +299,13 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -341,13 +327,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -438,9 +424,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -460,27 +446,27 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -502,9 +488,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -558,27 +544,27 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -599,25 +585,25 @@ } }, "node_modules/@babel/helpers": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", - "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.11.tgz", + "integrity": "sha512-OZ6+oaK7GMyg3IfCpj8JOmn8n+w2rJbDT0LLfVDTLb1ZFeKDh3AiNELgCMUw175IJHSkLl74hG0M0VLsvx+gag==", "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.11" }, "bin": { "parser": "bin/babel-parser.js" @@ -1397,15 +1383,15 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", - "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.8.tgz", + "integrity": "sha512-6iSnEK0zlkLKU4heofK/AdmRD4e2SHVpJMtrwnTCzhnaM98ria4rTrOXBBi45BTTYnJtO8txnPsX4fChYXkmeA==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.29.0" + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.8" }, "engines": { "node": ">=6.9.0" @@ -2062,73 +2048,45 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2584,15 +2542,6 @@ "node": ">=20.19.0" } }, - "node_modules/@discoveryjs/json-ext": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz", - "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -3474,9 +3423,9 @@ } }, "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "dev": true, "license": "MIT", "dependencies": { @@ -3515,21 +3464,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/console/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@jest/console/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", @@ -3674,21 +3608,6 @@ } } }, - "node_modules/@jest/core/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@jest/core/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", @@ -3975,21 +3894,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/@jest/expect/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@jest/expect/node_modules/@jest/expect-utils": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", @@ -4168,21 +4072,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/fake-timers/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@jest/fake-timers/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", @@ -4363,21 +4252,6 @@ } } }, - "node_modules/@jest/reporters/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@jest/reporters/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", @@ -4707,6 +4581,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-1.1.2.tgz", "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -4723,6 +4598,7 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-17.67.0.tgz", "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -4739,6 +4615,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz", "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -4755,6 +4632,7 @@ "version": "4.57.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-core/-/fs-core-4.57.2.tgz", "integrity": "sha512-SVjwklkpIV5wrynpYtuYnfYH1QF4/nDuLBX7VXdb+3miglcAgBVZb/5y0cOsehRV/9Vb+3UqhkMq3/NR3ztdkQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/fs-node-builtins": "4.57.2", @@ -4776,6 +4654,7 @@ "version": "4.57.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-fsa/-/fs-fsa-4.57.2.tgz", "integrity": "sha512-fhO8+iR2I+OCw668ISDJdn1aArc9zx033sWejIyzQ8RBeXa9bDSaUeA3ix0poYOfrj1KdOzytmYNv2/uLDfV6g==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/fs-core": "4.57.2", @@ -4798,6 +4677,7 @@ "version": "4.57.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node/-/fs-node-4.57.2.tgz", "integrity": "sha512-nX2AdL6cOFwLdju9G4/nbRnYevmCJbh7N7hvR3gGm97Cs60uEjyd0rpR+YBS7cTg175zzl22pGKXR5USaQMvKg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/fs-core": "4.57.2", @@ -4823,6 +4703,7 @@ "version": "4.57.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.57.2.tgz", "integrity": "sha512-xhiegylRmhw43Ki2HO1ZBL7DQ5ja/qpRsL29VtQ2xuUHiuDGbgf2uD4p9Qd8hJI5P6RCtGYD50IXHXVq/Ocjcg==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -4839,6 +4720,7 @@ "version": "4.57.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.57.2.tgz", "integrity": "sha512-18LmWTSONhoAPW+IWRuf8w/+zRolPFGPeGwMxlAhhfY11EKzX+5XHDBPAw67dBF5dxDErHJbl40U+3IXSDRXSQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/fs-fsa": "4.57.2", @@ -4860,6 +4742,7 @@ "version": "4.57.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.57.2.tgz", "integrity": "sha512-rsPSJgekz43IlNbLyAM/Ab+ouYLWGp5DDBfYBNNEqDaSpsbXfthBn29Q4muFA9L0F+Z3mKo+CWlgSCXrf+mOyQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/fs-node-builtins": "4.57.2" @@ -4879,6 +4762,7 @@ "version": "4.57.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-print/-/fs-print-4.57.2.tgz", "integrity": "sha512-wK9NSow48i4DbDl9F1CQE5TqnyZOJ04elU3WFG5aJ76p+YxO/ulyBBQvKsessPxdo381Bc2pcEoyPujMOhcRqQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/fs-node-utils": "4.57.2", @@ -4899,6 +4783,7 @@ "version": "4.57.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.57.2.tgz", "integrity": "sha512-GdduDZuoP5V/QCgJkx9+BZ6SC0EZ/smXAdTS7PfMqgMTGXLlt/bH/FqMYaqB9JmLf05sJPtO0XRbAwwkEEPbVw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/buffers": "^17.65.0", @@ -4921,6 +4806,7 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/base64/-/base64-17.67.0.tgz", "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -4937,6 +4823,7 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/codegen/-/codegen-17.67.0.tgz", "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -4953,6 +4840,7 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-17.67.0.tgz", "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/base64": "17.67.0", @@ -4979,6 +4867,7 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-17.67.0.tgz", "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/util": "17.67.0" @@ -4998,6 +4887,7 @@ "version": "17.67.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-17.67.0.tgz", "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/buffers": "17.67.0", @@ -5018,6 +4908,7 @@ "version": "1.21.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz", "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/base64": "^1.1.2", @@ -5044,6 +4935,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -5060,6 +4952,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz", "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/codegen": "^1.0.0", @@ -5080,6 +4973,7 @@ "version": "1.9.0", "resolved": "https://registry.npmjs.org/@jsonjoy.com/util/-/util-1.9.0.tgz", "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/buffers": "^1.0.0", @@ -5100,6 +4994,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz", "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -5112,12 +5007,6 @@ "tslib": "2" } }, - "node_modules/@leichtgewicht/ip-codec": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz", - "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", - "license": "MIT" - }, "node_modules/@microsoft/api-extractor": { "version": "7.58.7", "resolved": "https://registry.npmjs.org/@microsoft/api-extractor/-/api-extractor-7.58.7.tgz", @@ -5808,12 +5697,6 @@ "url": "https://opencollective.com/pkgr" } }, - "node_modules/@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "license": "MIT" - }, "node_modules/@release-it/conventional-changelog": { "version": "10.0.6", "resolved": "https://registry.npmjs.org/@release-it/conventional-changelog/-/conventional-changelog-10.0.6.tgz", @@ -6208,55 +6091,68 @@ } }, "node_modules/@rsdoctor/client": { - "version": "1.5.9", - "resolved": "https://registry.npmjs.org/@rsdoctor/client/-/client-1.5.9.tgz", - "integrity": "sha512-obu4fXxU5fwWyV1rcmMsbHTkzvjkw15EH+F7v0K3pGOz0GiOFpZl8bsweLEmroMz3NU4xYDJElJfogW0ILk/uw==", + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@rsdoctor/client/-/client-1.6.5.tgz", + "integrity": "sha512-k/k1X8Y+/7JbAiE7ELdSfTbRP51fKY0stHc1y1N2s0uF7RRBSozN5ENyD+5yJSIDgNP5N5lRnZF+1Pp4BOhhjA==", "license": "MIT" }, "node_modules/@rsdoctor/core": { - "version": "1.5.9", - "resolved": "https://registry.npmjs.org/@rsdoctor/core/-/core-1.5.9.tgz", - "integrity": "sha512-f2hqXYrheNCOyJlZfe0RY/ccMvrusqah6/DQFtT1GUcohamYQBWehE4HhijEAQLAL6VoL87qVKxt58ovaoEs4w==", - "license": "MIT", - "dependencies": { - "@rsbuild/plugin-check-syntax": "1.6.1", - "@rsdoctor/graph": "1.5.9", - "@rsdoctor/sdk": "1.5.9", - "@rsdoctor/types": "1.5.9", - "@rsdoctor/utils": "1.5.9", - "@rspack/resolver": "0.2.8", - "browserslist-load-config": "^1.0.1", - "es-toolkit": "^1.45.1", - "filesize": "^10.1.6", + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@rsdoctor/core/-/core-1.6.5.tgz", + "integrity": "sha512-yiY3Og8I05QNkDVY3Jgj2VPMVqZpWLn0wyhUXej2BQXJC+iifPSTEHAcB4sVofyx1v8MJgTBelepjDr0vbNeGA==", + "license": "MIT", + "dependencies": { + "@rsbuild/plugin-check-syntax": "^1.6.1", + "@rsdoctor/graph": "1.6.5", + "@rsdoctor/sdk": "1.6.5", + "@rsdoctor/types": "1.6.5", + "@rsdoctor/utils": "1.6.5", + "@rspack/resolver": "^0.2.8", + "browserslist-load-config": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-toolkit": "^1.49.0", + "filesize": "^11.0.17", "fs-extra": "^11.1.1", - "semver": "^7.7.4", + "semver": "^7.8.5", "source-map": "^0.7.6" } }, + "node_modules/@rsdoctor/core/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@rsdoctor/graph": { - "version": "1.5.9", - "resolved": "https://registry.npmjs.org/@rsdoctor/graph/-/graph-1.5.9.tgz", - "integrity": "sha512-gu9/+ZfKyHnVc3Rt19p2vd+zu1/ftSH/DdenfVjYv8qgmdRXZXX1A6tuyM0JAYxKXn9Zo89BVA6sUloF7BSbIQ==", + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@rsdoctor/graph/-/graph-1.6.5.tgz", + "integrity": "sha512-0XnUkPLG2HO4L8IC0o5NSYhoKUKsjERSc5sgdv5hsPGWDMa5K0lvJpXp9C6mHfEjZtHu7308Cm7/LGQsGPJffA==", "license": "MIT", "dependencies": { - "@rsdoctor/types": "1.5.9", - "@rsdoctor/utils": "1.5.9", - "es-toolkit": "^1.45.1", + "@rsdoctor/types": "1.6.5", + "@rsdoctor/utils": "1.6.5", + "es-toolkit": "^1.49.0", "path-browserify": "1.0.1", "source-map": "^0.7.6" } }, "node_modules/@rsdoctor/rspack-plugin": { - "version": "1.5.9", - "resolved": "https://registry.npmjs.org/@rsdoctor/rspack-plugin/-/rspack-plugin-1.5.9.tgz", - "integrity": "sha512-SgzjuoRGQ6cYM1001IdXD//MrizePimIdyeZqyp4p+NpYG3LwLzCKUFtT+Ef7qOAt9lOBYcF8oCMIpSh5Xk+xg==", + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@rsdoctor/rspack-plugin/-/rspack-plugin-1.6.5.tgz", + "integrity": "sha512-61U0NqOH6DKrkv7MJC0LwxG4NAYCP3xI5y3TXCs/95f0zm73PYVP80XTRQ1ffNC/yiFmU6uJYY6DXiKdQUpYZw==", "license": "MIT", "dependencies": { - "@rsdoctor/core": "1.5.9", - "@rsdoctor/graph": "1.5.9", - "@rsdoctor/sdk": "1.5.9", - "@rsdoctor/types": "1.5.9", - "@rsdoctor/utils": "1.5.9" + "@rsdoctor/core": "1.6.5", + "@rsdoctor/graph": "1.6.5", + "@rsdoctor/sdk": "1.6.5", + "@rsdoctor/types": "1.6.5", + "@rsdoctor/utils": "1.6.5" }, "peerDependencies": { "@rspack/core": "*" @@ -6268,25 +6164,25 @@ } }, "node_modules/@rsdoctor/sdk": { - "version": "1.5.9", - "resolved": "https://registry.npmjs.org/@rsdoctor/sdk/-/sdk-1.5.9.tgz", - "integrity": "sha512-aE3tO6EecnrPdV/Jfn8dGGqZGZ5kg9Ta8ULoVbuMo9dOOnNxYq122S0EGATqROorAsfahd8QSMV7YxznoIqP+w==", + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@rsdoctor/sdk/-/sdk-1.6.5.tgz", + "integrity": "sha512-TTXZ2o8qY1nTK8QN8M4kwrBRgRZ1rwRgwGKyM/v0f7VLjQ9BdLjekHapP7PyMVgw+nR5y9C/UR+eDQD1GqW93Q==", "license": "MIT", "dependencies": { - "@rsdoctor/client": "1.5.9", - "@rsdoctor/graph": "1.5.9", - "@rsdoctor/types": "1.5.9", - "@rsdoctor/utils": "1.5.9", + "@rsdoctor/client": "1.6.5", + "@rsdoctor/graph": "1.6.5", + "@rsdoctor/types": "1.6.5", + "@rsdoctor/utils": "1.6.5", "launch-editor": "^2.13.2", "safer-buffer": "2.1.2", "socket.io": "4.8.1", - "tapable": "2.3.2" + "tapable": "2.3.3" } }, "node_modules/@rsdoctor/types": { - "version": "1.5.9", - "resolved": "https://registry.npmjs.org/@rsdoctor/types/-/types-1.5.9.tgz", - "integrity": "sha512-kjxWEocZWLiNhCaVBQDfPMn8USIf+UyFm21VhxwM0cekW5pZKPwTFE6hTzHH8aWCWtGwApzzLXPBQqZoWYUXlQ==", + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@rsdoctor/types/-/types-1.6.5.tgz", + "integrity": "sha512-qcmJXkkohhGM+7/OKLWBDZ88CCDstc7YFTQNBVNO2bQ4xDyLjjWYg1MiS4Lsy3dC0BC7Sqy35eunIvmlnkMabw==", "license": "MIT", "dependencies": { "@types/connect": "3.4.38", @@ -6308,13 +6204,13 @@ } }, "node_modules/@rsdoctor/utils": { - "version": "1.5.9", - "resolved": "https://registry.npmjs.org/@rsdoctor/utils/-/utils-1.5.9.tgz", - "integrity": "sha512-e1Fii9zdtNCyYwsCZLKo5gkfBgZmkRkBpieSAcdworgdlhAA+UTxCVg1q6/Ozz8QQcUbj9AfP0do9ja633vfvw==", + "version": "1.6.5", + "resolved": "https://registry.npmjs.org/@rsdoctor/utils/-/utils-1.6.5.tgz", + "integrity": "sha512-/Ybsk9krXapFwn0LBS7iKqjVBreRbv2siySAKxEhMJiAiHmaWP8vFsgfyaQHK+TnjqQwiNlN/JeOnh8NF7+tyQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "7.26.2", - "@rsdoctor/types": "1.5.9", + "@rsdoctor/types": "1.6.5", "@types/estree": "1.0.5", "acorn": "^8.10.0", "acorn-import-attributes": "^1.9.5", @@ -6326,8 +6222,49 @@ "json-stream-stringify": "3.0.1", "lines-and-columns": "2.0.4", "picocolors": "^1.1.1", - "rslog": "^1.3.2", - "strip-ansi": "^6.0.1" + "rslog": "^2.1.2", + "strip-ansi": "^7.2.0" + } + }, + "node_modules/@rsdoctor/utils/node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@rsdoctor/utils/node_modules/ansi-regex": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.4.0.tgz", + "integrity": "sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@rsdoctor/utils/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, "node_modules/@rspack/binding": { @@ -6478,24 +6415,6 @@ "win32" ] }, - "node_modules/@rspack/cli": { - "version": "1.7.11", - "resolved": "https://registry.npmjs.org/@rspack/cli/-/cli-1.7.11.tgz", - "integrity": "sha512-vUnflkq4F654wTEpCd+L4RYVbet8L2lNqLMmAGIZvoZddlXm4Duvg+eqcFE9iF8plAjFflRcU7DhB7WZa76pwg==", - "license": "MIT", - "dependencies": { - "@discoveryjs/json-ext": "^0.5.7", - "@rspack/dev-server": "~1.1.5", - "exit-hook": "^4.0.0", - "webpack-bundle-analyzer": "4.10.2" - }, - "bin": { - "rspack": "bin/rspack.js" - }, - "peerDependencies": { - "@rspack/core": "^1.0.0-alpha || ^1.x" - } - }, "node_modules/@rspack/core": { "version": "1.7.11", "resolved": "https://registry.npmjs.org/@rspack/core/-/core-1.7.11.tgz", @@ -6518,25 +6437,6 @@ } } }, - "node_modules/@rspack/dev-server": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rspack/dev-server/-/dev-server-1.1.5.tgz", - "integrity": "sha512-cwz0qc6iqqoJhyWqxP7ZqE2wyYNHkBMQUXxoQ0tNoZ4YNRkDyQ4HVJ/3oPSmMKbvJk/iJ16u7xZmwG6sK47q/A==", - "license": "MIT", - "dependencies": { - "chokidar": "^3.6.0", - "http-proxy-middleware": "^2.0.9", - "p-retry": "^6.2.0", - "webpack-dev-server": "5.2.2", - "ws": "^8.18.0" - }, - "engines": { - "node": ">= 18.12.0" - }, - "peerDependencies": { - "@rspack/core": "*" - } - }, "node_modules/@rspack/lite-tapable": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rspack/lite-tapable/-/lite-tapable-1.1.0.tgz", @@ -6656,21 +6556,24 @@ } }, "node_modules/@rspack/resolver-binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.6.tgz", + "integrity": "sha512-mRyDmEw9/fCDXCGfL1sbgIw/GUK0PVxRW4YwgPN61F3LQ+fSyoBFarJVzgGyhF6kRzEQZ0FejDqm4WLXSeqQmw==", "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" }, "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" + "@emnapi/core": "^1.7.1 || 2.0.0-beta.2", + "@emnapi/runtime": "^1.7.1 || 2.0.0-beta.2" } }, "node_modules/@rspack/resolver-binding-win32-arm64-msvc": { @@ -7646,9 +7549,9 @@ "license": "MIT" }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz", + "integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==", "license": "MIT", "optional": true, "dependencies": { @@ -7715,25 +7618,6 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/bonjour": { - "version": "3.5.13", - "resolved": "https://registry.npmjs.org/@types/bonjour/-/bonjour-3.5.13.tgz", - "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/chrome": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@types/chrome/-/chrome-0.3.5.tgz", @@ -7753,16 +7637,6 @@ "@types/node": "*" } }, - "node_modules/@types/connect-history-api-fallback": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.5.4.tgz", - "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", - "license": "MIT", - "dependencies": { - "@types/express-serve-static-core": "*", - "@types/node": "*" - } - }, "node_modules/@types/cors": { "version": "2.8.19", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", @@ -7800,30 +7674,6 @@ "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "license": "MIT" }, - "node_modules/@types/express": { - "version": "4.17.25", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", - "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "^1" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "4.19.8", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", - "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, "node_modules/@types/filesystem": { "version": "0.0.36", "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.36.tgz", @@ -7845,26 +7695,11 @@ "integrity": "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A==", "license": "MIT" }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "license": "MIT" - }, - "node_modules/@types/http-proxy": { - "version": "1.17.17", - "resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz", - "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/istanbul-lib-coverage": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", - "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", - "dev": true, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-report": { @@ -7985,12 +7820,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "license": "MIT" - }, "node_modules/@types/node": { "version": "22.19.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", @@ -8000,15 +7829,6 @@ "undici-types": "~6.21.0" } }, - "node_modules/@types/node-forge": { - "version": "1.3.14", - "resolved": "https://registry.npmjs.org/@types/node-forge/-/node-forge-1.3.14.tgz", - "integrity": "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/normalize-package-data": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.4.tgz", @@ -8030,18 +7850,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/qs": { - "version": "6.15.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", - "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "license": "MIT" - }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -8062,12 +7870,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/retry": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.2.tgz", - "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", - "license": "MIT" - }, "node_modules/@types/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.7.1.tgz", @@ -8075,54 +7877,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", - "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/serve-index": { - "version": "1.9.4", - "resolved": "https://registry.npmjs.org/@types/serve-index/-/serve-index-1.9.4.tgz", - "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", - "license": "MIT", - "dependencies": { - "@types/express": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.10", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", - "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "<1" - } - }, - "node_modules/@types/serve-static/node_modules/@types/send": { - "version": "0.17.6", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", - "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/sockjs": { - "version": "0.3.36", - "resolved": "https://registry.npmjs.org/@types/sockjs/-/sockjs-0.3.36.tgz", - "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -8829,22 +8583,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ansi-html-community": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz", - "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", - "engines": [ - "node >= 0.8.0" - ], - "license": "Apache-2.0", - "bin": { - "ansi-html": "bin/ansi-html" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8877,6 +8620,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -8913,12 +8657,6 @@ "dequal": "^2.0.3" } }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, "node_modules/array-ify": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/array-ify/-/array-ify-1.0.0.tgz", @@ -9135,9 +8873,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.25", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.25.tgz", - "integrity": "sha512-QO/VHsXCQdnzADMfmkeOPvHdIAkoB7i0/rGjINPJEetLx75hNttVWGQ/jycHUDP9zZ9rupbm60WRxcwViB0MiA==", + "version": "2.11.33", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.33.tgz", + "integrity": "sha512-yiVKDX+qCMRKBE084lXvq9EcwFUhKZ2nU8TK1EQQuOgPNR/lmbGxXOeDdlGUqC2Zz11Cs9rk1d/8NfgBmlkt0w==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -9156,12 +8894,6 @@ "node": ">=10.0.0" } }, - "node_modules/batch": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/batch/-/batch-0.6.1.tgz", - "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", - "license": "MIT" - }, "node_modules/before-after-hook": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", @@ -9192,6 +8924,7 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9200,82 +8933,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/body-parser": { - "version": "1.20.5", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", - "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/body-parser/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/bonjour-service": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/bonjour-service/-/bonjour-service-1.3.0.tgz", - "integrity": "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "multicast-dns": "^7.2.5" - } - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -9283,21 +8940,22 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -9307,9 +8965,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.29.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.4.tgz", + "integrity": "sha512-2TcbbiwXEq+N0kD36tykpCfyyQigUXmuwNQbnPp4Xk4PX9JOqVIKjJlIDjpH7uY6tmRGoAd+apeT1p60T8wsdA==", "funding": [ { "type": "opencollective", @@ -9326,11 +8984,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.29", + "caniuse-lite": "^1.0.30001816", + "electron-to-chromium": "^1.5.459", + "node-releases": "^2.0.59", + "update-browserslist-db": "^1.3.4" }, "bin": { "browserslist": "cli.js" @@ -9340,18 +8998,18 @@ } }, "node_modules/browserslist-load-config": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/browserslist-load-config/-/browserslist-load-config-1.0.1.tgz", - "integrity": "sha512-orLR5HAoQugQNVUXUwNd+GAAwl3H64KLIwoMFBNW0AbMSqX2Lxs4ZV2/5UoNrVQlQqF9ygychiu7Svr/99bLtg==", + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/browserslist-load-config/-/browserslist-load-config-1.0.4.tgz", + "integrity": "sha512-5Naou4DgM5JyU3lubTHD+UWIvwpjxVAAxaGvh5DV4rDNEEwzAt26Z0XfrVwXOdQSJPs8OPwiDwZ9Sq/oew7U6w==", "license": "MIT" }, "node_modules/browserslist-to-es-version": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/browserslist-to-es-version/-/browserslist-to-es-version-1.4.1.tgz", - "integrity": "sha512-1bYCrck5Qh5HUy7P+iDuK39v757/ry5PnQo20vf4sHGeUrYKL2N2OF05U9ARSGt06TpFDQiTv9MT+eitYgWWxA==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/browserslist-to-es-version/-/browserslist-to-es-version-1.4.2.tgz", + "integrity": "sha512-3NV13pCv0wmPxxZZcekHAG6vt8rQ94w2c4/UBe3ZU3NDUm5TP+QFK3rjS6XeKWSHWpnPYNfQzlhnljka0BrEOA==", "license": "MIT", "dependencies": { - "browserslist": "^4.28.1" + "browserslist": "^4.28.2" }, "bin": { "browserslist-to-es-version": "dist/cli.js" @@ -9377,6 +9035,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, "license": "MIT", "dependencies": { "run-applescript": "^7.0.0" @@ -9404,15 +9063,6 @@ "esbuild": ">=0.18" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/c12": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", @@ -9490,35 +9140,6 @@ "node": ">=8" } }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -9541,9 +9162,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001791", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", - "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "version": "1.0.30001819", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001819.tgz", + "integrity": "sha512-LFUqIIAwpzsIuevnKnsR9qnStBcgzPShTX61lBwN9uFkkf8o8zLiebEjbW8AYtZiF+1YKDWfdt/pWDtMuG8Xwg==", "funding": [ { "type": "opencollective", @@ -9611,6 +9232,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, "license": "MIT", "dependencies": { "anymatch": "~3.1.2", @@ -9874,12 +9496,6 @@ "dev": true, "license": "MIT" }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "license": "MIT" - }, "node_modules/commander": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", @@ -9900,60 +9516,6 @@ "dot-prop": "^5.1.0" } }, - "node_modules/compressible": { - "version": "2.0.18", - "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", - "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", - "license": "MIT", - "dependencies": { - "mime-db": ">= 1.43.0 < 2" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/compression": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", - "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", - "license": "MIT", - "dependencies": { - "bytes": "3.1.2", - "compressible": "~2.0.18", - "debug": "2.6.9", - "negotiator": "~0.6.4", - "on-headers": "~1.1.0", - "safe-buffer": "5.2.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/compression/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/compression/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/compression/node_modules/negotiator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", - "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", @@ -9976,15 +9538,6 @@ "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "license": "MIT" }, - "node_modules/connect-history-api-fallback": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz", - "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", @@ -9994,27 +9547,6 @@ "node": "^14.18.0 || >=16.10.0" } }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/conventional-changelog": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/conventional-changelog/-/conventional-changelog-7.2.0.tgz", @@ -10157,12 +9689,6 @@ "node": ">= 0.6" } }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, "node_modules/core-js-compat": { "version": "3.49.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", @@ -10176,12 +9702,6 @@ "url": "https://opencollective.com/core-js" } }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, "node_modules/cors": { "version": "2.8.6", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", @@ -10545,6 +10065,7 @@ "version": "5.5.0", "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "dev": true, "license": "MIT", "dependencies": { "bundle-name": "^4.1.0", @@ -10561,6 +10082,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -10573,6 +10095,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -10602,15 +10125,6 @@ "node": ">= 14" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -10626,16 +10140,6 @@ "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", "license": "MIT" }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -10656,12 +10160,6 @@ "node": ">=8" } }, - "node_modules/detect-node": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz", - "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", - "license": "MIT" - }, "node_modules/diff": { "version": "8.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", @@ -10705,18 +10203,6 @@ "node": ">=8" } }, - "node_modules/dns-packet": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz", - "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", - "license": "MIT", - "dependencies": { - "@leichtgewicht/ip-codec": "^2.0.1" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -10816,36 +10302,10 @@ "url": "https://dotenvx.com" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/duplexer": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/duplexer/-/duplexer-0.1.2.tgz", - "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", - "license": "MIT" - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, "node_modules/electron-to-chromium": { - "version": "1.5.349", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", - "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", + "version": "1.5.464", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.464.tgz", + "integrity": "sha512-nRa65aFge40viPAKxMKULIY8tcXKwKboJWNn4RoKwyWXJ6SP7BnrDGKUTzc64818jIciH0PK7LSF8QzZes19GA==", "license": "ISC" }, "node_modules/emittery": { @@ -10877,31 +10337,21 @@ "node": ">= 4" } }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/engine.io": { - "version": "6.6.7", - "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.7.tgz", - "integrity": "sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ==", + "version": "6.6.11", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.11.tgz", + "integrity": "sha512-z34mGpLoY/82ZbkX1LqGMIaf3wjVMPL6gVC681QDqnmzJ26synI8qAE32P+a7UazmpJZ8UGrFIeAavgRfXzYrg==", "license": "MIT", "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "@types/ws": "^8.5.12", "accepts": "~1.3.4", - "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", - "ws": "~8.18.3" + "ws": "~8.21.0" }, "engines": { "node": ">=10.2.0" @@ -10917,9 +10367,9 @@ } }, "node_modules/engine.io/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -10942,7 +10392,6 @@ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" @@ -10951,20 +10400,6 @@ "node": ">=10.13.0" } }, - "node_modules/enhanced-resolve/node_modules/tapable": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", - "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -11008,15 +10443,6 @@ "is-arrayish": "^0.2.1" } }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -11033,26 +10459,16 @@ "license": "MIT", "peer": true }, - "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/es-toolkit": { - "version": "1.46.1", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.46.1.tgz", - "integrity": "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ==", + "version": "1.53.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.53.0.tgz", + "integrity": "sha512-3+AYXp9fmJ/UhHBMYj2/U+RaUouMD90SzoItyDOB2Za7W6dB7fQApFGnZPl2MF6K8/Tv1koh1JyirSx/kju6Pw==", "license": "MIT", "workspaces": [ "docs", - "benchmarks" + "benchmarks", + "tests/types", + "tests/browser-compat" ] }, "node_modules/esbuild": { @@ -11140,12 +10556,6 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, "node_modules/escape-string-regexp": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", @@ -11271,21 +10681,6 @@ "url": "https://github.com/bgub/eta?sponsor=1" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "license": "MIT" - }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -11320,18 +10715,6 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/exit-hook": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-4.0.0.tgz", - "integrity": "sha512-Fqs7ChZm72y40wKjOFXBKg7nJZvQJmewP5/7LtePDdnah/+FH9Hp5sgMujSCMPXlxOAW2//1jrW9pnsY7o20vQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/exit-x": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", @@ -11359,67 +10742,6 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/express": { - "version": "4.22.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", - "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.3", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.14.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -11473,9 +10795,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz", + "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==", "funding": [ { "type": "github", @@ -11498,18 +10820,6 @@ "reusify": "^1.0.4" } }, - "node_modules/faye-websocket": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", - "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", - "license": "Apache-2.0", - "dependencies": { - "websocket-driver": ">=0.5.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -11551,18 +10861,19 @@ } }, "node_modules/filesize": { - "version": "10.1.6", - "resolved": "https://registry.npmjs.org/filesize/-/filesize-10.1.6.tgz", - "integrity": "sha512-sJslQKU2uM33qH5nqewAwVB2QgR6w1aMNsYUp3aN5rMRyXEwJGmZvaWzeJFNTOXWlHQyBFCWrdj3fV/fsTOX8w==", + "version": "11.0.29", + "resolved": "https://registry.npmjs.org/filesize/-/filesize-11.0.29.tgz", + "integrity": "sha512-+Mkmp8keoklLzAnvP/RQGDUEWEpjGmhvm4d4U739C1ZnkqxqoSqdLHnjv9/4QX6lltKSRk6qzQI7+94JVDtuCA==", "license": "BSD-3-Clause", "engines": { - "node": ">= 10.4.0" + "node": ">= 10.8.0" } }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -11571,39 +10882,6 @@ "node": ">=8" } }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -11639,44 +10917,6 @@ "flat": "cli.js" } }, - "node_modules/follow-redirects": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/RubenVerborgh" - } - ], - "license": "MIT", - "engines": { - "node": ">=4.0" - }, - "peerDependenciesMeta": { - "debug": { - "optional": true - } - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/fs-extra": { "version": "11.3.4", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.4.tgz", @@ -11695,6 +10935,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -11746,30 +10987,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/get-package-type": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", @@ -11792,19 +11009,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -11920,6 +11124,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -11929,9 +11134,10 @@ } }, "node_modules/glob-to-regex.js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz", - "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/glob-to-regex.js/-/glob-to-regex.js-1.3.1.tgz", + "integrity": "sha512-zhWhMRsgnOym+1txhUmYJGD6eUsbUdrZv55gCYKuJX8t2p3+M8My23PD09bEIsreGUs5pnSa/idmcfp2/IlqGg==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -11988,53 +11194,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, - "node_modules/gzip-size": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/gzip-size/-/gzip-size-6.0.0.tgz", - "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", - "license": "MIT", - "dependencies": { - "duplexer": "^0.1.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/handle-thing": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.1.tgz", - "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", - "license": "MIT" - }, "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "version": "4.7.10", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz", + "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==", "dev": true, "license": "MIT", "dependencies": { - "minimist": "^1.2.5", + "minimist": "^1.2.8", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" @@ -12068,18 +11241,6 @@ "node": ">=8" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/hasown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", @@ -12112,48 +11273,6 @@ "dev": true, "license": "ISC" }, - "node_modules/hpack.js": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/hpack.js/-/hpack.js-2.1.6.tgz", - "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "obuf": "^1.0.0", - "readable-stream": "^2.0.1", - "wbuf": "^1.1.0" - } - }, - "node_modules/hpack.js/node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/hpack.js/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/hpack.js/node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -12171,6 +11290,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, "license": "MIT" }, "node_modules/htmlparser2": { @@ -12204,52 +11324,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/http-deceiver": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/http-deceiver/-/http-deceiver-1.2.7.tgz", - "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", - "license": "MIT" - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/http-parser-js": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz", - "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", - "license": "MIT" - }, - "node_modules/http-proxy": { - "version": "1.18.1", - "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", - "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", - "license": "MIT", - "dependencies": { - "eventemitter3": "^4.0.0", - "follow-redirects": "^1.0.0", - "requires-port": "^1.0.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -12264,42 +11338,6 @@ "node": ">= 14" } }, - "node_modules/http-proxy-middleware": { - "version": "2.0.9", - "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz", - "integrity": "sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q==", - "license": "MIT", - "dependencies": { - "@types/http-proxy": "^1.17.8", - "http-proxy": "^1.18.1", - "is-glob": "^4.0.1", - "is-plain-obj": "^3.0.0", - "micromatch": "^4.0.2" - }, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "@types/express": "^4.17.13" - }, - "peerDependenciesMeta": { - "@types/express": { - "optional": true - } - } - }, - "node_modules/http-proxy-middleware/node_modules/is-plain-obj": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz", - "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -12344,6 +11382,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/hyperdyperid/-/hyperdyperid-1.2.0.tgz", "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.18" @@ -12389,9 +11428,9 @@ } }, "node_modules/immutable": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", - "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", "license": "MIT" }, "node_modules/import-fresh": { @@ -12484,6 +11523,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -12524,24 +11564,15 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.7.5", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.5.tgz", + "integrity": "sha512-t2v0rkILYv9WXmSL7TpAaU3HXgaFYOPUcH3QHgwQ++gpJuH3bqyMy4Fvt/DJCTKwzH3Cf+bAp5W/mFL39KPLzA==", "dev": true, "license": "MIT", "engines": { "node": ">= 12" } }, - "node_modules/ipaddr.js": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", - "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -12552,6 +11583,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -12579,6 +11611,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, "license": "MIT", "bin": { "is-docker": "cli.js" @@ -12594,6 +11627,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -12623,6 +11657,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "devOptional": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -12635,6 +11670,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, "license": "MIT", "dependencies": { "is-docker": "^3.0.0" @@ -12662,22 +11698,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-network-error": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz", - "integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==", - "license": "MIT", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -12765,6 +11790,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "dev": true, "license": "MIT", "dependencies": { "is-inside-container": "^1.0.0" @@ -12776,12 +11802,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -13004,21 +12024,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-circus/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/jest-circus/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", @@ -14034,25 +13039,10 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-runner/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/jest-runner/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "node_modules/jest-runner/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", "engines": { @@ -14180,21 +13170,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-runtime/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/jest-runtime/node_modules/ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", @@ -14325,21 +13300,6 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-snapshot/node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/jest-snapshot/node_modules/@jest/expect-utils": { "version": "30.3.0", "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", @@ -14757,9 +13717,19 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", - "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -14927,13 +13897,13 @@ } }, "node_modules/launch-editor": { - "version": "2.13.2", - "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.2.tgz", - "integrity": "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==", + "version": "2.14.2", + "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.14.2.tgz", + "integrity": "sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ==", "license": "MIT", "dependencies": { "picocolors": "^1.1.1", - "shell-quote": "^1.8.3" + "shell-quote": "^1.10.0" } }, "node_modules/leven": { @@ -15193,15 +14163,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -15209,19 +14170,11 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/memfs": { "version": "4.57.2", "resolved": "https://registry.npmjs.org/memfs/-/memfs-4.57.2.tgz", "integrity": "sha512-2nWzSsJzrukurSDna4Z0WywuScK4Id3tSKejgu74u8KCdW4uNrseKRSIDg75C6Yw5ZRqBe0F0EtMNlTbUq8bAQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@jsonjoy.com/fs-core": "4.57.2", @@ -15260,15 +14213,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -15285,19 +14229,11 @@ "node": ">= 8" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -15307,18 +14243,6 @@ "node": ">=8.6" } }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -15332,6 +14256,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "dev": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -15433,12 +14358,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC" - }, "node_modules/minimatch": { "version": "10.2.3", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.3.tgz", @@ -15504,34 +14423,12 @@ "pathe": "^2.0.1" } }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/multicast-dns": { - "version": "7.2.5", - "resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz", - "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", - "license": "MIT", - "dependencies": { - "dns-packet": "^5.2.2", - "thunky": "^1.0.2" - }, - "bin": { - "multicast-dns": "cli.js" - } - }, "node_modules/mute-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", @@ -15569,9 +14466,9 @@ } }, "node_modules/nanoid": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", - "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", "funding": [ { "type": "github", @@ -15687,15 +14584,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-forge": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz", - "integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==", - "license": "(BSD-3-Clause OR GPL-2.0)", - "engines": { - "node": ">= 6.13.0" - } - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -15704,10 +14592,13 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.38", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", - "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", - "license": "MIT" + "version": "2.0.60", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.60.tgz", + "integrity": "sha512-OmO8+GM1JUZqXET720VrJCwkjUCXmCMQm0Z3UDDRbfNLHS4NEAvY60j5jiFv0tkKw2PNNlqOLhahVmwJIOLDIQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-package-data": { "version": "7.0.1", @@ -15728,6 +14619,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -15792,51 +14684,12 @@ "node": ">=0.10.0" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/obuf": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/obuf/-/obuf-1.1.2.tgz", - "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", - "license": "MIT" - }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", "license": "MIT" }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/on-headers": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", - "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/onetime": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", @@ -15857,6 +14710,7 @@ "version": "10.2.0", "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, "license": "MIT", "dependencies": { "default-browser": "^5.2.1", @@ -15871,15 +14725,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/opener": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", - "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", - "license": "(WTFPL OR MIT)", - "bin": { - "opener": "bin/opener-bin.js" - } - }, "node_modules/ora": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/ora/-/ora-9.0.0.tgz", @@ -16008,23 +14853,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-retry": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-6.2.1.tgz", - "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", - "license": "MIT", - "dependencies": { - "@types/retry": "0.12.2", - "is-network-error": "^1.0.0", - "retry": "^0.13.1" - }, - "engines": { - "node": ">=16.17" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -16155,15 +14983,6 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/path-browserify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", @@ -16221,12 +15040,6 @@ "node": "20 || >=22" } }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, "node_modules/path-type": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-6.0.0.tgz", @@ -16262,6 +15075,7 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -16327,9 +15141,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", "funding": [ { "type": "opencollective", @@ -16346,9 +15160,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -16483,9 +15297,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "version": "7.1.7", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.7.tgz", + "integrity": "sha512-wWBro/7hJ40ye6TPiXkUQoXKCw23xSsjriUvqd7Uls9Sso3oLiON1flc18cCdnjgCRXw4fBVqUJJeOdDTh3P3g==", "license": "MIT", "dependencies": { "cssesc": "^3.0.0", @@ -16502,9 +15316,9 @@ "license": "MIT" }, "node_modules/postcss/node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -16565,12 +15379,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, "node_modules/protocols": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/protocols/-/protocols-2.0.2.tgz", @@ -16578,28 +15386,6 @@ "dev": true, "license": "MIT" }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-addr/node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, "node_modules/proxy-agent": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", @@ -16663,21 +15449,6 @@ ], "license": "MIT" }, - "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/queue-lit": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz", @@ -16709,42 +15480,6 @@ ], "license": "MIT" }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/rc9": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", @@ -16788,6 +15523,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -16802,6 +15538,7 @@ "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, "license": "MIT", "dependencies": { "picomatch": "^2.2.1" @@ -17064,12 +15801,6 @@ "node": ">=0.10.0" } }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "license": "MIT" - }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -17205,6 +15936,7 @@ "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -17274,10 +16006,13 @@ "license": "MIT" }, "node_modules/rslog": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/rslog/-/rslog-1.3.2.tgz", - "integrity": "sha512-1YyYXBvN0a2b1MSIDLwDTqqgjDzRKxUg/S/+KO6EAgbtZW1B3fdLHAMhEEtvk1patJYMqcRvlp3HQwnxj7AdGQ==", - "license": "MIT" + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/rslog/-/rslog-2.3.0.tgz", + "integrity": "sha512-g2wW/ermwzGTLlzGdJBDRc4YKz2/yPBjf57w9g5CvhtpZ91Xq95OaWku0oNHYi+XsuV6v8QrCo2oJJR/pCUR8g==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } }, "node_modules/rspack-plugin-virtual-module": { "version": "1.0.1", @@ -17292,6 +16027,7 @@ "version": "7.1.0", "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -17348,6 +16084,7 @@ "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, "funding": [ { "type": "github", @@ -17535,25 +16272,6 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "license": "MIT" }, - "node_modules/select-hose": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz", - "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", - "license": "MIT" - }, - "node_modules/selfsigned": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/selfsigned/-/selfsigned-2.4.1.tgz", - "integrity": "sha512-th5B4L2U+eGLq1TVh7zNRGBapioSORUeymIydxgFpwww9d2qyKvtuPU2jJuHvYAwwqi2Y596QBL3eEqcPEYL8Q==", - "license": "MIT", - "dependencies": { - "@types/node-forge": "^1.3.0", - "node-forge": "^1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/semver": { "version": "7.7.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", @@ -17566,270 +16284,46 @@ "node": ">=10" } }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" + "kind-of": "^6.0.2" }, "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" + "node": ">=8" } }, - "node_modules/send/node_modules/debug/node_modules/ms": { + "node_modules/shebang-command": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/serve-index": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/serve-index/-/serve-index-1.9.2.tgz", - "integrity": "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { - "accepts": "~1.3.8", - "batch": "0.6.1", - "debug": "2.6.9", - "escape-html": "~1.0.3", - "http-errors": "~1.8.0", - "mime-types": "~2.1.35", - "parseurl": "~1.3.3" + "shebang-regex": "^3.0.0" }, "engines": { - "node": ">= 0.8.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" + "node": ">=8" } }, - "node_modules/serve-index/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", - "dependencies": { - "ms": "2.0.0" + "engines": { + "node": ">=8" } }, - "node_modules/serve-index/node_modules/depd": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz", - "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "node_modules/shell-quote": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.12.0.tgz", + "integrity": "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q==", "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/http-errors": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-1.8.1.tgz", - "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", - "license": "MIT", - "dependencies": { - "depd": "~1.1.2", - "inherits": "2.0.4", - "setprototypeof": "1.2.0", - "statuses": ">= 1.5.0 < 2", - "toidentifier": "1.0.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-index/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/serve-index/node_modules/statuses": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.5.0.tgz", - "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/shallow-clone": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", - "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", - "license": "MIT", - "dependencies": { - "kind-of": "^6.0.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, "engines": { "node": ">= 0.4" }, @@ -17844,20 +16338,6 @@ "dev": true, "license": "ISC" }, - "node_modules/sirv": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/sirv/-/sirv-2.0.4.tgz", - "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", - "license": "MIT", - "dependencies": { - "@polka/url": "^1.0.0-next.24", - "mrmime": "^2.0.0", - "totalist": "^3.0.0" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/slash": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", @@ -17911,19 +16391,19 @@ } }, "node_modules/socket.io-adapter": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", - "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", "license": "MIT", "dependencies": { "debug": "~4.4.1", - "ws": "~8.18.3" + "ws": "~8.21.0" } }, "node_modules/socket.io-adapter/node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -17942,9 +16422,9 @@ } }, "node_modules/socket.io-parser": { - "version": "4.2.6", - "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz", - "integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==", + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", @@ -17971,17 +16451,6 @@ } } }, - "node_modules/sockjs": { - "version": "0.3.24", - "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", - "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", - "license": "MIT", - "dependencies": { - "faye-websocket": "^0.11.3", - "uuid": "^8.3.2", - "websocket-driver": "^0.7.4" - } - }, "node_modules/socks": { "version": "2.8.8", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.8.tgz", @@ -18022,9 +16491,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -18087,36 +16556,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/spdy": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/spdy/-/spdy-4.0.2.tgz", - "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", - "license": "MIT", - "dependencies": { - "debug": "^4.1.0", - "handle-thing": "^2.0.0", - "http-deceiver": "^1.2.7", - "select-hose": "^2.0.0", - "spdy-transport": "^3.0.0" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/spdy-transport": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/spdy-transport/-/spdy-transport-3.0.0.tgz", - "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", - "license": "MIT", - "dependencies": { - "debug": "^4.1.0", - "detect-node": "^2.0.4", - "hpack.js": "^2.1.6", - "obuf": "^1.1.2", - "readable-stream": "^3.0.6", - "wbuf": "^1.7.3" - } - }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -18137,15 +16576,6 @@ "node": ">=10" } }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -18163,6 +16593,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -18242,6 +16673,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -18357,9 +16789,9 @@ "license": "MIT" }, "node_modules/svgo": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.3.tgz", - "integrity": "sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.5.tgz", + "integrity": "sha512-8SQMzdrvWaD8deUmrnYB+ASyxBVgWUOilg+A75nE/76WdLpj6LopCwiAVvkzkcqy/9b7t2Mg7faFLjg0ZRcZ3w==", "license": "MIT", "dependencies": { "commander": "^7.2.0", @@ -18424,9 +16856,9 @@ } }, "node_modules/tapable": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", - "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "license": "MIT", "engines": { "node": ">=6" @@ -18609,9 +17041,10 @@ } }, "node_modules/thingies": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.0.tgz", - "integrity": "sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/thingies/-/thingies-2.6.1.tgz", + "integrity": "sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.18" @@ -18624,12 +17057,6 @@ "tslib": "^2" } }, - "node_modules/thunky": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz", - "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", - "license": "MIT" - }, "node_modules/tinyexec": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", @@ -18719,6 +17146,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -18727,24 +17155,6 @@ "node": ">=8.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/totalist": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", - "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", @@ -18775,6 +17185,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/tree-dump/-/tree-dump-1.1.0.tgz", "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.0" @@ -19535,14 +17946,13 @@ "license": "MIT" }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.23.15", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz", + "integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", - "get-tsconfig": "^4.7.5" + "esbuild": "~0.28.0" }, "bin": { "tsx": "dist/cli.mjs" @@ -19555,9 +17965,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -19572,9 +17982,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -19589,9 +17999,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -19606,9 +18016,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -19623,9 +18033,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -19640,9 +18050,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -19657,9 +18067,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -19674,9 +18084,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -19691,9 +18101,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -19708,9 +18118,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -19725,9 +18135,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -19742,9 +18152,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -19759,9 +18169,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -19776,9 +18186,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -19793,9 +18203,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -19810,9 +18220,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -19827,9 +18237,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -19844,9 +18254,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", "cpu": [ "arm64" ], @@ -19861,9 +18271,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -19878,9 +18288,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -19895,9 +18305,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -19912,9 +18322,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -19929,9 +18339,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -19946,9 +18356,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -19963,9 +18373,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -19980,9 +18390,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -19997,9 +18407,9 @@ } }, "node_modules/tsx/node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -20010,32 +18420,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/type-detect": { @@ -20059,40 +18469,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/type-is/node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/type-is/node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -20134,9 +18510,9 @@ } }, "node_modules/undici": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", - "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", + "version": "6.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.29.1.tgz", + "integrity": "sha512-EvRlUt/QqAEvXdQeRMYUfwOQsfzpDztykrG2owGEE/rBOBoYv9uF39ZzsNDh4lTFHhTr2XOZjKkYgueD12ZmGA==", "dev": true, "license": "MIT", "engines": { @@ -20218,15 +18594,6 @@ "node": ">= 10.0.0" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/unrs-resolver": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", @@ -20263,9 +18630,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", "funding": [ { "type": "opencollective", @@ -20327,25 +18694,6 @@ "node": ">= 4" } }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -20435,15 +18783,6 @@ "node": ">=10.13.0" } }, - "node_modules/wbuf": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/wbuf/-/wbuf-1.7.3.tgz", - "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", - "license": "MIT", - "dependencies": { - "minimalistic-assert": "^1.0.0" - } - }, "node_modules/webidl-conversions": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", @@ -20502,229 +18841,6 @@ } } }, - "node_modules/webpack-bundle-analyzer": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.10.2.tgz", - "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==", - "license": "MIT", - "dependencies": { - "@discoveryjs/json-ext": "0.5.7", - "acorn": "^8.0.4", - "acorn-walk": "^8.0.0", - "commander": "^7.2.0", - "debounce": "^1.2.1", - "escape-string-regexp": "^4.0.0", - "gzip-size": "^6.0.0", - "html-escaper": "^2.0.2", - "opener": "^1.5.2", - "picocolors": "^1.0.0", - "sirv": "^2.0.3", - "ws": "^7.3.1" - }, - "bin": { - "webpack-bundle-analyzer": "lib/bin/analyzer.js" - }, - "engines": { - "node": ">= 10.13.0" - } - }, - "node_modules/webpack-bundle-analyzer/node_modules/debounce": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", - "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", - "license": "MIT" - }, - "node_modules/webpack-bundle-analyzer/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/webpack-bundle-analyzer/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", - "license": "MIT", - "engines": { - "node": ">=8.3.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/webpack-dev-middleware": { - "version": "7.4.5", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-7.4.5.tgz", - "integrity": "sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA==", - "license": "MIT", - "dependencies": { - "colorette": "^2.0.10", - "memfs": "^4.43.1", - "mime-types": "^3.0.1", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "schema-utils": "^4.0.0" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - } - } - }, - "node_modules/webpack-dev-middleware/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/webpack-dev-middleware/node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, - "node_modules/webpack-dev-server": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-5.2.2.tgz", - "integrity": "sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg==", - "license": "MIT", - "dependencies": { - "@types/bonjour": "^3.5.13", - "@types/connect-history-api-fallback": "^1.5.4", - "@types/express": "^4.17.21", - "@types/express-serve-static-core": "^4.17.21", - "@types/serve-index": "^1.9.4", - "@types/serve-static": "^1.15.5", - "@types/sockjs": "^0.3.36", - "@types/ws": "^8.5.10", - "ansi-html-community": "^0.0.8", - "bonjour-service": "^1.2.1", - "chokidar": "^3.6.0", - "colorette": "^2.0.10", - "compression": "^1.7.4", - "connect-history-api-fallback": "^2.0.0", - "express": "^4.21.2", - "graceful-fs": "^4.2.6", - "http-proxy-middleware": "^2.0.9", - "ipaddr.js": "^2.1.0", - "launch-editor": "^2.6.1", - "open": "^10.0.3", - "p-retry": "^6.2.0", - "schema-utils": "^4.2.0", - "selfsigned": "^2.4.1", - "serve-index": "^1.9.1", - "sockjs": "^0.3.24", - "spdy": "^4.0.2", - "webpack-dev-middleware": "^7.4.2", - "ws": "^8.18.0" - }, - "bin": { - "webpack-dev-server": "bin/webpack-dev-server.js" - }, - "engines": { - "node": ">= 18.12.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "webpack": "^5.0.0" - }, - "peerDependenciesMeta": { - "webpack": { - "optional": true - }, - "webpack-cli": { - "optional": true - } - } - }, - "node_modules/webpack-dev-server/node_modules/ajv-formats": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", - "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/webpack-dev-server/node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.9", - "ajv": "^8.9.0", - "ajv-formats": "^2.1.1", - "ajv-keywords": "^5.1.0" - }, - "engines": { - "node": ">= 10.13.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - } - }, "node_modules/webpack-merge": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", @@ -20794,29 +18910,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/websocket-driver": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", - "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", - "license": "Apache-2.0", - "dependencies": { - "http-parser-js": ">=0.5.1", - "safe-buffer": ">=5.1.0", - "websocket-extensions": ">=0.1.1" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/websocket-extensions": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/websocket-extensions/-/websocket-extensions-0.1.4.tgz", - "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/whatwg-mimetype": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", @@ -21095,9 +19188,10 @@ } }, "node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -21119,6 +19213,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, "license": "MIT", "dependencies": { "is-wsl": "^3.1.0" diff --git a/package.json b/package.json index 2a446d4f..5c77d80a 100644 --- a/package.json +++ b/package.json @@ -136,8 +136,7 @@ "@addon-core/browser": "^0.12.0", "@addon-core/inject-script": "^0.5.1", "@addon-core/storage": "^0.8.3", - "@rsdoctor/rspack-plugin": "^1.5.1", - "@rspack/cli": "^1.7.5", + "@rsdoctor/rspack-plugin": "^1.5.16", "@rspack/core": "^1.7.5", "@rspack/lite-tapable": "^1.1.0", "@rspackjs/plugin-html-tags": "^1.0.0", @@ -148,13 +147,13 @@ "css-loader": "^7.1.2", "file-loader": "^6.2.0", "get-value": "^4.0.1", - "js-yaml": "^4.1.1", + "js-yaml": "^4.3.2", "json-stringify-deterministic": "^1.0.12", "lodash": "4.18.1", "mini-css-extract-plugin": "^2.9.2", - "nanoid": "^5.1.4", + "nanoid": "^5.1.16", "pluralize": "^8.0.0", - "postcss": "^8.5.15", + "postcss": "^8.5.23", "postcss-scss": "^4.0.9", "postcss-value-parser": "^4.2.0", "resolve-url-loader": "^5.0.0", @@ -201,6 +200,7 @@ "istanbul-reports": "^3.2.0", "jest": "^30.2.0", "jest-environment-jsdom": "^30.0.0", + "memfs": "4.57.2", "prettier": "^3.6.2", "react": "^19.2.8", "react-dom": "^19.2.8", @@ -240,6 +240,9 @@ "glob": "$glob", "jsdom": "27.4.0", "test-exclude": "^7.0.1", + "release-it": { + "undici": "6.29.1" + }, "tsup": { "source-map": "^0.7.4" } diff --git a/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.test.ts b/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.test.ts index a5314865..123a1b4e 100644 --- a/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.test.ts +++ b/src/cli/bundler/plugins/isolated-styles/IsolatedStylesPlugin.test.ts @@ -866,6 +866,7 @@ test("switches the selected CSS runtime from shadow to normal and back during wa const watchFixtures = path.join(temporaryRoot, "fixtures"); const outputPath = path.join(temporaryRoot, "dist"); fs.cpSync(fixtures, watchFixtures, {recursive: true}); + fs.writeFileSync(path.join(watchFixtures, "package.json"), "{}\n"); fs.writeFileSync(path.join(watchFixtures, "watch-state.js"), "globalThis.shadowWatchState = true;\n"); fs.writeFileSync( path.join(watchFixtures, "shadow.js"), From 639300022b8f869e5c8c93ff497a5a917b4a0060 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:14:23 +0200 Subject: [PATCH 22/24] fix(types): install utility-types with the package Move utility-types to dependencies because the public declarations import it. Keep the existing version so clean consumer projects receive complete types without installing an undeclared development dependency. Validated strict consumer typechecks from the packed package on Node 22 and 24, including container callback inference with skipLibCheck disabled. --- package-lock.json | 5 ++--- package.json | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 05ea90af..6b3fb360 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "tsconfig-paths": "^4.2.0", "type-fest": "^4.37.0", "typescript": "^5.7.3", + "utility-types": "^3.11.0", "webpack-merge": "^6.0.1", "zod": "^3.24.2" }, @@ -89,8 +90,7 @@ "tsc-alias": "^1.8.17", "tsup": "^8.5.0", "tsx": "^4.19.2", - "uglify-js": "^3.19.3", - "utility-types": "^3.11.0" + "uglify-js": "^3.19.3" }, "engines": { "node": ">=22.12.0" @@ -18688,7 +18688,6 @@ "version": "3.11.0", "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 4" diff --git a/package.json b/package.json index 5c77d80a..f0b2c76c 100644 --- a/package.json +++ b/package.json @@ -164,6 +164,7 @@ "tsconfig-paths": "^4.2.0", "type-fest": "^4.37.0", "typescript": "^5.7.3", + "utility-types": "^3.11.0", "webpack-merge": "^6.0.1", "zod": "^3.24.2" }, @@ -209,8 +210,7 @@ "tsc-alias": "^1.8.17", "tsup": "^8.5.0", "tsx": "^4.19.2", - "uglify-js": "^3.19.3", - "utility-types": "^3.11.0" + "uglify-js": "^3.19.3" }, "optionalDependencies": { "@esbuild/darwin-arm64": "0.20.2" From b992c7bdc8d5b8c718851cfc64eceba64c228570 Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:14:34 +0200 Subject: [PATCH 23/24] fix(cli): resolve project paths and React dependencies Normalize root-relative filesystem paths before generating entrypoint imports and preserve JavaScript file extensions for ESM resolution. Resolve React packages from the project root and scheduler from react-dom, including ancestor and nested installations, while keeping React optional. Cover relative and absolute CLI/API roots, consecutive projects, watch updates, and JavaScript index entrypoints without increasing timeouts. Validated 2508 non-browser tests, builds and fixture typechecks on Node 22 and 24. Clean tarball installations pass 26 build scenarios per version, strict declaration checks, and separate React rendering checks in Chrome. --- src/cli/entrypoint/finder/AbstractFinder.ts | 5 +- src/cli/plugins/react.ts | 31 +++- src/cli/workspace/paths.ts | 2 +- .../cli/fixtures/root-dir-react/package.json | 10 ++ .../root-dir-react/src/react.page.tsx | 13 ++ .../cli/fixtures/root-dir-watch-values.ts | 2 + .../cli/fixtures/root-dir-watch.config.ts | 30 ++++ .../build/cli/root-dir.integration.test.ts | 157 ++++++++++++++++-- .../build/cli/scripts/verify-root-dir.mjs | 83 ++++++++- .../jsx/fixture/src/indexed.page/index.js | 6 + .../build/jsx/jsx.integration.test.ts | 1 + 11 files changed, 306 insertions(+), 34 deletions(-) create mode 100644 tests/integration/build/cli/fixtures/root-dir-react/package.json create mode 100644 tests/integration/build/cli/fixtures/root-dir-react/src/react.page.tsx create mode 100644 tests/integration/build/cli/fixtures/root-dir-watch-values.ts create mode 100644 tests/integration/build/cli/fixtures/root-dir-watch.config.ts create mode 100644 tests/integration/build/jsx/fixture/src/indexed.page/index.js diff --git a/src/cli/entrypoint/finder/AbstractFinder.ts b/src/cli/entrypoint/finder/AbstractFinder.ts index 94741629..17d52926 100644 --- a/src/cli/entrypoint/finder/AbstractFinder.ts +++ b/src/cli/entrypoint/finder/AbstractFinder.ts @@ -5,7 +5,7 @@ import {fileURLToPath} from "url"; import {compareFilePrecedence} from "./utils/filePrecedence"; -import {toPosixPath} from "@cli/utils/path"; +import {toPosix, toPosixPath} from "@cli/utils/path"; import {isFile} from "@cli/utils/fs"; import {getResolvePath, getSourcePath, resolveRootPath} from "@cli/workspace"; import {resolveAssetsPath, resolveEntrypointPath} from "@cli/entrypoint/utils"; @@ -59,7 +59,8 @@ export default abstract class implements EntrypointFinder { } protected file(filename: string): EntrypointFile { - return {file: filename, import: toPosixPath(filename)}; + // Absolute imports from JavaScript ESM entries must retain the file extension, including index files. + return {file: filename, import: toPosix(filename)}; } protected resolve(name: string, filename: string): EntrypointFile { diff --git a/src/cli/plugins/react.ts b/src/cli/plugins/react.ts index 6ecefbe5..f3bac959 100644 --- a/src/cli/plugins/react.ts +++ b/src/cli/plugins/react.ts @@ -1,22 +1,39 @@ import path from "path"; +import {createRequire} from "module"; import {Configuration as RspackConfig} from "@rspack/core"; import {definePlugin} from "@main/plugin"; -import {getResolvePath} from "@cli/workspace"; +import {resolveRootPath} from "@cli/workspace"; export default definePlugin(() => { return { name: "adnbn:react", - bundler: () => { + bundler: ({config}) => { + const projectRequire = createRequire(path.join(resolveRootPath(config), "package.json")); + const alias: Record = {}; + + for (const dependency of ["react", "react-dom", "scheduler"]) { + // Scheduler belongs to react-dom and may be installed in its own node_modules. + const dependencyRequire = + dependency === "scheduler" && alias["react-dom"] + ? createRequire(path.join(alias["react-dom"], "package.json")) + : projectRequire; + + try { + alias[dependency] = path.dirname(dependencyRequire.resolve(`${dependency}/package.json`)); + } catch (error) { + // React is optional; unused dependencies must not prevent vanilla builds. + if ((error as NodeJS.ErrnoException).code !== "MODULE_NOT_FOUND") { + throw error; + } + } + } + return { resolve: { extensions: [".js", ".jsx"], - alias: { - react: getResolvePath(path.join("node_modules", "react")), - "react-dom": getResolvePath(path.join("node_modules", "react-dom")), - scheduler: getResolvePath(path.join("node_modules", "scheduler")), - }, + alias, }, optimization: { splitChunks: { diff --git a/src/cli/workspace/paths.ts b/src/cli/workspace/paths.ts index 11db17e3..10977331 100644 --- a/src/cli/workspace/paths.ts +++ b/src/cli/workspace/paths.ts @@ -12,7 +12,7 @@ export const resolveRootPath = (config: ReadonlyConfig): string => { }; export const fromRootPath = (config: ReadonlyConfig, to?: string): string => { - return path.join(config.rootDir, to ?? ""); + return path.join(resolveRootPath(config), to ?? ""); }; export const getSourcePath = (config: ReadonlyConfig, to?: string): string => { diff --git a/tests/integration/build/cli/fixtures/root-dir-react/package.json b/tests/integration/build/cli/fixtures/root-dir-react/package.json new file mode 100644 index 00000000..a7c75add --- /dev/null +++ b/tests/integration/build/cli/fixtures/root-dir-react/package.json @@ -0,0 +1,10 @@ +{ + "name": "adnbn-test-react-root", + "private": true, + "type": "module", + "dependencies": { + "adnbn": "*", + "react": "*", + "react-dom": "*" + } +} diff --git a/tests/integration/build/cli/fixtures/root-dir-react/src/react.page.tsx b/tests/integration/build/cli/fixtures/root-dir-react/src/react.page.tsx new file mode 100644 index 00000000..9f4b2251 --- /dev/null +++ b/tests/integration/build/cli/fixtures/root-dir-react/src/react.page.tsx @@ -0,0 +1,13 @@ +import React, {useState} from "react"; +import {definePage} from "adnbn"; + +function RootPanel() { + const [count, setCount] = useState(0); + + return ; +} + +export default definePage({ + title: "React root page", + render: RootPanel, +}); diff --git a/tests/integration/build/cli/fixtures/root-dir-watch-values.ts b/tests/integration/build/cli/fixtures/root-dir-watch-values.ts new file mode 100644 index 00000000..5977276f --- /dev/null +++ b/tests/integration/build/cli/fixtures/root-dir-watch-values.ts @@ -0,0 +1,2 @@ +export const source = "https://root-a.example.com"; +export const markup = "
Root B runtime
"; diff --git a/tests/integration/build/cli/fixtures/root-dir-watch.config.ts b/tests/integration/build/cli/fixtures/root-dir-watch.config.ts new file mode 100644 index 00000000..3bfd4ed0 --- /dev/null +++ b/tests/integration/build/cli/fixtures/root-dir-watch.config.ts @@ -0,0 +1,30 @@ +import {defineConfig} from "adnbn"; +import path from "node:path"; +import type {Compiler} from "@rspack/core"; + +export default defineConfig({ + name: "Root Directory Watch Integration", + version: "1.0.0", + bundler: { + plugins: [ + (compiler: Compiler) => { + compiler.hooks.afterDone.tap("RootDirectoryWatch", stats => { + if (!compiler.watchMode || !process.send) { + return; + } + + const compilation = { + event: "root-directory-compiled", + errors: stats.hasErrors(), + modified: Array.from(compiler.modifiedFiles ?? [], filename => + path.relative(compiler.context, filename) + ), + }; + + // The CLI callback precedes watcher reconnection on nextTick; notify after both finish. + setImmediate(() => process.send?.(compilation)); + }); + }, + ], + }, +}); diff --git a/tests/integration/build/cli/root-dir.integration.test.ts b/tests/integration/build/cli/root-dir.integration.test.ts index 67d6f981..89314222 100644 --- a/tests/integration/build/cli/root-dir.integration.test.ts +++ b/tests/integration/build/cli/root-dir.integration.test.ts @@ -1,25 +1,146 @@ -import {spawnSync} from "node:child_process"; +import {spawn, spawnSync, type ChildProcess} from "node:child_process"; import {mkdtempSync, rmSync} from "node:fs"; +import {cp, mkdir, readFile, readdir, symlink, writeFile} from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import {stop, waitFor} from "../../browser/utils/browser"; + jest.setTimeout(90_000); -test.each(["api", "cli"])("%s builds absolute roots from an unrelated working directory", mode => { - const directory = mkdtempSync(path.join(os.tmpdir(), "adnbn-root-dir-")); - const projectRoot = path.resolve(__dirname, "../../../.."); - - try { - const result = spawnSync( - process.execPath, - [path.join(__dirname, "scripts/verify-root-dir.mjs"), projectRoot, directory, mode], - {encoding: "utf8", timeout: 60_000, maxBuffer: 4 * 1024 * 1024} - ); - - expect(result.error).toBeUndefined(); - expect({status: result.status, stderr: result.stderr}).toEqual({status: 0, stderr: ""}); - expect(result.stdout).toContain("Absolute root contract verified"); - } finally { - rmSync(directory, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); +const projectRoot = path.resolve(__dirname, "../../../.."); +const relativeForms = ["bare", "dot", "parent"]; +const buildCases = ["api", "cli"].flatMap(mode => + ["absolute", ...relativeForms].flatMap(form => ["vanilla", "react"].map(renderer => ({mode, form, renderer}))) +); + +test.each(buildCases)( + "$mode builds $renderer $form roots without changing the caller's working directory or environment", + ({mode, form, renderer}) => { + const directory = mkdtempSync(path.join(os.tmpdir(), "adnbn-root-dir-")); + + try { + const result = spawnSync( + process.execPath, + [path.join(__dirname, "scripts/verify-root-dir.mjs"), projectRoot, directory, mode, "", form, renderer], + {encoding: "utf8", timeout: 60_000, maxBuffer: 4 * 1024 * 1024} + ); + + expect(result.error).toBeUndefined(); + expect({status: result.status, stderr: result.stderr}).toEqual({status: 0, stderr: ""}); + expect(result.stdout).toContain("Root directory contract verified"); + } finally { + rmSync(directory, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); + } + } +); + +interface RootCompilation { + event: string; + errors: boolean; + modified: string[]; +} + +test.each(["absolute", ...relativeForms])( + "CLI watch rebuilds with %s root paths after an imported source changes", + async form => { + const directory = mkdtempSync(path.join(os.tmpdir(), "adnbn-root-dir-")); + const root = path.join(directory, "project a"); + const cwd = form === "parent" ? path.join(directory, "unrelated") : directory; + const rootDir = form === "absolute" ? root : (form === "dot" ? `.${path.sep}` : "") + path.relative(cwd, root); + const artifact = path.join(root, "dist/addon-chrome-mv3"); + const compilations: RootCompilation[] = []; + let watcher: ChildProcess | undefined; + let output = ""; + + try { + await cp(path.join(__dirname, "fixtures/root-dir"), root, {recursive: true}); + await cp(path.join(__dirname, "fixtures/root-dir-watch.config.ts"), path.join(root, "adnbn.config.ts")); + // Keep resolution inside the disposable project instead of watching missing packages up to the temp root. + await writeFile(path.join(root, "package.json"), '{"private":true,"type":"module"}\n'); + await mkdir(cwd, {recursive: true}); + await mkdir(path.join(root, "node_modules")); + + await symlink( + projectRoot, + path.join(root, "node_modules/adnbn"), + process.platform === "win32" ? "junction" : "dir" + ); + + watcher = spawn( + process.execPath, + [path.join(projectRoot, "bin/adnbn.js"), "watch", rootDir, "-b", "chrome"], + { + cwd, + stdio: ["ignore", "pipe", "pipe", "ipc"], + } + ); + + watcher.stdout?.on("data", chunk => { + output += chunk; + }); + + watcher.stderr?.on("data", chunk => { + output += chunk; + }); + + watcher.on("message", message => { + const compilation = message as RootCompilation; + + if (compilation?.event === "root-directory-compiled") { + compilations.push(compilation); + } + }); + + const initial = await waitFor( + async () => compilations[0], + 15_000, + "initial relative-root watch compilation" + ); + + expect(initial.errors).toBe(false); + expect(await readFile(path.join(artifact, "newtab.html"), "utf8")).toContain("Absolute root A"); + + const readCode = async (): Promise => { + const scripts = (await readdir(artifact, {recursive: true})).filter(filename => + filename.endsWith(".js") + ); + + return ( + await Promise.all(scripts.map(filename => readFile(path.join(artifact, filename), "utf8"))) + ).join("\n"); + }; + + expect(await readCode()).toContain("Root A runtime"); + const previous = compilations.length; + const source = path.join("src", "values.ts"); + + await writeFile( + path.join(root, source), + await readFile(path.join(__dirname, "fixtures/root-dir-watch-values.ts")) + ); + + const updated = await waitFor( + async () => compilations.slice(previous).find(compilation => compilation.modified.includes(source)), + 15_000, + "relative-root watch compilation observing the imported source edit" + ); + + expect(updated.errors).toBe(false); + expect(watcher.exitCode).toBeNull(); + const code = await readCode(); + expect(code).toContain("Root B runtime"); + expect(code).not.toContain("Root A runtime"); + } catch (error) { + throw new Error(`${String(error)}\n${output}`, {cause: error}); + } finally { + try { + if (watcher) { + await stop(watcher); + } + } finally { + rmSync(directory, {recursive: true, force: true, maxRetries: 5, retryDelay: 200}); + } + } } -}); +); diff --git a/tests/integration/build/cli/scripts/verify-root-dir.mjs b/tests/integration/build/cli/scripts/verify-root-dir.mjs index df07bca1..7dc2fd4d 100644 --- a/tests/integration/build/cli/scripts/verify-root-dir.mjs +++ b/tests/integration/build/cli/scripts/verify-root-dir.mjs @@ -1,14 +1,14 @@ import assert from "node:assert/strict"; import {spawnSync} from "node:child_process"; -import {cp, mkdir, readFile, readdir, symlink} from "node:fs/promises"; +import {cp, mkdir, readFile, readdir, realpath, symlink} from "node:fs/promises"; import path from "node:path"; import {pathToFileURL} from "node:url"; -const [projectRoot, directory, mode, sharedCwd] = process.argv.slice(2); +const [projectRoot, directory, mode, sharedCwd, form = "absolute", renderer = "vanilla"] = process.argv.slice(2); const {buildApp} = await import(pathToFileURL(path.join(projectRoot, "dist/cli/builders/app/index.js")).href); const rootA = path.join(directory, "project a"); const rootB = path.join(directory, "project b"); -const cwd = sharedCwd ?? path.join(directory, "unrelated"); +const cwd = sharedCwd || (["bare", "dot"].includes(form) ? directory : path.join(directory, "unrelated")); await mkdir(cwd, {recursive: true}); @@ -23,6 +23,32 @@ for (const root of [rootA, rootB]) { } await cp(path.join(import.meta.dirname, "../fixtures/root-dir-b"), path.join(rootB, "src"), {recursive: true}); + +if (renderer === "react") { + for (const root of [rootA, rootB]) { + await cp(path.join(import.meta.dirname, "../fixtures/root-dir-react"), root, {recursive: true}); + } + + // A owns its dependencies; B inherits the workspace installation. Keep real copies so module paths differ. + for (const root of [rootA, directory]) { + const modules = path.join(root, "node_modules"); + + for (const dependency of ["react", "react-dom"]) { + await cp(path.join(projectRoot, "node_modules", dependency), path.join(modules, dependency), { + recursive: true, + dereference: true, + }); + } + + const scheduler = + root === rootA + ? path.join(modules, "react-dom", "node_modules", "scheduler") + : path.join(modules, "scheduler"); + + await cp(path.join(projectRoot, "node_modules", "scheduler"), scheduler, {recursive: true, dereference: true}); + } +} + process.chdir(cwd); const workingDirectory = process.cwd(); const environment = process.env; @@ -35,9 +61,11 @@ const sequence = [ ]; for (const [root, label, browser] of mode === "parallel" ? sequence.slice(1, 2) : sequence) { + const rootDir = form === "absolute" ? root : (form === "dot" ? `.${path.sep}` : "") + path.relative(cwd, root); + if (mode !== "cli") { const stats = await buildApp({ - rootDir: root, + rootDir, browser, mode: "production", plugins: @@ -75,10 +103,47 @@ for (const [root, label, browser] of mode === "parallel" ? sequence.slice(1, 2) throw error; }); assert.equal(stats.hasErrors(), false); + + if (renderer === "react") { + const dependencies = await realpath(path.join(root === rootA ? rootA : directory, "node_modules")); + const modules = stats.toJson({all: false, modules: true, nestedModules: true}).modules; + const identifiers = []; + + const collect = entries => { + for (const entry of entries ?? []) { + if (entry.identifier) { + identifiers.push(entry.identifier); + } + + collect(entry.modules); + collect(entry.children); + } + }; + + collect(modules); + + for (const dependency of ["react", "react-dom", "scheduler"]) { + const expected = + (dependency === "scheduler" && root === rootA + ? path.join(dependencies, "react-dom", "node_modules", dependency) + : path.join(dependencies, dependency)) + path.sep; + + const selected = identifiers.filter(identifier => + identifier.includes(path.sep + dependency + path.sep) + ); + + assert.ok(selected.length > 0, dependency); + + assert.ok( + selected.every(identifier => identifier.includes(expected)), + selected.join("\n") + ); + } + } } else { const result = spawnSync( process.execPath, - [path.join(projectRoot, "bin/adnbn.js"), "build", root, "-b", browser], + [path.join(projectRoot, "bin/adnbn.js"), "build", rootDir, "-b", browser], { encoding: "utf8", timeout: 30_000, @@ -103,12 +168,18 @@ for (const [root, label, browser] of mode === "parallel" ? sequence.slice(1, 2) assert.ok(code.includes(`Root ${label} runtime`)); assert.ok(code.includes("Offscreen root runtime")); + if (renderer === "react") { + assert.ok(code.includes("React root runtime")); + const page = await readFile(path.join(output, "react.html"), "utf8"); + assert.ok(page.includes("React root page")); + } + if (browser === "firefox") { assert.ok(manifest.background.scripts.length > 0); } } -console.log("Absolute root contract verified"); +console.log("Root directory contract verified"); if (process.connected) { process.disconnect(); diff --git a/tests/integration/build/jsx/fixture/src/indexed.page/index.js b/tests/integration/build/jsx/fixture/src/indexed.page/index.js new file mode 100644 index 00000000..c4133d4d --- /dev/null +++ b/tests/integration/build/jsx/fixture/src/indexed.page/index.js @@ -0,0 +1,6 @@ +import {definePage} from "adnbn"; + +export default definePage({ + title: "JavaScript index page", + render: ({title}) => title, +}); diff --git a/tests/integration/build/jsx/jsx.integration.test.ts b/tests/integration/build/jsx/jsx.integration.test.ts index ece2a7b6..264abd0f 100644 --- a/tests/integration/build/jsx/jsx.integration.test.ts +++ b/tests/integration/build/jsx/jsx.integration.test.ts @@ -31,6 +31,7 @@ test.each(["chrome", "firefox"])("%s builds JSX pages and preserves their AST op ["named", "JSX named"], ["element", "JSX element"], ["javascript", "JavaScript page"], + ["indexed", "JavaScript index page"], ["typescript", "TypeScript page"], ["react-typescript", "TSX page"], ]) { From 885d95f4bdcd6c84d518278eba75d69004fba18d Mon Sep 17 00:00:00 2001 From: Anjey Tsibylskij <130153594+atldays@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:25:03 +0200 Subject: [PATCH 24/24] test(cli): normalize React resource paths on Windows --- .../build/cli/scripts/verify-root-dir.mjs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/integration/build/cli/scripts/verify-root-dir.mjs b/tests/integration/build/cli/scripts/verify-root-dir.mjs index 7dc2fd4d..b26a2206 100644 --- a/tests/integration/build/cli/scripts/verify-root-dir.mjs +++ b/tests/integration/build/cli/scripts/verify-root-dir.mjs @@ -107,12 +107,12 @@ for (const [root, label, browser] of mode === "parallel" ? sequence.slice(1, 2) if (renderer === "react") { const dependencies = await realpath(path.join(root === rootA ? rootA : directory, "node_modules")); const modules = stats.toJson({all: false, modules: true, nestedModules: true}).modules; - const identifiers = []; + const resources = []; const collect = entries => { for (const entry of entries ?? []) { - if (entry.identifier) { - identifiers.push(entry.identifier); + if (entry.nameForCondition) { + resources.push(entry.nameForCondition); } collect(entry.modules); @@ -128,14 +128,17 @@ for (const [root, label, browser] of mode === "parallel" ? sequence.slice(1, 2) ? path.join(dependencies, "react-dom", "node_modules", dependency) : path.join(dependencies, dependency)) + path.sep; - const selected = identifiers.filter(identifier => - identifier.includes(path.sep + dependency + path.sep) + // Windows may report the same temp directory with either its long name or an 8.3 alias. + const selected = await Promise.all( + resources + .filter(resource => resource.includes(path.sep + dependency + path.sep)) + .map(resource => realpath(resource)) ); assert.ok(selected.length > 0, dependency); assert.ok( - selected.every(identifier => identifier.includes(expected)), + selected.every(resource => resource.startsWith(expected)), selected.join("\n") ); }