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
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { parse } from "node-html-parser";
|
|
7
|
+
import { VageError } from "../core/errors.js";
|
|
8
|
+
import { insidePublic } from "../dev/guard.js";
|
|
9
|
+
import { imageSize } from "./images.js";
|
|
10
|
+
const RASTER = /\.(jpe?g|png|webp)$/i;
|
|
11
|
+
export const IMAGE_DIR = "_vage/img";
|
|
12
|
+
export function cacheDir(root) {
|
|
13
|
+
return path.join(root, "node_modules", ".cache", "vagejs", "img");
|
|
14
|
+
}
|
|
15
|
+
/** Loads sharp from the project (not from vagejs), with a clear message if it's missing. */
|
|
16
|
+
export async function sharpResize(root) {
|
|
17
|
+
let resolved;
|
|
18
|
+
try {
|
|
19
|
+
resolved = createRequire(path.join(root, "package.json")).resolve("sharp");
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
throw new VageError('images.optimize needs the "sharp" package. Install it with: npm install --save-dev sharp');
|
|
23
|
+
}
|
|
24
|
+
const sharp = (await import(pathToFileURL(resolved).href)).default;
|
|
25
|
+
// rotate() applies EXIF orientation first, so phone photos aren't served sideways.
|
|
26
|
+
return (input, width, quality) => sharp(input).rotate().resize({ width, withoutEnlargement: true }).webp({ quality }).toBuffer();
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* WebP variants for one site-path image in public/. Undefined for remote images,
|
|
30
|
+
* non-raster formats (SVG, GIF), paths outside public/ and unreadable files.
|
|
31
|
+
*/
|
|
32
|
+
export async function imageVariants(src, root, options, resize) {
|
|
33
|
+
if (!/^\/(?!\/)/.test(src))
|
|
34
|
+
return undefined;
|
|
35
|
+
let rel;
|
|
36
|
+
try {
|
|
37
|
+
rel = decodeURIComponent(src.split(/[?#]/)[0]);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
if (!RASTER.test(rel))
|
|
43
|
+
return undefined;
|
|
44
|
+
const publicDir = path.join(root, "public");
|
|
45
|
+
const file = path.join(publicDir, rel);
|
|
46
|
+
if (!file.startsWith(publicDir + path.sep))
|
|
47
|
+
return undefined;
|
|
48
|
+
// Never follow symlinked files or folders: they can point outside the project.
|
|
49
|
+
if (!insidePublic(file, publicDir))
|
|
50
|
+
return undefined;
|
|
51
|
+
const size = imageSize(file);
|
|
52
|
+
if (!size)
|
|
53
|
+
return undefined;
|
|
54
|
+
const input = fs.readFileSync(file);
|
|
55
|
+
// "v2": variants are auto-rotated; older cached files (not rotated) must not be reused.
|
|
56
|
+
const hash = createHash("sha256").update(input).update(`q${options.quality}|v2`).digest("hex").slice(0, 10);
|
|
57
|
+
const slug = path.basename(rel).replace(RASTER, "").replace(/[^\w-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
58
|
+
const base = /[a-z0-9]/i.test(slug) ? slug : "image";
|
|
59
|
+
// Never upscale; include the original width when it's smaller than every configured width.
|
|
60
|
+
const widths = [...new Set(options.widths.filter((w) => w < size.width).concat(size.width <= Math.max(...options.widths) ? [size.width] : []))].sort((a, b) => a - b);
|
|
61
|
+
const dir = cacheDir(root);
|
|
62
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
63
|
+
const files = new Map();
|
|
64
|
+
const entries = [];
|
|
65
|
+
for (const w of widths) {
|
|
66
|
+
const name = `${base}-${hash}-${w}.webp`;
|
|
67
|
+
const cached = path.join(dir, name);
|
|
68
|
+
if (!fs.existsSync(cached)) {
|
|
69
|
+
const tmp = `${cached}.${process.pid}.tmp`;
|
|
70
|
+
let data;
|
|
71
|
+
try {
|
|
72
|
+
data = await resize(input, w, options.quality);
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
throw new VageError(`Couldn't optimize ${src}: ${err.message}. Replace the file, or turn off images.optimize.`);
|
|
76
|
+
}
|
|
77
|
+
fs.writeFileSync(tmp, data);
|
|
78
|
+
fs.renameSync(tmp, cached);
|
|
79
|
+
}
|
|
80
|
+
// Label with the file's real width (rotation can change it); publish each width once.
|
|
81
|
+
const actual = imageSize(cached)?.width ?? w;
|
|
82
|
+
if (entries.some((e) => e.endsWith(` ${actual}w`)))
|
|
83
|
+
continue;
|
|
84
|
+
files.set(`${IMAGE_DIR}/${name}`, cached);
|
|
85
|
+
entries.push(`/${IMAGE_DIR}/${name} ${actual}w`);
|
|
86
|
+
}
|
|
87
|
+
return entries.length ? { srcset: entries.join(", "), files } : undefined;
|
|
88
|
+
}
|
|
89
|
+
/** Image sources used in rendered Markdown, read with an HTML parser (so entities decode like the DOM's). */
|
|
90
|
+
export function contentImageSources(html) {
|
|
91
|
+
return [...new Set(parse(html).querySelectorAll("img").map((img) => img.getAttribute("src")).filter((s) => !!s))];
|
|
92
|
+
}
|
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
* Sanitizes rendered Markdown HTML. Page content may be written by AI agents,
|
|
3
3
|
* contributors or (later) CMS sources, so scripts, event handlers, javascript:
|
|
4
4
|
* URLs, iframes, forms and <meta>/<base>/<style> tricks are removed. data-*
|
|
5
|
-
* attributes (used by analytics events) are kept.
|
|
5
|
+
* attributes (used by analytics events) are kept. style="" is removed unless
|
|
6
|
+
* `markdown.inlineStyles` is on. Loaded lazily: jsdom is heavy.
|
|
7
|
+
*
|
|
8
|
+
* With `publicDir`, images get their size and loading hints inside the sanitizer
|
|
9
|
+
* (a DOMPurify hook), so DOMPurify's serialization is the final output.
|
|
6
10
|
*/
|
|
7
|
-
export declare function sanitizeHtml(html: string
|
|
11
|
+
export declare function sanitizeHtml(html: string, options?: {
|
|
12
|
+
publicDir?: string;
|
|
13
|
+
inlineStyles?: boolean;
|
|
14
|
+
variants?: Map<string, {
|
|
15
|
+
srcset: string;
|
|
16
|
+
sizes: string;
|
|
17
|
+
}>;
|
|
18
|
+
}): Promise<string>;
|
|
19
|
+
/** For trusted content (`markdown.html: "allow"`): the same image hints, applied through the DOM. */
|
|
20
|
+
export declare function enhanceTrustedHtml(html: string, publicDir: string, variants?: Map<string, {
|
|
21
|
+
srcset: string;
|
|
22
|
+
sizes: string;
|
|
23
|
+
}>): Promise<string>;
|
package/dist/content/sanitize.js
CHANGED
|
@@ -1,17 +1,57 @@
|
|
|
1
|
+
import { enhanceImage } from "./images.js";
|
|
1
2
|
let purifier;
|
|
3
|
+
let jsdom;
|
|
4
|
+
async function load() {
|
|
5
|
+
if (!purifier) {
|
|
6
|
+
const [mod, { default: createDOMPurify }] = await Promise.all([import("jsdom"), import("dompurify")]);
|
|
7
|
+
jsdom = mod;
|
|
8
|
+
purifier = createDOMPurify(new mod.JSDOM("").window);
|
|
9
|
+
}
|
|
10
|
+
return { purifier, jsdom: jsdom };
|
|
11
|
+
}
|
|
2
12
|
/**
|
|
3
13
|
* Sanitizes rendered Markdown HTML. Page content may be written by AI agents,
|
|
4
14
|
* contributors or (later) CMS sources, so scripts, event handlers, javascript:
|
|
5
15
|
* URLs, iframes, forms and <meta>/<base>/<style> tricks are removed. data-*
|
|
6
|
-
* attributes (used by analytics events) are kept.
|
|
16
|
+
* attributes (used by analytics events) are kept. style="" is removed unless
|
|
17
|
+
* `markdown.inlineStyles` is on. Loaded lazily: jsdom is heavy.
|
|
18
|
+
*
|
|
19
|
+
* With `publicDir`, images get their size and loading hints inside the sanitizer
|
|
20
|
+
* (a DOMPurify hook), so DOMPurify's serialization is the final output.
|
|
7
21
|
*/
|
|
8
|
-
export async function sanitizeHtml(html) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
22
|
+
export async function sanitizeHtml(html, options = {}) {
|
|
23
|
+
const { purifier } = await load();
|
|
24
|
+
let first = true;
|
|
25
|
+
const publicDir = options.publicDir;
|
|
26
|
+
const hook = (node) => {
|
|
27
|
+
if (publicDir && node.nodeName === "IMG") {
|
|
28
|
+
enhanceImage(node, publicDir, first, options.variants?.get(node.getAttribute("src") ?? ""));
|
|
29
|
+
first = false;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
if (publicDir) {
|
|
33
|
+
purifier.addHook("afterSanitizeAttributes", hook);
|
|
12
34
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
35
|
+
try {
|
|
36
|
+
return purifier.sanitize(html, {
|
|
37
|
+
// font/marquee/center: legacy ways to hide or disguise text (white-on-white, 1px scrollers).
|
|
38
|
+
FORBID_TAGS: ["style", "form", "input", "button", "textarea", "select", "option", "base", "link", "meta", "object", "embed", "font", "marquee", "center"],
|
|
39
|
+
ALLOW_DATA_ATTR: true,
|
|
40
|
+
// Inline styles are the usual way to hide injected links (display:none, off-screen); layouts style pages.
|
|
41
|
+
// xlink:href: SVG links, which would slip past link checks.
|
|
42
|
+
FORBID_ATTR: options.inlineStyles ? ["xlink:href"] : ["style", "xlink:href"],
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
if (publicDir)
|
|
47
|
+
purifier.removeHook("afterSanitizeAttributes", hook);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** For trusted content (`markdown.html: "allow"`): the same image hints, applied through the DOM. */
|
|
51
|
+
export async function enhanceTrustedHtml(html, publicDir, variants) {
|
|
52
|
+
const { jsdom } = await load();
|
|
53
|
+
const template = new jsdom.JSDOM("").window.document.createElement("template");
|
|
54
|
+
template.innerHTML = html;
|
|
55
|
+
template.content.querySelectorAll("img").forEach((img, i) => enhanceImage(img, publicDir, i === 0, variants?.get(img.getAttribute("src") ?? "")));
|
|
56
|
+
return template.innerHTML;
|
|
17
57
|
}
|
package/dist/core/build.js
CHANGED
|
@@ -25,6 +25,10 @@ export async function buildSite(root) {
|
|
|
25
25
|
outputs.set(r.outFile, r.html);
|
|
26
26
|
for (const [name, content] of model.files)
|
|
27
27
|
outputs.set(name, content);
|
|
28
|
+
const assets = new Map();
|
|
29
|
+
for (const p of [...model.pages, ...(model.notFound ? [model.notFound] : [])])
|
|
30
|
+
for (const [k, f] of p.assets)
|
|
31
|
+
assets.set(k, f);
|
|
28
32
|
fs.rmSync(outDir, { recursive: true, force: true });
|
|
29
33
|
const publicDir = path.join(root, "public");
|
|
30
34
|
if (fs.existsSync(publicDir))
|
|
@@ -37,6 +41,13 @@ export async function buildSite(root) {
|
|
|
37
41
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
38
42
|
fs.writeFileSync(target, content);
|
|
39
43
|
}
|
|
44
|
+
for (const [output, file] of assets) {
|
|
45
|
+
const target = path.resolve(outDir, output);
|
|
46
|
+
if (!target.startsWith(outDir + path.sep))
|
|
47
|
+
throw new VageError(`Refusing to write outside ${OUT_DIR}/: ${output}`);
|
|
48
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
49
|
+
fs.copyFileSync(file, target);
|
|
50
|
+
}
|
|
40
51
|
const result = {
|
|
41
52
|
outDir,
|
|
42
53
|
pages: model.pages.map((p) => ({ route: p.route, source: p.source, output: p.outFile })),
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copies plain data (objects and arrays) and keeps everything else as-is (URL, Date,
|
|
3
|
+
* class instances from Zod transforms), so values can be shared or frozen safely
|
|
4
|
+
* without breaking what layouts put in their schemas.
|
|
5
|
+
*/
|
|
6
|
+
export declare function clonePlain<T>(value: T): T;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copies plain data (objects and arrays) and keeps everything else as-is (URL, Date,
|
|
3
|
+
* class instances from Zod transforms), so values can be shared or frozen safely
|
|
4
|
+
* without breaking what layouts put in their schemas.
|
|
5
|
+
*/
|
|
6
|
+
export function clonePlain(value) {
|
|
7
|
+
if (Array.isArray(value))
|
|
8
|
+
return value.map(clonePlain);
|
|
9
|
+
if (value && typeof value === "object") {
|
|
10
|
+
const proto = Object.getPrototypeOf(value);
|
|
11
|
+
if (proto !== Object.prototype && proto !== null)
|
|
12
|
+
return value;
|
|
13
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, clonePlain(v)]));
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
package/dist/core/config.d.ts
CHANGED
|
@@ -5,11 +5,18 @@ export declare const configSchema: z.ZodObject<{
|
|
|
5
5
|
title: z.ZodOptional<z.ZodString>;
|
|
6
6
|
description: z.ZodOptional<z.ZodString>;
|
|
7
7
|
lang: z.ZodDefault<z.ZodString>;
|
|
8
|
+
images: z.ZodDefault<z.ZodObject<{
|
|
9
|
+
optimize: z.ZodDefault<z.ZodBoolean>;
|
|
10
|
+
widths: z.ZodDefault<z.ZodArray<z.ZodNumber>>;
|
|
11
|
+
quality: z.ZodDefault<z.ZodNumber>;
|
|
12
|
+
sizes: z.ZodDefault<z.ZodString>;
|
|
13
|
+
}, z.core.$strict>>;
|
|
8
14
|
markdown: z.ZodDefault<z.ZodObject<{
|
|
9
15
|
html: z.ZodDefault<z.ZodEnum<{
|
|
10
16
|
allow: "allow";
|
|
11
17
|
sanitize: "sanitize";
|
|
12
18
|
}>>;
|
|
19
|
+
inlineStyles: z.ZodDefault<z.ZodBoolean>;
|
|
13
20
|
}, z.core.$strict>>;
|
|
14
21
|
seo: z.ZodDefault<z.ZodObject<{
|
|
15
22
|
robots: z.ZodPipe<z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
|
|
@@ -43,6 +50,17 @@ export declare const configSchema: z.ZodObject<{
|
|
|
43
50
|
aiTraining?: "allow" | "disallow" | undefined;
|
|
44
51
|
aiSearch?: "allow" | "disallow" | undefined;
|
|
45
52
|
}>>;
|
|
53
|
+
feeds: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
54
|
+
under: z.ZodString;
|
|
55
|
+
title: z.ZodOptional<z.ZodString>;
|
|
56
|
+
description: z.ZodOptional<z.ZodString>;
|
|
57
|
+
limit: z.ZodDefault<z.ZodNumber>;
|
|
58
|
+
}, z.core.$strict>>>;
|
|
59
|
+
lastmod: z.ZodDefault<z.ZodEnum<{
|
|
60
|
+
frontmatter: "frontmatter";
|
|
61
|
+
git: "git";
|
|
62
|
+
}>>;
|
|
63
|
+
markdownCopies: z.ZodDefault<z.ZodBoolean>;
|
|
46
64
|
sitemap: z.ZodDefault<z.ZodBoolean>;
|
|
47
65
|
llmsTxt: z.ZodPipe<z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
|
|
48
66
|
full: z.ZodDefault<z.ZodBoolean>;
|
|
@@ -95,9 +113,11 @@ export declare const configSchema: z.ZodObject<{
|
|
|
95
113
|
deploy: z.ZodDefault<z.ZodObject<{
|
|
96
114
|
target: z.ZodDefault<z.ZodEnum<{
|
|
97
115
|
"cloudflare-pages": "cloudflare-pages";
|
|
116
|
+
"cloudflare-workers": "cloudflare-workers";
|
|
98
117
|
"github-pages": "github-pages";
|
|
99
118
|
}>>;
|
|
100
119
|
cloudflareProject: z.ZodOptional<z.ZodString>;
|
|
120
|
+
cloudflareWorker: z.ZodOptional<z.ZodString>;
|
|
101
121
|
}, z.core.$strict>>;
|
|
102
122
|
security: z.ZodDefault<z.ZodObject<{
|
|
103
123
|
headers: z.ZodDefault<z.ZodBoolean>;
|
|
@@ -115,6 +135,12 @@ export declare const configSchema: z.ZodObject<{
|
|
|
115
135
|
"script-src": "script-src";
|
|
116
136
|
"style-src": "style-src";
|
|
117
137
|
}> & z.core.$partial, z.ZodArray<z.ZodString>>>;
|
|
138
|
+
outboundLinks: z.ZodDefault<z.ZodEnum<{
|
|
139
|
+
lock: "lock";
|
|
140
|
+
off: "off";
|
|
141
|
+
require: "require";
|
|
142
|
+
}>>;
|
|
143
|
+
spamPatterns: z.ZodDefault<z.ZodBoolean>;
|
|
118
144
|
}, z.core.$strict>>;
|
|
119
145
|
guidelines: z.ZodDefault<z.ZodObject<{
|
|
120
146
|
rulePack: z.ZodOptional<z.ZodString>;
|
package/dist/core/config.js
CHANGED
|
@@ -39,6 +39,20 @@ const robotsSchema = z.strictObject({
|
|
|
39
39
|
});
|
|
40
40
|
const seoSchema = z.strictObject({
|
|
41
41
|
robots: toggle(robotsSchema).describe("Generate robots.txt (true/false or options)."),
|
|
42
|
+
feeds: z
|
|
43
|
+
.array(z.strictObject({
|
|
44
|
+
under: z.string().regex(/^\/(?:[\w-]+\/)*$/, 'must be a section path like "/blog/" (letters, digits, "_" and "-")').describe("Section whose pages the feed lists."),
|
|
45
|
+
title: z.string().min(1).optional().describe("Feed title. Defaults to the section page's title."),
|
|
46
|
+
description: z.string().min(1).optional().describe("Feed description. Defaults to the section page's description."),
|
|
47
|
+
limit: z.number().int().min(1).max(500).default(20).describe("Most recent pages to include."),
|
|
48
|
+
}))
|
|
49
|
+
.default([])
|
|
50
|
+
.describe('RSS feeds, e.g. [{ under: "/blog/" }] writes /blog/feed.xml (needs `site`). Pages in the section link to it.'),
|
|
51
|
+
lastmod: z
|
|
52
|
+
.enum(["frontmatter", "git"])
|
|
53
|
+
.default("frontmatter")
|
|
54
|
+
.describe('Where sitemap <lastmod> dates come from: "frontmatter" (dateModified/datePublished) or "git" (each page\'s last commit; dateModified still wins). Git dates are skipped in shallow clones.'),
|
|
55
|
+
markdownCopies: z.boolean().default(false).describe("Also publish each listed page as Markdown at /<path>.md (e.g. /docs/cli.md), linked from the page, for AI tools."),
|
|
42
56
|
sitemap: z.boolean().default(true).describe("Generate sitemap.xml (needs `site`)."),
|
|
43
57
|
llmsTxt: toggle(z.strictObject({
|
|
44
58
|
full: z.boolean().default(false).describe("Also emit llms-full.txt with the Markdown of every listed page."),
|
|
@@ -171,25 +185,45 @@ export const configSchema = z.strictObject({
|
|
|
171
185
|
title: z.string().min(1).optional().describe("Site name: page titles, og:site_name, llms.txt heading."),
|
|
172
186
|
description: z.string().min(1).optional().describe("Fallback meta description; llms.txt summary."),
|
|
173
187
|
lang: z.string().min(1).default("en").describe("<html lang> value."),
|
|
188
|
+
images: z
|
|
189
|
+
.strictObject({
|
|
190
|
+
optimize: z.boolean().default(false).describe('Make resized WebP copies of images in page content and offer them with srcset. Needs "sharp" in the project (npm install --save-dev sharp).'),
|
|
191
|
+
widths: z.array(z.number().int().min(16).max(8000)).min(1).max(8).default([480, 960, 1600]).describe("Widths to generate (never larger than the original)."),
|
|
192
|
+
quality: z.number().int().min(1).max(100).default(75).describe("WebP quality."),
|
|
193
|
+
sizes: z.string().regex(/^[^<>"]+$/).default("(max-width: 48rem) 100vw, 48rem").describe('The sizes attribute: how wide images display, so browsers pick the right file.'),
|
|
194
|
+
})
|
|
195
|
+
.default({ optimize: false, widths: [480, 960, 1600], quality: 75, sizes: "(max-width: 48rem) 100vw, 48rem" }),
|
|
174
196
|
markdown: z
|
|
175
197
|
.strictObject({
|
|
176
198
|
html: z
|
|
177
199
|
.enum(["sanitize", "allow"])
|
|
178
200
|
.default("sanitize")
|
|
179
201
|
.describe('"sanitize" removes scripts, event handlers, javascript: URLs, iframes and forms from page content. "allow" trusts all content authors.'),
|
|
202
|
+
inlineStyles: z
|
|
203
|
+
.boolean()
|
|
204
|
+
.default(false)
|
|
205
|
+
.describe('Keep style="..." attributes in page content (sanitize mode). Off by default: inline styles are the usual way to hide spam links. Put styling in layouts.'),
|
|
180
206
|
})
|
|
181
|
-
.default({ html: "sanitize" }),
|
|
207
|
+
.default({ html: "sanitize", inlineStyles: false }),
|
|
182
208
|
seo: seoSchema.default(() => seoSchema.parse({})),
|
|
183
209
|
performance: performanceSchema.default(() => performanceSchema.parse({})),
|
|
184
210
|
deploy: z
|
|
185
211
|
.strictObject({
|
|
186
|
-
target: z
|
|
212
|
+
target: z
|
|
213
|
+
.enum(["github-pages", "cloudflare-pages", "cloudflare-workers"])
|
|
214
|
+
.default("github-pages")
|
|
215
|
+
.describe("Host used by `vage deploy`. Cloudflare now creates new sites as Workers: prefer cloudflare-workers there."),
|
|
187
216
|
cloudflareProject: z.string().regex(/^[a-z0-9][a-z0-9-]{0,57}$/).optional().describe("Cloudflare Pages project name."),
|
|
217
|
+
cloudflareWorker: z
|
|
218
|
+
.string()
|
|
219
|
+
.regex(/^[a-z0-9][a-z0-9-]{0,62}$/)
|
|
220
|
+
.optional()
|
|
221
|
+
.describe("Cloudflare Worker name (cloudflare-workers target, when the project has no wrangler config). Defaults to cloudflareProject."),
|
|
188
222
|
})
|
|
189
223
|
.default({ target: "github-pages" }),
|
|
190
224
|
security: z
|
|
191
225
|
.strictObject({
|
|
192
|
-
headers: z.boolean().default(true).describe("Write a _headers file with security headers (Cloudflare
|
|
226
|
+
headers: z.boolean().default(true).describe("Write a _headers file with security headers (Cloudflare targets)."),
|
|
193
227
|
csp: z
|
|
194
228
|
.enum(["enforce", "report-only", "off"])
|
|
195
229
|
.default("enforce")
|
|
@@ -198,8 +232,13 @@ export const configSchema = z.strictObject({
|
|
|
198
232
|
.partialRecord(z.enum(["script-src", "style-src", "img-src", "font-src", "connect-src", "frame-src", "media-src"]), z.array(z.string().regex(/^('[a-z0-9-]+'|https?:\/\/[^\s;,']+|[a-z]+:)$/, "must be a source like https://cdn.example.com or 'self'")))
|
|
199
233
|
.default({})
|
|
200
234
|
.describe("Extra sources per directive, e.g. { \"connect-src\": [\"https://api.example.com\"] }."),
|
|
235
|
+
outboundLinks: z
|
|
236
|
+
.enum(["require", "lock", "off"])
|
|
237
|
+
.default("lock")
|
|
238
|
+
.describe('"lock": when links.lock.json exists, every external domain the site links to must be listed there (approve with `vage links approve`); new domains fail `vage check`. Catches injected spam links. "require": the same, and a missing links.lock.json is an error, so deleting it can\'t switch the guard off. "off": no guard.'),
|
|
239
|
+
spamPatterns: z.boolean().default(true).describe("Warn in `vage check` about common spam words (casino, pills, payday loans...) in page text and links."),
|
|
201
240
|
})
|
|
202
|
-
.default({ headers: true, csp: "enforce", cspSources: {} }),
|
|
241
|
+
.default({ headers: true, csp: "enforce", cspSources: {}, outboundLinks: "lock", spamPatterns: true }),
|
|
203
242
|
guidelines: z
|
|
204
243
|
.strictObject({
|
|
205
244
|
rulePack: z
|
package/dist/core/errors.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** An expected, user-facing error. The CLI prints its message without a stack trace. */
|
|
2
2
|
export declare class VageError extends Error {
|
|
3
3
|
readonly file?: string | undefined;
|
|
4
|
+
/** The message without the "at <file>" suffix. */
|
|
5
|
+
readonly detail: string;
|
|
4
6
|
constructor(message: string, file?: string | undefined);
|
|
5
7
|
}
|
package/dist/core/errors.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
/** An expected, user-facing error. The CLI prints its message without a stack trace. */
|
|
2
2
|
export class VageError extends Error {
|
|
3
3
|
file;
|
|
4
|
+
/** The message without the "at <file>" suffix. */
|
|
5
|
+
detail;
|
|
4
6
|
constructor(message, file) {
|
|
5
7
|
super(file ? `${message}\n at ${file}` : message);
|
|
6
8
|
this.file = file;
|
|
9
|
+
this.detail = message;
|
|
7
10
|
this.name = "VageError";
|
|
8
11
|
}
|
|
9
12
|
}
|
package/dist/core/html.d.ts
CHANGED
|
@@ -1,2 +1,24 @@
|
|
|
1
1
|
/** Escapes text for safe use in HTML content and attribute values. */
|
|
2
2
|
export declare function escapeHtml(value: unknown): string;
|
|
3
|
+
declare const SAFE: unique symbol;
|
|
4
|
+
/** HTML that `html` inserts as-is. Create it with `html` or `raw()`; `String(x)` gives the markup. */
|
|
5
|
+
export interface SafeHtml {
|
|
6
|
+
readonly [SAFE]: true;
|
|
7
|
+
readonly value: string;
|
|
8
|
+
toString(): string;
|
|
9
|
+
}
|
|
10
|
+
export declare const isSafeHtml: (v: unknown) => v is SafeHtml;
|
|
11
|
+
/**
|
|
12
|
+
* Marks trusted markup so `html` won't escape it, such as `props.content` (already
|
|
13
|
+
* sanitized) or a JSON-LD script from `jsonLdScript()`. Never pass user or frontmatter text.
|
|
14
|
+
*/
|
|
15
|
+
export declare function raw(markup: string): SafeHtml;
|
|
16
|
+
/**
|
|
17
|
+
* Template tag for layouts: every `${value}` is escaped unless it is `html` itself or `raw()`.
|
|
18
|
+
* Arrays are joined, and null, undefined and false render nothing, so lists and conditions read naturally:
|
|
19
|
+
*
|
|
20
|
+
* html`<ul>${posts.map((p) => html`<li><a href="${p.route}">${p.title}</a></li>`)}</ul>`
|
|
21
|
+
* html`<main>${raw(content)}</main>`
|
|
22
|
+
*/
|
|
23
|
+
export declare function html(strings: TemplateStringsArray, ...values: unknown[]): SafeHtml;
|
|
24
|
+
export {};
|
package/dist/core/html.js
CHANGED
|
@@ -9,3 +9,34 @@ const ESCAPES = {
|
|
|
9
9
|
export function escapeHtml(value) {
|
|
10
10
|
return String(value ?? "").replace(/[&<>"']/g, (c) => ESCAPES[c]);
|
|
11
11
|
}
|
|
12
|
+
const SAFE = Symbol.for("vagejs.safeHtml");
|
|
13
|
+
const safe = (value) => ({ [SAFE]: true, value, toString: () => value });
|
|
14
|
+
export const isSafeHtml = (v) => typeof v === "object" && v !== null && SAFE in v;
|
|
15
|
+
/**
|
|
16
|
+
* Marks trusted markup so `html` won't escape it, such as `props.content` (already
|
|
17
|
+
* sanitized) or a JSON-LD script from `jsonLdScript()`. Never pass user or frontmatter text.
|
|
18
|
+
*/
|
|
19
|
+
export function raw(markup) {
|
|
20
|
+
return safe(String(markup));
|
|
21
|
+
}
|
|
22
|
+
function interpolate(v) {
|
|
23
|
+
if (isSafeHtml(v))
|
|
24
|
+
return v.value;
|
|
25
|
+
if (Array.isArray(v))
|
|
26
|
+
return v.map(interpolate).join("");
|
|
27
|
+
if (v === null || v === undefined || v === false)
|
|
28
|
+
return "";
|
|
29
|
+
return escapeHtml(v);
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Template tag for layouts: every `${value}` is escaped unless it is `html` itself or `raw()`.
|
|
33
|
+
* Arrays are joined, and null, undefined and false render nothing, so lists and conditions read naturally:
|
|
34
|
+
*
|
|
35
|
+
* html`<ul>${posts.map((p) => html`<li><a href="${p.route}">${p.title}</a></li>`)}</ul>`
|
|
36
|
+
* html`<main>${raw(content)}</main>`
|
|
37
|
+
*/
|
|
38
|
+
export function html(strings, ...values) {
|
|
39
|
+
let out = strings[0];
|
|
40
|
+
values.forEach((v, i) => (out += interpolate(v) + strings[i + 1]));
|
|
41
|
+
return safe(out);
|
|
42
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type Frontmatter } from "../content/markdown.js";
|
|
2
|
+
import type { ModuleLoader, SiteConfig } from "./config.js";
|
|
3
|
+
/** One published page, as layouts see it in `props.pages`. */
|
|
4
|
+
export interface PageSummary {
|
|
5
|
+
/** URL path with a trailing slash, e.g. "/blog/first-post/". */
|
|
6
|
+
route: string;
|
|
7
|
+
/** Absolute canonical URL, when `site` is set and the page is indexable. */
|
|
8
|
+
url?: string;
|
|
9
|
+
title: string;
|
|
10
|
+
/** Menu and breadcrumb text: `navTitle`, or the title. */
|
|
11
|
+
label: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
/** Source path relative to the project root, e.g. "src/pages/blog/first-post.md". */
|
|
14
|
+
source: string;
|
|
15
|
+
frontmatter: Frontmatter;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Every published page (drafts and 404.md excluded), sorted by route. Reads frontmatter
|
|
19
|
+
* and applies each page's layout schema, so list entries get the same defaults as the
|
|
20
|
+
* page itself. The result is frozen: layouts share it, so none may change it.
|
|
21
|
+
* `skipInvalid` (dev server, `vage check`) leaves out pages whose frontmatter is invalid;
|
|
22
|
+
* they report their own error instead of breaking every page that lists others.
|
|
23
|
+
*/
|
|
24
|
+
export declare function collectPages(root: string, site: SiteConfig, load: ModuleLoader, options?: {
|
|
25
|
+
skipInvalid?: boolean;
|
|
26
|
+
}): Promise<readonly PageSummary[]>;
|
|
27
|
+
export interface CollectionOptions {
|
|
28
|
+
/** Only pages below this route, e.g. "/blog/". The section's own index page is left out unless `includeIndex`. */
|
|
29
|
+
under?: string;
|
|
30
|
+
includeIndex?: boolean;
|
|
31
|
+
/**
|
|
32
|
+
* "date": newest `datePublished` first (undated pages last, then by title).
|
|
33
|
+
* "order": by the `order` field, lowest first (pages without one last, then by title).
|
|
34
|
+
* "title": A to Z. "route" (default): by URL. Or a compare function.
|
|
35
|
+
*/
|
|
36
|
+
sort?: "date" | "title" | "route" | "order" | ((a: PageSummary, b: PageSummary) => number);
|
|
37
|
+
limit?: number;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Picks and orders pages for lists such as a blog index, a sidebar or "latest posts":
|
|
41
|
+
* collection(pages, { under: "/blog/", sort: "date", limit: 5 })
|
|
42
|
+
*/
|
|
43
|
+
/** By `order` (lowest first; pages without one last), then title. */
|
|
44
|
+
export declare function byOrder(a: PageSummary, b: PageSummary): number;
|
|
45
|
+
export declare function collection(pages: readonly PageSummary[], options?: CollectionOptions): PageSummary[];
|
|
46
|
+
/** The pages before and after `route` in an ordered list (e.g. from collection()), for "Previous / Next" links. */
|
|
47
|
+
export declare function neighbors(list: readonly PageSummary[], route: string): {
|
|
48
|
+
prev?: PageSummary;
|
|
49
|
+
next?: PageSummary;
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* The trail from the home page to `route`, using the pages that exist at each level:
|
|
53
|
+
* /docs/guides/setup/ -> [Home, Docs, Guides, Setup] (missing levels are skipped).
|
|
54
|
+
*/
|
|
55
|
+
export declare function breadcrumbs(pages: readonly PageSummary[], route: string): PageSummary[];
|
|
56
|
+
export interface TreeNode {
|
|
57
|
+
page: PageSummary;
|
|
58
|
+
children: TreeNode[];
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Pages under `under` as a nested tree (folders become levels), each level sorted by
|
|
62
|
+
* `order`, then title: ready for a sidebar. A folder without its own index page is
|
|
63
|
+
* skipped; its pages move up a level.
|
|
64
|
+
*/
|
|
65
|
+
export declare function tree(pages: readonly PageSummary[], under?: string): TreeNode[];
|
|
66
|
+
/** A tree() in sidebar reading order (each page, then its children), for neighbors() in nested docs. */
|
|
67
|
+
export declare function flatten(nodes: readonly TreeNode[]): PageSummary[];
|