vagejs 0.1.0 → 0.2.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 +190 -25
- package/dist/checks/check.d.ts +2 -0
- package/dist/checks/check.js +106 -2
- 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 +35 -0
- package/dist/checks/verify.js +167 -0
- package/dist/cli/index.js +116 -10
- package/dist/cli/info.d.ts +35 -0
- package/dist/cli/info.js +49 -4
- package/dist/content/images.d.ts +23 -0
- package/dist/content/images.js +96 -0
- package/dist/content/markdown.d.ts +2 -0
- package/dist/content/markdown.js +6 -1
- package/dist/content/sanitize.d.ts +11 -2
- package/dist/content/sanitize.js +49 -9
- package/dist/core/clone.d.ts +6 -0
- package/dist/core/clone.js +16 -0
- package/dist/core/config.d.ts +9 -0
- package/dist/core/config.js +21 -4
- 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 +40 -0
- package/dist/core/pages.js +93 -0
- package/dist/core/render.d.ts +17 -1
- package/dist/core/render.js +51 -9
- package/dist/core/site.d.ts +12 -1
- package/dist/core/site.js +36 -13
- 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/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/server.js +3 -1
- package/dist/guidelines/rule-pack.json +1 -1
- 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 +1 -1
- package/package.json +1 -1
- package/templates/starter/.github/dependabot.yml +11 -0
- package/templates/starter/.github/workflows/ci.yml +2 -0
- package/templates/starter/.github/workflows/verify.yml +32 -0
- package/templates/starter/AGENTS.md +36 -15
- 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,172 @@
|
|
|
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.
|
|
23
|
+
|
|
24
|
+
## Quick start
|
|
25
|
+
|
|
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.
|
|
72
|
+
|
|
73
|
+
### Write a layout
|
|
74
|
+
|
|
75
|
+
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:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import { collection, html, raw, z, type Layout } from "vagejs";
|
|
79
|
+
|
|
80
|
+
// Optional: fields this layout accepts. Pages using it are checked strictly, so typos fail the build.
|
|
81
|
+
export const frontmatter = z.object({ intro: z.string().optional() });
|
|
82
|
+
|
|
83
|
+
const layout: Layout = ({ content, page, site, pages }) => html`<!doctype html>
|
|
84
|
+
<html lang="${site.lang}">
|
|
85
|
+
<head><title>${page.title}</title></head>
|
|
86
|
+
<body>
|
|
87
|
+
<main>${raw(content)}</main>
|
|
88
|
+
<h2>Latest posts</h2>
|
|
89
|
+
<ul>${collection(pages, { under: "/blog/", sort: "date", limit: 5 }).map((p) => html`<li><a href="${p.route}">${p.title}</a></li>`)}</ul>
|
|
90
|
+
</body>
|
|
91
|
+
</html>`;
|
|
92
|
+
|
|
93
|
+
export default layout;
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Everyday commands
|
|
97
|
+
|
|
98
|
+
| Command | What it does |
|
|
99
|
+
| --- | --- |
|
|
100
|
+
| `npm run dev` | Dev server with live reload |
|
|
101
|
+
| `npm run check` | Links, SEO, structured data and basic accessibility; writes nothing |
|
|
102
|
+
| `npm run build` | Static site in `dist/`, plus `robots.txt`, `sitemap.xml`, `llms.txt` |
|
|
103
|
+
| `npm run audit` | Page-weight budgets and automated accessibility (`-- --lighthouse` adds Lighthouse) |
|
|
104
|
+
| `npm run preview` | Serves `dist/` locally, the way a static host would |
|
|
105
|
+
|
|
106
|
+
Add `--json` to `npx vage build`, `check` or `audit` for machine-readable output. Failures exit with code 1.
|
|
107
|
+
|
|
108
|
+
### Configure the site
|
|
109
|
+
|
|
110
|
+
`vage.config.ts`:
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { defineConfig } from "vagejs";
|
|
114
|
+
|
|
115
|
+
export default defineConfig({
|
|
116
|
+
site: "https://example.com", // your real domain (used for canonical URLs and the sitemap)
|
|
117
|
+
title: "My Website",
|
|
118
|
+
description: "What this site is about.",
|
|
119
|
+
seo: {
|
|
120
|
+
robots: { aiTraining: "disallow" }, // optional: block AI training crawlers, keep search
|
|
121
|
+
llmsTxt: { full: false },
|
|
122
|
+
},
|
|
123
|
+
redirects: { "/old-page/": "/new-page/" },
|
|
124
|
+
});
|
|
125
|
+
```
|
|
6
126
|
|
|
7
|
-
|
|
127
|
+
Unknown keys are an error, so typos don't silently do nothing. `npx vage info --json` prints the full schema.
|
|
8
128
|
|
|
9
|
-
|
|
129
|
+
### Deploy
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
npx vage deploy:github --remote https://github.com/you/site.git # check, build, safe commit + push (asks first)
|
|
133
|
+
npx vage deploy # GitHub Pages via Actions
|
|
134
|
+
npx vage deploy --target cloudflare-workers --project my-site # Cloudflare Workers: new version, no downtime
|
|
135
|
+
npx vage deploy --target cloudflare-pages --project my-site # older Cloudflare Pages projects
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Every deploy shows its plan and asks for confirmation (`--dry-run` to preview, `--yes` to skip). It reports success only after the host confirms.
|
|
139
|
+
|
|
140
|
+
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.
|
|
141
|
+
|
|
142
|
+
### Analytics, Search Console and audits of live pages
|
|
143
|
+
|
|
144
|
+
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`.
|
|
145
|
+
|
|
146
|
+
## Updating
|
|
147
|
+
|
|
148
|
+
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.
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
npm outdated vagejs # see what's available
|
|
152
|
+
npm update vagejs # latest patch in your range (with ^0.2.0: any 0.2.x)
|
|
153
|
+
npm install vagejs@latest # move to the newest version, including a new minor
|
|
154
|
+
npx vage --version # confirm
|
|
155
|
+
npx vage check && npx vage build
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
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`.
|
|
159
|
+
|
|
160
|
+
## Use with AI coding agents
|
|
161
|
+
|
|
162
|
+
Every site includes `AGENTS.md` (conventions, recipes, definition of done) and `CLAUDE.md` (imports `AGENTS.md`). Agents can discover the API instead of guessing:
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
npx vage info --json # commands, options, config and frontmatter JSON Schemas
|
|
166
|
+
npx vage check --json # findings with file, rule and fix hints
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
## Command reference
|
|
10
170
|
|
|
11
171
|
```bash
|
|
12
172
|
vage create <dir> [--vage-spec <spec>] [--json] # scaffold a Markdown site
|
|
@@ -16,8 +176,9 @@ vage check [--root .] [--json] [--strict] # SEO, links, JSON-LD, basic a
|
|
|
16
176
|
vage audit [--root .] [--lighthouse] [--url U] [--strategy mobile|desktop|both] [--strict] [--json]
|
|
17
177
|
# budgets + axe a11y; optional Lighthouse; PageSpeed/CrUX for deployed URLs
|
|
18
178
|
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)
|
|
179
|
+
vage deploy:github [--remote URL] [--branch B] [-m MSG] [--dry-run] [--yes] [--json] # check, build, safe commit + push (asks first)
|
|
20
180
|
vage deploy [--remote URL] [--no-wait] [--dry-run] [--yes] # GitHub Pages via Actions; confirms with GitHub
|
|
181
|
+
vage deploy --target cloudflare-workers [--project NAME] # new Worker version at 100%; custom domains stay up
|
|
21
182
|
vage deploy --target cloudflare-pages --project NAME [--branch B] # Wrangler upload; preview URL per branch
|
|
22
183
|
vage indexnow --changed-since REF | --urls … | --all # IndexNow URL notification (asks first)
|
|
23
184
|
vage connect gsc|ga4 / vage disconnect google # read-only Google OAuth (PKCE, loopback); token outside the repo
|
|
@@ -27,45 +188,49 @@ vage guidelines [--json] # rule pack: provider facts in
|
|
|
27
188
|
vage info [--json] # commands + config/frontmatter JSON Schemas for agents
|
|
28
189
|
```
|
|
29
190
|
|
|
30
|
-
|
|
191
|
+
## Planned, not implemented
|
|
192
|
+
|
|
193
|
+
Bing Webmaster Tools API, Netlify/Vercel adapters, CMS content sources, multi-language sites, base paths, sitemap indexes, MCP server, image resizing and WebP/AVIF conversion.
|
|
31
194
|
|
|
32
|
-
|
|
195
|
+
## Security
|
|
196
|
+
|
|
197
|
+
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).
|
|
33
198
|
|
|
34
|
-
|
|
199
|
+
### Protection against spam injection
|
|
35
200
|
|
|
36
|
-
|
|
201
|
+
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:
|
|
202
|
+
|
|
203
|
+
| Protection | What it does |
|
|
204
|
+
| --- | --- |
|
|
205
|
+
| 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. |
|
|
206
|
+
| 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. |
|
|
207
|
+
| Spam words | `vage check` warns about common injected-spam terms (casino, pharmacy, payday loans…). |
|
|
208
|
+
| 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). |
|
|
209
|
+
| Supply chain | New sites run `npm audit` in CI and get Dependabot updates. |
|
|
37
210
|
|
|
38
211
|
```bash
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
npm run dev
|
|
212
|
+
npx vage links # list external domains: approved or NEW
|
|
213
|
+
npx vage links approve github.com # approve one (asks first)
|
|
214
|
+
npx vage verify # compare the live site with your files
|
|
43
215
|
```
|
|
44
216
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
See [docs/RELEASING.md](docs/RELEASING.md).
|
|
217
|
+
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
218
|
|
|
49
|
-
##
|
|
219
|
+
## Contributing
|
|
50
220
|
|
|
51
221
|
```bash
|
|
222
|
+
npm install
|
|
52
223
|
npm run typecheck
|
|
53
224
|
npm test # unit + integration (dev server, preview, build)
|
|
54
225
|
npm run test:e2e # pack -> create -> npm install/ci -> build (needs npm registry or cache)
|
|
55
226
|
```
|
|
56
227
|
|
|
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).
|
|
228
|
+
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
229
|
|
|
65
230
|
## Brand assets
|
|
66
231
|
|
|
67
232
|
- `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.
|
|
233
|
+
- `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
234
|
- New sites get `favicon.svg`, `favicon.ico` (16/32/48) and `apple-touch-icon.png` (180) in `public/`.
|
|
70
235
|
|
|
71
236
|
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,36 @@
|
|
|
1
|
+
import { type HTMLElement } from "node-html-parser";
|
|
2
|
+
import type { SiteModel } from "../core/site.js";
|
|
3
|
+
/**
|
|
4
|
+
* Defenses against injected spam (the classic hacked-site problem): links hidden with
|
|
5
|
+
* CSS, outbound links nobody approved, and common spam vocabulary. These run in
|
|
6
|
+
* `vage check` on the final HTML, so they cover page content and layouts alike.
|
|
7
|
+
*/
|
|
8
|
+
export declare const LINK_LOCK = "links.lock.json";
|
|
9
|
+
export interface Hidden {
|
|
10
|
+
reason: string;
|
|
11
|
+
/** "strong": a CSS/SVG trick with no legitimate use around a link. "soft": hidden by a mechanism sites also use for menus (hidden, closed <details>). */
|
|
12
|
+
strength: "strong" | "soft";
|
|
13
|
+
}
|
|
14
|
+
/** Why this element (or an ancestor) is hidden from people, if it is. Only inline signals; stylesheets aren't evaluated. */
|
|
15
|
+
export declare function hiddenReason(el: HTMLElement): Hidden | undefined;
|
|
16
|
+
/** A link with nothing a visitor can see or a screen reader can announce. */
|
|
17
|
+
export declare function isEmptyLink(a: HTMLElement): boolean;
|
|
18
|
+
/** href of <a>/<area>, including SVG's xlink:href. */
|
|
19
|
+
export declare function linkTarget(a: HTMLElement): string | undefined;
|
|
20
|
+
/** Normalized host of an external http(s) link ("www." dropped), or undefined for internal/other links. */
|
|
21
|
+
export declare function externalHost(href: string, site: string | undefined): string | undefined;
|
|
22
|
+
/** Lowercase, punycode, no "www.", no trailing dot: one spelling per host. */
|
|
23
|
+
export declare function normalizeHost(host: string): string;
|
|
24
|
+
/** Every external host the built site links to, with the pages that link there. */
|
|
25
|
+
export declare function outboundHosts(model: SiteModel): Map<string, {
|
|
26
|
+
route: string;
|
|
27
|
+
source: string;
|
|
28
|
+
}[]>;
|
|
29
|
+
export interface LinkLock {
|
|
30
|
+
domains: string[];
|
|
31
|
+
}
|
|
32
|
+
export declare function readLinkLock(root: string): LinkLock | undefined;
|
|
33
|
+
export declare function writeLinkLock(root: string, domains: Iterable<string>): void;
|
|
34
|
+
/** Words that rarely appear on legitimate business sites but are staples of injected spam. Warnings only. */
|
|
35
|
+
export declare const SPAM_PATTERNS: [RegExp, string][];
|
|
36
|
+
export declare function spamHits(text: string): string[];
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse } from "node-html-parser";
|
|
4
|
+
import { VageError } from "../core/errors.js";
|
|
5
|
+
/**
|
|
6
|
+
* Defenses against injected spam (the classic hacked-site problem): links hidden with
|
|
7
|
+
* CSS, outbound links nobody approved, and common spam vocabulary. These run in
|
|
8
|
+
* `vage check` on the final HTML, so they cover page content and layouts alike.
|
|
9
|
+
*/
|
|
10
|
+
export const LINK_LOCK = "links.lock.json";
|
|
11
|
+
// ---------------------------------------------------------------- hidden links
|
|
12
|
+
const HIDING_STYLES = [
|
|
13
|
+
[/display\s*:\s*none/i, "display:none"],
|
|
14
|
+
[/visibility\s*:\s*(hidden|collapse)/i, "visibility:hidden"],
|
|
15
|
+
[/opacity\s*:\s*0(\.0+)?\s*(;|$|!)/i, "opacity:0"],
|
|
16
|
+
[/font-size\s*:\s*0(\.0+)?(px|em|rem|%)?\s*(;|$|!)/i, "font-size:0"],
|
|
17
|
+
[/(^|;|\s)(width|height|max-width|max-height)\s*:\s*0(px)?\s*(;|$|!)/i, "zero size"],
|
|
18
|
+
[/(^|;|\s)(left|top|right|bottom|margin-left|margin-top)\s*:\s*-\s*\d{3,}/i, "positioned off-screen"],
|
|
19
|
+
[/text-indent\s*:\s*-\s*\d{3,}/i, "text pushed off-screen"],
|
|
20
|
+
[/clip\s*:\s*rect\(\s*0/i, "clipped away"],
|
|
21
|
+
[/clip-path\s*:\s*inset\(\s*(50%|100%)/i, "clipped away"],
|
|
22
|
+
];
|
|
23
|
+
/** Why this element (or an ancestor) is hidden from people, if it is. Only inline signals; stylesheets aren't evaluated. */
|
|
24
|
+
export function hiddenReason(el) {
|
|
25
|
+
for (let node = el; node && node.tagName; node = node.parentNode) {
|
|
26
|
+
const tag = node.tagName.toLowerCase();
|
|
27
|
+
const where = node === el ? "" : "inside ";
|
|
28
|
+
const style = node.getAttribute?.("style");
|
|
29
|
+
if (style) {
|
|
30
|
+
const hit = HIDING_STYLES.find(([re]) => re.test(style));
|
|
31
|
+
if (hit)
|
|
32
|
+
return { reason: `${where ? "inside an element " : ""}hidden with ${hit[1]}`, strength: "strong" };
|
|
33
|
+
}
|
|
34
|
+
if (node.getAttribute?.("aria-hidden") === "true")
|
|
35
|
+
return { reason: `${where}aria-hidden="true"`, strength: "strong" };
|
|
36
|
+
if (tag === "svg" && (/^0(px)?$/.test(node.getAttribute("width") ?? "") || /^0(px)?$/.test(node.getAttribute("height") ?? ""))) {
|
|
37
|
+
return { reason: "inside a zero-size <svg>", strength: "strong" };
|
|
38
|
+
}
|
|
39
|
+
if (node.hasAttribute?.("hidden"))
|
|
40
|
+
return { reason: `inside a hidden <${tag}>`, strength: "soft" };
|
|
41
|
+
if (tag === "details" && node !== el && !node.hasAttribute("open") && !el.closest?.("summary")) {
|
|
42
|
+
return { reason: "inside a closed <details>", strength: "soft" };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
/** A link with nothing a visitor can see or a screen reader can announce. */
|
|
48
|
+
export function isEmptyLink(a) {
|
|
49
|
+
return !a.text.trim() && !a.getAttribute("aria-label") && !a.querySelector("img[alt]:not([alt=''])");
|
|
50
|
+
}
|
|
51
|
+
/** href of <a>/<area>, including SVG's xlink:href. */
|
|
52
|
+
export function linkTarget(a) {
|
|
53
|
+
return a.getAttribute("href") ?? a.getAttribute("xlink:href") ?? undefined;
|
|
54
|
+
}
|
|
55
|
+
// ---------------------------------------------------------------- outbound links
|
|
56
|
+
/** Normalized host of an external http(s) link ("www." dropped), or undefined for internal/other links. */
|
|
57
|
+
export function externalHost(href, site) {
|
|
58
|
+
let url;
|
|
59
|
+
try {
|
|
60
|
+
url = new URL(href.trim(), site ?? "https://site.invalid");
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
if (url.protocol !== "http:" && url.protocol !== "https:")
|
|
66
|
+
return undefined;
|
|
67
|
+
const host = normalizeHost(url.hostname);
|
|
68
|
+
const own = site ? normalizeHost(new URL(site).hostname) : "site.invalid";
|
|
69
|
+
return host === own ? undefined : host;
|
|
70
|
+
}
|
|
71
|
+
/** Lowercase, punycode, no "www.", no trailing dot: one spelling per host. */
|
|
72
|
+
export function normalizeHost(host) {
|
|
73
|
+
return host.toLowerCase().replace(/\.$/, "").replace(/^www\./, "");
|
|
74
|
+
}
|
|
75
|
+
/** Places in a page that send visitors or search engines elsewhere, beyond <a>: all go through the link lock. */
|
|
76
|
+
const OTHER_TARGETS = [
|
|
77
|
+
['link[rel="canonical"]', (el) => el.getAttribute("href")],
|
|
78
|
+
['meta[property="og:url"]', (el) => el.getAttribute("content")],
|
|
79
|
+
["meta[http-equiv]", (el) => (/^refresh$/i.test(el.getAttribute("http-equiv") ?? "") ? /url\s*=\s*['"]?([^'";]+)/i.exec(el.getAttribute("content") ?? "")?.[1] : undefined)],
|
|
80
|
+
["iframe[src]", (el) => el.getAttribute("src")],
|
|
81
|
+
["form[action]", (el) => el.getAttribute("action")],
|
|
82
|
+
];
|
|
83
|
+
/** Every external host the built site links to, with the pages that link there. */
|
|
84
|
+
export function outboundHosts(model) {
|
|
85
|
+
const hosts = new Map();
|
|
86
|
+
const pages = model.notFound ? [...model.pages, model.notFound] : model.pages;
|
|
87
|
+
for (const page of pages) {
|
|
88
|
+
const seen = new Set();
|
|
89
|
+
const doc = parse(page.html);
|
|
90
|
+
const targets = [
|
|
91
|
+
...doc.querySelectorAll("a, area").map(linkTarget),
|
|
92
|
+
...OTHER_TARGETS.flatMap(([selector, read]) => doc.querySelectorAll(selector).map(read)),
|
|
93
|
+
];
|
|
94
|
+
for (const target of targets) {
|
|
95
|
+
if (!target)
|
|
96
|
+
continue;
|
|
97
|
+
const host = externalHost(target, model.site.site);
|
|
98
|
+
if (!host || seen.has(host))
|
|
99
|
+
continue;
|
|
100
|
+
seen.add(host);
|
|
101
|
+
hosts.set(host, [...(hosts.get(host) ?? []), { route: page.route, source: page.source }]);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return new Map([...hosts].sort(([a], [b]) => a.localeCompare(b)));
|
|
105
|
+
}
|
|
106
|
+
export function readLinkLock(root) {
|
|
107
|
+
const file = path.join(root, LINK_LOCK);
|
|
108
|
+
if (!fs.existsSync(file))
|
|
109
|
+
return undefined;
|
|
110
|
+
let data;
|
|
111
|
+
try {
|
|
112
|
+
data = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
throw new VageError(`${LINK_LOCK} is not valid JSON: ${err.message}`, file);
|
|
116
|
+
}
|
|
117
|
+
const domains = data?.domains;
|
|
118
|
+
if (!Array.isArray(domains) || !domains.every((d) => typeof d === "string")) {
|
|
119
|
+
throw new VageError(`${LINK_LOCK} must look like { "domains": ["example.com"] }.`, file);
|
|
120
|
+
}
|
|
121
|
+
return { domains: domains.map(normalizeHost) };
|
|
122
|
+
}
|
|
123
|
+
export function writeLinkLock(root, domains) {
|
|
124
|
+
const sorted = [...new Set(domains)].sort();
|
|
125
|
+
const body = {
|
|
126
|
+
$comment: "External domains this site may link to. vage check fails on links to any other domain. Only the site owner approves new ones: npx vage links approve <domain>",
|
|
127
|
+
domains: sorted,
|
|
128
|
+
};
|
|
129
|
+
fs.writeFileSync(path.join(root, LINK_LOCK), `${JSON.stringify(body, null, 2)}\n`);
|
|
130
|
+
}
|
|
131
|
+
// ---------------------------------------------------------------- spam vocabulary
|
|
132
|
+
/** Words that rarely appear on legitimate business sites but are staples of injected spam. Warnings only. */
|
|
133
|
+
export const SPAM_PATTERNS = [
|
|
134
|
+
[/\b(online|best|free|top)\s+casinos?\b|\bcasino\s+(bonus|online|games?|sites?)\b/i, "casino"],
|
|
135
|
+
[/\b(viagra|cialis|levitra|kamagra)\b/i, "pharmacy spam"],
|
|
136
|
+
[/\bpayday\s+loans?\b/i, "payday loans"],
|
|
137
|
+
[/\breplica\s+(watches|handbags|bags|rolex)\b/i, "replica goods"],
|
|
138
|
+
[/\bescort\s+(girls|agency|agencies)\b/i, "escort ads"],
|
|
139
|
+
[/\bporn\b|\bxxx\s+(videos?|porn|sex)\b/i, "adult content"],
|
|
140
|
+
[/\bbuy\s+(cheap\s+)?(followers|backlinks|likes)\b/i, "bought followers or backlinks"],
|
|
141
|
+
[/\bonline\s+slots\b|\bslot\s+gacor\b|\bjudi\s+online\b/i, "online gambling"],
|
|
142
|
+
];
|
|
143
|
+
export function spamHits(text) {
|
|
144
|
+
return [...new Set(SPAM_PATTERNS.filter(([re]) => re.test(text)).map(([, label]) => label))];
|
|
145
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { readLinkLock, writeLinkLock } from "./integrity.js";
|
|
2
|
+
export interface LinkStatus {
|
|
3
|
+
/** External domains the site links to now, with how many pages link there, and whether they're approved. */
|
|
4
|
+
domains: {
|
|
5
|
+
domain: string;
|
|
6
|
+
pages: string[];
|
|
7
|
+
approved: boolean;
|
|
8
|
+
}[];
|
|
9
|
+
/** Approved domains the site no longer links to. */
|
|
10
|
+
unused: string[];
|
|
11
|
+
hasLock: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function linkStatus(root: string): Promise<LinkStatus>;
|
|
14
|
+
/** "Bücher.DE." -> "xn--bcher-kva.de"; throws for anything that isn't a bare host (or IP). */
|
|
15
|
+
export declare function parseHost(input: string): string;
|
|
16
|
+
/** The lock file's domains after approving `add` (and, with `prune`, dropping unused ones). */
|
|
17
|
+
export declare function nextLock(status: LinkStatus, current: string[], add: string[], prune: boolean): string[];
|
|
18
|
+
export { readLinkLock, writeLinkLock };
|