canonical-beacon 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 +12 -0
- package/LICENSE +21 -0
- package/README.md +199 -2
- package/action.yml +47 -0
- package/bin/canonical-beacon.js +16 -0
- package/package.json +17 -4
- package/src/core.d.ts +16 -0
- package/src/core.js +176 -0
- package/src/index.d.ts +17 -0
- package/src/index.js +133 -0
- package/src/site.js +326 -0
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
|
+
- Canonical checks: missing, multiple, invalid, relative, outside `<head>`, tracking parameters/fragments, `og:url` and `Link` header mismatches.
|
|
8
|
+
- Canonical targets: must exist in the build, not be an error page or meta-refresh stub, not be `noindex`, and not chain to another canonical; cross-host/protocol canonicals flagged unless allowed.
|
|
9
|
+
- `noindex` + cross-page canonical conflict.
|
|
10
|
+
- hreflang: code validation (ISO 639-1 / ISO 3166-1 / UN M.49 / x-default), self-reference, reciprocal return links, duplicate codes, relative hrefs, missing/noindex/non-canonical alternates, hreflang on non-canonical pages, x-default and `<html lang>` agreement.
|
|
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,200 @@
|
|
|
1
|
-
#
|
|
1
|
+
# canonical-beacon
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> Check canonical tags and hreflang clusters in your built static site: one canonical per page, pointing at a real, indexable, final URL, and hreflang clusters that are valid, self-referencing and reciprocal.
|
|
4
|
+
|
|
5
|
+
Canonical and hreflang mistakes are silent. A template ships two canonicals, a page canonicalises to a URL that was renamed, a translated page points hreflang at an English page that never links back, or a region is written `en-UK`. Search engines then ignore the hints, index the wrong URL or show the wrong language. `canonical-beacon` reads the built HTML (plus `Link` headers from `_headers`/`vercel.json`) and checks every canonical and hreflang annotation against the other pages in the build. Each finding has the file, line, severity and 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
|
+
- **Canonical tags:** missing, more than one, empty or invalid, relative, outside `<head>`, tracking parameters or `#fragment`, `og:url` and `Link` header disagreements.
|
|
12
|
+
- **Canonical targets:** the target page must exist in the build, must not be a 404 page or meta-refresh stub, must not be `noindex`, and must not canonicalise somewhere else again (chains). Cross-host and http/https canonicals are flagged unless you allow cross-domain canonicals.
|
|
13
|
+
- **Mixed signals:** a `noindex` page that also canonicalises to another URL.
|
|
14
|
+
- **hreflang codes:** ISO 639-1 language with optional script and ISO 3166-1 / UN M.49 region (`en-GB`, `zh-Hant-TW`, `es-419`) or `x-default`; catches `en-UK`, `en_GB` and region-only codes.
|
|
15
|
+
- **hreflang clusters:** self-reference, return links (reciprocity), duplicate codes, relative hrefs, alternates that are missing, `noindex` or non-canonical, hreflang on non-canonical pages, `x-default` presence, and `<html lang>` agreeing with the page’s own code.
|
|
16
|
+
- Text/JSON/SARIF/Markdown output, `--fail-on`, exit codes 0/1/2, allowlist with expiry, severity overrides, GitHub Action with job summary.
|
|
17
|
+
|
|
18
|
+
### Scope
|
|
19
|
+
|
|
20
|
+
canonical-beacon does not crawl the live site, follow HTTP redirects or check broken links; use redirect-sentinel and static-link-sentinel for those. Alternates on other domains are accepted but cannot be verified offline.
|
|
21
|
+
|
|
22
|
+
## Rules
|
|
23
|
+
|
|
24
|
+
| Rule | Default severity | What it means |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `canonical-missing` | medium | Page has no rel=canonical |
|
|
27
|
+
| `canonical-multiple` | high | Page declares more than one different canonical URL |
|
|
28
|
+
| `canonical-invalid` | high | Canonical href is empty, unparsable or not http(s) |
|
|
29
|
+
| `canonical-relative` | low | Canonical href is relative (absolute URLs are recommended) |
|
|
30
|
+
| `canonical-in-body` | high | rel=canonical is outside <head>, so search engines ignore it |
|
|
31
|
+
| `canonical-other-host` | medium | Canonical points to another host or protocol |
|
|
32
|
+
| `canonical-target-missing` | high | Canonical target has no page in the build |
|
|
33
|
+
| `canonical-target-not-final` | high | Canonical target is an error page or meta-refresh stub |
|
|
34
|
+
| `canonical-target-noindex` | high | Canonical target is noindex |
|
|
35
|
+
| `canonical-chain` | medium | Canonical target itself canonicalises somewhere else |
|
|
36
|
+
| `canonical-noindex-conflict` | medium | Page is noindex and also canonicalises to another URL (mixed signals) |
|
|
37
|
+
| `canonical-has-query-or-fragment` | low | Canonical URL contains a #fragment or tracking query parameters |
|
|
38
|
+
| `canonical-og-url-mismatch` | low | og:url differs from the canonical URL |
|
|
39
|
+
| `canonical-header-mismatch` | medium | Link: rel=canonical header disagrees with the HTML canonical |
|
|
40
|
+
| `hreflang-invalid-code` | high | hreflang value is not a valid language(-region) code or x-default |
|
|
41
|
+
| `hreflang-duplicate-code` | high | Same hreflang code points to different URLs |
|
|
42
|
+
| `hreflang-no-self` | medium | hreflang cluster does not include the page itself |
|
|
43
|
+
| `hreflang-no-return` | high | Alternate page does not link back (non-reciprocal hreflang) |
|
|
44
|
+
| `hreflang-target-missing` | high | hreflang alternate has no page in the build |
|
|
45
|
+
| `hreflang-target-non-canonical` | high | hreflang alternate is noindex or canonicalises elsewhere |
|
|
46
|
+
| `hreflang-on-non-canonical` | medium | Page with hreflang canonicalises elsewhere |
|
|
47
|
+
| `hreflang-relative` | medium | hreflang href is relative (must be fully-qualified) |
|
|
48
|
+
| `hreflang-no-x-default` | info | hreflang cluster has no x-default |
|
|
49
|
+
| `hreflang-lang-mismatch` | low | Page <html lang> does not match its own hreflang code |
|
|
50
|
+
| `hreflang-in-body` | high | hreflang link is outside <head> |
|
|
51
|
+
| `allowlist-expired` | medium | An allowlist entry is past its `expires` date |
|
|
52
|
+
| `allowlist-unused` | info | An allowlist entry matched nothing |
|
|
53
|
+
|
|
54
|
+
## Install and run
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npx canonical-beacon dist
|
|
58
|
+
npx canonical-beacon dist --base-url https://example.com --fail-on medium
|
|
59
|
+
npx canonical-beacon out --clean-urls --exclude "/drafts/**"
|
|
60
|
+
npx canonical-beacon public --allow-cross-domain --format sarif > canonical.sarif
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
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`.
|
|
64
|
+
|
|
65
|
+
### Sample output
|
|
66
|
+
|
|
67
|
+
```text
|
|
68
|
+
canonical-beacon — https://example.com
|
|
69
|
+
11 page(s); 9 with canonical, 2 with hreflang at https://example.com
|
|
70
|
+
|
|
71
|
+
HIGH a.html:1 /a.html declares 2 canonicals: https://example.com/a.html, https://example.com/b.html [canonical-multiple]
|
|
72
|
+
↳ fix: Keep exactly one rel=canonical; search engines may ignore all of them otherwise.
|
|
73
|
+
HIGH c.html:1 Canonical https://example.com/d.html is noindex [canonical-target-noindex]
|
|
74
|
+
↳ fix: Canonicalise to an indexable page, or remove noindex from the target.
|
|
75
|
+
HIGH e.html:1 /e.html declares 2 canonicals: https://example.com/gone.html, https://example.com/e.html [canonical-multiple]
|
|
76
|
+
↳ fix: Keep exactly one rel=canonical; search engines may ignore all of them otherwise.
|
|
77
|
+
HIGH e.html:1 rel=canonical on line 1 is outside <head> [canonical-in-body]
|
|
78
|
+
↳ fix: Move it into <head>.
|
|
79
|
+
HIGH e.html:1 Canonical https://example.com/gone.html has no page in the build [canonical-target-missing]
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Options
|
|
83
|
+
|
|
84
|
+
```text
|
|
85
|
+
canonical-beacon 1.0.0 — check canonical tags and hreflang clusters in your built static site
|
|
86
|
+
|
|
87
|
+
Usage: canonical-beacon [site-dir] [options]
|
|
88
|
+
|
|
89
|
+
Site
|
|
90
|
+
--base-url <url> Public URL of the site, e.g. https://example.com (default: inferred from canonical/og:url)
|
|
91
|
+
--include <globs> Only check these pages (file or URL path globs, comma-separated)
|
|
92
|
+
--exclude <globs> Skip these pages
|
|
93
|
+
--clean-urls Pages are served without .html (about.html -> /about)
|
|
94
|
+
Checks
|
|
95
|
+
--no-require-canonical Do not report pages without rel=canonical
|
|
96
|
+
--allow-cross-domain Accept canonicals that point to another host
|
|
97
|
+
|
|
98
|
+
Common
|
|
99
|
+
-c, --config <file> JSON config (default: .canonical-beacon.json or "canonicalBeacon" in package.json)
|
|
100
|
+
-f, --format <fmt> text (default) | json | sarif | markdown
|
|
101
|
+
--json <file> Also write a JSON report
|
|
102
|
+
--sarif <file> Also write SARIF 2.1.0
|
|
103
|
+
--markdown <file> Also write Markdown (e.g. for a job summary)
|
|
104
|
+
--fail-on <sev> critical | high | medium | low | info | none (default: high)
|
|
105
|
+
--list-rules Print rules and default severities
|
|
106
|
+
--no-fixes Hide fix suggestions in text output
|
|
107
|
+
--no-color Disable colours
|
|
108
|
+
-v, --version / -h, --help
|
|
109
|
+
|
|
110
|
+
Exit codes: 0 = passed, 1 = findings at or above --fail-on, 2 = usage, config or read error.
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Configuration and allowlist
|
|
114
|
+
|
|
115
|
+
Options can live in `.canonical-beacon.json` (or a `"canonicalBeacon"` key in `package.json`, or `--config <file>`):
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"failOn": "medium",
|
|
120
|
+
"severity": {
|
|
121
|
+
"canonical-missing": "low"
|
|
122
|
+
},
|
|
123
|
+
"allowlist": [
|
|
124
|
+
{
|
|
125
|
+
"rule": "canonical-missing",
|
|
126
|
+
"subject": "/legacy/*",
|
|
127
|
+
"reason": "Legacy pages are being migrated",
|
|
128
|
+
"expires": "2027-03-31"
|
|
129
|
+
}
|
|
130
|
+
],
|
|
131
|
+
"requireCanonical": true,
|
|
132
|
+
"allowCrossDomain": false,
|
|
133
|
+
"xDefaultOptional": false
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- **`severity`** changes a rule's severity, or turns it `"off"`.
|
|
138
|
+
- **`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).
|
|
139
|
+
|
|
140
|
+
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 `.canonical-beacon.json` can live at your repository root. Other keys: `requireCanonical` (default true), `allowCrossDomain` (default false) and `xDefaultOptional` (default false; set true to silence `hreflang-no-x-default`).
|
|
141
|
+
|
|
142
|
+
## Output and exit codes
|
|
143
|
+
|
|
144
|
+
- `--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.
|
|
145
|
+
- `--fail-on <severity>` sets the threshold (`critical`, `high`, `medium`, `low`, `info`, `none`; default `high`).
|
|
146
|
+
- Exit codes: **0** passed, **1** findings at or above `--fail-on`, **2** usage, config or read error.
|
|
147
|
+
|
|
148
|
+
## GitHub Action
|
|
149
|
+
|
|
150
|
+
```yaml
|
|
151
|
+
permissions:
|
|
152
|
+
contents: read
|
|
153
|
+
security-events: write # only needed for the SARIF upload
|
|
154
|
+
jobs:
|
|
155
|
+
canonical-beacon:
|
|
156
|
+
runs-on: ubuntu-latest
|
|
157
|
+
steps:
|
|
158
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
|
159
|
+
with: { persist-credentials: false }
|
|
160
|
+
- run: npm ci && npm run build # produce the static site first
|
|
161
|
+
- uses: MrLouisWright/canonical-beacon@v1
|
|
162
|
+
with:
|
|
163
|
+
path: dist
|
|
164
|
+
fail-on: high
|
|
165
|
+
sarif: canonical-beacon.sarif
|
|
166
|
+
- uses: github/codeql-action/upload-sarif@v3
|
|
167
|
+
if: always()
|
|
168
|
+
with: { sarif_file: canonical-beacon.sarif }
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
| Input | Description | Default |
|
|
172
|
+
|---|---|---|
|
|
173
|
+
| `path` | Built site directory (e.g. dist, public, _site) | `.` |
|
|
174
|
+
| `base-url` | Public URL of the site, e.g. https://example.com | |
|
|
175
|
+
| `fail-on` | Failure threshold | `high` |
|
|
176
|
+
| `config` | Config file path | |
|
|
177
|
+
| `sarif` / `json` | Write SARIF / JSON report to this path | |
|
|
178
|
+
| `job-summary` | Add the Markdown report to the job summary | `true` |
|
|
179
|
+
| `args` | Extra CLI arguments | |
|
|
180
|
+
|
|
181
|
+
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.
|
|
182
|
+
|
|
183
|
+
## Library
|
|
184
|
+
|
|
185
|
+
```js
|
|
186
|
+
import { check, validHreflang } from "canonical-beacon";
|
|
187
|
+
const report = check("dist", { baseUrl: "https://example.com" });
|
|
188
|
+
console.log(report.counts, report.findings[0]);
|
|
189
|
+
validHreflang("en-UK"); // { ok: false, reason: ... }
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Types are included.
|
|
193
|
+
|
|
194
|
+
## Requirements
|
|
195
|
+
|
|
196
|
+
Node.js 18 or newer. Zero runtime dependencies.
|
|
197
|
+
|
|
198
|
+
## Licence
|
|
199
|
+
|
|
200
|
+
MIT © Louis Wright. See [LICENSE](LICENSE).
|
package/action.yml
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
name: 'canonical-beacon'
|
|
2
|
+
description: 'Check canonical tags and hreflang clusters in your built static site'
|
|
3
|
+
author: 'Louis Wright'
|
|
4
|
+
branding: { icon: 'link', 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/canonical-beacon.js
|
|
33
|
+
run: |
|
|
34
|
+
md="$RUNNER_TEMP/canonical-beacon.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,16 @@
|
|
|
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: 'canonical-beacon', version: pkg.version, configKey: 'canonicalBeacon', rules: RULES,
|
|
8
|
+
tagline: 'check canonical tags and hreflang clusters in your built static site',
|
|
9
|
+
usage: 'Usage: canonical-beacon [site-dir] [options]',
|
|
10
|
+
help: `${SITE_HELP}Checks
|
|
11
|
+
--no-require-canonical Do not report pages without rel=canonical
|
|
12
|
+
--allow-cross-domain Accept canonicals that point to another host
|
|
13
|
+
`,
|
|
14
|
+
options: { ...SITE_OPTIONS, 'no-require-canonical': { type: 'boolean' }, 'allow-cross-domain': { type: 'boolean' } },
|
|
15
|
+
run: async ({ dir, values: o, config, configFile }) => check(dir, { cli: o, requireCanonical: o['no-require-canonical'] ? false : undefined, allowCrossDomain: o['allow-cross-domain'] || undefined, toolVersion: pkg.version }, config, configFile),
|
|
16
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "canonical-beacon",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Check canonical tags and hreflang clusters in your built static site: missing, multiple, relative or in-body canonicals, targets that are missing, noindex or chained, and hreflang codes, self-references, return links and x-default. Text/JSON/SARIF/Markdown, allowlist with expiry, GitHub Action. Zero deps.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.js",
|
|
7
|
+
"types": "./src/index.d.ts",
|
|
8
|
+
"exports": { ".": { "types": "./src/index.d.ts", "default": "./src/index.js" }, "./package.json": "./package.json" },
|
|
9
|
+
"bin": { "canonical-beacon": "bin/canonical-beacon.js" },
|
|
10
|
+
"files": ["src", "bin", "action.yml", "README.md", "CHANGELOG.md", "LICENSE"],
|
|
11
|
+
"engines": { "node": ">=18" },
|
|
12
|
+
"scripts": { "test": "node --test test/*.test.js" },
|
|
13
|
+
"keywords": ["canonical", "rel-canonical", "hreflang", "international-seo", "duplicate-content", "seo", "static-site", "html", "sarif", "github-action"],
|
|
14
|
+
"author": "Louis Wright <hello@louiswright.co.uk> (https://github.com/MrLouisWright)",
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": { "type": "git", "url": "git+https://github.com/MrLouisWright/canonical-beacon.git" },
|
|
17
|
+
"bugs": { "url": "https://github.com/MrLouisWright/canonical-beacon/issues" },
|
|
18
|
+
"homepage": "https://github.com/MrLouisWright/canonical-beacon#readme"
|
|
19
|
+
}
|
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,17 @@
|
|
|
1
|
+
import type { Report, Rule } from './core.js';
|
|
2
|
+
export * from './core.js';
|
|
3
|
+
export interface CanonicalOptions {
|
|
4
|
+
/** Public URL of the site, e.g. "https://example.com". Inferred from canonical/og:url when omitted. */
|
|
5
|
+
baseUrl?: string; include?: string[]; exclude?: string[]; cleanUrls?: boolean;
|
|
6
|
+
/** Report pages without rel=canonical (default true). */
|
|
7
|
+
requireCanonical?: boolean;
|
|
8
|
+
/** Accept canonicals that point to another host. */
|
|
9
|
+
allowCrossDomain?: boolean;
|
|
10
|
+
now?: Date; toolVersion?: string; [option: string]: unknown;
|
|
11
|
+
}
|
|
12
|
+
export declare const RULES: Record<string, Rule>;
|
|
13
|
+
export declare const SITE_OPTIONS: Record<string, { type: 'string' | 'boolean'; multiple?: boolean }>;
|
|
14
|
+
export declare const SITE_HELP: string;
|
|
15
|
+
export declare function check(dir: string, opts?: CanonicalOptions, config?: Record<string, unknown>, configFile?: string): Report;
|
|
16
|
+
export declare function analyze(site: unknown, opts?: CanonicalOptions, config?: Record<string, unknown>): { findings: Report['findings']; info: Record<string, unknown>; summary: string };
|
|
17
|
+
export declare function validHreflang(value: string): { ok: boolean; xDefault?: boolean; lang?: string; region?: string | null; reason?: string };
|
package/src/index.js
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// canonical-beacon: canonical tag and hreflang cluster checks for built static sites. Louis Wright, MIT.
|
|
2
|
+
import { contentPages, runBeacon, finder, SITE_OPTIONS, SITE_HELP, siteOptions, robotsDirectives, isNoindex, canonicalOf, isRedirectStub, isErrorPage } from './site.js';
|
|
3
|
+
import { buildReport, UsageError } from './core.js';
|
|
4
|
+
|
|
5
|
+
export * from './core.js';
|
|
6
|
+
export { SITE_OPTIONS, SITE_HELP, siteOptions };
|
|
7
|
+
|
|
8
|
+
export const RULES = {
|
|
9
|
+
'canonical-missing': { severity: 'medium', title: 'Page has no rel=canonical' },
|
|
10
|
+
'canonical-multiple': { severity: 'high', title: 'Page declares more than one different canonical URL' },
|
|
11
|
+
'canonical-invalid': { severity: 'high', title: 'Canonical href is empty, unparsable or not http(s)' },
|
|
12
|
+
'canonical-relative': { severity: 'low', title: 'Canonical href is relative (absolute URLs are recommended)' },
|
|
13
|
+
'canonical-in-body': { severity: 'high', title: 'rel=canonical is outside <head>, so search engines ignore it' },
|
|
14
|
+
'canonical-other-host': { severity: 'medium', title: 'Canonical points to another host or protocol' },
|
|
15
|
+
'canonical-target-missing': { severity: 'high', title: 'Canonical target has no page in the build' },
|
|
16
|
+
'canonical-target-not-final': { severity: 'high', title: 'Canonical target is an error page or meta-refresh stub' },
|
|
17
|
+
'canonical-target-noindex': { severity: 'high', title: 'Canonical target is noindex' },
|
|
18
|
+
'canonical-chain': { severity: 'medium', title: 'Canonical target itself canonicalises somewhere else' },
|
|
19
|
+
'canonical-noindex-conflict': { severity: 'medium', title: 'Page is noindex and also canonicalises to another URL (mixed signals)' },
|
|
20
|
+
'canonical-has-query-or-fragment': { severity: 'low', title: 'Canonical URL contains a #fragment or tracking query parameters' },
|
|
21
|
+
'canonical-og-url-mismatch': { severity: 'low', title: 'og:url differs from the canonical URL' },
|
|
22
|
+
'canonical-header-mismatch': { severity: 'medium', title: 'Link: rel=canonical header disagrees with the HTML canonical' },
|
|
23
|
+
'hreflang-invalid-code': { severity: 'high', title: 'hreflang value is not a valid language(-region) code or x-default' },
|
|
24
|
+
'hreflang-duplicate-code': { severity: 'high', title: 'Same hreflang code points to different URLs' },
|
|
25
|
+
'hreflang-no-self': { severity: 'medium', title: 'hreflang cluster does not include the page itself' },
|
|
26
|
+
'hreflang-no-return': { severity: 'high', title: 'Alternate page does not link back (non-reciprocal hreflang)' },
|
|
27
|
+
'hreflang-target-missing': { severity: 'high', title: 'hreflang alternate has no page in the build' },
|
|
28
|
+
'hreflang-target-non-canonical': { severity: 'high', title: 'hreflang alternate is noindex or canonicalises elsewhere' },
|
|
29
|
+
'hreflang-on-non-canonical': { severity: 'medium', title: 'Page with hreflang canonicalises elsewhere' },
|
|
30
|
+
'hreflang-relative': { severity: 'medium', title: 'hreflang href is relative (must be fully-qualified)' },
|
|
31
|
+
'hreflang-no-x-default': { severity: 'info', title: 'hreflang cluster has no x-default' },
|
|
32
|
+
'hreflang-lang-mismatch': { severity: 'low', title: 'Page <html lang> does not match its own hreflang code' },
|
|
33
|
+
'hreflang-in-body': { severity: 'high', title: 'hreflang link is outside <head>' },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const REGIONS_ODD = new Set(['uk', 'eu']);
|
|
37
|
+
/** Validate an hreflang value: ISO 639-1 language, optional script, optional ISO 3166-1 alpha-2 region (or 3-digit UN M.49), or x-default. */
|
|
38
|
+
export function validHreflang(v) {
|
|
39
|
+
const s = String(v || '').trim();
|
|
40
|
+
if (/^x-default$/i.test(s)) return { ok: true, xDefault: true };
|
|
41
|
+
const m = /^([a-z]{2,3})(?:-([a-z]{4}))?(?:-([a-z]{2}|\d{3}))?$/i.exec(s);
|
|
42
|
+
if (!m) return { ok: false, reason: `"${s}" is not language[-Script][-REGION]` };
|
|
43
|
+
if (m[3] && REGIONS_ODD.has(m[3].toLowerCase())) return { ok: false, reason: `"${m[3]}" is not an ISO 3166-1 region (use ${m[3].toLowerCase() === 'uk' ? 'GB' : 'a country code'})` };
|
|
44
|
+
if (!m[2] && !m[3] && m[1].length === 2 && /^(gb|us|uk)$/i.test(m[1])) return { ok: false, reason: `"${s}" is a region, not a language (e.g. en-${m[1].toUpperCase() === 'UK' ? 'GB' : m[1].toUpperCase()})` };
|
|
45
|
+
return { ok: true, lang: m[1].toLowerCase(), region: m[3]?.toUpperCase() || null };
|
|
46
|
+
}
|
|
47
|
+
const TRACK = /^(utm_[a-z]+|gclid|fbclid|msclkid|mc_[a-z]+|ref|sessionid|sid)$/i;
|
|
48
|
+
const strip = (u) => u.href.replace(/#.*$/, '');
|
|
49
|
+
|
|
50
|
+
export function analyze(site, opts = {}, config = {}) {
|
|
51
|
+
const { findings, add } = finder(RULES);
|
|
52
|
+
const requireCanonical = opts.requireCanonical ?? config.requireCanonical ?? true;
|
|
53
|
+
const pages = contentPages(site);
|
|
54
|
+
const canon = new Map();
|
|
55
|
+
const canonOf = (pg) => { if (!canon.has(pg)) canon.set(pg, canonicalOf(site, pg)); return canon.get(pg); };
|
|
56
|
+
const urlOf = (pg) => new URL((site.origin || 'http://site.invalid') + (site.basePath || '') + pg.path);
|
|
57
|
+
let withCanon = 0; let clusters = 0;
|
|
58
|
+
for (const pg of pages) {
|
|
59
|
+
const at = (l) => ({ file: pg.file, line: l?.line });
|
|
60
|
+
const c = canonOf(pg); const self = urlOf(pg);
|
|
61
|
+
const noindex = isNoindex(robotsDirectives(site, pg));
|
|
62
|
+
const hrefs = [...new Set(c.tags.map((t) => String(t.href).trim()))];
|
|
63
|
+
if (!c.tags.length && !c.header.length) { if (requireCanonical && !noindex) add('canonical-missing', pg.path, `${pg.path} has no rel=canonical`, `Add <link rel="canonical" href="${site.origin ? site.urlOf(pg) : 'https://example.com' + pg.path}"> to <head>.`, { file: pg.file }); }
|
|
64
|
+
else {
|
|
65
|
+
withCanon++;
|
|
66
|
+
const resolved = new Set(c.tags.map((t) => site.resolve(pg, t.href)).filter(Boolean).map(strip));
|
|
67
|
+
if (resolved.size > 1 || (hrefs.length > 1 && resolved.size === 0)) add('canonical-multiple', pg.path, `${pg.path} declares ${hrefs.length} canonicals: ${hrefs.join(', ')}`, 'Keep exactly one rel=canonical; search engines may ignore all of them otherwise.', at(c.tags[1]));
|
|
68
|
+
for (const t of c.tags) if (!t.inHead) add('canonical-in-body', pg.path, `rel=canonical on line ${t.line} is outside <head>`, 'Move it into <head>.', at(t));
|
|
69
|
+
const t0 = c.tags.find((t) => t.inHead) || c.tags[0];
|
|
70
|
+
if (t0) {
|
|
71
|
+
const h = String(t0.href).trim();
|
|
72
|
+
if (!h || !c.url || !/^https?:$/.test(c.url.protocol)) { add('canonical-invalid', pg.path, `Canonical href "${h}" is not a valid http(s) URL`, 'Use the absolute URL of the preferred page.', at(t0)); continue; }
|
|
73
|
+
if (!/^https?:\/\//i.test(h)) add('canonical-relative', pg.path, `Canonical "${h}" is relative`, `Use the absolute URL (${c.url.href}).`, at(t0));
|
|
74
|
+
}
|
|
75
|
+
if (c.header.length && t0 && c.url) { const hu = site.resolve(pg, c.header[0]); if (hu && strip(hu) !== strip(c.url)) add('canonical-header-mismatch', pg.path, `Link header canonical ${hu.href} differs from HTML canonical ${c.url.href}`, 'Make both point to the same URL, or remove one.', { file: pg.file }); }
|
|
76
|
+
const cu = c.url;
|
|
77
|
+
if (cu) {
|
|
78
|
+
if (cu.hash || [...cu.searchParams.keys()].some((k) => TRACK.test(k))) add('canonical-has-query-or-fragment', pg.path, `Canonical ${cu.href} contains ${cu.hash ? 'a #fragment' : 'tracking parameters'}`, 'Canonicalise to the clean URL.', at(t0));
|
|
79
|
+
const og = pg.doc.metaContent('og:url');
|
|
80
|
+
if (og) { const ou = site.resolve(pg, og); if (ou && strip(ou).replace(/\/$/, '') !== strip(cu).replace(/\/$/, '')) add('canonical-og-url-mismatch', pg.path, `og:url ${ou.href} differs from canonical ${cu.href}`, 'Set og:url to the canonical URL.', { file: pg.file }); }
|
|
81
|
+
if (site.origin && cu.origin !== site.origin) {
|
|
82
|
+
let sameHost = false; try { sameHost = new URL(site.origin).hostname === cu.hostname; } catch { /* */ }
|
|
83
|
+
if (!(config.allowCrossDomain ?? opts.allowCrossDomain)) add('canonical-other-host', pg.path, `${pg.path} canonicalises to ${cu.href}${sameHost ? ' (protocol differs)' : ' on another host'}`, sameHost ? `Use ${site.origin}.` : 'If this is a deliberate cross-domain canonical, set "allowCrossDomain": true or allowlist it.', at(t0));
|
|
84
|
+
} else {
|
|
85
|
+
const tp = site.find(cu);
|
|
86
|
+
const isSelf = tp === pg || strip(cu) === strip(self);
|
|
87
|
+
if (!tp && !isSelf) add('canonical-target-missing', pg.path, `Canonical ${cu.href} has no page in the build`, 'Point the canonical at a page that exists (check trailing slash and .html).', at(t0));
|
|
88
|
+
else if (tp && !isSelf) {
|
|
89
|
+
if (isErrorPage(tp) || isRedirectStub(tp)) add('canonical-target-not-final', pg.path, `Canonical ${cu.href} is ${isErrorPage(tp) ? 'an error page' : 'a meta-refresh stub'} (${tp.file})`, 'Canonicalise to the final destination page.', at(t0));
|
|
90
|
+
else if (isNoindex(robotsDirectives(site, tp))) add('canonical-target-noindex', pg.path, `Canonical ${cu.href} is noindex`, 'Canonicalise to an indexable page, or remove noindex from the target.', at(t0));
|
|
91
|
+
else { const tc = canonOf(tp).url; const tt = tc && site.find(tc); if (tc && tt !== tp && strip(tc) !== strip(cu)) add('canonical-chain', pg.path, `Canonical ${cu.href} itself canonicalises to ${tc.href}`, `Point ${pg.path} directly at ${tc.href}.`, at(t0)); }
|
|
92
|
+
if (noindex) add('canonical-noindex-conflict', pg.path, `${pg.path} is noindex and canonicalises to ${cu.href}`, 'Use one signal: either canonical (to consolidate) or noindex (to drop).', at(t0));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// hreflang
|
|
98
|
+
const alts = pg.doc.links.filter((l) => l.rel.includes('alternate') && l.hreflang != null);
|
|
99
|
+
if (!alts.length) continue;
|
|
100
|
+
clusters++;
|
|
101
|
+
const codes = new Map(); let hasSelf = false; let hasX = false; let selfCode = null;
|
|
102
|
+
const cu = c.url; const nonCanon = cu && strip(cu) !== strip(self) && site.find(cu) !== pg;
|
|
103
|
+
if (nonCanon) add('hreflang-on-non-canonical', pg.path, `${pg.path} has hreflang but canonicalises to ${cu.href}`, 'Put hreflang only on canonical pages, pointing at canonical URLs.', at(alts[0]));
|
|
104
|
+
for (const a of alts) {
|
|
105
|
+
if (!a.inHead) add('hreflang-in-body', pg.path, `hreflang="${a.hreflang}" link is outside <head>`, 'Move it into <head>.', at(a));
|
|
106
|
+
const v = validHreflang(a.hreflang);
|
|
107
|
+
if (!v.ok) { add('hreflang-invalid-code', pg.path, `hreflang ${v.reason}`, 'Use ISO 639-1 language with optional ISO 3166-1 region, e.g. en, en-GB, pt-BR, or x-default.', at(a)); continue; }
|
|
108
|
+
if (v.xDefault) hasX = true;
|
|
109
|
+
const key = String(a.hreflang).toLowerCase(); const u = site.resolve(pg, a.href);
|
|
110
|
+
if (!u) { add('hreflang-target-missing', pg.path, `hreflang="${a.hreflang}" href "${a.href}" is invalid`, 'Use an absolute URL.', at(a)); continue; }
|
|
111
|
+
if (!/^https?:\/\//i.test(String(a.href).trim())) add('hreflang-relative', pg.path, `hreflang="${a.hreflang}" href "${a.href}" is relative`, `Use the absolute URL (${u.href}).`, at(a));
|
|
112
|
+
if (codes.has(key) && codes.get(key) !== strip(u)) add('hreflang-duplicate-code', pg.path, `hreflang="${a.hreflang}" points to both ${codes.get(key)} and ${strip(u)}`, 'Each code may map to one URL only.', at(a));
|
|
113
|
+
codes.set(key, strip(u));
|
|
114
|
+
const tp = site.origin && u.origin !== site.origin ? undefined : site.find(u);
|
|
115
|
+
if (tp === pg || strip(u) === strip(self)) { hasSelf = true; if (!v.xDefault) selfCode = v; continue; }
|
|
116
|
+
if (site.origin && u.origin !== site.origin) continue; // cross-domain alternates cannot be verified offline
|
|
117
|
+
if (!tp) { add('hreflang-target-missing', pg.path, `hreflang="${a.hreflang}" ${u.href} has no page in the build`, 'Fix the URL or remove the alternate from every page in the cluster.', at(a)); continue; }
|
|
118
|
+
const tc = canonOf(tp).url;
|
|
119
|
+
if (isNoindex(robotsDirectives(site, tp)) || (tc && site.find(tc) !== tp && strip(tc) !== strip(u))) { add('hreflang-target-non-canonical', pg.path, `hreflang="${a.hreflang}" ${u.href} is ${isNoindex(robotsDirectives(site, tp)) ? 'noindex' : 'canonicalised to ' + tc.href}`, 'Point hreflang at the canonical, indexable version.', at(a)); continue; }
|
|
120
|
+
const back = tp.doc.links.filter((l) => l.rel.includes('alternate') && l.hreflang != null).some((l) => { const bu = site.resolve(tp, l.href); return bu && (site.find(bu) === pg || strip(bu) === strip(self)); });
|
|
121
|
+
if (!back) add('hreflang-no-return', pg.path, `${tp.path} (hreflang="${a.hreflang}") does not link back to ${pg.path}`, `Add <link rel="alternate" hreflang="…" href="${site.urlOf(pg)}"> to ${tp.file}.`, at(a));
|
|
122
|
+
}
|
|
123
|
+
if (!hasSelf && !nonCanon) add('hreflang-no-self', pg.path, `${pg.path} has hreflang alternates but none for itself`, 'Include a self-referencing hreflang link for the page’s own language.', at(alts[0]));
|
|
124
|
+
if (!hasX && !(config.xDefaultOptional ?? false)) add('hreflang-no-x-default', pg.path, `${pg.path} hreflang cluster has no x-default`, 'Add hreflang="x-default" pointing at the language selector or default version.', at(alts[0]));
|
|
125
|
+
if (selfCode && pg.doc.lang) { const l = validHreflang(pg.doc.lang); if (l.ok && l.lang !== selfCode.lang) add('hreflang-lang-mismatch', pg.path, `<html lang="${pg.doc.lang}"> but the page’s own hreflang is ${selfCode.lang}${selfCode.region ? '-' + selfCode.region : ''}`, 'Make the html lang attribute and hreflang agree.', { file: pg.file }); }
|
|
126
|
+
}
|
|
127
|
+
return { findings, info: { canonicals: withCanon, hreflangPages: clusters }, summary: `${pages.length} page(s); ${withCanon} with canonical, ${clusters} with hreflang${site.baseUrl ? ` at ${site.baseUrl}` : ''}` };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function check(dir, opts = {}, config = {}, configFile) {
|
|
131
|
+
const tool = { name: 'canonical-beacon', version: opts.toolVersion || '1.0.0', rules: RULES, emoji: '🔗', defaultFile: 'index.html' };
|
|
132
|
+
return runBeacon(tool, dir, { ...opts, UsageError }, config, configFile, analyze, buildReport);
|
|
133
|
+
}
|
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
|
+
}
|