vagejs 0.3.5 → 0.5.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 +32 -6
- package/dist/audit/budgets.js +21 -1
- package/dist/checks/check.js +63 -5
- package/dist/cli/index.js +77 -5
- package/dist/cli/info.d.ts +1 -0
- package/dist/cli/info.js +7 -6
- package/dist/cli/new.d.ts +27 -0
- package/dist/cli/new.js +97 -0
- package/dist/content/markdown.d.ts +1 -0
- package/dist/content/markdown.js +4 -0
- package/dist/content/optimize.d.ts +8 -4
- package/dist/content/optimize.js +35 -22
- package/dist/content/sanitize.d.ts +12 -9
- package/dist/content/sanitize.js +86 -10
- package/dist/core/build.d.ts +5 -0
- package/dist/core/build.js +1 -0
- package/dist/core/config.d.ts +8 -0
- package/dist/core/config.js +24 -4
- package/dist/core/dates.d.ts +6 -0
- package/dist/core/dates.js +14 -0
- package/dist/core/pages.d.ts +15 -1
- package/dist/core/pages.js +27 -5
- package/dist/core/render.d.ts +2 -0
- package/dist/core/render.js +4 -4
- package/dist/core/site.d.ts +9 -0
- package/dist/core/site.js +49 -7
- package/dist/deploy/netlify.d.ts +22 -0
- package/dist/deploy/netlify.js +48 -0
- package/dist/deploy/redirects.d.ts +5 -0
- package/dist/deploy/redirects.js +8 -0
- package/dist/dev/guard.js +1 -1
- package/dist/dev/server.js +15 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/mcp/server.d.ts +27 -0
- package/dist/mcp/server.js +81 -0
- package/package.json +2 -1
- package/templates/starter/AGENTS.md +26 -10
- package/templates/starter/package.json +1 -1
package/dist/content/optimize.js
CHANGED
|
@@ -23,7 +23,11 @@ export async function sharpResize(root) {
|
|
|
23
23
|
}
|
|
24
24
|
const sharp = (await import(pathToFileURL(resolved).href)).default;
|
|
25
25
|
// rotate() applies EXIF orientation first, so phone photos aren't served sideways.
|
|
26
|
-
|
|
26
|
+
// AVIF uses sharp's own default quality: its scale differs from WebP's, so images.quality would overshoot.
|
|
27
|
+
return (input, width, quality, format = "webp") => {
|
|
28
|
+
const resized = sharp(input).rotate().resize({ width, withoutEnlargement: true });
|
|
29
|
+
return (format === "avif" ? resized.avif() : resized.webp({ quality })).toBuffer();
|
|
30
|
+
};
|
|
27
31
|
}
|
|
28
32
|
/**
|
|
29
33
|
* WebP variants for one site-path image in public/. Undefined for remote images,
|
|
@@ -61,30 +65,39 @@ export async function imageVariants(src, root, options, resize) {
|
|
|
61
65
|
const dir = cacheDir(root);
|
|
62
66
|
fs.mkdirSync(dir, { recursive: true });
|
|
63
67
|
const files = new Map();
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
const srcsets = {};
|
|
69
|
+
// Real widths, read from the WebP files; AVIF copies are resized the same way (vagejs can't read AVIF sizes).
|
|
70
|
+
const realWidth = new Map();
|
|
71
|
+
for (const format of options.avif ? ["webp", "avif"] : ["webp"]) {
|
|
72
|
+
const entries = [];
|
|
73
|
+
for (const w of widths) {
|
|
74
|
+
const name = `${base}-${hash}-${w}.${format}`;
|
|
75
|
+
const cached = path.join(dir, name);
|
|
76
|
+
if (!fs.existsSync(cached)) {
|
|
77
|
+
const tmp = `${cached}.${process.pid}.tmp`;
|
|
78
|
+
let data;
|
|
79
|
+
try {
|
|
80
|
+
data = await resize(input, w, options.quality, format);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
throw new VageError(`Couldn't make a ${format === "avif" ? "AVIF" : "WebP"} copy of ${src}: ${err.message}. Replace the file, or turn off ${format === "avif" ? "images.avif" : "images.optimize"}.`);
|
|
84
|
+
}
|
|
85
|
+
fs.writeFileSync(tmp, data);
|
|
86
|
+
fs.renameSync(tmp, cached);
|
|
73
87
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
88
|
+
// Label with the file's real width (rotation can change it); publish each width once.
|
|
89
|
+
const actual = format === "webp" ? (imageSize(cached)?.width ?? w) : (realWidth.get(w) ?? w);
|
|
90
|
+
if (format === "webp")
|
|
91
|
+
realWidth.set(w, actual);
|
|
92
|
+
if (entries.some((e) => e.endsWith(` ${actual}w`)))
|
|
93
|
+
continue;
|
|
94
|
+
files.set(`${IMAGE_DIR}/${name}`, cached);
|
|
95
|
+
entries.push(`/${IMAGE_DIR}/${name} ${actual}w`);
|
|
79
96
|
}
|
|
80
|
-
|
|
81
|
-
|
|
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`);
|
|
97
|
+
if (entries.length)
|
|
98
|
+
srcsets[format] = entries.join(", ");
|
|
86
99
|
}
|
|
87
|
-
return
|
|
100
|
+
return srcsets.webp ? { srcset: srcsets.webp, avifSrcset: srcsets.avif, files } : undefined;
|
|
88
101
|
}
|
|
89
102
|
/** Image sources used in rendered Markdown, read with an HTML parser (so entities decode like the DOM's). */
|
|
90
103
|
export function contentImageSources(html) {
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/** Optimized copies for one image source: WebP srcset and sizes, plus AVIF when images.avif is on. */
|
|
2
|
+
export interface ImageVariant {
|
|
3
|
+
srcset: string;
|
|
4
|
+
sizes: string;
|
|
5
|
+
avifSrcset?: string;
|
|
6
|
+
}
|
|
1
7
|
/**
|
|
2
8
|
* Sanitizes rendered Markdown HTML. Page content may be written by AI agents,
|
|
3
9
|
* contributors or (later) CMS sources, so scripts, event handlers, javascript:
|
|
@@ -11,13 +17,10 @@
|
|
|
11
17
|
export declare function sanitizeHtml(html: string, options?: {
|
|
12
18
|
publicDir?: string;
|
|
13
19
|
inlineStyles?: boolean;
|
|
14
|
-
variants?: Map<string,
|
|
15
|
-
|
|
16
|
-
sizes: string;
|
|
17
|
-
}>;
|
|
20
|
+
variants?: Map<string, ImageVariant>;
|
|
21
|
+
headingIds?: boolean;
|
|
18
22
|
}): 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
|
-
|
|
22
|
-
|
|
23
|
-
}>): Promise<string>;
|
|
23
|
+
/** For trusted content (`markdown.html: "allow"`): the same image hints and heading ids, applied through the DOM. */
|
|
24
|
+
export declare function enhanceTrustedHtml(html: string, publicDir: string, variants?: Map<string, ImageVariant>, headingIds?: boolean): Promise<string>;
|
|
25
|
+
/** "Quick start: 2 steps!" -> "quick-start-2-steps". Letters in any script are kept; accents are dropped. */
|
|
26
|
+
export declare function headingSlug(text: string): string;
|
package/dist/content/sanitize.js
CHANGED
|
@@ -29,29 +29,105 @@ export async function sanitizeHtml(html, options = {}) {
|
|
|
29
29
|
first = false;
|
|
30
30
|
}
|
|
31
31
|
};
|
|
32
|
+
// A heading's own id is kept even when DOMPurify's clobbering filter would drop it ("title",
|
|
33
|
+
// "images"), so it behaves like the ids vagejs adds and links to it keep working.
|
|
34
|
+
const keepHeadingId = (node, data) => {
|
|
35
|
+
if (data.attrName === "id" && /^H[23]$/.test(node.nodeName))
|
|
36
|
+
data.forceKeepAttr = true;
|
|
37
|
+
};
|
|
32
38
|
if (publicDir) {
|
|
33
39
|
purifier.addHook("afterSanitizeAttributes", hook);
|
|
34
40
|
}
|
|
41
|
+
if (options.headingIds)
|
|
42
|
+
purifier.addHook("uponSanitizeAttribute", keepHeadingId);
|
|
43
|
+
const config = {
|
|
44
|
+
// font/marquee/center: legacy ways to hide or disguise text (white-on-white, 1px scrollers).
|
|
45
|
+
FORBID_TAGS: ["style", "form", "input", "button", "textarea", "select", "option", "base", "link", "meta", "object", "embed", "font", "marquee", "center"],
|
|
46
|
+
ALLOW_DATA_ATTR: true,
|
|
47
|
+
// Inline styles are the usual way to hide injected links (display:none, off-screen); layouts style pages.
|
|
48
|
+
// xlink:href: SVG links, which would slip past link checks.
|
|
49
|
+
FORBID_ATTR: options.inlineStyles ? ["xlink:href"] : ["style", "xlink:href"],
|
|
50
|
+
};
|
|
35
51
|
try {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
52
|
+
const avif = [...(options.variants?.values() ?? [])].some((v) => v.avifSrcset);
|
|
53
|
+
if (!avif && !options.headingIds)
|
|
54
|
+
return purifier.sanitize(html, config);
|
|
55
|
+
// Heading ids and AVIF <picture> wrappers are added on DOMPurify's own output tree, then serialized once.
|
|
56
|
+
// (Editing sanitized HTML as a string and parsing it again could change its meaning.)
|
|
57
|
+
const fragment = purifier.sanitize(html, { ...config, RETURN_DOM_FRAGMENT: true });
|
|
58
|
+
if (options.headingIds)
|
|
59
|
+
addHeadingIds(fragment);
|
|
60
|
+
if (avif)
|
|
61
|
+
addAvifSources(fragment, options.variants);
|
|
62
|
+
const container = fragment.ownerDocument.createElement("div");
|
|
63
|
+
container.appendChild(fragment);
|
|
64
|
+
return container.innerHTML;
|
|
44
65
|
}
|
|
45
66
|
finally {
|
|
46
67
|
if (publicDir)
|
|
47
68
|
purifier.removeHook("afterSanitizeAttributes", hook);
|
|
69
|
+
if (options.headingIds)
|
|
70
|
+
purifier.removeHook("uponSanitizeAttribute", keepHeadingId);
|
|
48
71
|
}
|
|
49
72
|
}
|
|
50
|
-
/** For trusted content (`markdown.html: "allow"`): the same image hints, applied through the DOM. */
|
|
51
|
-
export async function enhanceTrustedHtml(html, publicDir, variants) {
|
|
73
|
+
/** For trusted content (`markdown.html: "allow"`): the same image hints and heading ids, applied through the DOM. */
|
|
74
|
+
export async function enhanceTrustedHtml(html, publicDir, variants, headingIds = false) {
|
|
52
75
|
const { jsdom } = await load();
|
|
53
76
|
const template = new jsdom.JSDOM("").window.document.createElement("template");
|
|
54
77
|
template.innerHTML = html;
|
|
55
78
|
template.content.querySelectorAll("img").forEach((img, i) => enhanceImage(img, publicDir, i === 0, variants?.get(img.getAttribute("src") ?? "")));
|
|
79
|
+
if (headingIds)
|
|
80
|
+
addHeadingIds(template.content);
|
|
81
|
+
if (variants)
|
|
82
|
+
addAvifSources(template.content, variants);
|
|
56
83
|
return template.innerHTML;
|
|
57
84
|
}
|
|
85
|
+
/** "Quick start: 2 steps!" -> "quick-start-2-steps". Letters in any script are kept; accents are dropped. */
|
|
86
|
+
export function headingSlug(text) {
|
|
87
|
+
return text
|
|
88
|
+
.normalize("NFKD")
|
|
89
|
+
// Only Latin accents are dropped; marks are part of other scripts (Hindi vowel signs, kana voicing).
|
|
90
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
91
|
+
.normalize("NFC")
|
|
92
|
+
.toLowerCase()
|
|
93
|
+
.replace(/[^\p{L}\p{M}\p{N}]+/gu, "-")
|
|
94
|
+
.replace(/^-+|-+$/g, "");
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Gives each h2 and h3 without an id one made from its text, so sections can be linked
|
|
98
|
+
* (/docs/setup/#quick-start). Ids already on the page are kept and never reused; repeats
|
|
99
|
+
* get -2, -3.
|
|
100
|
+
*/
|
|
101
|
+
function addHeadingIds(root) {
|
|
102
|
+
const used = new Set([...root.querySelectorAll("[id]")].map((el) => el.id));
|
|
103
|
+
for (const heading of root.querySelectorAll("h2, h3")) {
|
|
104
|
+
if (heading.id)
|
|
105
|
+
continue;
|
|
106
|
+
const base = headingSlug(heading.textContent ?? "") || "section";
|
|
107
|
+
let id = base;
|
|
108
|
+
for (let n = 2; used.has(id); n++)
|
|
109
|
+
id = `${base}-${n}`;
|
|
110
|
+
used.add(id);
|
|
111
|
+
heading.id = id;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Offers AVIF first: <picture><source type="image/avif" srcset sizes><img …></picture>.
|
|
116
|
+
* Browsers without AVIF use the <img> and its WebP srcset. Only images vagejs gave a
|
|
117
|
+
* srcset are wrapped (an author's own srcset or <picture> is left alone).
|
|
118
|
+
*/
|
|
119
|
+
function addAvifSources(root, variants) {
|
|
120
|
+
for (const img of [...root.querySelectorAll("img")]) {
|
|
121
|
+
const v = variants.get(img.getAttribute("src") ?? "");
|
|
122
|
+
if (!v?.avifSrcset || img.getAttribute("srcset") !== v.srcset || img.parentElement?.nodeName === "PICTURE")
|
|
123
|
+
continue;
|
|
124
|
+
const doc = img.ownerDocument;
|
|
125
|
+
const picture = doc.createElement("picture");
|
|
126
|
+
const source = doc.createElement("source");
|
|
127
|
+
source.setAttribute("type", "image/avif");
|
|
128
|
+
source.setAttribute("srcset", v.avifSrcset);
|
|
129
|
+
source.setAttribute("sizes", img.getAttribute("sizes") ?? v.sizes);
|
|
130
|
+
img.replaceWith(picture);
|
|
131
|
+
picture.append(source, img);
|
|
132
|
+
}
|
|
133
|
+
}
|
package/dist/core/build.d.ts
CHANGED
|
@@ -14,6 +14,11 @@ export interface BuildResult {
|
|
|
14
14
|
}[];
|
|
15
15
|
files: string[];
|
|
16
16
|
skippedDrafts: string[];
|
|
17
|
+
/** Pages with a later datePublished, and the date they go live (on a build from that day). */
|
|
18
|
+
skippedScheduled: {
|
|
19
|
+
source: string;
|
|
20
|
+
date: string;
|
|
21
|
+
}[];
|
|
17
22
|
warnings: string[];
|
|
18
23
|
durationMs: number;
|
|
19
24
|
}
|
package/dist/core/build.js
CHANGED
|
@@ -54,6 +54,7 @@ export async function buildSite(root) {
|
|
|
54
54
|
redirects: model.redirects.map((r) => ({ from: r.from, to: r.to, output: r.outFile })),
|
|
55
55
|
files: [...model.files.keys()],
|
|
56
56
|
skippedDrafts: model.skippedDrafts,
|
|
57
|
+
skippedScheduled: model.skippedScheduled.map(({ source, date }) => ({ source, date })),
|
|
57
58
|
warnings: model.warnings,
|
|
58
59
|
durationMs: Math.round(performance.now() - start),
|
|
59
60
|
};
|
package/dist/core/config.d.ts
CHANGED
|
@@ -9,14 +9,20 @@ export declare const configSchema: z.ZodObject<{
|
|
|
9
9
|
optimize: z.ZodDefault<z.ZodBoolean>;
|
|
10
10
|
widths: z.ZodDefault<z.ZodArray<z.ZodNumber>>;
|
|
11
11
|
quality: z.ZodDefault<z.ZodNumber>;
|
|
12
|
+
avif: z.ZodDefault<z.ZodBoolean>;
|
|
12
13
|
sizes: z.ZodDefault<z.ZodString>;
|
|
13
14
|
}, z.core.$strict>>;
|
|
15
|
+
tags: z.ZodDefault<z.ZodObject<{
|
|
16
|
+
pages: z.ZodDefault<z.ZodBoolean>;
|
|
17
|
+
layout: z.ZodOptional<z.ZodString>;
|
|
18
|
+
}, z.core.$strict>>;
|
|
14
19
|
markdown: z.ZodDefault<z.ZodObject<{
|
|
15
20
|
html: z.ZodDefault<z.ZodEnum<{
|
|
16
21
|
allow: "allow";
|
|
17
22
|
sanitize: "sanitize";
|
|
18
23
|
}>>;
|
|
19
24
|
inlineStyles: z.ZodDefault<z.ZodBoolean>;
|
|
25
|
+
headingIds: z.ZodDefault<z.ZodBoolean>;
|
|
20
26
|
}, z.core.$strict>>;
|
|
21
27
|
seo: z.ZodDefault<z.ZodObject<{
|
|
22
28
|
robots: z.ZodPipe<z.ZodDefault<z.ZodUnion<readonly [z.ZodBoolean, z.ZodObject<{
|
|
@@ -115,9 +121,11 @@ export declare const configSchema: z.ZodObject<{
|
|
|
115
121
|
"cloudflare-pages": "cloudflare-pages";
|
|
116
122
|
"cloudflare-workers": "cloudflare-workers";
|
|
117
123
|
"github-pages": "github-pages";
|
|
124
|
+
netlify: "netlify";
|
|
118
125
|
}>>;
|
|
119
126
|
cloudflareProject: z.ZodOptional<z.ZodString>;
|
|
120
127
|
cloudflareWorker: z.ZodOptional<z.ZodString>;
|
|
128
|
+
netlifySite: z.ZodOptional<z.ZodString>;
|
|
121
129
|
}, z.core.$strict>>;
|
|
122
130
|
security: z.ZodDefault<z.ZodObject<{
|
|
123
131
|
headers: z.ZodDefault<z.ZodBoolean>;
|
package/dist/core/config.js
CHANGED
|
@@ -190,9 +190,20 @@ export const configSchema = z.strictObject({
|
|
|
190
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
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
192
|
quality: z.number().int().min(1).max(100).default(75).describe("WebP quality."),
|
|
193
|
+
avif: z.boolean().default(false).describe("Also make AVIF copies (usually smaller than WebP, slower to make) and offer both with <picture>: browsers that support AVIF use it, others use WebP."),
|
|
193
194
|
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
|
})
|
|
195
|
-
.default({ optimize: false, widths: [480, 960, 1600], quality: 75, sizes: "(max-width: 48rem) 100vw, 48rem" }),
|
|
196
|
+
.default({ optimize: false, widths: [480, 960, 1600], quality: 75, avif: false, sizes: "(max-width: 48rem) 100vw, 48rem" }),
|
|
197
|
+
tags: z
|
|
198
|
+
.strictObject({
|
|
199
|
+
pages: z.boolean().default(true).describe("Generate a page at /tags/<tag>/ for every tag used in page frontmatter, listing its pages, newest first."),
|
|
200
|
+
layout: z
|
|
201
|
+
.string()
|
|
202
|
+
.regex(/^[\w-]+$/, "layout must be a file name without extension")
|
|
203
|
+
.optional()
|
|
204
|
+
.describe("Layout for tag pages (src/layouts/<layout>.ts). Defaults to the default layout."),
|
|
205
|
+
})
|
|
206
|
+
.default({ pages: true }),
|
|
196
207
|
markdown: z
|
|
197
208
|
.strictObject({
|
|
198
209
|
html: z
|
|
@@ -203,22 +214,31 @@ export const configSchema = z.strictObject({
|
|
|
203
214
|
.boolean()
|
|
204
215
|
.default(false)
|
|
205
216
|
.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.'),
|
|
217
|
+
headingIds: z
|
|
218
|
+
.boolean()
|
|
219
|
+
.default(true)
|
|
220
|
+
.describe('Give each ## and ### heading in page content an id made from its text ("Quick start" -> id="quick-start"), so sections can be linked. Ids you write yourself are kept.'),
|
|
206
221
|
})
|
|
207
|
-
.default({ html: "sanitize", inlineStyles: false }),
|
|
222
|
+
.default({ html: "sanitize", inlineStyles: false, headingIds: true }),
|
|
208
223
|
seo: seoSchema.default(() => seoSchema.parse({})),
|
|
209
224
|
performance: performanceSchema.default(() => performanceSchema.parse({})),
|
|
210
225
|
deploy: z
|
|
211
226
|
.strictObject({
|
|
212
227
|
target: z
|
|
213
|
-
.enum(["github-pages", "cloudflare-pages", "cloudflare-workers"])
|
|
228
|
+
.enum(["github-pages", "cloudflare-pages", "cloudflare-workers", "netlify"])
|
|
214
229
|
.default("github-pages")
|
|
215
|
-
.describe("Host used by `vage deploy`. Cloudflare now creates new sites as Workers: prefer cloudflare-workers there."),
|
|
230
|
+
.describe("Host used by `vage deploy`. Cloudflare now creates new sites as Workers: prefer cloudflare-workers there. netlify also makes the build write Netlify's _redirects and _headers."),
|
|
216
231
|
cloudflareProject: z.string().regex(/^[a-z0-9][a-z0-9-]{0,57}$/).optional().describe("Cloudflare Pages project name."),
|
|
217
232
|
cloudflareWorker: z
|
|
218
233
|
.string()
|
|
219
234
|
.regex(/^[a-z0-9][a-z0-9-]{0,62}$/)
|
|
220
235
|
.optional()
|
|
221
236
|
.describe("Cloudflare Worker name (cloudflare-workers target, when the project has no wrangler config). Defaults to cloudflareProject."),
|
|
237
|
+
netlifySite: z
|
|
238
|
+
.string()
|
|
239
|
+
.regex(/^[a-z0-9][a-z0-9-]{0,62}$/)
|
|
240
|
+
.optional()
|
|
241
|
+
.describe("Netlify site name or ID (netlify target). Create the site in Netlify first."),
|
|
222
242
|
})
|
|
223
243
|
.default({ target: "github-pages" }),
|
|
224
244
|
security: z
|
package/dist/core/dates.d.ts
CHANGED
|
@@ -1,2 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The publish date of a page that isn't due yet, else undefined. A date ("2026-11-01")
|
|
3
|
+
* is due from that day on the build machine's calendar (UTC on most CI); a datetime
|
|
4
|
+
* with an offset ("2026-11-01T09:00:00+05:30") from that moment.
|
|
5
|
+
*/
|
|
6
|
+
export declare function scheduledFor(datePublished: string | undefined, now?: Date): string | undefined;
|
|
1
7
|
/** A frontmatter date as a point in time ("2026-10-24" = midnight UTC); NaN when missing. */
|
|
2
8
|
export declare function instant(date: string | undefined): number;
|
package/dist/core/dates.js
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The publish date of a page that isn't due yet, else undefined. A date ("2026-11-01")
|
|
3
|
+
* is due from that day on the build machine's calendar (UTC on most CI); a datetime
|
|
4
|
+
* with an offset ("2026-11-01T09:00:00+05:30") from that moment.
|
|
5
|
+
*/
|
|
6
|
+
export function scheduledFor(datePublished, now = new Date()) {
|
|
7
|
+
if (!datePublished)
|
|
8
|
+
return undefined;
|
|
9
|
+
if (datePublished.length === 10) {
|
|
10
|
+
const today = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
|
11
|
+
return datePublished > today ? datePublished : undefined;
|
|
12
|
+
}
|
|
13
|
+
return Date.parse(datePublished) > now.getTime() ? datePublished : undefined;
|
|
14
|
+
}
|
|
1
15
|
/** A frontmatter date as a point in time ("2026-10-24" = midnight UTC); NaN when missing. */
|
|
2
16
|
export function instant(date) {
|
|
3
17
|
return date ? Date.parse(date.length === 10 ? `${date}T00:00:00Z` : date) : NaN;
|
package/dist/core/pages.d.ts
CHANGED
|
@@ -15,7 +15,7 @@ export interface PageSummary {
|
|
|
15
15
|
frontmatter: Frontmatter;
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
18
|
-
* Every published page (drafts and 404.md excluded), sorted by route. Reads frontmatter
|
|
18
|
+
* Every published page (drafts, pages scheduled for a later date and 404.md excluded), sorted by route. Reads frontmatter
|
|
19
19
|
* and applies each page's layout schema, so list entries get the same defaults as the
|
|
20
20
|
* page itself. The result is frozen: layouts share it, so none may change it.
|
|
21
21
|
* `skipInvalid` (dev server, `vage check`) leaves out pages whose frontmatter is invalid;
|
|
@@ -35,6 +35,8 @@ export interface CollectionOptions {
|
|
|
35
35
|
*/
|
|
36
36
|
sort?: "date" | "title" | "route" | "order" | ((a: PageSummary, b: PageSummary) => number);
|
|
37
37
|
limit?: number;
|
|
38
|
+
/** Only pages with this tag (compared like tag page addresses: "SEO" matches "seo"). */
|
|
39
|
+
tag?: string;
|
|
38
40
|
}
|
|
39
41
|
/**
|
|
40
42
|
* Picks and orders pages for lists such as a blog index, a sidebar or "latest posts":
|
|
@@ -43,6 +45,18 @@ export interface CollectionOptions {
|
|
|
43
45
|
/** By `order` (lowest first; pages without one last), then title. */
|
|
44
46
|
export declare function byOrder(a: PageSummary, b: PageSummary): number;
|
|
45
47
|
export declare function collection(pages: readonly PageSummary[], options?: CollectionOptions): PageSummary[];
|
|
48
|
+
/** A tag's address part: "Web Design" -> "web-design" (tag pages live at /tags/<slug>/). */
|
|
49
|
+
export declare function tagSlug(tag: string): string;
|
|
50
|
+
export interface TagSummary {
|
|
51
|
+
/** The tag as first written in the pages, e.g. "SEO". */
|
|
52
|
+
name: string;
|
|
53
|
+
slug: string;
|
|
54
|
+
/** "/tags/<slug>/" (generated when tags.pages is on). */
|
|
55
|
+
route: string;
|
|
56
|
+
count: number;
|
|
57
|
+
}
|
|
58
|
+
/** Every tag the pages use, A to Z, with how many pages have it: for tag clouds and tag menus. */
|
|
59
|
+
export declare function tagList(pages: readonly PageSummary[]): TagSummary[];
|
|
46
60
|
/** The pages before and after `route` in an ordered list (e.g. from collection()), for "Previous / Next" links. */
|
|
47
61
|
export declare function neighbors(list: readonly PageSummary[], route: string): {
|
|
48
62
|
prev?: PageSummary;
|
package/dist/core/pages.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parseFrontmatter } from "../content/markdown.js";
|
|
4
|
+
import { headingSlug } from "../content/sanitize.js";
|
|
4
5
|
import { pageMeta } from "../seo/head.js";
|
|
5
6
|
import { clonePlain } from "./clone.js";
|
|
6
|
-
import { instant } from "./dates.js";
|
|
7
|
+
import { instant, scheduledFor } from "./dates.js";
|
|
7
8
|
import { applyLayoutFields } from "./render.js";
|
|
8
9
|
import { VageError } from "./errors.js";
|
|
9
10
|
import { discoverRoutes } from "./routes.js";
|
|
10
11
|
/**
|
|
11
|
-
* Every published page (drafts and 404.md excluded), sorted by route. Reads frontmatter
|
|
12
|
+
* Every published page (drafts, pages scheduled for a later date and 404.md excluded), sorted by route. Reads frontmatter
|
|
12
13
|
* and applies each page's layout schema, so list entries get the same defaults as the
|
|
13
14
|
* page itself. The result is frozen: layouts share it, so none may change it.
|
|
14
15
|
* `skipInvalid` (dev server, `vage check`) leaves out pages whose frontmatter is invalid;
|
|
@@ -21,7 +22,7 @@ export async function collectPages(root, site, load, options = {}) {
|
|
|
21
22
|
let frontmatter;
|
|
22
23
|
try {
|
|
23
24
|
({ frontmatter } = parseFrontmatter(fs.readFileSync(entry.file, "utf8"), entry.file));
|
|
24
|
-
if (frontmatter.draft)
|
|
25
|
+
if (frontmatter.draft || scheduledFor(frontmatter.datePublished))
|
|
25
26
|
continue;
|
|
26
27
|
await applyLayoutFields(root, frontmatter, load, entry.file);
|
|
27
28
|
}
|
|
@@ -70,7 +71,9 @@ export function byOrder(a, b) {
|
|
|
70
71
|
export function collection(pages, options = {}) {
|
|
71
72
|
if (!Array.isArray(pages))
|
|
72
73
|
throw new VageError("collection(): pass the layout's `pages` as the first argument.");
|
|
73
|
-
const { under, includeIndex = false, sort = "route", limit } = options;
|
|
74
|
+
const { under, includeIndex = false, sort = "route", limit, tag } = options;
|
|
75
|
+
if (tag !== undefined && (typeof tag !== "string" || !tagSlug(tag)))
|
|
76
|
+
throw new VageError(`collection(): "tag" must be a tag name like "SEO" (got ${JSON.stringify(tag)}).`);
|
|
74
77
|
if (under !== undefined && !/^\/(?!\/)/.test(under)) {
|
|
75
78
|
throw new VageError(`collection(): "under" must be a site path starting with "/", like "/blog/" (got ${JSON.stringify(under)}).`);
|
|
76
79
|
}
|
|
@@ -83,7 +86,7 @@ export function collection(pages, options = {}) {
|
|
|
83
86
|
throw new VageError(`collection(): "limit" must be a whole number of 0 or more (got ${JSON.stringify(limit)}).`);
|
|
84
87
|
}
|
|
85
88
|
const prefix = under ? (under.endsWith("/") ? under : `${under}/`) : undefined;
|
|
86
|
-
const picked = pages.filter((p) => !prefix || (p.route.startsWith(prefix) && (includeIndex || p.route !== prefix)));
|
|
89
|
+
const picked = pages.filter((p) => (!prefix || (p.route.startsWith(prefix) && (includeIndex || p.route !== prefix))) && (tag === undefined || (p.frontmatter.tags ?? []).some((t) => tagSlug(t) === tagSlug(tag))));
|
|
87
90
|
const byTitle = (a, b) => a.title.localeCompare(b.title);
|
|
88
91
|
const compare = typeof sort === "function"
|
|
89
92
|
? sort
|
|
@@ -103,6 +106,25 @@ export function collection(pages, options = {}) {
|
|
|
103
106
|
const sorted = [...picked].sort(compare);
|
|
104
107
|
return limit === undefined ? sorted : sorted.slice(0, limit);
|
|
105
108
|
}
|
|
109
|
+
/** A tag's address part: "Web Design" -> "web-design" (tag pages live at /tags/<slug>/). */
|
|
110
|
+
export function tagSlug(tag) {
|
|
111
|
+
return headingSlug(tag);
|
|
112
|
+
}
|
|
113
|
+
/** Every tag the pages use, A to Z, with how many pages have it: for tag clouds and tag menus. */
|
|
114
|
+
export function tagList(pages) {
|
|
115
|
+
const tags = new Map();
|
|
116
|
+
for (const p of pages) {
|
|
117
|
+
for (const name of new Set(p.frontmatter.tags ?? [])) {
|
|
118
|
+
const slug = tagSlug(name);
|
|
119
|
+
if (!slug)
|
|
120
|
+
continue;
|
|
121
|
+
const t = tags.get(slug) ?? { name, slug, route: `/tags/${slug}/`, count: 0 };
|
|
122
|
+
t.count++;
|
|
123
|
+
tags.set(slug, t);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return [...tags.values()].sort((a, b) => a.name.localeCompare(b.name));
|
|
127
|
+
}
|
|
106
128
|
/** The pages before and after `route` in an ordered list (e.g. from collection()), for "Previous / Next" links. */
|
|
107
129
|
export function neighbors(list, route) {
|
|
108
130
|
const i = list.findIndex((p) => p.route === route);
|
package/dist/core/render.d.ts
CHANGED
|
@@ -49,6 +49,8 @@ export declare function renderPage(options: {
|
|
|
49
49
|
pages?: readonly PageSummary[];
|
|
50
50
|
/** Resizer for images.optimize (tests inject one; sharp otherwise). */
|
|
51
51
|
resize?: Resize;
|
|
52
|
+
/** The page's Markdown, for generated pages (tag pages); otherwise `file` is read. */
|
|
53
|
+
source?: string;
|
|
52
54
|
}): Promise<RenderedPage>;
|
|
53
55
|
/**
|
|
54
56
|
* Loads the page's layout and, if it exports a `frontmatter` schema, validates the page
|
package/dist/core/render.js
CHANGED
|
@@ -11,7 +11,7 @@ import { headTags, injectHead, pageMeta } from "../seo/head.js";
|
|
|
11
11
|
import { analyticsTags, injectAnalytics } from "../integrations/tags.js";
|
|
12
12
|
export async function renderPage(options) {
|
|
13
13
|
const { root, file, route, site, load } = options;
|
|
14
|
-
const parsed = parseMarkdown(fs.readFileSync(file, "utf8"), file);
|
|
14
|
+
const parsed = parseMarkdown(options.source ?? fs.readFileSync(file, "utf8"), file);
|
|
15
15
|
const { frontmatter, body } = parsed;
|
|
16
16
|
const publicDir = path.join(root, "public");
|
|
17
17
|
const assets = new Map();
|
|
@@ -23,14 +23,14 @@ export async function renderPage(options) {
|
|
|
23
23
|
const v = await imageVariants(src, root, site.images, resize);
|
|
24
24
|
if (!v)
|
|
25
25
|
continue;
|
|
26
|
-
variants.set(src, { srcset: v.srcset, sizes: site.images.sizes });
|
|
26
|
+
variants.set(src, { srcset: v.srcset, sizes: site.images.sizes, avifSrcset: v.avifSrcset });
|
|
27
27
|
for (const [k, f] of v.files)
|
|
28
28
|
assets.set(k, f);
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
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 });
|
|
32
|
+
? await enhanceTrustedHtml(parsed.html, publicDir, variants, site.markdown.headingIds)
|
|
33
|
+
: await sanitizeHtml(parsed.html, { publicDir, inlineStyles: site.markdown.inlineStyles, variants, headingIds: site.markdown.headingIds });
|
|
34
34
|
const layout = await applyLayoutFields(root, frontmatter, load, file);
|
|
35
35
|
const meta = pageMeta(site, route, frontmatter, options.notFound);
|
|
36
36
|
const result = await layout({
|
package/dist/core/site.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ModuleLoader, type SiteConfig } from "./config.js";
|
|
2
2
|
import { type RenderedPage } from "./render.js";
|
|
3
|
+
import { type PageSummary } from "./pages.js";
|
|
3
4
|
export interface SitePage extends RenderedPage {
|
|
4
5
|
route: string;
|
|
5
6
|
/** Source path relative to the project root. */
|
|
@@ -28,6 +29,12 @@ export interface SiteModel {
|
|
|
28
29
|
skippedDrafts: string[];
|
|
29
30
|
/** Routes of draft pages; links to them are broken in production. */
|
|
30
31
|
draftRoutes: string[];
|
|
32
|
+
/** Pages whose datePublished is still ahead: skipped until a build on or after that date. */
|
|
33
|
+
skippedScheduled: {
|
|
34
|
+
source: string;
|
|
35
|
+
route: string;
|
|
36
|
+
date: string;
|
|
37
|
+
}[];
|
|
31
38
|
notFound?: SitePage;
|
|
32
39
|
redirects: SiteRedirect[];
|
|
33
40
|
/** Generated discovery files: output path -> content. */
|
|
@@ -38,5 +45,7 @@ export interface SiteModel {
|
|
|
38
45
|
export declare function renderSite(root: string, load: ModuleLoader, options?: {
|
|
39
46
|
collectErrors?: boolean;
|
|
40
47
|
}): Promise<SiteModel>;
|
|
48
|
+
/** A tag page's Markdown: a heading and the tagged pages, newest first. Written as HTML so tag and page names can't become Markdown. */
|
|
49
|
+
export declare function tagPageSource(site: SiteConfig, name: string, tagged: PageSummary[]): string;
|
|
41
50
|
/** Pages that should appear in sitemap.xml and llms.txt: indexable and canonical to themselves. */
|
|
42
51
|
export declare function listedPages(model: SiteModel): SitePage[];
|