diff --git a/CHANGELOG.md b/CHANGELOG.md index ebe8858..110dba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,24 @@ 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. +## Unreleased + +### Fixed + +- **A Deno workflow from `init` no longer stops at its first step.** It was given + `deno install --frozen` as the install command, which is not a dependency install on + every Deno version. Deno projects now get no install step, since `deno task` fetches what + the build needs. +- **A Deno project with no `package.json` gets a workflow that sets up Deno and builds.** + `deno.json` and `deno.jsonc` are read for the `build` task, and a Deno config marks the + project as Deno even before it has a lockfile. +- **A Next.js build's own page list is crawled before the sitemap.** With a sitemap larger + than `--max-pages`, the limit used to run out on sitemap entries, leaving pages only the + build listed unrequested. +- **Packages a workspace excludes (`!apps/legacy`) are no longer offered as its sites.** + pnpm's exclusions were dropped. An excluded site could turn a one-site monorepo audit + into a prompt to choose between sites. + ## 0.10.0 — 2026-09-26 It finds your site, whatever it is built with. diff --git a/src/audit/crawl.ts b/src/audit/crawl.ts index 5c2727b..916ba0d 100644 --- a/src/audit/crawl.ts +++ b/src/audit/crawl.ts @@ -460,13 +460,10 @@ export async function crawlSite(entry: URL, options: CrawlOptions = {}): Promise maxBodyBytes, options.headers, ) - const listed = sitemap === undefined ? [] : urlsFromSitemap(sitemap, entry) - if (listed.length > 0) { - 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. + // The build's own list goes first, ahead of the sitemap, both as the account + // of how pages were found and in the queue: it is what was built, where a + // sitemap is what somebody chose to list, and a page limit that ran out on + // sitemap entries would leave built pages unrequested. const seeded = (options.seeds ?? []).flatMap((raw) => { try { const url = new URL(raw, entry) @@ -479,6 +476,11 @@ export async function crawlSite(entry: URL, options: CrawlOptions = {}): Promise discovery = 'manifest' for (const url of seeded) enqueue(url, 0) } + const listed = sitemap === undefined ? [] : urlsFromSitemap(sitemap, entry) + if (listed.length > 0) { + if (discovery !== 'manifest') discovery = 'sitemap' + for (const url of listed) enqueue(url, 0) + } enqueue(entry, 0) const pages: CollectedPage[] = [] diff --git a/src/audit/project.ts b/src/audit/project.ts index 4f09bb5..e26da29 100644 --- a/src/audit/project.ts +++ b/src/audit/project.ts @@ -51,6 +51,58 @@ export async function readPackageJson(cwd: string): Promise +} + +/** + * `deno.json` or `deno.jsonc`, the config of a Deno project, which may have no + * package.json at all. Undefined when there is neither, or it does not parse. + */ +export async function readDenoConfig(cwd: string): Promise { + for (const name of ['deno.json', 'deno.jsonc']) { + let source: string + try { + source = await readFile(path.join(cwd, name), 'utf8') + } catch { + continue + } + try { + return JSON.parse(withoutComments(source)) as DenoConfig + } catch { + return undefined + } + } + return undefined +} + +/** + * JSONC to JSON: comments out, strings untouched. A task is often a command + * with a URL in it, so `//` inside a string is not a comment. + */ +function withoutComments(source: string): string { + let out = '' + for (let i = 0; i < source.length; i++) { + const char = source[i] + if (char === '"') { + const start = i + for (i++; i < source.length && source[i] !== '"'; i++) if (source[i] === '\\') i++ + out += source.slice(start, i + 1) + } else if (char === '/' && source[i + 1] === '/') { + while (i < source.length && source[i] !== '\n') i++ + out += '\n' + } else if (char === '/' && source[i + 1] === '*') { + i = source.indexOf('*/', i + 2) + if (i === -1) break + i++ + } else { + out += char + } + } + // Trailing commas are allowed in JSONC and not in JSON. + return out.replace(/,(\s*[}\]])/g, '$1') +} + export type PackageManager = 'pnpm' | 'yarn' | 'bun' | 'deno' | 'npm' const MANAGERS: readonly PackageManager[] = ['pnpm', 'yarn', 'bun', 'deno', 'npm'] @@ -98,6 +150,13 @@ export async function findPackageManager(cwd: string): Promise pattern.replace(/^!/, '').replace(/\/$/, '') + const included = listed.globs.filter((pattern) => !pattern.startsWith('!')) + const excluded = listed.globs.filter((pattern) => pattern.startsWith('!')) const manifests = await glob( - listed.globs.map((pattern) => `${pattern.replace(/\/$/, '')}/package.json`), - { cwd: root, ignore: ['**/node_modules/**'], onlyFiles: true, dot: false }, + included.map((pattern) => `${trim(pattern)}/package.json`), + { + cwd: root, + ignore: [ + '**/node_modules/**', + ...excluded.flatMap((pattern) => [`${trim(pattern)}/package.json`, `${trim(pattern)}/**`]), + ], + onlyFiles: true, + dot: false, + }, ) const sites: WorkspaceSite[] = [] @@ -91,13 +104,13 @@ async function workspaceGlobs( /** * 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. + * Negated entries are kept: they are exclusions, applied when scanning. */ 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('!')) + .filter((entry) => entry !== '') } async function readText(file: string): Promise { diff --git a/src/cli/setup.ts b/src/cli/setup.ts index 87a7eeb..59970df 100644 --- a/src/cli/setup.ts +++ b/src/cli/setup.ts @@ -1,6 +1,6 @@ import { readFile, stat } from 'node:fs/promises' import path from 'node:path' -import { detectPackageManager, readPackageJson } from '../audit/project.ts' +import { detectPackageManager, readDenoConfig, readPackageJson } from '../audit/project.ts' import { TOOL_VERSION } from '../version.ts' /** @@ -62,7 +62,15 @@ export interface WorkflowInputs { */ export async function workflowFor(inputs: WorkflowInputs): Promise { const pkg = await readPackageJson(inputs.cwd) - const manager = pkg === undefined ? undefined : await detectPackageManager(inputs.cwd) + const deno = await readDenoConfig(inputs.cwd) + // A Deno project may have no package.json at all; anything else without one + // is a site written by hand, with nothing to install or build. + const manager = + pkg !== undefined + ? await detectPackageManager(inputs.cwd) + : deno !== undefined + ? 'deno' + : undefined const branch = (await currentBranch(inputs.root)) ?? 'main' const workingDirectory = toPosix(path.relative(inputs.root, inputs.cwd)) const directory = @@ -90,10 +98,17 @@ 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', + // None: `deno task` fetches what the build needs as it runs, and + // `deno install` is not a dependency install on every Deno version. + deno: undefined, }[manager] + // Deno runs a package.json script as a task too; npm and the rest cannot run + // a Deno task. + const hasBuild = + pkg?.scripts?.['build'] !== undefined || + (manager === 'deno' && deno?.tasks?.['build'] !== undefined) const build = - manager !== undefined && pkg?.scripts?.['build'] !== undefined + manager !== undefined && hasBuild ? `${manager} ${manager === 'deno' ? 'task' : 'run'} build` : undefined diff --git a/tests/audit/crawl.test.ts b/tests/audit/crawl.test.ts index 36af6e3..fd7a252 100644 --- a/tests/audit/crawl.test.ts +++ b/tests/audit/crawl.test.ts @@ -206,6 +206,33 @@ describe('crawlSite', () => { expect(result.pages.map((p) => p.relativePath)).toEqual(['/', 'orphan']) }) + it("crawls the build's own list before a sitemap, so a page limit keeps it", async () => { + // A sitemap larger than --max-pages would otherwise use the whole budget, + // leaving pages only the build lists unrequested under a report that says + // the build's list was followed. + const { fetchImpl } = site({ + '/sitemap.xml': { + body: `${['a', 'b', 'c'] + .map((name) => `http://localhost:3000/${name}`) + .join('')}`, + type: 'application/xml', + }, + '/': page('

Home

'), + '/a': page('a'), + '/b': page('b'), + '/c': page('c'), + '/only-in-build': page('built'), + }) + + const result = await crawlSite(entry, { + fetchImpl, + maxPages: 2, + seeds: ['http://localhost:3000/', 'http://localhost:3000/only-in-build'], + }) + + expect(result.pages.map((p) => p.relativePath)).toEqual(['/', 'only-in-build']) + }) + it('ignores a seed on another origin', async () => { const { fetchImpl, requests } = site({ '/': page('

Home

') }) diff --git a/tests/audit/workspaces.test.ts b/tests/audit/workspaces.test.ts index 738d333..70e40fa 100644 --- a/tests/audit/workspaces.test.ts +++ b/tests/audit/workspaces.test.ts @@ -81,6 +81,27 @@ describe('findWorkspaceSites', () => { expect((await findWorkspaceSites(dir))?.sites.map((site) => site.dir)).toEqual(['packages/app']) }) + it('leaves out the packages the workspace excludes', async () => { + const dir = await repo({ + 'package.json': '{}', + 'pnpm-workspace.yaml': "packages:\n - 'apps/*'\n - '!apps/legacy'\n", + 'apps/web/package.json': app({ astro: '5.0.0' }), + 'apps/legacy/package.json': app({ gatsby: '5.0.0' }), + }) + + expect((await findWorkspaceSites(dir))?.sites.map((site) => site.dir)).toEqual(['apps/web']) + }) + + it('leaves out an excluded package listed in package.json workspaces too', async () => { + const dir = await repo({ + 'package.json': JSON.stringify({ workspaces: ['apps/*', '!apps/old-*'] }), + 'apps/web/package.json': app({ astro: '5.0.0' }), + 'apps/old-site/package.json': app({ gatsby: '5.0.0' }), + }) + + expect((await findWorkspaceSites(dir))?.sites.map((site) => site.dir)).toEqual(['apps/web']) + }) + it('never looks inside node_modules', async () => { const dir = await repo({ 'package.json': JSON.stringify({ workspaces: ['**'] }), diff --git a/tests/cli/setup.test.ts b/tests/cli/setup.test.ts index b59ccb5..cc2a8de 100644 --- a/tests/cli/setup.test.ts +++ b/tests/cli/setup.test.ts @@ -88,6 +88,46 @@ describe('workflowFor', () => { expect(yaml).toContain('fail-on: critical') }) + it('sets up Deno for a Deno project with no package.json, and runs its build task', async () => { + // A native Deno site has deno.json and deno.lock and nothing for npm. + const root = await repo({ + '.git/HEAD': 'ref: refs/heads/main\n', + 'deno.json': JSON.stringify({ tasks: { build: 'deno run -A build.ts' } }), + 'deno.lock': '{}', + }) + + const yaml = await workflowFor({ cwd: root, root, failOn: 'serious' }) + + expect(yaml).toContain('uses: denoland/setup-deno@v2') + expect(yaml).toContain('build-command: deno task build') + }) + + it('writes no install step for Deno, whose tasks fetch their own dependencies', async () => { + // `deno install` with a flag and no module is not a dependency install on + // every Deno version, and a failing first step stops the whole workflow. + const root = await repo({ + '.git/HEAD': 'ref: refs/heads/main\n', + 'package.json': JSON.stringify({ scripts: { build: 'vite build' } }), + 'deno.lock': '{}', + }) + + const yaml = await workflowFor({ cwd: root, root, failOn: 'serious' }) + + expect(yaml).not.toContain('install-command') + expect(yaml).toContain('build-command: deno task build') + }) + + it('reads deno.jsonc, comments and all', async () => { + const root = await repo({ + '.git/HEAD': 'ref: refs/heads/main\n', + 'deno.jsonc': '{\n // how the site is built\n "tasks": { "build": "lume" }\n}\n', + }) + + expect(await workflowFor({ cwd: root, root, failOn: 'serious' })).toContain( + 'build-command: deno task build', + ) + }) + it('installs and builds nothing for a site written by hand', async () => { const root = await repo({ '.git/HEAD': 'ref: refs/heads/main\n', 'index.html': PAGE })