diff --git a/.gitignore b/.gitignore index 055b45a..cfde346 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ /.cache /vendor /node_modules +/.pnpm-store /web/static /web/cpresources /web/assets diff --git a/.zed/settings.json b/.zed/settings.json index c4ba147..7de0777 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -16,14 +16,14 @@ "JavaScript": { "format_on_save": "on", "prettier": { "allowed": false }, - "language_servers": ["oxlint", "oxfmt", "tsgo"], + "language_servers": ["oxlint", "oxfmt", "typescript-ls"], "formatter": [{ "language_server": { "name": "oxfmt" } }], "code_actions_on_format": { "source.fixAll.oxc": true } }, "TypeScript": { "format_on_save": "on", "prettier": { "allowed": false }, - "language_servers": ["oxlint", "oxfmt", "tsgo"], + "language_servers": ["oxlint", "oxfmt", "typescript-ls"], "formatter": [{ "language_server": { "name": "oxfmt" } }], "code_actions_on_format": { "source.fixAll.oxc": true } }, diff --git a/config/vite.php b/config/vite.php index b7b91f9..f1a8ea6 100644 --- a/config/vite.php +++ b/config/vite.php @@ -16,7 +16,7 @@ return [ 'manifestPath' => $manifest, - 'useDevServer' => !is_file($manifest), + 'useDevServer' => App::env('CRAFT_ENVIRONMENT') === 'dev' && !is_file($manifest), 'serverPublic' => UrlHelper::siteHost() . $viteBase . '/', 'devServerPublic' => implode(':', [ $primarySiteUrl, diff --git a/modules/general/web/twig/GeneralExtension.php b/modules/general/web/twig/GeneralExtension.php index 2b92f34..558a647 100644 --- a/modules/general/web/twig/GeneralExtension.php +++ b/modules/general/web/twig/GeneralExtension.php @@ -406,7 +406,7 @@ public static function onlyEnv(?string $markup, string|array $environments): str return $markup; } - return sprintf('', $markup); + return ''; } public static function plain(mixed $string): string diff --git a/oxfmt.config.ts b/oxfmt.config.ts index ed2a01d..dea0587 100644 --- a/oxfmt.config.ts +++ b/oxfmt.config.ts @@ -4,6 +4,7 @@ const config: OxfmtConfig = { semi: false, singleQuote: true, arrowParens: 'avoid', + bracketSameLine: true, sortTailwindcss: true, sortPackageJson: true, quoteProps: 'consistent', diff --git a/src/README.md b/src/README.md index 7995a56..a611d3e 100644 --- a/src/README.md +++ b/src/README.md @@ -114,8 +114,7 @@ All `data-*` attributes on the target element are automatically converted to com data-uid="123e4567-e89b-12d3-a456-426614174000" data-play-inline="true" data-config='{"autoplay": false, "controls": true}' - data-delay="1500" -> + data-delay="1500"> ``` diff --git a/src/lib/components/common/Image.svelte b/src/lib/components/common/Image.svelte index f85c4a5..812903d 100644 --- a/src/lib/components/common/Image.svelte +++ b/src/lib/components/common/Image.svelte @@ -39,8 +39,7 @@ {height} {...rest} {style} - src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" - /> + src="data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==" /> {/snippet} {/if} diff --git a/src/lib/components/common/Modal.svelte b/src/lib/components/common/Modal.svelte index 7e45c69..91d687f 100644 --- a/src/lib/components/common/Modal.svelte +++ b/src/lib/components/common/Modal.svelte @@ -1,5 +1,6 @@ @@ -148,23 +181,21 @@ tabindex="-1" role="dialog" aria-modal="true" + aria-label={label} onclick={onBackdropClick} onkeydown={onBackdropKeydown} transition:blur={{ duration: 150 }} - {@attach modal} - > + {@attach modal}>
+ class:border-neutral-300={overlay === 'polite'}> {@render children()} diff --git a/src/lib/components/common/Picture.svelte b/src/lib/components/common/Picture.svelte index 642cdf5..37bfeca 100644 --- a/src/lib/components/common/Picture.svelte +++ b/src/lib/components/common/Picture.svelte @@ -32,6 +32,5 @@ ? `visibility:hidden;aspect-ratio:${width}/${height};max-width:${width}px;max-height:${height}px;` : undefined} onerror={() => (isLoading = false)} - onload={() => (isLoading = false)} - /> + onload={() => (isLoading = false)} /> diff --git a/src/lib/components/common/Video.svelte b/src/lib/components/common/Video.svelte index 8d28c4b..ef3616b 100644 --- a/src/lib/components/common/Video.svelte +++ b/src/lib/components/common/Video.svelte @@ -122,8 +122,7 @@ {#snippet preview()} {#snippet icon()}
+ class="inset-center absolute flex rounded-full bg-white p-4 text-black transition group-hover:bg-neutral-200">
{/snippet} @@ -135,8 +134,7 @@ onclick={playInline ? activateInline : openModal} class="group relative w-full" aria-label="play video" - style:--focusable-color="currentcolor" - > + style:--focusable-color="currentcolor"> {@render icon()} {embed.title} + loading="lazy" /> {:else if upload} @@ -173,8 +168,7 @@
+ style:--aspect-ratio="{embed.width}/{embed.height}">
{:else if upload} @@ -194,8 +187,7 @@ autoplay playsinline preload="metadata" - {@attach playVideo} - > + {@attach playVideo}> {/if} diff --git a/src/lib/init.ts b/src/lib/init.ts index 0172faa..e00ef26 100644 --- a/src/lib/init.ts +++ b/src/lib/init.ts @@ -12,15 +12,47 @@ const modules = { 'x-svelte': () => import('$lib/sveltify'), } satisfies Record Promise<{ default: ModuleHandler }>> +interface Binding { + pass: number + cleanup: (() => void) | null +} + +const bindings = new WeakMap>() + export default function init(scope: Document | Element): void { + let scopeBindings = bindings.get(scope) + + if (!scopeBindings) { + scopeBindings = new Map() + bindings.set(scope, scopeBindings) + } + for (const [selector, request] of object.entries(modules)) { const els = scope.querySelectorAll(selector) + const binding = scopeBindings.get(selector) ?? { pass: 0, cleanup: null } + const pass = ++binding.pass - if (els.length) { - request() - .then(({ default: module }) => module(els)) - .catch(error => console.error(error)) + scopeBindings.set(selector, binding) + + if (!els.length) { + binding.cleanup?.() + binding.cleanup = null + continue } + + request() + .then(({ default: module }) => { + if (binding.pass !== pass) { + return + } + + binding.cleanup?.() + + const cleanup = module(els) + + binding.cleanup = typeof cleanup === 'function' ? cleanup : null + }) + .catch(error => console.error(error)) } for (const el of scope.querySelectorAll('[target=_blank]')) { diff --git a/src/lib/modules/lightbox.ts b/src/lib/modules/lightbox.ts index ec77f29..a870183 100644 --- a/src/lib/modules/lightbox.ts +++ b/src/lib/modules/lightbox.ts @@ -12,7 +12,6 @@ const preloaded = new Set() export default ModuleSchema.implement(els => { const forward = document.createElement('button') const backward = document.createElement('button') - const backdrop = document.createElement('div') const dialog = document.createElement('dialog') const groups: Record = {} const cleanups: Array<() => void> = [] @@ -20,26 +19,26 @@ export default ModuleSchema.implement(els => { let current: HTMLElement | null = null let scrollRelease: (() => void) | null = null - document.body.append(backdrop) - backdrop.append(dialog) - backdrop.append(forward) - backdrop.append(backward) + document.body.append(dialog) + dialog.append(backward) + dialog.append(forward) + backward.type = 'button' + backward.setAttribute('aria-label', 'previous image') backward.setAttribute( 'class', - 'flex fixed left-4 bottom-6 z-50 transition sm:bottom-auto sm:top-1/2 hover:text-white text-brand-orange', + 'flex fixed left-4 bottom-6 z-50 transition sm:bottom-auto sm:top-1/2 hover:text-white text-white/60', ) + forward.type = 'button' + forward.setAttribute('aria-label', 'next image') forward.setAttribute( 'class', - 'flex fixed right-4 bottom-6 z-50 transition sm:bottom-auto sm:top-1/2 hover:text-white text-brand-orange', - ) - backdrop.setAttribute( - 'class', - 'fixed inset-0 z-20 opacity-0 transition pointer-events-none bg-brand-gray-darker/95', + 'flex fixed right-4 bottom-6 z-50 transition sm:bottom-auto sm:top-1/2 hover:text-white text-white/60', ) + dialog.setAttribute('aria-label', 'image viewer') dialog.setAttribute( 'class', - 'overflow-auto fixed top-1/2 left-1/2 z-50 max-w-7xl rounded-md transform -translate-x-1/2 -translate-y-1/2 w-[90dvw] max-h-[90dvh]', + 'overflow-auto fixed top-1/2 left-1/2 z-50 max-w-7xl rounded-md transform -translate-x-1/2 -translate-y-1/2 w-[90dvw] max-h-[90dvh] backdrop:bg-neutral-950/95', ) forward.innerHTML = markup(rightArrowIcon, { @@ -95,13 +94,13 @@ export default ModuleSchema.implement(els => { return } - const { code, shiftKey } = event + const { code } = event if (code === 'Escape') { close() } - if (!dialog.hasAttribute('open')) { + if (!dialog.open) { return } @@ -111,51 +110,35 @@ export default ModuleSchema.implement(els => { const collection = getGroup(current) - if (code === 'ArrowLeft' || (code === 'Tab' && shiftKey)) { - const i = prev(collection.indexOf(current), collection.length) - - close() - open(collection[i]) - - current.focus() + if (code === 'ArrowLeft') { + event.preventDefault() + open(collection[prev(collection.indexOf(current), collection.length)]) } - if (code === 'ArrowRight' || (code === 'Tab' && !shiftKey)) { - const i = next(collection.indexOf(current), collection.length) - - close() - open(collection[i]) - - current.focus() + if (code === 'ArrowRight') { + event.preventDefault() + open(collection[next(collection.indexOf(current), collection.length)]) } }) - listen(forward, 'click', event => { - event.stopPropagation() - + listen(forward, 'click', () => { if (!current) { return } const collection = getGroup(current) - const i = next(collection.indexOf(current), collection.length) - close() - open(collection[i]) + open(collection[next(collection.indexOf(current), collection.length)]) }) - listen(backward, 'click', event => { - event.stopPropagation() - + listen(backward, 'click', () => { if (!current) { return } const collection = getGroup(current) - const i = prev(collection.indexOf(current), collection.length) - close() - open(collection[i]) + open(collection[prev(collection.indexOf(current), collection.length)]) }) const preload = (el: HTMLElement | undefined) => { @@ -204,10 +187,9 @@ export default ModuleSchema.implement(els => { img.setAttribute('src', src) } - backdrop.classList.remove('opacity-0') - backdrop.classList.remove('pointer-events-none') - - dialog.setAttribute('open', '') + if (!dialog.open) { + dialog.showModal() + } if (!scrollRelease) { scrollRelease = lockScroll() @@ -226,12 +208,17 @@ export default ModuleSchema.implement(els => { scrollRelease = null } - backdrop.classList.add('opacity-0') - backdrop.classList.add('pointer-events-none') - dialog.removeAttribute('open') + dialog.close() + current?.focus() } - listen(backdrop, 'click', () => close()) + listen(dialog, 'click', event => { + if (event.target === dialog) { + close() + } + }) + + listen(dialog, 'cancel', () => close()) for (const el of els) { const group = el.dataset.lightboxGroup || DEFAULT_GROUP @@ -248,7 +235,10 @@ export default ModuleSchema.implement(els => { groups[group].push(el) - el.setAttribute('type', 'submit') + if (el instanceof HTMLButtonElement) { + el.type = 'button' + } + listen(el, 'click', () => open(el)) listen(el, 'mouseover', () => preload(el)) } @@ -259,7 +249,7 @@ export default ModuleSchema.implement(els => { } close() - backdrop.remove() + dialog.remove() current = null } }) diff --git a/src/lib/stores/global.ts b/src/lib/stores/global.ts index 8901a66..04de0cc 100644 --- a/src/lib/stores/global.ts +++ b/src/lib/stores/global.ts @@ -1,4 +1,5 @@ import { MediaQuery } from 'svelte/reactivity' +import * as z from 'zod/mini' import { AppSchema, type App } from '$lib/schemas/app' const root = getComputedStyle(document.body) @@ -21,7 +22,29 @@ interface ScreenState { 'is2xl': MediaQuery } -export const craft: Readonly = Object.freeze(AppSchema.parse(window.$app)) +let app: Readonly | null = null + +/** Parsed on first access rather than at module init, so a `window.$app` mismatch only fails the code that reads `craft`. */ +function resolve(): Readonly { + if (!app) { + const result = AppSchema.safeParse(window.$app) + + if (!result.success) { + throw new Error(`window.$app failed validation:\n${z.prettifyError(result.error)}`) + } + + app = Object.freeze(result.data) + } + + return app +} + +export const craft: Readonly = new Proxy({} as App, { + get: (_, prop) => Reflect.get(resolve(), prop), + has: (_, prop) => Reflect.has(resolve(), prop), + ownKeys: () => Reflect.ownKeys(resolve()), + getOwnPropertyDescriptor: (_, prop) => Object.getOwnPropertyDescriptor(resolve(), prop), +}) export const screen: Readonly = Object.freeze({ 'prefersReducedMotion': new MediaQuery('prefers-reduced-motion: reduce'), diff --git a/src/lib/util/image.ts b/src/lib/util/image.ts index 2ff8593..8cd2ee1 100644 --- a/src/lib/util/image.ts +++ b/src/lib/util/image.ts @@ -59,7 +59,11 @@ function attributes(source: ImageSource, loading = 'lazy') { if (asset.uid) { const src = imgix(asset.src, args || {}) - const src2x = imgix(asset.src, { ...args, width: width * 2, height: height * 2 }) + const src2x = imgix(asset.src, { + ...args, + ...(width ? { width: width * 2 } : {}), + ...(height ? { height: height * 2 } : {}), + }) const alt = asset.alt if (!width && height) { @@ -70,6 +74,9 @@ function attributes(source: ImageSource, loading = 'lazy') { height = Math.floor(Math.min(asset.width, width) * (asset.height / asset.width)) } + width = width || asset.width + height = height || asset.height + Object.assign(attrs, { width, height, diff --git a/src/lib/util/slide.ts b/src/lib/util/slide.ts index c3f6d69..4df5a48 100644 --- a/src/lib/util/slide.ts +++ b/src/lib/util/slide.ts @@ -4,6 +4,7 @@ export function slideUp(target: HTMLElement, duration = 500): void { target.style.boxSizing = 'border-box' target.style.height = `${target.offsetHeight}px` target.style.overflow = 'hidden' + void target.offsetHeight // flush the start height so the transition has an interpolable origin target.style.height = '0' target.style.paddingTop = '0' target.style.paddingBottom = '0' @@ -48,6 +49,7 @@ export function slideDown(target: HTMLElement, duration = 500): void { target.style.boxSizing = 'border-box' target.style.transitionProperty = 'height, margin, padding' target.style.transitionDuration = `${duration}ms` + void target.offsetHeight // flush the zeroed start state so the transition has an interpolable origin target.style.height = `${data.height}px` target.style.removeProperty('padding-top') target.style.removeProperty('padding-bottom') diff --git a/templates/common/_mobileNavigation.twig b/templates/common/_mobileNavigation.twig index 33c534b..d4c8aad 100644 --- a/templates/common/_mobileNavigation.twig +++ b/templates/common/_mobileNavigation.twig @@ -2,7 +2,7 @@
-{{ include('common/_mobileNavigation') }} +{{ include('common/_mobileNavigation', { menu: menu }) }} diff --git a/tests/init.test.ts b/tests/init.test.ts new file mode 100644 index 0000000..7e277d4 --- /dev/null +++ b/tests/init.test.ts @@ -0,0 +1,50 @@ +import { afterEach, describe, expect, test, vi } from 'vitest' +import init from '$lib/init' + +async function flush() { + await vi.dynamicImportSettled() + await new Promise(resolve => setTimeout(resolve, 0)) +} + +afterEach(() => { + document.body.innerHTML = '' +}) + +describe('init', () => { + test('re-initializing a scope replaces module bindings instead of stacking them', async () => { + document.body.innerHTML = '' + + init(document) + await flush() + + init(document) + await flush() + + expect(document.querySelectorAll('dialog')).toHaveLength(1) + }) + + test('a re-initialization with no matching elements supersedes a pending earlier pass', async () => { + document.body.innerHTML = '' + + init(document) + + document.body.innerHTML = '' + init(document) + await flush() + + expect(document.querySelector('dialog')).toBeNull() + }) + + test('tears down a module when its elements are gone on re-initialization', async () => { + document.body.innerHTML = '' + + init(document) + await flush() + + document.body.innerHTML = '' + init(document) + await flush() + + expect(document.querySelector('dialog')).toBeNull() + }) +}) diff --git a/tests/lightbox.test.ts b/tests/lightbox.test.ts index 6441f8d..e04c276 100644 --- a/tests/lightbox.test.ts +++ b/tests/lightbox.test.ts @@ -43,13 +43,13 @@ describe('lightbox', () => { const cleanup = mountLightbox('') const button = document.querySelector('[data-lightbox]') - const backdrop = document.querySelector('div') + const dialog = document.querySelector('dialog') button?.click() expect(document.body.style.overflow).toBe('hidden') - backdrop?.click() + dialog?.click() expect(document.body.style.overflow).toBe('visible') @@ -76,15 +76,15 @@ describe('lightbox', () => { secondCleanup() }) - test('registers one backdrop close listener per initialization', () => { - const divListener = vi.spyOn(HTMLDivElement.prototype, 'addEventListener') + test('registers one dialog close listener per initialization', () => { + const dialogListener = vi.spyOn(HTMLDialogElement.prototype, 'addEventListener') const cleanup = mountLightbox(` `) - expect(divListener.mock.calls.filter(([type]) => type === 'click')).toHaveLength(1) + expect(dialogListener.mock.calls.filter(([type]) => type === 'click')).toHaveLength(1) cleanup() }) @@ -96,13 +96,13 @@ describe('lightbox', () => { `) const buttons = Array.from(document.querySelectorAll('[data-lightbox]')) - const backdrop = document.querySelector('div') + const dialog = document.querySelector('dialog') - if (!backdrop) { - throw new Error('Lightbox backdrop was not created.') + if (!dialog) { + throw new Error('Lightbox dialog was not created.') } - const navButtons = Array.from(backdrop.children).filter( + const navButtons = Array.from(dialog.children).filter( (child): child is HTMLButtonElement => child instanceof HTMLButtonElement, ) @@ -111,10 +111,10 @@ describe('lightbox', () => { expect(navButtons).toHaveLength(2) expect(navButtons.every(button => button.hidden)).toBe(true) - backdrop.click() + dialog.click() buttons[1]?.click() - expect(navButtons.every(button => button.parentElement === backdrop)).toBe(true) + expect(navButtons.every(button => button.parentElement === dialog)).toBe(true) expect(navButtons.every(button => !button.hidden)).toBe(true) cleanup() diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..8fdb0ff --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,17 @@ +// jsdom exposes HTMLDialogElement but implements none of its methods. +if (typeof HTMLDialogElement !== 'undefined' && typeof HTMLDialogElement.prototype.showModal !== 'function') { + HTMLDialogElement.prototype.show = function (this: HTMLDialogElement) { + this.open = true + } + + HTMLDialogElement.prototype.showModal = function (this: HTMLDialogElement) { + this.open = true + } + + HTMLDialogElement.prototype.close = function (this: HTMLDialogElement) { + if (this.open) { + this.open = false + this.dispatchEvent(new Event('close')) + } + } +} diff --git a/utility/install.sh b/utility/install.sh index caf9807..46058f8 100755 --- a/utility/install.sh +++ b/utility/install.sh @@ -1,5 +1,9 @@ -function get_op() { - op item get 'ENVIRONMENT_DEFAULTS' --fields=label=$1 --reveal --account=mostlyserious.1password.com --vault=Employee +#!/usr/bin/env bash + +set -euo pipefail + +get_op() { + op item get 'ENVIRONMENT_DEFAULTS' --fields=label="$1" --reveal --account=mostlyserious.1password.com --vault=Employee } if ! command -v ddev &> /dev/null @@ -10,10 +14,10 @@ fi # @todo: check if we can collect Fort Awesome token up-front -cp .env.example .env -ddev dotenv set .env --primary-site-url="https://$(basename $PWD).ddev.site" -ddev dotenv set .env --imgix-url="https://$(basename $PWD).imgix.net" -ddev config --project-name=$(basename $PWD) +[ -f .env ] || cp .env.example .env +ddev dotenv set .env --primary-site-url="https://$(basename "$PWD").ddev.site" +ddev dotenv set .env --imgix-url="https://$(basename "$PWD").imgix.net" +ddev config --project-name="$(basename "$PWD")" ddev start ddev composer update ddev craft setup/keys @@ -22,7 +26,7 @@ ddev pnpm install --frozen-lockfile if command -v op &> /dev/null then - ddev dotenv set .env --tinypng-key=$(get_op TINYPNG_KEY) + ddev dotenv set .env --tinypng-key="$(get_op TINYPNG_KEY)" ddev pnpm run build else echo "1Password CLI not found." diff --git a/utility/vite-plugin-tinify.ts b/utility/vite-plugin-tinify.ts index f8333d5..ed224ab 100644 --- a/utility/vite-plugin-tinify.ts +++ b/utility/vite-plugin-tinify.ts @@ -3,12 +3,12 @@ import fs from 'node:fs' import tinify from 'tinify' import type { Plugin } from 'vite' -export default (): Plugin => ({ +export default (options: { key?: string } = {}): Plugin => ({ name: 'vite-plugin-tinify', async generateBundle(_, bundler) { for (const [path, asset] of Object.entries(bundler)) { if (/\.(png|jpe?g)$/.test(path) && asset.type === 'asset' && typeof asset.source !== 'undefined') { - const checksum = crypto.createHash('sha1').update(asset.source.toString()).digest('hex') + const checksum = crypto.createHash('sha1').update(asset.source).digest('hex') const checksumfile = `node_modules/.vite/tinify/${checksum}` let content: Buffer | Uint8Array | undefined @@ -16,7 +16,7 @@ export default (): Plugin => ({ if (fs.existsSync(checksumfile)) { content = fs.readFileSync(checksumfile) } else { - if (!process.env.TINYPNG_KEY) { + if (!options.key) { throw new Error('vite-plugin-tinify: TINYPNG_KEY not defined. **Images not optimized**') } @@ -40,7 +40,7 @@ export default (): Plugin => ({ }, process.cwd()) } - tinify.key = process.env.TINYPNG_KEY + tinify.key = options.key content = await tinify.fromBuffer(asset.source).toBuffer() fs.writeFile(checksumfile, content, error => error && console.log(error)) diff --git a/vite.config.ts b/vite.config.ts index 6784305..24ea735 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -12,11 +12,17 @@ import tinify from './utility/vite-plugin-tinify' const root = dirname(fileURLToPath(import.meta.url)) export default defineConfig(({ mode }) => { - const { VITE_BASE, VITE_PORT, VITE_TEMP, PRIMARY_SITE_URL } = env.parse(mode) + const { VITE_BASE, VITE_PORT, VITE_TEMP, PRIMARY_SITE_URL, TINYPNG_KEY } = env.parse(mode) const basePath = toBasePath(VITE_BASE) const outDir = join('web', VITE_TEMP ? toBasePath(VITE_TEMP) : basePath) + if (outDir === 'web') { + throw new Error( + 'VITE_BASE (or VITE_TEMP) must resolve to a subdirectory of web/ — refusing to empty the webroot', + ) + } + fs.rmSync(outDir, { recursive: true, force: true, @@ -25,7 +31,7 @@ export default defineConfig(({ mode }) => { return { publicDir: false, base: `/${basePath}/`, - plugins: [tailwindcss(), svelte(), tinify(), svgo()], + plugins: [tailwindcss(), svelte(), tinify({ key: TINYPNG_KEY }), svgo()], css: { transformer: 'lightningcss', lightningcss: { diff --git a/vitest.config.ts b/vitest.config.ts index 16df249..2124b81 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -21,5 +21,6 @@ export default defineConfig({ test: { environment: 'jsdom', include: ['tests/**/*.test.ts'], + setupFiles: ['tests/setup.ts'], }, })