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
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Reads pixel dimensions from an image file's header (PNG, JPEG, GIF, WebP, SVG),
|
|
5
|
+
* without decoding it or adding a dependency. Undefined when the format isn't recognized.
|
|
6
|
+
*/
|
|
7
|
+
export function imageSize(file) {
|
|
8
|
+
let buf;
|
|
9
|
+
try {
|
|
10
|
+
buf = fs.readFileSync(file);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
const ext = path.extname(file).toLowerCase();
|
|
16
|
+
if (ext === ".svg")
|
|
17
|
+
return svgSize(buf.toString("utf8"));
|
|
18
|
+
// PNG: width and height in the IHDR chunk.
|
|
19
|
+
if (buf.length >= 24 && buf.readUInt32BE(0) === 0x89504e47)
|
|
20
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
21
|
+
// GIF: little-endian logical screen size.
|
|
22
|
+
if (buf.length >= 10 && buf.toString("latin1", 0, 3) === "GIF")
|
|
23
|
+
return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
|
|
24
|
+
// WebP: lossy (VP8), lossless (VP8L) or extended (VP8X).
|
|
25
|
+
if (buf.length >= 30 && buf.toString("latin1", 0, 4) === "RIFF" && buf.toString("latin1", 8, 12) === "WEBP") {
|
|
26
|
+
const chunk = buf.toString("latin1", 12, 16);
|
|
27
|
+
if (chunk === "VP8 ")
|
|
28
|
+
return { width: buf.readUInt16LE(26) & 0x3fff, height: buf.readUInt16LE(28) & 0x3fff };
|
|
29
|
+
if (chunk === "VP8L") {
|
|
30
|
+
const b = buf.readUInt32LE(21);
|
|
31
|
+
return { width: (b & 0x3fff) + 1, height: ((b >> 14) & 0x3fff) + 1 };
|
|
32
|
+
}
|
|
33
|
+
if (chunk === "VP8X")
|
|
34
|
+
return { width: buf.readUIntLE(24, 3) + 1, height: buf.readUIntLE(27, 3) + 1 };
|
|
35
|
+
}
|
|
36
|
+
// JPEG: walk the segments to the first start-of-frame marker.
|
|
37
|
+
if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) {
|
|
38
|
+
let i = 2;
|
|
39
|
+
while (i + 9 < buf.length) {
|
|
40
|
+
if (buf[i] !== 0xff)
|
|
41
|
+
return undefined;
|
|
42
|
+
const marker = buf[i + 1];
|
|
43
|
+
const length = buf.readUInt16BE(i + 2);
|
|
44
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
45
|
+
return { width: buf.readUInt16BE(i + 7), height: buf.readUInt16BE(i + 5) };
|
|
46
|
+
}
|
|
47
|
+
i += 2 + length;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
function svgSize(svg) {
|
|
53
|
+
const tag = /<svg\b[^>]*>/i.exec(svg)?.[0];
|
|
54
|
+
if (!tag)
|
|
55
|
+
return undefined;
|
|
56
|
+
const attr = (name) => new RegExp(`\\s${name}\\s*=\\s*["']\\s*([\\d.]+)(px)?\\s*["']`, "i").exec(tag)?.[1];
|
|
57
|
+
const w = attr("width");
|
|
58
|
+
const h = attr("height");
|
|
59
|
+
if (w && h)
|
|
60
|
+
return { width: Math.round(Number(w)), height: Math.round(Number(h)) };
|
|
61
|
+
const box = /\sviewBox\s*=\s*["']\s*[\d.-]+[\s,]+[\d.-]+[\s,]+([\d.]+)[\s,]+([\d.]+)\s*["']/i.exec(tag);
|
|
62
|
+
return box ? { width: Math.round(Number(box[1])), height: Math.round(Number(box[2])) } : undefined;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Fills in what Markdown images can't say: width and height from the file in public/
|
|
66
|
+
* (prevents layout shift), decoding="async", and loading="lazy" unless it's the first
|
|
67
|
+
* image (often the main one, which shouldn't wait). Attributes already present are kept;
|
|
68
|
+
* remote images only get the loading hints. Works on DOM elements, never on HTML text,
|
|
69
|
+
* so it can't change how the sanitized markup parses.
|
|
70
|
+
*/
|
|
71
|
+
export function enhanceImage(img, publicDir, first) {
|
|
72
|
+
const src = img.getAttribute("src");
|
|
73
|
+
if (src && !img.hasAttribute("width") && !img.hasAttribute("height") && /^\/(?!\/)/.test(src)) {
|
|
74
|
+
const size = localImageSize(src, publicDir);
|
|
75
|
+
if (size) {
|
|
76
|
+
img.setAttribute("width", String(size.width));
|
|
77
|
+
img.setAttribute("height", String(size.height));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!img.hasAttribute("decoding"))
|
|
81
|
+
img.setAttribute("decoding", "async");
|
|
82
|
+
if (!first && !img.hasAttribute("loading"))
|
|
83
|
+
img.setAttribute("loading", "lazy");
|
|
84
|
+
}
|
|
85
|
+
/** Size of a site-path image inside public/; undefined for bad paths or anything outside public/. */
|
|
86
|
+
function localImageSize(src, publicDir) {
|
|
87
|
+
let rel;
|
|
88
|
+
try {
|
|
89
|
+
rel = decodeURIComponent(src.split(/[?#]/)[0]);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return undefined; // e.g. "/100%.png": not a valid encoded path
|
|
93
|
+
}
|
|
94
|
+
const file = path.join(publicDir, rel);
|
|
95
|
+
return file.startsWith(publicDir + path.sep) ? imageSize(file) : undefined;
|
|
96
|
+
}
|
|
@@ -24,3 +24,5 @@ export interface MarkdownPage {
|
|
|
24
24
|
html: string;
|
|
25
25
|
}
|
|
26
26
|
export declare function parseMarkdown(source: string, file: string): MarkdownPage;
|
|
27
|
+
/** Frontmatter and Markdown body, without rendering HTML (cheap enough to run on every page). */
|
|
28
|
+
export declare function parseFrontmatter(source: string, file: string): Omit<MarkdownPage, "html">;
|
package/dist/content/markdown.js
CHANGED
|
@@ -32,6 +32,11 @@ export const frontmatterSchema = z.looseObject({
|
|
|
32
32
|
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
|
|
33
33
|
const marked = new Marked({ gfm: true });
|
|
34
34
|
export function parseMarkdown(source, file) {
|
|
35
|
+
const { frontmatter, body } = parseFrontmatter(source, file);
|
|
36
|
+
return { frontmatter, body, html: marked.parse(body, { async: false }) };
|
|
37
|
+
}
|
|
38
|
+
/** Frontmatter and Markdown body, without rendering HTML (cheap enough to run on every page). */
|
|
39
|
+
export function parseFrontmatter(source, file) {
|
|
35
40
|
const match = FRONTMATTER_RE.exec(source);
|
|
36
41
|
let data = {};
|
|
37
42
|
if (match) {
|
|
@@ -47,5 +52,5 @@ export function parseMarkdown(source, file) {
|
|
|
47
52
|
throw new VageError(`Invalid frontmatter:\n${z.prettifyError(result.error)}`, file);
|
|
48
53
|
}
|
|
49
54
|
const body = match ? source.slice(match[0].length) : source;
|
|
50
|
-
return { frontmatter: result.data, body
|
|
55
|
+
return { frontmatter: result.data, body };
|
|
51
56
|
}
|
|
@@ -2,6 +2,15 @@
|
|
|
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
|
+
}): Promise<string>;
|
|
15
|
+
/** For trusted content (`markdown.html: "allow"`): the same image hints, applied through the DOM. */
|
|
16
|
+
export declare function enhanceTrustedHtml(html: string, publicDir: string): 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);
|
|
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) {
|
|
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));
|
|
56
|
+
return template.innerHTML;
|
|
17
57
|
}
|
|
@@ -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
|
@@ -10,6 +10,7 @@ export declare const configSchema: z.ZodObject<{
|
|
|
10
10
|
allow: "allow";
|
|
11
11
|
sanitize: "sanitize";
|
|
12
12
|
}>>;
|
|
13
|
+
inlineStyles: z.ZodDefault<z.ZodBoolean>;
|
|
13
14
|
}, z.core.$strict>>;
|
|
14
15
|
seo: z.ZodDefault<z.ZodObject<{
|
|
15
16
|
robots: z.ZodPipe<z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
|
|
@@ -95,9 +96,11 @@ export declare const configSchema: z.ZodObject<{
|
|
|
95
96
|
deploy: z.ZodDefault<z.ZodObject<{
|
|
96
97
|
target: z.ZodDefault<z.ZodEnum<{
|
|
97
98
|
"cloudflare-pages": "cloudflare-pages";
|
|
99
|
+
"cloudflare-workers": "cloudflare-workers";
|
|
98
100
|
"github-pages": "github-pages";
|
|
99
101
|
}>>;
|
|
100
102
|
cloudflareProject: z.ZodOptional<z.ZodString>;
|
|
103
|
+
cloudflareWorker: z.ZodOptional<z.ZodString>;
|
|
101
104
|
}, z.core.$strict>>;
|
|
102
105
|
security: z.ZodDefault<z.ZodObject<{
|
|
103
106
|
headers: z.ZodDefault<z.ZodBoolean>;
|
|
@@ -115,6 +118,12 @@ export declare const configSchema: z.ZodObject<{
|
|
|
115
118
|
"script-src": "script-src";
|
|
116
119
|
"style-src": "style-src";
|
|
117
120
|
}> & z.core.$partial, z.ZodArray<z.ZodString>>>;
|
|
121
|
+
outboundLinks: z.ZodDefault<z.ZodEnum<{
|
|
122
|
+
lock: "lock";
|
|
123
|
+
off: "off";
|
|
124
|
+
require: "require";
|
|
125
|
+
}>>;
|
|
126
|
+
spamPatterns: z.ZodDefault<z.ZodBoolean>;
|
|
118
127
|
}, z.core.$strict>>;
|
|
119
128
|
guidelines: z.ZodDefault<z.ZodObject<{
|
|
120
129
|
rulePack: z.ZodOptional<z.ZodString>;
|
package/dist/core/config.js
CHANGED
|
@@ -177,19 +177,31 @@ export const configSchema = z.strictObject({
|
|
|
177
177
|
.enum(["sanitize", "allow"])
|
|
178
178
|
.default("sanitize")
|
|
179
179
|
.describe('"sanitize" removes scripts, event handlers, javascript: URLs, iframes and forms from page content. "allow" trusts all content authors.'),
|
|
180
|
+
inlineStyles: z
|
|
181
|
+
.boolean()
|
|
182
|
+
.default(false)
|
|
183
|
+
.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
184
|
})
|
|
181
|
-
.default({ html: "sanitize" }),
|
|
185
|
+
.default({ html: "sanitize", inlineStyles: false }),
|
|
182
186
|
seo: seoSchema.default(() => seoSchema.parse({})),
|
|
183
187
|
performance: performanceSchema.default(() => performanceSchema.parse({})),
|
|
184
188
|
deploy: z
|
|
185
189
|
.strictObject({
|
|
186
|
-
target: z
|
|
190
|
+
target: z
|
|
191
|
+
.enum(["github-pages", "cloudflare-pages", "cloudflare-workers"])
|
|
192
|
+
.default("github-pages")
|
|
193
|
+
.describe("Host used by `vage deploy`. Cloudflare now creates new sites as Workers: prefer cloudflare-workers there."),
|
|
187
194
|
cloudflareProject: z.string().regex(/^[a-z0-9][a-z0-9-]{0,57}$/).optional().describe("Cloudflare Pages project name."),
|
|
195
|
+
cloudflareWorker: z
|
|
196
|
+
.string()
|
|
197
|
+
.regex(/^[a-z0-9][a-z0-9-]{0,62}$/)
|
|
198
|
+
.optional()
|
|
199
|
+
.describe("Cloudflare Worker name (cloudflare-workers target, when the project has no wrangler config). Defaults to cloudflareProject."),
|
|
188
200
|
})
|
|
189
201
|
.default({ target: "github-pages" }),
|
|
190
202
|
security: z
|
|
191
203
|
.strictObject({
|
|
192
|
-
headers: z.boolean().default(true).describe("Write a _headers file with security headers (Cloudflare
|
|
204
|
+
headers: z.boolean().default(true).describe("Write a _headers file with security headers (Cloudflare targets)."),
|
|
193
205
|
csp: z
|
|
194
206
|
.enum(["enforce", "report-only", "off"])
|
|
195
207
|
.default("enforce")
|
|
@@ -198,8 +210,13 @@ export const configSchema = z.strictObject({
|
|
|
198
210
|
.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
211
|
.default({})
|
|
200
212
|
.describe("Extra sources per directive, e.g. { \"connect-src\": [\"https://api.example.com\"] }."),
|
|
213
|
+
outboundLinks: z
|
|
214
|
+
.enum(["require", "lock", "off"])
|
|
215
|
+
.default("lock")
|
|
216
|
+
.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.'),
|
|
217
|
+
spamPatterns: z.boolean().default(true).describe("Warn in `vage check` about common spam words (casino, pills, payday loans...) in page text and links."),
|
|
201
218
|
})
|
|
202
|
-
.default({ headers: true, csp: "enforce", cspSources: {} }),
|
|
219
|
+
.default({ headers: true, csp: "enforce", cspSources: {}, outboundLinks: "lock", spamPatterns: true }),
|
|
203
220
|
guidelines: z
|
|
204
221
|
.strictObject({
|
|
205
222
|
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,40 @@
|
|
|
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
|
+
description?: string;
|
|
11
|
+
/** Source path relative to the project root, e.g. "src/pages/blog/first-post.md". */
|
|
12
|
+
source: string;
|
|
13
|
+
frontmatter: Frontmatter;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Every published page (drafts and 404.md excluded), sorted by route. Reads frontmatter
|
|
17
|
+
* and applies each page's layout schema, so list entries get the same defaults as the
|
|
18
|
+
* page itself. The result is frozen: layouts share it, so none may change it.
|
|
19
|
+
* `skipInvalid` (dev server, `vage check`) leaves out pages whose frontmatter is invalid;
|
|
20
|
+
* they report their own error instead of breaking every page that lists others.
|
|
21
|
+
*/
|
|
22
|
+
export declare function collectPages(root: string, site: SiteConfig, load: ModuleLoader, options?: {
|
|
23
|
+
skipInvalid?: boolean;
|
|
24
|
+
}): Promise<readonly PageSummary[]>;
|
|
25
|
+
export interface CollectionOptions {
|
|
26
|
+
/** Only pages below this route, e.g. "/blog/". The section's own index page is left out unless `includeIndex`. */
|
|
27
|
+
under?: string;
|
|
28
|
+
includeIndex?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* "date": newest `datePublished` first (undated pages last, then by title).
|
|
31
|
+
* "title": A to Z. "route" (default): by URL. Or a compare function.
|
|
32
|
+
*/
|
|
33
|
+
sort?: "date" | "title" | "route" | ((a: PageSummary, b: PageSummary) => number);
|
|
34
|
+
limit?: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Picks and orders pages for lists such as a blog index, a sidebar or "latest posts":
|
|
38
|
+
* collection(pages, { under: "/blog/", sort: "date", limit: 5 })
|
|
39
|
+
*/
|
|
40
|
+
export declare function collection(pages: readonly PageSummary[], options?: CollectionOptions): PageSummary[];
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseFrontmatter } from "../content/markdown.js";
|
|
4
|
+
import { pageMeta } from "../seo/head.js";
|
|
5
|
+
import { clonePlain } from "./clone.js";
|
|
6
|
+
import { applyLayoutFields } from "./render.js";
|
|
7
|
+
import { VageError } from "./errors.js";
|
|
8
|
+
import { discoverRoutes } from "./routes.js";
|
|
9
|
+
/**
|
|
10
|
+
* Every published page (drafts and 404.md excluded), sorted by route. Reads frontmatter
|
|
11
|
+
* and applies each page's layout schema, so list entries get the same defaults as the
|
|
12
|
+
* page itself. The result is frozen: layouts share it, so none may change it.
|
|
13
|
+
* `skipInvalid` (dev server, `vage check`) leaves out pages whose frontmatter is invalid;
|
|
14
|
+
* they report their own error instead of breaking every page that lists others.
|
|
15
|
+
*/
|
|
16
|
+
export async function collectPages(root, site, load, options = {}) {
|
|
17
|
+
const pagesDir = path.join(root, "src", "pages");
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const entry of discoverRoutes(pagesDir)) {
|
|
20
|
+
let frontmatter;
|
|
21
|
+
try {
|
|
22
|
+
({ frontmatter } = parseFrontmatter(fs.readFileSync(entry.file, "utf8"), entry.file));
|
|
23
|
+
if (frontmatter.draft)
|
|
24
|
+
continue;
|
|
25
|
+
await applyLayoutFields(root, frontmatter, load, entry.file);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
// Any failure (bad frontmatter, a layout that throws or doesn't compile) is that page's own error.
|
|
29
|
+
if (options.skipInvalid)
|
|
30
|
+
continue;
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
out.push({
|
|
34
|
+
route: entry.route,
|
|
35
|
+
url: pageMeta(site, entry.route, frontmatter).canonical,
|
|
36
|
+
title: frontmatter.title,
|
|
37
|
+
description: frontmatter.description,
|
|
38
|
+
source: path.relative(root, entry.file),
|
|
39
|
+
frontmatter,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
// A frozen deep copy: shared by every layout, and never the objects a page renders with.
|
|
43
|
+
return deepFreeze(clonePlain(out));
|
|
44
|
+
}
|
|
45
|
+
/** Freezes plain data only; URL/Date/class instances from schema transforms stay usable. */
|
|
46
|
+
function deepFreeze(value) {
|
|
47
|
+
const plain = Array.isArray(value) || (value && typeof value === "object" && [Object.prototype, null].includes(Object.getPrototypeOf(value)));
|
|
48
|
+
if (plain && !Object.isFrozen(value)) {
|
|
49
|
+
Object.freeze(value);
|
|
50
|
+
for (const v of Object.values(value))
|
|
51
|
+
deepFreeze(v);
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
const SORTS = ["date", "title", "route"];
|
|
56
|
+
/**
|
|
57
|
+
* Picks and orders pages for lists such as a blog index, a sidebar or "latest posts":
|
|
58
|
+
* collection(pages, { under: "/blog/", sort: "date", limit: 5 })
|
|
59
|
+
*/
|
|
60
|
+
export function collection(pages, options = {}) {
|
|
61
|
+
if (!Array.isArray(pages))
|
|
62
|
+
throw new VageError("collection(): pass the layout's `pages` as the first argument.");
|
|
63
|
+
const { under, includeIndex = false, sort = "route", limit } = options;
|
|
64
|
+
if (under !== undefined && !/^\/(?!\/)/.test(under)) {
|
|
65
|
+
throw new VageError(`collection(): "under" must be a site path starting with "/", like "/blog/" (got ${JSON.stringify(under)}).`);
|
|
66
|
+
}
|
|
67
|
+
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)}).`);
|
|
69
|
+
}
|
|
70
|
+
if (typeof includeIndex !== "boolean")
|
|
71
|
+
throw new VageError(`collection(): "includeIndex" must be true or false (got ${JSON.stringify(includeIndex)}).`);
|
|
72
|
+
if (limit !== undefined && !(Number.isInteger(limit) && limit >= 0)) {
|
|
73
|
+
throw new VageError(`collection(): "limit" must be a whole number of 0 or more (got ${JSON.stringify(limit)}).`);
|
|
74
|
+
}
|
|
75
|
+
const prefix = under ? (under.endsWith("/") ? under : `${under}/`) : undefined;
|
|
76
|
+
const picked = pages.filter((p) => !prefix || (p.route.startsWith(prefix) && (includeIndex || p.route !== prefix)));
|
|
77
|
+
const byTitle = (a, b) => a.title.localeCompare(b.title);
|
|
78
|
+
const compare = typeof sort === "function"
|
|
79
|
+
? sort
|
|
80
|
+
: sort === "title"
|
|
81
|
+
? byTitle
|
|
82
|
+
: sort === "date"
|
|
83
|
+
? (a, b) => {
|
|
84
|
+
const da = a.frontmatter.datePublished ?? "";
|
|
85
|
+
const db = b.frontmatter.datePublished ?? "";
|
|
86
|
+
if (da !== db)
|
|
87
|
+
return da && db ? db.localeCompare(da) : da ? -1 : 1;
|
|
88
|
+
return byTitle(a, b);
|
|
89
|
+
}
|
|
90
|
+
: (a, b) => a.route.localeCompare(b.route);
|
|
91
|
+
const sorted = [...picked].sort(compare);
|
|
92
|
+
return limit === undefined ? sorted : sorted.slice(0, limit);
|
|
93
|
+
}
|
package/dist/core/render.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type Frontmatter } from "../content/markdown.js";
|
|
2
2
|
import type { ModuleLoader, SiteConfig } from "./config.js";
|
|
3
|
+
import { type SafeHtml } from "./html.js";
|
|
3
4
|
import { type PageMeta } from "../seo/head.js";
|
|
5
|
+
import type { PageSummary } from "./pages.js";
|
|
4
6
|
export interface LayoutProps {
|
|
5
7
|
/** Rendered page body HTML. Insert as-is; do not escape. */
|
|
6
8
|
content: string;
|
|
@@ -13,8 +15,14 @@ export interface LayoutProps {
|
|
|
13
15
|
frontmatter: Frontmatter;
|
|
14
16
|
};
|
|
15
17
|
site: SiteConfig;
|
|
18
|
+
/**
|
|
19
|
+
* Every published page (drafts excluded), sorted by route. Use `collection()` to
|
|
20
|
+
* pick a section and order it, e.g. a blog index or a sidebar.
|
|
21
|
+
*/
|
|
22
|
+
pages: readonly PageSummary[];
|
|
16
23
|
}
|
|
17
|
-
|
|
24
|
+
/** Returns the page's HTML: a string, or `html\`...\`` (escapes values automatically). */
|
|
25
|
+
export type Layout = (props: LayoutProps) => string | SafeHtml | Promise<string | SafeHtml>;
|
|
18
26
|
export interface RenderedPage {
|
|
19
27
|
html: string;
|
|
20
28
|
frontmatter: Frontmatter;
|
|
@@ -32,6 +40,14 @@ export declare function renderPage(options: {
|
|
|
32
40
|
notFound?: boolean;
|
|
33
41
|
/** Add configured analytics tags. Off in the dev server so development traffic is never measured. */
|
|
34
42
|
analytics?: boolean;
|
|
43
|
+
/** Published pages for `props.pages`. */
|
|
44
|
+
pages?: readonly PageSummary[];
|
|
35
45
|
}): Promise<RenderedPage>;
|
|
46
|
+
/**
|
|
47
|
+
* Loads the page's layout and, if it exports a `frontmatter` schema, validates the page
|
|
48
|
+
* against it and fills in defaults (mutating `frontmatter`). Used for rendering and for
|
|
49
|
+
* `props.pages`, so both see the same values. Returns the layout function.
|
|
50
|
+
*/
|
|
51
|
+
export declare function applyLayoutFields(root: string, frontmatter: Frontmatter, load: ModuleLoader, file: string): Promise<Layout>;
|
|
36
52
|
/** Used when a project has no src/layouts/default.ts. */
|
|
37
53
|
export declare const defaultLayout: Layout;
|