render-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,11 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0 (2026-10-14)
4
+
5
+ First release, by Louis Wright.
6
+
7
+ - Head scripts without defer/async/module, synchronous body scripts that block parsing, document.write.
8
+ - Render-blocking stylesheets: gzip budget per page, count, @import chains (files and inline), print CSS without media, stylesheets placed after sync scripts, missing critical CSS.
9
+ - Third-party render-blocking resources without preconnect.
10
+ - Text/JSON/SARIF/Markdown output, `--fail-on`, exit codes 0/1/2, allowlist with expiry, severity overrides.
11
+ - 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,181 @@
1
- # Temporary Holding Version
1
+ # render-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
+ > Find the CSS and JavaScript that block first paint in your built site, with the defer, async, media or critical-CSS fix for each.
4
+
5
+ Nothing is painted until the browser has downloaded and parsed every render-blocking stylesheet and synchronous script in `<head>`. One forgotten `<script src>` without `defer`, a 200 kB framework stylesheet or an `@import` chain can add seconds to First Contentful Paint and LCP on mobile. `render-pulse` reads your static build output, works out which resources are actually on the critical path (respecting `defer`, `async`, `type=module`, `media`, `disabled` and alternate stylesheets) and reports each one with a concrete fix.
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
+ - **Scripts:** classic `<script src>` in `<head>` without `defer`/`async` (high), synchronous external scripts in the middle of `<body>`, `document.write()`, scripts placed before stylesheets.
12
+ - **Stylesheets:** gzip size of all render-blocking CSS per page against a budget (default 50 kB), too many blocking stylesheets, `@import` in CSS files or inline styles, print stylesheets without `media="print"`, and a hint to inline critical CSS when blocking CSS exceeds the 14 kB first round trip.
13
+ - **Third parties:** render-blocking resources from other origins without `<link rel="preconnect">`.
14
+ - **Knows the async patterns:** `media="print" onload="this.media='all'"`, module scripts, JSON-LD and template scripts are not reported.
15
+
16
+ ### Scope
17
+
18
+ render-pulse inspects the HTML and CSS on disk; it does not run a browser. Font loading has its own tool (font-pulse), JavaScript size is bundle-pulse, and lab timings are vitals-pulse.
19
+
20
+ ## Rules
21
+
22
+ | Rule | Default severity | What it means |
23
+ |---|---|---|
24
+ | `render-blocking-script` | high | Classic <script src> in <head> without defer or async blocks rendering |
25
+ | `render-blocking-css-heavy` | medium | Render-blocking CSS is large (inline the critical CSS, load the rest later) |
26
+ | `render-css-import` | medium | @import in render-blocking CSS serialises downloads |
27
+ | `render-third-party-blocking` | medium | Render-blocking resource from another origin without preconnect |
28
+ | `render-document-write` | medium | document.write() in a script delays parsing and can block on network |
29
+ | `render-parser-blocking-body` | low | Synchronous external <script> in <body> blocks parsing of everything after it |
30
+ | `render-too-many-stylesheets` | low | Many render-blocking stylesheets |
31
+ | `render-print-css-blocking` | low | Print/alternate stylesheet without a matching media attribute blocks rendering |
32
+ | `render-large-inline` | low | Very large inline <style> or <script> in <head> |
33
+ | `render-sync-before-css` | low | Synchronous script placed before stylesheets delays CSS discovery |
34
+ | `render-no-critical-css` | info | Blocking CSS over 14 KB and no inline critical CSS |
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 render-pulse dist
42
+ npx render-pulse public --max-css-kb 30 --fail-on medium
43
+ npx render-pulse _site --format sarif > render.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
+ render-pulse — https://example.com
52
+ 1 page(s) at https://example.com
53
+
54
+ HIGH index.html:2 /: <script src="/js/jquery.js"> (0.0 KB gzip) in <head> blocks rendering [render-blocking-script]
55
+ HIGH index.html:7 /: <script src="https://tags.example.org/t.js"> in <head> blocks rendering [render-blocking-script]
56
+ MEDIUM css/all.css css/all.css uses @import [render-css-import]
57
+ MEDIUM index.html /: render-blocking resource from https://fonts.example.org without <link rel="preconnect"> [render-third-party-blocking]
58
+ MEDIUM index.html /: render-blocking resource from https://tags.example.org without <link rel="preconnect"> [render-third-party-blocking]
59
+ MEDIUM index.html /: 56.2 KB gzip (195.4 KB raw) of render-blocking CSS (budget 50.0 KB) [render-blocking-css-heavy]
60
+ ```
61
+
62
+ ## Options
63
+
64
+ ```text
65
+ render-pulse 1.0.0 — find render-blocking CSS and JS in your built site
66
+
67
+ Usage: render-pulse [site-dir] [options]
68
+
69
+ Site
70
+ --base-url <url> Public URL of the site, e.g. https://example.com (default: inferred from canonical/og:url)
71
+ --include <globs> Only check these pages (file or URL path globs, comma-separated)
72
+ --exclude <globs> Skip these pages
73
+ --clean-urls Pages are served without .html (about.html -> /about)
74
+ Render
75
+ --max-css-kb <n> Render-blocking CSS budget per page, gzip KB (default 50)
76
+
77
+ Common
78
+ -c, --config <file> JSON config (default: .render-pulse.json or "renderPulse" in package.json)
79
+ -f, --format <fmt> text (default) | json | sarif | markdown
80
+ --json <file> Also write a JSON report
81
+ --sarif <file> Also write SARIF 2.1.0
82
+ --markdown <file> Also write Markdown (e.g. for a job summary)
83
+ --fail-on <sev> critical | high | medium | low | info | none (default: high)
84
+ --list-rules Print rules and default severities
85
+ --no-fixes Hide fix suggestions in text output
86
+ --no-color Disable colours
87
+ -v, --version / -h, --help
88
+
89
+ Exit codes: 0 = passed, 1 = findings at or above --fail-on, 2 = usage, config or read error.
90
+ ```
91
+
92
+ ## Configuration and allowlist
93
+
94
+ Options can live in `.render-pulse.json` (or a `"renderPulse"` key in `package.json`, or `--config <file>`):
95
+
96
+ ```json
97
+ {
98
+ "failOn": "medium",
99
+ "severity": {
100
+ "render-blocking-script": "low"
101
+ },
102
+ "allowlist": [
103
+ {
104
+ "rule": "render-blocking-script",
105
+ "subject": "/js/consent-loader.js",
106
+ "reason": "Consent manager must run before other tags",
107
+ "expires": "2027-03-31"
108
+ }
109
+ ],
110
+ "maxCss": 40960,
111
+ "maxStylesheets": 2,
112
+ "maxInline": 30720,
113
+ "exclude": [
114
+ "/legacy/**"
115
+ ]
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 `.render-pulse.json` can live at your repository root. Other keys: `maxCss` (bytes gzip), `maxStylesheets` (default 3), `maxInline` (default 50 kB), plus `baseUrl`, `include`, `exclude` and `cleanUrls`.
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
+ render-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/render-pulse@v1
144
+ with:
145
+ path: dist
146
+ fail-on: high
147
+ sarif: render-pulse.sarif
148
+ - uses: github/codeql-action/upload-sarif@v3
149
+ if: always()
150
+ with: { sarif_file: render-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, blocksRender } from "render-pulse";
169
+ blocksRender({ rel: "stylesheet", media: "print" }); // false
170
+ const report = check("dist", { maxCss: 30 * 1024 });
171
+ ```
172
+
173
+ Types are included.
174
+
175
+ ## Requirements
176
+
177
+ Node.js 18 or newer. Zero runtime dependencies.
178
+
179
+ ## Licence
180
+
181
+ MIT © Louis Wright. See [LICENSE](LICENSE).
package/action.yml ADDED
@@ -0,0 +1,47 @@
1
+ name: 'render-pulse'
2
+ description: 'Find render-blocking CSS and JS in your built site'
3
+ author: 'Louis Wright'
4
+ branding: { icon: 'alert-triangle', 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/render-pulse.js
33
+ run: |
34
+ md="$RUNNER_TEMP/render-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: 'render-pulse', version: pkg.version, configKey: 'renderPulse', rules: RULES,
8
+ tagline: 'find render-blocking CSS and JS in your built site',
9
+ usage: 'Usage: render-pulse [site-dir] [options]',
10
+ help: `${SITE_HELP}Render
11
+ --max-css-kb <n> Render-blocking CSS budget per page, gzip KB (default 50)
12
+ `,
13
+ options: { ...SITE_OPTIONS, 'max-css-kb': { type: 'string' } },
14
+ run: async ({ dir, values: o, config, configFile }) => check(dir, { cli: o, maxCss: o['max-css-kb'] != null ? Number(o['max-css-kb']) * 1024 : undefined, toolVersion: pkg.version }, config, configFile),
15
+ });
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "render-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": "Find render-blocking resources in a built static site: head scripts without defer/async, heavy blocking CSS, @import chains, third-party blocking resources without preconnect, document.write, parser-blocking body scripts, print CSS without media, missing critical CSS. SARIF, GitHub 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
+ "render-pulse": "bin/render-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
+ "render-blocking",
34
+ "critical-css",
35
+ "performance",
36
+ "defer",
37
+ "async",
38
+ "fcp",
39
+ "lcp",
40
+ "core-web-vitals",
41
+ "static-site",
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/render-pulse.git"
49
+ },
50
+ "bugs": {
51
+ "url": "https://github.com/MrLouisWright/render-pulse/issues"
52
+ },
53
+ "homepage": "https://github.com/MrLouisWright/render-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,6 @@
1
+ import type { Report, Rule } from './core.js';
2
+ export * from './core.js';
3
+ export interface RenderOptions { baseUrl?: string; include?: string[]; exclude?: string[]; cleanUrls?: boolean; maxCss?: number; toolVersion?: string; now?: Date }
4
+ export declare const RULES: Record<string, Rule>;
5
+ export declare function blocksRender(attrs: Record<string, string>): boolean;
6
+ export declare function check(dir: string, opts?: RenderOptions, config?: Record<string, unknown>, configFile?: string): Report;
package/src/index.js ADDED
@@ -0,0 +1,90 @@
1
+ // render-pulse: find render-blocking CSS and JS in the critical path of a built static site and suggest
2
+ // defer/async/media/critical-CSS fixes. Zero dependencies. Louis Wright, MIT.
3
+ import { gzipSync } from 'node:zlib';
4
+ import { runBeacon, finder } 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
+ 'render-blocking-script': { severity: 'high', title: 'Classic <script src> in <head> without defer or async blocks rendering' },
12
+ 'render-blocking-css-heavy': { severity: 'medium', title: 'Render-blocking CSS is large (inline the critical CSS, load the rest later)' },
13
+ 'render-css-import': { severity: 'medium', title: '@import in render-blocking CSS serialises downloads' },
14
+ 'render-third-party-blocking': { severity: 'medium', title: 'Render-blocking resource from another origin without preconnect' },
15
+ 'render-document-write': { severity: 'medium', title: 'document.write() in a script delays parsing and can block on network' },
16
+ 'render-parser-blocking-body': { severity: 'low', title: 'Synchronous external <script> in <body> blocks parsing of everything after it' },
17
+ 'render-too-many-stylesheets': { severity: 'low', title: 'Many render-blocking stylesheets' },
18
+ 'render-print-css-blocking': { severity: 'low', title: 'Print/alternate stylesheet without a matching media attribute blocks rendering' },
19
+ 'render-large-inline': { severity: 'low', title: 'Very large inline <style> or <script> in <head>' },
20
+ 'render-sync-before-css': { severity: 'low', title: 'Synchronous script placed before stylesheets delays CSS discovery' },
21
+ 'render-no-critical-css': { severity: 'info', title: 'Blocking CSS over 14 KB and no inline critical CSS' },
22
+ };
23
+ const kb = (n) => `${(n / 1024).toFixed(1)} KB`;
24
+ const gz = (s) => gzipSync(Buffer.from(s)).length;
25
+ const isJsType = (t) => !t || /^(text|application)\/(java|ecma)script$|^module$/i.test(t.trim());
26
+ /** True when a <link rel=stylesheet> blocks rendering for screen. */
27
+ export function blocksRender(attrs) {
28
+ if (attrs.disabled != null) return false; const rel = (attrs.rel || '').toLowerCase();
29
+ if (/\balternate\b/.test(rel)) return false;
30
+ const media = (attrs.media || 'all').toLowerCase().trim();
31
+ if (/^print$/.test(media) || /\bprint\b/.test(media) && !/\b(all|screen)\b/.test(media)) return false;
32
+ if (/^\(prefers-color-scheme/.test(media) || /orientation|min-width|max-width/.test(media)) return true; // conservative: may match
33
+ return true;
34
+ }
35
+
36
+ function analyze(site, opts, config) {
37
+ const { findings, add } = finder(RULES);
38
+ const cssBudget = opts.maxCss ?? config.maxCss ?? 50 * 1024; const maxSheets = config.maxStylesheets ?? 3; const inlineMax = config.maxInline ?? 50 * 1024;
39
+ const cache = new Map();
40
+ const local = (u) => { if (!u || !site.isInternal(u)) return null; let p = u.pathname; if (site.basePath && p.startsWith(site.basePath)) p = p.slice(site.basePath.length); try { p = decodeURIComponent(p); } catch { /* keep */ } p = p.replace(/^\/+/, ''); if (!cache.has(p)) { const t = site.read(p); cache.set(p, t); } return { rel: p, text: cache.get(p) }; };
41
+ let blockingTotal = 0;
42
+ for (const pg of site.pages) {
43
+ const doc = pg.doc; const head = doc.all.filter((e) => e.inHead);
44
+ const preconnect = new Set(doc.links.filter((l) => l.rel.includes('preconnect')).map((l) => { try { return new URL(l.href, 'https://x.invalid').origin; } catch { return ''; } }));
45
+ const at = (e) => ({ file: pg.file, line: e.line });
46
+ let sheets = 0; let cssBytes = 0; let cssGz = 0; let seenSyncScript = null; let warnedOrder = false; const third = new Set();
47
+ const inlineCss = head.filter((e) => e.tag === 'style').reduce((a, e) => a + (e.rawText || '').length, 0);
48
+ for (const e of head) {
49
+ const a = e.attrs;
50
+ if (e.tag === 'script') {
51
+ const t = a.type; if (!isJsType(t)) continue;
52
+ if (a.src) {
53
+ const isModule = /^module$/i.test(t || ''); if (a.async != null || a.defer != null || isModule) continue;
54
+ const u = site.resolve(pg, a.src); const l = local(u); const size = l?.text != null ? ` (${kb(gz(l.text))} gzip)` : '';
55
+ add('render-blocking-script', a.src, `${pg.path}: <script src="${a.src}">${size} in <head> blocks rendering`, 'Add defer (keeps order, runs after parsing) or async (independent scripts like analytics), or type="module".', at(e));
56
+ seenSyncScript ||= e;
57
+ if (u && !site.isInternal(u) && !preconnect.has(u.origin)) third.add(u.origin);
58
+ } else if (/document\.write\s*\(/.test(e.rawText || '')) add('render-document-write', `${pg.path}:${e.line}`, `${pg.path}: inline script calls document.write()`, 'Insert elements with DOM APIs instead; Chrome may block document.write-injected scripts on slow connections.', at(e));
59
+ else if ((e.rawText || '').length > inlineMax) add('render-large-inline', `${pg.path}:${e.line}`, `${pg.path}: inline <script> of ${kb((e.rawText || '').length)} in <head>`, 'Move it to a cached external file with defer.', at(e));
60
+ continue;
61
+ }
62
+ if (e.tag === 'style') { const css = e.rawText || ''; if (css.length > inlineMax) add('render-large-inline', `${pg.path}:${e.line}`, `${pg.path}: inline <style> of ${kb(css.length)} in <head>`, 'Inline only the critical above-the-fold CSS (under ~14 KB); load the rest from a cached file.', at(e)); if (/@import\s/i.test(css)) add('render-css-import', `${pg.path}:${e.line}`, `${pg.path}: inline <style> uses @import`, 'Replace @import with <link rel="stylesheet"> tags so downloads start in parallel.', at(e)); continue; }
63
+ if (e.tag !== 'link' || !/\bstylesheet\b/i.test(a.rel || '') || !a.href) continue;
64
+ const u = site.resolve(pg, a.href);
65
+ if (!blocksRender(a)) continue;
66
+ if (/(^|[/._-])print([._-]|\.css)/i.test(a.href) && !a.media) add('render-print-css-blocking', a.href, `${pg.path}: ${a.href} looks like a print stylesheet but has no media="print"`, 'Add media="print" so it downloads at low priority without blocking rendering.', at(e));
67
+ sheets++;
68
+ if (seenSyncScript && !warnedOrder) { add('render-sync-before-css', a.href, `${pg.path}: stylesheet ${a.href} comes after a synchronous script (line ${seenSyncScript.line})`, 'Put stylesheets before scripts in <head> (or defer the script) so CSS downloads start immediately.', at(e)); warnedOrder = true; }
69
+ if (u && !site.isInternal(u)) { if (!preconnect.has(u.origin)) third.add(u.origin); continue; }
70
+ const l = local(u); if (l?.text == null) continue;
71
+ cssBytes += l.text.length; cssGz += gz(l.text);
72
+ if (/@import\s/i.test(l.text.replace(/\/\*[\s\S]*?\*\//g, ''))) add('render-css-import', l.rel, `${l.rel} uses @import`, 'Bundle the imported CSS or link it directly, so the browser does not discover it only after downloading this file.', { file: l.rel });
73
+ }
74
+ for (const o of third) add('render-third-party-blocking', `${pg.path}|${o}`, `${pg.path}: render-blocking resource from ${o} without <link rel="preconnect">`, `Self-host it, load it without blocking, or add <link rel="preconnect" href="${o}">.`, { file: pg.file });
75
+ if (sheets > maxSheets) add('render-too-many-stylesheets', pg.path, `${pg.path} has ${sheets} render-blocking stylesheets`, `Combine them (max ${maxSheets}) or load non-critical ones with media/print-onload.`, { file: pg.file });
76
+ if (cssGz > cssBudget) add('render-blocking-css-heavy', pg.path, `${pg.path}: ${kb(cssGz)} gzip (${kb(cssBytes)} raw) of render-blocking CSS (budget ${kb(cssBudget)})`, 'Inline critical CSS (e.g. critters/beasties) and load the rest asynchronously; remove unused CSS.', { file: pg.file });
77
+ else if (cssGz > 14 * 1024 && inlineCss < 500) add('render-no-critical-css', pg.path, `${pg.path}: ${kb(cssGz)} gzip of blocking CSS and no inline critical CSS`, 'Consider inlining above-the-fold CSS so first paint needs only the HTML round trip.', { file: pg.file });
78
+ blockingTotal += cssGz;
79
+ for (const e of doc.all) if (!e.inHead && e.tag === 'script' && e.attrs.src && isJsType(e.attrs.type) && !/^module$/i.test(e.attrs.type || '') && e.attrs.async == null && e.attrs.defer == null) {
80
+ const after = doc.all.indexOf(e); const rest = doc.all.slice(after + 1).filter((x) => !['script', 'noscript', 'template', 'style', 'link'].includes(x.tag) && x.tag !== '#document').length;
81
+ if (rest > 5) add('render-parser-blocking-body', e.attrs.src, `${pg.path}: synchronous <script src="${e.attrs.src}"> in <body> before ${rest} more elements`, 'Move it to the end of <body> or add defer/async.', at(e));
82
+ }
83
+ }
84
+ return { findings, info: { blockingCssGzip: blockingTotal } };
85
+ }
86
+
87
+ export function check(dir, opts = {}, config = {}, configFile) {
88
+ const tool = { name: 'render-pulse', version: opts.toolVersion || '1.0.0', rules: RULES, emoji: '🚦', defaultFile: 'index.html' };
89
+ return runBeacon(tool, dir, { ...opts, UsageError }, config, configFile, analyze, buildReport);
90
+ }
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
+ }