vagejs 0.2.0 → 0.3.1
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 +9 -3
- package/dist/checks/crawlability.d.ts +34 -0
- package/dist/checks/crawlability.js +142 -0
- package/dist/checks/verify.d.ts +8 -1
- package/dist/checks/verify.js +108 -11
- package/dist/cli/index.js +6 -4
- package/dist/cli/info.d.ts +11 -0
- package/dist/cli/info.js +20 -5
- package/dist/content/images.d.ts +4 -1
- package/dist/content/images.js +36 -3
- package/dist/content/markdown.d.ts +2 -0
- package/dist/content/markdown.js +2 -0
- package/dist/content/optimize.d.ts +31 -0
- package/dist/content/optimize.js +92 -0
- package/dist/content/sanitize.d.ts +8 -1
- package/dist/content/sanitize.js +3 -3
- package/dist/core/build.js +11 -0
- package/dist/core/config.d.ts +17 -0
- package/dist/core/config.js +24 -2
- package/dist/core/dates.d.ts +2 -0
- package/dist/core/dates.js +4 -0
- package/dist/core/pages.d.ts +28 -1
- package/dist/core/pages.js +86 -11
- package/dist/core/render.d.ts +7 -0
- package/dist/core/render.js +19 -2
- package/dist/core/site.js +50 -9
- package/dist/deploy/github-pages.js +2 -0
- package/dist/deploy/headers.d.ts +13 -2
- package/dist/deploy/headers.js +15 -3
- package/dist/dev/guard.d.ts +2 -0
- package/dist/dev/guard.js +30 -1
- package/dist/dev/server.js +19 -3
- 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 +23 -0
- package/dist/discovery/markdown-copy.js +152 -0
- package/dist/guidelines/rule-pack.json +133 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/seo/head.js +16 -0
- package/package.json +4 -1
- package/templates/starter/.github/workflows/ci.yml +2 -0
- package/templates/starter/.github/workflows/verify.yml +2 -0
- package/templates/starter/AGENTS.md +15 -7
- package/templates/starter/package.json +1 -1
package/dist/content/images.js
CHANGED
|
@@ -33,22 +33,50 @@ export function imageSize(file) {
|
|
|
33
33
|
if (chunk === "VP8X")
|
|
34
34
|
return { width: buf.readUIntLE(24, 3) + 1, height: buf.readUIntLE(27, 3) + 1 };
|
|
35
35
|
}
|
|
36
|
-
// JPEG: walk the segments to the first start-of-frame marker.
|
|
36
|
+
// JPEG: walk the segments to the first start-of-frame marker. EXIF orientations 5-8
|
|
37
|
+
// (phone photos taken sideways) display rotated, so width and height swap.
|
|
37
38
|
if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) {
|
|
38
39
|
let i = 2;
|
|
40
|
+
let orientation = 1;
|
|
39
41
|
while (i + 9 < buf.length) {
|
|
40
42
|
if (buf[i] !== 0xff)
|
|
41
43
|
return undefined;
|
|
42
44
|
const marker = buf[i + 1];
|
|
43
45
|
const length = buf.readUInt16BE(i + 2);
|
|
46
|
+
if (marker === 0xe1)
|
|
47
|
+
orientation = exifOrientation(buf, i + 4, length - 2) ?? orientation;
|
|
44
48
|
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
45
|
-
|
|
49
|
+
const width = buf.readUInt16BE(i + 7);
|
|
50
|
+
const height = buf.readUInt16BE(i + 5);
|
|
51
|
+
return orientation >= 5 && orientation <= 8 ? { width: height, height: width } : { width, height };
|
|
46
52
|
}
|
|
47
53
|
i += 2 + length;
|
|
48
54
|
}
|
|
49
55
|
}
|
|
50
56
|
return undefined;
|
|
51
57
|
}
|
|
58
|
+
/** The EXIF Orientation tag (0x0112) from an APP1 segment, if present. */
|
|
59
|
+
function exifOrientation(buf, start, length) {
|
|
60
|
+
const end = Math.min(buf.length, start + length);
|
|
61
|
+
if (end - start < 14 || buf.toString("latin1", start, start + 6) !== "Exif\0\0")
|
|
62
|
+
return undefined;
|
|
63
|
+
const tiff = start + 6;
|
|
64
|
+
const little = buf.toString("latin1", tiff, tiff + 2) === "II";
|
|
65
|
+
const u16 = (o) => (o + 2 <= end ? (little ? buf.readUInt16LE(o) : buf.readUInt16BE(o)) : undefined);
|
|
66
|
+
const u32 = (o) => (o + 4 <= end ? (little ? buf.readUInt32LE(o) : buf.readUInt32BE(o)) : undefined);
|
|
67
|
+
const ifd = u32(tiff + 4);
|
|
68
|
+
if (ifd === undefined)
|
|
69
|
+
return undefined;
|
|
70
|
+
const count = u16(tiff + ifd);
|
|
71
|
+
if (count === undefined)
|
|
72
|
+
return undefined;
|
|
73
|
+
for (let k = 0; k < count; k++) {
|
|
74
|
+
const entry = tiff + ifd + 2 + k * 12;
|
|
75
|
+
if (u16(entry) === 0x0112)
|
|
76
|
+
return u16(entry + 8);
|
|
77
|
+
}
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
52
80
|
function svgSize(svg) {
|
|
53
81
|
const tag = /<svg\b[^>]*>/i.exec(svg)?.[0];
|
|
54
82
|
if (!tag)
|
|
@@ -68,8 +96,13 @@ function svgSize(svg) {
|
|
|
68
96
|
* remote images only get the loading hints. Works on DOM elements, never on HTML text,
|
|
69
97
|
* so it can't change how the sanitized markup parses.
|
|
70
98
|
*/
|
|
71
|
-
export function enhanceImage(img, publicDir, first) {
|
|
99
|
+
export function enhanceImage(img, publicDir, first, variants) {
|
|
72
100
|
const src = img.getAttribute("src");
|
|
101
|
+
if (variants && !img.hasAttribute("srcset")) {
|
|
102
|
+
img.setAttribute("srcset", variants.srcset);
|
|
103
|
+
if (!img.hasAttribute("sizes"))
|
|
104
|
+
img.setAttribute("sizes", variants.sizes);
|
|
105
|
+
}
|
|
73
106
|
if (src && !img.hasAttribute("width") && !img.hasAttribute("height") && /^\/(?!\/)/.test(src)) {
|
|
74
107
|
const size = localImageSize(src, publicDir);
|
|
75
108
|
if (size) {
|
|
@@ -14,6 +14,8 @@ export declare const frontmatterSchema: z.ZodObject<{
|
|
|
14
14
|
author: z.ZodOptional<z.ZodString>;
|
|
15
15
|
datePublished: z.ZodOptional<z.ZodPreprocess<z.ZodUnion<readonly [z.ZodISODate, z.ZodISODateTime]>, unknown>>;
|
|
16
16
|
dateModified: z.ZodOptional<z.ZodPreprocess<z.ZodUnion<readonly [z.ZodISODate, z.ZodISODateTime]>, unknown>>;
|
|
17
|
+
order: z.ZodOptional<z.ZodNumber>;
|
|
18
|
+
navTitle: z.ZodOptional<z.ZodString>;
|
|
17
19
|
}, z.core.$loose>;
|
|
18
20
|
export type Frontmatter = z.output<typeof frontmatterSchema>;
|
|
19
21
|
export interface MarkdownPage {
|
package/dist/content/markdown.js
CHANGED
|
@@ -28,6 +28,8 @@ export const frontmatterSchema = z.looseObject({
|
|
|
28
28
|
author: singleLine.min(1).optional().describe("Article author name. Only use a real author."),
|
|
29
29
|
datePublished: isoDate.optional().describe("ISO date or datetime. Only use a real date."),
|
|
30
30
|
dateModified: isoDate.optional().describe("ISO date or datetime; also the sitemap <lastmod>."),
|
|
31
|
+
order: z.number().finite().optional().describe('Position in navigation lists (lower first). Used by collection(pages, { sort: "order" }) and tree().'),
|
|
32
|
+
navTitle: singleLine.min(1).optional().describe("Shorter title for menus and breadcrumbs. Defaults to the title."),
|
|
31
33
|
});
|
|
32
34
|
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
33
35
|
const marked = new Marked({ gfm: true });
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opt-in image pipeline (images.optimize): resized WebP copies of the images used in
|
|
3
|
+
* page content, offered to browsers with srcset. Needs the `sharp` package in the
|
|
4
|
+
* project. Results are cached in node_modules/.cache/vagejs/img, keyed by the file's
|
|
5
|
+
* content and the settings, so unchanged images are never processed twice.
|
|
6
|
+
*/
|
|
7
|
+
export interface ImageOptions {
|
|
8
|
+
optimize: boolean;
|
|
9
|
+
widths: number[];
|
|
10
|
+
quality: number;
|
|
11
|
+
sizes: string;
|
|
12
|
+
}
|
|
13
|
+
export interface Variants {
|
|
14
|
+
/** "url 480w, url 960w" */
|
|
15
|
+
srcset: string;
|
|
16
|
+
/** Published path (e.g. "_vage/img/photo-ab12cd34-480.webp") -> cached file on disk. */
|
|
17
|
+
files: Map<string, string>;
|
|
18
|
+
}
|
|
19
|
+
/** Turns an image into WebP at a given width. Injected in tests; sharp otherwise. */
|
|
20
|
+
export type Resize = (input: Buffer, width: number, quality: number) => Promise<Buffer>;
|
|
21
|
+
export declare const IMAGE_DIR = "_vage/img";
|
|
22
|
+
export declare function cacheDir(root: string): string;
|
|
23
|
+
/** Loads sharp from the project (not from vagejs), with a clear message if it's missing. */
|
|
24
|
+
export declare function sharpResize(root: string): Promise<Resize>;
|
|
25
|
+
/**
|
|
26
|
+
* WebP variants for one site-path image in public/. Undefined for remote images,
|
|
27
|
+
* non-raster formats (SVG, GIF), paths outside public/ and unreadable files.
|
|
28
|
+
*/
|
|
29
|
+
export declare function imageVariants(src: string, root: string, options: ImageOptions, resize: Resize): Promise<Variants | undefined>;
|
|
30
|
+
/** Image sources used in rendered Markdown, read with an HTML parser (so entities decode like the DOM's). */
|
|
31
|
+
export declare function contentImageSources(html: string): string[];
|
|
@@ -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
|
+
}
|
|
@@ -11,6 +11,13 @@
|
|
|
11
11
|
export declare function sanitizeHtml(html: string, options?: {
|
|
12
12
|
publicDir?: string;
|
|
13
13
|
inlineStyles?: boolean;
|
|
14
|
+
variants?: Map<string, {
|
|
15
|
+
srcset: string;
|
|
16
|
+
sizes: string;
|
|
17
|
+
}>;
|
|
14
18
|
}): Promise<string>;
|
|
15
19
|
/** For trusted content (`markdown.html: "allow"`): the same image hints, applied through the DOM. */
|
|
16
|
-
export declare function enhanceTrustedHtml(html: string, publicDir: string
|
|
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
|
@@ -25,7 +25,7 @@ export async function sanitizeHtml(html, options = {}) {
|
|
|
25
25
|
const publicDir = options.publicDir;
|
|
26
26
|
const hook = (node) => {
|
|
27
27
|
if (publicDir && node.nodeName === "IMG") {
|
|
28
|
-
enhanceImage(node, publicDir, first);
|
|
28
|
+
enhanceImage(node, publicDir, first, options.variants?.get(node.getAttribute("src") ?? ""));
|
|
29
29
|
first = false;
|
|
30
30
|
}
|
|
31
31
|
};
|
|
@@ -48,10 +48,10 @@ export async function sanitizeHtml(html, options = {}) {
|
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
50
|
/** For trusted content (`markdown.html: "allow"`): the same image hints, applied through the DOM. */
|
|
51
|
-
export async function enhanceTrustedHtml(html, publicDir) {
|
|
51
|
+
export async function enhanceTrustedHtml(html, publicDir, variants) {
|
|
52
52
|
const { jsdom } = await load();
|
|
53
53
|
const template = new jsdom.JSDOM("").window.document.createElement("template");
|
|
54
54
|
template.innerHTML = html;
|
|
55
|
-
template.content.querySelectorAll("img").forEach((img, i) => enhanceImage(img, publicDir, i === 0));
|
|
55
|
+
template.content.querySelectorAll("img").forEach((img, i) => enhanceImage(img, publicDir, i === 0, variants?.get(img.getAttribute("src") ?? "")));
|
|
56
56
|
return template.innerHTML;
|
|
57
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 })),
|
package/dist/core/config.d.ts
CHANGED
|
@@ -5,6 +5,12 @@ 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";
|
|
@@ -44,6 +50,17 @@ export declare const configSchema: z.ZodObject<{
|
|
|
44
50
|
aiTraining?: "allow" | "disallow" | undefined;
|
|
45
51
|
aiSearch?: "allow" | "disallow" | undefined;
|
|
46
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>;
|
|
47
64
|
sitemap: z.ZodDefault<z.ZodBoolean>;
|
|
48
65
|
llmsTxt: z.ZodPipe<z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
|
|
49
66
|
full: z.ZodDefault<z.ZodBoolean>;
|
package/dist/core/config.js
CHANGED
|
@@ -31,14 +31,28 @@ const robotsSchema = z.strictObject({
|
|
|
31
31
|
aiTraining: z
|
|
32
32
|
.enum(["allow", "disallow"])
|
|
33
33
|
.optional()
|
|
34
|
-
.describe("Groups for documented AI training crawlers (GPTBot, ClaudeBot, Google-Extended). Unset: the * group applies."),
|
|
34
|
+
.describe("Groups for documented AI training crawlers (GPTBot, ClaudeBot, Google-Extended, Applebot-Extended). Google-Extended also covers grounding in Gemini Apps and Vertex AI. Unset: the * group applies."),
|
|
35
35
|
aiSearch: z
|
|
36
36
|
.enum(["allow", "disallow"])
|
|
37
37
|
.optional()
|
|
38
|
-
.describe("Groups for documented AI search crawlers (OAI-SearchBot, Claude-SearchBot). Unset: the * group applies."),
|
|
38
|
+
.describe("Groups for documented AI search crawlers (OAI-SearchBot, Claude-SearchBot, PerplexityBot). Unset: the * group applies."),
|
|
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,6 +185,14 @@ 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
|
package/dist/core/pages.d.ts
CHANGED
|
@@ -7,6 +7,8 @@ export interface PageSummary {
|
|
|
7
7
|
/** Absolute canonical URL, when `site` is set and the page is indexable. */
|
|
8
8
|
url?: string;
|
|
9
9
|
title: string;
|
|
10
|
+
/** Menu and breadcrumb text: `navTitle`, or the title. */
|
|
11
|
+
label: string;
|
|
10
12
|
description?: string;
|
|
11
13
|
/** Source path relative to the project root, e.g. "src/pages/blog/first-post.md". */
|
|
12
14
|
source: string;
|
|
@@ -28,13 +30,38 @@ export interface CollectionOptions {
|
|
|
28
30
|
includeIndex?: boolean;
|
|
29
31
|
/**
|
|
30
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).
|
|
31
34
|
* "title": A to Z. "route" (default): by URL. Or a compare function.
|
|
32
35
|
*/
|
|
33
|
-
sort?: "date" | "title" | "route" | ((a: PageSummary, b: PageSummary) => number);
|
|
36
|
+
sort?: "date" | "title" | "route" | "order" | ((a: PageSummary, b: PageSummary) => number);
|
|
34
37
|
limit?: number;
|
|
35
38
|
}
|
|
36
39
|
/**
|
|
37
40
|
* Picks and orders pages for lists such as a blog index, a sidebar or "latest posts":
|
|
38
41
|
* collection(pages, { under: "/blog/", sort: "date", limit: 5 })
|
|
39
42
|
*/
|
|
43
|
+
/** By `order` (lowest first; pages without one last), then title. */
|
|
44
|
+
export declare function byOrder(a: PageSummary, b: PageSummary): number;
|
|
40
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[];
|
package/dist/core/pages.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { parseFrontmatter } from "../content/markdown.js";
|
|
4
4
|
import { pageMeta } from "../seo/head.js";
|
|
5
5
|
import { clonePlain } from "./clone.js";
|
|
6
|
+
import { instant } from "./dates.js";
|
|
6
7
|
import { applyLayoutFields } from "./render.js";
|
|
7
8
|
import { VageError } from "./errors.js";
|
|
8
9
|
import { discoverRoutes } from "./routes.js";
|
|
@@ -34,6 +35,7 @@ export async function collectPages(root, site, load, options = {}) {
|
|
|
34
35
|
route: entry.route,
|
|
35
36
|
url: pageMeta(site, entry.route, frontmatter).canonical,
|
|
36
37
|
title: frontmatter.title,
|
|
38
|
+
label: frontmatter.navTitle ?? frontmatter.title,
|
|
37
39
|
description: frontmatter.description,
|
|
38
40
|
source: path.relative(root, entry.file),
|
|
39
41
|
frontmatter,
|
|
@@ -52,11 +54,19 @@ function deepFreeze(value) {
|
|
|
52
54
|
}
|
|
53
55
|
return value;
|
|
54
56
|
}
|
|
55
|
-
const SORTS = ["date", "title", "route"];
|
|
57
|
+
const SORTS = ["date", "title", "route", "order"];
|
|
56
58
|
/**
|
|
57
59
|
* Picks and orders pages for lists such as a blog index, a sidebar or "latest posts":
|
|
58
60
|
* collection(pages, { under: "/blog/", sort: "date", limit: 5 })
|
|
59
61
|
*/
|
|
62
|
+
/** By `order` (lowest first; pages without one last), then title. */
|
|
63
|
+
export function byOrder(a, b) {
|
|
64
|
+
const oa = a.frontmatter.order;
|
|
65
|
+
const ob = b.frontmatter.order;
|
|
66
|
+
if (oa !== ob)
|
|
67
|
+
return oa === undefined ? 1 : ob === undefined ? -1 : oa - ob;
|
|
68
|
+
return a.title.localeCompare(b.title);
|
|
69
|
+
}
|
|
60
70
|
export function collection(pages, options = {}) {
|
|
61
71
|
if (!Array.isArray(pages))
|
|
62
72
|
throw new VageError("collection(): pass the layout's `pages` as the first argument.");
|
|
@@ -65,7 +75,7 @@ export function collection(pages, options = {}) {
|
|
|
65
75
|
throw new VageError(`collection(): "under" must be a site path starting with "/", like "/blog/" (got ${JSON.stringify(under)}).`);
|
|
66
76
|
}
|
|
67
77
|
if (typeof sort !== "function" && !SORTS.includes(sort)) {
|
|
68
|
-
throw new VageError(`collection(): "sort" must be "date", "title", "route" or a compare function (got ${JSON.stringify(sort)}).`);
|
|
78
|
+
throw new VageError(`collection(): "sort" must be "date", "order", "title", "route" or a compare function (got ${JSON.stringify(sort)}).`);
|
|
69
79
|
}
|
|
70
80
|
if (typeof includeIndex !== "boolean")
|
|
71
81
|
throw new VageError(`collection(): "includeIndex" must be true or false (got ${JSON.stringify(includeIndex)}).`);
|
|
@@ -79,15 +89,80 @@ export function collection(pages, options = {}) {
|
|
|
79
89
|
? sort
|
|
80
90
|
: sort === "title"
|
|
81
91
|
? byTitle
|
|
82
|
-
: sort === "
|
|
83
|
-
?
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
92
|
+
: sort === "order"
|
|
93
|
+
? byOrder
|
|
94
|
+
: sort === "date"
|
|
95
|
+
? (a, b) => {
|
|
96
|
+
const ta = instant(a.frontmatter.datePublished);
|
|
97
|
+
const tb = instant(b.frontmatter.datePublished);
|
|
98
|
+
if (ta !== tb && !(Number.isNaN(ta) && Number.isNaN(tb)))
|
|
99
|
+
return Number.isNaN(ta) ? 1 : Number.isNaN(tb) ? -1 : tb - ta;
|
|
100
|
+
return byTitle(a, b);
|
|
101
|
+
}
|
|
102
|
+
: (a, b) => a.route.localeCompare(b.route);
|
|
91
103
|
const sorted = [...picked].sort(compare);
|
|
92
104
|
return limit === undefined ? sorted : sorted.slice(0, limit);
|
|
93
105
|
}
|
|
106
|
+
/** The pages before and after `route` in an ordered list (e.g. from collection()), for "Previous / Next" links. */
|
|
107
|
+
export function neighbors(list, route) {
|
|
108
|
+
const i = list.findIndex((p) => p.route === route);
|
|
109
|
+
if (i === -1)
|
|
110
|
+
return {};
|
|
111
|
+
return { prev: list[i - 1], next: list[i + 1] };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The trail from the home page to `route`, using the pages that exist at each level:
|
|
115
|
+
* /docs/guides/setup/ -> [Home, Docs, Guides, Setup] (missing levels are skipped).
|
|
116
|
+
*/
|
|
117
|
+
export function breadcrumbs(pages, route) {
|
|
118
|
+
const byRoute = new Map(pages.map((p) => [p.route, p]));
|
|
119
|
+
const parts = route.split("/").filter(Boolean);
|
|
120
|
+
const trail = [];
|
|
121
|
+
for (let i = 0; i <= parts.length; i++) {
|
|
122
|
+
const r = i === 0 ? "/" : `/${parts.slice(0, i).join("/")}/`;
|
|
123
|
+
const page = byRoute.get(r);
|
|
124
|
+
if (page)
|
|
125
|
+
trail.push(page);
|
|
126
|
+
}
|
|
127
|
+
return trail;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Pages under `under` as a nested tree (folders become levels), each level sorted by
|
|
131
|
+
* `order`, then title: ready for a sidebar. A folder without its own index page is
|
|
132
|
+
* skipped; its pages move up a level.
|
|
133
|
+
*/
|
|
134
|
+
export function tree(pages, under = "/") {
|
|
135
|
+
if (!/^\/(?!\/)/.test(under))
|
|
136
|
+
throw new VageError(`tree(): "under" must be a site path starting with "/" (got ${JSON.stringify(under)}).`);
|
|
137
|
+
const prefix = under.endsWith("/") ? under : `${under}/`;
|
|
138
|
+
const inside = pages.filter((p) => p.route.startsWith(prefix) && p.route !== prefix);
|
|
139
|
+
const routes = new Set(inside.map((p) => p.route));
|
|
140
|
+
const parentOf = (route) => {
|
|
141
|
+
const parts = route.split("/").filter(Boolean);
|
|
142
|
+
for (let i = parts.length - 1; i > 0; i--) {
|
|
143
|
+
const r = `/${parts.slice(0, i).join("/")}/`;
|
|
144
|
+
if (r.length <= prefix.length)
|
|
145
|
+
return undefined;
|
|
146
|
+
if (routes.has(r))
|
|
147
|
+
return r;
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
};
|
|
151
|
+
const nodes = new Map(inside.map((p) => [p.route, { page: p, children: [] }]));
|
|
152
|
+
const roots = [];
|
|
153
|
+
for (const node of nodes.values()) {
|
|
154
|
+
const parent = parentOf(node.page.route);
|
|
155
|
+
(parent ? nodes.get(parent).children : roots).push(node);
|
|
156
|
+
}
|
|
157
|
+
const sort = (list) => {
|
|
158
|
+
list.sort((a, b) => byOrder(a.page, b.page));
|
|
159
|
+
for (const n of list)
|
|
160
|
+
sort(n.children);
|
|
161
|
+
return list;
|
|
162
|
+
};
|
|
163
|
+
return sort(roots);
|
|
164
|
+
}
|
|
165
|
+
/** A tree() in sidebar reading order (each page, then its children), for neighbors() in nested docs. */
|
|
166
|
+
export function flatten(nodes) {
|
|
167
|
+
return nodes.flatMap((n) => [n.page, ...flatten(n.children)]);
|
|
168
|
+
}
|
package/dist/core/render.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Frontmatter } from "../content/markdown.js";
|
|
2
|
+
import { type Resize } from "../content/optimize.js";
|
|
2
3
|
import type { ModuleLoader, SiteConfig } from "./config.js";
|
|
3
4
|
import { type SafeHtml } from "./html.js";
|
|
4
5
|
import { type PageMeta } from "../seo/head.js";
|
|
@@ -25,6 +26,10 @@ export interface LayoutProps {
|
|
|
25
26
|
export type Layout = (props: LayoutProps) => string | SafeHtml | Promise<string | SafeHtml>;
|
|
26
27
|
export interface RenderedPage {
|
|
27
28
|
html: string;
|
|
29
|
+
/** The page body as rendered and sanitized, before the layout (source of Markdown copies). */
|
|
30
|
+
content: string;
|
|
31
|
+
/** Generated image files this page uses: published path -> file on disk (images.optimize). */
|
|
32
|
+
assets: Map<string, string>;
|
|
28
33
|
frontmatter: Frontmatter;
|
|
29
34
|
/** Markdown body without frontmatter. */
|
|
30
35
|
body: string;
|
|
@@ -42,6 +47,8 @@ export declare function renderPage(options: {
|
|
|
42
47
|
analytics?: boolean;
|
|
43
48
|
/** Published pages for `props.pages`. */
|
|
44
49
|
pages?: readonly PageSummary[];
|
|
50
|
+
/** Resizer for images.optimize (tests inject one; sharp otherwise). */
|
|
51
|
+
resize?: Resize;
|
|
45
52
|
}): Promise<RenderedPage>;
|
|
46
53
|
/**
|
|
47
54
|
* Loads the page's layout and, if it exports a `frontmatter` schema, validates the page
|
package/dist/core/render.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { frontmatterSchema, parseMarkdown } from "../content/markdown.js";
|
|
5
|
+
import { contentImageSources, imageVariants, sharpResize } from "../content/optimize.js";
|
|
5
6
|
import { enhanceTrustedHtml, sanitizeHtml } from "../content/sanitize.js";
|
|
6
7
|
import { VageError } from "./errors.js";
|
|
7
8
|
import { clonePlain } from "./clone.js";
|
|
@@ -13,7 +14,23 @@ export async function renderPage(options) {
|
|
|
13
14
|
const parsed = parseMarkdown(fs.readFileSync(file, "utf8"), file);
|
|
14
15
|
const { frontmatter, body } = parsed;
|
|
15
16
|
const publicDir = path.join(root, "public");
|
|
16
|
-
const
|
|
17
|
+
const assets = new Map();
|
|
18
|
+
let variants;
|
|
19
|
+
if (site.images.optimize) {
|
|
20
|
+
const resize = options.resize ?? (await sharpResize(root));
|
|
21
|
+
variants = new Map();
|
|
22
|
+
for (const src of contentImageSources(parsed.html)) {
|
|
23
|
+
const v = await imageVariants(src, root, site.images, resize);
|
|
24
|
+
if (!v)
|
|
25
|
+
continue;
|
|
26
|
+
variants.set(src, { srcset: v.srcset, sizes: site.images.sizes });
|
|
27
|
+
for (const [k, f] of v.files)
|
|
28
|
+
assets.set(k, f);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const content = site.markdown.html === "allow"
|
|
32
|
+
? await enhanceTrustedHtml(parsed.html, publicDir, variants)
|
|
33
|
+
: await sanitizeHtml(parsed.html, { publicDir, inlineStyles: site.markdown.inlineStyles, variants });
|
|
17
34
|
const layout = await applyLayoutFields(root, frontmatter, load, file);
|
|
18
35
|
const meta = pageMeta(site, route, frontmatter, options.notFound);
|
|
19
36
|
const result = await layout({
|
|
@@ -30,7 +47,7 @@ export async function renderPage(options) {
|
|
|
30
47
|
const tags = options.analytics ? analyticsTags(site) : undefined;
|
|
31
48
|
if (tags)
|
|
32
49
|
out = injectAnalytics(out, tags, file);
|
|
33
|
-
return { html: out, frontmatter, body, meta };
|
|
50
|
+
return { html: out, content, frontmatter, body, meta, assets };
|
|
34
51
|
}
|
|
35
52
|
const LAYOUT_EXTENSIONS = [".ts", ".js", ".mjs"];
|
|
36
53
|
async function resolveLayout(root, name, load, pageFile) {
|