vagejs 0.1.0 → 0.3.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/README.md +196 -25
- package/dist/checks/check.d.ts +2 -0
- package/dist/checks/check.js +106 -2
- package/dist/checks/crawlability.d.ts +34 -0
- package/dist/checks/crawlability.js +142 -0
- package/dist/checks/integrity.d.ts +36 -0
- package/dist/checks/integrity.js +145 -0
- package/dist/checks/links.d.ts +18 -0
- package/dist/checks/links.js +47 -0
- package/dist/checks/verify.d.ts +42 -0
- package/dist/checks/verify.js +264 -0
- package/dist/cli/index.js +118 -10
- package/dist/cli/info.d.ts +46 -0
- package/dist/cli/info.js +65 -5
- package/dist/content/images.d.ts +26 -0
- package/dist/content/images.js +129 -0
- package/dist/content/markdown.d.ts +4 -0
- package/dist/content/markdown.js +8 -1
- package/dist/content/optimize.d.ts +31 -0
- package/dist/content/optimize.js +92 -0
- package/dist/content/sanitize.d.ts +18 -2
- package/dist/content/sanitize.js +49 -9
- package/dist/core/build.js +11 -0
- package/dist/core/clone.d.ts +6 -0
- package/dist/core/clone.js +16 -0
- package/dist/core/config.d.ts +26 -0
- package/dist/core/config.js +43 -4
- package/dist/core/dates.d.ts +2 -0
- package/dist/core/dates.js +4 -0
- package/dist/core/errors.d.ts +2 -0
- package/dist/core/errors.js +3 -0
- package/dist/core/html.d.ts +22 -0
- package/dist/core/html.js +31 -0
- package/dist/core/pages.d.ts +67 -0
- package/dist/core/pages.js +168 -0
- package/dist/core/render.d.ts +24 -1
- package/dist/core/render.js +69 -10
- package/dist/core/site.d.ts +12 -1
- package/dist/core/site.js +77 -18
- package/dist/core/validate.d.ts +2 -0
- package/dist/core/validate.js +6 -0
- package/dist/deploy/cloudflare-pages.js +3 -1
- package/dist/deploy/cloudflare-workers.d.ts +59 -0
- package/dist/deploy/cloudflare-workers.js +223 -0
- package/dist/deploy/github-pages.js +2 -0
- package/dist/deploy/headers.js +1 -1
- package/dist/deploy/publish.d.ts +1 -1
- package/dist/deploy/publish.js +3 -3
- package/dist/deploy/redirects.js +1 -1
- package/dist/deploy/secrets.js +1 -1
- package/dist/dev/guard.d.ts +2 -0
- package/dist/dev/guard.js +30 -1
- package/dist/dev/server.js +22 -4
- package/dist/discovery/feed.d.ts +21 -0
- package/dist/discovery/feed.js +68 -0
- package/dist/discovery/git-dates.d.ts +10 -0
- package/dist/discovery/git-dates.js +57 -0
- package/dist/discovery/markdown-copy.d.ts +13 -0
- package/dist/discovery/markdown-copy.js +112 -0
- package/dist/guidelines/rule-pack.json +101 -2
- package/dist/index.d.ts +4 -1
- package/dist/index.js +4 -1
- package/dist/integrations/api-keys.d.ts +1 -1
- package/dist/integrations/api-keys.js +1 -1
- package/dist/integrations/google-auth.js +1 -1
- package/dist/integrations/tags.d.ts +1 -1
- package/dist/integrations/tags.js +1 -1
- package/dist/seo/head.js +17 -1
- package/package.json +4 -1
- package/templates/starter/.github/dependabot.yml +11 -0
- package/templates/starter/.github/workflows/ci.yml +4 -0
- package/templates/starter/.github/workflows/verify.yml +34 -0
- package/templates/starter/AGENTS.md +45 -16
- package/templates/starter/links.lock.json +4 -0
- package/templates/starter/package.json +1 -1
- package/templates/starter/src/layouts/blog.ts +13 -0
- package/templates/starter/src/layouts/default.ts +8 -7
- package/templates/starter/src/pages/blog/index.md +7 -0
- package/templates/starter/src/pages/index.md +2 -2
- package/templates/starter/vage.config.ts +5 -1
package/README.md
CHANGED
|
@@ -1,12 +1,178 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<
|
|
2
|
+
<picture>
|
|
3
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/agevenkat/vagejs/main/assets/brand/vagejs-logo-dark.svg">
|
|
4
|
+
<img src="https://raw.githubusercontent.com/agevenkat/vagejs/main/assets/brand/vagejs-logo.svg" alt="vagejs" width="320">
|
|
5
|
+
</picture>
|
|
3
6
|
</p>
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
<p align="center">
|
|
9
|
+
<a href="https://www.npmjs.com/package/vagejs"><img src="https://img.shields.io/npm/v/vagejs?color=4F46E5&label=npm" alt="npm version"></a>
|
|
10
|
+
<a href="https://github.com/agevenkat/vagejs/actions/workflows/ci.yml"><img src="https://github.com/agevenkat/vagejs/actions/workflows/ci.yml/badge.svg" alt="CI"></a>
|
|
11
|
+
<a href="LICENSE"><img src="https://img.shields.io/npm/l/vagejs?color=2E2A8C" alt="MIT license"></a>
|
|
12
|
+
</p>
|
|
13
|
+
|
|
14
|
+
# vagejs
|
|
15
|
+
|
|
16
|
+
An AI-native, static-first web framework. Write pages in Markdown, and vagejs builds a fast static site with SEO, `llms.txt`, accessibility and security handled for you. It's built so AI coding agents can use it without guessing. **Pre-release (0.x).**
|
|
17
|
+
|
|
18
|
+
- **Static by default**: plain HTML, no client JavaScript unless you add analytics.
|
|
19
|
+
- **SEO built in**: `robots.txt`, `sitemap.xml`, `llms.txt`, canonical URLs, Open Graph and JSON-LD, generated from your pages.
|
|
20
|
+
- **Checks before you ship**: `vage check` (links, SEO, structured data, accessibility) and `vage audit` (page-weight budgets, axe, optional Lighthouse and PageSpeed).
|
|
21
|
+
- **Safe by default**: page content is sanitized; secrets, build output and force-pushes are refused; Cloudflare deploys get a computed Content-Security-Policy.
|
|
22
|
+
- **Agent-friendly**: every site ships with `AGENTS.md`, and `vage info --json` describes every command and config option.
|
|
6
23
|
|
|
7
|
-
|
|
24
|
+
## Quick start
|
|
8
25
|
|
|
9
|
-
|
|
26
|
+
Requires **Node.js 22.12 or newer**.
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm create vagejs@latest my-site
|
|
30
|
+
cd my-site
|
|
31
|
+
npm install
|
|
32
|
+
npm run dev
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Open the URL it prints. Edit `src/pages/index.md` and the browser reloads.
|
|
36
|
+
|
|
37
|
+
## Using vagejs
|
|
38
|
+
|
|
39
|
+
### Project layout
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
my-site/
|
|
43
|
+
├── src/
|
|
44
|
+
│ ├── pages/ # every .md file is a page
|
|
45
|
+
│ │ ├── index.md # → /
|
|
46
|
+
│ │ ├── about.md # → /about/
|
|
47
|
+
│ │ └── blog/
|
|
48
|
+
│ │ └── first-post.md # → /blog/first-post/
|
|
49
|
+
│ └── layouts/
|
|
50
|
+
│ └── default.ts # HTML around each page
|
|
51
|
+
├── public/ # copied to the site as-is (favicons, images)
|
|
52
|
+
├── vage.config.ts # site settings
|
|
53
|
+
└── AGENTS.md # instructions for AI coding agents
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Write a page
|
|
57
|
+
|
|
58
|
+
```markdown
|
|
59
|
+
---
|
|
60
|
+
title: Pricing
|
|
61
|
+
description: Plans and prices.
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
# Pricing
|
|
65
|
+
|
|
66
|
+
Our plans start at …
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Save it as `src/pages/pricing.md` and it's served at `/pricing/`. `title` is required. `description`, `draft`, `noindex`, `canonical`, `image`, `type: article`, `author`, `datePublished` and `dateModified` are optional; see [`templates/starter/AGENTS.md`](templates/starter/AGENTS.md).
|
|
70
|
+
|
|
71
|
+
Images in `public/` used as `` get their `width` and `height` from the file, so the page doesn't jump while loading. With `images: { optimize: true }` (and `npm install --save-dev sharp`), vagejs also makes resized WebP copies of images in page content and offers them with `srcset`.
|
|
72
|
+
|
|
73
|
+
### Navigation, feeds and AI extras
|
|
74
|
+
|
|
75
|
+
- `order:` and `navTitle:` in frontmatter, with `tree()`, `breadcrumbs()` and `neighbors()` in layouts, build sidebars, breadcrumbs and previous/next links.
|
|
76
|
+
- `seo: { feeds: [{ under: "/blog/" }] }` writes an RSS feed at `/blog/feed.xml`.
|
|
77
|
+
- `seo: { markdownCopies: true }` publishes a Markdown copy of every listed page (`/docs/cli.md`) for AI tools, without comments or raw HTML; `seo: { lastmod: "git" }` dates the sitemap from git history (CI needs `fetch-depth: 0`).
|
|
78
|
+
|
|
79
|
+
### Write a layout
|
|
80
|
+
|
|
81
|
+
Layouts are TypeScript functions. The `html` template tag escapes every value for you; `raw()` inserts trusted HTML such as the rendered page. (Escaping doesn't check URLs, so validate link targets that come from frontmatter.) `pages` lists every published page, and `collection()` picks and sorts them:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
import { collection, html, raw, z, type Layout } from "vagejs";
|
|
85
|
+
|
|
86
|
+
// Optional: fields this layout accepts. Pages using it are checked strictly, so typos fail the build.
|
|
87
|
+
export const frontmatter = z.object({ intro: z.string().optional() });
|
|
88
|
+
|
|
89
|
+
const layout: Layout = ({ content, page, site, pages }) => html`<!doctype html>
|
|
90
|
+
<html lang="${site.lang}">
|
|
91
|
+
<head><title>${page.title}</title></head>
|
|
92
|
+
<body>
|
|
93
|
+
<main>${raw(content)}</main>
|
|
94
|
+
<h2>Latest posts</h2>
|
|
95
|
+
<ul>${collection(pages, { under: "/blog/", sort: "date", limit: 5 }).map((p) => html`<li><a href="${p.route}">${p.title}</a></li>`)}</ul>
|
|
96
|
+
</body>
|
|
97
|
+
</html>`;
|
|
98
|
+
|
|
99
|
+
export default layout;
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Everyday commands
|
|
103
|
+
|
|
104
|
+
| Command | What it does |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `npm run dev` | Dev server with live reload |
|
|
107
|
+
| `npm run check` | Links, SEO, structured data and basic accessibility; writes nothing |
|
|
108
|
+
| `npm run build` | Static site in `dist/`, plus `robots.txt`, `sitemap.xml`, `llms.txt` |
|
|
109
|
+
| `npm run audit` | Page-weight budgets and automated accessibility (`-- --lighthouse` adds Lighthouse) |
|
|
110
|
+
| `npm run preview` | Serves `dist/` locally, the way a static host would |
|
|
111
|
+
|
|
112
|
+
Add `--json` to `npx vage build`, `check` or `audit` for machine-readable output. Failures exit with code 1.
|
|
113
|
+
|
|
114
|
+
### Configure the site
|
|
115
|
+
|
|
116
|
+
`vage.config.ts`:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { defineConfig } from "vagejs";
|
|
120
|
+
|
|
121
|
+
export default defineConfig({
|
|
122
|
+
site: "https://example.com", // your real domain (used for canonical URLs and the sitemap)
|
|
123
|
+
title: "My Website",
|
|
124
|
+
description: "What this site is about.",
|
|
125
|
+
seo: {
|
|
126
|
+
robots: { aiTraining: "disallow" }, // optional: block AI training crawlers, keep search
|
|
127
|
+
llmsTxt: { full: false },
|
|
128
|
+
},
|
|
129
|
+
redirects: { "/old-page/": "/new-page/" },
|
|
130
|
+
});
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Unknown keys are an error, so typos don't silently do nothing. `npx vage info --json` prints the full schema.
|
|
134
|
+
|
|
135
|
+
### Deploy
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
npx vage deploy:github --remote https://github.com/you/site.git # check, build, safe commit + push (asks first)
|
|
139
|
+
npx vage deploy # GitHub Pages via Actions
|
|
140
|
+
npx vage deploy --target cloudflare-workers --project my-site # Cloudflare Workers: new version, no downtime
|
|
141
|
+
npx vage deploy --target cloudflare-pages --project my-site # older Cloudflare Pages projects
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Every deploy shows its plan and asks for confirmation (`--dry-run` to preview, `--yes` to skip). It reports success only after the host confirms.
|
|
145
|
+
|
|
146
|
+
Cloudflare now creates new static sites as Workers, so use `cloudflare-workers` for new projects. For a custom domain, add a `wrangler.jsonc` with your `routes`; vagejs uses it and switches versions without re-attaching domains, so the site stays up during deploys.
|
|
147
|
+
|
|
148
|
+
### Analytics, Search Console and audits of live pages
|
|
149
|
+
|
|
150
|
+
Optional, and off until configured: GA4 or Google Tag Manager (consent denied by default), Plausible, Microsoft Clarity, IndexNow. `vage connect gsc|ga4` adds read-only Search Console and GA4 reports, and `vage audit --url https://your-site` runs PageSpeed and CrUX with **your own** Google API key. Details are in the generated `AGENTS.md`.
|
|
151
|
+
|
|
152
|
+
## Updating
|
|
153
|
+
|
|
154
|
+
vagejs follows semver. While it's 0.x, a minor version (0.1 → 0.2) may include breaking changes, so read the release notes before moving up a minor version.
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
npm outdated vagejs # see what's available
|
|
158
|
+
npm update vagejs # latest patch in your range (with ^0.3.0: any 0.3.x)
|
|
159
|
+
npm install vagejs@latest # move to the newest version, including a new minor
|
|
160
|
+
npx vage --version # confirm
|
|
161
|
+
npx vage check && npx vage build
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
If your config pins the rule pack (`guidelines: { rulePack: "…" }`), a build after an update that ships new provider rules stops with a message. Review them with `npx vage guidelines`, then update the pin. New projects always use the latest version through `npm create vagejs@latest`.
|
|
165
|
+
|
|
166
|
+
## Use with AI coding agents
|
|
167
|
+
|
|
168
|
+
Every site includes `AGENTS.md` (conventions, recipes, definition of done) and `CLAUDE.md` (imports `AGENTS.md`). Agents can discover the API instead of guessing:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
npx vage info --json # commands, options, config and frontmatter JSON Schemas
|
|
172
|
+
npx vage check --json # findings with file, rule and fix hints
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Command reference
|
|
10
176
|
|
|
11
177
|
```bash
|
|
12
178
|
vage create <dir> [--vage-spec <spec>] [--json] # scaffold a Markdown site
|
|
@@ -16,8 +182,9 @@ vage check [--root .] [--json] [--strict] # SEO, links, JSON-LD, basic a
|
|
|
16
182
|
vage audit [--root .] [--lighthouse] [--url U] [--strategy mobile|desktop|both] [--strict] [--json]
|
|
17
183
|
# budgets + axe a11y; optional Lighthouse; PageSpeed/CrUX for deployed URLs
|
|
18
184
|
vage preview [--root .] [--port N] [--host] # serve dist/ locally
|
|
19
|
-
vage deploy:github [--remote URL] [--dry-run] [--yes] [--json] # check, build, safe commit + push (asks first)
|
|
185
|
+
vage deploy:github [--remote URL] [--branch B] [-m MSG] [--dry-run] [--yes] [--json] # check, build, safe commit + push (asks first)
|
|
20
186
|
vage deploy [--remote URL] [--no-wait] [--dry-run] [--yes] # GitHub Pages via Actions; confirms with GitHub
|
|
187
|
+
vage deploy --target cloudflare-workers [--project NAME] # new Worker version at 100%; custom domains stay up
|
|
21
188
|
vage deploy --target cloudflare-pages --project NAME [--branch B] # Wrangler upload; preview URL per branch
|
|
22
189
|
vage indexnow --changed-since REF | --urls … | --all # IndexNow URL notification (asks first)
|
|
23
190
|
vage connect gsc|ga4 / vage disconnect google # read-only Google OAuth (PKCE, loopback); token outside the repo
|
|
@@ -27,45 +194,49 @@ vage guidelines [--json] # rule pack: provider facts in
|
|
|
27
194
|
vage info [--json] # commands + config/frontmatter JSON Schemas for agents
|
|
28
195
|
```
|
|
29
196
|
|
|
30
|
-
|
|
197
|
+
## Planned, not implemented
|
|
198
|
+
|
|
199
|
+
Bing Webmaster Tools API, Netlify/Vercel adapters, CMS content sources, multi-language sites, base paths, sitemap indexes, MCP server, AVIF images.
|
|
200
|
+
|
|
201
|
+
## Security
|
|
202
|
+
|
|
203
|
+
Page content is sanitized, dev/preview servers serve only what a site needs, and Cloudflare deploys get security headers with a computed Content-Security-Policy. Report vulnerabilities privately: see [SECURITY.md](SECURITY.md).
|
|
31
204
|
|
|
32
|
-
|
|
205
|
+
### Protection against spam injection
|
|
33
206
|
|
|
34
|
-
|
|
207
|
+
A static site has no admin panel, database or plugins to break into, so attackers go after your content, your repository or your host instead, usually to plant hidden links and spam pages. vagejs checks for that on every build:
|
|
35
208
|
|
|
36
|
-
|
|
209
|
+
| Protection | What it does |
|
|
210
|
+
| --- | --- |
|
|
211
|
+
| Outbound link lock | `links.lock.json` lists the domains your site may link to. A link to any other domain fails `vage check` (and so every deploy) until you approve it with `vage links approve <domain>`. It covers links, SVG links, canonical and `og:url`, meta refresh, iframes and forms. With `security.outboundLinks: "require"` (the default for new sites), deleting the file is an error too. |
|
|
212
|
+
| No hidden links | Inline styles, SVG `xlink:href` and legacy tags like `<font>` and `<marquee>` are stripped from page content, and `vage check` fails on external links hidden with CSS, `aria-hidden`, zero-size SVG or no visible content, in content and layouts. |
|
|
213
|
+
| Spam words | `vage check` warns about common injected-spam terms (casino, pharmacy, payday loans…). |
|
|
214
|
+
| Live-site verification | `vage verify` compares the live site with your files: links and other targets, scripts, event handlers, JSON-LD, text (spam words are errors), `robots.txt` and sitemap pages. It also looks at every page as Googlebot, to catch spam shown only to search engines. New sites include a daily GitHub Actions check (opt in with the `VAGE_VERIFY_URL` variable). |
|
|
215
|
+
| Supply chain | New sites run `npm audit` in CI and get Dependabot updates. |
|
|
37
216
|
|
|
38
217
|
```bash
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
npm run dev
|
|
218
|
+
npx vage links # list external domains: approved or NEW
|
|
219
|
+
npx vage links approve github.com # approve one (asks first)
|
|
220
|
+
npx vage verify # compare the live site with your files
|
|
43
221
|
```
|
|
44
222
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
See [docs/RELEASING.md](docs/RELEASING.md).
|
|
223
|
+
Also protect the accounts around the site: two-factor authentication on GitHub, npm, your host and your domain registrar; branch protection on `main`; and Google Search Console alerts for security issues.
|
|
48
224
|
|
|
49
|
-
##
|
|
225
|
+
## Contributing
|
|
50
226
|
|
|
51
227
|
```bash
|
|
228
|
+
npm install
|
|
52
229
|
npm run typecheck
|
|
53
230
|
npm test # unit + integration (dev server, preview, build)
|
|
54
231
|
npm run test:e2e # pack -> create -> npm install/ci -> build (needs npm registry or cache)
|
|
55
232
|
```
|
|
56
233
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
Bing Webmaster Tools API, Netlify/Vercel adapters, CMS content sources, multi-language sites, base paths, sitemap indexes, MCP server. See `VAGE_JS_BUILD_SPEC.md`.
|
|
60
|
-
|
|
61
|
-
## Security
|
|
62
|
-
|
|
63
|
-
Page content is sanitized, dev/preview servers serve only what a site needs, and Cloudflare deploys get security headers with a computed Content-Security-Policy. Report vulnerabilities privately: see [SECURITY.md](SECURITY.md).
|
|
234
|
+
Design decisions: [docs/DECISIONS.md](docs/DECISIONS.md). Provider rules and how they're monitored: [docs/GUIDELINES.md](docs/GUIDELINES.md). Releasing: [docs/RELEASING.md](docs/RELEASING.md).
|
|
64
235
|
|
|
65
236
|
## Brand assets
|
|
66
237
|
|
|
67
238
|
- `assets/brand/vagejs-icon.svg`: the icon, as received (with C2PA content credentials).
|
|
68
|
-
- `assets/brand/vagejs-logo.svg`: the wordmark with **Space Grotesk Bold embedded** (a 1.6 KB WOFF2 subset of the glyphs used), so it renders the same without the font installed. Space Grotesk is © 2020 The Space Grotesk Project Authors, under the SIL Open Font License 1.1 (`assets/brand/fonts/OFL.txt`). Editing the file invalidated its C2PA signature, so that block was removed.
|
|
239
|
+
- `assets/brand/vagejs-logo.svg` and `vagejs-logo-dark.svg`: the wordmark for light and dark backgrounds, with **Space Grotesk Bold embedded** (a 1.6 KB WOFF2 subset of the glyphs used), so it renders the same without the font installed. Space Grotesk is © 2020 The Space Grotesk Project Authors, under the SIL Open Font License 1.1 (`assets/brand/fonts/OFL.txt`). Editing the file invalidated its C2PA signature, so that block was removed. The dark variant only changes the text colors (`#F4F5FA` and `#A5B4FC`).
|
|
69
240
|
- New sites get `favicon.svg`, `favicon.ico` (16/32/48) and `apple-touch-icon.png` (180) in `public/`.
|
|
70
241
|
|
|
71
242
|
Regenerate (maintainer tools; need Chrome, and Python with `fonttools` and `brotli`):
|
package/dist/checks/check.d.ts
CHANGED
|
@@ -18,6 +18,8 @@ export interface CheckResult {
|
|
|
18
18
|
pagesChecked: number;
|
|
19
19
|
findings: Finding[];
|
|
20
20
|
}
|
|
21
|
+
/** Paths of frontmatter values that came out empty (null), e.g. "faq.items[3].Claude and other AI search?". */
|
|
22
|
+
export declare function emptyValues(value: unknown, at?: string): string[];
|
|
21
23
|
/**
|
|
22
24
|
* Static checks on the rendered site (nothing is written to disk).
|
|
23
25
|
* Passing checks mean the output is technically sound; they do not guarantee
|
package/dist/checks/check.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { parse } from "node-html-parser";
|
|
4
4
|
import { createBuildLoader } from "../core/loader.js";
|
|
5
5
|
import { listedPages, renderSite } from "../core/site.js";
|
|
6
|
+
import { externalHost, hiddenReason, isEmptyLink, LINK_LOCK, linkTarget, outboundHosts, readLinkLock, spamHits } from "./integrity.js";
|
|
6
7
|
const DOCS = {
|
|
7
8
|
title: "https://developers.google.com/search/docs/appearance/title-link",
|
|
8
9
|
description: "https://developers.google.com/search/docs/appearance/snippet",
|
|
@@ -13,7 +14,19 @@ const DOCS = {
|
|
|
13
14
|
lang: "https://www.w3.org/WAI/WCAG22/Understanding/language-of-page.html",
|
|
14
15
|
linkPurpose: "https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context.html",
|
|
15
16
|
robots: "https://developers.google.com/search/docs/crawling-indexing/robots/intro",
|
|
17
|
+
yamlFlow: "https://yaml.org/spec/1.2.2/#74-flow-collection-styles",
|
|
16
18
|
};
|
|
19
|
+
/** Paths of frontmatter values that came out empty (null), e.g. "faq.items[3].Claude and other AI search?". */
|
|
20
|
+
export function emptyValues(value, at = "") {
|
|
21
|
+
if (value === null)
|
|
22
|
+
return [at || "(frontmatter)"];
|
|
23
|
+
if (Array.isArray(value))
|
|
24
|
+
return value.flatMap((v, i) => emptyValues(v, `${at}[${i}]`));
|
|
25
|
+
if (typeof value === "object" && value !== undefined) {
|
|
26
|
+
return Object.entries(value).flatMap(([k, v]) => emptyValues(v, at ? `${at}.${k}` : k));
|
|
27
|
+
}
|
|
28
|
+
return [];
|
|
29
|
+
}
|
|
17
30
|
/**
|
|
18
31
|
* Static checks on the rendered site (nothing is written to disk).
|
|
19
32
|
* Passing checks mean the output is technically sound; they do not guarantee
|
|
@@ -23,7 +36,7 @@ export async function check(root, options = {}) {
|
|
|
23
36
|
root = path.resolve(root);
|
|
24
37
|
const loader = await createBuildLoader(root);
|
|
25
38
|
try {
|
|
26
|
-
const model = await renderSite(root, loader.load);
|
|
39
|
+
const model = await renderSite(root, loader.load, { collectErrors: true });
|
|
27
40
|
const findings = checkSite(root, model);
|
|
28
41
|
const errors = findings.filter((f) => f.severity === "error").length;
|
|
29
42
|
const warnings = findings.length - errors;
|
|
@@ -31,7 +44,7 @@ export async function check(root, options = {}) {
|
|
|
31
44
|
ok: errors === 0 && (!options.strict || warnings === 0),
|
|
32
45
|
errors,
|
|
33
46
|
warnings,
|
|
34
|
-
pagesChecked: model.pages.length + (model.notFound ? 1 : 0),
|
|
47
|
+
pagesChecked: model.pages.length + (model.notFound ? 1 : 0) + model.pageErrors.length,
|
|
35
48
|
findings,
|
|
36
49
|
};
|
|
37
50
|
}
|
|
@@ -41,7 +54,13 @@ export async function check(root, options = {}) {
|
|
|
41
54
|
}
|
|
42
55
|
export function checkSite(root, model) {
|
|
43
56
|
const findings = model.warnings.map((message) => ({ rule: "config", severity: "warning", message }));
|
|
57
|
+
for (const e of model.pageErrors)
|
|
58
|
+
findings.push({ rule: "page-error", severity: "error", message: e.message, source: e.source });
|
|
44
59
|
const outputs = outputPaths(root, model);
|
|
60
|
+
// Pages that failed to render are already reported; links to them aren't broken links on top of that.
|
|
61
|
+
for (const e of model.pageErrors)
|
|
62
|
+
if (e.route)
|
|
63
|
+
outputs.add(`${e.route}index.html`);
|
|
45
64
|
const drafts = new Set(model.draftRoutes);
|
|
46
65
|
const pages = model.notFound ? [...model.pages, model.notFound] : model.pages;
|
|
47
66
|
for (const page of pages) {
|
|
@@ -50,10 +69,31 @@ export function checkSite(root, model) {
|
|
|
50
69
|
}
|
|
51
70
|
findings.push(...checkDuplicates(listedPages(model)));
|
|
52
71
|
findings.push(...checkRobots(model));
|
|
72
|
+
findings.push(...checkRedirectTargets(model, outputs));
|
|
73
|
+
findings.push(...checkOutboundLinks(root, model));
|
|
53
74
|
return findings;
|
|
54
75
|
}
|
|
55
76
|
function checkPage(page, doc, model, outputs, drafts, add) {
|
|
56
77
|
const indexable = !page.meta.noindex;
|
|
78
|
+
if (model.site.security.spamPatterns) {
|
|
79
|
+
const text = `${doc.querySelector("body")?.text ?? ""} ${doc.querySelectorAll("a[href]").map((a) => a.getAttribute("href")).join(" ")}`;
|
|
80
|
+
const hits = spamHits(text);
|
|
81
|
+
if (hits.length) {
|
|
82
|
+
add({
|
|
83
|
+
rule: "spam-pattern",
|
|
84
|
+
severity: "warning",
|
|
85
|
+
message: `Page contains words common in injected spam (${hits.join(", ")}). If you didn't write them, check this page's history. Turn off with security.spamPatterns: false.`,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const key of emptyValues(page.frontmatter)) {
|
|
90
|
+
add({
|
|
91
|
+
rule: "frontmatter-empty",
|
|
92
|
+
severity: "warning",
|
|
93
|
+
message: `Frontmatter "${key}" has no value. If you meant to write text there, check for a missing value; inside { } a comma or colon splits the text, so wrap it in quotes.`,
|
|
94
|
+
docs: DOCS.yamlFlow,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
57
97
|
if (!doc.querySelector("html")?.getAttribute("lang")) {
|
|
58
98
|
add({ rule: "html-lang", severity: "error", message: "<html> has no lang attribute.", docs: DOCS.lang });
|
|
59
99
|
}
|
|
@@ -104,6 +144,7 @@ function checkPage(page, doc, model, outputs, drafts, add) {
|
|
|
104
144
|
}
|
|
105
145
|
for (const a of doc.querySelectorAll("a[href]")) {
|
|
106
146
|
const href = a.getAttribute("href");
|
|
147
|
+
checkHiddenLink(a, href, model, add);
|
|
107
148
|
if (!a.text.trim() && !a.getAttribute("aria-label") && !a.querySelector("img[alt]:not([alt=''])")) {
|
|
108
149
|
add({ rule: "link-text", severity: "warning", message: `Link to "${href}" has no accessible text.`, docs: DOCS.linkPurpose });
|
|
109
150
|
}
|
|
@@ -127,6 +168,11 @@ function checkPage(page, doc, model, outputs, drafts, add) {
|
|
|
127
168
|
add({ rule: "link-trailing-slash", severity: "warning", message: `Link to "${href}" redirects; use "${target}/".` });
|
|
128
169
|
}
|
|
129
170
|
}
|
|
171
|
+
// SVG links (a xlink:href) aren't in the loop above; they get the hidden-link check too.
|
|
172
|
+
for (const a of doc.querySelectorAll("a")) {
|
|
173
|
+
if (!a.hasAttribute("href") && a.getAttribute("xlink:href"))
|
|
174
|
+
checkHiddenLink(a, linkTarget(a), model, add);
|
|
175
|
+
}
|
|
130
176
|
for (const [selector, attr] of [
|
|
131
177
|
["img[src]", "src"],
|
|
132
178
|
["script[src]", "src"],
|
|
@@ -253,6 +299,64 @@ function checkDuplicates(pages) {
|
|
|
253
299
|
}
|
|
254
300
|
return findings;
|
|
255
301
|
}
|
|
302
|
+
/** Links visitors can't see but search engines can: the signature of injected spam. */
|
|
303
|
+
function checkHiddenLink(a, href, model, add) {
|
|
304
|
+
const external = externalHost(href, model.site.site) !== undefined;
|
|
305
|
+
const hidden = hiddenReason(a) ?? (external && isEmptyLink(a) ? { reason: "empty (no text or image)", strength: "strong" } : undefined);
|
|
306
|
+
if (!hidden)
|
|
307
|
+
return;
|
|
308
|
+
add({
|
|
309
|
+
rule: "link-hidden",
|
|
310
|
+
severity: external && hidden.strength === "strong" ? "error" : "warning",
|
|
311
|
+
message: `Link to "${href}" is ${hidden.reason}: visitors can't see it, but search engines can. Hidden links are a common sign of injected spam. Remove it, or show it.`,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Outbound link guard: with security.outboundLinks "lock", every external domain must be
|
|
316
|
+
* listed in links.lock.json. A domain nobody approved is how injected spam usually shows up.
|
|
317
|
+
*/
|
|
318
|
+
function checkOutboundLinks(root, model) {
|
|
319
|
+
if (model.site.security.outboundLinks === "off")
|
|
320
|
+
return [];
|
|
321
|
+
const hosts = outboundHosts(model);
|
|
322
|
+
const lock = readLinkLock(root);
|
|
323
|
+
if (!lock && model.site.security.outboundLinks === "require") {
|
|
324
|
+
return [{ rule: "links-lock-missing", severity: "error", message: `${LINK_LOCK} is missing, but security.outboundLinks is "require". Restore it from git, or have the site owner recreate it with "npx vage links approve --all" after reviewing "npx vage links".` }];
|
|
325
|
+
}
|
|
326
|
+
if (!lock) {
|
|
327
|
+
return hosts.size
|
|
328
|
+
? [{ rule: "links-lock-missing", severity: "warning", message: `The site links to ${hosts.size} external domain(s) and has no ${LINK_LOCK}. The site owner should review them with "npx vage links" and approve them with "npx vage links approve --all"; after that, links to any new domain fail the check.` }]
|
|
329
|
+
: [];
|
|
330
|
+
}
|
|
331
|
+
const approved = new Set(lock.domains);
|
|
332
|
+
const findings = [];
|
|
333
|
+
for (const [host, pages] of hosts) {
|
|
334
|
+
if (approved.has(host))
|
|
335
|
+
continue;
|
|
336
|
+
const where = pages.map((p) => p.route);
|
|
337
|
+
findings.push({
|
|
338
|
+
rule: "link-unapproved",
|
|
339
|
+
severity: "error",
|
|
340
|
+
message: `Links to ${host} (on ${where.slice(0, 3).join(", ")}${where.length > 3 ? ` and ${where.length - 3} more` : ""}) aren't approved in ${LINK_LOCK}. If the site owner added them on purpose, they approve with "npx vage links approve ${host}". Otherwise remove them: unapproved links are how injected spam usually shows up.`,
|
|
341
|
+
page: pages[0].route,
|
|
342
|
+
source: pages[0].source,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
return findings;
|
|
346
|
+
}
|
|
347
|
+
/** Redirects to a page on this site must land on something that exists. */
|
|
348
|
+
function checkRedirectTargets(model, outputs) {
|
|
349
|
+
const findings = [];
|
|
350
|
+
for (const r of model.redirects) {
|
|
351
|
+
const target = internalPath(r.to, "/", model.site.site);
|
|
352
|
+
if (target === undefined)
|
|
353
|
+
continue;
|
|
354
|
+
if (target === MALFORMED || resolveOutput(target, outputs) === "missing") {
|
|
355
|
+
findings.push({ rule: "redirect-target", severity: "error", message: `Redirect from "${r.from}" points to "${r.to}", which isn't a page or file on this site.`, source: "vage.config.ts" });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return findings;
|
|
359
|
+
}
|
|
256
360
|
function checkRobots(model) {
|
|
257
361
|
const robots = model.files.get("robots.txt");
|
|
258
362
|
if (!robots)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Difference } from "./verify.js";
|
|
2
|
+
/**
|
|
3
|
+
* Can search engines and AI crawlers actually reach the live site? Checks what a site's
|
|
4
|
+
* own files can't show: CDN/firewall blocks and challenges, robots.txt rules, missing
|
|
5
|
+
* http→https and www redirects, and noindex headers. Probes send each crawler's
|
|
6
|
+
* documented user agent (or token), so blocks based on the real crawlers' IP
|
|
7
|
+
* addresses can't be detected this way.
|
|
8
|
+
*/
|
|
9
|
+
export interface Crawler {
|
|
10
|
+
token: string;
|
|
11
|
+
provider: string;
|
|
12
|
+
purpose: "search" | "ai-search" | "ai-training" | "ai-user";
|
|
13
|
+
userAgent?: string;
|
|
14
|
+
source: string;
|
|
15
|
+
}
|
|
16
|
+
export declare const CRAWLERS: Crawler[];
|
|
17
|
+
/** The documented user agent (W.X.Y.Z filled in), or a minimal one carrying the documented token. */
|
|
18
|
+
export declare function probeUserAgent(c: Crawler): string;
|
|
19
|
+
/**
|
|
20
|
+
* Is the site root ("/") disallowed for this crawler? RFC 9309: the crawler follows the
|
|
21
|
+
* groups naming its token (merged), else the "*" groups; among rules matching "/", the
|
|
22
|
+
* longest pattern wins and Allow wins ties. Lines like Sitemap: don't end a group.
|
|
23
|
+
*/
|
|
24
|
+
export declare function robotsBlocks(robots: string, token: string): boolean;
|
|
25
|
+
/** Does an X-Robots-Tag value say noindex for everyone, or for one of these crawlers? */
|
|
26
|
+
export declare function xRobotsNoindex(value: string, tokens: string[]): boolean;
|
|
27
|
+
type Fetcher = typeof fetch;
|
|
28
|
+
export declare function checkCrawlability(origin: string, options?: {
|
|
29
|
+
fetch?: Fetcher;
|
|
30
|
+
}): Promise<{
|
|
31
|
+
differences: Difference[];
|
|
32
|
+
crawlersChecked: number;
|
|
33
|
+
}>;
|
|
34
|
+
export {};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { ruleData } from "../guidelines/pack.js";
|
|
2
|
+
export const CRAWLERS = ruleData("crawlers.probes").crawlers;
|
|
3
|
+
/** The documented user agent (W.X.Y.Z filled in), or a minimal one carrying the documented token. */
|
|
4
|
+
export function probeUserAgent(c) {
|
|
5
|
+
return c.userAgent ? c.userAgent.replace("W.X.Y.Z", "131.0.0.0") : `Mozilla/5.0 AppleWebKit/537.36 (KHTML, like Gecko; compatible; ${c.token}/1.0)`;
|
|
6
|
+
}
|
|
7
|
+
/** A challenge or block page, recognized by its title (not by scripts like reCAPTCHA that normal pages load). */
|
|
8
|
+
const CHALLENGE_TITLE = /<title>\s*(Just a moment|Attention Required|Access denied|Checking your browser)/i;
|
|
9
|
+
/** robots.txt path pattern -> RegExp (RFC 9309: "*" wildcard, "$" end anchor). */
|
|
10
|
+
function patternRegex(pattern) {
|
|
11
|
+
const anchored = pattern.endsWith("$");
|
|
12
|
+
const body = (anchored ? pattern.slice(0, -1) : pattern).split("*").map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
|
|
13
|
+
return new RegExp(`^${body}${anchored ? "$" : ""}`);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Is the site root ("/") disallowed for this crawler? RFC 9309: the crawler follows the
|
|
17
|
+
* groups naming its token (merged), else the "*" groups; among rules matching "/", the
|
|
18
|
+
* longest pattern wins and Allow wins ties. Lines like Sitemap: don't end a group.
|
|
19
|
+
*/
|
|
20
|
+
export function robotsBlocks(robots, token) {
|
|
21
|
+
const groups = [];
|
|
22
|
+
let current;
|
|
23
|
+
let collectingAgents = false;
|
|
24
|
+
for (const raw of robots.split(/\r?\n/)) {
|
|
25
|
+
const m = /^\s*([a-z-]+)\s*:\s*(.*?)\s*$/i.exec(raw.replace(/#.*/, ""));
|
|
26
|
+
if (!m)
|
|
27
|
+
continue;
|
|
28
|
+
const key = m[1].toLowerCase();
|
|
29
|
+
const value = m[2];
|
|
30
|
+
if (key === "user-agent") {
|
|
31
|
+
if (!current || !collectingAgents)
|
|
32
|
+
groups.push((current = { agents: [], rules: [] }));
|
|
33
|
+
// "Googlebot/2.1" names the Googlebot product token.
|
|
34
|
+
current.agents.push(value.toLowerCase().split("/")[0].trim());
|
|
35
|
+
collectingAgents = true;
|
|
36
|
+
}
|
|
37
|
+
else if (key === "allow" || key === "disallow") {
|
|
38
|
+
collectingAgents = false;
|
|
39
|
+
if (current && value)
|
|
40
|
+
current.rules.push({ allow: key === "allow", pattern: value });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const named = groups.filter((g) => g.agents.includes(token.toLowerCase()));
|
|
44
|
+
const rules = (named.length ? named : groups.filter((g) => g.agents.includes("*"))).flatMap((g) => g.rules);
|
|
45
|
+
let best;
|
|
46
|
+
for (const r of rules) {
|
|
47
|
+
if (!patternRegex(r.pattern).test("/"))
|
|
48
|
+
continue;
|
|
49
|
+
const length = r.pattern.length;
|
|
50
|
+
if (!best || length > best.length || (length === best.length && r.allow))
|
|
51
|
+
best = { allow: r.allow, length };
|
|
52
|
+
}
|
|
53
|
+
return best !== undefined && !best.allow;
|
|
54
|
+
}
|
|
55
|
+
/** Does an X-Robots-Tag value say noindex for everyone, or for one of these crawlers? */
|
|
56
|
+
export function xRobotsNoindex(value, tokens) {
|
|
57
|
+
const lower = tokens.map((t) => t.toLowerCase());
|
|
58
|
+
// "googlebot: noindex, nofollow" targets one bot; "noindex" targets all.
|
|
59
|
+
return value.split(/,(?=\s*[a-z-]+\s*:)/i).some((part) => {
|
|
60
|
+
const m = /^\s*([a-z-]+)\s*:\s*(.*)$/i.exec(part);
|
|
61
|
+
const target = m && !/^(unavailable_after|max-[a-z-]+)$/i.test(m[1]) ? m[1].toLowerCase() : undefined;
|
|
62
|
+
const directives = target ? m[2] : part;
|
|
63
|
+
return (!target || lower.includes(target)) && /\b(noindex|none)\b/i.test(directives);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
export async function checkCrawlability(origin, options = {}) {
|
|
67
|
+
const doFetch = options.fetch ?? fetch;
|
|
68
|
+
const out = [];
|
|
69
|
+
const get = async (url, init = {}, maxChars = 8000) => {
|
|
70
|
+
try {
|
|
71
|
+
const res = await doFetch(url, { signal: AbortSignal.timeout(20_000), ...init });
|
|
72
|
+
// Read the body here too: a reset mid-download is a failed request, not a crash.
|
|
73
|
+
const body = res.ok ? (await res.text()).slice(0, maxChars) : "";
|
|
74
|
+
return { status: res.status, ok: res.ok, headers: res.headers, body };
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const blockedReason = (res) => !res ? "no response" : res.headers.get("cf-mitigated") ? `blocked by the CDN (cf-mitigated: ${res.headers.get("cf-mitigated")})` : !res.ok ? `HTTP ${res.status}` : CHALLENGE_TITLE.test(res.body) ? "a challenge page instead of the site" : undefined;
|
|
81
|
+
// 1. Crawlers reach the home page. Only blocks that a normal browser doesn't get count.
|
|
82
|
+
const browser = await get(`${origin}/`, { headers: { "user-agent": "Mozilla/5.0 (compatible; vagejs-verify) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", accept: "text/html" } });
|
|
83
|
+
const browserBlocked = blockedReason(browser);
|
|
84
|
+
if (browserBlocked) {
|
|
85
|
+
out.push({ route: "/", severity: "error", kind: "crawler-blocked", detail: `Even a normal browser gets ${browserBlocked}, so crawlers can't reach the site either.` });
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
for (const c of CRAWLERS) {
|
|
89
|
+
const why = blockedReason(await get(`${origin}/`, { headers: { "user-agent": probeUserAgent(c), accept: "text/html" } }));
|
|
90
|
+
if (!why)
|
|
91
|
+
continue;
|
|
92
|
+
out.push({
|
|
93
|
+
route: "/",
|
|
94
|
+
severity: "warning",
|
|
95
|
+
kind: "crawler-blocked",
|
|
96
|
+
detail: `${c.token} (${c.provider}, ${c.purpose}) gets ${why}. ${c.purpose === "search"
|
|
97
|
+
? "If your host blocks fake search bots, this probe (which can't come from the real crawler's addresses) may be what's blocked; confirm in Google Search Console or Bing Webmaster Tools."
|
|
98
|
+
: "If you didn't block it on purpose, check your host's bot or AI-crawler settings."}`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// 2. robots.txt
|
|
103
|
+
// Read robots.txt in full (Google processes up to 500 KiB), not just the first few KB.
|
|
104
|
+
const robotsRes = await get(`${origin}/robots.txt`, {}, 512 * 1024);
|
|
105
|
+
if (robotsRes?.ok) {
|
|
106
|
+
for (const c of CRAWLERS.filter((x) => x.purpose !== "ai-user")) {
|
|
107
|
+
if (robotsBlocks(robotsRes.body, c.token)) {
|
|
108
|
+
out.push({
|
|
109
|
+
route: "/robots.txt",
|
|
110
|
+
severity: c.purpose === "search" ? "error" : "warning",
|
|
111
|
+
kind: "robots-blocked",
|
|
112
|
+
detail: `robots.txt disallows ${c.token} (${c.provider}, ${c.purpose}) from the whole site.${c.purpose === "ai-training" ? " Fine if that's your choice (seo.robots.aiTraining)." : ""}`,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// 3. noindex sent as a header hides the site even when the HTML says nothing.
|
|
118
|
+
const home = await get(`${origin}/`, { redirect: "manual" });
|
|
119
|
+
const xRobots = home?.headers.get("x-robots-tag");
|
|
120
|
+
if (xRobots && xRobotsNoindex(xRobots, ["googlebot", "bingbot"])) {
|
|
121
|
+
out.push({ route: "/", severity: "error", kind: "noindex-header", detail: `The server sends "X-Robots-Tag: ${xRobots}", which keeps the site out of search results.` });
|
|
122
|
+
}
|
|
123
|
+
// 4. One address per page: http should redirect to https, and www/non-www to the site's host.
|
|
124
|
+
// Skipped for local addresses (ports, IPs, localhost), where there's no public twin.
|
|
125
|
+
const url = new URL(origin);
|
|
126
|
+
const publicHost = !url.port && !/^(localhost|\d+\.\d+\.\d+\.\d+|\[.*\])$/i.test(url.hostname) && url.hostname.includes(".");
|
|
127
|
+
if (publicHost && url.protocol === "https:") {
|
|
128
|
+
const plain = await get(`http://${url.host}/`, { redirect: "manual" });
|
|
129
|
+
const to = plain?.headers.get("location") ?? "";
|
|
130
|
+
if (plain && !(plain.status >= 300 && plain.status < 400 && to.startsWith("https://"))) {
|
|
131
|
+
out.push({ route: "/", severity: "warning", kind: "redirect-missing", detail: `http://${url.host}/ answers ${plain.status} instead of redirecting to https. Turn on "Always use HTTPS" (or equivalent) at your host.` });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (publicHost) {
|
|
135
|
+
const other = url.hostname.startsWith("www.") ? url.hostname.slice(4) : `www.${url.hostname}`;
|
|
136
|
+
const twin = await get(`${url.protocol}//${other}/`, { redirect: "manual" });
|
|
137
|
+
if (twin && twin.status === 200) {
|
|
138
|
+
out.push({ route: "/", severity: "warning", kind: "duplicate-host", detail: `${other} serves the site too instead of redirecting to ${url.hostname}. Add a redirect at your host so search engines see one address.` });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return { differences: out, crawlersChecked: browserBlocked ? 0 : CRAWLERS.length };
|
|
142
|
+
}
|