From 59b9ecb5838a6dbf2c684119d5235b475cd815e5 Mon Sep 17 00:00:00 2001 From: oekazuma Date: Mon, 22 Jun 2026 11:43:58 +0900 Subject: [PATCH 01/10] feat(vite): add pure dev-report formatter and dedup signature Co-Authored-By: Claude Opus 4.8 --- packages/vite/src/hooks/format.ts | 29 ++++++++++++++ packages/vite/test/dev-format.test.ts | 54 +++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 packages/vite/src/hooks/format.ts create mode 100644 packages/vite/test/dev-format.test.ts diff --git a/packages/vite/src/hooks/format.ts b/packages/vite/src/hooks/format.ts new file mode 100644 index 00000000..9157e41d --- /dev/null +++ b/packages/vite/src/hooks/format.ts @@ -0,0 +1,29 @@ +import { isPenalized, effectiveSeverity, type Config, type Result, type Severity } from '@svelte-vitals/core'; + +const GLYPH: Record = { critical: '✗', warning: '⚠', info: '·' }; +const RANK: Record = { critical: 0, warning: 1, info: 2 }; + +function penalized(results: Result[], config: Config): Result[] { + return results.filter((r) => isPenalized(r.detection, config.treatDynamicAs)); +} + +/** Compact terminal report for one route's penalized findings; '' when the route is clean. */ +export function formatDevReport(route: string, results: Result[], config: Config): string { + const failing = penalized(results, config).sort( + (a, b) => RANK[effectiveSeverity(a, config)] - RANK[effectiveSeverity(b, config)] || a.id.localeCompare(b.id) + ); + if (failing.length === 0) return ''; + const lines = [`[svelte-vitals] ${route}`]; + for (const r of failing) { + lines.push(` ${GLYPH[effectiveSeverity(r, config)]} ${r.id} ${r.message}`); + } + return lines.join('\n'); +} + +/** Stable signature of a route's penalized findings, so a route is re-printed only when it changes. */ +export function findingSignature(results: Result[], config: Config): string { + return penalized(results, config) + .map((r) => `${r.id}:${effectiveSeverity(r, config)}`) + .sort() + .join('|'); +} diff --git a/packages/vite/test/dev-format.test.ts b/packages/vite/test/dev-format.test.ts new file mode 100644 index 00000000..833c74ad --- /dev/null +++ b/packages/vite/test/dev-format.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from 'vitest'; +import { formatDevReport, findingSignature } from '../src/hooks/format.js'; +import { defineConfig, type Result } from '@svelte-vitals/core'; + +const config = defineConfig({}); + +const failing: Result[] = [ + { + id: 'SEO003', + severity: 'warning', + detection: { presence: 'none', value: 'absent' }, + route: '/p', + message: 'Missing ' + }, + { + id: 'SEO001', + severity: 'critical', + detection: { presence: 'none', value: 'absent' }, + route: '/p', + message: 'Missing ' + } +]; + +const passing: Result[] = [ + { id: 'SEO001', severity: 'critical', detection: { presence: 'own', value: 'static' }, route: '/p', message: '<title>' } +]; + +describe('formatDevReport', () => { + it('lists only penalized findings, most-severe first, under a route header', () => { + const out = formatDevReport('/p', failing, config); + const lines = out.split('\n'); + expect(lines[0]).toBe('[svelte-vitals] /p'); + expect(lines[1]).toBe(' ✗ SEO001 Missing <title>'); + expect(lines[2]).toBe(' ⚠ SEO003 Missing <link rel="canonical">'); + }); + + it('returns an empty string for a clean route', () => { + expect(formatDevReport('/p', passing, config)).toBe(''); + }); +}); + +describe('findingSignature', () => { + it('is stable regardless of input order', () => { + const reversed = [...failing].reverse(); + expect(findingSignature(failing, config)).toBe(findingSignature(reversed, config)); + }); + + it('ignores passing findings and changes when penalized findings change', () => { + expect(findingSignature(passing, config)).toBe(''); + const sigA = findingSignature(failing, config); + const sigB = findingSignature([failing[0]!], config); + expect(sigA).not.toBe(sigB); + }); +}); From 42162851f74c38f574b3e4beec0a9c35acd8de7f Mon Sep 17 00:00:00 2001 From: oekazuma <oe.kazuma@a-tm.co.jp> Date: Mon, 22 Jun 2026 11:47:56 +0900 Subject: [PATCH 02/10] feat(vite): add svelteVitalsHandle dev-time SvelteKit handle (@svelte-vitals/vite/hooks) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- packages/vite/package.json | 7 ++ packages/vite/src/hooks/handle.ts | 69 ++++++++++++++ packages/vite/src/hooks/index.ts | 2 + packages/vite/src/hooks/options.ts | 9 ++ packages/vite/test/dev-handle.test.ts | 94 +++++++++++++++++++ packages/vite/tsup.config.ts | 2 +- pnpm-lock.yaml | 128 ++++++++++++++++++++++++++ pnpm-workspace.yaml | 1 + 8 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 packages/vite/src/hooks/handle.ts create mode 100644 packages/vite/src/hooks/index.ts create mode 100644 packages/vite/src/hooks/options.ts create mode 100644 packages/vite/test/dev-handle.test.ts diff --git a/packages/vite/package.json b/packages/vite/package.json index a7d614f7..4b4a6444 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -25,6 +25,10 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./hooks": { + "types": "./dist/hooks/index.d.ts", + "import": "./dist/hooks/index.js" } }, "main": "./dist/index.js", @@ -43,10 +47,13 @@ "tinyglobby": "catalog:" }, "peerDependencies": { + "@sveltejs/kit": "catalog:", "vite": "catalog:" }, "devDependencies": { + "@sveltejs/kit": "catalog:", "@types/node": "catalog:", + "svelte": "catalog:", "vite": "catalog:" } } diff --git a/packages/vite/src/hooks/handle.ts b/packages/vite/src/hooks/handle.ts new file mode 100644 index 00000000..79cc10ba --- /dev/null +++ b/packages/vite/src/hooks/handle.ts @@ -0,0 +1,69 @@ +import type { Handle } from '@sveltejs/kit'; +import { + allRules, + applyRuleSeverities, + defineConfig, + runRules, + selectRules, + type Config, + type Project, + type ResolvedHead, + type Rule +} from '@svelte-vitals/core'; +import { parseHtmlHead } from '../providers/rendered/parse-html.js'; +import { findingSignature, formatDevReport } from './format.js'; +import type { SvelteVitalsHookOptions } from './options.js'; + +async function analyzeAndWarn( + html: string, + route: string, + rules: Rule[], + config: Config, + lastSignature: Map<string, string> +): Promise<void> { + try { + const { tags, htmlLang } = parseHtmlHead(html); + const head: ResolvedHead = { route, source: 'rendered', tags, file: route }; + // robots/sitemap are not page-scoped, so mark them present to suppress SEO006/SEO007; + // htmlLang comes from the rendered document so SEO009 is evaluated against reality. + const project: Project = { hasRobotsTxt: true, hasSitemap: true, htmlLang }; + const results = applyRuleSeverities(await runRules(rules, { heads: [head], project, config }), config); + + const signature = findingSignature(results, config); + if (lastSignature.get(route) === signature) return; + lastSignature.set(route, signature); + + const report = formatDevReport(route, results, config); + if (report) console.warn(report); + } catch { + // Dev tooling must never break the request: swallow any parse/rule error. + } +} + +/** + * SvelteKit `handle` that prints SEO warnings for each visited page's rendered `<head>`, + * in dev only. Add it to `src/hooks.server.ts`, e.g. `sequence(svelteVitalsHandle())`. + */ +export function svelteVitalsHandle(options: SvelteVitalsHookOptions = {}): Handle { + const config = defineConfig({ + metaComponents: options.metaComponents ?? [], + rules: options.rules ?? {}, + treatDynamicAs: 'pass', + failOn: 'critical' + }); + const rules = selectRules(allRules, config); + const lastSignature = new Map<string, string>(); + + return ({ event, resolve }) => { + if (process.env.NODE_ENV === 'production') return resolve(event); + + let buffer = ''; + return resolve(event, { + transformPageChunk: async ({ html, done }) => { + buffer += html; + if (done) await analyzeAndWarn(buffer, event.route.id ?? event.url.pathname, rules, config, lastSignature); + return html; + } + }); + }; +} diff --git a/packages/vite/src/hooks/index.ts b/packages/vite/src/hooks/index.ts new file mode 100644 index 00000000..44fd50f4 --- /dev/null +++ b/packages/vite/src/hooks/index.ts @@ -0,0 +1,2 @@ +export { svelteVitalsHandle } from './handle.js'; +export type { SvelteVitalsHookOptions } from './options.js'; diff --git a/packages/vite/src/hooks/options.ts b/packages/vite/src/hooks/options.ts new file mode 100644 index 00000000..019ee926 --- /dev/null +++ b/packages/vite/src/hooks/options.ts @@ -0,0 +1,9 @@ +import type { RuleSetting } from '@svelte-vitals/core'; + +/** Options for the dev-time SvelteKit handle. A focused subset of the plugin options. */ +export interface SvelteVitalsHookOptions { + /** Component names treated as meta sources (design §11 layer 4). Mirrors the plugin option. */ + metaComponents?: string[]; + /** Per-rule overrides keyed by rule id, e.g. `{ SEO008: 'off' }`. Mirrors the plugin option. */ + rules?: Record<string, RuleSetting>; +} diff --git a/packages/vite/test/dev-handle.test.ts b/packages/vite/test/dev-handle.test.ts new file mode 100644 index 00000000..3c6fe19c --- /dev/null +++ b/packages/vite/test/dev-handle.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import type { Handle } from '@sveltejs/kit'; +import { svelteVitalsHandle } from '../src/hooks/index.js'; + +// A minimal fake RequestEvent carrying only what the handle reads. +function fakeEvent(routeId: string | null, pathname = '/') { + return { route: { id: routeId }, url: new URL(`http://localhost${pathname}`) } as unknown as Parameters< + Parameters<Handle>[0]['resolve'] + >[0]; +} + +// A resolve() that feeds the given HTML chunks through transformPageChunk, awaiting each. +function resolveWith(chunks: string[]) { + return (async (event: unknown, opts?: { transformPageChunk?: (i: { html: string; done: boolean }) => unknown }) => { + const tpc = opts?.transformPageChunk; + const seen: unknown[] = []; + if (tpc) { + for (let i = 0; i < chunks.length; i++) { + seen.push(await tpc({ html: chunks[i]!, done: i === chunks.length - 1 })); + } + } + return { seen, transformed: tpc !== undefined } as unknown as Response; + }) as Parameters<Handle>[0]['resolve']; +} + +const PAGE_NO_TITLE = '<html lang="en"><head><meta name="description" content="x"></head><body></body></html>'; +const PAGE_OK = + '<html lang="en"><head><title>Home' + + '' + + '' + + ''; + +afterEach(() => vi.restoreAllMocks()); + +describe('svelteVitalsHandle', () => { + it('warns about a missing for the visited route', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const handle = svelteVitalsHandle(); + await handle({ event: fakeEvent('/none', '/none'), resolve: resolveWith([PAGE_NO_TITLE]) }); + const out = warn.mock.calls.map((c) => String(c[0])).join('\n'); + expect(out).toContain('[svelte-vitals] /none'); + expect(out).toContain('✗ SEO001 Missing <title>'); + }); + + it('prints nothing for a clean page', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const handle = svelteVitalsHandle(); + await handle({ event: fakeEvent('/ok', '/ok'), resolve: resolveWith([PAGE_OK]) }); + expect(warn).not.toHaveBeenCalled(); + }); + + it('returns each chunk unchanged', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const handle = svelteVitalsHandle(); + const res = (await handle({ + event: fakeEvent('/none', '/none'), + resolve: resolveWith(['<html><head>', '</head></html>']) + })) as unknown as { seen: string[] }; + expect(res.seen).toEqual(['<html><head>', '</head></html>']); + }); + + it('dedups: the same findings on a repeat visit print only once', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const handle = svelteVitalsHandle(); + await handle({ event: fakeEvent('/none', '/none'), resolve: resolveWith([PAGE_NO_TITLE]) }); + await handle({ event: fakeEvent('/none', '/none'), resolve: resolveWith([PAGE_NO_TITLE]) }); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('is a pass-through in production (no transformPageChunk, no output)', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const prev = process.env.NODE_ENV; + process.env.NODE_ENV = 'production'; + try { + const handle = svelteVitalsHandle(); + const res = (await handle({ + event: fakeEvent('/none', '/none'), + resolve: resolveWith([PAGE_NO_TITLE]) + })) as unknown as { transformed: boolean }; + expect(res.transformed).toBe(false); + expect(warn).not.toHaveBeenCalled(); + } finally { + process.env.NODE_ENV = prev; + } + }); + + it('does not throw when the HTML is unparseable garbage', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const handle = svelteVitalsHandle(); + await expect( + handle({ event: fakeEvent(null, '/x'), resolve: resolveWith(['not really <<< html']) }) + ).resolves.toBeDefined(); + }); +}); diff --git a/packages/vite/tsup.config.ts b/packages/vite/tsup.config.ts index bf3e9e63..84ccc672 100644 --- a/packages/vite/tsup.config.ts +++ b/packages/vite/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup'; export default defineConfig({ - entry: ['src/index.ts'], + entry: ['src/index.ts', 'src/hooks/index.ts'], format: ['esm'], dts: true, clean: true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe2d4f67..aafc0753 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,9 @@ catalogs: '@eslint/js': specifier: ^10.0.1 version: 10.0.1 + '@sveltejs/kit': + specifier: ^2.0.0 + version: 2.66.0 '@types/eslint': specifier: ^9.6.1 version: 9.6.1 @@ -162,9 +165,15 @@ importers: specifier: 'catalog:' version: 0.2.17 devDependencies: + '@sveltejs/kit': + specifier: 'catalog:' + version: 2.66.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.61.0))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.7)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.7)) '@types/node': specifier: 'catalog:' version: 24.13.2 + svelte: + specifier: 'catalog:' + version: 5.56.3(@typescript-eslint/types@8.61.0) vite: specifier: 'catalog:' version: 8.0.16(@types/node@24.13.2)(esbuild@0.27.7) @@ -515,6 +524,9 @@ packages: '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@publint/pack@0.1.4': resolution: {integrity: sha512-HDVTWq3H0uTXiU0eeSQntcVUTPP3GamzeXI41+x7uU9J65JgWQh3qWZHblR1i0npXfFtF+mxBiU2nJH8znxWnQ==} engines: {node: '>=18'} @@ -763,12 +775,38 @@ packages: peerDependencies: acorn: ^8.9.0 + '@sveltejs/kit@2.66.0': + resolution: {integrity: sha512-7nN4Ur4+nofZ36DVo83JbRe02m61Vc+I441mML/DYa1pUTZ/x26+lbrdqPen8gjmsUc6flMtHEqAtn0UfmfvAw==} + engines: {node: '>=18.13'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0 + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.3.3 || ^6.0.0 + vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + typescript: + optional: true + + '@sveltejs/vite-plugin-svelte@7.1.2': + resolution: {integrity: sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + svelte: ^5.46.4 + vite: ^8.0.0-beta.7 || ^8.0.0 + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} @@ -985,6 +1023,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1013,6 +1055,10 @@ packages: deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -1321,6 +1367,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + known-css-properties@0.37.0: resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} @@ -1453,6 +1503,10 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1785,6 +1839,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-cookie-parser@3.1.0: + resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1800,6 +1857,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -1883,6 +1944,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -1993,6 +2058,14 @@ packages: yaml: optional: true + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + vitest@4.1.8: resolution: {integrity: sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -2419,6 +2492,8 @@ snapshots: '@oxc-project/types@0.133.0': {} + '@polka/url@1.0.0-next.29': {} + '@publint/pack@0.1.4': {} '@rolldown/binding-android-arm64@1.0.3': @@ -2553,6 +2628,35 @@ snapshots: dependencies: acorn: 8.17.0 + '@sveltejs/kit@2.66.0(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.61.0))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.7)))(svelte@5.56.3(@typescript-eslint/types@8.61.0))(typescript@6.0.3)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.7))': + dependencies: + '@standard-schema/spec': 1.1.0 + '@sveltejs/acorn-typescript': 1.0.10(acorn@8.17.0) + '@sveltejs/vite-plugin-svelte': 7.1.2(svelte@5.56.3(@typescript-eslint/types@8.61.0))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.7)) + '@types/cookie': 0.6.0 + acorn: 8.17.0 + cookie: 0.6.0 + devalue: 5.8.1 + esm-env: 1.2.2 + kleur: 4.1.5 + magic-string: 0.30.21 + mrmime: 2.0.1 + set-cookie-parser: 3.1.0 + sirv: 3.0.2 + svelte: 5.56.3(@typescript-eslint/types@8.61.0) + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.27.7) + optionalDependencies: + typescript: 6.0.3 + + '@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.61.0))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.7))': + dependencies: + deepmerge: 4.3.1 + magic-string: 0.30.21 + obug: 2.1.3 + svelte: 5.56.3(@typescript-eslint/types@8.61.0) + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.27.7) + vitefu: 1.1.3(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.7)) + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 @@ -2563,6 +2667,8 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/cookie@0.6.0': {} + '@types/deep-eql@4.0.2': {} '@types/eslint@9.6.1': @@ -2790,6 +2896,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie@0.6.0: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -2814,6 +2922,8 @@ snapshots: deep-is@0.1.4: {} + deepmerge@4.3.1: {} + detect-indent@6.1.0: {} detect-libc@2.1.2: {} @@ -3149,6 +3259,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + kleur@4.1.5: {} + known-css-properties@0.37.0: {} levn@0.4.1: @@ -3249,6 +3361,8 @@ snapshots: mri@1.2.0: {} + mrmime@2.0.1: {} + ms@2.1.3: {} mz@2.7.0: @@ -3474,6 +3588,8 @@ snapshots: semver@7.8.4: {} + set-cookie-parser@3.1.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -3484,6 +3600,12 @@ snapshots: signal-exit@4.1.0: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + slash@3.0.0: {} source-map-js@1.2.1: {} @@ -3577,6 +3699,8 @@ snapshots: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + tree-kill@1.2.2: {} ts-api-utils@2.5.0(typescript@6.0.3): @@ -3657,6 +3781,10 @@ snapshots: esbuild: 0.27.7 fsevents: 2.3.3 + vitefu@1.1.3(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.7)): + optionalDependencies: + vite: 8.0.16(@types/node@24.13.2)(esbuild@0.27.7) + vitest@4.1.8(@types/node@24.13.2)(vite@8.0.16(@types/node@24.13.2)(esbuild@0.27.7)): dependencies: '@vitest/expect': 4.1.8 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 995580be..3122d8dc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,6 +10,7 @@ catalog: '@changesets/cli': ^2.31.0 '@eslint/compat': ^2.1.0 '@eslint/js': ^10.0.1 + '@sveltejs/kit': ^2.0.0 '@types/eslint': ^9.6.1 '@types/node': ^24.7.0 eslint: ^10.5.0 From 6310819db11179a18c59d1ab1b64710439159ca4 Mon Sep 17 00:00:00 2001 From: oekazuma <oe.kazuma@a-tm.co.jp> Date: Mon, 22 Jun 2026 11:52:05 +0900 Subject: [PATCH 03/10] docs: document the dev-time overlay handle (@svelte-vitals/vite/hooks) --- README.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/README.md b/README.md index e45391b3..cf0e3e0e 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,20 @@ jobs: > [!NOTE] > Code scanning only displays results that carry a file location, so project-scoped checks that aren't tied to a route file (`robots.txt`, `sitemap`, `<html lang>`) don't appear as alerts in the Security tab. They are still reported by the `github`, `console`, and `json` reporters — keep one of those in your pipeline if you rely on those checks. +### Dev overlay (request-driven) + +Get SEO feedback while developing: add the dev handle to `src/hooks.server.ts` and svelte-vitals analyzes each page's **rendered** `<head>` as you navigate, printing warnings for the current route to your dev-server terminal. It sees real values, so dynamic routes (`{data.title}`) are checked against what actually renders. + +```ts +// src/hooks.server.ts +import { sequence } from '@sveltejs/kit/hooks'; +import { svelteVitalsHandle } from '@svelte-vitals/vite/hooks'; + +export const handle = sequence(svelteVitalsHandle()); +``` + +It runs in `dev` only (a no-op in production builds) and never modifies the response — it only reads the rendered HTML. Coverage follows navigation: a route is checked when you visit it, and re-warned only when its findings change. + ### Exit codes | Code | Meaning | From 3c7c36ed61b22d8ffbb931a813365a7c4b16abce Mon Sep 17 00:00:00 2001 From: oekazuma <oe.kazuma@a-tm.co.jp> Date: Mon, 22 Jun 2026 11:52:11 +0900 Subject: [PATCH 04/10] chore: changeset for the dev-time overlay handle --- .changeset/dev-overlay-handle.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dev-overlay-handle.md diff --git a/.changeset/dev-overlay-handle.md b/.changeset/dev-overlay-handle.md new file mode 100644 index 00000000..772d3a1d --- /dev/null +++ b/.changeset/dev-overlay-handle.md @@ -0,0 +1,5 @@ +--- +'@svelte-vitals/vite': minor +--- + +Add a dev-time SvelteKit handle, `svelteVitalsHandle` (exported from `@svelte-vitals/vite/hooks`). Added to `src/hooks.server.ts`, it analyzes each visited page's rendered `<head>` in dev and prints SEO warnings for the current route to the terminal — request-driven, dev-only, and never mutates the response. From f14abdbd18dd112b40121bc9d7c0c25ef14ac6f3 Mon Sep 17 00:00:00 2001 From: oekazuma <oe.kazuma@a-tm.co.jp> Date: Mon, 22 Jun 2026 11:56:05 +0900 Subject: [PATCH 05/10] style: apply prettier to dev-format test --- packages/vite/test/dev-format.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/vite/test/dev-format.test.ts b/packages/vite/test/dev-format.test.ts index 833c74ad..6df7eaf3 100644 --- a/packages/vite/test/dev-format.test.ts +++ b/packages/vite/test/dev-format.test.ts @@ -22,7 +22,13 @@ const failing: Result[] = [ ]; const passing: Result[] = [ - { id: 'SEO001', severity: 'critical', detection: { presence: 'own', value: 'static' }, route: '/p', message: '<title>' } + { + id: 'SEO001', + severity: 'critical', + detection: { presence: 'own', value: 'static' }, + route: '/p', + message: '<title>' + } ]; describe('formatDevReport', () => { From a8ab418414b955808b4fa3a974b29da75ebf1ae1 Mon Sep 17 00:00:00 2001 From: oekazuma <oe.kazuma@a-tm.co.jp> Date: Mon, 22 Jun 2026 12:04:36 +0900 Subject: [PATCH 06/10] fix(vite): mark @sveltejs/kit as an optional peer dependency Only the ./hooks subpath (svelteVitalsHandle) needs SvelteKit; the main Vite plugin does not. Marking it optional avoids widening install requirements and spurious peer warnings for plugin-only consumers. Addresses CodeRabbit review on #26. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- packages/vite/package.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/vite/package.json b/packages/vite/package.json index 4b4a6444..0dfe67d3 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -50,6 +50,11 @@ "@sveltejs/kit": "catalog:", "vite": "catalog:" }, + "peerDependenciesMeta": { + "@sveltejs/kit": { + "optional": true + } + }, "devDependencies": { "@sveltejs/kit": "catalog:", "@types/node": "catalog:", From afa9256426439528d1e5b36845c6d1fd922e6ea5 Mon Sep 17 00:00:00 2001 From: oekazuma <oe.kazuma@a-tm.co.jp> Date: Mon, 22 Jun 2026 12:06:57 +0900 Subject: [PATCH 07/10] fix(vite): include detection presence/value in dev dedup signature A penalized finding can change state while keeping the same rule id and severity (e.g. a tag going missing -> present-but-empty). Keying the dedup signature only on id+severity suppressed the legitimate re-warning; include detection.presence/value so changed findings re-print. Addresses CodeRabbit review on #26. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- packages/vite/src/hooks/format.ts | 2 +- packages/vite/test/dev-format.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/vite/src/hooks/format.ts b/packages/vite/src/hooks/format.ts index 9157e41d..4764094c 100644 --- a/packages/vite/src/hooks/format.ts +++ b/packages/vite/src/hooks/format.ts @@ -23,7 +23,7 @@ export function formatDevReport(route: string, results: Result[], config: Config /** Stable signature of a route's penalized findings, so a route is re-printed only when it changes. */ export function findingSignature(results: Result[], config: Config): string { return penalized(results, config) - .map((r) => `${r.id}:${effectiveSeverity(r, config)}`) + .map((r) => `${r.id}:${effectiveSeverity(r, config)}:${r.detection.presence}:${r.detection.value}`) .sort() .join('|'); } diff --git a/packages/vite/test/dev-format.test.ts b/packages/vite/test/dev-format.test.ts index 6df7eaf3..4bbcba76 100644 --- a/packages/vite/test/dev-format.test.ts +++ b/packages/vite/test/dev-format.test.ts @@ -57,4 +57,26 @@ describe('findingSignature', () => { const sigB = findingSignature([failing[0]!], config); expect(sigA).not.toBe(sigB); }); + + it('distinguishes a missing tag from an empty one (same id and severity)', () => { + const missing: Result[] = [ + { + id: 'SEO001', + severity: 'critical', + detection: { presence: 'none', value: 'absent' }, + route: '/p', + message: 'Missing <title>' + } + ]; + const empty: Result[] = [ + { + id: 'SEO001', + severity: 'critical', + detection: { presence: 'own', value: 'absent' }, + route: '/p', + message: 'Empty <title>' + } + ]; + expect(findingSignature(missing, config)).not.toBe(findingSignature(empty, config)); + }); }); From 144624d2ba8f83b1d368649e7d31876acc96f809 Mon Sep 17 00:00:00 2001 From: oekazuma <oe.kazuma@a-tm.co.jp> Date: Mon, 22 Jun 2026 12:16:50 +0900 Subject: [PATCH 08/10] fix(vite): guard dev handle against undefined process on edge runtimes process.env.NODE_ENV threw a ReferenceError where process is undefined (edge adapters), crashing every request instead of no-op'ing. Guard typeof process and pass through when unavailable. Also restore NODE_ENV by deleting when it was originally unset (avoids the string 'undefined' polluting later tests). Addresses Copilot review on #26. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --- packages/vite/src/hooks/handle.ts | 6 +++++- packages/vite/test/dev-handle.test.ts | 21 ++++++++++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/vite/src/hooks/handle.ts b/packages/vite/src/hooks/handle.ts index 79cc10ba..1aa442f1 100644 --- a/packages/vite/src/hooks/handle.ts +++ b/packages/vite/src/hooks/handle.ts @@ -55,7 +55,11 @@ export function svelteVitalsHandle(options: SvelteVitalsHookOptions = {}): Handl const lastSignature = new Map<string, string>(); return ({ event, resolve }) => { - if (process.env.NODE_ENV === 'production') return resolve(event); + // Dev-only: run analysis only under a Node dev server. In production — and in + // non-Node runtimes (edge adapters) where `process` is undefined — pass through + // untouched. Guarding `typeof process` first avoids a ReferenceError that would + // otherwise crash every request on edge deployments. + if (typeof process === 'undefined' || process.env.NODE_ENV === 'production') return resolve(event); let buffer = ''; return resolve(event, { diff --git a/packages/vite/test/dev-handle.test.ts b/packages/vite/test/dev-handle.test.ts index 3c6fe19c..eac3da30 100644 --- a/packages/vite/test/dev-handle.test.ts +++ b/packages/vite/test/dev-handle.test.ts @@ -80,7 +80,26 @@ describe('svelteVitalsHandle', () => { expect(res.transformed).toBe(false); expect(warn).not.toHaveBeenCalled(); } finally { - process.env.NODE_ENV = prev; + // Restore precisely: assigning `undefined` would coerce to the string + // 'undefined' and leave the var set, polluting later tests. + if (prev === undefined) delete process.env.NODE_ENV; + else process.env.NODE_ENV = prev; + } + }); + + it('passes through when process is undefined (non-Node/edge runtime)', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const handle = svelteVitalsHandle(); + vi.stubGlobal('process', undefined); + try { + const res = (await handle({ + event: fakeEvent('/none', '/none'), + resolve: resolveWith([PAGE_NO_TITLE]) + })) as unknown as { transformed: boolean }; + expect(res.transformed).toBe(false); + expect(warn).not.toHaveBeenCalled(); + } finally { + vi.unstubAllGlobals(); } }); From f4ae5188279f23cd9663b93773d7f1f1cddf4f0f Mon Sep 17 00:00:00 2001 From: oekazuma <oe.kazuma@a-tm.co.jp> Date: Mon, 22 Jun 2026 12:28:54 +0900 Subject: [PATCH 09/10] refactor(vite): gate dev handle with esm-env and run analysis off the response path Replace the hand-rolled `typeof process` / `NODE_ENV` dev guard with esm-env's `DEV`. It resolves statically to `false` in production builds (so it's tree-shaken out, not a runtime read) and its fallback reads no bare `process`, covering edge runtimes too. Move the guard to the factory so the rule set isn't built outside dev. Run analysis fire-and-forget on the final chunk so the dev response is never blocked on parsing/rule execution; the chunk is still returned unchanged (observe-only). Drop the redundant `treatDynamicAs`/`failOn` config (defaults; both moot here) and add an opt-in `SVELTE_VITALS_DEBUG` escape hatch to surface swallowed tool errors. Tests: mock esm-env to exercise the not-in-dev no-op (DEV is a static import, so toggling env vars wouldn't flip it) and flush a macrotask where assertions depend on the now-detached analysis. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- packages/vite/package.json | 1 + packages/vite/src/hooks/handle.ts | 33 ++++++++++++-------- packages/vite/test/dev-handle.test.ts | 44 ++++++++++++--------------- pnpm-lock.yaml | 6 ++++ pnpm-workspace.yaml | 1 + 5 files changed, 49 insertions(+), 36 deletions(-) diff --git a/packages/vite/package.json b/packages/vite/package.json index 0dfe67d3..ccef6208 100644 --- a/packages/vite/package.json +++ b/packages/vite/package.json @@ -43,6 +43,7 @@ }, "dependencies": { "@svelte-vitals/core": "workspace:*", + "esm-env": "catalog:", "node-html-parser": "catalog:", "tinyglobby": "catalog:" }, diff --git a/packages/vite/src/hooks/handle.ts b/packages/vite/src/hooks/handle.ts index 1aa442f1..43035350 100644 --- a/packages/vite/src/hooks/handle.ts +++ b/packages/vite/src/hooks/handle.ts @@ -1,3 +1,4 @@ +import { DEV } from 'esm-env'; import type { Handle } from '@sveltejs/kit'; import { allRules, @@ -35,8 +36,12 @@ async function analyzeAndWarn( const report = formatDevReport(route, results, config); if (report) console.warn(report); - } catch { + } catch (err) { // Dev tooling must never break the request: swallow any parse/rule error. + // Set SVELTE_VITALS_DEBUG to surface tool-internal errors while debugging. + if (globalThis.process?.env?.SVELTE_VITALS_DEBUG) { + console.warn('[svelte-vitals] dev analysis failed:', err); + } } } @@ -45,27 +50,31 @@ async function analyzeAndWarn( * in dev only. Add it to `src/hooks.server.ts`, e.g. `sequence(svelteVitalsHandle())`. */ export function svelteVitalsHandle(options: SvelteVitalsHookOptions = {}): Handle { + // Dev-only. `DEV` (esm-env) resolves statically to `true` under the dev server and + // `false` in production builds; on non-Node runtimes (edge) its fallback reads no + // bare `process`, so it stays `false`. Everywhere but dev this handle is a no-op, + // and we skip building the rule set entirely. + if (!DEV) return ({ event, resolve }) => resolve(event); + + // treatDynamicAs/failOn intentionally left at defaults: rendered HTML never yields + // `dynamic` values (so treatDynamicAs is moot) and this handle reports rather than + // gates (so failOn is unused). const config = defineConfig({ metaComponents: options.metaComponents ?? [], - rules: options.rules ?? {}, - treatDynamicAs: 'pass', - failOn: 'critical' + rules: options.rules ?? {} }); const rules = selectRules(allRules, config); const lastSignature = new Map<string, string>(); return ({ event, resolve }) => { - // Dev-only: run analysis only under a Node dev server. In production — and in - // non-Node runtimes (edge adapters) where `process` is undefined — pass through - // untouched. Guarding `typeof process` first avoids a ReferenceError that would - // otherwise crash every request on edge deployments. - if (typeof process === 'undefined' || process.env.NODE_ENV === 'production') return resolve(event); - let buffer = ''; return resolve(event, { - transformPageChunk: async ({ html, done }) => { + transformPageChunk: ({ html, done }) => { buffer += html; - if (done) await analyzeAndWarn(buffer, event.route.id ?? event.url.pathname, rules, config, lastSignature); + // Observe-only: return the chunk unchanged and never block the response on + // analysis. We fire-and-forget on the final chunk; analyzeAndWarn swallows + // its own errors, so the floating promise can never reject. + if (done) void analyzeAndWarn(buffer, event.route.id ?? event.url.pathname, rules, config, lastSignature); return html; } }); diff --git a/packages/vite/test/dev-handle.test.ts b/packages/vite/test/dev-handle.test.ts index eac3da30..792e752d 100644 --- a/packages/vite/test/dev-handle.test.ts +++ b/packages/vite/test/dev-handle.test.ts @@ -23,6 +23,11 @@ function resolveWith(chunks: string[]) { }) as Parameters<Handle>[0]['resolve']; } +// The handle analyzes fire-and-forget (it never blocks the response), so the +// resulting console.warn lands a few microtasks after handle() resolves. One +// macrotask tick drains that chain — analysis is purely in-memory, no real I/O. +const flush = () => new Promise((resolve) => setTimeout(resolve, 0)); + const PAGE_NO_TITLE = '<html lang="en"><head><meta name="description" content="x"></head><body></body></html>'; const PAGE_OK = '<html lang="en"><head><title>Home' + @@ -37,6 +42,7 @@ describe('svelteVitalsHandle', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const handle = svelteVitalsHandle(); await handle({ event: fakeEvent('/none', '/none'), resolve: resolveWith([PAGE_NO_TITLE]) }); + await flush(); const out = warn.mock.calls.map((c) => String(c[0])).join('\n'); expect(out).toContain('[svelte-vitals] /none'); expect(out).toContain('✗ SEO001 Missing '); @@ -46,6 +52,7 @@ describe('svelteVitalsHandle', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const handle = svelteVitalsHandle(); await handle({ event: fakeEvent('/ok', '/ok'), resolve: resolveWith([PAGE_OK]) }); + await flush(); expect(warn).not.toHaveBeenCalled(); }); @@ -63,35 +70,23 @@ describe('svelteVitalsHandle', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); const handle = svelteVitalsHandle(); await handle({ event: fakeEvent('/none', '/none'), resolve: resolveWith([PAGE_NO_TITLE]) }); + await flush(); await handle({ event: fakeEvent('/none', '/none'), resolve: resolveWith([PAGE_NO_TITLE]) }); + await flush(); expect(warn).toHaveBeenCalledTimes(1); }); - it('is a pass-through in production (no transformPageChunk, no output)', async () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const prev = process.env.NODE_ENV; - process.env.NODE_ENV = 'production'; - try { - const handle = svelteVitalsHandle(); - const res = (await handle({ - event: fakeEvent('/none', '/none'), - resolve: resolveWith([PAGE_NO_TITLE]) - })) as unknown as { transformed: boolean }; - expect(res.transformed).toBe(false); - expect(warn).not.toHaveBeenCalled(); - } finally { - // Restore precisely: assigning `undefined` would coerce to the string - // 'undefined' and leave the var set, polluting later tests. - if (prev === undefined) delete process.env.NODE_ENV; - else process.env.NODE_ENV = prev; - } - }); - - it('passes through when process is undefined (non-Node/edge runtime)', async () => { + // Outside dev (production builds, and non-Node/edge runtimes), esm-env resolves + // `DEV` to false, so the handle short-circuits to a pass-through. Mocking esm-env + // is the canonical way to exercise that branch — `DEV` is a static import, not a + // runtime read of NODE_ENV, so toggling env vars wouldn't flip it. + it('is a pass-through when not in dev (no transformPageChunk, no output)', async () => { + vi.resetModules(); + vi.doMock('esm-env', () => ({ DEV: false })); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - const handle = svelteVitalsHandle(); - vi.stubGlobal('process', undefined); try { + const { svelteVitalsHandle: prodHandle } = await import('../src/hooks/index.js'); + const handle = prodHandle(); const res = (await handle({ event: fakeEvent('/none', '/none'), resolve: resolveWith([PAGE_NO_TITLE]) @@ -99,7 +94,8 @@ describe('svelteVitalsHandle', () => { expect(res.transformed).toBe(false); expect(warn).not.toHaveBeenCalled(); } finally { - vi.unstubAllGlobals(); + vi.doUnmock('esm-env'); + vi.resetModules(); } }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aafc0753..7d3af3cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ catalogs: eslint-plugin-svelte: specifier: ^3.19.0 version: 3.19.0 + esm-env: + specifier: ^1.2.2 + version: 1.2.2 globals: specifier: ^17.6.0 version: 17.6.0 @@ -158,6 +161,9 @@ importers: '@svelte-vitals/core': specifier: workspace:* version: link:../core + esm-env: + specifier: 'catalog:' + version: 1.2.2 node-html-parser: specifier: 'catalog:' version: 6.1.13 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3122d8dc..afd9d358 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,6 +16,7 @@ catalog: eslint: ^10.5.0 eslint-config-prettier: ^10.1.8 eslint-plugin-svelte: ^3.19.0 + esm-env: ^1.2.2 globals: ^17.6.0 mri: ^1.2.0 node-html-parser: ^6.1.13 From 3f88bafa5a172ef05cdda7b1bad4f71876e0d5f3 Mon Sep 17 00:00:00 2001 From: oekazuma <oe.kazuma@a-tm.co.jp> Date: Mon, 22 Jun 2026 12:35:40 +0900 Subject: [PATCH 10/10] test(vite): cover dev-handle option plumbing and debug hatch; document both Add tests that `options.rules` overrides flow into the handle's config and that `SVELTE_VITALS_DEBUG` surfaces otherwise-swallowed analysis errors. Document the handle's `metaComponents`/`rules` options and the debug env var in the README, and include @svelte-vitals/vite in `check:publish` so the new `./hooks` export map is validated by publint. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- README.md | 2 ++ package.json | 2 +- packages/vite/test/dev-handle.test.ts | 38 +++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index cf0e3e0e..fa59cd91 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,8 @@ export const handle = sequence(svelteVitalsHandle()); It runs in `dev` only (a no-op in production builds) and never modifies the response — it only reads the rendered HTML. Coverage follows navigation: a route is checked when you visit it, and re-warned only when its findings change. +`svelteVitalsHandle` accepts a focused subset of the plugin options — `metaComponents` and per-rule `rules` overrides (e.g. `svelteVitalsHandle({ rules: { SEO008: 'off' } })`). Analysis errors are swallowed so a tool bug never breaks a request; set the `SVELTE_VITALS_DEBUG` env var to surface them in the terminal while debugging. + ### Exit codes | Code | Meaning | diff --git a/package.json b/package.json index 8653c425..8d6b1c0f 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "test": "pnpm -r test", "lint": "prettier --check . && eslint .", "format": "prettier --write .", - "check:publish": "pnpm --filter @svelte-vitals/core --filter svelte-vitals exec publint", + "check:publish": "pnpm --filter @svelte-vitals/core --filter @svelte-vitals/vite --filter svelte-vitals exec publint", "release": "changeset publish", "changeset": "changeset" }, diff --git a/packages/vite/test/dev-handle.test.ts b/packages/vite/test/dev-handle.test.ts index 792e752d..36f6f362 100644 --- a/packages/vite/test/dev-handle.test.ts +++ b/packages/vite/test/dev-handle.test.ts @@ -106,4 +106,42 @@ describe('svelteVitalsHandle', () => { handle({ event: fakeEvent(null, '/x'), resolve: resolveWith(['not really <<< html']) }) ).resolves.toBeDefined(); }); + + it('honors per-rule overrides from options (rules)', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const handle = svelteVitalsHandle({ rules: { SEO001: 'off' } }); + await handle({ event: fakeEvent('/none', '/none'), resolve: resolveWith([PAGE_NO_TITLE]) }); + await flush(); + const out = warn.mock.calls.map((c) => String(c[0])).join('\n'); + // The page still trips other rules, so the route is reported — but with SEO001 + // disabled, the missing-title line is gone. Proves options flow into the config. + expect(out).toContain('[svelte-vitals] /none'); + expect(out).not.toContain('SEO001'); + }); + + it('surfaces swallowed analysis errors when SVELTE_VITALS_DEBUG is set', async () => { + vi.resetModules(); + vi.doMock('../src/providers/rendered/parse-html.js', () => ({ + parseHtmlHead: () => { + throw new Error('boom'); + } + })); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const prev = process.env.SVELTE_VITALS_DEBUG; + process.env.SVELTE_VITALS_DEBUG = '1'; + try { + const { svelteVitalsHandle: debugHandle } = await import('../src/hooks/index.js'); + const handle = debugHandle(); + await handle({ event: fakeEvent('/none', '/none'), resolve: resolveWith([PAGE_NO_TITLE]) }); + await flush(); + const out = warn.mock.calls.map((c) => String(c[0])).join('\n'); + expect(out).toContain('[svelte-vitals] dev analysis failed:'); + } finally { + // Restore precisely: assigning `undefined` would coerce to the string 'undefined'. + if (prev === undefined) delete process.env.SVELTE_VITALS_DEBUG; + else process.env.SVELTE_VITALS_DEBUG = prev; + vi.doUnmock('../src/providers/rendered/parse-html.js'); + vi.resetModules(); + } + }); });