lazy-pulse 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 (2026-10-14)
4
+
5
+ First release, by Louis Wright.
6
+
7
+ - LCP candidate detection (fetchpriority="high", preloaded image, else first content image; icons, nav and tracking pixels skipped).
8
+ - LCP checks: `loading="lazy"`, JavaScript lazy-loading (`data-src`), `fetchpriority="low"`, preload/lazy conflicts.
9
+ - Below-the-fold images and iframes without `loading="lazy"`, with a configurable fold (`foldImages`).
10
+ - Lazy images without width/height; JS lazy-load libraries where native lazy loading would do.
11
+ - Text/JSON/SARIF/Markdown output, `--fail-on`, exit codes 0/1/2, allowlist with expiry, severity overrides.
12
+ - Composite GitHub Action with job summary.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Louis Wright
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,182 @@
1
- # Temporary Holding Version
1
+ # lazy-pulse
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > Lazy-load what is below the fold, never the LCP image: a static lazy-loading audit for your built site.
4
+
5
+ Native `loading="lazy"` is one attribute, and it is easy to get wrong in both directions: galleries and embeds below the fold download eagerly, while a template applies `loading="lazy"` (or a `data-src` library) to the hero image and pushes back Largest Contentful Paint. `lazy-pulse` reads your built HTML, picks the likely LCP image on each page, and checks every image and iframe against where it sits in the document.
6
+
7
+ CLI, library and GitHub Action. Node.js 18+, **zero runtime dependencies**. Works on any static build output (Astro, Next.js `output: 'export'`, Eleventy, Hugo, Jekyll, Docusaurus, VitePress, plain HTML) and runs offline: point it at the folder your build writes. By Louis Wright.
8
+
9
+ ## Features
10
+
11
+ - **LCP candidate:** the image with `fetchpriority="high"`, else a preloaded image, else the first content image (icons, logos in nav/header, tracking pixels and `<noscript>` are skipped).
12
+ - **LCP mistakes:** `loading="lazy"` (high), lazy-loaded by JavaScript via `data-src` (high), `fetchpriority="low"` (medium), a `<link rel=preload as=image>` for an image that is lazy (high), and no `fetchpriority` at all (info).
13
+ - **Below the fold:** images and iframes without `loading="lazy"` after the first `foldImages` content images (default 1 after the LCP image); iframes (video, maps, widgets) are always expected to be lazy unless allowlisted.
14
+ - **Above the fold:** content images near the top that are lazy (low).
15
+ - **Hygiene:** lazy images without width/height or aspect-ratio (layout shift), and JS lazy-load libraries where native lazy loading would do.
16
+
17
+ ### Scope
18
+
19
+ A static heuristic: there is no layout engine, so "the fold" is document order. Tune `foldImages` for your templates and allowlist the exceptions.
20
+
21
+ ## Rules
22
+
23
+ | Rule | Default severity | What it means |
24
+ |---|---|---|
25
+ | `lazy-lcp-lazy` | high | LCP candidate image has loading="lazy" (delays the largest paint) |
26
+ | `lazy-lcp-js` | high | LCP candidate is lazy-loaded by JavaScript (data-src, no real src) |
27
+ | `lazy-preload-conflict` | high | Image is preloaded but its <img> has loading="lazy" |
28
+ | `lazy-lcp-low-priority` | medium | LCP candidate has fetchpriority="low" |
29
+ | `lazy-img-missing` | medium | Below-the-fold image without loading="lazy" |
30
+ | `lazy-iframe-missing` | medium | Below-the-fold iframe without loading="lazy" |
31
+ | `lazy-above-fold-lazy` | low | Image near the top of the page is lazy-loaded |
32
+ | `lazy-no-dimensions` | low | Lazy-loaded image without width/height (layout shift when it loads) |
33
+ | `lazy-js-library` | low | JavaScript lazy-loading (data-src) where native loading="lazy" would do |
34
+ | `lazy-lcp-no-priority` | info | LCP candidate has no fetchpriority="high" |
35
+ | `allowlist-expired` | medium | An allowlist entry is past its `expires` date |
36
+ | `allowlist-unused` | info | An allowlist entry matched nothing |
37
+
38
+ ## Install and run
39
+
40
+ ```bash
41
+ npx lazy-pulse dist
42
+ npx lazy-pulse _site --fold-images 2
43
+ npx lazy-pulse public --fail-on medium -f sarif > lazy.sarif
44
+ ```
45
+
46
+ Pages are mapped to URLs the way static hosts serve them: `index.html` → `/`, `blog/index.html` → `/blog/`, `about.html` → `/about.html` (or `/about` with `--clean-urls`). Pass `--base-url` with your public URL, or let it be inferred from canonical / `og:url` tags. Meta-refresh redirect stubs and `404.html` are skipped. `X-Robots-Tag` and `Link` headers are read from Netlify/Cloudflare `_headers` and `vercel.json`.
47
+
48
+ ### Sample output
49
+
50
+ ```text
51
+ lazy-pulse — test/fixtures/bad
52
+ 1 page(s), 7 image(s)/iframe(s), 4 lazy-loaded
53
+
54
+ HIGH index.html:6 LCP candidate hero.jpg on / has loading="lazy" [lazy-lcp-lazy]
55
+ HIGH index.html:6 /img/hero.jpg is preloaded on / but its <img> is loading="lazy" [lazy-preload-conflict]
56
+ MEDIUM index.html:9 Below-the-fold image a.jpg on / has no loading="lazy" [lazy-img-missing]
57
+ MEDIUM index.html:12 Below-the-fold image d.jpg on / has no loading="lazy" [lazy-img-missing]
58
+ MEDIUM index.html:13 iframe https://www.google.com/maps/embed?pb=1 on / is not lazy-loaded [lazy-iframe-missing]
59
+ LOW index.html:7 second.jpg is among the first 1 content images on / but has loading="lazy" [lazy-above-fold-lazy]
60
+ LOW index.html:10 Lazy image team.jpg on / has no width/height [lazy-no-dimensions]
61
+ LOW index.html:11 c.jpg on / is lazy-loaded with data-src [lazy-js-library]
62
+
63
+ 2 high, 3 medium, 3 low
64
+ ✔ Passing (fail-on: none).
65
+ ```
66
+
67
+ ## Options
68
+
69
+ ```text
70
+ lazy-pulse 1.0.0 — lazy-loading audit: below-the-fold media without loading=lazy, LCP images wrongly lazy-loaded
71
+
72
+ Usage: lazy-pulse [site-dir] [options]
73
+
74
+ Site
75
+ --base-url <url> Public URL of the site, e.g. https://example.com (default: inferred from canonical/og:url)
76
+ --include <globs> Only check these pages (file or URL path globs, comma-separated)
77
+ --exclude <globs> Skip these pages
78
+ --clean-urls Pages are served without .html (about.html -> /about)
79
+ Lazy loading
80
+ --fold-images <n> Content images treated as above the fold, after the LCP candidate (default 1)
81
+
82
+ Common
83
+ -c, --config <file> JSON config (default: .lazy-pulse.json or "lazyPulse" in package.json)
84
+ -f, --format <fmt> text (default) | json | sarif | markdown
85
+ --json <file> Also write a JSON report
86
+ --sarif <file> Also write SARIF 2.1.0
87
+ --markdown <file> Also write Markdown (e.g. for a job summary)
88
+ --fail-on <sev> critical | high | medium | low | info | none (default: high)
89
+ --list-rules Print rules and default severities
90
+ --no-fixes Hide fix suggestions in text output
91
+ --no-color Disable colours
92
+ -v, --version / -h, --help
93
+
94
+ Exit codes: 0 = passed, 1 = findings at or above --fail-on, 2 = usage, config or read error.
95
+ ```
96
+
97
+ ## Configuration and allowlist
98
+
99
+ Options can live in `.lazy-pulse.json` (or a `"lazyPulse"` key in `package.json`, or `--config <file>`):
100
+
101
+ ```json
102
+ {
103
+ "failOn": "medium",
104
+ "severity": {
105
+ "lazy-lcp-lazy": "low"
106
+ },
107
+ "allowlist": [
108
+ {
109
+ "rule": "lazy-iframe-missing",
110
+ "subject": "/contact/ *",
111
+ "reason": "Map is the main content of the contact page",
112
+ "expires": "2027-03-31"
113
+ }
114
+ ],
115
+ "foldImages": 2
116
+ }
117
+ ```
118
+
119
+ - **`severity`** changes a rule's severity, or turns it `"off"`.
120
+ - **`allowlist`** entries suppress matching findings. Each needs a `reason` and matches on any combination of `rule`, `file`, `subject` (globs with `*`) and `match` (a substring of the message). Add `expires` (`YYYY-MM-DD`) so the exception is time-boxed: once it lapses the finding comes back along with an `allowlist-expired` finding (medium). Entries that no longer match anything are reported as `allowlist-unused` (info).
121
+
122
+ Config can also set `baseUrl`, `include`, `exclude` and `cleanUrls`. The config file is looked up in the site directory first, then in the current directory, so `.lazy-pulse.json` can live at your repository root. Other keys: `foldImages` (content images after the LCP image treated as above the fold, default 1) and `iconSize` (images at or below this many px are icons, default 64).
123
+
124
+ ## Output and exit codes
125
+
126
+ - `--format text` (default), `json`, `sarif` (SARIF 2.1.0 for GitHub code scanning) or `markdown` (for job summaries and PR comments). `--json`, `--sarif` and `--markdown` write extra report files in the same run.
127
+ - `--fail-on <severity>` sets the threshold (`critical`, `high`, `medium`, `low`, `info`, `none`; default `high`).
128
+ - Exit codes: **0** passed, **1** findings at or above `--fail-on`, **2** usage, config or read error.
129
+
130
+ ## GitHub Action
131
+
132
+ ```yaml
133
+ permissions:
134
+ contents: read
135
+ security-events: write # only needed for the SARIF upload
136
+ jobs:
137
+ lazy-pulse:
138
+ runs-on: ubuntu-latest
139
+ steps:
140
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
141
+ with: { persist-credentials: false }
142
+ - run: npm ci && npm run build # produce the static site first
143
+ - uses: MrLouisWright/lazy-pulse@v1
144
+ with:
145
+ path: dist
146
+ fail-on: high
147
+ sarif: lazy-pulse.sarif
148
+ - uses: github/codeql-action/upload-sarif@v3
149
+ if: always()
150
+ with: { sarif_file: lazy-pulse.sarif }
151
+ ```
152
+
153
+ | Input | Description | Default |
154
+ |---|---|---|
155
+ | `path` | Built site directory (e.g. dist, public, _site) | `.` |
156
+ | `base-url` | Public URL of the site, e.g. https://example.com | |
157
+ | `fail-on` | Failure threshold | `high` |
158
+ | `config` | Config file path | |
159
+ | `sarif` / `json` | Write SARIF / JSON report to this path | |
160
+ | `job-summary` | Add the Markdown report to the job summary | `true` |
161
+ | `args` | Extra CLI arguments | |
162
+
163
+ The action runs the bundled CLI with Node 20; it has no dependencies to install. Pin it to a commit SHA if your policy requires it.
164
+
165
+ ## Library
166
+
167
+ ```js
168
+ import { check, parseHtml, mediaOf, lcpCandidate } from "lazy-pulse";
169
+ const doc = parseHtml(html);
170
+ lcpCandidate(doc, mediaOf(doc))?.el.attrs.src;
171
+ check("dist", { foldImages: 2 }).findings;
172
+ ```
173
+
174
+ Types are included.
175
+
176
+ ## Requirements
177
+
178
+ Node.js 18 or newer. Zero runtime dependencies.
179
+
180
+ ## Licence
181
+
182
+ MIT © Louis Wright. See [LICENSE](LICENSE).
package/action.yml ADDED
@@ -0,0 +1,47 @@
1
+ name: 'lazy-pulse'
2
+ description: 'Lazy-loading audit: below-the-fold media and wrongly lazy LCP images'
3
+ author: 'Louis Wright'
4
+ branding: { icon: 'image', color: 'blue' }
5
+ inputs:
6
+ path: { description: 'Built site directory (e.g. dist, public, _site)', default: '.' }
7
+ base-url: { description: 'Public URL of the site, e.g. https://example.com', default: '' }
8
+ fail-on: { description: 'critical | high | medium | low | info | none', default: 'high' }
9
+ config: { description: 'Path to JSON config file', default: '' }
10
+ sarif: { description: 'Write SARIF to this path', default: '' }
11
+ json: { description: 'Write JSON report to this path', default: '' }
12
+ job-summary: { description: 'Write the Markdown report to the job summary (true/false)', default: 'true' }
13
+ args: { description: 'Extra CLI arguments', default: '' }
14
+ outputs:
15
+ markdown-file: { description: 'Path of the Markdown report', value: '${{ steps.run.outputs.markdown-file }}' }
16
+ runs:
17
+ using: 'composite'
18
+ steps:
19
+ - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
20
+ with: { node-version: '20' }
21
+ - id: run
22
+ shell: bash
23
+ env:
24
+ XB_PATH: ${{ inputs.path }}
25
+ XB_BASE_URL: ${{ inputs.base-url }}
26
+ XB_FAIL_ON: ${{ inputs.fail-on }}
27
+ XB_CONFIG: ${{ inputs.config }}
28
+ XB_SARIF: ${{ inputs.sarif }}
29
+ XB_JSON: ${{ inputs.json }}
30
+ XB_SUMMARY: ${{ inputs.job-summary }}
31
+ XB_ARGS: ${{ inputs.args }}
32
+ XB_BIN: ${{ github.action_path }}/bin/lazy-pulse.js
33
+ run: |
34
+ md="$RUNNER_TEMP/lazy-pulse.md"
35
+ args=("$XB_PATH" --fail-on "$XB_FAIL_ON" --markdown "$md")
36
+ [ -n "$XB_BASE_URL" ] && args+=(--base-url "$XB_BASE_URL")
37
+ [ -n "$XB_CONFIG" ] && args+=(--config "$XB_CONFIG")
38
+ [ -n "$XB_SARIF" ] && args+=(--sarif "$XB_SARIF")
39
+ [ -n "$XB_JSON" ] && args+=(--json "$XB_JSON")
40
+ set +e
41
+ # shellcheck disable=SC2086
42
+ node "$XB_BIN" "${args[@]}" $XB_ARGS
43
+ code=$?
44
+ set -e
45
+ echo "markdown-file=$md" >> "$GITHUB_OUTPUT"
46
+ if [ "$XB_SUMMARY" = "true" ] && [ -f "$md" ]; then cat "$md" >> "$GITHUB_STEP_SUMMARY"; fi
47
+ exit $code
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs';
3
+ import { mainCli, check, RULES, SITE_OPTIONS, SITE_HELP } from '../src/index.js';
4
+
5
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
6
+ mainCli({
7
+ name: 'lazy-pulse', version: pkg.version, configKey: 'lazyPulse', rules: RULES,
8
+ tagline: 'lazy-loading audit: below-the-fold media without loading=lazy, LCP images wrongly lazy-loaded',
9
+ usage: 'Usage: lazy-pulse [site-dir] [options]',
10
+ help: `${SITE_HELP}Lazy loading
11
+ --fold-images <n> Content images treated as above the fold, after the LCP candidate (default 1)
12
+ `,
13
+ options: { ...SITE_OPTIONS, 'fold-images': { type: 'string' } },
14
+ run: async ({ dir, values: o, config, configFile }) => check(dir, { cli: o, foldImages: o['fold-images'], toolVersion: pkg.version }, config, configFile),
15
+ });
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "lazy-pulse",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Lazy-loading audit for static sites: below-the-fold images and iframes missing loading=lazy, and LCP candidates (hero images, first images) wrongly lazy-loaded or deprioritised. SARIF, Action. Zero deps.",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "types": "./src/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.d.ts",
11
+ "default": "./src/index.js"
12
+ },
13
+ "./package.json": "./package.json"
14
+ },
15
+ "bin": {
16
+ "lazy-pulse": "bin/lazy-pulse.js"
17
+ },
18
+ "files": [
19
+ "src",
20
+ "bin",
21
+ "action.yml",
22
+ "README.md",
23
+ "CHANGELOG.md",
24
+ "LICENSE"
25
+ ],
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "scripts": {
30
+ "test": "node --test test/*.test.js"
31
+ },
32
+ "keywords": [
33
+ "lazy-loading",
34
+ "loading-lazy",
35
+ "lcp",
36
+ "performance",
37
+ "web-performance",
38
+ "core-web-vitals",
39
+ "images",
40
+ "static-site",
41
+ "sarif",
42
+ "github-action"
43
+ ],
44
+ "author": "Louis Wright <hello@louiswright.co.uk> (https://github.com/MrLouisWright)",
45
+ "license": "MIT",
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/MrLouisWright/lazy-pulse.git"
49
+ },
50
+ "bugs": {
51
+ "url": "https://github.com/MrLouisWright/lazy-pulse/issues"
52
+ },
53
+ "homepage": "https://github.com/MrLouisWright/lazy-pulse#readme"
54
+ }
package/src/core.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info';
2
+ export interface Finding { rule: string; severity: Severity; message: string; fix?: string; file?: string; line?: number; subject?: string; [k: string]: unknown }
3
+ export interface AllowlistEntry { rule?: string; file?: string; subject?: string; match?: string; reason: string; expires?: string }
4
+ export interface Config { failOn?: Severity | 'none'; allowlist?: AllowlistEntry[]; severity?: Record<string, Severity | 'off'>; [k: string]: unknown }
5
+ export interface Rule { severity: Severity; title: string }
6
+ export interface Report { tool: { name: string; version: string; rules: Record<string, Rule> }; target: string; summaryLine?: string; counts: Record<Severity, number>; findings: Finding[]; suppressed: (Finding & { reason: string; expires: string | null })[]; [k: string]: unknown }
7
+ export const SEVERITIES: Severity[];
8
+ export const POLICY_RULES: Record<string, Rule>;
9
+ export class UsageError extends Error {}
10
+ export function applyPolicy(raw: Finding[], config?: Config, opts?: { now?: Date; configFile?: string }): { findings: Finding[]; suppressed: Report['suppressed'] };
11
+ export function validateConfig(config: unknown): Config;
12
+ export function exceeds(report: Report, failOn: Severity | 'none'): boolean;
13
+ export function formatText(report: Report, opts?: { color?: boolean; failOn?: string; fixes?: boolean }): string;
14
+ export function formatJson(report: Report): string;
15
+ export function formatSarif(report: Report): string;
16
+ export function formatMarkdown(report: Report, opts?: { failOn?: string }): string;
package/src/core.js ADDED
@@ -0,0 +1,176 @@
1
+ // Shared reporting / policy / CLI core. Zero dependencies. Louis Wright, MIT.
2
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
3
+ import { join, resolve } from 'node:path';
4
+ import { parseArgs } from 'node:util';
5
+
6
+ export const SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
7
+ export const sevRank = (s) => { const i = SEVERITIES.indexOf(s); return i === -1 ? 99 : i; };
8
+ export class UsageError extends Error {}
9
+
10
+ export const POLICY_RULES = {
11
+ 'allowlist-expired': { severity: 'medium', title: 'Allowlist entry has expired' },
12
+ 'allowlist-unused': { severity: 'info', title: 'Allowlist entry matched nothing' },
13
+ };
14
+
15
+ function globRe(p) { return new RegExp('^' + String(p).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.') + '$', 'i'); }
16
+
17
+ /** Does an allowlist entry ({rule, file, match, subject}) cover a finding? */
18
+ export function entryMatches(e, f) {
19
+ if (e.rule && !globRe(e.rule).test(f.rule)) return false;
20
+ if (e.file && !globRe(e.file).test(f.file || '')) return false;
21
+ if (e.subject && !globRe(e.subject).test(f.subject || '')) return false;
22
+ if (e.match && !String(f.message).toLowerCase().includes(String(e.match).toLowerCase())) return false;
23
+ return !!(e.rule || e.file || e.subject || e.match);
24
+ }
25
+
26
+ export function validateConfig(config) {
27
+ if (config == null || typeof config !== 'object' || Array.isArray(config)) throw new UsageError('config must be a JSON object');
28
+ if (config.allowlist != null && !Array.isArray(config.allowlist)) throw new UsageError('"allowlist" must be an array');
29
+ for (const [i, e] of (config.allowlist || []).entries()) {
30
+ if (!e || typeof e !== 'object') throw new UsageError(`allowlist[${i}] must be an object`);
31
+ if (!e.rule && !e.file && !e.subject && !e.match) throw new UsageError(`allowlist[${i}] needs at least one of rule, file, subject, match`);
32
+ if (!e.reason) throw new UsageError(`allowlist[${i}] needs a "reason"`);
33
+ if (e.expires != null && (!/^\d{4}-\d{2}-\d{2}$/.test(e.expires) || Number.isNaN(Date.parse(e.expires)))) throw new UsageError(`allowlist[${i}].expires must be YYYY-MM-DD`);
34
+ }
35
+ for (const [k, v] of Object.entries(config.severity || {})) if (v !== 'off' && !SEVERITIES.includes(v)) throw new UsageError(`severity.${k} must be one of ${SEVERITIES.join(', ')}, off`);
36
+ return config;
37
+ }
38
+
39
+ /** Apply severity overrides and the allowlist. Returns { findings, suppressed }. */
40
+ export function applyPolicy(raw, config = {}, { now = new Date(), configFile = 'config' } = {}) {
41
+ const today = now.toISOString().slice(0, 10);
42
+ const sev = config.severity || {};
43
+ const list = (config.allowlist || []).map((e, i) => ({ ...e, index: i, used: 0, expired: !!(e.expires && e.expires < today) }));
44
+ const findings = []; const suppressed = [];
45
+ for (const f0 of raw) {
46
+ const o = sev[f0.rule];
47
+ if (o === 'off') continue;
48
+ const f = o ? { ...f0, severity: o } : f0;
49
+ const hit = list.find((e) => entryMatches(e, f));
50
+ if (hit) { hit.used++; if (!hit.expired) { suppressed.push({ ...f, reason: hit.reason, expires: hit.expires || null }); continue; } }
51
+ findings.push(f);
52
+ }
53
+ for (const e of list) {
54
+ const label = [e.rule, e.file, e.subject, e.match].filter(Boolean).join(' / ');
55
+ if (e.expired && sev['allowlist-expired'] !== 'off') findings.push({ rule: 'allowlist-expired', severity: sev['allowlist-expired'] || 'medium', file: configFile, message: `Allowlist entry #${e.index + 1} (${label}) expired on ${e.expires}`, fix: 'Fix the underlying finding, or renew the entry with a new "expires" date and reason.' });
56
+ else if (!e.used && sev['allowlist-unused'] !== 'off') findings.push({ rule: 'allowlist-unused', severity: sev['allowlist-unused'] || 'info', file: configFile, message: `Allowlist entry #${e.index + 1} (${label}) matched no finding`, fix: 'Remove the stale entry.' });
57
+ }
58
+ findings.sort((a, b) => sevRank(a.severity) - sevRank(b.severity) || String(a.file || '').localeCompare(String(b.file || '')) || (a.line || 0) - (b.line || 0));
59
+ return { findings, suppressed };
60
+ }
61
+
62
+ export function countBy(findings) { const c = {}; for (const s of SEVERITIES) c[s] = 0; for (const f of findings) c[f.severity] = (c[f.severity] || 0) + 1; return c; }
63
+ export const exceeds = (r, failOn) => !!failOn && failOn !== 'none' && r.findings.some((f) => sevRank(f.severity) <= sevRank(failOn));
64
+
65
+ const C = { critical: '\x1b[35;1m', high: '\x1b[31;1m', medium: '\x1b[33m', low: '\x1b[36m', info: '\x1b[2m', dim: '\x1b[2m', bold: '\x1b[1m', reset: '\x1b[0m', red: '\x1b[31m', green: '\x1b[32m' };
66
+ const sumText = (counts) => SEVERITIES.filter((k) => counts[k]).map((k) => `${counts[k]} ${k}`).join(', ') || 'no findings';
67
+ const loc = (f) => (f.file ? `${f.file}${f.line ? ':' + f.line : ''}` : '');
68
+
69
+ export function formatText(r, { color = false, failOn, fixes = true } = {}) {
70
+ const c = (k, s) => (color ? `${C[k]}${s}${C.reset}` : s);
71
+ const l = [c('bold', `${r.tool.name} — ${r.target}`)];
72
+ if (r.summaryLine) l.push(c('dim', r.summaryLine));
73
+ l.push('');
74
+ for (const f of r.findings) {
75
+ l.push(`${c(f.severity, f.severity.toUpperCase().padEnd(8))} ${loc(f) ? c('dim', loc(f) + ' ') : ''}${f.message} [${f.rule}]`);
76
+ if (fixes && f.fix) l.push(c('dim', ` ↳ fix: ${f.fix}`));
77
+ }
78
+ if (r.findings.length) l.push('');
79
+ if (r.suppressed.length) l.push(c('dim', `${r.suppressed.length} finding(s) covered by the allowlist`));
80
+ l.push(sumText(r.counts));
81
+ if (failOn) l.push(exceeds(r, failOn) ? c('red', `✖ Failing (fail-on: ${failOn}).`) : c('green', `✔ Passing (fail-on: ${failOn}).`));
82
+ return l.join('\n') + '\n';
83
+ }
84
+
85
+ export const formatJson = (r) => JSON.stringify(r, null, 2) + '\n';
86
+
87
+ const LEVEL = { critical: 'error', high: 'error', medium: 'warning', low: 'note', info: 'note' };
88
+ const SCORE = { critical: '9.0', high: '7.5', medium: '5.0', low: '3.0', info: '1.0' };
89
+ export function formatSarif(r) {
90
+ const rules = { ...r.tool.rules, ...POLICY_RULES };
91
+ const ids = Object.keys(rules);
92
+ const repo = `https://github.com/MrLouisWright/${r.tool.name}`;
93
+ return JSON.stringify({
94
+ $schema: 'https://json.schemastore.org/sarif-2.1.0.json', version: '2.1.0',
95
+ runs: [{
96
+ tool: { driver: { name: r.tool.name, version: r.tool.version, informationUri: repo, rules: ids.map((id) => ({ id, name: id.replace(/(^|-)(\w)/g, (_, __, ch) => ch.toUpperCase()), shortDescription: { text: rules[id].title }, helpUri: `${repo}#rules`, defaultConfiguration: { level: LEVEL[rules[id].severity] }, properties: { 'security-severity': SCORE[rules[id].severity] } })) } },
97
+ results: r.findings.map((f) => ({ ruleId: f.rule, ruleIndex: ids.indexOf(f.rule), level: LEVEL[f.severity], message: { text: f.fix ? `${f.message}. Fix: ${f.fix}` : f.message }, locations: [{ physicalLocation: { artifactLocation: { uri: f.file || r.tool.defaultFile || '.' }, region: { startLine: f.line || 1 } } }], partialFingerprints: { ruleSubject: `${f.rule}:${f.file || ''}:${f.subject || f.message}` }, properties: { severity: f.severity } })),
98
+ }],
99
+ }, null, 2) + '\n';
100
+ }
101
+
102
+ const esc = (s) => String(s ?? '').replace(/\|/g, '\\|').replace(/\n/g, ' ');
103
+ export function formatMarkdown(r, { failOn } = {}) {
104
+ const l = [`## ${r.tool.emoji || '📏'} ${r.tool.name}`, '', `**${esc(r.target)}**: ${r.summaryLine ? esc(r.summaryLine) + '; ' : ''}${sumText(r.counts)}.`];
105
+ if (failOn) l.push('', exceeds(r, failOn) ? `❌ Failing at \`--fail-on ${failOn}\`.` : `✅ Passing at \`--fail-on ${failOn}\`.`);
106
+ if (r.findings.length) {
107
+ l.push('', '| Severity | Location | Rule | Finding | Fix |', '|---|---|---|---|---|');
108
+ for (const f of r.findings) l.push(`| ${f.severity} | ${loc(f) ? '`' + esc(loc(f)) + '`' : ''} | \`${f.rule}\` | ${esc(f.message)} | ${esc(f.fix)} |`);
109
+ }
110
+ if (r.suppressed.length) l.push('', `_${r.suppressed.length} finding(s) covered by the allowlist._`);
111
+ return l.join('\n') + '\n';
112
+ }
113
+
114
+ /** Build a report object from raw findings. */
115
+ export function buildReport(tool, target, raw, config, extra = {}) {
116
+ const { findings, suppressed } = applyPolicy(raw, config, { now: extra.now, configFile: extra.configFile });
117
+ const { now, configFile, ...rest } = extra;
118
+ return { tool: { name: tool.name, version: tool.version, rules: tool.rules, emoji: tool.emoji, defaultFile: tool.defaultFile }, target, ...rest, counts: countBy(findings), findings, suppressed };
119
+ }
120
+
121
+ export function loadConfig(dir, name, key, explicit) {
122
+ const file = explicit ? resolve(explicit) : join(dir, `.${name}.json`);
123
+ if (explicit || existsSync(file)) {
124
+ let c; try { c = JSON.parse(readFileSync(file, 'utf8')); } catch (e) { throw new UsageError(`Cannot read config ${file}: ${e.message}`); }
125
+ return { config: validateConfig(c), configFile: explicit || `.${name}.json` };
126
+ }
127
+ for (const d of [dir, process.cwd()]) {
128
+ if (d !== dir && existsSync(join(d, `.${name}.json`))) return loadConfig(d, name, key, join(d, `.${name}.json`));
129
+ try { const pj = JSON.parse(readFileSync(join(d, 'package.json'), 'utf8')); if (pj[key]) return { config: validateConfig(pj[key]), configFile: 'package.json' }; } catch (e) { if (e instanceof UsageError) throw e; }
130
+ }
131
+ return { config: {}, configFile: null };
132
+ }
133
+
134
+ const list = (v) => (v || []).flatMap((x) => String(x).split(',')).map((x) => x.trim()).filter(Boolean);
135
+ export { list as splitList };
136
+
137
+ /**
138
+ * Generic CLI. spec = { name, version, configKey, rules, help (tool-specific options text), options (parseArgs), defaultFailOn,
139
+ * run: async ({ dir, positionals, values, config, configFile, stdin }) => report }
140
+ */
141
+ export async function runCli(spec, argv = process.argv.slice(2), io = { stdout: process.stdout, stderr: process.stderr }) {
142
+ const usage = (m) => { io.stderr.write(`${spec.name}: ${m}\nRun with --help for usage.\n`); return 2; };
143
+ let a;
144
+ try {
145
+ a = parseArgs({ args: argv, allowPositionals: true, options: { ...spec.options, config: { type: 'string', short: 'c' }, format: { type: 'string', short: 'f' }, json: { type: 'string' }, sarif: { type: 'string' }, markdown: { type: 'string' }, 'fail-on': { type: 'string' }, 'no-color': { type: 'boolean' }, 'no-fixes': { type: 'boolean' }, 'list-rules': { type: 'boolean' }, version: { type: 'boolean', short: 'v' }, help: { type: 'boolean', short: 'h' } } });
146
+ } catch (e) { return usage(e.message); }
147
+ const { values: o, positionals } = a;
148
+ const all = { ...spec.rules, ...POLICY_RULES };
149
+ if (o.help) { io.stdout.write(`${spec.name} ${spec.version} — ${spec.tagline}\n\n${spec.usage}\n\n${spec.help}\nCommon\n -c, --config <file> JSON config (default: .${spec.name}.json or "${spec.configKey}" in package.json)\n -f, --format <fmt> text (default) | json | sarif | markdown\n --json <file> Also write a JSON report\n --sarif <file> Also write SARIF 2.1.0\n --markdown <file> Also write Markdown (e.g. for a job summary)\n --fail-on <sev> critical | high | medium | low | info | none (default: ${spec.defaultFailOn || 'high'})\n --list-rules Print rules and default severities\n --no-fixes Hide fix suggestions in text output\n --no-color Disable colours\n -v, --version / -h, --help\n\nExit codes: 0 = passed, 1 = findings at or above --fail-on, 2 = usage, config or read error.\n`); return 0; }
150
+ if (o.version) { io.stdout.write(spec.version + '\n'); return 0; }
151
+ if (o['list-rules']) { for (const [id, r] of Object.entries(all)) io.stdout.write(`${id.padEnd(28)} ${r.severity.padEnd(9)} ${r.title}\n`); return 0; }
152
+ const format = o.format || 'text';
153
+ if (!['text', 'json', 'sarif', 'markdown'].includes(format)) return usage(`Invalid --format: ${format}`);
154
+ let report; let failOn;
155
+ try {
156
+ const dir = resolve(spec.dirFrom ? spec.dirFrom(o, positionals) : (positionals[0] || '.'));
157
+ if (!existsSync(dir)) return usage(`No such directory: ${dir}`);
158
+ const { config, configFile } = loadConfig(dir, spec.name, spec.configKey, o.config);
159
+ failOn = o['fail-on'] || config.failOn || spec.defaultFailOn || 'high';
160
+ if (failOn !== 'none' && !SEVERITIES.includes(failOn)) return usage(`Invalid --fail-on: ${failOn}`);
161
+ report = await spec.run({ dir, positionals, values: o, config, configFile: configFile || `.${spec.name}.json` });
162
+ } catch (e) { if (e instanceof UsageError || e.code === 'ENOENT' || e.code === 'EACCES') return usage(e.message); throw e; }
163
+ const color = !o['no-color'] && io.stdout.isTTY && !process.env.NO_COLOR;
164
+ const out = { text: () => formatText(report, { color, failOn, fixes: !o['no-fixes'] }), json: () => formatJson(report), sarif: () => formatSarif(report), markdown: () => formatMarkdown(report, { failOn }) };
165
+ io.stdout.write(out[format]());
166
+ try {
167
+ if (o.json) writeFileSync(o.json, formatJson(report));
168
+ if (o.sarif) writeFileSync(o.sarif, formatSarif(report));
169
+ if (o.markdown) writeFileSync(o.markdown, formatMarkdown(report, { failOn }));
170
+ } catch (e) { return usage(`Cannot write output: ${e.message}`); }
171
+ return exceeds(report, failOn) ? 1 : 0;
172
+ }
173
+
174
+ export function mainCli(spec) {
175
+ runCli(spec).then((c) => { process.exitCode = c; }, (e) => { process.stderr.write(`${spec.name}: ${e.stack || e}\n`); process.exitCode = 2; });
176
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { Report, Rule } from './core.js';
2
+ export * from './core.js';
3
+ export interface LazyOptions { baseUrl?: string; include?: string[]; exclude?: string[]; cleanUrls?: boolean; foldImages?: number; toolVersion?: string; now?: Date }
4
+ export interface Media { el: { tag: string; attrs: Record<string, string>; line: number }; kind: 'img' | 'iframe'; icon: boolean; chrome: boolean }
5
+ export declare const RULES: Record<string, Rule>;
6
+ export declare const DEFAULTS: { foldImages: number; iconSize: number };
7
+ export declare function parseHtml(html: string): unknown;
8
+ export declare function mediaOf(doc: unknown, opts?: { iconSize?: number }): Media[];
9
+ export declare function lcpCandidate(doc: unknown, media: Media[]): Media | null;
10
+ export declare function check(dir: string, opts?: LazyOptions, config?: Record<string, unknown>, configFile?: string): Report;
package/src/index.js ADDED
@@ -0,0 +1,92 @@
1
+ // lazy-pulse: lazy-loading audit for a built static site. Finds below-the-fold images and iframes without
2
+ // loading="lazy", and LCP candidates that are lazy-loaded, JS-lazy-loaded or deprioritised.
3
+ // Static heuristic (document order, no layout engine). Zero dependencies. Louis Wright, MIT.
4
+ import { runBeacon, finder, contentPages, ancestor } from './site.js';
5
+ import { buildReport, UsageError } from './core.js';
6
+
7
+ export * from './core.js';
8
+ export { loadSite, parseHtml, SITE_OPTIONS, SITE_HELP } from './site.js';
9
+
10
+ export const RULES = {
11
+ 'lazy-lcp-lazy': { severity: 'high', title: 'LCP candidate image has loading="lazy" (delays the largest paint)' },
12
+ 'lazy-lcp-js': { severity: 'high', title: 'LCP candidate is lazy-loaded by JavaScript (data-src, no real src)' },
13
+ 'lazy-preload-conflict': { severity: 'high', title: 'Image is preloaded but its <img> has loading="lazy"' },
14
+ 'lazy-lcp-low-priority': { severity: 'medium', title: 'LCP candidate has fetchpriority="low"' },
15
+ 'lazy-img-missing': { severity: 'medium', title: 'Below-the-fold image without loading="lazy"' },
16
+ 'lazy-iframe-missing': { severity: 'medium', title: 'Below-the-fold iframe without loading="lazy"' },
17
+ 'lazy-above-fold-lazy': { severity: 'low', title: 'Image near the top of the page is lazy-loaded' },
18
+ 'lazy-no-dimensions': { severity: 'low', title: 'Lazy-loaded image without width/height (layout shift when it loads)' },
19
+ 'lazy-js-library': { severity: 'low', title: 'JavaScript lazy-loading (data-src) where native loading="lazy" would do' },
20
+ 'lazy-lcp-no-priority': { severity: 'info', title: 'LCP candidate has no fetchpriority="high"' },
21
+ };
22
+ export const DEFAULTS = { foldImages: 1, iconSize: 64 };
23
+ const JS_SRC = ['data-src', 'data-lazy-src', 'data-original', 'data-srcset'];
24
+ const isLazy = (el) => String(el.attrs.loading || '').toLowerCase() === 'lazy';
25
+ const jsLazy = (el) => JS_SRC.some((k) => el.attrs[k] != null) && (!el.attrs.src || /^data:|placeholder|blank|spacer|1x1/i.test(el.attrs.src));
26
+ const num = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : null; };
27
+ const srcOf = (el) => (jsLazy(el) ? el.attrs['data-src'] || el.attrs['data-lazy-src'] || el.attrs['data-original'] : el.attrs.src) || el.attrs.src || '';
28
+ const urlKey = (s) => String(s || '').split(/[?#]/)[0].replace(/^https?:\/\/[^/]+/, '').replace(/^\.?\/?/, '/');
29
+
30
+ /** Media elements of a page in document order with classification: [{ el, kind, icon, chrome }]. */
31
+ export function mediaOf(doc, opts = {}) {
32
+ const iconSize = opts.iconSize ?? DEFAULTS.iconSize; const out = [];
33
+ for (const el of doc.all) {
34
+ if (el.inHead || ancestor(el, (n) => n.tag === 'noscript' || n.tag === 'template')) continue;
35
+ if (el.tag === 'img') {
36
+ const w = num(el.attrs.width); const h = num(el.attrs.height); const src = srcOf(el);
37
+ const icon = (w != null && w <= iconSize && (h == null || h <= iconSize)) || (h != null && h <= iconSize && w == null) || /^data:/i.test(src) && !jsLazy(el) || el.attrs['aria-hidden'] === 'true' && w != null && w <= iconSize * 2;
38
+ const pixel = (w === 1 && h === 1) || /display\s*:\s*none/.test(el.attrs.style || '');
39
+ if (pixel) continue;
40
+ out.push({ el, kind: 'img', icon, chrome: !!ancestor(el, (n) => n.tag === 'nav' || n.tag === 'footer' || (n.tag === 'header' && !ancestor(n, (m) => m.tag === 'main' || m.tag === 'article'))) });
41
+ } else if (el.tag === 'iframe' && (el.attrs.src || el.attrs['data-src'])) out.push({ el, kind: 'iframe', icon: false, chrome: false });
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /** The likely LCP image: fetchpriority=high, else a preloaded image, else the first non-icon content image. */
47
+ export function lcpCandidate(doc, media) {
48
+ const imgs = media.filter((m) => m.kind === 'img');
49
+ const hi = imgs.find((m) => String(m.el.attrs.fetchpriority).toLowerCase() === 'high'); if (hi) return hi;
50
+ const pre = new Set(doc.all.filter((e) => e.tag === 'link' && /preload/i.test(e.attrs.rel || '') && e.attrs.as === 'image').map((e) => urlKey(e.attrs.href)));
51
+ const p = imgs.find((m) => pre.has(urlKey(srcOf(m.el)))); if (p) return p;
52
+ return imgs.find((m) => !m.icon && !m.chrome) || null;
53
+ }
54
+
55
+ function analyze(site, opts, config) {
56
+ const { findings, add } = finder(RULES);
57
+ const fold = opts.foldImages ?? config.foldImages ?? DEFAULTS.foldImages;
58
+ const pages = contentPages(site); let media = 0; let lazyCount = 0;
59
+ for (const pg of pages) {
60
+ const doc = pg.doc; const all = mediaOf(doc, config); media += all.length;
61
+ const lcp = lcpCandidate(doc, all);
62
+ const pre = doc.all.filter((e) => e.tag === 'link' && /preload/i.test(e.attrs.rel || '') && e.attrs.as === 'image');
63
+ const loc = (el) => ({ file: pg.file, line: el.line });
64
+ let contentIdx = 0;
65
+ for (const m of all) {
66
+ const { el } = m; const src = srcOf(el) || el.attrs.src || ''; const name = src.split('/').pop()?.split(/[?#]/)[0] || el.tag;
67
+ if (isLazy(el) || jsLazy(el)) lazyCount++;
68
+ if (m === lcp) {
69
+ if (isLazy(el)) add('lazy-lcp-lazy', `${pg.path} ${name}`, `LCP candidate ${name} on ${pg.path} has loading="lazy"`, 'Remove loading="lazy" from the hero/first content image and add fetchpriority="high".', loc(el));
70
+ else if (jsLazy(el)) add('lazy-lcp-js', `${pg.path} ${name}`, `LCP candidate ${name} on ${pg.path} is lazy-loaded by JavaScript (${JS_SRC.find((k) => el.attrs[k] != null)})`, 'Give the hero image a real src (and fetchpriority="high"); exclude it from the lazy-load library.', loc(el));
71
+ if (String(el.attrs.fetchpriority).toLowerCase() === 'low') add('lazy-lcp-low-priority', `${pg.path} ${name}`, `LCP candidate ${name} on ${pg.path} has fetchpriority="low"`, 'Use fetchpriority="high" on the LCP image.', loc(el));
72
+ else if (!el.attrs.fetchpriority && !isLazy(el) && !jsLazy(el)) add('lazy-lcp-no-priority', `${pg.path} ${name}`, `LCP candidate ${name} on ${pg.path} has no fetchpriority`, 'Add fetchpriority="high" to the hero image.', loc(el));
73
+ continue;
74
+ }
75
+ const above = m.kind === 'img' ? (!m.icon && !m.chrome ? contentIdx++ < fold : !!(m.chrome && ancestor(el, (n) => n.tag === 'header'))) : false;
76
+ if (m.kind === 'iframe') { if (!isLazy(el) && !jsLazy(el)) add('lazy-iframe-missing', `${pg.path} ${name}`, `iframe ${el.attrs.src || name} on ${pg.path} is not lazy-loaded`, 'Add loading="lazy" to embeds (video, maps, widgets) or use a click-to-load facade.', loc(el)); continue; }
77
+ if (m.icon) continue;
78
+ if (above) { if (isLazy(el) && !m.chrome) add('lazy-above-fold-lazy', `${pg.path} ${name}`, `${name} is among the first ${fold} content images on ${pg.path} but has loading="lazy"`, 'Load images near the top eagerly; lazy-load from the fold down. Adjust foldImages if your layout differs.', loc(el)); continue; }
79
+ if (!isLazy(el) && !jsLazy(el)) { if (!m.chrome || ancestor(el, (n) => n.tag === 'footer')) add('lazy-img-missing', `${pg.path} ${name}`, `Below-the-fold image ${name} on ${pg.path} has no loading="lazy"`, 'Add loading="lazy" (and decoding="async") to images below the fold.', loc(el)); continue; }
80
+ if (jsLazy(el)) add('lazy-js-library', `${pg.path} ${name}`, `${name} on ${pg.path} is lazy-loaded with ${JS_SRC.find((k) => el.attrs[k] != null)}`, 'Use native loading="lazy" with a real src; it needs no JavaScript and works before scripts run.', loc(el));
81
+ if (isLazy(el) && (el.attrs.width == null || el.attrs.height == null) && !/aspect-ratio/.test(el.attrs.style || '')) add('lazy-no-dimensions', `${pg.path} ${name}`, `Lazy image ${name} on ${pg.path} has no width/height`, 'Set width and height (or CSS aspect-ratio) so the space is reserved before it loads.', loc(el));
82
+ }
83
+ for (const l of pre) { const k = urlKey(l.attrs.href); const m = all.find((x) => x.kind === 'img' && urlKey(srcOf(x.el)) === k && isLazy(x.el)); if (m) add('lazy-preload-conflict', `${pg.path} ${k}`, `${k} is preloaded on ${pg.path} but its <img> is loading="lazy"`, 'Drop loading="lazy" from the image (or drop the preload).', { file: pg.file, line: m.el.line }); }
84
+ }
85
+ return { findings, info: { media, lazy: lazyCount }, summary: `${pages.length} page(s), ${media} image(s)/iframe(s), ${lazyCount} lazy-loaded` };
86
+ }
87
+
88
+ export function check(dir, opts = {}, config = {}, configFile) {
89
+ const tool = { name: 'lazy-pulse', version: opts.toolVersion || '1.0.0', rules: RULES, emoji: '💤', defaultFile: 'index.html' };
90
+ const f = opts.foldImages ?? config.foldImages; if (f != null && !(Number.isInteger(Number(f)) && Number(f) >= 0)) throw new UsageError('foldImages must be a non-negative integer');
91
+ return runBeacon(tool, dir, { ...opts, foldImages: f == null ? undefined : Number(f), UsageError }, config, configFile, analyze, buildReport);
92
+ }
package/src/site.js ADDED
@@ -0,0 +1,326 @@
1
+ // Shared static-site reader for the *-beacon packages: a small, forgiving HTML parser,
2
+ // a build-output walker that maps files to URLs, and _headers / vercel.json header rules.
3
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
4
+ import { join, relative, sep } from 'node:path';
5
+
6
+ const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
7
+ const RAW = new Set(['script', 'style', 'textarea', 'title', 'xmp', 'noscript', 'template']);
8
+ const HEADISH = new Set(['html', 'head', 'meta', 'link', 'title', 'base', 'script', 'style', 'noscript', 'template']);
9
+ const BLOCK = new Set(['address', 'article', 'aside', 'blockquote', 'br', 'dd', 'details', 'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'li', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'summary', 'table', 'td', 'th', 'tr', 'ul', 'option', 'caption']);
10
+ const CLOSES_P = new Set(['address', 'article', 'aside', 'blockquote', 'details', 'div', 'dl', 'fieldset', 'figure', 'footer', 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'main', 'nav', 'ol', 'p', 'pre', 'section', 'table', 'ul']);
11
+ const ENT = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: '\u00a0', mdash: '—', ndash: '–', hellip: '…', rsquo: '’', lsquo: '‘', ldquo: '“', rdquo: '”', copy: '©', reg: '®', trade: '™', laquo: '«', raquo: '»', middot: '·', bull: '•', times: '×', eacute: 'é', egrave: 'è', aacute: 'á', uuml: 'ü', ouml: 'ö', auml: 'ä', szlig: 'ß', ccedil: 'ç', ntilde: 'ñ', euro: '€', pound: '£', yen: '¥', deg: '°', shy: '' };
12
+ export const decode = (s) => String(s).replace(/&(#x[0-9a-f]+|#\d+|[a-z]+\d*);?/gi, (m, e) => { if (e[0] === '#') { const n = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10); try { return String.fromCodePoint(n); } catch { return m; } } const v = ENT[e.toLowerCase()]; return v ?? m; });
13
+ export const squash = (s) => String(s).replace(/[\s\u00a0]+/g, ' ').trim();
14
+
15
+ function parseAttrs(src) {
16
+ const attrs = {};
17
+ for (const m of src.matchAll(/([^\s"'<>\/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g)) { const k = m[1].toLowerCase(); if (!(k in attrs)) attrs[k] = decode(m[2] ?? m[3] ?? m[4] ?? ''); }
18
+ return attrs;
19
+ }
20
+
21
+ /** Parse HTML into a light tree. Elements: { tag, attrs, line, children, parent, inHead }. Text nodes: { text }. */
22
+ export function parseHtml(html) {
23
+ html = String(html).replace(/^\uFEFF/, '');
24
+ const root = { tag: '#document', attrs: {}, children: [], line: 1, parent: null };
25
+ const all = []; let cur = root; let bodyStarted = false; let i = 0; let line = 1;
26
+ const lineAt = (to) => { for (let k = i; k < to; k++) if (html.charCodeAt(k) === 10) line++; i = to; };
27
+ const inHeadEl = () => { for (let n = cur; n; n = n.parent) if (n.tag === 'head') return true; return false; };
28
+ const close = (tag) => { for (let n = cur; n && n !== root; n = n.parent) if (n.tag === tag) { cur = n.parent; return true; } return false; };
29
+ const open = (tag, attrs, ln, selfClose) => {
30
+ if (tag === 'body') bodyStarted = true;
31
+ else if (!HEADISH.has(tag) && !inHeadEl()) bodyStarted = true;
32
+ if (cur.tag === 'head' && !HEADISH.has(tag)) cur = cur.parent; // implicit </head>
33
+ if (CLOSES_P.has(tag)) { for (let n = cur; n && n !== root && !['div', 'section', 'article', 'main', 'body', 'td', 'li', 'blockquote'].includes(n.tag) || n === cur; n = n.parent) { if (n === root) break; if (n.tag === 'p') { cur = n.parent; break; } if (n !== cur) break; } }
34
+ if (tag === 'li') { for (let n = cur; n && n !== root; n = n.parent) { if (n.tag === 'li') { cur = n.parent; break; } if (n.tag === 'ul' || n.tag === 'ol') break; } }
35
+ if (tag === 'dt' || tag === 'dd') { for (let n = cur; n && n !== root; n = n.parent) { if (n.tag === 'dt' || n.tag === 'dd') { cur = n.parent; break; } if (n.tag === 'dl') break; } }
36
+ if (tag === 'option' && cur.tag === 'option') cur = cur.parent;
37
+ if ((tag === 'tr' || tag === 'td' || tag === 'th') && (cur.tag === 'td' || cur.tag === 'th')) cur = cur.parent;
38
+ if (tag === 'tr' && cur.tag === 'tr') cur = cur.parent;
39
+ const el = { tag, attrs, line: ln, children: [], parent: cur, inHead: !bodyStarted || inHeadEl() };
40
+ cur.children.push(el); all.push(el);
41
+ if (!VOID.has(tag) && !selfClose) cur = el;
42
+ return el;
43
+ };
44
+ while (i < html.length) {
45
+ const lt = html.indexOf('<', i);
46
+ if (lt === -1) { const t = html.slice(i); if (t.trim() && !inHeadEl()) bodyStarted = true; cur.children.push({ text: decode(t) }); lineAt(html.length); break; }
47
+ if (lt > i) { const t = html.slice(i, lt); if (t.trim()) { if (!inHeadEl() && cur.tag !== 'html' || cur.tag === 'html' || cur === root) { if (!inHeadEl()) bodyStarted = true; } } cur.children.push({ text: decode(t) }); lineAt(lt); }
48
+ if (html.startsWith('<!--', lt)) { const e = html.indexOf('-->', lt + 4); lineAt(e === -1 ? html.length : e + 3); continue; }
49
+ if (html[lt + 1] === '!' || html[lt + 1] === '?') { const e = html.indexOf('>', lt); lineAt(e === -1 ? html.length : e + 1); continue; }
50
+ const m = /^<(\/?)([a-zA-Z][a-zA-Z0-9:-]*)((?:[^>"']|"[^"]*"|'[^']*')*)>/.exec(html.slice(lt, lt + 20000));
51
+ if (!m) { cur.children.push({ text: '<' }); lineAt(lt + 1); continue; }
52
+ const tag = m[2].toLowerCase(); const ln = line;
53
+ lineAt(lt + m[0].length);
54
+ if (m[1]) { if (tag === 'p' && !close('p')) { /* stray </p> */ } else if (tag !== 'p') close(tag); continue; }
55
+ const el = open(tag, parseAttrs(m[3]), ln, /\/\s*$/.test(m[3]));
56
+ if (RAW.has(tag) && !/\/\s*$/.test(m[3])) {
57
+ const re = new RegExp(`</${tag}\\s*>`, 'i'); re.lastIndex = 0; const rest = html.slice(i); const em = re.exec(rest);
58
+ const end = em ? i + em.index : html.length; const raw = html.slice(i, end);
59
+ if (tag === 'noscript' || tag === 'template') { const inner = parseHtml(raw); for (const c of inner.root.children) { c.parent = el; el.children.push(c); } for (const c of inner.all) { c.line += ln - 1; c.inHead = el.inHead; all.push(c); } }
60
+ else el.children.push({ text: tag === 'script' || tag === 'style' ? raw : decode(raw), raw: true });
61
+ el.rawText = raw;
62
+ lineAt(end); if (em) lineAt(end + em[0].length);
63
+ cur = el.parent;
64
+ }
65
+ }
66
+ return makeDoc(root, all);
67
+ }
68
+
69
+ /** Visible text of an element (skips script/style/template, adds spaces at block boundaries). opts.alt includes img alt text. */
70
+ export function textOf(node, opts = {}) {
71
+ const out = [];
72
+ const walk = (n) => {
73
+ if (n.text != null) { if (!n.raw || (n.parent?.tag !== 'script' && n.parent?.tag !== 'style')) out.push(n.text); return; }
74
+ if (n.tag === 'script' || n.tag === 'style' || n.tag === 'template' || (n.tag === 'noscript' && !opts.noscript)) return;
75
+ if (opts.skip && opts.skip.has(n.tag)) return;
76
+ if (n.attrs?.hidden != null || n.attrs?.['aria-hidden'] === 'true' && opts.skipHidden) return;
77
+ if (n.tag === 'img' && opts.alt) out.push(` ${n.attrs.alt || ''} `);
78
+ if (BLOCK.has(n.tag)) out.push(' ');
79
+ for (const c of n.children || []) walk(c);
80
+ if (BLOCK.has(n.tag)) out.push(' ');
81
+ };
82
+ if (node.text != null) return squash(node.text);
83
+ for (const c of node.children || []) walk(c);
84
+ return squash(out.join(''));
85
+ }
86
+ const has = (el, tag) => { for (let n = el.parent; n; n = n.parent) if (n.tag === tag) return true; return false; };
87
+ export const ancestor = (el, pred) => { for (let n = el.parent; n; n = n.parent) if (n.tag && pred(n)) return n; return null; };
88
+
89
+ function makeDoc(root, all) {
90
+ const doc = { root, all };
91
+ const by = (t) => all.filter((e) => e.tag === t);
92
+ doc.find = (t) => by(t);
93
+ const html = all.find((e) => e.tag === 'html');
94
+ doc.lang = html?.attrs.lang ?? null;
95
+ doc.titles = by('title').filter((e) => !has(e, 'svg')).map((e) => ({ text: squash(decode(e.rawText ?? textOf(e))), line: e.line, inHead: e.inHead, el: e }));
96
+ doc.title = doc.titles.find((t) => t.inHead)?.text ?? doc.titles[0]?.text ?? null;
97
+ doc.metas = by('meta').map((e) => ({ name: (e.attrs.name || '').toLowerCase() || null, property: (e.attrs.property || '').toLowerCase() || null, httpEquiv: (e.attrs['http-equiv'] || '').toLowerCase() || null, content: e.attrs.content ?? null, charset: e.attrs.charset ?? null, itemprop: e.attrs.itemprop ?? null, line: e.line, inHead: e.inHead, el: e }));
98
+ doc.meta = (key) => { key = key.toLowerCase(); return doc.metas.filter((m) => m.name === key || m.property === key); };
99
+ doc.metaContent = (key) => { const m = doc.meta(key).find((x) => x.inHead) || doc.meta(key)[0]; return m ? (m.content ?? '') : null; };
100
+ doc.links = by('link').map((e) => ({ rel: (e.attrs.rel || '').toLowerCase().split(/\s+/).filter(Boolean), href: e.attrs.href ?? null, hreflang: e.attrs.hreflang ?? null, type: e.attrs.type ?? null, line: e.line, inHead: e.inHead, el: e }));
101
+ doc.base = by('base').find((e) => e.attrs.href)?.attrs.href ?? null;
102
+ doc.anchors = by('a').filter((e) => e.attrs.href != null).map((e) => ({ href: e.attrs.href, rel: (e.attrs.rel || '').toLowerCase().split(/\s+/).filter(Boolean), text: textOf(e, { alt: true }), line: e.line, el: e }));
103
+ doc.headings = [];
104
+ for (const e of all) {
105
+ let level = /^h([1-6])$/.exec(e.tag)?.[1];
106
+ if (!level && e.attrs.role === 'heading') level = e.attrs['aria-level'] || '2';
107
+ if (level && !has(e, 'svg') && !has(e, 'template')) doc.headings.push({ level: +level, text: textOf(e, { alt: true }), line: e.line, el: e, hidden: e.attrs.hidden != null || !!ancestor(e, (n) => n.attrs?.hidden != null) });
108
+ }
109
+ doc.jsonld = by('script').filter((e) => /^application\/ld\+json$/i.test((e.attrs.type || '').trim())).map((e) => { const raw = e.rawText ?? ''; try { return { raw, line: e.line, data: JSON.parse(raw.trim().replace(/^<!--|-->$/g, '')) }; } catch (err) { return { raw, line: e.line, error: err.message }; } });
110
+ const body = by('body')[0] || root;
111
+ let _text; let _main;
112
+ Object.defineProperty(doc, 'text', { get: () => (_text ??= textOf(body)) });
113
+ Object.defineProperty(doc, 'mainText', { get: () => {
114
+ if (_main != null) return _main;
115
+ const mains = all.filter((e) => e.tag === 'main' || e.attrs.role === 'main');
116
+ const arts = mains.length ? mains : all.filter((e) => e.tag === 'article' && !has(e, 'article'));
117
+ _main = arts.length ? squash(arts.map((e) => textOf(e, { skip: new Set(['nav', 'aside']) })).join(' ')) : textOf(body, { skip: new Set(['nav', 'header', 'footer', 'aside']) });
118
+ return _main;
119
+ } });
120
+ doc.words = (s) => (s ?? doc.mainText).split(/[^\p{L}\p{N}'’-]+/u).filter((w) => /[\p{L}\p{N}]/u.test(w));
121
+ doc.robots = (bots = ['robots']) => doc.metas.filter((m) => m.name && bots.includes(m.name)).map((m) => ({ ...m, directives: String(m.content || '').toLowerCase().split(',').map((d) => d.trim()).filter(Boolean) }));
122
+ return doc;
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------------------------- site
126
+ export function globToRe(g) {
127
+ let s = ''; g = String(g);
128
+ for (let k = 0; k < g.length; k++) {
129
+ const c = g[k];
130
+ if (c === '*') { if (g[k + 1] === '*') { s += '.*'; k++; if (g[k + 1] === '/') k++; } else s += '[^/]*'; }
131
+ else if (c === '?') s += '[^/]';
132
+ else s += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
133
+ }
134
+ return new RegExp(`^${s}$`);
135
+ }
136
+ export const matchAny = (globs, s) => (globs || []).some((g) => globToRe(g).test(s));
137
+
138
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.svn', '.hg']);
139
+ function walk(dir, base = dir, out = []) {
140
+ let ents; try { ents = readdirSync(dir, { withFileTypes: true }); } catch { return out; }
141
+ for (const e of ents) {
142
+ if (SKIP_DIRS.has(e.name)) continue;
143
+ const p = join(dir, e.name);
144
+ if (e.isDirectory()) walk(p, base, out);
145
+ else if (e.isFile()) out.push(relative(base, p).split(sep).join('/'));
146
+ }
147
+ return out;
148
+ }
149
+
150
+ /** URL path for a built file: index.html -> "/", a/index.html -> "/a/", a.html -> "/a.html" (or "/a" with cleanUrls). */
151
+ export function pathForFile(file, { cleanUrls = false } = {}) {
152
+ if (/(^|\/)index\.html?$/i.test(file)) return '/' + file.replace(/index\.html?$/i, '');
153
+ return '/' + (cleanUrls ? file.replace(/\.html?$/i, '') : file);
154
+ }
155
+ export function normPath(p) {
156
+ let s = String(p || '/').split('#')[0].split('?')[0];
157
+ try { s = decodeURI(s); } catch { /* keep */ }
158
+ if (!s.startsWith('/')) s = '/' + s;
159
+ return s.replace(/\/{2,}/g, '/');
160
+ }
161
+
162
+ /**
163
+ * Load a built static site. opts: { baseUrl, include, exclude, cleanUrls }.
164
+ * Returns { dir, files, pages, baseUrl, origin, find(urlOrPath), resolve(page, href), isInternal(url), headersFor(path), read(rel) }.
165
+ */
166
+ export function loadSite(dir, opts = {}) {
167
+ if (!existsSync(dir) || !statSync(dir).isDirectory()) throw Object.assign(new Error(`No such directory: ${dir}`), { code: 'ENOENT' });
168
+ const files = walk(dir).sort();
169
+ const htmlFiles = files.filter((f) => /\.html?$/i.test(f) && !matchAny(opts.exclude, f) && !matchAny(opts.exclude, pathForFile(f, opts)) && (!opts.include?.length || matchAny(opts.include, f) || matchAny(opts.include, pathForFile(f, opts))));
170
+ const cache = new Map();
171
+ const pages = htmlFiles.map((file) => {
172
+ const page = { file, path: pathForFile(file, opts) };
173
+ Object.defineProperty(page, 'html', { get: () => readFileSync(join(dir, file), 'utf8') });
174
+ Object.defineProperty(page, 'doc', { get: () => { if (!cache.has(file)) cache.set(file, parseHtml(readFileSync(join(dir, file), 'utf8'))); return cache.get(file); }, enumerable: false });
175
+ return page;
176
+ });
177
+ const index = new Map();
178
+ const keyVariants = (path) => { const p = normPath(path).toLowerCase(); const out = [p]; if (p.endsWith('/')) { out.push(p + 'index.html', p + 'index.htm'); if (p !== '/') out.push(p.slice(0, -1)); } else if (/\.html?$/.test(p)) { out.push(p.replace(/\.html?$/, '')); if (/\/index\.html?$/.test(p)) out.push(p.replace(/index\.html?$/, '')); } else out.push(p + '/', p + '.html'); return out; };
179
+ for (const pg of pages) for (const k of keyVariants('/' + pg.file).concat(keyVariants(pg.path))) if (!index.has(k)) index.set(k, pg);
180
+
181
+ let baseUrl = opts.baseUrl ? String(opts.baseUrl).replace(/\/+$/, '') : null;
182
+ if (!baseUrl) {
183
+ const counts = new Map();
184
+ for (const pg of pages.slice(0, 60)) { const d = pg.doc; for (const v of [d.links.find((l) => l.rel.includes('canonical'))?.href, d.metaContent('og:url')]) { try { const u = new URL(v); if (/^https?:$/.test(u.protocol)) counts.set(u.origin, (counts.get(u.origin) || 0) + 1); } catch { /* relative */ } } }
185
+ baseUrl = [...counts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
186
+ }
187
+ let origin = null; let basePath = '';
188
+ if (baseUrl) { try { const u = new URL(baseUrl); origin = u.origin; basePath = u.pathname.replace(/\/+$/, ''); } catch { throw Object.assign(new Error(`Invalid base URL: ${baseUrl}`), { code: 'EINVAL', usage: true }); } }
189
+ const site = { dir, files, pages, baseUrl, origin, basePath, inferredBase: !opts.baseUrl && !!baseUrl };
190
+ site.urlOf = (pg) => (origin ? origin + basePath + pg.path : pg.path);
191
+ /** Absolute URL object for href as seen from page (honours <base href>). */
192
+ site.resolve = (pg, href) => {
193
+ if (href == null) return null; href = String(href).trim();
194
+ if (!href || /^(javascript|mailto|tel|data|sms|ftp|blob):/i.test(href)) return null;
195
+ const pageUrl = (origin || 'http://site.invalid') + basePath + pg.path;
196
+ try { const b = pg.doc?.base ? new URL(pg.doc.base, pageUrl).href : pageUrl; return new URL(href, b); } catch { return null; }
197
+ };
198
+ site.isInternal = (u) => { if (!u) return false; const o = origin || 'http://site.invalid'; if (u.origin === o) return true; if (origin) { try { const a = new URL(o); return u.hostname.replace(/^www\./, '') === a.hostname.replace(/^www\./, '') && false; } catch { return false; } } return false; };
199
+ /** Find the page for a URL / path / URL object. Returns undefined for external or unknown. */
200
+ site.find = (x) => {
201
+ let path;
202
+ if (x instanceof URL) { if (!(x.origin === (origin || 'http://site.invalid'))) return undefined; path = x.pathname; }
203
+ else { const s = String(x); if (/^[a-z][a-z0-9+.-]*:/i.test(s)) { try { const u = new URL(s); if (u.origin !== (origin || 'http://site.invalid')) return undefined; path = u.pathname; } catch { return undefined; } } else path = s; }
204
+ if (basePath && path.startsWith(basePath)) path = path.slice(basePath.length) || '/';
205
+ for (const k of keyVariants(path)) { const pg = index.get(k); if (pg) return pg; }
206
+ return undefined;
207
+ };
208
+ site.read = (rel) => { try { return readFileSync(join(dir, rel), 'utf8'); } catch { return null; } };
209
+ site.exists = (rel) => existsSync(join(dir, rel));
210
+ const rules = loadHeaderRules(dir);
211
+ site.headerRules = rules;
212
+ site.headersFor = (path) => { const out = {}; for (const r of rules) if (r.re.test(path) || r.re.test(path.replace(/\/$/, '')) ) for (const [k, v] of r.headers) (out[k] ||= []).push({ value: v, source: r.source, line: r.line }); return out; };
213
+ return site;
214
+ }
215
+
216
+ /** Header rules from Netlify / Cloudflare Pages `_headers` and `vercel.json`. */
217
+ export function loadHeaderRules(dir) {
218
+ const rules = [];
219
+ const hf = join(dir, '_headers');
220
+ if (existsSync(hf)) {
221
+ let cur = null;
222
+ readFileSync(hf, 'utf8').split(/\r?\n/).forEach((l, i) => {
223
+ if (!l.trim() || l.trim().startsWith('#')) return;
224
+ if (!/^\s/.test(l)) { const pat = l.trim(); cur = { source: '_headers', line: i + 1, pattern: pat, re: pathPatternRe(pat), headers: [] }; rules.push(cur); }
225
+ else if (cur) { const m = /^\s+([^:]+):\s*(.*)$/.exec(l); if (m) cur.headers.push([m[1].trim().toLowerCase(), m[2].trim()]); }
226
+ });
227
+ }
228
+ for (const f of ['vercel.json', '../vercel.json']) {
229
+ const vf = join(dir, f); if (!existsSync(vf)) continue;
230
+ try { const j = JSON.parse(readFileSync(vf, 'utf8')); const text = readFileSync(vf, 'utf8').split('\n'); for (const h of j.headers || []) { const ln = text.findIndex((l) => l.includes(JSON.stringify(h.source))) + 1 || undefined; rules.push({ source: f.replace('../', ''), line: ln, pattern: h.source, re: pathPatternRe(h.source), headers: (h.headers || []).map((x) => [String(x.key).toLowerCase(), String(x.value)]) }); } } catch { /* ignore */ }
231
+ break;
232
+ }
233
+ return rules;
234
+ }
235
+ function pathPatternRe(p) {
236
+ try { if (/^https?:\/\//.test(p)) p = new URL(p).pathname; } catch { /* keep */ }
237
+ if (/\(.*\)/.test(p) && !/:\w/.test(p.replace(/\(.*\)/g, ''))) { try { return new RegExp('^' + p.replace(/\//g, '\\/') + '$'); } catch { /* fall through */ } }
238
+ let s = '';
239
+ for (let k = 0; k < p.length; k++) {
240
+ const c = p[k];
241
+ if (c === '*') s += '.*';
242
+ else if (c === ':' && /\w/.test(p[k + 1] || '')) { const m = /^:(\w+)(\*|\+|\?)?(\([^)]*\))?/.exec(p.slice(k)); k += m[0].length - 1; s += m[2] === '*' || m[2] === '+' ? '.*' : m[3] ? m[3] : '[^/]+'; }
243
+ else s += c.replace(/[.+^${}()|[\]\\?]/g, '\\$&');
244
+ }
245
+ return new RegExp(`^${s}$`);
246
+ }
247
+
248
+ /** Robots directives that apply to a page from meta tags and X-Robots-Tag headers. bots: lowercased names. */
249
+ export function robotsDirectives(site, pg, bots = ['robots', 'googlebot']) {
250
+ const out = [];
251
+ for (const m of pg.doc.robots(bots)) out.push({ bot: m.name, directives: m.directives, source: 'meta', file: pg.file, line: m.line, inHead: m.inHead, raw: m.content });
252
+ for (const h of site.headersFor(pg.path)['x-robots-tag'] || []) {
253
+ const v = h.value; const mm = /^([a-z0-9_-]+)\s*:\s*(.*)$/i.exec(v);
254
+ const bot = mm && !/^(noindex|nofollow|none|all|index|follow|noarchive|nosnippet|unavailable_after|max-snippet|max-image-preview|max-video-preview|noimageindex|notranslate|indexifembedded)$/i.test(mm[1]) ? mm[1].toLowerCase() : 'robots';
255
+ const body = bot === 'robots' ? v : mm[2];
256
+ if (bot === 'robots' || bots.includes(bot)) out.push({ bot, directives: body.toLowerCase().split(',').map((d) => d.trim()).filter(Boolean), source: 'header', file: h.source, line: h.line, raw: v });
257
+ }
258
+ return out;
259
+ }
260
+ export const isNoindex = (dirs) => dirs.some((d) => d.directives.some((x) => x === 'noindex' || x === 'none'));
261
+
262
+ /** Page-level helpers shared across beacons. */
263
+ export function canonicalOf(site, pg) {
264
+ const c = pg.doc.links.filter((l) => l.rel.includes('canonical') && l.href != null);
265
+ const hdr = (site.headersFor(pg.path).link || []).map((h) => /<([^>]+)>\s*;\s*rel="?canonical"?/i.exec(h.value)?.[1]).filter(Boolean);
266
+ const href = c.find((l) => l.inHead)?.href ?? c[0]?.href ?? hdr[0] ?? null;
267
+ return { href, url: href != null ? site.resolve(pg, href) : null, tags: c, header: hdr };
268
+ }
269
+
270
+ /** Very small JSON-LD helpers. */
271
+ export function jsonldNodes(doc) {
272
+ const out = [];
273
+ const visit = (n, block, depth = 0) => {
274
+ if (Array.isArray(n)) { n.forEach((x) => visit(x, block, depth)); return; }
275
+ if (!n || typeof n !== 'object') return;
276
+ if (n['@graph']) visit(n['@graph'], block, depth);
277
+ if (n['@type']) out.push({ node: n, types: [].concat(n['@type']).map(String), line: block.line, depth });
278
+ for (const [k, v] of Object.entries(n)) if (k !== '@graph' && k !== '@context' && v && typeof v === 'object') visit(v, block, depth + 1);
279
+ };
280
+ for (const b of doc.jsonld) if (b.data) visit(b.data, b);
281
+ return out;
282
+ }
283
+ export const typeName = (t) => String(t).replace(/^https?:\/\/schema\.org\//, '').replace(/^schema:/, '');
284
+
285
+ // ---------------------------------------------------------------------------------------------- CLI glue
286
+ export const SITE_OPTIONS = { 'base-url': { type: 'string' }, include: { type: 'string', multiple: true }, exclude: { type: 'string', multiple: true }, 'clean-urls': { type: 'boolean' } };
287
+ export const SITE_HELP = `Site
288
+ --base-url <url> Public URL of the site, e.g. https://example.com (default: inferred from canonical/og:url)
289
+ --include <globs> Only check these pages (file or URL path globs, comma-separated)
290
+ --exclude <globs> Skip these pages
291
+ --clean-urls Pages are served without .html (about.html -> /about)
292
+ `;
293
+ const list = (v) => (v || []).flatMap((x) => String(x).split(',')).map((x) => x.trim()).filter(Boolean);
294
+ export function siteOptions(o = {}, config = {}) {
295
+ return { baseUrl: o['base-url'] ?? config.baseUrl, include: [...list(config.include), ...list(o.include)], exclude: [...list(config.exclude), ...list(o.exclude)], cleanUrls: o['clean-urls'] ?? config.cleanUrls ?? false };
296
+ }
297
+ /** Run a beacon: loads the site, calls analyze(site, opts) -> { findings, info, summary }, returns a report via buildReport. */
298
+ export function runBeacon(tool, dir, opts, config, configFile, analyze, buildReport) {
299
+ let site;
300
+ try { site = loadSite(dir, siteOptions(opts.cli || {}, { ...config, ...pick(opts, ['baseUrl', 'include', 'exclude', 'cleanUrls']) })); }
301
+ catch (e) { if (e.usage || e.code === 'ENOENT') { const U = opts.UsageError || Error; throw new U(e.message); } throw e; }
302
+ const { findings, info = {}, summary } = analyze(site, opts, config);
303
+ const line = summary ?? `${site.pages.length} page(s)${site.baseUrl ? ` at ${site.baseUrl}${site.inferredBase ? ' (inferred)' : ''}` : ' (no base URL; pass --base-url)'}`;
304
+ return buildReport(tool, site.baseUrl || dir, findings, config, { now: opts.now, configFile, summaryLine: line, info: { pages: site.pages.length, baseUrl: site.baseUrl, ...info } });
305
+ }
306
+ function pick(o, keys) { const r = {}; for (const k of keys) if (o[k] !== undefined && !(Array.isArray(o[k]) && !o[k].length)) r[k] = o[k]; return r; }
307
+ /** Finding factory bound to a rule table. */
308
+ export function finder(RULES, findings = []) {
309
+ const add = (rule, subject, message, fix, loc = {}) => { if (!RULES[rule]) throw new Error(`unknown rule ${rule}`); findings.push({ rule, severity: loc.severity || RULES[rule].severity, subject, file: loc.file, line: loc.line, message, fix }); };
310
+ return { findings, add };
311
+ }
312
+ /** Redirect stubs (meta refresh) and error pages are skipped by most checks. */
313
+ export const isRedirectStub = (pg) => pg.doc.metas.some((m) => m.httpEquiv === 'refresh');
314
+ export const isErrorPage = (pg) => /(^|\/)(404|500|50x|error)(\/index)?\.html?$/i.test(pg.file);
315
+ export const contentPages = (site) => site.pages.filter((p) => !isRedirectStub(p) && !isErrorPage(p));
316
+ /** Published / modified dates a page declares (JSON-LD, Open Graph article tags, <time itemprop>, meta). Values are Date or null. */
317
+ export function pageDates(doc) {
318
+ const pick = (vals) => { for (const v of vals) { if (!v) continue; const d = new Date(String(v).trim()); if (!Number.isNaN(d.getTime())) return d; } return null; };
319
+ const ld = jsonldNodes(doc).map((n) => n.node);
320
+ const ldv = (k) => ld.map((n) => [].concat(n[k] ?? [])[0]).filter(Boolean);
321
+ const timeProp = (k) => doc.all.filter((e) => e.attrs.itemprop === k).map((e) => e.attrs.datetime || e.attrs.content);
322
+ return {
323
+ published: pick([...ldv('datePublished'), doc.metaContent('article:published_time'), ...timeProp('datePublished'), doc.metaContent('date'), doc.metaContent('dc.date'), doc.metaContent('pubdate')]),
324
+ modified: pick([...ldv('dateModified'), doc.metaContent('article:modified_time'), doc.metaContent('og:updated_time'), ...timeProp('dateModified'), doc.metaContent('last-modified')]),
325
+ };
326
+ }