@meddleware/frontend-config 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.0.3] - 2026-11-07
9
+
10
+ ### Added
11
+
12
+ - `mw-audit-gate`, `mw-third-party-licenses` and `mw-check-csp-inline`: the dependency-audit gate, the licence bundle and the inline-script
13
+ CSP check that every app repository carried as its own copy of a script.
14
+ - `/csp`: `buildCsp`, `cspForProfile` and six named profiles that reproduce, byte for byte, the policies the app images served.
15
+ - `meddlewareApp` (`/vite`), `vueTestConfig` (`/vitest`) and `tsconfig/vue-app.json`: the Vite, Vitest and TypeScript configuration that eleven
16
+ apps each repeated.
17
+ - `mw-sync --check|--write` and `templates/`: the files that cannot be shared by reference stay identical across repositories, and the
18
+ Dockerfile's `ARG CSP` is held equal to the repository's profile.
19
+
20
+ ## [0.0.2] - 2026-11-01
21
+
22
+ ### Added
23
+
24
+ - `check-prod-bundle` (and `scanBundle` at `/bundle`): the production-artefact scan for the e2e harness, moved here from token-issuance-ui so
25
+ every wallet app runs the same one (the mock-wallet hooks; `--also` / `--also-regex` add an app's own stand-in marker).
26
+
27
+ - `expectKeyboardFocusVisible` (and so `defineA11ySuite`) now also fails a stop that is entirely hidden behind sticky or fixed content when it
28
+ takes focus (WCAG 2.4.11 focus not obscured; axe has no rule for it), going forward and then backwards through the tab order; the message names the covering element, and the pointer is parked first so a stale hover tooltip cannot cover the walk; each stop is judged after the scroll has settled (WebKit animates the scroll that focus causes).
29
+
30
+ - `defineA11ySuite` takes `noKeyboard: [state names]` for states with nothing to operate (they skip the keyboard pass; every other state must
31
+ have a stop). `expectKeyboardFocusVisible` no longer reports "no keyboard stops" for a page reached with focus on its last control.
32
+
33
+ ### Changed
34
+
35
+ - The keyboard walk follows element identity (a repeated link label no longer ends it early), stops at 300 stops and fails when it is capped (`maxStops` raises the cap); its test limit scales with the cap (WebKit needs about half a minute for a page of a hundred stops).
36
+ - `SECURITY.md` is part of the published files.
37
+
8
38
  ## [0.0.1] - 2026-10-31
9
39
 
10
40
  ### Added
package/README.md CHANGED
@@ -10,14 +10,22 @@ same accessibility and browser-support bar.
10
10
  | `/stylelint` | `stylelintConfig({ tokens?, compat?, browsers?, rules? })` — standard config, no hex, token-only colour and `z-index`, `@double-great/stylelint-a11y`, `stylelint-no-unsupported-browser-features`. |
11
11
  | `/html-validate` | `htmlValidateConfig({ elements?, rules? })` — recommended + `html-validate:a11y` + Vue, with `@meddleware/ui` components described as the native element they render. |
12
12
  | `/postcss` | Autoprefixer for the repo's browserslist. |
13
- | `/vite` | `viteTarget()` — Vite `build.target` derived from the browserslist; `themeInit({ default })` — a Vite plugin that loads ui's `theme-init.js` first in `<head>` (the saved or default colour mode and the season applied before first paint, no inline script, no CSP hash) and adds `color-scheme` and the browser-chrome `theme-color` from the tokens. |
13
+ | `/vite` | `viteTarget()` — Vite `build.target` derived from the browserslist; `meddlewareApp({ plugins, theme?, wallet?, config? })` — the whole Vite configuration of a tool app (target, theme-init, e2e guard, wallet-adapter kept out of pre-bundling); `themeInit({ default })` — a Vite plugin that loads ui's `theme-init.js` first in `<head>` (the saved or default colour mode and the season applied before first paint, no inline script, no CSP hash) and adds `color-scheme` and the browser-chrome `theme-color` from the tokens. |
14
14
  | `/playwright` | `definePlaywrightConfig({ port, kind?, command?, browsers?, extra? })`. |
15
15
  | `/axe` | `expectAccessible(page, options)` — WCAG 2.2 AA gate; AAA as `aaa: 'gate'`, `'report'` or `'off'`. |
16
16
  | `/look` | `looks()`, `applyLook(page, theme, season)`, `offline(page)`, `VIEWPORTS`, `THEMES`, `SEASONS` — the viewport × colour mode × season matrix. |
17
17
  | `/shots` | `defineShots(states)` — one screenshot per state × look, for looking at the UI. |
18
18
  | `/a11y` | `defineA11ySuite(states, { aaa })` — the sweep every app runs, one call: each state in every colour mode × season at WCAG 2.2 AA and AAA, a 320 px reflow, 200% text size, forced colours, reduced motion and a keyboard pass. `expectNoHorizontalScroll`, `expectNoRunningAnimations` and `expectKeyboardFocusVisible` are exported on their own. |
19
- | `/vitest` | `inlineMeddleware` — the packages a test must process through Vite (they import `.css`/`.vue`), which also gives them the test's single copy of Vue; `installDialogShim()` — jsdom's missing `<dialog>` modality. |
19
+ | `/vitest` | `vueTestConfig({ plugins, test? })` — the whole Vitest configuration of a Vue repository (jsdom, `tests/**/*.test.ts`, one copy of Vue, the UI packages processed through Vite); `inlineMeddleware` — those packages on their own; `installDialogShim()` — jsdom's missing `<dialog>` modality. |
20
+ | `/bundle` + `check-prod-bundle` | `scanBundle(dist, { also })` and the `check-prod-bundle [dist] [--also TEXT]... [--also-regex PATTERN]...` command: fails when the e2e harness (the mock-wallet hooks, or an app's own stand-in marker) is found in a production build. Run it after the production build in CI and in the Dockerfile. |
21
+ | `/wallet` | `connectMockWallet(page)`, `exposeSigner(page, signer)`, `E2E_ADDRESS` — the Playwright side of the mock wallet. |
20
22
  | `/lighthouserc` | `lighthouseConfig({ port, paths?, … })` — Lighthouse CI (accessibility must score 100). |
23
+ | `/tsconfig/vue-app.json` | The compiler options of a Vue app (`"extends": "@meddleware/frontend-config/tsconfig/vue-app.json"`); the repository keeps only its `include`. |
24
+ | `/csp` | `buildCsp(options)`, `cspForProfile(name, current?)`, `PROFILES`, `scriptHashesOf(csp)` — the Content-Security-Policy every app image serves, as six named profiles (`static`, `static-strict`, `console`, `chain`, `chain-wasm`, `docs`), so the policy is written once. |
25
+ | `mw-sync` | `mw-sync --check` / `--write` in a repository root: the files every frontend repository copies (dependabot.yml, .dockerignore, postcss/stylelint configs, the Lighthouse allowlist) equal this package's `templates/`, and the Dockerfile's `ARG CSP` equals its profile. Declared in package.json: `"meddleware": { "kind": "app", "csp": "chain-wasm", "lhci": true }`. |
26
+ | `mw-audit-gate` | CI dependency gate: `npm audit` at high/critical minus the dated, reasoned entries of `.github/audit-allowlist.json` (an entry with no reason or an expired date fails). |
27
+ | `mw-third-party-licenses` | Writes `dist/THIRD_PARTY_LICENSES` from the lockfile's production packages and their installed licence texts; `--check` fails when none can be written. |
28
+ | `mw-check-csp-inline` | `mw-check-csp-inline <policy or @Dockerfile> [dist]`: every inline script in the built pages is allowed by a `sha256` source in the policy, and the policy never allows `'unsafe-inline'` scripts. |
21
29
 
22
30
  ## Use
23
31
 
@@ -108,6 +116,14 @@ await connectMockWallet(page) // the app's wallet dialog → "Test Wallet"
108
116
 
109
117
  The page side is `@meddleware/wallet-adapter/testing` (`installE2eWallet`), registered by an app's e2e build.
110
118
 
119
+ ## Keeping the e2e harness out of production
120
+
121
+ An app's end-to-end build (`vite build --mode e2e`) registers a mock wallet and a stub chain. Three things keep that out of every
122
+ other build: the app's entry loads it only when `import.meta.env.MODE === 'e2e' && import.meta.env.VITE_E2E === '1'` (both static, so
123
+ the block is removed); `assertE2eOnlyInE2eMode(mode)` from `/vite` fails a build with `VITE_E2E` set in any other mode; and
124
+ `check-prod-bundle dist` (`/bundle`) scans the built files for the harness's hooks and fails if any is present. An app whose e2e build also
125
+ swaps a module for a stand-in adds a marker of that stand-in: `check-prod-bundle dist --also 'a string only the stand-in contains'` (`--also-regex` takes a pattern).
126
+
111
127
  ## Peers
112
128
 
113
129
  `eslint` ^10 (required for `/eslint`), and optional peers per subpath: `stylelint` ^17, `html-validate` ^11, `postcss` ^8,
package/SECURITY.md ADDED
@@ -0,0 +1,30 @@
1
+ # Security Policy
2
+
3
+ ## Scope
4
+
5
+ This policy covers `@meddleware/frontend-config`: lint, build and test configuration for MeddleWare frontends. It runs only in a
6
+ consumer's development and CI processes (ESLint, Stylelint, html-validate, PostCSS, Vite, Playwright, Lighthouse); it ships nothing
7
+ to end users and makes no network requests of its own.
8
+
9
+ It does not cover a consuming application, or the tools and plugins it configures.
10
+
11
+ ## What it guarantees
12
+
13
+ The rules are a **guard rail**: they catch accidents and shortcuts, not deliberate evasion (a `stylelint-disable` or
14
+ `eslint-disable` comment defeats them). Accessibility checks are automated and cover only what axe and the linters can detect;
15
+ they are not a substitute for manual testing with assistive technology.
16
+
17
+ ## Supply chain
18
+
19
+ Releases are published from this repository's `npm-publish.yml` with npm trusted publishing (OIDC and provenance); tokens are not
20
+ used. A compromised release could weaken the lint, accessibility and browser-support gates in every consumer, or run code in their
21
+ build, so report anything that suggests tampering.
22
+
23
+ ## Supported versions
24
+
25
+ Only the latest published npm version receives fixes.
26
+
27
+ ## Reporting a vulnerability
28
+
29
+ Please **do not** open a public GitHub issue for security vulnerabilities. Use GitHub's private vulnerability reporting for this
30
+ repository, or email dev@meddleware.co.uk.
package/dist/a11y.d.ts CHANGED
@@ -1,4 +1,5 @@
1
- /** @typedef {(page: import('@playwright/test').Page) => Promise<void>} State */
1
+ /** @typedef {(page: import('@playwright/test').Page) => Promise<void>} Open */
2
+ /** @typedef {Open | { open: Open, disableRules?: string[] }} State A state is how to open it, or that and the axe rules that do not apply to it (a lone dialog has no landmarks). */
2
3
  /**
3
4
  * The page has no horizontal scroll: nothing is wider than the viewport (WCAG 1.4.10 reflow, and 1.4.4 at a larger text size).
4
5
  * @param {import('@playwright/test').Page} page
@@ -10,7 +11,9 @@ export function expectNoHorizontalScroll(page: import("@playwright/test").Page):
10
11
  */
11
12
  export function expectNoRunningAnimations(page: import("@playwright/test").Page): Promise<void>;
12
13
  /**
13
- * Tab through the page: every stop is visible and shows a focus indicator (an outline or a shadow), and the tab order ends (no trap).
14
+ * Tab through the page: every stop is visible, shows a focus indicator (an outline or a shadow) and is not hidden behind sticky or fixed
15
+ * content, and the tab order ends (no trap). Then Shift+Tab back through it, so a stop reached from below is checked as well (browsers
16
+ * scroll a stop into view from the side it arrives from, so a sticky header or a bottom bar can cover it in only one direction).
14
17
  * @param {import('@playwright/test').Page} page
15
18
  * @param {{ maxStops?: number }} [options]
16
19
  */
@@ -19,10 +22,20 @@ export function expectKeyboardFocusVisible(page: import("@playwright/test").Page
19
22
  }): Promise<void>;
20
23
  /**
21
24
  * @param {Record<string, State>} states Opens each state of the app (navigates, mocks, opens the dialog).
22
- * @param {{ aaa?: 'gate' | 'report' | 'off' }} [options] How WCAG AAA findings are treated (default `report`; a repo moves to `gate`
23
- * in the release that clears them).
25
+ * @param {{ aaa?: 'gate' | 'report' | 'off', noKeyboard?: string[], maxStops?: number }} [options] How WCAG AAA findings are treated (default `report`; a repo
26
+ * moves to `gate` in the release that clears them); `noKeyboard` names states with nothing to operate (a static summary, a modal that
27
+ * only reports progress), which skip the keyboard pass: every other state must have at least one keyboard stop; `maxStops` (default 300) caps the keyboard walk, and a page with more stops fails rather than passing unchecked.
24
28
  */
25
- export function defineA11ySuite(states: Record<string, State>, { aaa }?: {
29
+ export function defineA11ySuite(states: Record<string, State>, { aaa, noKeyboard, maxStops }?: {
26
30
  aaa?: "gate" | "report" | "off";
31
+ noKeyboard?: string[];
32
+ maxStops?: number;
27
33
  }): void;
28
- export type State = (page: import("@playwright/test").Page) => Promise<void>;
34
+ export type Open = (page: import("@playwright/test").Page) => Promise<void>;
35
+ /**
36
+ * A state is how to open it, or that and the axe rules that do not apply to it (a lone dialog has no landmarks).
37
+ */
38
+ export type State = Open | {
39
+ open: Open;
40
+ disableRules?: string[];
41
+ };
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Scan the built files under `dist` for the harness.
3
+ * @param {string} dist The build output directory.
4
+ * @param {{ also?: (string | RegExp)[] }} [options] Extra markers: an app's own stand-in (a string is matched literally).
5
+ * @returns {{ scanned: number, hits: string[] }}
6
+ * @throws When `dist` cannot be read.
7
+ */
8
+ export function scanBundle(dist: string, { also }?: {
9
+ also?: (string | RegExp)[];
10
+ }): {
11
+ scanned: number;
12
+ hits: string[];
13
+ };
14
+ /**
15
+ * What the shared harness (`@meddleware/wallet-adapter/testing` `installE2eWallet`, `@meddleware/frontend-config/wallet`) leaves in a
16
+ * bundle that includes it: the hooks it puts on `window`. Property names survive minification.
17
+ */
18
+ export const E2E_MARKERS: readonly {
19
+ name: string;
20
+ re: RegExp;
21
+ }[];
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/csp.d.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @param {CspOptions} options
3
+ * @returns {string}
4
+ */
5
+ export function buildCsp(options?: CspOptions): string;
6
+ /** The `'sha256-…'` hashes in a policy's script-src (kept when a profile such as `docs` is rebuilt). */
7
+ export function scriptHashesOf(csp: string): string[];
8
+ /** @param {string} name @param {string} [current] the policy now in the Dockerfile (its script hashes are kept) */
9
+ export function cspForProfile(name: string, current?: string): string;
10
+ /**
11
+ * @typedef {object} CspOptions
12
+ * @property {boolean} [wasm] allow WebAssembly (`'wasm-unsafe-eval'`): the Walrus and Move-bytecode clients
13
+ * @property {boolean} [chain] chain-facing app: https images, https connections (RPC, relay, aggregator and key-server hosts are partly configured), blob workers
14
+ * @property {boolean} [worker] allow `worker-src 'self' blob:` without the rest of `chain` (VitePress sites)
15
+ * @property {boolean} [strict] `base-uri` and `form-action` are `'none'` (a page with no forms and no `<base>`)
16
+ * @property {'self' | 'none'} [frameAncestors] who may frame the page (default `'self'`)
17
+ * @property {string[]} [scriptHashes] `'sha256-…'` sources for the inline scripts a static-site generator emits
18
+ */
19
+ /** @type {Record<string, CspOptions>} */
20
+ export const PROFILES: Record<string, CspOptions>;
21
+ export type CspOptions = {
22
+ /**
23
+ * allow WebAssembly (`'wasm-unsafe-eval'`): the Walrus and Move-bytecode clients
24
+ */
25
+ wasm?: boolean | undefined;
26
+ /**
27
+ * chain-facing app: https images, https connections (RPC, relay, aggregator and key-server hosts are partly configured), blob workers
28
+ */
29
+ chain?: boolean | undefined;
30
+ /**
31
+ * allow `worker-src 'self' blob:` without the rest of `chain` (VitePress sites)
32
+ */
33
+ worker?: boolean | undefined;
34
+ /**
35
+ * `base-uri` and `form-action` are `'none'` (a page with no forms and no `<base>`)
36
+ */
37
+ strict?: boolean | undefined;
38
+ /**
39
+ * who may frame the page (default `'self'`)
40
+ */
41
+ frameAncestors?: "none" | "self" | undefined;
42
+ /**
43
+ * `'sha256-…'` sources for the inline scripts a static-site generator emits
44
+ */
45
+ scriptHashes?: string[] | undefined;
46
+ };
package/dist/sync.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * @param {string} root repository root
4
+ * @param {{ write?: boolean }} [options]
5
+ * @returns {{ file: string, ok: boolean, note: string }[]}
6
+ */
7
+ export function sync(root: string, options?: {
8
+ write?: boolean;
9
+ }): {
10
+ file: string;
11
+ ok: boolean;
12
+ note: string;
13
+ }[];
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/vite.d.ts CHANGED
@@ -41,3 +41,28 @@ export function themeInit({ default: mode, scriptPath, colors }?: {
41
41
  * @param {Record<string, string | undefined>} env The environment (default: the process's).
42
42
  */
43
43
  export function assertE2eOnlyInE2eMode(mode: string, env?: Record<string, string | undefined>): void;
44
+ /**
45
+ * The Vite configuration every MeddleWare tool app shares: the browserslist build target, the colour mode and season before first paint
46
+ * ({@link themeInit}), the e2e-build guard ({@link assertE2eOnlyInE2eMode}), and `@meddleware/wallet-adapter` excluded from dependency
47
+ * pre-bundling. The wallet adapter ships TS and `.vue` source and holds the shared wallet singleton; pre-bundling would put its TS in
48
+ * `.vite/deps` while its `.vue` files (which esbuild cannot bundle) are served raw and import the raw module, so dev would run two
49
+ * singletons and the guard and the app would see different connection state.
50
+ *
51
+ * ```ts
52
+ * import vue from '@vitejs/plugin-vue'
53
+ * export default meddlewareApp({ plugins: [vue()] })
54
+ * ```
55
+ *
56
+ * @param {object} options
57
+ * @param {import('vite').PluginOption[]} options.plugins The app's plugins (`vue()` …); `themeInit` is added after them.
58
+ * @param {'light' | 'dark' | 'system'} [options.theme] The mode a first visit gets (default `dark`, the brand default).
59
+ * @param {boolean} [options.wallet] The app uses `@meddleware/wallet-adapter` (default true): keep it out of pre-bundling.
60
+ * @param {import('vite').UserConfig} [options.config] Anything else (`base`, `build.rollupOptions`, `server` …); `build` is merged.
61
+ * @returns {import('vite').UserConfigFnObject}
62
+ */
63
+ export function meddlewareApp({ plugins, theme, wallet, config }: {
64
+ plugins: import("vite").PluginOption[];
65
+ theme?: "light" | "dark" | "system" | undefined;
66
+ wallet?: boolean | undefined;
67
+ config?: import("vite").UserConfig | undefined;
68
+ }): import("vite").UserConfigFnObject;
package/dist/vitest.d.ts CHANGED
@@ -4,6 +4,26 @@
4
4
  * renders a dialog; the browser's real behaviour (top layer, inert background) is checked by the Playwright suites, not jsdom.
5
5
  */
6
6
  export function installDialogShim(): void;
7
+ /**
8
+ * The Vitest configuration of a MeddleWare Vue repository: jsdom, `tests/**\/*.test.ts`, a single copy of Vue for the code under test and
9
+ * `@vue/test-utils`, and the UI packages processed through Vite ({@link inlineMeddleware}).
10
+ *
11
+ * ```ts
12
+ * import vue from '@vitejs/plugin-vue'
13
+ * export default defineConfig(vueTestConfig({ plugins: [vue()] }))
14
+ * ```
15
+ *
16
+ * @param {object} options
17
+ * @param {import('vite').PluginOption[]} options.plugins The Vue plugin (and any other the tests need).
18
+ * @param {import('vitest/config').TestUserConfig} [options.test] Overrides merged into `test` (`include`, `setupFiles`, `environment` …).
19
+ * @returns {import('vitest/config').ViteUserConfig & { test: import('vitest/config').TestUserConfig }}
20
+ */
21
+ export function vueTestConfig({ plugins, test }: {
22
+ plugins: import("vite").PluginOption[];
23
+ test?: import("vitest/node").TestUserConfig | undefined;
24
+ }): import("vitest/config").ViteUserConfig & {
25
+ test: import("vitest/config").TestUserConfig;
26
+ };
7
27
  /**
8
28
  * Packages to process through Vite in tests: the ones that import CSS or `.vue` files. Vitest matches these against the module's resolved
9
29
  * file path (`…/node_modules/@meddleware/ui/dist/…`), not the package name, so the patterns match the path.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meddleware/frontend-config",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "type": "module",
5
5
  "description": "Shared frontend tooling for MeddleWare apps: browser support, ESLint, Stylelint, html-validate, PostCSS, Playwright, axe, screenshots, Lighthouse and the Vite target.",
6
6
  "license": "0BSD",
@@ -22,7 +22,10 @@
22
22
  "files": [
23
23
  "src",
24
24
  "dist",
25
- "CHANGELOG.md"
25
+ "CHANGELOG.md",
26
+ "SECURITY.md",
27
+ "templates",
28
+ "tsconfig"
26
29
  ],
27
30
  "exports": {
28
31
  ".": {
@@ -81,7 +84,16 @@
81
84
  "./wallet": {
82
85
  "types": "./dist/wallet.d.ts",
83
86
  "default": "./src/wallet.js"
84
- }
87
+ },
88
+ "./bundle": {
89
+ "types": "./dist/bundle.d.ts",
90
+ "default": "./src/bundle.js"
91
+ },
92
+ "./csp": {
93
+ "types": "./dist/csp.d.ts",
94
+ "default": "./src/csp.js"
95
+ },
96
+ "./tsconfig/vue-app.json": "./tsconfig/vue-app.json"
85
97
  },
86
98
  "scripts": {
87
99
  "build": "rm -rf dist && tsc -p tsconfig.build.json",
@@ -157,5 +169,12 @@
157
169
  "last 2 Samsung versions",
158
170
  "Firefox ESR",
159
171
  "not dead"
160
- ]
172
+ ],
173
+ "bin": {
174
+ "check-prod-bundle": "./src/check-prod-bundle.js",
175
+ "mw-audit-gate": "./src/audit-gate.js",
176
+ "mw-third-party-licenses": "./src/third-party-licenses.js",
177
+ "mw-check-csp-inline": "./src/check-csp-inline.js",
178
+ "mw-sync": "./src/sync.js"
179
+ }
161
180
  }
package/src/a11y.js CHANGED
@@ -9,7 +9,8 @@ import { expect, test } from '@playwright/test'
9
9
  import { expectAccessible } from './axe.js'
10
10
  import { applyLook, looks, offline } from './look.js'
11
11
 
12
- /** @typedef {(page: import('@playwright/test').Page) => Promise<void>} State */
12
+ /** @typedef {(page: import('@playwright/test').Page) => Promise<void>} Open */
13
+ /** @typedef {Open | { open: Open, disableRules?: string[] }} State A state is how to open it, or that and the axe rules that do not apply to it (a lone dialog has no landmarks). */
13
14
 
14
15
  /**
15
16
  * The page has no horizontal scroll: nothing is wider than the viewport (WCAG 1.4.10 reflow, and 1.4.4 at a larger text size).
@@ -31,54 +32,154 @@ export async function expectNoRunningAnimations(page) {
31
32
  }
32
33
 
33
34
  /**
34
- * Tab through the page: every stop is visible and shows a focus indicator (an outline or a shadow), and the tab order ends (no trap).
35
+ * What the focused element looks like: visible, shows an indicator, and is not hidden behind other content. Runs in the page.
36
+ * @param {import('@playwright/test').Page} page
37
+ */
38
+ async function probeFocus(page) {
39
+ // A browser may animate the scroll that brings the focused control into view (WebKit does, and `scroll-behavior: smooth` makes the
40
+ // others): judge where it comes to rest, not where it passes through.
41
+ await page.evaluate(
42
+ () =>
43
+ new Promise((done) => {
44
+ let last = [scrollX, scrollY]
45
+ let still = 0
46
+ let frames = 0
47
+ const tick = () => {
48
+ const now = [scrollX, scrollY]
49
+ if (now[0] === last[0] && now[1] === last[1]) still++
50
+ else {
51
+ still = 0
52
+ last = now
53
+ }
54
+ if (still >= 3 || ++frames > 60) done(undefined)
55
+ else requestAnimationFrame(tick)
56
+ }
57
+ requestAnimationFrame(tick)
58
+ }),
59
+ )
60
+ return page.evaluate(() => {
61
+ const el = document.activeElement
62
+ // Once Tab has left the last control, Firefox hands focus to the browser's own chrome: the document no longer has focus, yet
63
+ // `activeElement` still names the control it left. That is the end of the tab order, not a stop.
64
+ if (!el || el === document.body || !document.hasFocus()) return null
65
+ // A visually hidden control (a radio or checkbox inside its label) shows focus on its label, so that is what is checked.
66
+ const tiny = el.getBoundingClientRect().width < 4 || el.getBoundingClientRect().height < 4
67
+ const target = tiny ? (el.closest('label') ?? el.parentElement ?? el) : el
68
+ const box = target.getBoundingClientRect()
69
+ const shows = (/** @type {Element} */ node) => {
70
+ const style = getComputedStyle(node)
71
+ return (style.outlineStyle !== 'none' && parseFloat(style.outlineWidth) > 0) || style.boxShadow !== 'none'
72
+ }
73
+ const indicator = shows(target) || (!tiny && shows(el)) || ['::before', '::after'].some((p) => {
74
+ const s = getComputedStyle(target, p)
75
+ return (s.outlineStyle !== 'none' && parseFloat(s.outlineWidth) > 0) || s.boxShadow !== 'none'
76
+ })
77
+ // WCAG 2.4.11 (focus not obscured): once focus has scrolled the stop into view, it is not entirely hidden behind sticky or fixed
78
+ // content. Five points are sampled (the centre and four inset corners of the part inside the viewport); hidden means all are covered.
79
+ const x0 = Math.max(box.left, 0)
80
+ const x1 = Math.min(box.right, innerWidth)
81
+ const y0 = Math.max(box.top, 0)
82
+ const y1 = Math.min(box.bottom, innerHeight)
83
+ const inView = x1 > x0 && y1 > y0
84
+ const samples = [[0.5, 0.5], [0.1, 0.1], [0.9, 0.1], [0.1, 0.9], [0.9, 0.9]]
85
+ /** @type {Element | null} */
86
+ let cover = null
87
+ const obscured = inView && samples.every(([fx, fy]) => {
88
+ const top = document.elementFromPoint(x0 + (x1 - x0) * /** @type {number} */ (fx), y0 + (y1 - y0) * /** @type {number} */ (fy))
89
+ cover = top
90
+ return !!top && !target.contains(top) && !top.contains(target)
91
+ })
92
+ const coveredBy = obscured && cover ? `${/** @type {Element} */ (cover).tagName.toLowerCase()}${/** @type {Element} */ (cover).id ? `#${/** @type {Element} */ (cover).id}` : ''}${/** @type {Element} */ (cover).className && typeof (/** @type {Element} */ (cover).className) === 'string' ? `.${String(/** @type {Element} */ (cover).className).trim().split(/\s+/).slice(0, 2).join('.')}` : ''}` : ''
93
+ // The walk is complete when focus comes back to an element it has already stopped on: elements are compared by identity (two links can
94
+ // share a label, as the same link often appears in a menu and in a sidebar), through a set the walk resets at its start.
95
+ const seen = /** @type {{ __mwSeen?: WeakSet<Element> }} */ (/** @type {unknown} */ (window)).__mwSeen
96
+ const repeat = !!seen && seen.has(el)
97
+ seen?.add(el)
98
+ const label = `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}${el.getAttribute('aria-label') ? `[${el.getAttribute('aria-label')}]` : ''} "${(target.textContent ?? '').trim().slice(0, 24)}"`
99
+ return { label, visible: box.width > 0 && box.height > 0, indicator, obscured, coveredBy, repeat }
100
+ })
101
+ }
102
+
103
+ /**
104
+ * Put the keyboard at the start of the document: nothing focused, the pointer out of the way, and the point sequential navigation continues
105
+ * from at the top (a button that was just clicked is where the next Tab would otherwise start).
106
+ * @param {import('@playwright/test').Page} page
107
+ */
108
+ async function startAtTop(page) {
109
+ // The pointer where an earlier click left it would keep a hover tooltip open over the controls the keyboard walk is about to reach.
110
+ await page.mouse.move(0, 0)
111
+ await page.evaluate(() => {
112
+ ;/** @type {{ __mwSeen?: WeakSet<Element> }} */ (/** @type {unknown} */ (window)).__mwSeen = new WeakSet()
113
+ const active = document.activeElement
114
+ if (active instanceof HTMLElement && active !== document.body) active.blur()
115
+ const selection = getSelection()
116
+ if (!selection) return
117
+ selection.removeAllRanges()
118
+ const range = document.createRange()
119
+ range.setStart(document.body, 0)
120
+ range.collapse(true)
121
+ selection.addRange(range)
122
+ })
123
+ }
124
+
125
+ /**
126
+ * Tab through the page: every stop is visible, shows a focus indicator (an outline or a shadow) and is not hidden behind sticky or fixed
127
+ * content, and the tab order ends (no trap). Then Shift+Tab back through it, so a stop reached from below is checked as well (browsers
128
+ * scroll a stop into view from the side it arrives from, so a sticky header or a bottom bar can cover it in only one direction).
35
129
  * @param {import('@playwright/test').Page} page
36
130
  * @param {{ maxStops?: number }} [options]
37
131
  */
38
- export async function expectKeyboardFocusVisible(page, { maxStops = 120 } = {}) {
132
+ export async function expectKeyboardFocusVisible(page, { maxStops = 300 } = {}) {
39
133
  /** @type {string[]} */
40
134
  const problems = []
41
135
  let stops = 0
42
- const seen = new Set()
136
+ let wrapped = false
137
+ let finished = false
138
+ await startAtTop(page)
43
139
  for (let i = 0; i < maxStops; i++) {
44
140
  await page.keyboard.press('Tab')
45
- const stop = await page.evaluate(() => {
46
- const el = document.activeElement
47
- if (!el || el === document.body) return null
48
- // A visually hidden control (a radio or checkbox inside its label) shows focus on its label, so that is what is checked.
49
- const tiny = el.getBoundingClientRect().width < 4 || el.getBoundingClientRect().height < 4
50
- const target = tiny ? (el.closest('label') ?? el.parentElement ?? el) : el
51
- const box = target.getBoundingClientRect()
52
- const shows = (/** @type {Element} */ node) => {
53
- const style = getComputedStyle(node)
54
- return (style.outlineStyle !== 'none' && parseFloat(style.outlineWidth) > 0) || style.boxShadow !== 'none'
141
+ const stop = await probeFocus(page)
142
+ if (!stop) {
143
+ // Focus left the document: the cycle is complete. But a page that is reached with focus already on its last control (a button just
144
+ // clicked) leaves on the first Tab, before any stop was seen: that press only wraps round to the start.
145
+ if (stops === 0 && !wrapped) {
146
+ wrapped = true
147
+ continue
55
148
  }
56
- const indicator = shows(target) || (!tiny && shows(el)) || ['::before', '::after'].some((p) => {
57
- const s = getComputedStyle(target, p)
58
- return (s.outlineStyle !== 'none' && parseFloat(s.outlineWidth) > 0) || s.boxShadow !== 'none'
59
- })
60
- const label = `${el.tagName.toLowerCase()}${el.id ? `#${el.id}` : ''}${el.getAttribute('aria-label') ? `[${el.getAttribute('aria-label')}]` : ''} "${(target.textContent ?? '').trim().slice(0, 24)}"`
61
- return { label, visible: box.width > 0 && box.height > 0, indicator }
62
- })
63
- if (!stop) break // focus left the document: the cycle is complete
64
- const key = stop.label
65
- if (seen.has(key) && seen.size > 1 && i > seen.size + 1) break
66
- seen.add(key)
149
+ finished = true
150
+ break
151
+ }
152
+ if (stop.repeat) {
153
+ finished = true // back at a stop already seen: a focus cycle (a modal dialog holds focus inside itself), so the walk is complete
154
+ break
155
+ }
67
156
  stops++
68
157
  if (!stop.visible) problems.push(`${stop.label} takes focus but is not visible`)
69
158
  if (!stop.indicator) problems.push(`${stop.label} shows no focus indicator`)
159
+ if (stop.obscured) problems.push(`${stop.label} is hidden behind ${stop.coveredBy || 'other content'} when focused (WCAG 2.4.11: add scroll-padding for sticky chrome)`)
70
160
  }
71
161
  expect(stops, 'the page has no keyboard stops').toBeGreaterThan(0)
72
- expect(problems).toEqual([])
162
+ // A walk that hit its cap has not seen the rest of the page: say so instead of passing it.
163
+ if (!finished) problems.push(`the tab order has more than ${maxStops} stops, so the rest of the page was not checked (raise maxStops for this state)`)
164
+ for (let i = 0; i < Math.min(stops, maxStops); i++) {
165
+ await page.keyboard.press('Shift+Tab')
166
+ const stop = await probeFocus(page)
167
+ if (!stop) break
168
+ if (stop.obscured) problems.push(`${stop.label} is hidden behind ${stop.coveredBy || 'other content'} when reached backwards (WCAG 2.4.11: add scroll-padding for sticky chrome)`)
169
+ }
170
+ expect([...new Set(problems)]).toEqual([])
73
171
  }
74
172
 
75
173
  /**
76
174
  * @param {Record<string, State>} states Opens each state of the app (navigates, mocks, opens the dialog).
77
- * @param {{ aaa?: 'gate' | 'report' | 'off' }} [options] How WCAG AAA findings are treated (default `report`; a repo moves to `gate`
78
- * in the release that clears them).
175
+ * @param {{ aaa?: 'gate' | 'report' | 'off', noKeyboard?: string[], maxStops?: number }} [options] How WCAG AAA findings are treated (default `report`; a repo
176
+ * moves to `gate` in the release that clears them); `noKeyboard` names states with nothing to operate (a static summary, a modal that
177
+ * only reports progress), which skip the keyboard pass: every other state must have at least one keyboard stop; `maxStops` (default 300) caps the keyboard walk, and a page with more stops fails rather than passing unchecked.
79
178
  */
80
- export function defineA11ySuite(states, { aaa = 'report' } = {}) {
81
- for (const [name, open] of Object.entries(states)) {
179
+ export function defineA11ySuite(states, { aaa = 'report', noKeyboard = [], maxStops } = {}) {
180
+ for (const [name, state] of Object.entries(states)) {
181
+ const open = typeof state === 'function' ? state : state.open
182
+ const disableRules = typeof state === 'function' ? [] : (state.disableRules ?? [])
82
183
  test.describe(name, () => {
83
184
  test.beforeEach(async ({ page }) => {
84
185
  await offline(page)
@@ -89,7 +190,7 @@ export function defineA11ySuite(states, { aaa = 'report' } = {}) {
89
190
  await page.emulateMedia({ colorScheme: /** @type {'light' | 'dark'} */ (theme), reducedMotion: 'reduce' })
90
191
  await open(page)
91
192
  await applyLook(page, theme, season)
92
- await expectAccessible(page, { aaa })
193
+ await expectAccessible(page, { aaa, disableRules })
93
194
  })
94
195
  }
95
196
 
@@ -97,21 +198,21 @@ export function defineA11ySuite(states, { aaa = 'report' } = {}) {
97
198
  await page.setViewportSize({ width: 320, height: 640 })
98
199
  await open(page)
99
200
  await expectNoHorizontalScroll(page)
100
- await expectAccessible(page, { aaa: 'off' })
201
+ await expectAccessible(page, { aaa: 'off', disableRules })
101
202
  })
102
203
 
103
204
  test('holds at 200% text size', async ({ page }) => {
104
205
  await open(page)
105
206
  await page.addStyleTag({ content: 'html { font-size: 200% !important; }' })
106
207
  await expectNoHorizontalScroll(page)
107
- await expectAccessible(page, { aaa: 'off' })
208
+ await expectAccessible(page, { aaa: 'off', disableRules })
108
209
  })
109
210
 
110
211
  test('works in forced colours', async ({ page }) => {
111
212
  await page.emulateMedia({ forcedColors: 'active' })
112
213
  await open(page)
113
214
  // The browser chooses the colours in forced-colours mode, so axe's contrast arithmetic on the declared ones does not apply.
114
- await expectAccessible(page, { aaa: 'off', disableRules: ['color-contrast'] })
215
+ await expectAccessible(page, { aaa: 'off', disableRules: [...disableRules, 'color-contrast'] })
115
216
  })
116
217
 
117
218
  test('holds still with reduced motion', async ({ page }) => {
@@ -120,10 +221,15 @@ export function defineA11ySuite(states, { aaa = 'report' } = {}) {
120
221
  await expectNoRunningAnimations(page)
121
222
  })
122
223
 
123
- test('can be used from the keyboard, and shows where focus is', async ({ page }) => {
124
- await open(page)
125
- await expectKeyboardFocusVisible(page)
126
- })
224
+ if (!noKeyboard.includes(name)) {
225
+ test('can be used from the keyboard, and shows where focus is', async ({ page }) => {
226
+ // The walk probes every stop twice (forward, then backward), and WebKit animates the scroll each one causes: a page of a
227
+ // hundred stops takes about half a minute there, longer than the default 30 s. Scale the limit with the cap.
228
+ test.setTimeout(Math.max(60_000, (maxStops || 300) * 400))
229
+ await open(page)
230
+ await expectKeyboardFocusVisible(page, maxStops ? { maxStops } : {})
231
+ })
232
+ }
127
233
  })
128
234
  }
129
235
  }
@@ -0,0 +1,67 @@
1
+ #!/usr/bin/env node
2
+ // mw-audit-gate — CI dependency gate: `npm audit` at high/critical, minus advisories listed in
3
+ // .github/audit-allowlist.json. Each entry needs a reason and an expiry date, so an
4
+ // accepted advisory is re-reviewed instead of being ignored forever.
5
+ import { execFileSync } from 'node:child_process'
6
+ import { existsSync, readFileSync } from 'node:fs'
7
+
8
+ const ALLOWLIST = '.github/audit-allowlist.json'
9
+ const LEVELS = new Set(['high', 'critical'])
10
+ const GHSA = /^GHSA(-[23456789cfghjmpqrvwx]{4}){3}$/
11
+ const DATE = /^\d{4}-\d{2}-\d{2}$/
12
+
13
+ let failed = false
14
+ /** @param {string} msg */
15
+ const fail = (msg) => {
16
+ console.log(`::error::${msg}`)
17
+ failed = true
18
+ }
19
+
20
+ const entries = existsSync(ALLOWLIST) ? JSON.parse(readFileSync(ALLOWLIST, 'utf8')) : []
21
+ if (!Array.isArray(entries)) throw new Error(`${ALLOWLIST} must be an array`)
22
+ const today = new Date().toISOString().slice(0, 10)
23
+ const allowed = new Map()
24
+ for (const e of entries) {
25
+ if (!GHSA.test(e?.id) || typeof e.reason !== 'string' || !e.reason || !DATE.test(e.expires)) {
26
+ fail(`invalid allowlist entry ${JSON.stringify(e)} (needs id, reason, expires YYYY-MM-DD)`)
27
+ } else if (e.expires < today) {
28
+ fail(`allowlist entry ${e.id} expired on ${e.expires}; re-review it`)
29
+ } else {
30
+ allowed.set(e.id, e)
31
+ }
32
+ }
33
+
34
+ let raw
35
+ try {
36
+ raw = execFileSync('npm', ['audit', '--json'], { encoding: 'utf8', maxBuffer: 64 << 20 })
37
+ } catch (err) {
38
+ // npm audit exits non-zero whenever it reports anything; the JSON is still on stdout.
39
+ raw = /** @type {{ stdout: string }} */ (err).stdout
40
+ }
41
+ const report = JSON.parse(raw)
42
+ if (report.error) throw new Error(`npm audit failed: ${JSON.stringify(report.error)}`)
43
+
44
+ const found = new Map()
45
+ for (const vuln of Object.values(report.vulnerabilities ?? {})) {
46
+ for (const via of vuln.via) {
47
+ if (typeof via === 'object' && LEVELS.has(via.severity)) {
48
+ found.set(via.url.split('/').pop(), via)
49
+ }
50
+ }
51
+ }
52
+
53
+ for (const [id, via] of found) {
54
+ const entry = allowed.get(id)
55
+ if (entry) {
56
+ console.log(`::warning::${id} (${via.name}, ${via.severity}) allowed until ${entry.expires}: ${entry.reason}`)
57
+ } else {
58
+ fail(`${id} (${via.name}, ${via.severity}): ${via.title}`)
59
+ }
60
+ }
61
+ for (const id of allowed.keys()) {
62
+ if (!found.has(id)) console.log(`::warning::allowlist entry ${id} no longer matches an advisory; remove it`)
63
+ }
64
+
65
+ const open = [...found.keys()].filter((id) => !allowed.has(id)).length
66
+ console.log(`${found.size} high/critical advisories, ${open} not allowlisted`)
67
+ process.exit(failed ? 1 : 0)
package/src/bundle.js ADDED
@@ -0,0 +1,57 @@
1
+ // @ts-check
2
+ // Production-artefact scan for the end-to-end harness. An app's e2e build (`vite build --mode e2e`) registers a mock wallet, a stub chain
3
+ // and sometimes a stand-in for a module that needs a live service; none of it may reach a production build. The app's entry loads the
4
+ // harness only when the mode is `e2e` and `VITE_E2E` is `1` (both statically replaced), so every other build drops the block. This scan
5
+ // proves it on the built files instead of trusting that: it fails when a hook or marker is found in `dist/`.
6
+ //
7
+ // // package.json: "check:bundle": "check-prod-bundle dist"
8
+ // // CI, after the production build; and in the Dockerfile, right after `npm run build`
9
+ // // an app with its own stand-in adds a marker: "check-prod-bundle dist --also 'e2e session key'"
10
+ import { readFileSync, readdirSync, statSync } from 'node:fs'
11
+ import { join, relative } from 'node:path'
12
+
13
+ /**
14
+ * What the shared harness (`@meddleware/wallet-adapter/testing` `installE2eWallet`, `@meddleware/frontend-config/wallet`) leaves in a
15
+ * bundle that includes it: the hooks it puts on `window`. Property names survive minification.
16
+ */
17
+ export const E2E_MARKERS = Object.freeze([
18
+ { name: '__registerMockWallet hook', re: /__registerMockWallet/ },
19
+ { name: '__unregisterMockWallet hook', re: /__unregisterMockWallet/ },
20
+ { name: '__e2eAddress hook', re: /__e2eAddress/ },
21
+ { name: '__e2eWalletAddress hook', re: /__e2eWalletAddress/ },
22
+ { name: '__e2eSignTransaction hook', re: /__e2eSignTransaction/ },
23
+ { name: '__e2eSignPersonalMessage hook', re: /__e2eSignPersonalMessage/ },
24
+ { name: '__getSuiClient hook', re: /__getSuiClient/ },
25
+ ])
26
+
27
+ /** @param {string} dir @returns {Generator<string>} */
28
+ function* files(dir) {
29
+ for (const name of readdirSync(dir)) {
30
+ const path = join(dir, name)
31
+ if (statSync(path).isDirectory()) yield* files(path)
32
+ else if (/\.(m?js|html)$/.test(name)) yield path
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Scan the built files under `dist` for the harness.
38
+ * @param {string} dist The build output directory.
39
+ * @param {{ also?: (string | RegExp)[] }} [options] Extra markers: an app's own stand-in (a string is matched literally).
40
+ * @returns {{ scanned: number, hits: string[] }}
41
+ * @throws When `dist` cannot be read.
42
+ */
43
+ export function scanBundle(dist, { also = [] } = {}) {
44
+ const markers = [
45
+ ...E2E_MARKERS,
46
+ ...also.map((m) => ({ name: `marker ${String(m)}`, re: typeof m === 'string' ? new RegExp(m.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) : m })),
47
+ ]
48
+ let scanned = 0
49
+ /** @type {string[]} */
50
+ const hits = []
51
+ for (const file of files(dist)) {
52
+ scanned++
53
+ const text = readFileSync(file, 'utf8')
54
+ for (const { name, re } of markers) if (re.test(text)) hits.push(`${relative(dist, file)}: ${name}`)
55
+ }
56
+ return { scanned, hits }
57
+ }
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ // Fails the build when dist/ contains an inline <script> that the CSP's script-src does not allow, or when script-src allows
3
+ // 'unsafe-inline'. VitePress inlines a couple of small scripts (the appearance/theme bootstrap and the page-hash map); the image serves a
4
+ // hash-based CSP, so a VitePress upgrade (or a change of the `appearance` option) that changes them must update the Dockerfile's CSP ARG
5
+ // in the same change — this check makes that impossible to miss, in CI as well as in the image build.
6
+ //
7
+ // Usage: mw-check-csp-inline "<csp>" [distDir]
8
+ // mw-check-csp-inline @Dockerfile [distDir] (reads the Dockerfile's `ARG CSP="…"` default)
9
+ import { createHash } from 'node:crypto'
10
+ import { readdirSync, readFileSync, statSync } from 'node:fs'
11
+ import { join } from 'node:path'
12
+
13
+ const [source, dist = 'dist'] = process.argv.slice(2)
14
+ if (!source) {
15
+ console.error('usage: mw-check-csp-inline "<csp>"|@Dockerfile [distDir]')
16
+ process.exit(2)
17
+ }
18
+
19
+ let csp = source
20
+ if (source.startsWith('@')) {
21
+ const m = readFileSync(source.slice(1), 'utf8').match(/^ARG CSP="([^"]*)"/m)
22
+ if (!m?.[1]) {
23
+ console.error(`CSP check: no 'ARG CSP="…"' default found in ${source.slice(1)}`)
24
+ process.exit(2)
25
+ }
26
+ csp = m[1]
27
+ }
28
+
29
+ const scriptSrc = (csp.split(';').map((d) => d.trim()).find((d) => d.startsWith('script-src ')) ?? '').split(/\s+/)
30
+ if (scriptSrc.includes("'unsafe-inline'")) {
31
+ console.error("CSP check: script-src allows 'unsafe-inline'; the page must not.")
32
+ process.exit(1)
33
+ }
34
+ const allowed = new Set(scriptSrc.filter((t) => t.startsWith("'sha256-")).map((t) => t.slice(1, -1)))
35
+
36
+ /**
37
+ * @param {string} dir
38
+ * @returns {Generator<string>}
39
+ */
40
+ function* htmlFiles(dir) {
41
+ for (const name of readdirSync(dir)) {
42
+ const p = join(dir, name)
43
+ if (statSync(p).isDirectory()) yield* htmlFiles(p)
44
+ else if (name.endsWith('.html')) yield p
45
+ }
46
+ }
47
+
48
+ const missing = new Map()
49
+ let files = 0
50
+ for (const file of htmlFiles(dist)) {
51
+ files++
52
+ const html = readFileSync(file, 'utf8')
53
+ for (const m of html.matchAll(/<script(?![^>]*\bsrc=)[^>]*>([\s\S]*?)<\/script>/g)) {
54
+ if (!m[1].trim()) continue
55
+ const hash = 'sha256-' + createHash('sha256').update(m[1]).digest('base64')
56
+ if (!allowed.has(hash)) missing.set(hash, file)
57
+ }
58
+ }
59
+ if (files === 0) {
60
+ console.error(`CSP check: no HTML file found in ${dist}; build first.`)
61
+ process.exit(2)
62
+ }
63
+ if (missing.size) {
64
+ console.error(`CSP check: ${missing.size} inline script(s) not allowed by script-src:`)
65
+ for (const [hash, file] of missing) console.error(` '${hash}' (first seen in ${file})`)
66
+ process.exit(1)
67
+ }
68
+ console.log(`CSP check: every inline script in ${files} HTML file(s) is allowed by script-src.`)
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // CLI for `scanBundle`: `check-prod-bundle [dist] [--also TEXT]... [--also-regex PATTERN]...` (text is matched literally). Exit 1 when the harness is found, when `dist` is missing or when it
4
+ // holds no .js/.html file (a build that has not run must not pass).
5
+ import { scanBundle } from './bundle.js'
6
+
7
+ const args = process.argv.slice(2)
8
+ /** @type {(string | RegExp)[]} */
9
+ const also = []
10
+ /** @type {string[]} */
11
+ const rest = []
12
+ for (let i = 0; i < args.length; i++) {
13
+ if (args[i] === '--also' || args[i] === '--also-regex') {
14
+ const flag = args[i]
15
+ const marker = args[++i]
16
+ if (!marker) {
17
+ console.error(`check-prod-bundle: ${flag} needs a marker`)
18
+ process.exit(2)
19
+ }
20
+ try {
21
+ also.push(flag === '--also' ? marker : new RegExp(marker))
22
+ } catch {
23
+ console.error(`check-prod-bundle: ${marker} is not a valid pattern`)
24
+ process.exit(2)
25
+ }
26
+ } else rest.push(/** @type {string} */ (args[i]))
27
+ }
28
+ const dist = rest[0] ?? 'dist'
29
+
30
+ let result
31
+ try {
32
+ result = scanBundle(dist, { also })
33
+ } catch (e) {
34
+ console.error(`check-prod-bundle: cannot read ${dist}/ (${/** @type {Error} */ (e).message}) — build first.`)
35
+ process.exit(1)
36
+ }
37
+ if (result.scanned === 0) {
38
+ console.error(`check-prod-bundle: no .js/.html files under ${dist}/ — build first.`)
39
+ process.exit(1)
40
+ }
41
+ if (result.hits.length > 0) {
42
+ console.error(`check-prod-bundle: the e2e harness is in the production bundle:\n ${result.hits.join('\n ')}`)
43
+ process.exit(1)
44
+ }
45
+ console.log(`check-prod-bundle: ${result.scanned} files clean (no e2e hooks${also.length ? ` or the ${also.length} extra marker(s)` : ''}).`)
package/src/csp.js ADDED
@@ -0,0 +1,67 @@
1
+ // @ts-check
2
+ // The Content-Security-Policy of a MeddleWare static site, built from a named profile. Every frontend image serves one (the Dockerfile's `ARG CSP`,
3
+ // handed to static-server): twelve hand-copied strings drifted in small ways, so the profiles live here and `mw-sync` checks each Dockerfile against its
4
+ // profile. A policy has no 'unsafe-inline' for scripts (`mw-check-csp-inline` fails the build on an inline script that is not allowed by hash).
5
+
6
+ /**
7
+ * @typedef {object} CspOptions
8
+ * @property {boolean} [wasm] allow WebAssembly (`'wasm-unsafe-eval'`): the Walrus and Move-bytecode clients
9
+ * @property {boolean} [chain] chain-facing app: https images, https connections (RPC, relay, aggregator and key-server hosts are partly configured), blob workers
10
+ * @property {boolean} [worker] allow `worker-src 'self' blob:` without the rest of `chain` (VitePress sites)
11
+ * @property {boolean} [strict] `base-uri` and `form-action` are `'none'` (a page with no forms and no `<base>`)
12
+ * @property {'self' | 'none'} [frameAncestors] who may frame the page (default `'self'`)
13
+ * @property {string[]} [scriptHashes] `'sha256-…'` sources for the inline scripts a static-site generator emits
14
+ */
15
+
16
+ /** @type {Record<string, CspOptions>} */
17
+ export const PROFILES = {
18
+ /** Same-origin only: the landing page. */
19
+ static: {},
20
+ /** Same-origin only, no forms and no `<base>`: the status page and the public roadmap. */
21
+ 'static-strict': { strict: true },
22
+ /** An operator console: same-origin only and never framed. */
23
+ console: { frameAncestors: 'none' },
24
+ /** A chain-facing tool: https connections and images, blob workers. */
25
+ chain: { chain: true },
26
+ /** A chain-facing tool that runs WebAssembly (Walrus, Move bytecode). */
27
+ 'chain-wasm': { chain: true, wasm: true },
28
+ /** A VitePress site: same-origin connections, https images, blob workers, inline scripts allowed by hash (the hashes are kept from the current policy). */
29
+ docs: { worker: true },
30
+ }
31
+
32
+ /**
33
+ * @param {CspOptions} options
34
+ * @returns {string}
35
+ */
36
+ export function buildCsp(options = {}) {
37
+ const { wasm = false, chain = false, worker = false, strict = false, frameAncestors = 'self', scriptHashes = [] } = options
38
+ const script = ["'self'", ...(wasm ? ["'wasm-unsafe-eval'"] : []), ...scriptHashes.map((h) => `'${h.replace(/^'|'$/g, '')}'`)]
39
+ const richImages = chain || worker
40
+ const directives = [
41
+ ["default-src", "'self'"],
42
+ ['script-src', script.join(' ')],
43
+ ['style-src', "'self' 'unsafe-inline'"],
44
+ ['img-src', richImages ? "'self' data: blob: https:" : "'self' data:"],
45
+ ['font-src', "'self' data:"],
46
+ ['connect-src', chain ? "'self' https:" : "'self'"],
47
+ ...(chain || worker ? [['worker-src', "'self' blob:"]] : []),
48
+ ['object-src', "'none'"],
49
+ ['base-uri', strict ? "'none'" : "'self'"],
50
+ ['form-action', strict ? "'none'" : "'self'"],
51
+ ['frame-ancestors', `'${frameAncestors}'`],
52
+ ]
53
+ return [...directives.map(([d, v]) => `${d} ${v}`), 'upgrade-insecure-requests'].join('; ')
54
+ }
55
+
56
+ /** The `'sha256-…'` hashes in a policy's script-src (kept when a profile such as `docs` is rebuilt). */
57
+ export function scriptHashesOf(/** @type {string} */ csp) {
58
+ const src = csp.split(';').map((d) => d.trim()).find((d) => d.startsWith('script-src ')) ?? ''
59
+ return src.split(/\s+/).filter((t) => t.startsWith("'sha256-")).map((t) => t.slice(1, -1))
60
+ }
61
+
62
+ /** @param {string} name @param {string} [current] the policy now in the Dockerfile (its script hashes are kept) */
63
+ export function cspForProfile(name, current = '') {
64
+ const profile = PROFILES[name]
65
+ if (!profile) throw new Error(`unknown CSP profile "${name}" (known: ${Object.keys(PROFILES).join(', ')})`)
66
+ return buildCsp({ ...profile, ...(name === 'docs' ? { scriptHashes: scriptHashesOf(current) } : {}) })
67
+ }
package/src/sync.js ADDED
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ // mw-sync — keep the files that every MeddleWare frontend repository copies from this package identical to the package's templates, and the
4
+ // Dockerfile's Content-Security-Policy equal to its named profile. Run in a repository root:
5
+ //
6
+ // mw-sync --check fail (exit 1) when a file differs; print which and how to fix
7
+ // mw-sync --write overwrite the files with the templates / the profile's policy
8
+ //
9
+ // The repository declares what it is in package.json:
10
+ // "meddleware": { "kind": "app", "csp": "chain-wasm", "lhci": true }
11
+ // kind "app" → .github/dependabot.yml, .dockerignore, postcss.config.js, stylelint.config.js are the templates
12
+ // csp a profile of ./csp.js → the Dockerfile's `ARG CSP="…"` is that profile's policy (`docs` keeps the script hashes already there)
13
+ // lhci true → .github/audit-allowlist.json is the template (the dated allowlist for the Lighthouse CI tool chain; one expiry, reviewed together)
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
15
+ import { dirname, join } from 'node:path'
16
+ import { fileURLToPath } from 'node:url'
17
+ import { cspForProfile } from './csp.js'
18
+
19
+ const templates = join(dirname(fileURLToPath(import.meta.url)), '..', 'templates')
20
+ const tpl = (/** @type {string} */ name) => readFileSync(join(templates, name), 'utf8')
21
+
22
+ /**
23
+ * @param {string} root repository root
24
+ * @param {{ write?: boolean }} [options]
25
+ * @returns {{ file: string, ok: boolean, note: string }[]}
26
+ */
27
+ export function sync(root, options = {}) {
28
+ const pkgPath = join(root, 'package.json')
29
+ const pkg = existsSync(pkgPath) ? JSON.parse(readFileSync(pkgPath, 'utf8')) : {}
30
+ const cfg = pkg.meddleware ?? {}
31
+ /** @type {{ file: string, ok: boolean, note: string }[]} */
32
+ const out = []
33
+ /** @param {string} file @param {string} want */
34
+ const expectFile = (file, want) => {
35
+ const p = join(root, file)
36
+ const have = existsSync(p) ? readFileSync(p, 'utf8') : null
37
+ if (have === want) return out.push({ file, ok: true, note: 'matches the template' })
38
+ if (options.write) {
39
+ mkdirSync(dirname(p), { recursive: true })
40
+ writeFileSync(p, want)
41
+ return out.push({ file, ok: true, note: have === null ? 'created' : 'rewritten' })
42
+ }
43
+ out.push({ file, ok: false, note: have === null ? 'missing (mw-sync --write creates it)' : 'differs from the template (mw-sync --write restores it)' })
44
+ }
45
+ if (cfg.kind === 'app') {
46
+ expectFile('.github/dependabot.yml', tpl('dependabot.app.yml'))
47
+ expectFile('.dockerignore', tpl('dockerignore'))
48
+ expectFile('postcss.config.js', tpl('postcss.config.js'))
49
+ expectFile('stylelint.config.js', tpl('stylelint.config.js'))
50
+ }
51
+ if (cfg.lhci === true) expectFile('.github/audit-allowlist.json', tpl('audit-allowlist.lhci.json'))
52
+ if (cfg.csp) {
53
+ const df = join(root, 'Dockerfile')
54
+ const text = existsSync(df) ? readFileSync(df, 'utf8') : ''
55
+ const m = text.match(/^ARG CSP="([^"]*)"/m)
56
+ if (!m) out.push({ file: 'Dockerfile', ok: false, note: `no 'ARG CSP="…"' default found (profile ${cfg.csp})` })
57
+ else {
58
+ const want = cspForProfile(cfg.csp, m[1])
59
+ if (m[1] === want) out.push({ file: 'Dockerfile', ok: true, note: `CSP equals the "${cfg.csp}" profile` })
60
+ else if (options.write) {
61
+ writeFileSync(df, text.replace(/^ARG CSP="[^"]*"/m, () => `ARG CSP="${want}"`))
62
+ out.push({ file: 'Dockerfile', ok: true, note: `CSP rewritten to the "${cfg.csp}" profile` })
63
+ } else out.push({ file: 'Dockerfile', ok: false, note: `CSP differs from the "${cfg.csp}" profile:\n have ${m[1]}\n want ${want}` })
64
+ }
65
+ }
66
+ if (out.length === 0) out.push({ file: 'package.json', ok: false, note: 'no "meddleware" section: nothing to check (add kind / csp / lhci)' })
67
+ return out
68
+ }
69
+
70
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
71
+ const args = process.argv.slice(2)
72
+ const write = args.includes('--write')
73
+ if (!write && !args.includes('--check')) {
74
+ console.error('usage: mw-sync --check | --write')
75
+ process.exit(2)
76
+ }
77
+ const results = sync(process.cwd(), { write })
78
+ for (const r of results) console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.file}: ${r.note}`)
79
+ process.exit(results.every((r) => r.ok) ? 0 : 1)
80
+ }
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ // Writes dist/THIRD_PARTY_LICENSES: the licence and NOTICE texts of every production dependency in
3
+ // package-lock.json. The site ships a bundle, so the packages' own files do not travel with it;
4
+ // Apache-2.0 (§4), MIT and BSD all require the notice to accompany a redistribution. The file is served
5
+ // with the site (/THIRD_PARTY_LICENSES) and read by licence audits.
6
+ //
7
+ // mw-third-party-licenses write the file
8
+ // mw-third-party-licenses --check also fail when the file would be empty
9
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
10
+ import { join } from 'node:path'
11
+
12
+ // The project root is the directory the command runs in (an app's npm script), not this package.
13
+ const root = process.cwd()
14
+ const lock = JSON.parse(readFileSync(join(root, 'package-lock.json'), 'utf8'))
15
+ const check = process.argv.includes('--check')
16
+ const LICENSE_FILE = /^(licen[cs]e|notice|copying|unlicense)(\.|$|-)/i
17
+
18
+ const out = []
19
+ const missing = []
20
+ const apacheMissing = []
21
+ /** @param {any} pkg */
22
+ const declared = (pkg) => (typeof pkg.license === 'string' ? pkg.license : JSON.stringify(pkg.license ?? pkg.licenses ?? 'unknown'))
23
+ /** @param {any} pkg */
24
+ const holder = (pkg) =>
25
+ [pkg.author && (typeof pkg.author === 'string' ? pkg.author : pkg.author.name), pkg.repository && (typeof pkg.repository === 'string' ? pkg.repository : pkg.repository.url)]
26
+ .filter(Boolean)
27
+ .join(' · ')
28
+ const names = Object.keys(lock.packages)
29
+ .filter((p) => p.startsWith('node_modules/') && !lock.packages[p].dev && !lock.packages[p].optional)
30
+ .sort()
31
+ for (const p of names) {
32
+ const dir = join(root, p)
33
+ if (!existsSync(dir)) continue
34
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
35
+ const files = readdirSync(dir).filter((f) => LICENSE_FILE.test(f)).sort()
36
+ out.push(`${'='.repeat(78)}\n${pkg.name}@${pkg.version} (${declared(pkg)})\n${'='.repeat(78)}`)
37
+ if (files.length === 0) {
38
+ missing.push(`${pkg.name}@${pkg.version}`)
39
+ if (/Apache-2\.0/.test(declared(pkg))) apacheMissing.push(`${pkg.name}@${pkg.version}`)
40
+ }
41
+ for (const f of files) out.push(`--- ${f} ---\n${readFileSync(join(dir, f), 'utf8').trim()}\n`)
42
+ if (files.length === 0) {
43
+ const apache = /Apache-2\.0/.test(declared(pkg))
44
+ out.push(`(the package ships no licence file; declared licence: ${declared(pkg)}${holder(pkg) ? `; ${holder(pkg)}` : ''}${apache ? '; the Apache License 2.0 text follows at the end of this file' : ''})\n`)
45
+ }
46
+ }
47
+ // Apache-2.0 §4(a) asks that a recipient gets a copy of the License. A package that declares it but ships no
48
+ // file is covered by the canonical text, taken from any installed package that carries it.
49
+ if (apacheMissing.length > 0) {
50
+ let text = null
51
+ for (const p of Object.keys(lock.packages).filter((k) => k.startsWith('node_modules/'))) {
52
+ const dir = join(root, p)
53
+ if (!existsSync(dir)) continue
54
+ for (const f of readdirSync(dir).filter((x) => LICENSE_FILE.test(x))) {
55
+ const t = readFileSync(join(dir, f), 'utf8')
56
+ if (/Apache License\s+Version 2\.0, January 2004/.test(t) && /END OF TERMS AND CONDITIONS/.test(t)) {
57
+ text = t.slice(0, t.indexOf('END OF TERMS AND CONDITIONS') + 'END OF TERMS AND CONDITIONS'.length).trim()
58
+ break
59
+ }
60
+ }
61
+ if (text) break
62
+ }
63
+ out.push(`${'='.repeat(78)}\nApache License 2.0 — applies to: ${apacheMissing.join(', ')}\n${'='.repeat(78)}`)
64
+ out.push(text ?? '(canonical text not found in the installed packages: see https://www.apache.org/licenses/LICENSE-2.0)')
65
+ if (check && text === null) {
66
+ console.error('Apache-2.0 packages without a licence file, and no canonical Apache License text installed')
67
+ process.exit(1)
68
+ }
69
+ }
70
+ mkdirSync(join(root, 'dist'), { recursive: true })
71
+ writeFileSync(join(root, 'dist', 'THIRD_PARTY_LICENSES'), `${out.join('\n')}\n`)
72
+ console.log(`THIRD_PARTY_LICENSES: ${names.length} production packages, ${missing.length} without a licence file`)
73
+ if (missing.length) console.log(` no licence file: ${missing.join(', ')}`)
74
+ if (check && out.length === 0) {
75
+ console.error('no production packages found in package-lock.json')
76
+ process.exit(1)
77
+ }
package/src/vite.js CHANGED
@@ -85,3 +85,34 @@ export function assertE2eOnlyInE2eMode(mode, env = process.env) {
85
85
  throw new Error(`VITE_E2E is set but the build mode is "${mode}": the end-to-end build is \`vite build --mode e2e\`, and nothing else may include the test wallet.`)
86
86
  }
87
87
  }
88
+
89
+ /**
90
+ * The Vite configuration every MeddleWare tool app shares: the browserslist build target, the colour mode and season before first paint
91
+ * ({@link themeInit}), the e2e-build guard ({@link assertE2eOnlyInE2eMode}), and `@meddleware/wallet-adapter` excluded from dependency
92
+ * pre-bundling. The wallet adapter ships TS and `.vue` source and holds the shared wallet singleton; pre-bundling would put its TS in
93
+ * `.vite/deps` while its `.vue` files (which esbuild cannot bundle) are served raw and import the raw module, so dev would run two
94
+ * singletons and the guard and the app would see different connection state.
95
+ *
96
+ * ```ts
97
+ * import vue from '@vitejs/plugin-vue'
98
+ * export default meddlewareApp({ plugins: [vue()] })
99
+ * ```
100
+ *
101
+ * @param {object} options
102
+ * @param {import('vite').PluginOption[]} options.plugins The app's plugins (`vue()` …); `themeInit` is added after them.
103
+ * @param {'light' | 'dark' | 'system'} [options.theme] The mode a first visit gets (default `dark`, the brand default).
104
+ * @param {boolean} [options.wallet] The app uses `@meddleware/wallet-adapter` (default true): keep it out of pre-bundling.
105
+ * @param {import('vite').UserConfig} [options.config] Anything else (`base`, `build.rollupOptions`, `server` …); `build` is merged.
106
+ * @returns {import('vite').UserConfigFnObject}
107
+ */
108
+ export function meddlewareApp({ plugins, theme = 'dark', wallet = true, config = {} }) {
109
+ return ({ mode }) => {
110
+ assertE2eOnlyInE2eMode(mode)
111
+ return {
112
+ ...config,
113
+ build: { target: viteTarget(), ...config.build },
114
+ plugins: [...plugins, themeInit({ default: theme })],
115
+ ...(wallet ? { optimizeDeps: { ...config.optimizeDeps, exclude: ['@meddleware/wallet-adapter', ...(config.optimizeDeps?.exclude ?? [])] } } : {}),
116
+ }
117
+ }
118
+ }
package/src/vitest.js CHANGED
@@ -50,3 +50,32 @@ export function installDialogShim() {
50
50
  this.dispatchEvent(new Event('close'))
51
51
  }
52
52
  }
53
+
54
+ /**
55
+ * The Vitest configuration of a MeddleWare Vue repository: jsdom, `tests/**\/*.test.ts`, a single copy of Vue for the code under test and
56
+ * `@vue/test-utils`, and the UI packages processed through Vite ({@link inlineMeddleware}).
57
+ *
58
+ * ```ts
59
+ * import vue from '@vitejs/plugin-vue'
60
+ * export default defineConfig(vueTestConfig({ plugins: [vue()] }))
61
+ * ```
62
+ *
63
+ * @param {object} options
64
+ * @param {import('vite').PluginOption[]} options.plugins The Vue plugin (and any other the tests need).
65
+ * @param {import('vitest/config').TestUserConfig} [options.test] Overrides merged into `test` (`include`, `setupFiles`, `environment` …).
66
+ * @returns {import('vitest/config').ViteUserConfig & { test: import('vitest/config').TestUserConfig }}
67
+ */
68
+ export function vueTestConfig({ plugins, test = {} }) {
69
+ const inline = test.server?.deps?.inline
70
+ return {
71
+ plugins,
72
+ resolve: { dedupe: ['vue', '@vue/test-utils'] },
73
+ test: {
74
+ environment: 'jsdom',
75
+ include: ['tests/**/*.test.ts'],
76
+ exclude: ['node_modules/**'],
77
+ ...test,
78
+ server: { ...test.server, deps: { ...test.server?.deps, inline: inline === true ? true : [...inlineMeddleware, ...(inline ?? [])] } },
79
+ },
80
+ }
81
+ }
@@ -0,0 +1,12 @@
1
+ [
2
+ {
3
+ "id": "GHSA-jmr9-qjv8-65gv",
4
+ "reason": "extract-zip has no patched release. It is a dev-only transitive of @lhci/cli (lighthouse > puppeteer-core > @puppeteer/browsers) and only unpacks browser archives that Lighthouse CI downloads itself; nothing from it ships in the image.",
5
+ "expires": "2027-01-31"
6
+ },
7
+ {
8
+ "id": "GHSA-7pqw-9j4j-h8q3",
9
+ "reason": "extract-zip has no patched release. It is a dev-only transitive of @lhci/cli (lighthouse > puppeteer-core > @puppeteer/browsers) and only unpacks browser archives that Lighthouse CI downloads itself; nothing from it ships in the image.",
10
+ "expires": "2027-01-31"
11
+ }
12
+ ]
@@ -0,0 +1,32 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: npm
4
+ directory: /
5
+ schedule:
6
+ interval: weekly
7
+ open-pull-requests-limit: 5
8
+ ignore:
9
+ - dependency-name: "typescript"
10
+ update-types: ["version-update:semver-major"]
11
+ groups:
12
+ npm-minor-patch:
13
+ update-types: [minor, patch]
14
+ - package-ecosystem: docker
15
+ directory: /
16
+ schedule:
17
+ interval: weekly
18
+ open-pull-requests-limit: 5
19
+ ignore:
20
+ - dependency-name: "golang"
21
+ update-types: ["version-update:semver-minor", "version-update:semver-major"]
22
+ groups:
23
+ docker:
24
+ patterns: ['*']
25
+ - package-ecosystem: github-actions
26
+ directory: /
27
+ schedule:
28
+ interval: weekly
29
+ open-pull-requests-limit: 5
30
+ groups:
31
+ github-actions:
32
+ patterns: ['*']
@@ -0,0 +1,13 @@
1
+ # Build context: sources only. The image runs its own `npm ci` and production build, so local
2
+ # installs and build output never enter it — and local-only env files (Vite loads `.env*.local` in
3
+ # every mode) can never be baked into the production bundle. Committed `.env` / `.env.production`
4
+ # defaults stay in the context on purpose.
5
+ node_modules
6
+ dist
7
+ .git
8
+ .github
9
+ .env*.local
10
+ *.log
11
+ test-results
12
+ playwright-report
13
+ coverage
@@ -0,0 +1 @@
1
+ export { default } from '@meddleware/frontend-config/postcss'
@@ -0,0 +1,3 @@
1
+ import { stylelintConfig } from '@meddleware/frontend-config/stylelint'
2
+
3
+ export default stylelintConfig()
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/tsconfig",
3
+ "compilerOptions": {
4
+ "target": "ESNext",
5
+ "module": "ESNext",
6
+ "moduleResolution": "Bundler",
7
+ "lib": ["ESNext", "DOM", "DOM.Iterable"],
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "resolveJsonModule": true,
12
+ "isolatedModules": true,
13
+ "verbatimModuleSyntax": true,
14
+ "jsx": "preserve",
15
+ "types": ["node"],
16
+ "noEmit": true
17
+ }
18
+ }