Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 9 additions & 7 deletions src/audit/crawl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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[] = []
Expand Down
59 changes: 59 additions & 0 deletions src/audit/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,58 @@ export async function readPackageJson(cwd: string): Promise<PackageJson | undefi
}
}

export interface DenoConfig {
tasks?: Record<string, string>
}

/**
* `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<DenoConfig | undefined> {
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']
Expand Down Expand Up @@ -98,6 +150,13 @@ export async function findPackageManager(cwd: string): Promise<PackageManagerFin
return { manager, evidence: `found ${toPosix(where)}` }
}
}
// A Deno project before its first lockfile still has its config.
for (const file of ['deno.json', 'deno.jsonc']) {
if (await exists(path.join(dir, file))) {
const where = path.relative(cwd, path.join(dir, file)) || file
return { manager: 'deno', evidence: `found ${toPosix(where)}` }
}
}
const parent = path.dirname(dir)
if (parent === dir || (await exists(path.join(dir, '.git')))) break
dir = parent
Expand Down
21 changes: 17 additions & 4 deletions src/audit/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,22 @@ export async function findWorkspaceSites(root: string): Promise<WorkspaceScan |
const listed = await workspaceGlobs(root)
if (listed === undefined) return undefined

// `!apps/legacy` excludes a package from the workspace, in pnpm's list and in
// package.json's alike, and an excluded package is not one of its sites.
const trim = (pattern: string): string => 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[] = []
Expand Down Expand Up @@ -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<string | undefined> {
Expand Down
23 changes: 19 additions & 4 deletions src/cli/setup.ts
Original file line number Diff line number Diff line change
@@ -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'

/**
Expand Down Expand Up @@ -62,7 +62,15 @@ export interface WorkflowInputs {
*/
export async function workflowFor(inputs: WorkflowInputs): Promise<string> {
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 =
Expand Down Expand Up @@ -90,10 +98,17 @@ export async function workflowFor(inputs: WorkflowInputs): Promise<string> {
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

Expand Down
27 changes: 27 additions & 0 deletions tests/audit/crawl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<urlset>${['a', 'b', 'c']
.map((name) => `<url><loc>http://localhost:3000/${name}</loc></url>`)
.join('')}</urlset>`,
type: 'application/xml',
},
'/': page('<h1>Home</h1>'),
'/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('<h1>Home</h1>') })

Expand Down
21 changes: 21 additions & 0 deletions tests/audit/workspaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: ['**'] }),
Expand Down
40 changes: 40 additions & 0 deletions tests/cli/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })

Expand Down
Loading