From f48a155e82ee4a9902d25a0045ae7d86d4e5b5e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:13:31 +0000 Subject: [PATCH 1/8] feat(detect): more frameworks, package managers, and the evidence for each - The registry recognises Qwik, SolidStart, TanStack Start, Analog, Vue CLI, Parcel, Rsbuild, Rspack, Ember, Hexo, MkDocs, Sphinx, mdBook, Zola, Quarto and Pelican, and Drupal, Statamic, Ghost and Shopify themes, which are never started. Hugo is also found through config/_default/. Zola is told from Hugo by what config.toml says. - detectFramework says why it recognised a framework and where the output directory came from, for `eaa-kit detect`. - The package manager comes from corepack's packageManager field first, then a lockfile, including Bun's text bun.lock, deno.lock and package-lock.json, searched up to the repository root so an app in a monorepo finds the workspace's lockfile. Deno runs scripts as tasks. - storybook-static/ is never audited as part of the site. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013BXnvSeRtgZoTXe4j753gM --- src/audit/collect.ts | 11 +- src/audit/frameworks.ts | 255 ++++++++++++++++++++++++++++++--- src/audit/project.ts | 79 +++++++--- src/cli/setup.ts | 4 +- tests/audit/collect.test.ts | 15 ++ tests/audit/frameworks.test.ts | 69 +++++++++ tests/audit/project.test.ts | 51 +++++++ 7 files changed, 450 insertions(+), 34 deletions(-) diff --git a/src/audit/collect.ts b/src/audit/collect.ts index 15137a5..a4d168a 100644 --- a/src/audit/collect.ts +++ b/src/audit/collect.ts @@ -7,7 +7,16 @@ import { exists, toPosix } from '../fs.ts' export const DEFAULT_INCLUDE = ['**/*.html', '**/*.htm'] as const /** Vendored and tooling directories are never part of the shipped site. */ -export const DEFAULT_EXCLUDE = ['**/node_modules/**', '**/.git/**'] as const +/** + * Never pages of the site. `storybook-static/` is Storybook's build: a + * catalogue of components out of context, whose findings would be reported + * against the site as though a visitor could reach them. + */ +export const DEFAULT_EXCLUDE = [ + '**/node_modules/**', + '**/.git/**', + '**/storybook-static/**', +] as const /** Number of files read in parallel; keeps large builds under the fd limit. */ const READ_CONCURRENCY = 24 diff --git a/src/audit/frameworks.ts b/src/audit/frameworks.ts index 2d81e31..ba6916c 100644 --- a/src/audit/frameworks.ts +++ b/src/audit/frameworks.ts @@ -25,6 +25,11 @@ export interface Framework { packages: readonly string[] /** Files that identify it, for the ones that are not npm packages. */ files?: readonly string[] + /** + * A file that identifies it only by what it says, for a name two tools share: + * Zola and Hugo both read `config.toml`, and only Zola's has `base_url`. + */ + contents?: { file: string; pattern: RegExp } /** Where it writes browsable HTML, best candidate first. */ outputs: readonly string[] /** @@ -109,6 +114,38 @@ export const FRAMEWORKS: readonly Framework[] = [ outputs: ['build/client'], serves: true, }, + { + id: 'qwik', + name: 'Qwik', + packages: ['@builder.io/qwik-city', '@qwik.dev/router'], + outputs: ['dist'], + staticOutput: { + needs: 'the static adapter', + how: 'run npm run qwik add static, then build', + }, + serves: true, + }, + { + id: 'solidstart', + name: 'SolidStart', + packages: ['@solidjs/start'], + outputs: ['.output/public', 'dist'], + serves: true, + }, + { + id: 'tanstack-start', + name: 'TanStack Start', + packages: ['@tanstack/react-start', '@tanstack/solid-start'], + outputs: ['.output/public', 'dist/client'], + serves: true, + }, + { + id: 'analog', + name: 'Analog', + packages: ['@analogjs/platform'], + outputs: ['dist/analog/public'], + serves: true, + }, { id: 'astro', name: 'Astro', @@ -165,11 +202,84 @@ export const FRAMEWORKS: readonly Framework[] = [ outputs: ['build'], serves: false, }, + { + id: 'hexo', + name: 'Hexo', + packages: ['hexo'], + outputs: ['public'], + configs: ['_config.yml'], + outputPattern: /^public_dir:\s*['"]?([^'"\s#]+)/m, + serves: false, + }, + { + id: 'mkdocs', + name: 'MkDocs', + packages: [], + files: ['mkdocs.yml', 'mkdocs.yaml'], + outputs: ['site'], + configs: ['mkdocs.yml', 'mkdocs.yaml'], + outputPattern: /^site_dir:\s*['"]?([^'"\s#]+)/m, + serves: false, + }, + { + id: 'sphinx', + name: 'Sphinx', + packages: [], + files: ['conf.py', 'docs/conf.py', 'doc/conf.py', 'source/conf.py', 'docs/source/conf.py'], + outputs: [ + '_build/html', + 'docs/_build/html', + 'build/html', + 'docs/build/html', + 'doc/_build/html', + ], + serves: false, + }, + { + id: 'mdbook', + name: 'mdBook', + packages: [], + files: ['book.toml'], + outputs: ['book'], + configs: ['book.toml'], + outputPattern: /build-dir\s*=\s*['"]([^'"]+)['"]/, + serves: false, + }, + { + id: 'quarto', + name: 'Quarto', + packages: [], + files: ['_quarto.yml', '_quarto.yaml'], + outputs: ['_site'], + configs: ['_quarto.yml', '_quarto.yaml'], + outputPattern: /output-dir:\s*['"]?([^'"\s#]+)/, + serves: false, + }, + { + id: 'pelican', + name: 'Pelican', + packages: [], + files: ['pelicanconf.py'], + outputs: ['output'], + configs: ['pelicanconf.py', 'publishconf.py'], + outputPattern: /OUTPUT_PATH\s*=\s*['"]([^'"]+)['"]/, + serves: false, + }, + { + // Before Hugo: both read config.toml, and only Zola's says base_url. + id: 'zola', + name: 'Zola', + packages: [], + files: ['zola.toml'], + contents: { file: 'config.toml', pattern: /^\s*base_url\s*=/m }, + outputs: ['public'], + serves: false, + }, { id: 'hugo', name: 'Hugo', packages: [], - files: ['hugo.toml', 'hugo.yaml', 'config.toml'], + files: ['hugo.toml', 'hugo.yaml', 'hugo.json', 'config.toml', 'config/_default'], outputs: ['public'], serves: false, }, @@ -214,6 +324,25 @@ export const FRAMEWORKS: readonly Framework[] = [ serves: true, serveCommand: 'ddev start, or php craft serve', }, + { + id: 'drupal', + name: 'Drupal', + packages: [], + files: ['core/lib/Drupal.php', 'web/core/lib/Drupal.php', 'docroot/core/lib/Drupal.php'], + outputs: [], + serves: true, + serveCommand: 'ddev start, or drush runserver', + }, + { + // Before Laravel: a Statamic site is a Laravel app too, and has artisan. + id: 'statamic', + name: 'Statamic', + packages: [], + files: ['please'], + outputs: [], + serves: true, + serveCommand: 'php artisan serve', + }, { id: 'laravel', name: 'Laravel', @@ -250,6 +379,63 @@ export const FRAMEWORKS: readonly Framework[] = [ serves: true, serveCommand: 'python manage.py runserver', }, + { + id: 'ghost', + name: 'Ghost theme', + packages: [], + files: ['default.hbs'], + outputs: [], + serves: true, + serveCommand: 'ghost start, in the Ghost install this theme belongs to', + }, + { + id: 'shopify', + name: 'Shopify theme', + packages: [], + files: ['layout/theme.liquid'], + outputs: [], + serves: true, + serveCommand: 'shopify theme dev', + }, + // Bundlers, after everything that might use one: a Vue CLI or Parcel build in + // a CMS theme is that CMS's asset pipeline, not the site. + { + id: 'vue-cli', + name: 'Vue CLI', + packages: ['@vue/cli-service'], + outputs: ['dist'], + configs: ['vue.config.js', 'vue.config.mjs', 'vue.config.ts'], + outputPattern: /outputDir\s*:\s*['"`]([^'"`]+)['"`]/, + serves: false, + }, + { + id: 'ember', + name: 'Ember', + packages: ['ember-cli'], + outputs: ['dist'], + serves: false, + }, + { + id: 'parcel', + name: 'Parcel', + packages: ['parcel'], + outputs: ['dist'], + serves: false, + }, + { + id: 'rsbuild', + name: 'Rsbuild', + packages: ['@rsbuild/core'], + outputs: ['dist'], + serves: false, + }, + { + id: 'rspack', + name: 'Rspack', + packages: ['@rspack/cli', '@rspack/core'], + outputs: ['dist'], + serves: false, + }, { id: 'vite', name: 'Vite', @@ -278,6 +464,8 @@ export interface DetectedFramework { * then the framework's defaults. */ outputs: string[] + /** Why it was recognised, in words, for `eaa-kit detect`. */ + evidence: string[] } /** @@ -295,28 +483,56 @@ export async function detectFramework( const deps = { ...pkg?.dependencies, ...pkg?.devDependencies } for (const framework of FRAMEWORKS) { - const byPackage = framework.packages.some((name) => deps[name] !== undefined) - let byFile = false - if (!byPackage && framework.files !== undefined) { - for (const file of framework.files) { - if (await exists(file, cwd)) { - byFile = true - break - } - } - } - if (!byPackage && !byFile) continue + const evidence = await identify(cwd, framework, deps) + if (evidence === undefined) continue - const configured = await outputFromConfig(cwd, framework) + const configured = await configuredOutput(cwd, framework) // Configured first, then the defaults: a project that moved its output // still usually has the default directory lying around from before. const outputs = - configured === undefined ? [...framework.outputs] : [configured, ...framework.outputs] - return { framework, outputs: [...new Set(outputs)] } + configured === undefined ? [...framework.outputs] : [configured.output, ...framework.outputs] + return { + framework, + outputs: [...new Set(outputs)], + evidence: [ + evidence, + ...(configured === undefined + ? [] + : [`${configured.file} sets the output to ${configured.output}/`]), + ], + } } return undefined } +/** What identifies this framework here, in words, or undefined if nothing does. */ +async function identify( + cwd: string, + framework: Framework, + deps: Record, +): Promise { + const dependency = framework.packages.find((name) => deps[name] !== undefined) + if (dependency !== undefined) return `package.json depends on ${dependency}` + for (const file of framework.files ?? []) { + if (await exists(file, cwd)) return `found ${file}` + } + if (framework.contents !== undefined) { + const { file, pattern } = framework.contents + try { + if (pattern.test(await readFile(path.resolve(cwd, file), 'utf8'))) { + return `${file} is ${article(framework.name)} ${framework.name} config` + } + } catch { + // not there + } + } + return undefined +} + +function article(name: string): string { + return /^[AEFHILMNORSX]/.test(name) ? 'an' : 'a' +} + /** * A custom output directory, read out of the framework's config file. * @@ -330,6 +546,13 @@ export async function outputFromConfig( cwd: string, framework: Framework, ): Promise { + return (await configuredOutput(cwd, framework))?.output +} + +async function configuredOutput( + cwd: string, + framework: Framework, +): Promise<{ file: string; output: string } | undefined> { if (framework.configs === undefined || framework.outputPattern === undefined) return undefined for (const name of framework.configs) { let source: string @@ -344,7 +567,7 @@ export async function outputFromConfig( // Relative to the project. An absolute one is somebody's machine, not a // fact about the project, and joining it would produce nonsense. if (path.isAbsolute(value)) continue - return value.replace(/^\.\//, '') + return { file: name, output: value.replace(/^\.\//, '').replace(/\/$/, '') } } return undefined } diff --git a/src/audit/project.ts b/src/audit/project.ts index 3e31da1..812faeb 100644 --- a/src/audit/project.ts +++ b/src/audit/project.ts @@ -2,7 +2,8 @@ import { spawn } from 'node:child_process' import { readFile } from 'node:fs/promises' import path from 'node:path' import { glob } from 'tinyglobby' -import { exists, isDirectory } from '../fs.ts' +import { exists, isDirectory, toPosix } from '../fs.ts' +import { DEFAULT_EXCLUDE } from './collect.ts' import { candidateOutputs } from './frameworks.ts' /** @@ -30,6 +31,10 @@ export interface PackageJson { scripts?: Record dependencies?: Record devDependencies?: Record + /** Corepack's `name@version`, e.g. `pnpm@10.4.1`. */ + packageManager?: string + /** npm and yarn workspaces: an array, or yarn's `{ packages }`. */ + workspaces?: string[] | { packages?: string[] } } export async function readPackageJson(cwd: string): Promise { @@ -40,22 +45,62 @@ export async function readPackageJson(cwd: string): Promise = [ + ['pnpm-lock.yaml', 'pnpm'], + ['yarn.lock', 'yarn'], + // Bun 1.2 writes a text lockfile; earlier versions the binary one. + ['bun.lock', 'bun'], + ['bun.lockb', 'bun'], + ['deno.lock', 'deno'], + ['package-lock.json', 'npm'], + ['npm-shrinkwrap.json', 'npm'], +] + +export interface PackageManagerFinding { + manager: PackageManager + /** What decided it, for `eaa-kit detect`. */ + evidence: string +} + /** - * The package manager this project uses, from its lockfile. + * The package manager this project uses, and how that was decided. * * Running the wrong one either fails or, worse, silently installs a second - * dependency tree, so the lockfile decides rather than a guess. + * dependency tree, so this is read rather than guessed: corepack's + * `packageManager` field first, because it is the project saying so outright, + * then a lockfile. An app inside a monorepo has no lockfile of its own, so the + * search walks up to the repository root, and no further: a lockfile above the + * repository belongs to somebody else's project. */ -export async function detectPackageManager(cwd: string): Promise<'pnpm' | 'yarn' | 'bun' | 'npm'> { - const lockfiles = [ - ['pnpm-lock.yaml', 'pnpm'], - ['yarn.lock', 'yarn'], - ['bun.lockb', 'bun'], - ] as const - for (const [file, manager] of lockfiles) { - if (await exists(path.join(cwd, file))) return manager +export async function findPackageManager(cwd: string): Promise { + let dir = path.resolve(cwd) + for (;;) { + const declared = (await readPackageJson(dir))?.packageManager + const name = typeof declared === 'string' ? declared.split('@')[0] : undefined + const known = MANAGERS.find((manager) => manager === name) + if (known !== undefined) { + return { manager: known, evidence: `package.json declares packageManager ${declared}` } + } + for (const [file, manager] of LOCKFILES) { + if (await exists(path.join(dir, file))) { + const where = path.relative(cwd, path.join(dir, file)) || file + return { manager, evidence: `found ${toPosix(where)}` } + } + } + const parent = path.dirname(dir) + if (parent === dir || (await exists(path.join(dir, '.git')))) break + dir = parent } - return 'npm' + return { manager: 'npm', evidence: 'no lockfile, so npm' } +} + +export async function detectPackageManager(cwd: string): Promise { + return (await findPackageManager(cwd)).manager } /** @@ -74,7 +119,7 @@ export async function findBuildOutput(cwd: string): Promise if (!(await isDirectory(directory))) continue const found = await glob(['**/*.html', '**/*.htm'], { cwd: directory, - ignore: ['**/node_modules/**'], + ignore: [...DEFAULT_EXCLUDE], onlyFiles: true, dot: false, }) @@ -99,10 +144,12 @@ async function holdsTopLevelHtml(cwd: string): Promise { * directly — so cmd.exe is invoked explicitly with one command string instead. */ function scriptCommand(manager: string, script: string): { command: string; args: string[] } { - if (process.platform !== 'win32') return { command: manager, args: ['run', script] } + // Deno runs package.json scripts as tasks; `deno run` would execute a file. + const verb = manager === 'deno' ? 'task' : 'run' + if (process.platform !== 'win32') return { command: manager, args: [verb, script] } return { command: process.env['ComSpec'] ?? 'cmd.exe', - args: ['/d', '/s', '/c', `${manager} run ${script}`], + args: ['/d', '/s', '/c', `${manager} ${verb} ${script}`], } } @@ -122,7 +169,7 @@ export interface ScriptRun { */ export async function runScript(cwd: string, script: string): Promise { const manager = await detectPackageManager(cwd) - const command = `${manager} run ${script}` + const command = `${manager} ${manager === 'deno' ? 'task' : 'run'} ${script}` return new Promise((resolve) => { const { command: bin, args } = scriptCommand(manager, script) const child = spawn(bin, args, { cwd, stdio: ['ignore', 'inherit', 'inherit'] }) diff --git a/src/cli/setup.ts b/src/cli/setup.ts index a635159..87a7eeb 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -80,6 +80,7 @@ export async function workflowFor(inputs: WorkflowInputs): Promise { ) } if (manager === 'bun') setup.push(' - uses: oven-sh/setup-bun@v2') + if (manager === 'deno') setup.push(' - uses: denoland/setup-deno@v2') const install = manager === undefined @@ -89,10 +90,11 @@ export async function workflowFor(inputs: WorkflowInputs): Promise { pnpm: 'pnpm install --frozen-lockfile', yarn: 'yarn install --frozen-lockfile', bun: 'bun install --frozen-lockfile', + deno: 'deno install --frozen', }[manager] const build = manager !== undefined && pkg?.scripts?.['build'] !== undefined - ? `${manager} run build` + ? `${manager} ${manager === 'deno' ? 'task' : 'run'} build` : undefined const withLines = [ diff --git a/tests/audit/collect.test.ts b/tests/audit/collect.test.ts index 1dde03a..380eaa7 100644 --- a/tests/audit/collect.test.ts +++ b/tests/audit/collect.test.ts @@ -25,6 +25,21 @@ afterEach(async () => { }) describe('collectPages', () => { + it('leaves out a Storybook build, which is a component catalogue and not the site', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'eaa-kit-storybook-')) + try { + await mkdir(path.join(dir, 'storybook-static'), { recursive: true }) + await writeFile(path.join(dir, 'index.html'), 'x') + await writeFile(path.join(dir, 'storybook-static/index.html'), '') + + const pages = await collectPages(dir) + + expect(pages.map((page) => page.relativePath)).toEqual(['index.html']) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + it('collects .html and .htm files recursively, sorted by relative path', async () => { const pages = await collectPages(SITE) diff --git a/tests/audit/frameworks.test.ts b/tests/audit/frameworks.test.ts index 8ac1066..a56548c 100644 --- a/tests/audit/frameworks.test.ts +++ b/tests/audit/frameworks.test.ts @@ -83,6 +83,16 @@ describe('detectFramework', () => { ['react-scripts', 'Create React App', 'build'], ['@react-router/dev', 'React Router / Remix', 'build/client'], ['vite', 'Vite', 'dist'], + ['@builder.io/qwik-city', 'Qwik', 'dist'], + ['@solidjs/start', 'SolidStart', '.output/public'], + ['@tanstack/react-start', 'TanStack Start', '.output/public'], + ['@analogjs/platform', 'Analog', 'dist/analog/public'], + ['@vue/cli-service', 'Vue CLI', 'dist'], + ['parcel', 'Parcel', 'dist'], + ['@rsbuild/core', 'Rsbuild', 'dist'], + ['@rspack/cli', 'Rspack', 'dist'], + ['ember-cli', 'Ember', 'dist'], + ['hexo', 'Hexo', 'public'], ])('recognises %s as %s writing to %s', async (dependency, name, output) => { const cwd = await project({ 'package.json': '{}' }) @@ -95,12 +105,61 @@ describe('detectFramework', () => { it.each([ ['hugo.toml', 'Hugo'], ['_config.yml', 'Jekyll'], + ['config/_default/hugo.toml', 'Hugo'], + ['mkdocs.yml', 'MkDocs'], + ['docs/conf.py', 'Sphinx'], + ['book.toml', 'mdBook'], + ['zola.toml', 'Zola'], + ['_quarto.yml', 'Quarto'], + ['pelicanconf.py', 'Pelican'], ])('recognises %s, which has no package.json to read', async (file, name) => { const detected = await detectFramework(await project({ [file]: '' })) expect(detected?.framework.name).toBe(name) }) + it('tells Zola from Hugo by what config.toml says', async () => { + // Both read config.toml. Zola's has base_url; Hugo's has baseURL. + const zola = await detectFramework(await project({ 'config.toml': 'base_url = "https://x"\n' })) + const hugo = await detectFramework(await project({ 'config.toml': 'baseURL = "https://x"\n' })) + + expect(zola?.framework.id).toBe('zola') + expect(hugo?.framework.id).toBe('hugo') + }) + + it('reads a Hexo project as Hexo, although it has a _config.yml like Jekyll', async () => { + const dir = await project({ '_config.yml': 'public_dir: public\n' }) + + expect((await detectFramework(dir, pkg({ hexo: '7.0.0' }, false)))?.framework.id).toBe('hexo') + }) + + it.each([ + ['mkdocs.yml', 'site_dir: build/site\n', 'build/site'], + ['_quarto.yml', 'project:\n output-dir: docs\n', 'docs'], + ['book.toml', '[build]\nbuild-dir = "out"\n', 'out'], + ])('reads the output directory out of %s', async (file, body, output) => { + const detected = await detectFramework(await project({ [file]: body })) + + expect(detected?.outputs[0]).toBe(output) + }) + + it('says why it recognised the framework', async () => { + const dir = await project({ 'astro.config.mjs': "export default { outDir: './public-html' }" }) + + const detected = await detectFramework(dir, pkg({ astro: '5.0.0' })) + + expect(detected?.evidence).toEqual([ + 'package.json depends on astro', + 'astro.config.mjs sets the output to public-html/', + ]) + }) + + it('names the file when that is what recognised it', async () => { + const detected = await detectFramework(await project({ 'hugo.toml': '' })) + + expect(detected?.evidence).toEqual(['found hugo.toml']) + }) + it('prefers the specific framework over the bundler underneath it', async () => { // A SvelteKit project depends on Vite. Calling it a Vite project would send // the reader to dist/, which SvelteKit does not use. @@ -195,6 +254,10 @@ describe('projects that render on a server and write no HTML', () => { ['symfony', 'Symfony', 'symfony.lock'], ['rails', 'Ruby on Rails', 'config.ru'], ['django', 'Django', 'manage.py'], + ['drupal', 'Drupal', 'web/core/lib/Drupal.php'], + ['statamic', 'Statamic', 'please'], + ['ghost', 'Ghost theme', 'default.hbs'], + ['shopify', 'Shopify theme', 'layout/theme.liquid'], ])('recognises %s by a file rather than a dependency', async (id, name, file) => { // Their package.json, where there is one, belongs to a theme's asset build // and says nothing about the CMS around it. @@ -216,6 +279,12 @@ describe('projects that render on a server and write no HTML', () => { expect(detected?.framework.serveCommand).toContain('artisan serve') }) + it('reads a Statamic site as Statamic, although it is also a Laravel one', async () => { + const dir = await project({ please: '', artisan: '' }) + + expect((await detectFramework(dir, undefined))?.framework.id).toBe('statamic') + }) + it('beats a bundler in the theme, because the site is the CMS', async () => { // A WordPress theme built with Vite is a WordPress site; auditing the // folder Vite filled would audit its stylesheets. diff --git a/tests/audit/project.test.ts b/tests/audit/project.test.ts index 59adce0..99aa602 100644 --- a/tests/audit/project.test.ts +++ b/tests/audit/project.test.ts @@ -43,6 +43,12 @@ describe('findBuildOutput', () => { expect(await findBuildOutput(dir)).toBeUndefined() }) + it('does not take a Storybook build for the site', async () => { + const dir = await project({ 'build/storybook-static/index.html': '' }) + + expect(await findBuildOutput(dir)).toBeUndefined() + }) + it('finds HTML nested inside the directory', async () => { const dir = await project({ 'dist/blog/post/index.html': '' }) @@ -66,10 +72,55 @@ describe('detectPackageManager', () => { ['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'], ['bun.lockb', 'bun'], + ['bun.lock', 'bun'], + ['deno.lock', 'deno'], + ['package-lock.json', 'npm'], ])('reads %s as %s', async (lockfile, expected) => { expect(await detectPackageManager(await project({ [lockfile]: '' }))).toBe(expected) }) + it('takes the packageManager field before any lockfile', async () => { + // Corepack's field is the project stating it outright; a stray lockfile + // from somebody running the wrong tool once is not. + const dir = await project({ + 'package.json': JSON.stringify({ packageManager: 'pnpm@10.4.1+sha512.abc' }), + 'package-lock.json': '', + }) + + expect(await detectPackageManager(dir)).toBe('pnpm') + }) + + it('ignores a packageManager field it does not know', async () => { + const dir = await project({ + 'package.json': JSON.stringify({ packageManager: 'cargo@1.0.0' }), + 'yarn.lock': '', + }) + + expect(await detectPackageManager(dir)).toBe('yarn') + }) + + it('finds the lockfile at the workspace root, above an app that has none', async () => { + const dir = await project({ + 'pnpm-lock.yaml': '', + 'pnpm-workspace.yaml': 'packages:\n - apps/*\n', + 'apps/web/package.json': '{}', + }) + + expect(await detectPackageManager(path.join(dir, 'apps/web'))).toBe('pnpm') + }) + + it('stops looking at the repository root', async () => { + // Above the repository is somebody else's directory, and a lockfile there + // says nothing about this project. + const outer = await project({ + 'yarn.lock': '', + 'repo/.git/HEAD': '', + 'repo/package.json': '{}', + }) + + expect(await detectPackageManager(path.join(outer, 'repo'))).toBe('npm') + }) + it('falls back to npm', async () => { // Running the wrong one either fails or silently installs a second // dependency tree, so this is decided by the lockfile, never guessed. From a6dd6f474772c3fd1600fc01e75107e63dc9a139 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:20:38 +0000 Subject: [PATCH 2/8] feat(detect): audit a Next.js server build from its own page list A plain `next build` writes no HTML this tool can audit as files, so the project was built, started and crawled from its home page, which finds what the navigation links to rather than the site. - The build's manifests (prerender, routes, app paths, pages) are read for every page it has, and the crawl is seeded from them, so a page nothing links to is audited. Report discovery is "manifest". - basePath, the default locale's unprefixed paths, and trailingSlash are respected; API routes, error pages and metadata files are left out. - A dynamic route with no prerendered pages is named as not audited rather than silently missed. - A standalone build is served by its server.js when its static files are in place; otherwise `next start`, never `next dev`. - Servers are offered a free port through PORT, the announced URL is read through colour codes and 0.0.0.0, and more framework ports are probed. The fixtures are the manifests of real Next.js 16 builds, with the preview keys redacted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013BXnvSeRtgZoTXe4j753gM --- docs/reports.md | 2 +- src/audit/completeness.ts | 4 +- src/audit/crawl.ts | 22 ++- src/audit/next.ts | 169 ++++++++++++++++ src/audit/project.ts | 137 ++++++++++++- src/cli/pages.ts | 19 +- tests/audit/crawl.test.ts | 26 +++ tests/audit/next.test.ts | 110 +++++++++++ tests/audit/project.test.ts | 64 ++++++ .../fixtures/stacks/next-i18n/.next/BUILD_ID | 1 + .../next-i18n/.next/prerender-manifest.json | 46 +++++ .../next-i18n/.next/routes-manifest.json | 104 ++++++++++ .../.next/server/pages-manifest.json | 15 ++ .../fixtures/stacks/next-i18n/next.config.mjs | 1 + tests/fixtures/stacks/next-i18n/package.json | 1 + .../stacks/next-server/.next/BUILD_ID | 1 + .../.next/app-path-routes-manifest.json | 8 + .../next-server/.next/prerender-manifest.json | 185 ++++++++++++++++++ .../next-server/.next/routes-manifest.json | 95 +++++++++ .../.next/server/pages-manifest.json | 7 + .../stacks/next-server/next.config.mjs | 1 + .../fixtures/stacks/next-server/package.json | 1 + 22 files changed, 1007 insertions(+), 12 deletions(-) create mode 100644 src/audit/next.ts create mode 100644 tests/audit/next.test.ts create mode 100644 tests/fixtures/stacks/next-i18n/.next/BUILD_ID create mode 100644 tests/fixtures/stacks/next-i18n/.next/prerender-manifest.json create mode 100644 tests/fixtures/stacks/next-i18n/.next/routes-manifest.json create mode 100644 tests/fixtures/stacks/next-i18n/.next/server/pages-manifest.json create mode 100644 tests/fixtures/stacks/next-i18n/next.config.mjs create mode 100644 tests/fixtures/stacks/next-i18n/package.json create mode 100644 tests/fixtures/stacks/next-server/.next/BUILD_ID create mode 100644 tests/fixtures/stacks/next-server/.next/app-path-routes-manifest.json create mode 100644 tests/fixtures/stacks/next-server/.next/prerender-manifest.json create mode 100644 tests/fixtures/stacks/next-server/.next/routes-manifest.json create mode 100644 tests/fixtures/stacks/next-server/.next/server/pages-manifest.json create mode 100644 tests/fixtures/stacks/next-server/next.config.mjs create mode 100644 tests/fixtures/stacks/next-server/package.json diff --git a/docs/reports.md b/docs/reports.md index d27f8b7..5244a59 100644 --- a/docs/reports.md +++ b/docs/reports.md @@ -88,7 +88,7 @@ A complete generated document is checked in at // are. Read "complete" before drawing any conclusion from the counts above. "completeness": { "complete": true, // false when anything went unmeasured - "discovery": "directory", // "directory" | "sitemap" | "links" + "discovery": "directory", // "directory" | "manifest" | "sitemap" | "links" "collected": 5, // pages handed to the engine "audited": 5, // pages this run reached a verdict on "reused": 0, // pages whose result came from the cache diff --git a/src/audit/completeness.ts b/src/audit/completeness.ts index 5244eb6..004ee2b 100644 --- a/src/audit/completeness.ts +++ b/src/audit/completeness.ts @@ -24,7 +24,7 @@ import type { PageAudit } from './result.ts' */ /** How the pages that were audited came to be found. */ -export type Discovery = 'directory' | 'sitemap' | 'links' +export type Discovery = 'directory' | 'manifest' | 'sitemap' | 'links' /** Something known to exist that this run never reached a verdict on. */ export interface Unmeasured { @@ -193,6 +193,8 @@ export function discoveryLabel(discovery: Discovery): string { switch (discovery) { case 'directory': return 'files in the build directory' + case 'manifest': + return "the build's own page list and links" case 'sitemap': return 'sitemap.xml and links' case 'links': diff --git a/src/audit/crawl.ts b/src/audit/crawl.ts index 297f535..5c2727b 100644 --- a/src/audit/crawl.ts +++ b/src/audit/crawl.ts @@ -76,6 +76,12 @@ export interface CrawlOptions { * Relative to the entry URL, or absolute on the same origin. */ sitemap?: string + /** + * Pages the project itself says it has, from its build: a Next.js build's + * manifests list every page it produced. Crawled ahead of anything else, and + * links are still followed from them. URLs on another origin are ignored. + */ + seeds?: readonly string[] /** * Largest response body to read, in bytes. Defaults to MAX_BODY_BYTES. A * response over it is recorded as a failure rather than buffered. @@ -113,7 +119,7 @@ export interface CrawlResult { /** True when the crawl stopped at maxPages rather than running out of links. */ truncated: boolean /** How the pages were found. */ - discovery: 'sitemap' | 'links' + discovery: 'manifest' | 'sitemap' | 'links' } /** Hosts that are this machine. Anything else needs allowRemote. */ @@ -459,6 +465,20 @@ export async function crawlSite(entry: URL, options: CrawlOptions = {}): Promise discovery = 'sitemap' for (const url of listed) enqueue(url, 0) } + // The build's own list outranks the sitemap as the account of how pages were + // found: it is what was built, where a sitemap is what somebody chose to list. + const seeded = (options.seeds ?? []).flatMap((raw) => { + try { + const url = new URL(raw, entry) + return url.origin === entry.origin ? [url] : [] + } catch { + return [] + } + }) + if (seeded.length > 0) { + discovery = 'manifest' + for (const url of seeded) enqueue(url, 0) + } enqueue(entry, 0) const pages: CollectedPage[] = [] diff --git a/src/audit/next.ts b/src/audit/next.ts new file mode 100644 index 0000000..dc2bd49 --- /dev/null +++ b/src/audit/next.ts @@ -0,0 +1,169 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' + +/** + * What a Next.js build says about its own pages. + * + * A plain `next build` writes no browsable HTML anywhere this tool can audit + * as files: the prerendered pages under `.next/server` link to + * `/_next/static/…`, which exists at that path only when `next start` serves + * it, so auditing them off disk would audit pages without their CSS and get + * contrast wrong. The only honest view of the site is the one `next start` + * serves. + * + * Following links from the home page then finds what the navigation links to, + * which is not the site. Next already wrote down every page it built, in the + * manifests beside the build, so those are read instead and the crawl is + * seeded from them: a page nothing links to is still audited, and a dynamic + * route that renders on request is named as not audited rather than silently + * missed. + * + * The manifest formats are internal to Next and have changed between majors. + * Every field here is read defensively, and the fixtures in + * `tests/fixtures/stacks/next-*` are copied from real builds, so a format + * change shows up as a failing test rather than a quietly shorter audit. + */ + +const CONFIG_FILES = ['next.config.ts', 'next.config.mjs', 'next.config.js', 'next.config.cjs'] + +export interface NextConfigFacts { + /** The config file the facts were read from. */ + file?: string + output?: 'export' | 'standalone' + distDir?: string + basePath?: string + trailingSlash?: boolean +} + +/** + * The facts in `next.config.*` that decide how to audit the site. + * + * Read with patterns rather than executed, for the reason `outputFromConfig` + * gives: a config file is code, and this runs before anything has decided the + * project is trustworthy. A value computed at runtime is simply not seen, + * which leaves the default, which is what the caller would do anyway. + */ +export async function readNextConfig(cwd: string): Promise { + for (const file of CONFIG_FILES) { + let source: string + try { + source = await readFile(path.join(cwd, file), 'utf8') + } catch { + continue + } + const string = (key: string): string | undefined => + new RegExp(`\\b${key}\\s*:\\s*['"\`]([^'"\`]*)['"\`]`).exec(source)?.[1] + const facts: NextConfigFacts = { file } + const output = string('output') + if (output === 'export' || output === 'standalone') facts.output = output + const distDir = string('distDir') + if (distDir !== undefined && distDir !== '' && !path.isAbsolute(distDir)) { + facts.distDir = distDir.replace(/^\.\//, '').replace(/\/$/, '') + } + const basePath = string('basePath') + if (basePath !== undefined && basePath !== '') facts.basePath = basePath.replace(/\/$/, '') + if (/\btrailingSlash\s*:\s*true\b/.test(source)) facts.trailingSlash = true + return facts + } + return {} +} + +export interface NextRoutes { + /** `basePath`, or '' when there is none. Every path below is under it. */ + basePath: string + /** Every page the build knows the address of, sorted, without the basePath. */ + pages: string[] + /** Dynamic page routes with no page built ahead of time, e.g. `/user/[id]`. */ + dynamic: string[] + trailingSlash: boolean +} + +/** + * Every page a `next build` produced or can serve at a known address. + * + * Undefined when there is no build to read, which the caller takes as "build + * first". + */ +export async function nextRoutes(cwd: string): Promise { + const config = await readNextConfig(cwd) + const dist = path.join(cwd, config.distDir ?? '.next') + + const routesManifest = await readJson<{ + basePath?: string + i18n?: { defaultLocale?: string } + }>(path.join(dist, 'routes-manifest.json')) + const prerender = await readJson<{ + routes?: Record + dynamicRoutes?: Record + }>(path.join(dist, 'prerender-manifest.json')) + if (routesManifest === undefined && prerender === undefined) return undefined + + const pagesManifest = + (await readJson>(path.join(dist, 'server', 'pages-manifest.json'))) ?? {} + const appPaths = + (await readJson>(path.join(dist, 'app-path-routes-manifest.json'))) ?? {} + + const defaultLocale = routesManifest?.i18n?.defaultLocale + + // Next serves the default locale without its prefix, and the prefixed path + // is the same page: listing both would audit it twice. + const unprefixed = (route: string): string => { + if (defaultLocale === undefined) return route + if (route === `/${defaultLocale}`) return '/' + if (route.startsWith(`/${defaultLocale}/`)) return route.slice(defaultLocale.length + 1) + return route + } + + const candidates = [ + ...Object.keys(prerender?.routes ?? {}), + ...Object.keys(pagesManifest), + // App router: keys are the files (`/about/page`), values the routes. Only + // pages count; `/route` entries are API handlers and metadata files. + ...Object.entries(appPaths) + .filter(([file]) => file.endsWith('/page')) + .map(([, route]) => route), + ].map(unprefixed) + + const isPage = (route: string): boolean => { + const segments = route.split('/').filter(Boolean) + if (segments[0] === 'api') return false + // /_app, /_document, /_error, /_not-found, /_global-error: never a page a + // visitor is sent to. + if (segments.some((segment) => segment.startsWith('_'))) return false + if (/^(404|500)$/.test(segments.at(-1) ?? '')) return false + // robots.txt, sitemap.xml, an Open Graph image: files, not pages. + if (/\.[a-z0-9]+$/i.test(segments.at(-1) ?? '')) return false + return true + } + + const known = new Set(candidates.filter(isPage)) + const pages = [...known].filter((route) => !route.includes('[')).sort() + + // A dynamic route whose pages were prerendered has them listed above. One + // with none is rendered on request, and the only record of which pages it + // has is whatever links to them. + const withPages = new Set(Object.keys(prerender?.dynamicRoutes ?? {}).map(unprefixed)) + const dynamic = [...known].filter((route) => route.includes('[') && !withPages.has(route)).sort() + + return { + basePath: (routesManifest?.basePath ?? config.basePath ?? '').replace(/\/$/, ''), + pages, + dynamic, + trailingSlash: config.trailingSlash === true, + } +} + +/** The page's address on a running server, basePath and trailing slash included. */ +export function nextPageUrl(origin: string, routes: NextRoutes, page: string): string { + let pathname = `${routes.basePath}${page === '/' ? '' : page}` || '/' + if (routes.trailingSlash && !pathname.endsWith('/')) pathname += '/' + return new URL(pathname, origin).href +} + +async function readJson(file: string): Promise { + try { + return JSON.parse(await readFile(file, 'utf8')) as T + } catch { + return undefined + } +} diff --git a/src/audit/project.ts b/src/audit/project.ts index 812faeb..4797f27 100644 --- a/src/audit/project.ts +++ b/src/audit/project.ts @@ -1,9 +1,11 @@ import { spawn } from 'node:child_process' import { readFile } from 'node:fs/promises' +import { createServer } from 'node:net' import path from 'node:path' import { glob } from 'tinyglobby' import { exists, isDirectory, toPosix } from '../fs.ts' import { DEFAULT_EXCLUDE } from './collect.ts' +import type { Unmeasured } from './completeness.ts' import { candidateOutputs } from './frameworks.ts' /** @@ -21,8 +23,12 @@ import { candidateOutputs } from './frameworks.ts' * or passing --url skips all of it. */ -/** Ports the common dev and preview servers use, tried if nothing is announced. */ -const KNOWN_PORTS = [3000, 4321, 5173, 8080, 4173, 3001] +/** + * Ports the common servers use, tried if nothing is announced: Next and Remix, + * Astro, Vite dev and preview, Angular, Gatsby, Hugo, Jekyll, and the usual + * fallbacks. + */ +const KNOWN_PORTS = [3000, 4321, 5173, 4173, 4200, 8000, 1313, 4000, 8080, 3001] /** How long to wait for a started server to answer. */ const SERVER_START_TIMEOUT_MS = 90_000 @@ -206,11 +212,41 @@ async function answers(origin: string): Promise { * on any port, and only falls back to probing the common ones. Returns undefined * if nothing came up before the timeout, having already stopped the process. */ -export async function startServer(cwd: string, script: string): Promise { +export interface StartServerOptions { + /** Run this instead of a package script, e.g. Next's standalone server.js. */ + command?: { bin: string; args: string[]; cwd?: string } +} + +/** A port nothing is listening on right now, from the operating system. */ +async function freePort(): Promise { + return new Promise((resolve) => { + const probe = createServer() + probe.once('error', () => resolve(undefined)) + probe.listen(0, '127.0.0.1', () => { + const address = probe.address() + const port = typeof address === 'object' && address !== null ? address.port : undefined + probe.close(() => resolve(port)) + }) + }) +} + +export async function startServer( + cwd: string, + script: string, + options: StartServerOptions = {}, +): Promise { const manager = await detectPackageManager(cwd) - const { command: bin, args } = scriptCommand(manager, script) + const { command: bin, args } = + options.command === undefined + ? scriptCommand(manager, script) + : { command: options.command.bin, args: options.command.args } + // Offered through PORT, which Next, Nuxt, Remix and most Node servers read, + // so a server already on 3000 is not mistaken for this one. A server that + // ignores it announces its own address, and that is taken instead. + const port = await freePort() const child = spawn(bin, args, { - cwd, + cwd: options.command?.cwd ?? cwd, + env: port === undefined ? process.env : { ...process.env, PORT: String(port) }, stdio: ['ignore', 'pipe', 'pipe'], // Its own process group, so the whole tree can be signalled. `npm run start` // spawns the real server as a grandchild: signalling npm alone leaves that @@ -221,7 +257,11 @@ export async function startServer(cwd: string, script: string): Promise { - const match = /https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]):(\d{2,5})/.exec(String(chunk)) + // Colour codes are stripped first: Next prints its URL between two of them, + // and 0.0.0.0 is a server listening everywhere, reachable as localhost. + // biome-ignore lint/suspicious/noControlCharactersInRegex: matching ANSI escapes is the point + const text = String(chunk).replace(/\u001b\[[0-9;]*m/g, '') + const match = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1?\]):(\d{2,5})/.exec(text) if (match && announced === undefined) announced = `http://localhost:${match[1]}` } child.stdout?.on('data', watch) @@ -268,8 +308,9 @@ export async function startServer(cwd: string, script: string): Promise `http://localhost:${port}`) + ? ports.map((candidate) => `http://localhost:${candidate}`) : [announced]) { if (await answers(origin)) return { origin, stop } } @@ -286,6 +327,13 @@ export interface AutoSource { directory?: string /** Site to crawl, when it did not. */ url?: string + /** + * Pages the project's build says it has, as URLs on `url`'s server: the crawl + * starts from these as well as from `url`. + */ + seeds?: string[] + /** Parts of the site known to exist that the crawl has no list of. */ + unreachable?: Unmeasured[] /** Called when the audit is done, to stop anything this started. */ cleanup?: () => Promise /** What was done, for the reader. One line per step. */ @@ -370,6 +418,8 @@ export async function autoDetectSource( // Built and still no HTML anywhere: the site renders on a server. Start it // and audit what it actually serves, which is the only honest view of it. + if (detected?.framework.id === 'next') return serveNext(cwd, scripts, steps, step) + const serveScript = ['start', 'preview', 'serve'].find((name) => scripts[name] !== undefined) if (serveScript === undefined) return { steps } @@ -383,3 +433,76 @@ export async function autoDetectSource( step(`Auditing ${server.origin}`) return { url: server.origin, cleanup: server.stop, steps } } + +/** + * Serve a Next.js build and list its pages from the build's own manifests. + * + * Never `next dev`: the development overlay and unoptimised output are not the + * site anybody visits. A standalone build is served by the server.js it wrote, + * when the static files were copied beside it as Next's docs say; otherwise by + * `next start`, which serves any build. + */ +async function serveNext( + cwd: string, + scripts: Record, + steps: string[], + step: (message: string) => void, +): Promise { + const { nextPageUrl, nextRoutes, readNextConfig } = await import('./next.ts') + const config = await readNextConfig(cwd) + const dist = config.distDir ?? '.next' + const standalone = path.join(cwd, dist, 'standalone') + + let options: StartServerOptions | undefined + let how: string + if ( + config.output === 'standalone' && + (await exists(path.join(standalone, 'server.js'))) && + (await exists(path.join(standalone, dist, 'static'))) + ) { + options = { command: { bin: process.execPath, args: ['server.js'], cwd: standalone } } + how = `node ${toPosix(path.join(dist, 'standalone', 'server.js'))}` + } else if (scripts['start'] !== undefined) { + how = 'start' + } else { + const bin = path.join(cwd, 'node_modules', 'next', 'dist', 'bin', 'next') + if (!(await exists(bin))) return { steps } + options = { command: { bin: process.execPath, args: [bin, 'start'] } } + how = 'next start' + } + + step(`This site renders on a server; starting it with ${how}`) + const server = await startServer(cwd, 'start', options) + if (server === undefined) { + step(`Could not start the site with ${how}`) + return { steps } + } + + const routes = await nextRoutes(cwd) + if (routes === undefined) { + step(`Auditing ${server.origin}`) + return { url: server.origin, cleanup: server.stop, steps } + } + + const url = nextPageUrl(server.origin, routes, '/') + const seeds = routes.pages.map((page) => nextPageUrl(server.origin, routes, page)) + step( + `Read ${seeds.length} ${seeds.length === 1 ? 'page' : 'pages'} from the Next.js build manifests`, + ) + step(`Auditing ${url}`) + return { + url, + seeds, + ...(routes.dynamic.length === 0 + ? {} + : { + unreachable: routes.dynamic.map((route) => ({ + location: `${routes.basePath}${route}`, + reason: + 'a dynamic route rendered on request, with no list of its pages: only pages that links reached were audited', + })), + }), + cleanup: server.stop, + steps, + } +} diff --git a/src/cli/pages.ts b/src/cli/pages.ts index f8942f1..f0f605c 100644 --- a/src/cli/pages.ts +++ b/src/cli/pages.ts @@ -48,6 +48,10 @@ export interface CrawlCommandOptions { * `ask` then treats as a no. */ confirm?: (question: string) => Promise + /** Pages the project's build lists, crawled as well as the entry URL. */ + seeds?: readonly string[] + /** Parts of the site known to exist that the crawl has no way to list. */ + knownUnreachable?: readonly Unmeasured[] /** Injectable for tests. Defaults to global fetch. */ fetchImpl?: typeof fetch } @@ -251,6 +255,7 @@ async function crawlPages( ...(options.maxDepth === undefined ? {} : { maxDepth: options.maxDepth }), ...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }), ...(options.headers === undefined ? {} : { headers: options.headers }), + ...(options.seeds === undefined ? {} : { seeds: options.seeds }), ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), }) @@ -262,7 +267,8 @@ async function crawlPages( return undefined } - const found = result.discovery === 'sitemap' ? 'sitemap.xml and links' : 'links' + const { discoveryLabel } = await import('../audit/completeness.ts') + const found = discoveryLabel(result.discovery) note(`Found ${count(result.pages.length, 'page')} from ${found}`) // Pages that could not be fetched are named rather than counted away: a @@ -273,6 +279,8 @@ async function crawlPages( reason: failure.reason, })) warnUnmeasured(failed, 'URL', 'fetched') + const known = options.knownUnreachable ?? [] + warnUnmeasured(known, 'route', 'listed') if (result.truncated) { warn( @@ -315,6 +323,7 @@ async function crawlPages( collected: result.pages.length, unreachable: [ ...failed, + ...known, // Never reached, whatever the status code said: the run has a verdict // about the page it was sent to, and none about the page it asked for. ...collapsed.map((redirect) => ({ @@ -351,6 +360,7 @@ async function followEntry( timeoutMs: options.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, maxBodyBytes: MAX_BODY_BYTES, ...(options.headers === undefined ? {} : { headers: options.headers }), + ...(options.seeds === undefined ? {} : { seeds: options.seeds }), ...(options.fetchImpl === undefined ? {} : { fetchImpl: options.fetchImpl }), }) @@ -500,7 +510,12 @@ async function resolveAutomatically( } if (detected?.url !== undefined) { - const resolved = await resolvePages(undefined, { ...options, url: detected.url }) + const resolved = await resolvePages(undefined, { + ...options, + url: detected.url, + ...(detected.seeds === undefined ? {} : { seeds: detected.seeds }), + ...(detected.unreachable === undefined ? {} : { knownUnreachable: detected.unreachable }), + }) if (resolved === undefined) { await detected.cleanup?.() return undefined diff --git a/tests/audit/crawl.test.ts b/tests/audit/crawl.test.ts index 54ca7aa..36af6e3 100644 --- a/tests/audit/crawl.test.ts +++ b/tests/audit/crawl.test.ts @@ -189,6 +189,32 @@ describe('crawlSite', () => { expect(result.pages.map((p) => p.relativePath)).toContain('orphan') }) + it("takes the build's own page list first, when the project wrote one", async () => { + // A Next.js build lists every page it built in its manifests. Seeded from + // those, a page nothing links to is audited all the same. + const { fetchImpl } = site({ + '/': page('

Home

'), + '/orphan': page('

Orphan

'), + }) + + const result = await crawlSite(entry, { + fetchImpl, + seeds: ['http://localhost:3000/orphan'], + }) + + expect(result.discovery).toBe('manifest') + expect(result.pages.map((p) => p.relativePath)).toEqual(['/', 'orphan']) + }) + + it('ignores a seed on another origin', async () => { + const { fetchImpl, requests } = site({ '/': page('

Home

') }) + + await crawlSite(entry, { fetchImpl, seeds: ['https://example.com/'] }) + + expect(requests).not.toContain('https://example.com/') + expect(requests.every((request) => request.startsWith('/'))).toBe(true) + }) + it('still follows links when the sitemap is incomplete', async () => { // A sitemap listing three of forty pages would otherwise be worse than none. const { fetchImpl } = site({ diff --git a/tests/audit/next.test.ts b/tests/audit/next.test.ts new file mode 100644 index 0000000..a7b255e --- /dev/null +++ b/tests/audit/next.test.ts @@ -0,0 +1,110 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { nextRoutes, readNextConfig } from '../../src/audit/next.ts' + +const STACKS = fileURLToPath(new URL('../fixtures/stacks/', import.meta.url)) + +const dirs: string[] = [] + +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +async function project(files: Record): Promise { + const dir = await mkdtemp(path.join(tmpdir(), 'eaa-kit-next-')) + dirs.push(dir) + for (const [name, body] of Object.entries(files)) { + await mkdir(path.join(dir, path.dirname(name)), { recursive: true }) + await writeFile(path.join(dir, name), body) + } + return dir +} + +describe('readNextConfig', () => { + it('reads output, distDir, basePath and trailingSlash without running the file', async () => { + const dir = await project({ + 'next.config.ts': ` + import type { NextConfig } from 'next' + const config: NextConfig = { + output: 'standalone', + distDir: 'build', + basePath: '/docs', + trailingSlash: true, + } + export default config + `, + }) + + expect(await readNextConfig(dir)).toEqual({ + file: 'next.config.ts', + output: 'standalone', + distDir: 'build', + basePath: '/docs', + trailingSlash: true, + }) + }) + + it('reads a static export', async () => { + const dir = await project({ 'next.config.mjs': "export default { output: 'export' }" }) + + expect((await readNextConfig(dir)).output).toBe('export') + }) + + it('reads nothing it cannot see written down', async () => { + const dir = await project({ 'next.config.js': 'module.exports = require("./shared")' }) + + expect(await readNextConfig(dir)).toEqual({ file: 'next.config.js' }) + }) + + it('has nothing to read without a config', async () => { + expect(await readNextConfig(await project({}))).toEqual({}) + }) +}) + +describe('nextRoutes, from a real Next.js 16 build', () => { + it('lists every page, prerendered or not, under the basePath', async () => { + const routes = await nextRoutes(path.join(STACKS, 'next-server')) + + expect(routes?.basePath).toBe('/docs') + expect(routes?.pages).toEqual(['/', '/about', '/blog/first', '/blog/second', '/legacy']) + }) + + it('names a dynamic route with no page built ahead of time', async () => { + // /user/[id] renders on request, so no list of its pages exists anywhere. + // /blog/[slug] is dynamic too, but its pages were prerendered and listed. + const routes = await nextRoutes(path.join(STACKS, 'next-server')) + + expect(routes?.dynamic).toEqual(['/user/[id]']) + }) + + it('lists each locale once, with the default locale unprefixed as Next serves it', async () => { + const routes = await nextRoutes(path.join(STACKS, 'next-i18n')) + + expect(routes?.pages).toEqual(['/', '/about', '/de', '/de/about', '/posts/one']) + }) + + it('leaves out API routes and the error pages', async () => { + const routes = await nextRoutes(path.join(STACKS, 'next-i18n')) + + expect(routes?.pages.some((page) => page.startsWith('/api'))).toBe(false) + expect(routes?.pages.some((page) => /\/(404|500)$/.test(page))).toBe(false) + }) + + it('returns nothing when there is no build to read', async () => { + expect(await nextRoutes(await project({ 'next.config.mjs': '' }))).toBeUndefined() + }) + + it('reads the build from distDir', async () => { + const dir = await project({ + 'next.config.mjs': "export default { distDir: 'build' }", + 'build/routes-manifest.json': JSON.stringify({ basePath: '' }), + 'build/prerender-manifest.json': JSON.stringify({ routes: {}, dynamicRoutes: {} }), + 'build/server/pages-manifest.json': JSON.stringify({ '/': 'pages/index.html' }), + }) + + expect((await nextRoutes(dir))?.pages).toEqual(['/']) + }) +}) diff --git a/tests/audit/project.test.ts b/tests/audit/project.test.ts index 99aa602..0f3a114 100644 --- a/tests/audit/project.test.ts +++ b/tests/audit/project.test.ts @@ -211,3 +211,67 @@ describe('a site written by hand', () => { expect(await autoDetectSource(dir, { noBuild: true })).toBeUndefined() }) }) + +describe('a Next.js project that renders on a server', () => { + // `next start` stood in for by a server that answers everything, printing its + // address the way Next does, on the PORT it is given. + const server = ` + import { createServer } from 'node:http' + const port = Number(process.env.PORT) + createServer((req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }) + res.end('t' + req.url + '') + }).listen(port, () => console.log(' \\u001b[1m- Local:\\u001b[22m http://localhost:' + port)) + ` + const fixture = path.join(import.meta.dirname, '../fixtures/stacks/next-server/.next') + + async function nextProject(): Promise { + const { readdir, readFile } = await import('node:fs/promises') + const files: Record = { + 'package.json': JSON.stringify({ + dependencies: { next: '16.0.0' }, + scripts: { start: 'node server.mjs' }, + }), + 'next.config.mjs': "export default { basePath: '/docs' }", + 'server.mjs': server, + } + for (const name of await readdir(fixture, { recursive: true })) { + if (!name.endsWith('.json')) continue + files[`.next/${name}`] = await readFile(path.join(fixture, name), 'utf8') + } + return project(files) + } + + it('starts it, under its basePath, and seeds the crawl from its manifests', async () => { + const detected = await autoDetectSource(await nextProject()) + try { + expect(detected?.url).toMatch(/^http:\/\/localhost:\d+\/docs$/) + const paths = (detected?.seeds ?? []).map((seed) => new URL(seed).pathname) + expect(paths).toEqual([ + '/docs', + '/docs/about', + '/docs/blog/first', + '/docs/blog/second', + '/docs/legacy', + ]) + expect(detected?.steps.join('\n')).toContain('5 pages from the Next.js build') + } finally { + await detected?.cleanup?.() + } + }, 60_000) + + it('names a dynamic route it has no list of pages for', async () => { + const detected = await autoDetectSource(await nextProject()) + try { + expect(detected?.unreachable).toEqual([ + { + location: '/docs/user/[id]', + reason: + 'a dynamic route rendered on request, with no list of its pages: only pages that links reached were audited', + }, + ]) + } finally { + await detected?.cleanup?.() + } + }, 60_000) +}) diff --git a/tests/fixtures/stacks/next-i18n/.next/BUILD_ID b/tests/fixtures/stacks/next-i18n/.next/BUILD_ID new file mode 100644 index 0000000..411dbc5 --- /dev/null +++ b/tests/fixtures/stacks/next-i18n/.next/BUILD_ID @@ -0,0 +1 @@ +vX74Hlraf6HQDJN_I0HGU \ No newline at end of file diff --git a/tests/fixtures/stacks/next-i18n/.next/prerender-manifest.json b/tests/fixtures/stacks/next-i18n/.next/prerender-manifest.json new file mode 100644 index 0000000..541af50 --- /dev/null +++ b/tests/fixtures/stacks/next-i18n/.next/prerender-manifest.json @@ -0,0 +1,46 @@ +{ + "version": 4, + "routes": { + "/en/posts/one": { + "routeType": "page", + "response": "complete", + "compute": "static", + "initialRevalidateSeconds": false, + "srcRoute": "/posts/[id]", + "dataRoute": "/_next/data/vX74Hlraf6HQDJN_I0HGU/en/posts/one.json", + "allowHeader": [ + "host", + "x-matched-path", + "x-prerender-revalidate", + "x-prerender-revalidate-if-generated", + "x-next-revalidated-tags", + "x-next-revalidate-tag-token" + ] + } + }, + "dynamicRoutes": { + "/posts/[id]": { + "routeRegex": "^/posts/([^/]+?)(?:/)?$", + "dataRoute": "/_next/data/vX74Hlraf6HQDJN_I0HGU/posts/[id].json", + "fallback": null, + "routeType": "page", + "response": "empty", + "compute": "blocking", + "dataRouteRegex": "^/_next/data/vX74Hlraf6HQDJN_I0HGU/posts/([^/]+?)\\.json$", + "allowHeader": [ + "host", + "x-matched-path", + "x-prerender-revalidate", + "x-prerender-revalidate-if-generated", + "x-next-revalidated-tags", + "x-next-revalidate-tag-token" + ] + } + }, + "notFoundRoutes": [], + "preview": { + "previewModeId": "redacted", + "previewModeSigningKey": "redacted", + "previewModeEncryptionKey": "redacted" + } +} \ No newline at end of file diff --git a/tests/fixtures/stacks/next-i18n/.next/routes-manifest.json b/tests/fixtures/stacks/next-i18n/.next/routes-manifest.json new file mode 100644 index 0000000..ae616f3 --- /dev/null +++ b/tests/fixtures/stacks/next-i18n/.next/routes-manifest.json @@ -0,0 +1,104 @@ +{ + "version": 3, + "pages404": true, + "appType": "pages", + "caseSensitive": false, + "basePath": "", + "redirects": [ + { + "source": "/:file((?!\\.well-known(?:/.*)?)(?:[^/]+/)*[^/]+\\.\\w+)/", + "destination": "/:file", + "locale": false, + "internal": true, + "priority": true, + "missing": [ + { + "type": "header", + "key": "x-nextjs-data" + } + ], + "statusCode": 308, + "regex": "^(?:/((?!\\.well-known(?:/.*)?)(?:[^/]+/)*[^/]+\\.\\w+))/$" + }, + { + "source": "/:notfile((?!\\.well-known(?:/.*)?)(?:[^/]+/)*[^/\\.]+)", + "destination": "/:notfile/", + "locale": false, + "internal": true, + "priority": true, + "statusCode": 308, + "regex": "^(?:/((?!\\.well-known(?:/.*)?)(?:[^/]+/)*[^/\\.]+))$" + } + ], + "headers": [], + "onMatchHeaders": [], + "rewrites": { + "beforeFiles": [], + "afterFiles": [], + "fallback": [] + }, + "dynamicRoutes": [ + { + "page": "/posts/[id]", + "regex": "^/posts/([^/]+?)(?:/)?$", + "routeKeys": { + "nxtPid": "nxtPid" + }, + "namedRegex": "^/posts/(?[^/]+?)(?:/)?$" + } + ], + "staticRoutes": [ + { + "page": "/", + "regex": "^/(?:/)?$", + "routeKeys": {}, + "namedRegex": "^/(?:/)?$" + }, + { + "page": "/about", + "regex": "^/about(?:/)?$", + "routeKeys": {}, + "namedRegex": "^/about(?:/)?$" + }, + { + "page": "/api/hello", + "regex": "^/api/hello(?:/)?$", + "routeKeys": {}, + "namedRegex": "^/api/hello(?:/)?$" + } + ], + "dataRoutes": [ + { + "page": "/posts/[id]", + "routeKeys": { + "nxtPid": "nxtPid" + }, + "dataRouteRegex": "^/_next/data/vX74Hlraf6HQDJN_I0HGU/posts/([^/]+?)\\.json$", + "namedDataRouteRegex": "^/_next/data/vX74Hlraf6HQDJN_I0HGU/posts/(?[^/]+?)\\.json$" + } + ], + "i18n": { + "locales": [ + "en", + "de" + ], + "defaultLocale": "en" + }, + "rsc": { + "header": "rsc", + "varyHeader": "rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch", + "prefetchHeader": "next-router-prefetch", + "didPostponeHeader": "x-nextjs-postponed", + "contentTypeHeader": "text/x-component", + "suffix": ".rsc", + "prefetchSegmentHeader": "next-router-segment-prefetch", + "prefetchSegmentSuffix": ".segment.rsc", + "prefetchSegmentDirSuffix": ".segments", + "clientParamParsing": false, + "dynamicRSCPrerender": false + }, + "rewriteHeaders": { + "pathHeader": "x-nextjs-rewritten-path", + "queryHeader": "x-nextjs-rewritten-query" + } +} \ No newline at end of file diff --git a/tests/fixtures/stacks/next-i18n/.next/server/pages-manifest.json b/tests/fixtures/stacks/next-i18n/.next/server/pages-manifest.json new file mode 100644 index 0000000..d0652b0 --- /dev/null +++ b/tests/fixtures/stacks/next-i18n/.next/server/pages-manifest.json @@ -0,0 +1,15 @@ +{ + "/_app": "pages/_app.js", + "/_document": "pages/_document.js", + "/_error": "pages/_error.js", + "/api/hello": "pages/api/hello.js", + "/posts/[id]": "pages/posts/[id].js", + "/en/404": "pages/en/404.html", + "/de/404": "pages/de/404.html", + "/en/500": "pages/en/500.html", + "/de/500": "pages/de/500.html", + "/en/about": "pages/en/about.html", + "/de/about": "pages/de/about.html", + "/en": "pages/en.html", + "/de": "pages/de.html" +} \ No newline at end of file diff --git a/tests/fixtures/stacks/next-i18n/next.config.mjs b/tests/fixtures/stacks/next-i18n/next.config.mjs new file mode 100644 index 0000000..c0bbaa7 --- /dev/null +++ b/tests/fixtures/stacks/next-i18n/next.config.mjs @@ -0,0 +1 @@ +export default { i18n: { locales: ['en', 'de'], defaultLocale: 'en' }, trailingSlash: true } diff --git a/tests/fixtures/stacks/next-i18n/package.json b/tests/fixtures/stacks/next-i18n/package.json new file mode 100644 index 0000000..d9c192a --- /dev/null +++ b/tests/fixtures/stacks/next-i18n/package.json @@ -0,0 +1 @@ +{"name":"next-i18n","private":true,"scripts":{"build":"next build","start":"next start"},"dependencies":{"next":"16.3.6","react":"19.2.0","react-dom":"19.2.0"}} diff --git a/tests/fixtures/stacks/next-server/.next/BUILD_ID b/tests/fixtures/stacks/next-server/.next/BUILD_ID new file mode 100644 index 0000000..702f057 --- /dev/null +++ b/tests/fixtures/stacks/next-server/.next/BUILD_ID @@ -0,0 +1 @@ +-Dyluf7RFN7XDmcLRWjoN \ No newline at end of file diff --git a/tests/fixtures/stacks/next-server/.next/app-path-routes-manifest.json b/tests/fixtures/stacks/next-server/.next/app-path-routes-manifest.json new file mode 100644 index 0000000..d9b6a66 --- /dev/null +++ b/tests/fixtures/stacks/next-server/.next/app-path-routes-manifest.json @@ -0,0 +1,8 @@ +{ + "/_global-error/page": "/_global-error", + "/_not-found/page": "/_not-found", + "/about/page": "/about", + "/blog/[slug]/page": "/blog/[slug]", + "/page": "/", + "/user/[id]/page": "/user/[id]" +} \ No newline at end of file diff --git a/tests/fixtures/stacks/next-server/.next/prerender-manifest.json b/tests/fixtures/stacks/next-server/.next/prerender-manifest.json new file mode 100644 index 0000000..7c653b9 --- /dev/null +++ b/tests/fixtures/stacks/next-server/.next/prerender-manifest.json @@ -0,0 +1,185 @@ +{ + "version": 4, + "routes": { + "/": { + "routeType": "page", + "response": "complete", + "compute": "static", + "htmlSize": 4831, + "experimentalBypassFor": [ + { + "type": "header", + "key": "next-action" + }, + { + "type": "header", + "key": "content-type", + "value": "multipart/form-data;.*" + } + ], + "initialRevalidateSeconds": false, + "srcRoute": "/", + "dataRoute": "/index.rsc", + "allowHeader": [ + "host", + "x-matched-path", + "x-prerender-revalidate", + "x-prerender-revalidate-if-generated", + "x-next-revalidated-tags", + "x-next-revalidate-tag-token" + ] + }, + "/_not-found": { + "initialStatus": 404, + "routeType": "page", + "response": "complete", + "compute": "static", + "htmlSize": 6931, + "experimentalBypassFor": [ + { + "type": "header", + "key": "next-action" + }, + { + "type": "header", + "key": "content-type", + "value": "multipart/form-data;.*" + } + ], + "initialRevalidateSeconds": false, + "srcRoute": "/_not-found", + "dataRoute": "/_not-found.rsc", + "allowHeader": [ + "host", + "x-matched-path", + "x-prerender-revalidate", + "x-prerender-revalidate-if-generated", + "x-next-revalidated-tags", + "x-next-revalidate-tag-token" + ] + }, + "/about": { + "routeType": "page", + "response": "complete", + "compute": "static", + "htmlSize": 4916, + "experimentalBypassFor": [ + { + "type": "header", + "key": "next-action" + }, + { + "type": "header", + "key": "content-type", + "value": "multipart/form-data;.*" + } + ], + "initialRevalidateSeconds": false, + "srcRoute": "/about", + "dataRoute": "/about.rsc", + "allowHeader": [ + "host", + "x-matched-path", + "x-prerender-revalidate", + "x-prerender-revalidate-if-generated", + "x-next-revalidated-tags", + "x-next-revalidate-tag-token" + ] + }, + "/blog/first": { + "routeType": "page", + "response": "complete", + "compute": "static", + "htmlSize": 5436, + "experimentalBypassFor": [ + { + "type": "header", + "key": "next-action" + }, + { + "type": "header", + "key": "content-type", + "value": "multipart/form-data;.*" + } + ], + "initialRevalidateSeconds": false, + "srcRoute": "/blog/[slug]", + "dataRoute": "/blog/first.rsc", + "allowHeader": [ + "host", + "x-matched-path", + "x-prerender-revalidate", + "x-prerender-revalidate-if-generated", + "x-next-revalidated-tags", + "x-next-revalidate-tag-token" + ] + }, + "/blog/second": { + "routeType": "page", + "response": "complete", + "compute": "static", + "htmlSize": 5440, + "experimentalBypassFor": [ + { + "type": "header", + "key": "next-action" + }, + { + "type": "header", + "key": "content-type", + "value": "multipart/form-data;.*" + } + ], + "initialRevalidateSeconds": false, + "srcRoute": "/blog/[slug]", + "dataRoute": "/blog/second.rsc", + "allowHeader": [ + "host", + "x-matched-path", + "x-prerender-revalidate", + "x-prerender-revalidate-if-generated", + "x-next-revalidated-tags", + "x-next-revalidate-tag-token" + ] + } + }, + "dynamicRoutes": { + "/blog/[slug]": { + "routeType": "page", + "response": "empty", + "compute": "blocking", + "experimentalBypassFor": [ + { + "type": "header", + "key": "next-action" + }, + { + "type": "header", + "key": "content-type", + "value": "multipart/form-data;.*" + } + ], + "routeRegex": "^/blog/([^/]+?)(?:/)?$", + "dataRoute": "/blog/[slug].rsc", + "fallback": null, + "fallbackRootParams": [], + "fallbackRouteParams": [], + "dataRouteRegex": "^/blog/([^/]+?)\\.rsc$", + "prefetchDataRoute": null, + "allowHeader": [ + "host", + "x-matched-path", + "x-prerender-revalidate", + "x-prerender-revalidate-if-generated", + "x-next-revalidated-tags", + "x-next-revalidate-tag-token" + ] + } + }, + "notFoundRoutes": [], + "preview": { + "previewModeId": "redacted", + "previewModeSigningKey": "redacted", + "previewModeEncryptionKey": "redacted" + } +} \ No newline at end of file diff --git a/tests/fixtures/stacks/next-server/.next/routes-manifest.json b/tests/fixtures/stacks/next-server/.next/routes-manifest.json new file mode 100644 index 0000000..48007c0 --- /dev/null +++ b/tests/fixtures/stacks/next-server/.next/routes-manifest.json @@ -0,0 +1,95 @@ +{ + "version": 3, + "pages404": true, + "appType": "hybrid", + "caseSensitive": false, + "basePath": "/docs", + "redirects": [ + { + "source": "/docs/", + "destination": "/docs", + "basePath": false, + "internal": true, + "priority": true, + "statusCode": 308, + "regex": "^/docs/$" + }, + { + "source": "/:path+/", + "destination": "/:path+", + "internal": true, + "priority": true, + "statusCode": 308, + "regex": "^(?:/((?:[^/]+?)(?:/(?:[^/]+?))*))/$" + } + ], + "headers": [], + "onMatchHeaders": [], + "rewrites": { + "beforeFiles": [], + "afterFiles": [], + "fallback": [] + }, + "dynamicRoutes": [ + { + "page": "/blog/[slug]", + "regex": "^/blog/([^/]+?)(?:/)?$", + "routeKeys": { + "nxtPslug": "nxtPslug" + }, + "namedRegex": "^/blog/(?[^/]+?)(?:/)?$" + }, + { + "page": "/user/[id]", + "regex": "^/user/([^/]+?)(?:/)?$", + "routeKeys": { + "nxtPid": "nxtPid" + }, + "namedRegex": "^/user/(?[^/]+?)(?:/)?$" + } + ], + "staticRoutes": [ + { + "page": "/", + "regex": "^/(?:/)?$", + "routeKeys": {}, + "namedRegex": "^/(?:/)?$" + }, + { + "page": "/_not-found", + "regex": "^/_not\\-found(?:/)?$", + "routeKeys": {}, + "namedRegex": "^/_not\\-found(?:/)?$" + }, + { + "page": "/about", + "regex": "^/about(?:/)?$", + "routeKeys": {}, + "namedRegex": "^/about(?:/)?$" + }, + { + "page": "/legacy", + "regex": "^/legacy(?:/)?$", + "routeKeys": {}, + "namedRegex": "^/legacy(?:/)?$" + } + ], + "dataRoutes": [], + "rsc": { + "header": "rsc", + "varyHeader": "rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch", + "prefetchHeader": "next-router-prefetch", + "didPostponeHeader": "x-nextjs-postponed", + "contentTypeHeader": "text/x-component", + "suffix": ".rsc", + "prefetchSegmentHeader": "next-router-segment-prefetch", + "prefetchSegmentSuffix": ".segment.rsc", + "prefetchSegmentDirSuffix": ".segments", + "clientParamParsing": false, + "dynamicRSCPrerender": false + }, + "rewriteHeaders": { + "pathHeader": "x-nextjs-rewritten-path", + "queryHeader": "x-nextjs-rewritten-query" + } +} \ No newline at end of file diff --git a/tests/fixtures/stacks/next-server/.next/server/pages-manifest.json b/tests/fixtures/stacks/next-server/.next/server/pages-manifest.json new file mode 100644 index 0000000..aa0021a --- /dev/null +++ b/tests/fixtures/stacks/next-server/.next/server/pages-manifest.json @@ -0,0 +1,7 @@ +{ + "/_app": "pages/_app.js", + "/_document": "pages/_document.js", + "/_error": "pages/_error.js", + "/legacy": "pages/legacy.html", + "/404": "pages/404.html" +} \ No newline at end of file diff --git a/tests/fixtures/stacks/next-server/next.config.mjs b/tests/fixtures/stacks/next-server/next.config.mjs new file mode 100644 index 0000000..e91ab76 --- /dev/null +++ b/tests/fixtures/stacks/next-server/next.config.mjs @@ -0,0 +1 @@ +export default { basePath: '/docs' } diff --git a/tests/fixtures/stacks/next-server/package.json b/tests/fixtures/stacks/next-server/package.json new file mode 100644 index 0000000..b4fa663 --- /dev/null +++ b/tests/fixtures/stacks/next-server/package.json @@ -0,0 +1 @@ +{"name":"next-server","private":true,"scripts":{"build":"next build","start":"next start"},"dependencies":{"next":"16.3.6","react":"19.2.0","react-dom":"19.2.0"}} From 1fd7a4d85738ecd286b7d7a6e0ba52791c9cec1a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:25:38 +0000 Subject: [PATCH 3/8] feat(detect): monorepos, and single-page-app shells named instead of passed - Run from the root of a pnpm, yarn or npm workspace, or a Turborepo, Nx or Lerna repository, the audit finds the packages that are sites. One site is audited as though the command ran inside it; several are listed with the command for each. `init` asks which site and writes the config there, and refuses to guess when there is nobody to ask. - A page with nothing a visitor could perceive before a script runs, such as a Vite build's empty div#root, is set aside and named as not audited, since the browserless engine would report it clean. A build holding only a shell stops with the command to audit it in a browser. --browser runs the script and audits it as before. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013BXnvSeRtgZoTXe4j753gM --- src/audit/project.ts | 27 +++++++ src/audit/shell.ts | 35 +++++++++ src/audit/workspaces.ts | 109 ++++++++++++++++++++++++++++ src/cli/init.ts | 43 ++++++++++- src/cli/pages.ts | 71 +++++++++++++++++- tests/audit/shell.test.ts | 34 +++++++++ tests/audit/workspaces.test.ts | 127 +++++++++++++++++++++++++++++++++ tests/cli/init.test.ts | 46 ++++++++++++ tests/cli/pages.test.ts | 34 +++++++++ 9 files changed, 524 insertions(+), 2 deletions(-) create mode 100644 src/audit/shell.ts create mode 100644 src/audit/workspaces.ts create mode 100644 tests/audit/shell.test.ts create mode 100644 tests/audit/workspaces.test.ts diff --git a/src/audit/project.ts b/src/audit/project.ts index 4797f27..a6e034c 100644 --- a/src/audit/project.ts +++ b/src/audit/project.ts @@ -334,6 +334,11 @@ export interface AutoSource { seeds?: string[] /** Parts of the site known to exist that the crawl has no list of. */ unreachable?: Unmeasured[] + /** + * The sites in this monorepo, when there are several and nothing decides + * between them: directories relative to the root, for the reader to choose. + */ + sites?: string[] /** Called when the audit is done, to stop anything this started. */ cleanup?: () => Promise /** What was done, for the reader. One line per step. */ @@ -381,6 +386,28 @@ export async function autoDetectSource( // could hand back a dev server for the wrong thing entirely. const { detectFramework } = await import('./frameworks.ts') const detected = await detectFramework(cwd, pkg) + + // The root of a monorepo is not a site; its packages are. One site is the + // answer, and it is audited as though the command had been run inside it. + // Several need somebody to choose, since auditing all of them at once would + // mix their pages into one report. + if (detected === undefined) { + const { findWorkspaceSites } = await import('./workspaces.ts') + const workspace = await findWorkspaceSites(cwd) + const sites = workspace?.sites ?? [] + const only = sites[0] + if (sites.length === 1 && only !== undefined) { + step(`This is a monorepo with one site, in ${only.dir}/`) + const inner = await autoDetectSource(path.join(cwd, only.dir), options) + return inner === undefined ? { steps } : { ...inner, steps: [...steps, ...inner.steps] } + } + if (sites.length > 1) { + step( + `This is a monorepo with ${sites.length} sites: ${sites.map((site) => site.dir).join(', ')}`, + ) + return { steps, sites: sites.map((site) => site.dir) } + } + } if (detected !== undefined && detected.framework.outputs.length === 0) { step(`${detected.framework.name} renders on a server and writes no HTML to disk`) return { steps } diff --git a/src/audit/shell.ts b/src/audit/shell.ts new file mode 100644 index 0000000..01cc43d --- /dev/null +++ b/src/audit/shell.ts @@ -0,0 +1,35 @@ +/** + * Whether a page is an empty shell that a script fills in. + * + * A single-page app's build writes one `index.html` holding an empty + * `
` and a script tag. The browserless engine does not run + * scripts, so it audits the empty div, finds nothing wrong with it, and the + * run reports a clean site that was never looked at. That is the false + * assurance this tool exists to refuse, so such a page is set aside and named + * as not audited instead. + * + * The test is what a visitor could perceive before any script runs: text, + * images and media, embedded frames, and form controls. A page with none of + * those is a shell, whatever its root element is called. + */ +export function isAppShell(html: string): boolean { + const body = + /]*>([\s\S]*)<\/body\s*>/i.exec(html)?.[1] ?? + html.replace(//i, '') + const visible = body + .replace(//g, '') + // Never shown as they are, and a noscript apology is not the page either. + .replace(/<(script|style|template|noscript)\b[\s\S]*?<\/\1\s*>/gi, '') + if ( + /<(img|svg|video|audio|canvas|iframe|object|embed|input|select|textarea|button|picture)\b/i.test( + visible, + ) + ) { + return false + } + const text = visible + .replace(/<[^>]*>/g, '') + .replace(/ | /g, ' ') + .trim() + return text === '' +} diff --git a/src/audit/workspaces.ts b/src/audit/workspaces.ts new file mode 100644 index 0000000..af23cb2 --- /dev/null +++ b/src/audit/workspaces.ts @@ -0,0 +1,109 @@ +import { readFile } from 'node:fs/promises' +import path from 'node:path' +import { glob } from 'tinyglobby' +import { exists, toPosix } from '../fs.ts' +import { type DetectedFramework, detectFramework } from './frameworks.ts' +import { readPackageJson } from './project.ts' + +/** + * The sites in a monorepo. + * + * Run from the root of a pnpm, yarn or npm workspace, the tool used to see the + * root's package.json, which usually depends on Turborepo and nothing else, and + * conclude there was nothing here to audit. The sites are one level down, in + * `apps/*` or wherever the workspace says, and each of them is an ordinary + * project that everything else here already knows how to handle. + */ + +export interface WorkspaceSite { + /** The package's directory, relative to the root, with forward slashes. */ + dir: string + framework: DetectedFramework +} + +export interface WorkspaceScan { + /** The file that made this a workspace, e.g. `pnpm-workspace.yaml`. */ + evidence: string + /** The packages that are sites, sorted by directory. */ + sites: WorkspaceSite[] +} + +/** Where Turborepo and Nx put things by convention, when nothing else says. */ +const CONVENTIONAL = ['apps/*', 'packages/*'] + +/** Undefined when `root` is not the root of a workspace. */ +export async function findWorkspaceSites(root: string): Promise { + const listed = await workspaceGlobs(root) + if (listed === undefined) return undefined + + const manifests = await glob( + listed.globs.map((pattern) => `${pattern.replace(/\/$/, '')}/package.json`), + { cwd: root, ignore: ['**/node_modules/**'], onlyFiles: true, dot: false }, + ) + + const sites: WorkspaceSite[] = [] + for (const manifest of manifests.sort()) { + const dir = path.dirname(manifest) + if (dir === '.') continue + const absolute = path.join(root, dir) + const framework = await detectFramework(absolute, await readPackageJson(absolute)) + if (framework === undefined) continue + // Vite builds libraries as well as apps, and a component library is not a + // site. An app has the index.html Vite starts from. + if (framework.framework.id === 'vite' && !(await exists(path.join(absolute, 'index.html')))) { + continue + } + sites.push({ dir: toPosix(dir), framework }) + } + return { evidence: listed.evidence, sites } +} + +async function workspaceGlobs( + root: string, +): Promise<{ evidence: string; globs: string[] } | undefined> { + const pnpm = await readText(path.join(root, 'pnpm-workspace.yaml')) + if (pnpm !== undefined) { + return { evidence: 'pnpm-workspace.yaml', globs: yamlPackages(pnpm) } + } + + const pkg = await readPackageJson(root) + const workspaces = Array.isArray(pkg?.workspaces) ? pkg.workspaces : pkg?.workspaces?.packages + if (workspaces !== undefined && workspaces.length > 0) { + return { evidence: 'package.json workspaces', globs: workspaces } + } + + const lerna = await readText(path.join(root, 'lerna.json')) + if (lerna !== undefined) { + try { + const packages = (JSON.parse(lerna) as { packages?: string[] }).packages + return { evidence: 'lerna.json', globs: packages ?? CONVENTIONAL } + } catch { + return { evidence: 'lerna.json', globs: CONVENTIONAL } + } + } + + for (const marker of ['turbo.json', 'nx.json']) { + if (await exists(path.join(root, marker))) return { evidence: marker, globs: CONVENTIONAL } + } + return undefined +} + +/** + * The `packages:` list out of pnpm-workspace.yaml, read with a pattern. It is a + * list of strings, and a YAML parser would be a dependency for one list. + * Negated entries are exclusions, which the site check makes moot. + */ +function yamlPackages(source: string): string[] { + const block = /^packages:\s*\n((?:[ \t]*(?:-.*|#.*)?\n?)*)/m.exec(source)?.[1] ?? '' + return [...block.matchAll(/^[ \t]*-[ \t]*['"]?([^'"\n#]+?)['"]?[ \t]*(?:#.*)?$/gm)] + .map((match) => match[1] ?? '') + .filter((entry) => entry !== '' && !entry.startsWith('!')) +} + +async function readText(file: string): Promise { + try { + return await readFile(file, 'utf8') + } catch { + return undefined + } +} diff --git a/src/cli/init.ts b/src/cli/init.ts index 1b509fc..50cdf04 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -141,7 +141,8 @@ function terminalPrompt(): Prompt { } export async function runInitCommand(options: InitCommandOptions = {}): Promise { - const cwd = options.cwd ?? process.cwd() + const cwd = await chooseSite(options.cwd ?? process.cwd(), options) + if (cwd === undefined) return { exitCode: 1 } const target = path.resolve(cwd, options.output ?? 'eaa.config.json') const already = await existingConfig(cwd) @@ -293,6 +294,46 @@ export async function runInitCommand(options: InitCommandOptions = {}): Promise< return { file: target, exitCode: 0 } } +/** + * The directory to set up: `cwd`, or in a monorepo with several sites, the + * one the reader names. The config goes in that site, next to what it audits, + * and the workflow runs there. With nobody to ask, this refuses rather than + * picks: a config for the wrong site passes CI on a site nobody checked. + */ +async function chooseSite(cwd: string, options: InitCommandOptions): Promise { + const { detectFramework } = await import('../audit/frameworks.ts') + const { readPackageJson } = await import('../audit/project.ts') + if ((await detectFramework(cwd, await readPackageJson(cwd))) !== undefined) return cwd + const { findWorkspaceSites } = await import('../audit/workspaces.ts') + const sites = (await findWorkspaceSites(cwd))?.sites.map((site) => site.dir) ?? [] + if (sites.length < 2) return cwd + + const interactive = options.ask !== undefined || (!options.yes && process.stdin.isTTY === true) + if (!interactive) { + warn(`This is a monorepo with ${sites.length} sites; set up one at a time, inside it:`) + for (const site of sites) nextStep({ command: `cd ${site} && npx eaa-kit init`, why: '' }) + return undefined + } + + const terminal = options.ask === undefined ? terminalPrompt() : undefined + const ask = options.ask ?? terminal?.ask + let answer: string + try { + answer = + ask === undefined + ? '' + : await ask(`Which site is this for? (${sites.join(', ')})`, sites[0] ?? '') + } finally { + terminal?.close() + } + const chosen = sites.find((site) => site === answer.replace(/^\.\//, '').replace(/\/$/, '')) + if (chosen === undefined) { + fail(`${answer} is not one of the sites here: ${sites.join(', ')}`) + return undefined + } + return path.join(cwd, chosen) +} + function isYes(answer: string): boolean { return /^y(es)?$/i.test(answer.trim()) } diff --git a/src/cli/pages.ts b/src/cli/pages.ts index f0f605c..e27ce9f 100644 --- a/src/cli/pages.ts +++ b/src/cli/pages.ts @@ -63,6 +63,11 @@ export interface ResolvePagesOptions extends CrawlCommandOptions { cwd?: string /** Never run the project's build or start its server. */ noBuild?: boolean + /** + * The audit runs in a real browser, which runs the page's scripts. Without + * one, a single-page-app shell is set aside rather than audited empty. + */ + browser?: boolean /** * How to name the directory in messages. Defaults to the directory itself. * `baseline` resolves the path before collecting but still wants the reader @@ -129,8 +134,21 @@ export async function resolvePages( warn(`No pages could be fetched from ${options.url}`) return undefined } + const { kept, shells } = await setAsideShells(crawled.pages, options.browser, 'absolutePath') + if (kept.length === 0) { + warn( + `${options.url} serves only a single-page-app shell, which JavaScript fills in and this engine does not run.`, + ) + nextStep({ + command: `eaa-kit audit --url ${options.url} --browser`, + why: 'audit it in Chromium, scripts and all', + }) + return undefined + } + warnUnmeasured(shells, 'page', 'audited') + crawled.completeness.unreachable.push(...shells) return { - pages: crawled.pages, + pages: kept, origin: crawled.origin, label: options.url, completeness: crawled.completeness, @@ -183,6 +201,21 @@ export async function resolvePages( warnUnmeasured(unreachable, 'file', 'readable') + const { kept, shells } = await setAsideShells(pages, options.browser) + if (kept.length === 0) { + warn( + `${shown} holds only a single-page-app shell, which JavaScript fills in and this engine does not run.`, + ) + nextStep({ + command: `eaa-kit audit ${shown} --browser`, + why: 'audit it in Chromium, scripts and all', + }) + return undefined + } + warnUnmeasured(shells, 'page', 'audited') + pages = kept + unreachable.push(...shells) + return { pages, directory: directory as string, @@ -198,6 +231,33 @@ export async function resolvePages( } } +/** + * Take out the pages that are empty shells a script fills in, unless the audit + * runs in a browser that would run the script. See `isAppShell`. + */ +async function setAsideShells( + pages: CollectedPage[], + browser: boolean | undefined, + locate: 'relativePath' | 'absolutePath' = 'relativePath', +): Promise<{ kept: CollectedPage[]; shells: Unmeasured[] }> { + if (browser) return { kept: pages, shells: [] } + const { isAppShell } = await import('../audit/shell.ts') + const kept: CollectedPage[] = [] + const shells: Unmeasured[] = [] + for (const page of pages) { + if (isAppShell(page.html)) { + shells.push({ + location: page[locate], + reason: + 'a single-page-app shell: its content is rendered by JavaScript, which this engine does not run; audit it with --browser', + }) + } else { + kept.push(page) + } + } + return { kept, shells } +} + /** * Name what was missed, up to ten of them, and count the rest. * @@ -524,6 +584,15 @@ async function resolveAutomatically( } await detected?.cleanup?.() + if (detected?.sites !== undefined) { + // Several sites and nothing to choose between them: which one this run is + // about is the reader's call, and each is its own project to audit. + warn('Choose which site to audit, and run the audit inside it:') + for (const site of detected.sites) { + nextStep({ command: `cd ${site} && npx eaa-kit`, why: '' }) + } + return undefined + } // Nothing worked. The directory hint knows this project better than anything // here does, so it explains rather than a second message competing with it. warn(await emptyDirectoryHint('./dist', cwd)) diff --git a/tests/audit/shell.test.ts b/tests/audit/shell.test.ts new file mode 100644 index 0000000..2245541 --- /dev/null +++ b/tests/audit/shell.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { isAppShell } from '../../src/audit/shell.ts' + +const doc = (body: string, head = 'App'): string => + `${head}${body}` + +describe('isAppShell', () => { + it.each([ + ['a Vite app', '
'], + ['a Vue app', '
'], + ['an Angular app', ''], + ['a Nuxt SPA fallback', '
'], + [ + 'one that apologises without JavaScript', + '
', + ], + ])('recognises %s: nothing a visitor could perceive until a script runs', (_name, body) => { + expect(isAppShell(doc(body))).toBe(true) + }) + + it.each([ + ['a page with text', '

Hello

'], + ['a prerendered app', '

Home

Welcome

'], + ['a page that is only an image', ''], + ['a page with a form control', '
'], + ['an embedded frame', ''], + ])('audits %s', (_name, body) => { + expect(isAppShell(doc(body))).toBe(false) + }) + + it('does not count what is in the head, or in a template', () => { + expect(isAppShell(doc('
'))).toBe(true) + }) +}) diff --git a/tests/audit/workspaces.test.ts b/tests/audit/workspaces.test.ts new file mode 100644 index 0000000..738d333 --- /dev/null +++ b/tests/audit/workspaces.test.ts @@ -0,0 +1,127 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { autoDetectSource } from '../../src/audit/project.ts' +import { findWorkspaceSites } from '../../src/audit/workspaces.ts' + +const dirs: string[] = [] + +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +async function repo(files: Record): Promise { + const dir = await mkdtemp(path.join(tmpdir(), 'eaa-kit-monorepo-')) + dirs.push(dir) + for (const [name, body] of Object.entries(files)) { + await mkdir(path.join(dir, path.dirname(name)), { recursive: true }) + await writeFile(path.join(dir, name), body) + } + return dir +} + +const app = (deps: Record): string => JSON.stringify({ dependencies: deps }) + +describe('findWorkspaceSites', () => { + it('reads pnpm-workspace.yaml and keeps the packages that are sites', async () => { + const dir = await repo({ + 'package.json': JSON.stringify({ private: true }), + 'pnpm-workspace.yaml': 'packages:\n - \'apps/*\'\n - "packages/*"\n', + 'apps/web/package.json': app({ astro: '5.0.0' }), + 'apps/docs/package.json': app({ '@docusaurus/core': '3.0.0' }), + 'packages/ui/package.json': app({ react: '19.0.0' }), + }) + + const found = await findWorkspaceSites(dir) + + expect(found?.evidence).toBe('pnpm-workspace.yaml') + expect(found?.sites.map((site) => [site.dir, site.framework.framework.name])).toEqual([ + ['apps/docs', 'Docusaurus'], + ['apps/web', 'Astro'], + ]) + }) + + it('reads npm and yarn workspaces from package.json, as an array or as packages', async () => { + const npm = await repo({ + 'package.json': JSON.stringify({ workspaces: ['sites/*'] }), + 'sites/shop/package.json': app({ next: '16.0.0' }), + }) + const yarn = await repo({ + 'package.json': JSON.stringify({ workspaces: { packages: ['sites/*'] } }), + 'sites/shop/package.json': app({ nuxt: '4.0.0' }), + }) + + expect((await findWorkspaceSites(npm))?.sites.map((site) => site.dir)).toEqual(['sites/shop']) + expect((await findWorkspaceSites(yarn))?.sites.map((site) => site.dir)).toEqual(['sites/shop']) + }) + + it("takes Turborepo's and Nx's usual layout when nothing lists the packages", async () => { + const dir = await repo({ + 'package.json': '{}', + 'nx.json': '{}', + 'apps/site/package.json': app({ '@angular/core': '19.0.0' }), + }) + + const found = await findWorkspaceSites(dir) + + expect(found?.evidence).toBe('nx.json') + expect(found?.sites.map((site) => site.dir)).toEqual(['apps/site']) + }) + + it('does not count a Vite library as a site', async () => { + // Vite builds libraries as well as apps; an app has an index.html. + const dir = await repo({ + 'package.json': JSON.stringify({ workspaces: ['packages/*'] }), + 'packages/lib/package.json': app({ vite: '6.0.0' }), + 'packages/app/package.json': app({ vite: '6.0.0' }), + 'packages/app/index.html': '', + }) + + expect((await findWorkspaceSites(dir))?.sites.map((site) => site.dir)).toEqual(['packages/app']) + }) + + it('never looks inside node_modules', async () => { + const dir = await repo({ + 'package.json': JSON.stringify({ workspaces: ['**'] }), + 'node_modules/astro/package.json': app({ astro: '5.0.0' }), + }) + + expect((await findWorkspaceSites(dir))?.sites).toEqual([]) + }) + + it('is not a monorepo without workspaces', async () => { + expect(await findWorkspaceSites(await repo({ 'package.json': '{}' }))).toBeUndefined() + }) +}) + +describe('auditing from the root of a monorepo', () => { + it('audits the one site there is', async () => { + const dir = await repo({ + 'package.json': JSON.stringify({ workspaces: ['apps/*'] }), + 'apps/web/package.json': app({ astro: '5.0.0' }), + 'apps/web/dist/index.html': '', + 'packages/ui/package.json': '{}', + }) + + const detected = await autoDetectSource(dir, { noBuild: true }) + + expect(detected?.directory).toBe(path.join(dir, 'apps/web/dist')) + expect(detected?.steps[0]).toBe('This is a monorepo with one site, in apps/web/') + }) + + it('lists the sites and how to audit each when there are several', async () => { + const dir = await repo({ + 'package.json': JSON.stringify({ workspaces: ['apps/*'] }), + 'apps/web/package.json': app({ astro: '5.0.0' }), + 'apps/docs/package.json': app({ vitepress: '1.0.0' }), + }) + + const detected = await autoDetectSource(dir, { noBuild: true }) + + expect(detected?.directory).toBeUndefined() + expect(detected?.url).toBeUndefined() + expect(detected?.sites).toEqual(['apps/docs', 'apps/web']) + expect(detected?.steps.join('\n')).toContain('This is a monorepo with 2 sites') + }) +}) diff --git a/tests/cli/init.test.ts b/tests/cli/init.test.ts index 951cd09..65c8a16 100644 --- a/tests/cli/init.test.ts +++ b/tests/cli/init.test.ts @@ -229,3 +229,49 @@ describe('existingConfig', () => { expect(await existingConfig(await project())).toBeUndefined() }) }) + +describe('in a monorepo with several sites', () => { + async function monorepo(): Promise { + const { mkdir } = await import('node:fs/promises') + const dir = await project({ 'package.json': JSON.stringify({ workspaces: ['apps/*'] }) }) + for (const [site, dependency] of [ + ['web', 'astro'], + ['docs', 'vitepress'], + ] as const) { + await mkdir(path.join(dir, 'apps', site), { recursive: true }) + await writeFile( + path.join(dir, 'apps', site, 'package.json'), + JSON.stringify({ name: site, dependencies: { [dependency]: '1.0.0' } }), + ) + } + return dir + } + + it('asks which site, and writes the config there', async () => { + const dir = await monorepo() + + const result = await runInitCommand({ + cwd: dir, + ci: false, + baseline: false, + ask: answers('apps/web', '', '', 'AT', '', '', 'a@b.at'), + }) + + expect(result.exitCode).toBe(0) + expect(result.file).toBe(path.join(dir, 'apps/web/eaa.config.json')) + expect((await written(path.join(dir, 'apps/web'))).site).toMatchObject({ name: 'web' }) + }) + + it('refuses to guess without a terminal, and says how to choose', async () => { + const result = await runInitCommand({ cwd: await monorepo(), yes: true }) + + expect(result.exitCode).toBe(1) + expect(stderr.join('')).toContain('cd apps/web && npx eaa-kit init') + }) + + it('refuses a site that is not one of them', async () => { + const result = await runInitCommand({ cwd: await monorepo(), ask: answers('apps/nope') }) + + expect(result.exitCode).toBe(1) + }) +}) diff --git a/tests/cli/pages.test.ts b/tests/cli/pages.test.ts index 0f4ada6..b2b2c08 100644 --- a/tests/cli/pages.test.ts +++ b/tests/cli/pages.test.ts @@ -78,3 +78,37 @@ describe('resolvePages', () => { } }) }) + +describe('a single-page-app shell', () => { + const shell = + 'App
' + + it('is set aside and named, rather than audited as an empty page that passes', async () => { + await writeFile(path.join(project, 'dist', 'app.html'), shell, 'utf8') + + const resolved = await resolvePages(path.join(project, 'dist'), { cwd: project }) + + expect(resolved?.pages.map((page) => page.relativePath)).toEqual(['index.html']) + expect(resolved?.completeness.unreachable).toEqual([ + { + location: 'app.html', + reason: + 'a single-page-app shell: its content is rendered by JavaScript, which this engine does not run; audit it with --browser', + }, + ]) + }) + + it('stops the run when it is all there is, rather than reporting nothing wrong', async () => { + await writeFile(path.join(project, 'dist', 'index.html'), shell, 'utf8') + + expect(await resolvePages(path.join(project, 'dist'), { cwd: project })).toBeUndefined() + }) + + it('is audited in a browser, which runs the script that fills it', async () => { + await writeFile(path.join(project, 'dist', 'index.html'), shell, 'utf8') + + const resolved = await resolvePages(path.join(project, 'dist'), { cwd: project, browser: true }) + + expect(resolved?.pages).toHaveLength(1) + }) +}) From 1b316b288117e7b4783a15331b78eea955440452 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:29:52 +0000 Subject: [PATCH 4/8] feat(cli): eaa-kit detect, and a recorded answer for every stack `eaa-kit detect [dir]` says what an audit here would do and the evidence for each part of it: the framework and what identified it, the package manager and why, the build output or what would be built or started, a Next.js build's page count and its unlisted dynamic routes, a monorepo's sites. It builds, starts and writes nothing. `--json` prints the same as data. tests/fixtures/stacks holds one project layout per kind of stack, each with the answer detect must give in expected.json, and the suite checks every one. Generators that are not npm packages carry their own build command, so a Hugo or MkDocs project with no build yet is told what to run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013BXnvSeRtgZoTXe4j753gM --- src/audit/detect.ts | 241 ++++++++++++++++++ src/audit/frameworks.ts | 13 + src/cli/detect.ts | 104 ++++++++ src/cli/index.ts | 10 + tests/audit/detect.test.ts | 52 ++++ tests/cli/detect.test.ts | 74 ++++++ tests/fixtures/stacks/astro/astro.config.mjs | 1 + tests/fixtures/stacks/astro/expected.json | 28 ++ tests/fixtures/stacks/astro/package.json | 1 + tests/fixtures/stacks/astro/pnpm-lock.yaml | 1 + tests/fixtures/stacks/bun-remix/bun.lock | 1 + tests/fixtures/stacks/bun-remix/expected.json | 27 ++ tests/fixtures/stacks/bun-remix/package.json | 1 + tests/fixtures/stacks/hand-written/about.html | 1 + .../stacks/hand-written/expected.json | 13 + tests/fixtures/stacks/hand-written/index.html | 1 + tests/fixtures/stacks/hugo/expected.json | 20 ++ tests/fixtures/stacks/hugo/hugo.toml | 1 + tests/fixtures/stacks/hugo/public/index.html | 1 + tests/fixtures/stacks/laravel/artisan | 0 tests/fixtures/stacks/laravel/expected.json | 20 ++ tests/fixtures/stacks/mkdocs/expected.json | 21 ++ tests/fixtures/stacks/mkdocs/mkdocs.yml | 1 + .../stacks/monorepo-one/apps/web/package.json | 1 + .../stacks/monorepo-one/expected.json | 50 ++++ .../fixtures/stacks/monorepo-one/package.json | 1 + .../monorepo-one/packages/ui/package.json | 1 + .../stacks/monorepo-one/pnpm-lock.yaml | 1 + .../stacks/monorepo-one/pnpm-workspace.yaml | 3 + .../monorepo-two/apps/docs/package.json | 1 + .../monorepo-two/apps/shop/package.json | 1 + .../stacks/monorepo-two/expected.json | 29 +++ .../stacks/monorepo-two/package-lock.json | 1 + .../fixtures/stacks/monorepo-two/package.json | 1 + .../fixtures/stacks/next-export/expected.json | 24 ++ .../stacks/next-export/next.config.mjs | 1 + .../stacks/next-export/out/index.html | 1 + .../fixtures/stacks/next-export/package.json | 1 + tests/fixtures/stacks/next-i18n/expected.json | 29 +++ .../fixtures/stacks/next-server/expected.json | 32 +++ .../stacks/sveltekit-built/build/index.html | 1 + .../stacks/sveltekit-built/expected.json | 25 ++ .../stacks/sveltekit-built/package.json | 1 + tests/fixtures/stacks/vite-spa/expected.json | 27 ++ tests/fixtures/stacks/vite-spa/index.html | 1 + tests/fixtures/stacks/vite-spa/package.json | 1 + tests/fixtures/stacks/vite-spa/yarn.lock | 0 tests/fixtures/stacks/wordpress/expected.json | 24 ++ tests/fixtures/stacks/wordpress/package.json | 1 + tests/fixtures/stacks/wordpress/wp-config.php | 1 + 50 files changed, 893 insertions(+) create mode 100644 src/audit/detect.ts create mode 100644 src/cli/detect.ts create mode 100644 tests/audit/detect.test.ts create mode 100644 tests/cli/detect.test.ts create mode 100644 tests/fixtures/stacks/astro/astro.config.mjs create mode 100644 tests/fixtures/stacks/astro/expected.json create mode 100644 tests/fixtures/stacks/astro/package.json create mode 100644 tests/fixtures/stacks/astro/pnpm-lock.yaml create mode 100644 tests/fixtures/stacks/bun-remix/bun.lock create mode 100644 tests/fixtures/stacks/bun-remix/expected.json create mode 100644 tests/fixtures/stacks/bun-remix/package.json create mode 100644 tests/fixtures/stacks/hand-written/about.html create mode 100644 tests/fixtures/stacks/hand-written/expected.json create mode 100644 tests/fixtures/stacks/hand-written/index.html create mode 100644 tests/fixtures/stacks/hugo/expected.json create mode 100644 tests/fixtures/stacks/hugo/hugo.toml create mode 100644 tests/fixtures/stacks/hugo/public/index.html create mode 100644 tests/fixtures/stacks/laravel/artisan create mode 100644 tests/fixtures/stacks/laravel/expected.json create mode 100644 tests/fixtures/stacks/mkdocs/expected.json create mode 100644 tests/fixtures/stacks/mkdocs/mkdocs.yml create mode 100644 tests/fixtures/stacks/monorepo-one/apps/web/package.json create mode 100644 tests/fixtures/stacks/monorepo-one/expected.json create mode 100644 tests/fixtures/stacks/monorepo-one/package.json create mode 100644 tests/fixtures/stacks/monorepo-one/packages/ui/package.json create mode 100644 tests/fixtures/stacks/monorepo-one/pnpm-lock.yaml create mode 100644 tests/fixtures/stacks/monorepo-one/pnpm-workspace.yaml create mode 100644 tests/fixtures/stacks/monorepo-two/apps/docs/package.json create mode 100644 tests/fixtures/stacks/monorepo-two/apps/shop/package.json create mode 100644 tests/fixtures/stacks/monorepo-two/expected.json create mode 100644 tests/fixtures/stacks/monorepo-two/package-lock.json create mode 100644 tests/fixtures/stacks/monorepo-two/package.json create mode 100644 tests/fixtures/stacks/next-export/expected.json create mode 100644 tests/fixtures/stacks/next-export/next.config.mjs create mode 100644 tests/fixtures/stacks/next-export/out/index.html create mode 100644 tests/fixtures/stacks/next-export/package.json create mode 100644 tests/fixtures/stacks/next-i18n/expected.json create mode 100644 tests/fixtures/stacks/next-server/expected.json create mode 100644 tests/fixtures/stacks/sveltekit-built/build/index.html create mode 100644 tests/fixtures/stacks/sveltekit-built/expected.json create mode 100644 tests/fixtures/stacks/sveltekit-built/package.json create mode 100644 tests/fixtures/stacks/vite-spa/expected.json create mode 100644 tests/fixtures/stacks/vite-spa/index.html create mode 100644 tests/fixtures/stacks/vite-spa/package.json create mode 100644 tests/fixtures/stacks/vite-spa/yarn.lock create mode 100644 tests/fixtures/stacks/wordpress/expected.json create mode 100644 tests/fixtures/stacks/wordpress/package.json create mode 100644 tests/fixtures/stacks/wordpress/wp-config.php diff --git a/src/audit/detect.ts b/src/audit/detect.ts new file mode 100644 index 0000000..fadd5bf --- /dev/null +++ b/src/audit/detect.ts @@ -0,0 +1,241 @@ +import path from 'node:path' +import { toPosix } from '../fs.ts' +import { detectFramework } from './frameworks.ts' +import { + findBuildOutput, + findPackageManager, + type PackageManager, + readPackageJson, +} from './project.ts' + +/** + * What `eaa-kit` would do in this project, and why, without doing any of it. + * + * Zero-config only works when its decisions can be checked. Before this, the + * only way to find out what the tool made of a project was to run an audit and + * read the progress lines, which builds the project and starts its server on + * the way. This reads the same facts and stops there: nothing is built, + * started or written. + * + * It is also what the stack fixtures are tested against, so every framework + * the registry claims to know has a recorded answer that cannot drift. + */ + +export type DetectionPlan = + /** A build with HTML in it is already there, and is audited as files. */ + | 'audit-build' + /** The project's build is run first, then its output is audited. */ + | 'build' + /** The project's build is run, then its server is started and crawled. */ + | 'build-and-serve' + /** The project's server is started and crawled, with nothing to build. */ + | 'serve' + /** A CMS or server framework: never started uninvited, audited with --url. */ + | 'url' + /** A monorepo with several sites: one has to be chosen. */ + | 'choose-site' + /** Nothing this can audit without being told where. */ + | 'nothing' + +export interface ProjectDetection { + framework?: { id: string; name: string; evidence: string[] } + /** Only for a project with a package.json: nothing else is installed with one. */ + packageManager?: { name: PackageManager; evidence: string } + plan: DetectionPlan + /** In words, one sentence: what an audit here would do. */ + summary: string + /** Output directories tried, most likely first, relative to cwd. */ + outputs: string[] + /** The build found or expected, relative to cwd. */ + directory?: string + /** The command an audit would run to build, if any. */ + build?: string + /** The command an audit would run to serve, if any. */ + serve?: string + /** For a CMS: how to get the site running so --url has something to crawl. */ + serveCommand?: string + /** + * The page the build starts from is an empty single-page-app shell, so its + * output will be one too: audit it with --browser. + */ + appShell?: true + /** A Next.js build's own page list, when one is there to read. */ + next?: { output?: string; basePath?: string; pages: number; dynamic: string[] } + /** A monorepo: the file that made it one, and its sites. */ + workspace?: { evidence: string; sites: Array<{ dir: string; framework: string }> } + /** When a monorepo has one site, what detection concluded inside it. */ + site?: ProjectDetection +} + +export async function detectProject(cwd: string): Promise { + const pkg = await readPackageJson(cwd) + const manager = await findPackageManager(cwd) + const detected = await detectFramework(cwd, pkg) + const { candidateOutputs } = await import('./frameworks.ts') + const base: ProjectDetection = { + ...(detected === undefined + ? {} + : { + framework: { + id: detected.framework.id, + name: detected.framework.name, + evidence: detected.evidence, + }, + }), + ...(pkg === undefined + ? {} + : { packageManager: { name: manager.manager, evidence: manager.evidence } }), + plan: 'nothing', + summary: '', + outputs: await candidateOutputs(cwd, pkg), + } + const run = (script: string): string => + `${manager.manager} ${manager.manager === 'deno' ? 'task' : 'run'} ${script}` + const relative = (dir: string): string => toPosix(path.relative(cwd, dir)) || '.' + + const existing = await findBuildOutput(cwd) + if (existing !== undefined) { + return { + ...base, + plan: 'audit-build', + directory: relative(existing), + summary: `audit the build already in ${relative(existing)}/`, + } + } + + if (detected === undefined) { + const { findWorkspaceSites } = await import('./workspaces.ts') + const workspace = await findWorkspaceSites(cwd) + const sites = workspace?.sites ?? [] + if (workspace !== undefined && sites.length > 0) { + const listed = { + evidence: workspace.evidence, + sites: sites.map((site) => ({ dir: site.dir, framework: site.framework.framework.name })), + } + const only = sites[0] + if (sites.length === 1 && only !== undefined) { + const inner = await detectProject(path.join(cwd, only.dir)) + return { + ...base, + plan: inner.plan, + workspace: listed, + site: inner, + summary: `go into ${only.dir}/, this monorepo's one site, and ${inner.summary}`, + } + } + return { + ...base, + plan: 'choose-site', + workspace: listed, + summary: `do nothing until a site is chosen: this monorepo has ${sites.length}`, + } + } + } + + if (detected !== undefined && detected.framework.outputs.length === 0) { + return { + ...base, + plan: 'url', + ...(detected.framework.serveCommand === undefined + ? {} + : { serveCommand: detected.framework.serveCommand }), + summary: `do nothing on its own: ${detected.framework.name} renders on a server, so start the site and audit it with --url`, + } + } + + if (pkg === undefined) { + const { glob } = await import('tinyglobby') + const html = await glob(['*.html', '*.htm'], { cwd, onlyFiles: true, dot: false }) + if (detected === undefined && html.length > 0) { + return { + ...base, + plan: 'audit-build', + directory: '.', + summary: 'audit the hand-written HTML in this folder', + } + } + const command = detected?.framework.buildCommand + if (command !== undefined) { + return { + ...base, + build: command, + summary: `do nothing yet: build the site with ${command} first`, + } + } + return { ...base, summary: 'do nothing: there is no build, no package.json and no HTML here' } + } + + const scripts = pkg.scripts ?? {} + const isNext = detected?.framework.id === 'next' + const nextFacts = isNext ? await readNext(cwd) : undefined + const servesAfterBuild = isNext && nextFacts?.output !== 'export' + const serveScript = servesAfterBuild + ? scripts['start'] !== undefined + ? run('start') + : 'next start' + : ['start', 'preview', 'serve'].filter((name) => scripts[name] !== undefined).map(run)[0] + const withNext = nextFacts === undefined ? {} : { next: nextFacts } + + if (scripts['build'] !== undefined) { + const expected = base.outputs[0] + if (servesAfterBuild) { + return { + ...base, + ...withNext, + plan: 'build-and-serve', + build: run('build'), + ...(serveScript === undefined ? {} : { serve: serveScript }), + summary: `run ${run('build')}, start the site with ${serveScript}, and crawl the pages its build lists`, + } + } + const shell = await sourceIsShell(cwd) + return { + ...base, + ...withNext, + plan: 'build', + build: run('build'), + ...(expected === undefined ? {} : { directory: expected }), + ...(serveScript === undefined ? {} : { serve: serveScript }), + ...(shell ? { appShell: true as const } : {}), + summary: + (expected === undefined + ? `run ${run('build')} and audit what it writes` + : `run ${run('build')} and audit what it writes, expected in ${expected}/`) + + (shell ? ', which is an empty app shell until JavaScript runs: add --browser' : ''), + } + } + + if (serveScript !== undefined) { + return { + ...base, + ...withNext, + plan: 'serve', + serve: serveScript, + summary: `start the site with ${serveScript} and crawl it`, + } + } + return { ...base, summary: 'do nothing: there is no build, and no script to build or serve one' } +} + +async function readNext(cwd: string): Promise { + const { nextRoutes, readNextConfig } = await import('./next.ts') + const config = await readNextConfig(cwd) + const routes = await nextRoutes(cwd) + return { + ...(config.output === undefined ? {} : { output: config.output }), + ...(config.basePath === undefined ? {} : { basePath: config.basePath }), + pages: routes?.pages.length ?? 0, + dynamic: routes?.dynamic ?? [], + } +} + +/** Whether the index.html a Vite-style build starts from is an empty shell. */ +async function sourceIsShell(cwd: string): Promise { + const { readFile } = await import('node:fs/promises') + const { isAppShell } = await import('./shell.ts') + try { + return isAppShell(await readFile(path.join(cwd, 'index.html'), 'utf8')) + } catch { + return false + } +} diff --git a/src/audit/frameworks.ts b/src/audit/frameworks.ts index ba6916c..59d1ebd 100644 --- a/src/audit/frameworks.ts +++ b/src/audit/frameworks.ts @@ -60,6 +60,11 @@ export interface Framework { * that gets the site up, so `--url` has something to point at. */ serveCommand?: string + /** + * How to build it, for a generator that is not an npm package and so has no + * `build` script for this tool to run: `hugo`, `mkdocs build`. + */ + buildCommand?: string } /** @@ -219,6 +224,7 @@ export const FRAMEWORKS: readonly Framework[] = [ outputs: ['site'], configs: ['mkdocs.yml', 'mkdocs.yaml'], outputPattern: /^site_dir:\s*['"]?([^'"\s#]+)/m, + buildCommand: 'mkdocs build', serves: false, }, { @@ -233,6 +239,7 @@ export const FRAMEWORKS: readonly Framework[] = [ 'docs/build/html', 'doc/_build/html', ], + buildCommand: 'sphinx-build -M html docs docs/_build', serves: false, }, { @@ -243,6 +250,7 @@ export const FRAMEWORKS: readonly Framework[] = [ outputs: ['book'], configs: ['book.toml'], outputPattern: /build-dir\s*=\s*['"]([^'"]+)['"]/, + buildCommand: 'mdbook build', serves: false, }, { @@ -253,6 +261,7 @@ export const FRAMEWORKS: readonly Framework[] = [ outputs: ['_site'], configs: ['_quarto.yml', '_quarto.yaml'], outputPattern: /output-dir:\s*['"]?([^'"\s#]+)/, + buildCommand: 'quarto render', serves: false, }, { @@ -263,6 +272,7 @@ export const FRAMEWORKS: readonly Framework[] = [ outputs: ['output'], configs: ['pelicanconf.py', 'publishconf.py'], outputPattern: /OUTPUT_PATH\s*=\s*['"]([^'"]+)['"]/, + buildCommand: 'pelican content', serves: false, }, { @@ -273,6 +283,7 @@ export const FRAMEWORKS: readonly Framework[] = [ files: ['zola.toml'], contents: { file: 'config.toml', pattern: /^\s*base_url\s*=/m }, outputs: ['public'], + buildCommand: 'zola build', serves: false, }, { @@ -281,6 +292,7 @@ export const FRAMEWORKS: readonly Framework[] = [ packages: [], files: ['hugo.toml', 'hugo.yaml', 'hugo.json', 'config.toml', 'config/_default'], outputs: ['public'], + buildCommand: 'hugo', serves: false, }, { @@ -289,6 +301,7 @@ export const FRAMEWORKS: readonly Framework[] = [ packages: [], files: ['_config.yml'], outputs: ['_site'], + buildCommand: 'bundle exec jekyll build', serves: false, }, // Everything below renders on a server and writes no browsable HTML to disk. diff --git a/src/cli/detect.ts b/src/cli/detect.ts new file mode 100644 index 0000000..a9c58ce --- /dev/null +++ b/src/cli/detect.ts @@ -0,0 +1,104 @@ +import path from 'node:path' +import pc from 'picocolors' +import type { ProjectDetection } from '../audit/detect.ts' + +/** + * `eaa-kit detect`. + * + * What the tool makes of this project, and the evidence for each part of it, + * without building, starting or auditing anything. The answer to "why did it + * audit that?" before the question has to be asked, and the thing to paste + * into an issue when the answer is wrong. + */ + +export interface DetectCommandOptions { + /** Print the detection as JSON, for tools and for bug reports. */ + json?: boolean + /** Colour. Defaults to whatever picocolors detects for this terminal. */ + color?: boolean +} + +export async function runDetectCommand( + dir: string | undefined, + options: DetectCommandOptions = {}, +): Promise { + const { detectProject } = await import('../audit/detect.ts') + const detection = await detectProject(path.resolve(dir ?? '.')) + return options.json + ? `${JSON.stringify(detection, null, 2)}\n` + : formatDetection(detection, options) +} + +export function formatDetection( + detection: ProjectDetection, + options: DetectCommandOptions = {}, +): string { + const colors = pc.createColors(options.color ?? pc.isColorSupported) + const lines: string[] = [] + const row = (label: string, value: string, why?: string): void => { + lines.push( + `${label.padEnd(16)} ${value}${why === undefined ? '' : ` ${colors.dim(`(${why})`)}`}`, + ) + } + + const framework = detection.framework + row( + 'Framework', + framework?.name ?? 'none recognised', + framework === undefined ? undefined : framework.evidence.join('; '), + ) + if (detection.packageManager !== undefined) { + row('Package manager', detection.packageManager.name, detection.packageManager.evidence) + } + if (detection.workspace !== undefined) { + row( + 'Monorepo', + detection.workspace.sites.map((site) => `${site.dir} (${site.framework})`).join(', '), + detection.workspace.evidence, + ) + } + const inner = detection.site ?? detection + if (inner.site === undefined && detection.site !== undefined) { + row( + 'Site framework', + inner.framework?.name ?? 'none recognised', + inner.framework?.evidence.join('; '), + ) + } + if (inner.directory !== undefined) row('Build output', `${inner.directory}/`) + if (inner.next !== undefined) { + const { pages, dynamic } = inner.next + row( + 'Next.js pages', + `${pages} listed by the build`, + dynamic.length === 0 ? undefined : `not listed, rendered on request: ${dynamic.join(', ')}`, + ) + } + lines.push('') + lines.push(`${colors.bold('An audit would')} ${detection.summary}.`) + + const next = nextCommand(detection) + if (next !== undefined) lines.push(` ${colors.cyan('→')} ${colors.bold(next)}`) + return `${lines.join('\n')}\n` +} + +/** The command to type next, given what was found. */ +function nextCommand(detection: ProjectDetection): string | undefined { + const inner = detection.site ?? detection + if (detection.plan === 'choose-site') { + const first = detection.workspace?.sites[0]?.dir + return first === undefined ? undefined : `cd ${first} && npx eaa-kit detect` + } + if (inner.appShell) return 'npx eaa-kit audit --browser' + switch (inner.plan) { + case 'audit-build': + case 'build': + case 'build-and-serve': + case 'serve': + return 'npx eaa-kit' + case 'url': + return 'npx eaa-kit audit --url http://localhost:8000' + default: + return inner.build ?? 'npx eaa-kit audit ./path/to/build' + } +} diff --git a/src/cli/index.ts b/src/cli/index.ts index b689406..cb4f37b 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -327,6 +327,16 @@ program process.stdout.write(formatCountries(flags)) }) +program + .command('detect') + .description('Say what an audit here would do and why, without building or starting anything') + .argument('[dir]', 'the project to look at (default: this directory)') + .option('--json', 'print it as JSON, for tools and bug reports') + .action(async (dir: string | undefined, flags: { json?: true }) => { + const { runDetectCommand } = await import('./detect.ts') + process.stdout.write(await runDetectCommand(dir, flags)) + }) + program .command('diff') .description('Compare two JSON reports: what a change made worse, and what it fixed') diff --git a/tests/audit/detect.test.ts b/tests/audit/detect.test.ts new file mode 100644 index 0000000..6b28769 --- /dev/null +++ b/tests/audit/detect.test.ts @@ -0,0 +1,52 @@ +import { cp, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, describe, expect, it } from 'vitest' +import { detectProject } from '../../src/audit/detect.ts' + +/** + * The gate for detection: one fixture per stack, each the file layout of a real + * project of that kind, and the answer `eaa-kit detect --json` must give for + * it, recorded next to it in expected.json. + * + * Each is copied somewhere empty first. Inside this repository, a fixture + * without a lockfile would find this repository's own and call itself pnpm. + * + * `UPDATE_STACKS=1 pnpm vitest run tests/audit/detect.test.ts` rewrites the + * expected answers, which then have to be read and believed before committing. + */ + +const STACKS = fileURLToPath(new URL('../fixtures/stacks/', import.meta.url)) +const scratch = await mkdtemp(path.join(tmpdir(), 'eaa-kit-stacks-')) + +afterAll(async () => { + await rm(scratch, { recursive: true, force: true }) +}) + +const stacks = (await readdir(STACKS, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort() + +describe('eaa-kit detect, over every stack fixture', () => { + it('has a fixture for every kind of project it claims to handle', () => { + expect(stacks.length).toBeGreaterThanOrEqual(14) + }) + + it.each(stacks)('%s', async (stack) => { + const copy = path.join(scratch, stack) + await cp(path.join(STACKS, stack), copy, { + recursive: true, + filter: (source) => path.basename(source) !== 'expected.json', + }) + + const detected = JSON.parse(JSON.stringify(await detectProject(copy))) + const expectedFile = path.join(STACKS, stack, 'expected.json') + + if (process.env['UPDATE_STACKS'] === '1') { + await writeFile(expectedFile, `${JSON.stringify(detected, null, 2)}\n`) + } + expect(detected).toEqual(JSON.parse(await readFile(expectedFile, 'utf8'))) + }) +}) diff --git a/tests/cli/detect.test.ts b/tests/cli/detect.test.ts new file mode 100644 index 0000000..a5e38bc --- /dev/null +++ b/tests/cli/detect.test.ts @@ -0,0 +1,74 @@ +import { cp, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { runDetectCommand } from '../../src/cli/detect.ts' + +const STACKS = fileURLToPath(new URL('../fixtures/stacks/', import.meta.url)) +const dirs: string[] = [] + +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +async function stack(name: string): Promise { + const dir = await mkdtemp(path.join(tmpdir(), 'eaa-kit-detect-')) + dirs.push(dir) + await cp(path.join(STACKS, name), dir, { recursive: true }) + return dir +} + +describe('eaa-kit detect', () => { + it('says what it found, the evidence, and what an audit would do', async () => { + const output = await runDetectCommand(await stack('next-server'), { color: false }) + + expect(output).toBe( + [ + 'Framework Next.js (package.json depends on next)', + 'Package manager npm (no lockfile, so npm)', + 'Next.js pages 5 listed by the build (not listed, rendered on request: /user/[id])', + '', + 'An audit would run npm run build, start the site with npm run start, and crawl the pages its build lists.', + ' → npx eaa-kit', + '', + ].join('\n'), + ) + }) + + it('names the sites in a monorepo, and how to look at one', async () => { + const output = await runDetectCommand(await stack('monorepo-two'), { color: false }) + + expect(output).toContain('Monorepo apps/docs (VitePress), apps/shop (Next.js)') + expect(output).toContain('→ cd apps/docs && npx eaa-kit detect') + }) + + it('sends a CMS to --url, since it is never started uninvited', async () => { + const output = await runDetectCommand(await stack('wordpress'), { color: false }) + + expect(output).toContain('An audit would do nothing on its own: WordPress renders on a server') + expect(output).toContain('→ npx eaa-kit audit --url') + }) + + it('prints the same as JSON', async () => { + const output = await runDetectCommand(await stack('astro'), { json: true }) + + expect(JSON.parse(output)).toMatchObject({ + framework: { id: 'astro' }, + packageManager: { name: 'pnpm' }, + plan: 'build', + directory: 'public-html', + }) + }) + + it('builds nothing and starts nothing', async () => { + // The next-server fixture has a build script; detect must not run it. + const dir = await stack('next-server') + const { readdir } = await import('node:fs/promises') + const before = await readdir(dir, { recursive: true }) + + await runDetectCommand(dir, { json: true }) + + expect(await readdir(dir, { recursive: true })).toEqual(before) + }) +}) diff --git a/tests/fixtures/stacks/astro/astro.config.mjs b/tests/fixtures/stacks/astro/astro.config.mjs new file mode 100644 index 0000000..08d3ade --- /dev/null +++ b/tests/fixtures/stacks/astro/astro.config.mjs @@ -0,0 +1 @@ +export default { outDir: './public-html' } \ No newline at end of file diff --git a/tests/fixtures/stacks/astro/expected.json b/tests/fixtures/stacks/astro/expected.json new file mode 100644 index 0000000..1d52b47 --- /dev/null +++ b/tests/fixtures/stacks/astro/expected.json @@ -0,0 +1,28 @@ +{ + "framework": { + "id": "astro", + "name": "Astro", + "evidence": [ + "package.json depends on astro", + "astro.config.mjs sets the output to public-html/" + ] + }, + "packageManager": { + "name": "pnpm", + "evidence": "found pnpm-lock.yaml" + }, + "plan": "build", + "summary": "run pnpm run build and audit what it writes, expected in public-html/", + "outputs": [ + "public-html", + "dist", + "out", + "build", + "_site", + "public", + ".output/public" + ], + "build": "pnpm run build", + "directory": "public-html", + "serve": "pnpm run preview" +} diff --git a/tests/fixtures/stacks/astro/package.json b/tests/fixtures/stacks/astro/package.json new file mode 100644 index 0000000..29f82f7 --- /dev/null +++ b/tests/fixtures/stacks/astro/package.json @@ -0,0 +1 @@ +{"scripts":{"build":"astro build","preview":"astro preview"},"devDependencies":{"astro":"5.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/stacks/astro/pnpm-lock.yaml b/tests/fixtures/stacks/astro/pnpm-lock.yaml new file mode 100644 index 0000000..7ed6a64 --- /dev/null +++ b/tests/fixtures/stacks/astro/pnpm-lock.yaml @@ -0,0 +1 @@ +lockfileVersion: '9.0' \ No newline at end of file diff --git a/tests/fixtures/stacks/bun-remix/bun.lock b/tests/fixtures/stacks/bun-remix/bun.lock new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/tests/fixtures/stacks/bun-remix/bun.lock @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/fixtures/stacks/bun-remix/expected.json b/tests/fixtures/stacks/bun-remix/expected.json new file mode 100644 index 0000000..37d7fa5 --- /dev/null +++ b/tests/fixtures/stacks/bun-remix/expected.json @@ -0,0 +1,27 @@ +{ + "framework": { + "id": "remix", + "name": "React Router / Remix", + "evidence": [ + "package.json depends on react-router" + ] + }, + "packageManager": { + "name": "bun", + "evidence": "found bun.lock" + }, + "plan": "build", + "summary": "run bun run build and audit what it writes, expected in build/client/", + "outputs": [ + "build/client", + "dist", + "out", + "build", + "_site", + "public", + ".output/public" + ], + "build": "bun run build", + "directory": "build/client", + "serve": "bun run start" +} diff --git a/tests/fixtures/stacks/bun-remix/package.json b/tests/fixtures/stacks/bun-remix/package.json new file mode 100644 index 0000000..42abf81 --- /dev/null +++ b/tests/fixtures/stacks/bun-remix/package.json @@ -0,0 +1 @@ +{"scripts":{"build":"react-router build","start":"react-router-serve ./build/server/index.js"},"dependencies":{"react-router":"7.0.0","@react-router/dev":"7.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/stacks/hand-written/about.html b/tests/fixtures/stacks/hand-written/about.html new file mode 100644 index 0000000..714bab3 --- /dev/null +++ b/tests/fixtures/stacks/hand-written/about.html @@ -0,0 +1 @@ +Home

Home

\ No newline at end of file diff --git a/tests/fixtures/stacks/hand-written/expected.json b/tests/fixtures/stacks/hand-written/expected.json new file mode 100644 index 0000000..6142230 --- /dev/null +++ b/tests/fixtures/stacks/hand-written/expected.json @@ -0,0 +1,13 @@ +{ + "plan": "audit-build", + "summary": "audit the hand-written HTML in this folder", + "outputs": [ + "dist", + "out", + "build", + "_site", + "public", + ".output/public" + ], + "directory": "." +} diff --git a/tests/fixtures/stacks/hand-written/index.html b/tests/fixtures/stacks/hand-written/index.html new file mode 100644 index 0000000..714bab3 --- /dev/null +++ b/tests/fixtures/stacks/hand-written/index.html @@ -0,0 +1 @@ +Home

Home

\ No newline at end of file diff --git a/tests/fixtures/stacks/hugo/expected.json b/tests/fixtures/stacks/hugo/expected.json new file mode 100644 index 0000000..49fa65e --- /dev/null +++ b/tests/fixtures/stacks/hugo/expected.json @@ -0,0 +1,20 @@ +{ + "framework": { + "id": "hugo", + "name": "Hugo", + "evidence": [ + "found hugo.toml" + ] + }, + "plan": "audit-build", + "summary": "audit the build already in public/", + "outputs": [ + "public", + "dist", + "out", + "build", + "_site", + ".output/public" + ], + "directory": "public" +} diff --git a/tests/fixtures/stacks/hugo/hugo.toml b/tests/fixtures/stacks/hugo/hugo.toml new file mode 100644 index 0000000..e86b14d --- /dev/null +++ b/tests/fixtures/stacks/hugo/hugo.toml @@ -0,0 +1 @@ +baseURL = "https://example.org/" \ No newline at end of file diff --git a/tests/fixtures/stacks/hugo/public/index.html b/tests/fixtures/stacks/hugo/public/index.html new file mode 100644 index 0000000..714bab3 --- /dev/null +++ b/tests/fixtures/stacks/hugo/public/index.html @@ -0,0 +1 @@ +Home

Home

\ No newline at end of file diff --git a/tests/fixtures/stacks/laravel/artisan b/tests/fixtures/stacks/laravel/artisan new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/stacks/laravel/expected.json b/tests/fixtures/stacks/laravel/expected.json new file mode 100644 index 0000000..589064b --- /dev/null +++ b/tests/fixtures/stacks/laravel/expected.json @@ -0,0 +1,20 @@ +{ + "framework": { + "id": "laravel", + "name": "Laravel", + "evidence": [ + "found artisan" + ] + }, + "plan": "url", + "summary": "do nothing on its own: Laravel renders on a server, so start the site and audit it with --url", + "outputs": [ + "dist", + "out", + "build", + "_site", + "public", + ".output/public" + ], + "serveCommand": "php artisan serve" +} diff --git a/tests/fixtures/stacks/mkdocs/expected.json b/tests/fixtures/stacks/mkdocs/expected.json new file mode 100644 index 0000000..5f96ce7 --- /dev/null +++ b/tests/fixtures/stacks/mkdocs/expected.json @@ -0,0 +1,21 @@ +{ + "framework": { + "id": "mkdocs", + "name": "MkDocs", + "evidence": [ + "found mkdocs.yml" + ] + }, + "plan": "nothing", + "summary": "do nothing yet: build the site with mkdocs build first", + "outputs": [ + "site", + "dist", + "out", + "build", + "_site", + "public", + ".output/public" + ], + "build": "mkdocs build" +} diff --git a/tests/fixtures/stacks/mkdocs/mkdocs.yml b/tests/fixtures/stacks/mkdocs/mkdocs.yml new file mode 100644 index 0000000..66eead7 --- /dev/null +++ b/tests/fixtures/stacks/mkdocs/mkdocs.yml @@ -0,0 +1 @@ +site_name: Docs \ No newline at end of file diff --git a/tests/fixtures/stacks/monorepo-one/apps/web/package.json b/tests/fixtures/stacks/monorepo-one/apps/web/package.json new file mode 100644 index 0000000..4ec63a7 --- /dev/null +++ b/tests/fixtures/stacks/monorepo-one/apps/web/package.json @@ -0,0 +1 @@ +{"scripts":{"build":"astro build"},"dependencies":{"astro":"5.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/stacks/monorepo-one/expected.json b/tests/fixtures/stacks/monorepo-one/expected.json new file mode 100644 index 0000000..be60fd0 --- /dev/null +++ b/tests/fixtures/stacks/monorepo-one/expected.json @@ -0,0 +1,50 @@ +{ + "packageManager": { + "name": "pnpm", + "evidence": "found pnpm-lock.yaml" + }, + "plan": "build", + "summary": "go into apps/web/, this monorepo's one site, and run pnpm run build and audit what it writes, expected in dist/", + "outputs": [ + "dist", + "out", + "build", + "_site", + "public", + ".output/public" + ], + "workspace": { + "evidence": "pnpm-workspace.yaml", + "sites": [ + { + "dir": "apps/web", + "framework": "Astro" + } + ] + }, + "site": { + "framework": { + "id": "astro", + "name": "Astro", + "evidence": [ + "package.json depends on astro" + ] + }, + "packageManager": { + "name": "pnpm", + "evidence": "found ../../pnpm-lock.yaml" + }, + "plan": "build", + "summary": "run pnpm run build and audit what it writes, expected in dist/", + "outputs": [ + "dist", + "out", + "build", + "_site", + "public", + ".output/public" + ], + "build": "pnpm run build", + "directory": "dist" + } +} diff --git a/tests/fixtures/stacks/monorepo-one/package.json b/tests/fixtures/stacks/monorepo-one/package.json new file mode 100644 index 0000000..a413b41 --- /dev/null +++ b/tests/fixtures/stacks/monorepo-one/package.json @@ -0,0 +1 @@ +{"private":true,"devDependencies":{"turbo":"2.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/stacks/monorepo-one/packages/ui/package.json b/tests/fixtures/stacks/monorepo-one/packages/ui/package.json new file mode 100644 index 0000000..1306e5a --- /dev/null +++ b/tests/fixtures/stacks/monorepo-one/packages/ui/package.json @@ -0,0 +1 @@ +{"dependencies":{"react":"19.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/stacks/monorepo-one/pnpm-lock.yaml b/tests/fixtures/stacks/monorepo-one/pnpm-lock.yaml new file mode 100644 index 0000000..7ed6a64 --- /dev/null +++ b/tests/fixtures/stacks/monorepo-one/pnpm-lock.yaml @@ -0,0 +1 @@ +lockfileVersion: '9.0' \ No newline at end of file diff --git a/tests/fixtures/stacks/monorepo-one/pnpm-workspace.yaml b/tests/fixtures/stacks/monorepo-one/pnpm-workspace.yaml new file mode 100644 index 0000000..e9b0dad --- /dev/null +++ b/tests/fixtures/stacks/monorepo-one/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - 'apps/*' + - 'packages/*' diff --git a/tests/fixtures/stacks/monorepo-two/apps/docs/package.json b/tests/fixtures/stacks/monorepo-two/apps/docs/package.json new file mode 100644 index 0000000..8c247ae --- /dev/null +++ b/tests/fixtures/stacks/monorepo-two/apps/docs/package.json @@ -0,0 +1 @@ +{"scripts":{"build":"vitepress build"},"devDependencies":{"vitepress":"1.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/stacks/monorepo-two/apps/shop/package.json b/tests/fixtures/stacks/monorepo-two/apps/shop/package.json new file mode 100644 index 0000000..05c56a1 --- /dev/null +++ b/tests/fixtures/stacks/monorepo-two/apps/shop/package.json @@ -0,0 +1 @@ +{"scripts":{"build":"next build","start":"next start"},"dependencies":{"next":"16.3.6"}} \ No newline at end of file diff --git a/tests/fixtures/stacks/monorepo-two/expected.json b/tests/fixtures/stacks/monorepo-two/expected.json new file mode 100644 index 0000000..58cde32 --- /dev/null +++ b/tests/fixtures/stacks/monorepo-two/expected.json @@ -0,0 +1,29 @@ +{ + "packageManager": { + "name": "npm", + "evidence": "found package-lock.json" + }, + "plan": "choose-site", + "summary": "do nothing until a site is chosen: this monorepo has 2", + "outputs": [ + "dist", + "out", + "build", + "_site", + "public", + ".output/public" + ], + "workspace": { + "evidence": "package.json workspaces", + "sites": [ + { + "dir": "apps/docs", + "framework": "VitePress" + }, + { + "dir": "apps/shop", + "framework": "Next.js" + } + ] + } +} diff --git a/tests/fixtures/stacks/monorepo-two/package-lock.json b/tests/fixtures/stacks/monorepo-two/package-lock.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/tests/fixtures/stacks/monorepo-two/package-lock.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/tests/fixtures/stacks/monorepo-two/package.json b/tests/fixtures/stacks/monorepo-two/package.json new file mode 100644 index 0000000..e7f7210 --- /dev/null +++ b/tests/fixtures/stacks/monorepo-two/package.json @@ -0,0 +1 @@ +{"private":true,"workspaces":["apps/*"]} \ No newline at end of file diff --git a/tests/fixtures/stacks/next-export/expected.json b/tests/fixtures/stacks/next-export/expected.json new file mode 100644 index 0000000..e25ed72 --- /dev/null +++ b/tests/fixtures/stacks/next-export/expected.json @@ -0,0 +1,24 @@ +{ + "framework": { + "id": "next", + "name": "Next.js", + "evidence": [ + "package.json depends on next" + ] + }, + "packageManager": { + "name": "npm", + "evidence": "no lockfile, so npm" + }, + "plan": "audit-build", + "summary": "audit the build already in out/", + "outputs": [ + "out", + "dist", + "build", + "_site", + "public", + ".output/public" + ], + "directory": "out" +} diff --git a/tests/fixtures/stacks/next-export/next.config.mjs b/tests/fixtures/stacks/next-export/next.config.mjs new file mode 100644 index 0000000..71370f2 --- /dev/null +++ b/tests/fixtures/stacks/next-export/next.config.mjs @@ -0,0 +1 @@ +export default { output: 'export' } \ No newline at end of file diff --git a/tests/fixtures/stacks/next-export/out/index.html b/tests/fixtures/stacks/next-export/out/index.html new file mode 100644 index 0000000..714bab3 --- /dev/null +++ b/tests/fixtures/stacks/next-export/out/index.html @@ -0,0 +1 @@ +Home

Home

\ No newline at end of file diff --git a/tests/fixtures/stacks/next-export/package.json b/tests/fixtures/stacks/next-export/package.json new file mode 100644 index 0000000..e6c15ae --- /dev/null +++ b/tests/fixtures/stacks/next-export/package.json @@ -0,0 +1 @@ +{"scripts":{"build":"next build"},"dependencies":{"next":"16.3.6"}} \ No newline at end of file diff --git a/tests/fixtures/stacks/next-i18n/expected.json b/tests/fixtures/stacks/next-i18n/expected.json new file mode 100644 index 0000000..bd79148 --- /dev/null +++ b/tests/fixtures/stacks/next-i18n/expected.json @@ -0,0 +1,29 @@ +{ + "framework": { + "id": "next", + "name": "Next.js", + "evidence": [ + "package.json depends on next" + ] + }, + "packageManager": { + "name": "npm", + "evidence": "no lockfile, so npm" + }, + "plan": "build-and-serve", + "summary": "run npm run build, start the site with npm run start, and crawl the pages its build lists", + "outputs": [ + "out", + "dist", + "build", + "_site", + "public", + ".output/public" + ], + "next": { + "pages": 5, + "dynamic": [] + }, + "build": "npm run build", + "serve": "npm run start" +} diff --git a/tests/fixtures/stacks/next-server/expected.json b/tests/fixtures/stacks/next-server/expected.json new file mode 100644 index 0000000..52deadd --- /dev/null +++ b/tests/fixtures/stacks/next-server/expected.json @@ -0,0 +1,32 @@ +{ + "framework": { + "id": "next", + "name": "Next.js", + "evidence": [ + "package.json depends on next" + ] + }, + "packageManager": { + "name": "npm", + "evidence": "no lockfile, so npm" + }, + "plan": "build-and-serve", + "summary": "run npm run build, start the site with npm run start, and crawl the pages its build lists", + "outputs": [ + "out", + "dist", + "build", + "_site", + "public", + ".output/public" + ], + "next": { + "basePath": "/docs", + "pages": 5, + "dynamic": [ + "/user/[id]" + ] + }, + "build": "npm run build", + "serve": "npm run start" +} diff --git a/tests/fixtures/stacks/sveltekit-built/build/index.html b/tests/fixtures/stacks/sveltekit-built/build/index.html new file mode 100644 index 0000000..714bab3 --- /dev/null +++ b/tests/fixtures/stacks/sveltekit-built/build/index.html @@ -0,0 +1 @@ +Home

Home

\ No newline at end of file diff --git a/tests/fixtures/stacks/sveltekit-built/expected.json b/tests/fixtures/stacks/sveltekit-built/expected.json new file mode 100644 index 0000000..d49a9ed --- /dev/null +++ b/tests/fixtures/stacks/sveltekit-built/expected.json @@ -0,0 +1,25 @@ +{ + "framework": { + "id": "sveltekit", + "name": "SvelteKit", + "evidence": [ + "package.json depends on @sveltejs/kit" + ] + }, + "packageManager": { + "name": "pnpm", + "evidence": "package.json declares packageManager pnpm@10.4.1" + }, + "plan": "audit-build", + "summary": "audit the build already in build/", + "outputs": [ + "build", + ".svelte-kit/output/prerendered/pages", + "dist", + "out", + "_site", + "public", + ".output/public" + ], + "directory": "build" +} diff --git a/tests/fixtures/stacks/sveltekit-built/package.json b/tests/fixtures/stacks/sveltekit-built/package.json new file mode 100644 index 0000000..f72e132 --- /dev/null +++ b/tests/fixtures/stacks/sveltekit-built/package.json @@ -0,0 +1 @@ +{"packageManager":"pnpm@10.4.1","devDependencies":{"@sveltejs/kit":"2.0.0","vite":"6.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/stacks/vite-spa/expected.json b/tests/fixtures/stacks/vite-spa/expected.json new file mode 100644 index 0000000..5dd0d82 --- /dev/null +++ b/tests/fixtures/stacks/vite-spa/expected.json @@ -0,0 +1,27 @@ +{ + "framework": { + "id": "vite", + "name": "Vite", + "evidence": [ + "package.json depends on vite" + ] + }, + "packageManager": { + "name": "yarn", + "evidence": "found yarn.lock" + }, + "plan": "build", + "summary": "run yarn run build and audit what it writes, expected in dist/, which is an empty app shell until JavaScript runs: add --browser", + "outputs": [ + "dist", + "out", + "build", + "_site", + "public", + ".output/public" + ], + "build": "yarn run build", + "directory": "dist", + "serve": "yarn run preview", + "appShell": true +} diff --git a/tests/fixtures/stacks/vite-spa/index.html b/tests/fixtures/stacks/vite-spa/index.html new file mode 100644 index 0000000..c3de64d --- /dev/null +++ b/tests/fixtures/stacks/vite-spa/index.html @@ -0,0 +1 @@ +App
\ No newline at end of file diff --git a/tests/fixtures/stacks/vite-spa/package.json b/tests/fixtures/stacks/vite-spa/package.json new file mode 100644 index 0000000..d95fc77 --- /dev/null +++ b/tests/fixtures/stacks/vite-spa/package.json @@ -0,0 +1 @@ +{"scripts":{"build":"vite build","preview":"vite preview"},"devDependencies":{"vite":"6.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/stacks/vite-spa/yarn.lock b/tests/fixtures/stacks/vite-spa/yarn.lock new file mode 100644 index 0000000..e69de29 diff --git a/tests/fixtures/stacks/wordpress/expected.json b/tests/fixtures/stacks/wordpress/expected.json new file mode 100644 index 0000000..3991817 --- /dev/null +++ b/tests/fixtures/stacks/wordpress/expected.json @@ -0,0 +1,24 @@ +{ + "framework": { + "id": "wordpress", + "name": "WordPress", + "evidence": [ + "found wp-config.php" + ] + }, + "packageManager": { + "name": "npm", + "evidence": "no lockfile, so npm" + }, + "plan": "url", + "summary": "do nothing on its own: WordPress renders on a server, so start the site and audit it with --url", + "outputs": [ + "dist", + "out", + "build", + "_site", + "public", + ".output/public" + ], + "serveCommand": "wp-env start, ddev start, or whichever local stack this site uses" +} diff --git a/tests/fixtures/stacks/wordpress/package.json b/tests/fixtures/stacks/wordpress/package.json new file mode 100644 index 0000000..7ca66bf --- /dev/null +++ b/tests/fixtures/stacks/wordpress/package.json @@ -0,0 +1 @@ +{"scripts":{"build":"vite build"},"devDependencies":{"vite":"6.0.0"}} \ No newline at end of file diff --git a/tests/fixtures/stacks/wordpress/wp-config.php b/tests/fixtures/stacks/wordpress/wp-config.php new file mode 100644 index 0000000..a814366 --- /dev/null +++ b/tests/fixtures/stacks/wordpress/wp-config.php @@ -0,0 +1 @@ + Date: Sat, 26 Sep 2026 19:33:17 +0000 Subject: [PATCH 5/8] feat(cli): eaa-kit doctor One screen with everything this tool needs to work in a project, each problem followed by the command that fixes it: - the Node.js version, against the engines range; - the project's package manager, and whether it is installed; - what detection makes of the site; - the config, and whether it parses; - a GitHub, GitLab or Bitbucket pipeline that runs eaa-kit; - the baseline, and entries in it that have expired; - Playwright and Chromium, for the optional --browser. Exit 2 only for what stops an audit from running; a missing config, CI file or baseline is advice. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013BXnvSeRtgZoTXe4j753gM --- src/cli/doctor.ts | 322 +++++++++++++++++++++++++++++++++++++++ src/cli/index.ts | 12 ++ tests/cli/doctor.test.ts | 144 +++++++++++++++++ 3 files changed, 478 insertions(+) create mode 100644 src/cli/doctor.ts create mode 100644 tests/cli/doctor.test.ts diff --git a/src/cli/doctor.ts b/src/cli/doctor.ts new file mode 100644 index 0000000..a0950f7 --- /dev/null +++ b/src/cli/doctor.ts @@ -0,0 +1,322 @@ +import { spawn } from 'node:child_process' +import { createRequire } from 'node:module' +import path from 'node:path' +import pc from 'picocolors' +import { exists } from '../fs.ts' + +/** + * `eaa-kit doctor`. + * + * Everything that has to be true for this tool to work here, checked in one + * go and shown on one screen: the Node version, the package manager, the + * config, what detection makes of the project, the CI file, the baseline and + * the optional browser. Each problem ends with the command that fixes it. + * + * Only what stops an audit from running is a failure, and exits 2. A missing + * config, CI file or baseline is advice: the audit runs without them. + */ + +export type DoctorStatus = 'ok' | 'warn' | 'fail' | 'skip' + +export interface DoctorCheck { + label: string + status: DoctorStatus + detail: string + /** The command that fixes it. */ + fix?: string +} + +export interface DoctorResult { + checks: DoctorCheck[] + exitCode: number +} + +/** What doctor asks of the machine rather than the project. Injectable for tests. */ +export interface DoctorEnvironment { + nodeVersion?: string + /** Whether a command can be run at all. */ + onPath?: (command: string) => Promise + /** Whether browser mode could run: Playwright, and Chromium downloaded for it. */ + chromium?: (cwd: string) => Promise> +} + +export async function runDoctor( + cwd: string, + environment: DoctorEnvironment = {}, +): Promise { + const nodeVersion = environment.nodeVersion ?? process.versions.node + const onPath = environment.onPath ?? canRun + const chromium = environment.chromium ?? chromiumCheck + const checks: DoctorCheck[] = [] + + const engines = ( + createRequire(import.meta.url)('eaa-kit/package.json') as { engines: { node: string } } + ).engines.node + checks.push( + nodeSatisfies(nodeVersion, engines) + ? { label: 'Node.js', status: 'ok', detail: nodeVersion } + : { + label: 'Node.js', + status: 'fail', + detail: `${nodeVersion} is not supported; this needs ${engines}`, + fix: 'install Node.js 22 LTS or newer from https://nodejs.org', + }, + ) + + const { detectProject } = await import('../audit/detect.ts') + const detection = await detectProject(cwd) + const inner = detection.site ?? detection + + const manager = detection.packageManager + if (manager !== undefined) { + const found = await onPath(manager.name) + checks.push( + found + ? { + label: 'Package manager', + status: 'ok', + detail: `${manager.name} (${manager.evidence})`, + } + : { + label: 'Package manager', + status: inner.build === undefined && inner.serve === undefined ? 'warn' : 'fail', + detail: `${manager.name} is what this project uses (${manager.evidence}), and it is not installed`, + fix: + manager.name === 'pnpm' || manager.name === 'yarn' + ? 'corepack enable' + : manager.name === 'bun' + ? 'npm i -g bun' + : manager.name === 'deno' + ? 'npm i -g deno' + : 'install Node.js, which includes npm', + }, + ) + } + + checks.push(siteCheck(detection)) + checks.push(await configCheck(cwd)) + checks.push(await ciCheck(cwd)) + checks.push(await baselineCheck(cwd)) + checks.push({ label: 'Browser mode', ...(await chromium(cwd)) }) + + return { + checks, + exitCode: checks.some((item) => item.status === 'fail') ? 2 : 0, + } +} + +function siteCheck(detection: import('../audit/detect.ts').ProjectDetection): DoctorCheck { + const inner = detection.site ?? detection + const label = 'Site' + const name = inner.framework?.name + switch (detection.plan) { + case 'nothing': + return { + label, + status: 'fail', + detail: `nothing to audit: an audit would ${detection.summary}`, + fix: inner.build ?? 'npx eaa-kit audit ./path/to/build', + } + case 'choose-site': + return { + label, + status: 'warn', + detail: `a monorepo with ${detection.workspace?.sites.length ?? 0} sites`, + fix: `cd ${detection.workspace?.sites[0]?.dir ?? ''} && npx eaa-kit doctor`, + } + case 'url': + return { + label, + status: 'warn', + detail: `${name} renders on a server; audit it running, with --url`, + fix: 'npx eaa-kit audit --url http://localhost:8000', + } + default: + return { + label, + status: inner.appShell ? 'warn' : 'ok', + detail: `${name ?? 'HTML'}: an audit would ${detection.summary}`, + ...(inner.appShell ? { fix: 'npx eaa-kit audit --browser' } : {}), + } + } +} + +async function configCheck(cwd: string): Promise { + const { findConfigFile, loadConfig } = await import('../config/load.ts') + const file = await findConfigFile(cwd) + if (file === undefined) { + return { + label: 'Config', + status: 'warn', + detail: 'none: audits run on defaults, and statements cannot be written', + fix: 'npx eaa-kit init', + } + } + const shown = path.relative(cwd, file) || path.basename(file) + try { + await loadConfig({ cwd, path: file }) + return { label: 'Config', status: 'ok', detail: shown } + } catch (cause) { + const issues = (cause as { issues?: string[] }).issues ?? [] + return { + label: 'Config', + status: 'fail', + detail: `${shown}: ${[(cause as Error).message, ...issues].join('; ')}`, + fix: 'npx eaa-kit init --force', + } + } +} + +/** Where the CI systems `init` knows about keep their pipelines. */ +async function ciCheck(cwd: string): Promise { + const { findGitRoot } = await import('./setup.ts') + const root = (await findGitRoot(cwd)) ?? cwd + const { glob } = await import('tinyglobby') + const { readFile } = await import('node:fs/promises') + const candidates = [ + ...(await glob(['.github/workflows/*.{yml,yaml}'], { cwd: root, dot: true })).sort(), + '.gitlab-ci.yml', + 'bitbucket-pipelines.yml', + ] + for (const file of candidates) { + try { + if ((await readFile(path.join(root, file), 'utf8')).includes('eaa-kit')) { + return { label: 'CI', status: 'ok', detail: file } + } + } catch { + // not there + } + } + return { + label: 'CI', + status: 'warn', + detail: 'no pipeline runs eaa-kit, so nothing stops a barrier being merged', + fix: 'npx eaa-kit init', + } +} + +async function baselineCheck(cwd: string): Promise { + const { DEFAULT_BASELINE_FILE, readBaseline } = await import('../audit/baseline.ts') + if (!(await exists(path.join(cwd, DEFAULT_BASELINE_FILE)))) { + return { + label: 'Baseline', + status: 'warn', + detail: 'none: CI fails on every barrier the site has today, not only new ones', + fix: 'npx eaa-kit init', + } + } + try { + const baseline = await readBaseline(DEFAULT_BASELINE_FILE, cwd) + const today = new Date().toISOString().slice(0, 10) + const expired = baseline.entries.filter( + (entry) => entry.expiresOn !== undefined && entry.expiresOn < today, + ).length + const count = `${baseline.entries.length} ${baseline.entries.length === 1 ? 'entry' : 'entries'}` + if (expired > 0) { + return { + label: 'Baseline', + status: 'warn', + detail: `${DEFAULT_BASELINE_FILE}: ${expired} ${expired === 1 ? 'entry has' : 'entries have'} expired and no longer suppress anything`, + fix: 'npx eaa-kit baseline', + } + } + return { label: 'Baseline', status: 'ok', detail: `${DEFAULT_BASELINE_FILE}, ${count}` } + } catch (cause) { + return { + label: 'Baseline', + status: 'fail', + detail: (cause as Error).message, + fix: 'npx eaa-kit baseline', + } + } +} + +async function chromiumCheck(cwd: string): Promise> { + const { loadChromium } = await import('../audit/runners/playwright.ts') + let launcher: { executablePath?: () => string } + try { + launcher = (await loadChromium(cwd)) as { executablePath?: () => string } + } catch { + return { + status: 'skip', + detail: 'optional, for --browser: Playwright is not installed', + fix: 'npm i -D playwright && npx playwright install chromium', + } + } + const executable = launcher.executablePath?.() + if (executable !== undefined && !(await exists(executable))) { + return { + status: 'warn', + detail: 'Playwright is installed, but the Chromium it drives is not', + fix: 'npx playwright install chromium', + } + } + return { status: 'ok', detail: 'Playwright and Chromium are installed, for --browser' } +} + +/** Whether `command --version` runs. */ +async function canRun(command: string): Promise { + return new Promise((resolve) => { + const child = spawn(command, ['--version'], { + stdio: 'ignore', + // npm, pnpm and yarn are .cmd shims on Windows, which only a shell runs. + shell: process.platform === 'win32', + }) + child.on('error', () => resolve(false)) + child.on('close', (code) => resolve(code === 0)) + }) +} + +/** + * Whether a Node version satisfies an `engines` range of the forms this + * package uses: `^x.y.z`, `>=x.y.z`, joined with `||`. Not a semver library, + * because one range is all it has to read. + */ +export function nodeSatisfies(version: string, range: string): boolean { + const parse = (value: string): number[] => + value + .replace(/^v/, '') + .split('.') + .map((part) => Number.parseInt(part, 10) || 0) + const compare = (a: number[], b: number[]): number => { + for (let i = 0; i < 3; i++) { + const diff = (a[i] ?? 0) - (b[i] ?? 0) + if (diff !== 0) return diff + } + return 0 + } + const have = parse(version) + return range.split('||').some((alternative) => { + const clause = alternative.trim() + if (clause.startsWith('^')) { + const want = parse(clause.slice(1)) + return have[0] === want[0] && compare(have, want) >= 0 + } + if (clause.startsWith('>=')) return compare(have, parse(clause.slice(2))) >= 0 + return compare(have, parse(clause)) === 0 + }) +} + +export function formatDoctor(result: DoctorResult, color = pc.isColorSupported): string { + const colors = pc.createColors(color) + const mark: Record = { + ok: colors.green('✓'), + warn: colors.yellow('!'), + fail: colors.red('✗'), + skip: colors.dim('–'), + } + const lines = result.checks.flatMap((item) => [ + `${mark[item.status]} ${item.label.padEnd(16)} ${item.detail}`, + ...(item.fix === undefined || item.status === 'ok' + ? [] + : [` ${' '.repeat(16)} ${colors.cyan('→')} ${colors.bold(item.fix)}`]), + ]) + const failed = result.checks.filter((item) => item.status === 'fail').length + lines.push( + '', + failed === 0 + ? 'Nothing stops an audit from running here.' + : `${failed} ${failed === 1 ? 'problem stops' : 'problems stop'} an audit from running here.`, + ) + return `${lines.join('\n')}\n` +} diff --git a/src/cli/index.ts b/src/cli/index.ts index cb4f37b..5f395e2 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -337,6 +337,18 @@ program process.stdout.write(await runDetectCommand(dir, flags)) }) +program + .command('doctor') + .description('Check everything this needs to work here, and say how to fix what is missing') + .argument('[dir]', 'the project to check (default: this directory)') + .action(async (dir: string | undefined) => { + const { formatDoctor, runDoctor } = await import('./doctor.ts') + const { resolve } = await import('node:path') + const result = await runDoctor(resolve(dir ?? '.')) + process.stdout.write(formatDoctor(result)) + process.exitCode = result.exitCode + }) + program .command('diff') .description('Compare two JSON reports: what a change made worse, and what it fixed') diff --git a/tests/cli/doctor.test.ts b/tests/cli/doctor.test.ts new file mode 100644 index 0000000..03d79f8 --- /dev/null +++ b/tests/cli/doctor.test.ts @@ -0,0 +1,144 @@ +import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { type DoctorCheck, nodeSatisfies, runDoctor } from '../../src/cli/doctor.ts' + +const STACKS = fileURLToPath(new URL('../fixtures/stacks/', import.meta.url)) +const dirs: string[] = [] + +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))) +}) + +async function stack(name: string, extra: Record = {}): Promise { + const dir = await mkdtemp(path.join(tmpdir(), 'eaa-kit-doctor-')) + dirs.push(dir) + await cp(path.join(STACKS, name), dir, { recursive: true }) + for (const [file, body] of Object.entries(extra)) { + await mkdir(path.join(dir, path.dirname(file)), { recursive: true }) + await writeFile(path.join(dir, file), body) + } + return dir +} + +/** Everything outside the project answered the healthy way, unless told otherwise. */ +const healthy = { + nodeVersion: '22.22.2', + onPath: async () => true, + chromium: async () => ({ status: 'ok' as const, detail: 'Chromium 140' }), +} + +const check = (checks: DoctorCheck[], label: string): DoctorCheck | undefined => + checks.find((item) => item.label === label) + +const config = await readFile( + fileURLToPath(new URL('../../examples/eaa.config.json', import.meta.url)), + 'utf8', +) + +describe('nodeSatisfies', () => { + it.each([ + ['22.22.2', true], + ['22.23.0', true], + ['22.18.0', false], + ['24.15.0', true], + ['24.1.0', false], + ['26.0.0', true], + ['27.3.1', true], + ['20.19.0', false], + ])('reads %s against the engines range as %s', (version, ok) => { + expect(nodeSatisfies(version, '^22.22.2 || ^24.15.0 || >=26.0.0')).toBe(ok) + }) +}) + +describe('eaa-kit doctor', () => { + it('passes a project that is ready, and exits 0', async () => { + const dir = await stack('astro', { + 'eaa.config.json': config, + '.github/workflows/accessibility.yml': 'uses: likeBloodMoon/eaa-kit@v0.10.0', + 'eaa-baseline.json': JSON.stringify({ schemaVersion: 2, entries: [] }), + }) + + const result = await runDoctor(dir, healthy) + + expect(result.checks.filter((item) => item.status !== 'ok')).toEqual([]) + expect(result.exitCode).toBe(0) + expect(result.checks.every((item) => item.status === 'ok')).toBe(true) + }) + + it('fails on a Node too old to run this, and says so first', async () => { + const result = await runDoctor(await stack('astro'), { ...healthy, nodeVersion: '20.11.0' }) + + expect(result.exitCode).toBe(2) + expect(result.checks[0]).toMatchObject({ label: 'Node.js', status: 'fail' }) + }) + + it("fails when the project's package manager is not installed", async () => { + const result = await runDoctor(await stack('astro'), { ...healthy, onPath: async () => false }) + + expect(check(result.checks, 'Package manager')).toMatchObject({ + status: 'fail', + fix: 'corepack enable', + }) + }) + + it('fails on a config that does not parse, and names the file', async () => { + const result = await runDoctor( + await stack('astro', { 'eaa.config.json': '{"site": 1' }), + healthy, + ) + + expect(check(result.checks, 'Config')).toMatchObject({ status: 'fail' }) + expect(check(result.checks, 'Config')?.detail).toContain('eaa.config.json') + expect(result.exitCode).toBe(2) + }) + + it('suggests init where there is no config, CI or baseline, without failing', async () => { + const result = await runDoctor(await stack('astro'), healthy) + + for (const label of ['Config', 'CI', 'Baseline']) { + expect(check(result.checks, label)).toMatchObject({ status: 'warn', fix: 'npx eaa-kit init' }) + } + expect(result.exitCode).toBe(0) + }) + + it('warns about baseline entries that have expired', async () => { + const baseline = JSON.stringify({ + schemaVersion: 2, + entries: [ + { page: 'index.html', ruleId: 'image-alt', fingerprint: 'img', expiresOn: '2020-01-01' }, + ], + }) + const result = await runDoctor(await stack('astro', { 'eaa-baseline.json': baseline }), healthy) + + expect(check(result.checks, 'Baseline')).toMatchObject({ status: 'warn' }) + expect(check(result.checks, 'Baseline')?.detail).toContain('1 entry has expired') + }) + + it('fails when there is nothing it could audit', async () => { + const result = await runDoctor(await stack('mkdocs'), healthy) + + expect(check(result.checks, 'Site')).toMatchObject({ status: 'fail', fix: 'mkdocs build' }) + }) + + it('finds a GitLab or Bitbucket pipeline as well as a GitHub workflow', async () => { + const gitlab = await runDoctor( + await stack('astro', { '.gitlab-ci.yml': 'script: npx eaa-kit audit' }), + healthy, + ) + + expect(check(gitlab.checks, 'CI')).toMatchObject({ status: 'ok', detail: '.gitlab-ci.yml' }) + }) + + it('treats browser mode as optional', async () => { + const result = await runDoctor(await stack('astro'), { + ...healthy, + chromium: async () => ({ status: 'skip' as const, detail: 'Playwright not installed' }), + }) + + expect(check(result.checks, 'Browser mode')).toMatchObject({ status: 'skip' }) + expect(result.exitCode).toBe(0) + }) +}) From 640a5c15f4d598e377fbd9556cc068b2bdea22e1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:38:31 +0000 Subject: [PATCH 6/8] ci: nightly soak over real projects, scaffolded from each framework's starter Next.js (on Linux, macOS and Windows), Astro, SvelteKit, Nuxt, Docusaurus and a Vite app (in a browser, since it is an app shell) are scaffolded from their official starters at pinned majors, installed, and audited with no directory, so detection decides. Exit 2 or no pages fails the job. Every scaffold command was checked to run without a terminal, and the audits were run against the same starters before this was committed. Progress messages name the real command a server is started with. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013BXnvSeRtgZoTXe4j753gM --- .github/workflows/soak.yml | 122 +++++++++++++++++++++++++++++++++++++ src/audit/project.ts | 13 +++- 2 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/soak.yml diff --git a/.github/workflows/soak.yml b/.github/workflows/soak.yml new file mode 100644 index 0000000..d4326df --- /dev/null +++ b/.github/workflows/soak.yml @@ -0,0 +1,122 @@ +name: Soak + +# Real projects, built from scratch, every night. +# +# The stack fixtures in tests/fixtures/stacks are file layouts, checked on every +# pull request, and they are only as true as the day they were copied. A +# framework's next release can move its output directory or change a manifest +# format without touching any of them. This scaffolds each framework with its +# own official starter, installs it, builds it the way `npx eaa-kit` would, and +# audits it, so a change on the framework's side shows up here within a day, +# and before somebody's first run meets it. +# +# Too slow and too dependent on registries to gate a pull request. A red run +# here is triaged into a fix or an issue, not left standing. + +on: + schedule: + - cron: '17 3 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + stack: + name: ${{ matrix.stack }} on ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + stack: [next, astro, sveltekit, nuxt, docusaurus, vite] + include: + # The one most people meet first, on the other two platforms too. + - os: windows-latest + stack: next + scaffold: npx --yes create-next-app@16 site --yes --use-npm --skip-install + - os: macos-latest + stack: next + scaffold: npx --yes create-next-app@16 site --yes --use-npm --skip-install + - stack: next + scaffold: npx --yes create-next-app@16 site --yes --use-npm --skip-install + - stack: astro + scaffold: npm create --yes astro@4 site -- --template minimal --no-install --no-git --yes + - stack: sveltekit + scaffold: npx --yes sv@0 create site --template minimal --types ts --no-add-ons --no-install + - stack: nuxt + scaffold: npx --yes nuxi@3 init site --template minimal --packageManager npm --gitInit false --no-install + - stack: docusaurus + scaffold: npx --yes create-docusaurus@3 site classic --javascript --package-manager npm --skip-install + - stack: vite + scaffold: npm create --yes vite@7 site -- --template react-ts --no-interactive + # A Vite app is an empty shell until its script runs. + browser: true + + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm build + + - name: Scaffold ${{ matrix.stack }} + working-directory: ${{ runner.temp }} + shell: bash + # No terminal, as in CI: every starter here was checked to run + # without asking anything. + run: ${{ matrix.scaffold }} < /dev/null + + - name: Install its dependencies + working-directory: ${{ runner.temp }}/site + shell: bash + run: | + npm install --no-audit --no-fund + if [ "${{ matrix.browser }}" = "true" ]; then + npm install --no-audit --no-fund -D playwright + npx playwright install ${{ runner.os == 'Linux' && '--with-deps' || '' }} chromium + fi + + - name: What detect makes of it + working-directory: ${{ runner.temp }}/site + shell: bash + run: node "$GITHUB_WORKSPACE/dist/cli/index.js" detect --json | tee detect.json + + # Exit 1 means barriers were found, which is a working run. Exit 2 means + # it could not audit the project at all, which is what this job exists + # to catch. + - name: Audit it with no directory, so detection decides + working-directory: ${{ runner.temp }}/site + shell: bash + run: | + set +e + node "$GITHUB_WORKSPACE/dist/cli/index.js" audit ${{ matrix.browser && '--browser' || '' }} \ + --format json --output report.json + code=$? + set -e + echo "exit code $code" + test "$code" -le 1 + node -e ' + const r = require("./report.json") + const pages = r.pages.length + console.log(`${pages} pages, discovery ${r.completeness.discovery}`) + if (pages === 0) process.exit(1) + ' + + - uses: actions/upload-artifact@v4 + if: always() + with: + name: soak-${{ matrix.stack }}-${{ matrix.os }} + path: | + ${{ runner.temp }}/site/detect.json + ${{ runner.temp }}/site/report.json + if-no-files-found: ignore diff --git a/src/audit/project.ts b/src/audit/project.ts index a6e034c..d180fae 100644 --- a/src/audit/project.ts +++ b/src/audit/project.ts @@ -450,10 +450,11 @@ export async function autoDetectSource( const serveScript = ['start', 'preview', 'serve'].find((name) => scripts[name] !== undefined) if (serveScript === undefined) return { steps } - step(`This site renders on a server; starting it with ${serveScript}`) + const shown = await scriptLabel(cwd, serveScript) + step(`This site renders on a server; starting it with ${shown}`) const server = await startServer(cwd, serveScript) if (server === undefined) { - step(`Could not start the site with ${serveScript}`) + step(`Could not start the site with ${shown}`) return { steps } } @@ -490,7 +491,7 @@ async function serveNext( options = { command: { bin: process.execPath, args: ['server.js'], cwd: standalone } } how = `node ${toPosix(path.join(dist, 'standalone', 'server.js'))}` } else if (scripts['start'] !== undefined) { - how = 'start' + how = await scriptLabel(cwd, 'start') } else { const bin = path.join(cwd, 'node_modules', 'next', 'dist', 'bin', 'next') if (!(await exists(bin))) return { steps } @@ -533,3 +534,9 @@ async function serveNext( steps, } } + +/** How a script is run here, for a message: `pnpm run start`. */ +async function scriptLabel(cwd: string, script: string): Promise { + const manager = await detectPackageManager(cwd) + return `${manager} ${manager === 'deno' ? 'task' : 'run'} ${script}` +} From 9186da1a3e24a725e50b7a04e109ac5ec66ef67f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 19:42:50 +0000 Subject: [PATCH 7/8] release: 0.10.0, it finds your site Docs for detection, detect and doctor, the builder table, the changelog, the version, the Action pins, the examples and the roadmap status. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013BXnvSeRtgZoTXe4j753gM --- .github/workflows/accessibility.yml | 2 +- CHANGELOG.md | 68 ++++++++++++++++++ README.md | 16 ++++- ROADMAP.md | 4 +- docs/audit.md | 104 +++++++++++++++++++++------- docs/integrations.md | 2 +- examples/report.html | 4 +- examples/report.json | 2 +- examples/report.sarif | 2 +- examples/statement.de.html | 2 +- package.json | 2 +- 11 files changed, 171 insertions(+), 37 deletions(-) diff --git a/.github/workflows/accessibility.yml b/.github/workflows/accessibility.yml index 159c5ff..4916ffe 100644 --- a/.github/workflows/accessibility.yml +++ b/.github/workflows/accessibility.yml @@ -28,7 +28,7 @@ jobs: node-version: 22 # In your own repository this becomes: - # uses: likeBloodMoon/eaa-kit@v0.9.1 + # uses: likeBloodMoon/eaa-kit@v0.10.0 # # An exact release tag. There is deliberately no moving v0 tag to follow: # this is a 0.x package, the flags and the JSON contract can still move diff --git a/CHANGELOG.md b/CHANGELOG.md index 17acb0c..6aac4e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,74 @@ move: the JSON report's `schemaVersion` and the baseline file's. Both are bumped a field is removed, renamed, or changes meaning — new fields may appear without one, so consumers must ignore what they do not recognise. +## 0.10.0 — 2026-09-26 + +It finds your site, whatever it is built with. + +### Added + +- **Next.js, in depth.** With no directory, a Next.js app that renders on a server is + built, started with `next start` on a free port, and crawled from the list of pages its + own build manifests give, so a page nothing links to is still audited. `basePath`, the + default locale's unprefixed paths and `trailingSlash` are respected; API routes, error + pages and metadata files are left out. A dynamic route with no prerendered pages is named + as not audited, with the reason. A standalone build is served by its `server.js` when its + static files are in place. `next dev` is never used. +- **Twenty more stacks**: + - apps: Qwik, SolidStart, TanStack Start, Analog, Vue CLI, Parcel, Rsbuild, Rspack and + Ember; + - documentation and static generators: Hexo, MkDocs, Sphinx, mdBook, Zola, Quarto and + Pelican, each with its output directory read out of its config; + - never started, and pointed at `--url`: Drupal, Statamic, and Ghost and Shopify themes. + + Hugo is also found through `config/_default/`. Zola is told from Hugo by what + `config.toml` says, and Hexo from Jekyll by its dependency. +- **Monorepos.** Run from the root of a pnpm, yarn or npm workspace, or a Turborepo, Nx or + Lerna repository, `eaa-kit` finds the packages that are sites. One site is audited as + though the command ran inside it. Several are listed with the command for each. `init` + asks which site to set up and writes the config there. +- **Package managers.** Corepack's `packageManager` field is read first, then the lockfile: + Bun's text `bun.lock`, `deno.lock` and `package-lock.json` as well as pnpm's, yarn's and + Bun's binary one. The lockfile is looked for up to the repository root. Deno runs scripts + as tasks, and `init` writes a Deno or Bun setup step into the workflow. +- **`eaa-kit detect [dir]`** says what an audit here would do, and the evidence for each + part: the framework and what identified it, the package manager and why, the build + output or what would be built or started. It builds, starts and writes nothing. + `--json` prints the same as data. +- **`eaa-kit doctor [dir]`** checks, on one screen, everything the tool needs in a + project. Each problem is followed by the command that fixes it: + - the Node.js version; + - the package manager; + - the site; + - the config; + - a GitHub, GitLab or Bitbucket pipeline; + - the baseline and any expired entries; + - Playwright and Chromium. + + It exits 2 only for what stops an audit from running. +- **A recorded answer for every stack.** `tests/fixtures/stacks` holds one project layout + per kind of stack, with the answer `detect` must give for it, and the suite checks each + one. A nightly job scaffolds Next.js, Astro, SvelteKit, Nuxt, Docusaurus and a Vite app + from their official starters, installs them and audits them with no directory. + +### Changed + +- **A single-page app's empty shell is no longer audited as a clean page.** A page with + nothing a visitor could perceive before a script runs, such as a Vite build's + `
`, is set aside and listed in `completeness.unreachable`. A build + that holds only a shell stops with exit 2 and the command to audit it with `--browser`, + which runs the script as before. +- **`storybook-static/` is never audited** as part of the site. +- **Servers are started on a free port,** offered through `PORT`. The address a server + prints is read through colour codes and `0.0.0.0`. The Angular, Gatsby, Hugo and Jekyll + default ports are also tried. + +### Report format + +- `completeness.discovery` can now be `"manifest"`: the pages came from the project's own + build, a Next.js build's manifests. `schemaVersion` stays 2, since no field was removed + or renamed. A consumer that switches on `discovery` should expect the new value. + ## 0.9.1 — 2026-09-26 ### Fixed diff --git a/README.md b/README.md index 4903b5d..1542de1 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,14 @@ the statute and supervisory body of **fifteen countries**: Austria, Belgium, Cze Denmark, Finland, France, Germany, Ireland, Italy, the Netherlands, Poland, Portugal, Spain, Sweden and Switzerland, each in its own language as well as English. +0.10.0 finds your site, whatever it is built with. A Next.js app that renders on a server +is built, started and audited page by page from its own build manifests, including pages +nothing links to. Forty-one stacks are recognised, from Qwik and TanStack Start to +MkDocs, Sphinx and Zola, and a monorepo's sites are found from its root. The package +manager comes from the project itself, Bun and Deno included. A single-page app's empty +shell is named as not audited instead of passing. `eaa-kit detect` says what the tool +makes of a project and why, and `eaa-kit doctor` checks everything it needs on one screen. + 0.9.0 needs no setup. `npx eaa-kit` on its own finds the site, audits it, writes an HTML report and says which command comes next. `init` fills itself in from what the built site states, and also writes a baseline and a GitHub Actions workflow tailored to the project. @@ -42,6 +50,8 @@ npx eaa-kit init # the config, a baseline and a CI workflow npx eaa-kit statement # accessibility statement, in one of fifteen countries npx eaa-kit countries # which ones, in which languages, under which law npx eaa-kit checklist # the manual review no engine can do for you +npx eaa-kit detect # what it makes of this project, and why +npx eaa-kit doctor # everything it needs here, checked on one screen ``` > **Not legal advice.** eaa-kit reports what an automated engine can and cannot determine @@ -71,8 +81,10 @@ answer. eaa-kit audit ./dist --fail-on serious ``` -Sites that render on a server and never write HTML to disk — Next.js without a static -export, Nuxt, SvelteKit, anything behind a CMS — are audited running instead: +Sites that render on a server and never write HTML to disk, such as Next.js without a +static export, Nuxt or SvelteKit, are built and started by `eaa-kit audit` with no +directory, and crawled while they run. Anything behind a CMS is never started uninvited; +start it yourself and audit it running: ```bash eaa-kit audit --url http://localhost:3000 diff --git a/ROADMAP.md b/ROADMAP.md index 18ea47a..4ba51d1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,7 +26,9 @@ having to learn the tool first. These releases get there: the wrong version. - **0.10.0 — it finds your site.** Stack detection for what people actually build with (Next.js properly first), monorepos and package managers, and `eaa-kit detect` and - `eaa-kit doctor` to explain what it found. + `eaa-kit doctor` to explain what it found. *Done:* forty-one stacks, Next.js served and + crawled from its build manifests, single-page-app shells named instead of passed, a + recorded `detect` answer per stack fixture, and a nightly soak over six real starters. - **0.11.0 — it fits your workflow.** GitLab and Bitbucket CI from `init`, a Markdown summary for job summaries and PR comments, and an HTML report that prints, speaks the site's language and shows what changed since last time. diff --git a/docs/audit.md b/docs/audit.md index 8035b82..073fbeb 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -41,23 +41,45 @@ left untouched. Exit codes are those of `audit`. ## With no arguments -`eaa-kit audit` on its own works out what this project needs, in three steps: - -1. **A build that already exists.** The first of `dist/`, `out/`, `build/`, `_site/`, - `.output/public/` or `public/` that holds HTML. Holding HTML is the test, not merely - existing — `.next/` exists after any Next.js build and holds no browsable page, and - `public/` exists in most projects and holds assets. +`eaa-kit audit` on its own works out what this project needs, in three steps. +`eaa-kit detect` shows what it would decide, and why, without doing any of it. + +1. **A build that already exists.** The framework's own output directory first (`out/` + for a Next.js export, `_site/` for Eleventy, `site/` for MkDocs, whatever the config + file names), then `dist/`, `out/`, `build/`, `_site/`, `public/` or `.output/public/`, + whichever first holds HTML. Holding HTML is the test, not merely existing — `.next/` + exists after any Next.js build and holds no browsable page, and `public/` exists in + most projects and holds assets. `storybook-static/` is never counted: a component + catalogue is not the site. 2. **The project's own build.** Nothing built yet, so it runs the `build` script rather - than telling you to go and do it and come back. The package manager comes from the - lockfile. + than telling you to go and do it and come back. 3. **The project's server.** Built and still no HTML anywhere means the site renders on a - server — a Next.js app with an API route, middleware or ISR, and anything else that - cannot be exported. It starts `start`, `preview` or `serve`, crawls what that serves, - and stops it again afterwards. + server. It starts `start`, `preview` or `serve` on a free port, crawls what that serves, + and stops it again afterwards. For **Next.js** it reads the build's own manifests for + the list of pages, so a page nothing links to is still audited, and a dynamic route + with no prerendered pages is named as not audited rather than silently missed. It + respects `basePath` and i18n locales, serves a standalone build with its own + `server.js` when the static files are beside it, and never uses `next dev`. A folder with no `package.json` and HTML files at its top level is a site written by hand, and is audited where it stands. +**The package manager** is the one the project states: corepack's `packageManager` field +first, then the lockfile — `pnpm-lock.yaml`, `yarn.lock`, `bun.lock` or `bun.lockb`, +`deno.lock`, `package-lock.json` — looked for up to the repository root, so an app inside +a monorepo uses the workspace's. + +**In a monorepo** (pnpm, yarn or npm workspaces, Turborepo, Nx, Lerna), run from the root, +it looks for the packages that are sites. One site is audited as though the command had +been run inside it. Several are listed with the command for each, since auditing them +together would mix their pages into one report; `init` asks which one to set up. + +**A single-page app's shell** — an `index.html` holding an empty `
` and a +script — has nothing in it until the script runs, and the browserless engine does not run +scripts. Rather than audit the empty div and report the site clean, such a page is set +aside and [named as not audited](reports.md#completeness); a build that is only a shell +stops with the command to audit it in a browser, `--browser`, which runs the script. + Naming a directory or passing `--url` skips all of it, and `--no-build` stops it running anything, leaving step 1 only. @@ -65,6 +87,30 @@ Steps two and three run your project's own scripts. That is what a build-time to the Astro integration already audits from inside a build — but it is announced as it happens, and `--no-build` turns it off. +### `eaa-kit detect` + +``` +$ npx eaa-kit detect +Framework Next.js (package.json depends on next) +Package manager pnpm (found pnpm-lock.yaml) +Next.js pages 5 listed by the build (not listed, rendered on request: /user/[id]) + +An audit would run pnpm run build, start the site with pnpm run start, and crawl the pages its build lists. + → npx eaa-kit +``` + +Nothing is built, started or written. `--json` prints the same as data, which is also the +thing to paste into an issue when the answer is wrong. + +### `eaa-kit doctor` + +Everything this tool needs in a project, on one screen, each problem followed by the +command that fixes it: the Node.js version, whether the project's package manager is +installed, what detection makes of the site, the config and whether it parses, a GitHub, +GitLab or Bitbucket pipeline that runs eaa-kit, the baseline and any expired entries in +it, and Playwright with Chromium for `--browser`. It exits 2 only for what stops an audit +from running; a missing config, pipeline or baseline is advice. + ## Auditing a running site ```bash @@ -220,22 +266,28 @@ emit; it is not special. | Builder | Directory | Note | | --- | --- | --- | -| Astro, Vite, SvelteKit (static), Nuxt (generate) | `dist/`, `.output/public/` | ready as built | -| Eleventy, Hugo, Jekyll | `_site/`, `public/` | ready as built | -| Create React App | `build/` | one `index.html`; a client-rendered app has little in it | -| Next.js | `out/` | **only with `output: 'export'`** — see below | +| Astro, Vite, Vue CLI, Parcel, Rsbuild, Rspack, Ember, Qwik | `dist/` | ready as built; an app shell needs `--browser` | +| SvelteKit (static), Nuxt (generate), SolidStart, TanStack Start | `build/`, `.output/public/` | ready as built | +| Analog | `dist/analog/public/` | ready as built | +| Angular 17+ | `dist//browser/` | ready as built | +| Eleventy, Jekyll, Quarto | `_site/` | ready as built | +| Hugo, Hexo, Zola, Gatsby | `public/` | ready as built | +| Docusaurus, Create React App | `build/` | ready as built | +| VitePress | `.vitepress/dist/` | ready as built | +| MkDocs | `site/` | ready as built | +| Sphinx | `_build/html/` | ready as built | +| mdBook | `book/` | ready as built | +| Pelican | `output/` | ready as built | +| Next.js | `out/` | **only with `output: 'export'`**; otherwise it is served, see below | + +A directory set in the framework's config (`outDir`, `distDir`, `site_dir`, `output-dir`, +`build-dir`, `public_dir`, `OUTPUT_PATH`, `outputDir`) is read, never executed, and tried +first. **Next.js does not write HTML to `dist/`.** A default `next build` produces `.next/`, which -holds the server bundle rather than a browsable site. To audit the files, set -`output: 'export'` in `next.config.js`, run `next build`, and point eaa-kit at `out/`. - -That works only for a site with no server-side rendering, API routes, middleware or ISR. -If yours has any of those, do not fight the export — audit it running instead: - -```bash -npm run build && npx next start -eaa-kit audit --url http://localhost:3000 -``` +holds the server bundle rather than a browsable site. `eaa-kit audit` with no directory +handles that itself: it builds, starts `next start`, and crawls every page the build's +manifests list. With `output: 'export'`, the files in `out/` are audited instead. A run that reports `No HTML files found` means the directory exists but holds no `.html` — almost always the wrong directory rather than a clean site. @@ -780,7 +832,7 @@ a recorded result is a claim by a person, which is a different kind of thing. A CMS writes no browsable HTML to disk: every page is rendered per request, so there is no build directory to point at and never was one. `eaa-kit audit` recognises WordPress, TYPO3, -Craft, Laravel, Symfony, Rails and Django, and rather than reporting an empty `./dist` it +Drupal, Craft, Statamic, Laravel, Symfony, Rails, Django, and Ghost and Shopify themes, and rather than reporting an empty `./dist` it says what the project is and how to audit it: ``` diff --git a/docs/integrations.md b/docs/integrations.md index 2da4c48..b6ce3ee 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -250,7 +250,7 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 22 - - uses: likeBloodMoon/eaa-kit@v0.9.1 + - uses: likeBloodMoon/eaa-kit@v0.10.0 with: install-command: npm ci build-command: npm run build diff --git a/examples/report.html b/examples/report.html index 70db090..e9d7713 100644 --- a/examples/report.html +++ b/examples/report.html @@ -3,7 +3,7 @@ - + Accessibility audit · tests/fixtures/site