From c2a46aeaf07a7ba2b59970445821f476511023fc Mon Sep 17 00:00:00 2001 From: Erik Marks Date: Mon, 29 Jul 2024 13:49:54 +0200 Subject: [PATCH 1/4] refactor: Migrate monorepo to ESM, make various tweaks - test: Add internal test-utils package - chore: Synchronize workspace package dependencies - Accomplished by copying over sections of the Snaps monorepo constraints, which allow the `workspace:^` dependency version. In this way, we will never accidentally pull a workspace package from npm. - build(extension): Externalize endoify.mjs in HTML files - refactor: Upgrade ts-bridge and migrate all packages to ESM - chore: Add "clean" script to all packages - fix: Fix intra-repo path resolution in tests - By means of the plugin `vite-tsconfig-paths` --- .depcheckrc.yml | 1 + .eslintrc.js => .eslintrc.cjs | 27 +- .prettierrc.js => .prettierrc.cjs | 0 constraints.pro | 64 +++-- package.json | 10 +- .../extension/{.eslintrc.js => .eslintrc.cjs} | 2 +- packages/extension/package.json | 13 +- packages/extension/src/background.ts | 4 +- packages/extension/src/iframe-manager.test.ts | 6 +- packages/extension/src/iframe-manager.ts | 4 +- packages/extension/src/iframe.html | 4 +- packages/extension/src/iframe.ts | 3 - packages/extension/src/offscreen.html | 4 +- packages/extension/src/offscreen.ts | 7 +- packages/extension/src/shared.test.ts | 2 +- packages/extension/tsconfig.json | 3 +- packages/extension/vite.config.mts | 40 ++- packages/extension/vitest.config.mts | 14 +- .../shims/{.eslintrc.js => .eslintrc.cjs} | 2 +- packages/shims/package.json | 7 +- packages/test-utils/README.md | 7 + packages/test-utils/package.json | 27 ++ packages/test-utils/src/env/mock-endo.ts | 7 + packages/test-utils/src/index.ts | 2 + packages/test-utils/src/mocks.ts | 15 ++ packages/test-utils/src/utils.ts | 7 + packages/test-utils/tsconfig.build.json | 10 + packages/test-utils/tsconfig.json | 9 + tsconfig.json | 5 +- tsconfig.packages.json | 4 +- vitest.config.packages.mjs | 8 + yarn.lock | 252 +++++++++++++++--- 32 files changed, 457 insertions(+), 113 deletions(-) rename .eslintrc.js => .eslintrc.cjs (88%) rename .prettierrc.js => .prettierrc.cjs (100%) rename packages/extension/{.eslintrc.js => .eslintrc.cjs} (94%) rename packages/shims/{.eslintrc.js => .eslintrc.cjs} (89%) create mode 100644 packages/test-utils/README.md create mode 100644 packages/test-utils/package.json create mode 100644 packages/test-utils/src/env/mock-endo.ts create mode 100644 packages/test-utils/src/index.ts create mode 100644 packages/test-utils/src/mocks.ts create mode 100644 packages/test-utils/src/utils.ts create mode 100644 packages/test-utils/tsconfig.build.json create mode 100644 packages/test-utils/tsconfig.json diff --git a/.depcheckrc.yml b/.depcheckrc.yml index 115a7ce4d..d33658e87 100644 --- a/.depcheckrc.yml +++ b/.depcheckrc.yml @@ -11,5 +11,6 @@ ignores: - 'jsdom' - 'prettier-plugin-*' - 'typedoc' + - 'vite-tsconfig-paths' - 'vite' - 'vitest' diff --git a/.eslintrc.js b/.eslintrc.cjs similarity index 88% rename from .eslintrc.js rename to .eslintrc.cjs index 31d833a3a..597f96f9a 100644 --- a/.eslintrc.js +++ b/.eslintrc.cjs @@ -12,7 +12,7 @@ module.exports = { }, ignorePatterns: [ - '!.eslintrc.js', + '!.eslintrc.cjs', '!vite.config.mts', '!vitest.config.mts', 'node_modules', @@ -27,6 +27,14 @@ module.exports = { // in `@metamask/eslint-config-nodejs` in the future. 'import/no-nodejs-modules': 'off', + 'import/no-useless-path-segments': [ + 'error', + { + // Enabling this causes false errors in ESM files. + noUselessIndex: false, + }, + ], + // This prevents using Node.js and/or browser specific globals. We // currently use both in our codebase, so this rule is disabled. 'no-restricted-globals': 'off', @@ -34,7 +42,7 @@ module.exports = { overrides: [ { - files: ['*.js', '*.cjs'], + files: ['*.cjs'], parserOptions: { sourceType: 'script', ecmaVersion: '2020', @@ -42,7 +50,7 @@ module.exports = { }, { - files: ['*.mjs'], + files: ['*.js', '*.mjs'], parserOptions: { sourceType: 'module', ecmaVersion: '2020', @@ -70,8 +78,8 @@ module.exports = { rules: { '@typescript-eslint/no-explicit-any': 'error', - // This rule is broken, and without the `allowAny` option, it causes - // a lot of false positives. + // This rule is broken, and without the `allowAny` option, it reports a lot + // of false errors. '@typescript-eslint/restrict-template-expressions': [ 'error', { @@ -98,6 +106,15 @@ module.exports = { }, }, + { + // Overrides of overrides. + files: ['*'], + rules: { + // This prevents pretty formatting of comments with multi-line lists entries. + 'jsdoc/check-indentation': 'off', + }, + }, + { // @metamask/eslint-plugin-vitest does not exist, so this is copied from the // jest-equivalent. All of the rules we specify are the same. Ref: diff --git a/.prettierrc.js b/.prettierrc.cjs similarity index 100% rename from .prettierrc.js rename to .prettierrc.cjs diff --git a/constraints.pro b/constraints.pro index be0347393..fb5b0a732 100644 --- a/constraints.pro +++ b/constraints.pro @@ -208,10 +208,10 @@ gen_enforced_field(WorkspaceCwd, 'module', './dist/index.mjs') :- \+ workspace_field(WorkspaceCwd, 'private', true). % Non-published packages must not specify an entrypoint. gen_enforced_field(WorkspaceCwd, 'main', null) :- - WorkspaceCwd \= 'packages/shims', + WorkspaceCwd \= 'packages/shims'. workspace_field(WorkspaceCwd, 'private', true). gen_enforced_field(WorkspaceCwd, 'module', null) :- - WorkspaceCwd \= 'packages/shims', + WorkspaceCwd \= 'packages/shims'. workspace_field(WorkspaceCwd, 'private', true). % The type definitions entrypoint for all publishable packages must be the same. @@ -219,7 +219,7 @@ gen_enforced_field(WorkspaceCwd, 'types', './dist/index.d.cts') :- \+ workspace_field(WorkspaceCwd, 'private', true). % Non-published packages must not specify a type definitions entrypoint. gen_enforced_field(WorkspaceCwd, 'types', null) :- - WorkspaceCwd \= 'packages/shims', + WorkspaceCwd \= 'packages/shims'. workspace_field(WorkspaceCwd, 'private', true). % The exports for all published packages must be the same. @@ -235,18 +235,18 @@ gen_enforced_field(WorkspaceCwd, 'exports["."].import.types', './dist/index.d.mt \+ workspace_field(WorkspaceCwd, 'private', true). % package.json gen_enforced_field(WorkspaceCwd, 'exports["./package.json"]', './package.json') :- - \+ workspace_field(WorkspaceCwd, 'private', true). -% Non-published packages must not specify exports. + WorkspaceCwd \= 'packages/extension', + WorkspaceCwd \= '.'. +% The root package must not specify exports. gen_enforced_field(WorkspaceCwd, 'exports', null) :- - WorkspaceCwd \= 'packages/shims', - workspace_field(WorkspaceCwd, 'private', true). + WorkspaceCwd = '.'. % Published packages must not have side effects. gen_enforced_field(WorkspaceCwd, 'sideEffects', false) :- \+ workspace_field(WorkspaceCwd, 'private', true). % Non-published packages must not specify side effects. gen_enforced_field(WorkspaceCwd, 'sideEffects', null) :- - WorkspaceCwd \= 'packages/shims', + WorkspaceCwd \= 'packages/shims'. workspace_field(WorkspaceCwd, 'private', true). % The list of files included in published packages must only include files @@ -265,9 +265,14 @@ gen_enforced_field(WorkspaceCwd, 'files', []) :- % gen_enforced_field(WorkspaceCwd, 'scripts.build', '') :- % WorkspaceCwd \= '.'. -% All packages except the root and extension must have the same "build:docs" script. +% All packages except the root and other exceptions must have the same "build:docs" script. gen_enforced_field(WorkspaceCwd, 'scripts.build:docs', 'typedoc') :- WorkspaceCwd \= 'packages/extension', + WorkspaceCwd \= 'packages/test-utils', + WorkspaceCwd \= '.'. + +% All packages except the root must have the same "clean" script. +gen_enforced_field(WorkspaceCwd, 'scripts.clean', 'rimraf ./dist \'./*.tsbuildinfo\'') :- WorkspaceCwd \= '.'. % All published packages must have the same "publish:preview" script. @@ -301,53 +306,56 @@ gen_enforced_field(WorkspaceCwd, 'scripts.changelog:update', CorrectChangelogUpd % All non-root packages must have the same "test" script. gen_enforced_field(WorkspaceCwd, 'scripts.test', 'vitest run --config vitest.config.mts') :- WorkspaceCwd \= 'packages/shims', + WorkspaceCwd \= 'packages/test-utils', WorkspaceCwd \= '.'. % All non-root packages must have the same "test:clean" script. gen_enforced_field(WorkspaceCwd, 'scripts.test:clean', 'yarn test --no-cache --coverage.clean') :- + WorkspaceCwd \= 'packages/test-utils', WorkspaceCwd \= '.'. % All non-root packages must have the same "test:dev" script. gen_enforced_field(WorkspaceCwd, 'scripts.test:dev', 'yarn test --coverage false') :- + WorkspaceCwd \= 'packages/test-utils', WorkspaceCwd \= '.'. % All non-root packages must have the same "test:verbose" script. gen_enforced_field(WorkspaceCwd, 'scripts.test:verbose', 'yarn test --reporter verbose') :- + WorkspaceCwd \= 'packages/test-utils', WorkspaceCwd \= '.'. % All non-root packages must have the same "test:watch" script. gen_enforced_field(WorkspaceCwd, 'scripts.test:watch', 'vitest --config vitest.config.mts') :- + WorkspaceCwd \= 'packages/test-utils', WorkspaceCwd \= '.'. % All dependency ranges must be recognizable (this makes it possible to apply % the next two rules effectively). gen_enforced_dependency(WorkspaceCwd, DependencyIdent, 'a range optionally starting with ^ or ~', DependencyType) :- workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, DependencyType), - \+ is_valid_version_range(DependencyRange). - -% All version ranges used to reference one workspace package in another -% workspace package's `dependencies` or `devDependencies` must be the same. -% Among all references to the same dependency across the monorepo, the one with -% the smallest version range will win. (We handle `peerDependencies` in another -% constraint, as it has slightly different logic.) + \+ ( + DependencyRange = '^1.0.0-rc.12'; % is_valid_version_range does not handle rc suffixes + is_valid_version_range(DependencyRange) + ). + +% All dependency ranges for a package must be synchronized across the monorepo +% (the least version range wins), regardless of which "*dependencies" field +% where the package appears. gen_enforced_dependency(WorkspaceCwd, DependencyIdent, OtherDependencyRange, DependencyType) :- workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, DependencyType), workspace_has_dependency(OtherWorkspaceCwd, DependencyIdent, OtherDependencyRange, OtherDependencyType), WorkspaceCwd \= OtherWorkspaceCwd, DependencyRange \= OtherDependencyRange, - npm_version_range_out_of_sync(DependencyRange, OtherDependencyRange), - DependencyType \= 'peerDependencies', - OtherDependencyType \= 'peerDependencies'. - -% All version ranges used to reference one workspace package in another -% workspace package's `dependencies` or `devDependencies` must match the current -% version of that package. (We handle `peerDependencies` in another rule.) -gen_enforced_dependency(WorkspaceCwd, DependencyIdent, CorrectDependencyRange, DependencyType) :- - DependencyType \= 'peerDependencies', + npm_version_range_out_of_sync(DependencyRange, OtherDependencyRange). + +% If a dependency is listed under "dependencies", it should not be listed under +% "devDependencies". We match on the same dependency range so that if a +% dependency is listed under both lists, their versions are synchronized and +% then this constraint will apply and remove the "right" duplicate. +gen_enforced_dependency(WorkspaceCwd, DependencyIdent, null, DependencyType) :- + workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, 'dependencies'), workspace_has_dependency(WorkspaceCwd, DependencyIdent, DependencyRange, DependencyType), - workspace_ident(OtherWorkspaceCwd, DependencyIdent), - workspace_version(OtherWorkspaceCwd, OtherWorkspaceVersion), - atomic_list_concat(['^', OtherWorkspaceVersion], CorrectDependencyRange). + DependencyType == 'devDependencies'. % If a workspace package is listed under another workspace package's % `dependencies`, it should not also be listed under its `devDependencies`. diff --git a/package.json b/package.json index 978c610e4..2f7a4fa67 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "type": "git", "url": "https://github.com/MetaMask/ocap-kernel.git" }, + "type": "module", "files": [], "workspaces": [ "packages/*" @@ -19,13 +20,13 @@ "build:watch": "yarn run build --watch", "changelog:update": "yarn workspaces foreach --all --no-private --parallel --interlaced --verbose run changelog:update", "changelog:validate": "yarn workspaces foreach --all --no-private --parallel --interlaced --verbose run changelog:validate", - "clean": "rimraf dist '**/*.tsbuildinfo'", + "clean": "rimraf '**/*.tsbuildinfo' && yarn workspaces foreach --all --parallel --interlaced --verbose run clean", "lint": "yarn lint:eslint && yarn lint:misc --check && yarn constraints && yarn lint:dependencies", "lint:dependencies": "depcheck && yarn dedupe --check", "lint:dependencies:fix": "depcheck && yarn dedupe", "lint:eslint": "eslint . --cache --ext js,mjs,cjs,ts,mts,cts", "lint:fix": "yarn lint:eslint --fix && yarn lint:misc --write && yarn constraints --fix && yarn lint:dependencies:fix", - "lint:misc": "prettier '**/*.json' '**/*.md' '!**/CHANGELOG.old.md' '**/*.yml' '!.yarnrc.yml' '!merged-packages/**' --ignore-path .gitignore", + "lint:misc": "prettier '**/*.json' '**/*.md' '**/*.html' '!**/CHANGELOG.old.md' '**/*.yml' '!.yarnrc.yml' '!merged-packages/**' --ignore-path .gitignore", "prepack": "./scripts/prepack.sh", "test": "yarn workspaces foreach --all --parallel --verbose run test", "test:clean": "yarn workspaces foreach --all --parallel --verbose run test:clean && yarn test", @@ -39,7 +40,7 @@ "@metamask/eslint-config": "^12.2.0", "@metamask/eslint-config-nodejs": "^12.1.0", "@metamask/eslint-config-typescript": "^12.1.0", - "@ts-bridge/cli": "^0.1.4", + "@ts-bridge/cli": "^0.3.0", "@ts-bridge/shims": "^0.1.1", "@types/node": "^18.18.14", "@typescript-eslint/eslint-plugin": "^5.43.0", @@ -60,7 +61,8 @@ "typedoc": "^0.24.8", "typescript": "~4.9.5", "vite": "^5.3.5", - "vitest": "^2.0.4" + "vite-tsconfig-paths": "^4.3.2", + "vitest": "^2.0.5" }, "packageManager": "yarn@4.2.2", "engines": { diff --git a/packages/extension/.eslintrc.js b/packages/extension/.eslintrc.cjs similarity index 94% rename from packages/extension/.eslintrc.js rename to packages/extension/.eslintrc.cjs index 13900fb6d..1ff55a970 100644 --- a/packages/extension/.eslintrc.js +++ b/packages/extension/.eslintrc.cjs @@ -1,5 +1,5 @@ module.exports = { - extends: ['../../.eslintrc.js'], + extends: ['../../.eslintrc.cjs'], overrides: [ { diff --git a/packages/extension/package.json b/packages/extension/package.json index 08af11d5b..bb903f216 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -7,6 +7,7 @@ "type": "git", "url": "https://github.com/MetaMask/ocap-kernel.git" }, + "type": "module", "files": [ "dist/" ], @@ -15,6 +16,7 @@ "build:types": "tsc --project tsconfig.build.json", "build:vite": "vite build --config vite.config.mts", "changelog:validate": "../../scripts/validate-changelog.sh @ocap/extension", + "clean": "rimraf ./dist './*.tsbuildinfo'", "publish:preview": "yarn npm publish --tag preview", "start": "vite --config vite.config.mts & vite build --watch --config vite.config.mts", "test": "vitest run --config vitest.config.mts", @@ -28,22 +30,25 @@ "@endo/promise-kit": "^1.1.2", "@metamask/snaps-utils": "^7.8.0", "@metamask/utils": "^9.1.0", - "@ocap/shims": "^0.0.0", - "ses": "^1.5.0" + "@ocap/shims": "workspace:^", + "ses": "^1.7.0" }, "devDependencies": { "@arethetypeswrong/cli": "^0.15.3", "@metamask/auto-changelog": "^3.4.4", + "@ocap/test-utils": "workspace:^", "@types/chrome": "^0.0.268", - "@vitest/coverage-v8": "^2.0.4", + "@vitest/coverage-v8": "^2.0.5", + "cheerio": "^1.0.0-rc.12", "deepmerge": "^4.3.1", "jsdom": "^24.1.1", + "prettier": "^2.7.1", "typedoc": "^0.24.8", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~4.9.5", "vite": "^5.3.5", "vite-plugin-static-copy": "^1.0.6", - "vitest": "^2.0.4" + "vitest": "^2.0.5" }, "engines": { "node": "^18.18 || >=20" diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index d04913a8d..f1213e891 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -3,8 +3,8 @@ import './dev-console.mjs'; import './endoify.mjs'; /* eslint-enable import/extensions,import/no-unassigned-import */ -import type { ExtensionMessage } from './shared'; -import { Command, makeHandledCallback } from './shared'; +import type { ExtensionMessage } from './shared.js'; +import { Command, makeHandledCallback } from './shared.js'; // globalThis.kernel will exist due to dev-console.mjs Object.defineProperties(globalThis.kernel, { diff --git a/packages/extension/src/iframe-manager.test.ts b/packages/extension/src/iframe-manager.test.ts index a5c621570..2a732124e 100644 --- a/packages/extension/src/iframe-manager.test.ts +++ b/packages/extension/src/iframe-manager.test.ts @@ -1,7 +1,7 @@ import * as snapsUtils from '@metamask/snaps-utils'; import { vi, beforeEach, describe, it, expect } from 'vitest'; -import { Command } from './shared'; +import { Command } from './shared.js'; vi.mock('@endo/promise-kit', () => ({ makePromiseKit: () => { @@ -21,11 +21,11 @@ vi.mock('@metamask/snaps-utils', () => ({ describe('IframeManager', () => { // eslint-disable-next-line @typescript-eslint/consistent-type-imports - let IframeManager: typeof import('./iframe-manager').IframeManager; + let IframeManager: typeof import('./iframe-manager.js').IframeManager; beforeEach(async () => { vi.resetModules(); - IframeManager = (await import('./iframe-manager')).IframeManager; + IframeManager = (await import('./iframe-manager.js')).IframeManager; }); describe('getInstance', () => { diff --git a/packages/extension/src/iframe-manager.ts b/packages/extension/src/iframe-manager.ts index 6e7148b2c..cd6073cb0 100644 --- a/packages/extension/src/iframe-manager.ts +++ b/packages/extension/src/iframe-manager.ts @@ -2,8 +2,8 @@ import type { PromiseKit } from '@endo/promise-kit'; import { makePromiseKit } from '@endo/promise-kit'; import { createWindow } from '@metamask/snaps-utils'; -import type { IframeMessage } from './shared'; -import { Command, isWrappedIframeMessage } from './shared'; +import type { IframeMessage } from './shared.js'; +import { Command, isWrappedIframeMessage } from './shared.js'; const IFRAME_URI = 'iframe.html'; diff --git a/packages/extension/src/iframe.html b/packages/extension/src/iframe.html index 0f2fd2476..a90a1d487 100644 --- a/packages/extension/src/iframe.html +++ b/packages/extension/src/iframe.html @@ -5,7 +5,5 @@ Ocap Iframe - - + - diff --git a/packages/extension/src/iframe.ts b/packages/extension/src/iframe.ts index 308e831ec..6c7586e34 100644 --- a/packages/extension/src/iframe.ts +++ b/packages/extension/src/iframe.ts @@ -1,6 +1,3 @@ -// eslint-disable-next-line import/extensions,import/no-unassigned-import -import './endoify.mjs'; - import { Command, isWrappedIframeMessage } from './shared.js'; const defaultCompartment = new Compartment({ URL }); diff --git a/packages/extension/src/offscreen.html b/packages/extension/src/offscreen.html index f9d736a9a..f538d3459 100644 --- a/packages/extension/src/offscreen.html +++ b/packages/extension/src/offscreen.html @@ -5,7 +5,5 @@ Ocap Offscreen Document - - + - diff --git a/packages/extension/src/offscreen.ts b/packages/extension/src/offscreen.ts index 1ee16f560..4f3f91755 100644 --- a/packages/extension/src/offscreen.ts +++ b/packages/extension/src/offscreen.ts @@ -1,9 +1,6 @@ -// eslint-disable-next-line import/extensions,import/no-unassigned-import -import './endoify.mjs'; - import { IframeManager } from './iframe-manager.js'; -import type { ExtensionMessage } from './shared'; -import { Command, makeHandledCallback } from './shared'; +import type { ExtensionMessage } from './shared.js'; +import { Command, makeHandledCallback } from './shared.js'; // Handle messages from the background service worker chrome.runtime.onMessage.addListener( diff --git a/packages/extension/src/shared.test.ts b/packages/extension/src/shared.test.ts index 1616a8f0c..fb96f0987 100644 --- a/packages/extension/src/shared.test.ts +++ b/packages/extension/src/shared.test.ts @@ -1,6 +1,6 @@ import { vi, describe, it, expect } from 'vitest'; -import { isWrappedIframeMessage, makeHandledCallback } from './shared'; +import { isWrappedIframeMessage, makeHandledCallback } from './shared.js'; describe('shared', () => { describe('isWrappedIframeMessage', () => { diff --git a/packages/extension/tsconfig.json b/packages/extension/tsconfig.json index ce25d4837..8f0d1116f 100644 --- a/packages/extension/tsconfig.json +++ b/packages/extension/tsconfig.json @@ -7,7 +7,8 @@ "lib": ["DOM", "ES2020"], "noEmit": true, "skipLibCheck": true, - "types": ["chrome", "ses", "vitest"] + "types": ["chrome", "ses", "vitest", "vitest/jsdom"] }, + "references": [{ "path": "../test-utils" }], "include": ["./src/**/*.ts", "./src/dev-console.mjs"] } diff --git a/packages/extension/vite.config.mts b/packages/extension/vite.config.mts index 37e41c83f..27affcda0 100644 --- a/packages/extension/vite.config.mts +++ b/packages/extension/vite.config.mts @@ -1,7 +1,10 @@ // eslint-disable-next-line spaced-comment /// +import { load as loadHtml } from 'cheerio'; import path from 'path'; +import { format as prettierFormat } from 'prettier'; +import type { Plugin } from 'vite'; import { defineConfig } from 'vite'; import { viteStaticCopy } from 'vite-plugin-static-copy'; @@ -9,7 +12,7 @@ const projectRoot = './src'; /** * Module specifiers that will be ignored by Rollup if imported, and therefore - * not transformed. + * not transformed. **Only applies to JavaScript and TypeScript files.** */ const externalModules: Readonly = [ './dev-console.mjs', @@ -55,9 +58,44 @@ export default defineConfig({ }, plugins: [ + endoifyHtmlFilesPlugin(), viteStaticCopy({ targets: staticCopyTargets.map((src) => ({ src, dest: './' })), watch: { reloadPageOnChange: true }, }), ], }); + +/** + * Vite plugin to insert the endoify script before the first script in the head element. + * @throws If the HTML document already references the endoify script or lacks the expected + * structure. + * @returns The Vite plugin. + */ +function endoifyHtmlFilesPlugin(): Plugin { + const endoifyElement = ''; + + return { + name: 'externalize-plugin', + async transformIndexHtml(htmlString) { + if (htmlString.includes('endoify.mjs')) { + throw new Error( + `HTML document already references endoify script:\n${htmlString}`, + ); + } + + const htmlDoc = loadHtml(htmlString); + if (htmlDoc('head').length !== 1 || htmlDoc('head script').length < 1) { + throw new Error( + `Expected HTML document with a single containing at least one