@stati/core 1.28.0 → 1.29.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 +6 -1
- package/dist/core/build.d.ts.map +1 -1
- package/dist/core/build.js +192 -71
- package/dist/core/collections.d.ts +139 -0
- package/dist/core/collections.d.ts.map +1 -0
- package/dist/core/collections.js +342 -0
- package/dist/core/content.d.ts.map +1 -1
- package/dist/core/content.js +2 -1
- package/dist/core/dev.d.ts.map +1 -1
- package/dist/core/dev.js +32 -93
- package/dist/core/expand.d.ts +86 -0
- package/dist/core/expand.d.ts.map +1 -0
- package/dist/core/expand.js +484 -0
- package/dist/core/isg/builder.d.ts +15 -2
- package/dist/core/isg/builder.d.ts.map +1 -1
- package/dist/core/isg/builder.js +44 -6
- package/dist/core/isg/hash.d.ts +4 -2
- package/dist/core/isg/hash.d.ts.map +1 -1
- package/dist/core/isg/hash.js +16 -2
- package/dist/core/isg/index.d.ts +2 -1
- package/dist/core/isg/index.d.ts.map +1 -1
- package/dist/core/isg/index.js +3 -1
- package/dist/core/isg/manifest.js +6 -0
- package/dist/core/isg/prune.d.ts +32 -0
- package/dist/core/isg/prune.d.ts.map +1 -0
- package/dist/core/isg/prune.js +113 -0
- package/dist/core/navigation.d.ts +4 -2
- package/dist/core/navigation.d.ts.map +1 -1
- package/dist/core/navigation.js +17 -2
- package/dist/core/preview.d.ts.map +1 -1
- package/dist/core/preview.js +21 -32
- package/dist/core/templates.d.ts +3 -0
- package/dist/core/templates.d.ts.map +1 -1
- package/dist/core/templates.js +67 -127
- package/dist/core/utils/bundle-matching.utils.d.ts +8 -8
- package/dist/core/utils/bundle-matching.utils.d.ts.map +1 -1
- package/dist/core/utils/bundle-matching.utils.js +10 -10
- package/dist/core/utils/fs.utils.d.ts +8 -0
- package/dist/core/utils/fs.utils.d.ts.map +1 -1
- package/dist/core/utils/fs.utils.js +13 -1
- package/dist/core/utils/index.d.ts +6 -6
- package/dist/core/utils/index.d.ts.map +1 -1
- package/dist/core/utils/index.js +5 -5
- package/dist/core/utils/paths.utils.d.ts +15 -0
- package/dist/core/utils/paths.utils.d.ts.map +1 -1
- package/dist/core/utils/paths.utils.js +20 -0
- package/dist/core/utils/server.utils.d.ts +38 -3
- package/dist/core/utils/server.utils.d.ts.map +1 -1
- package/dist/core/utils/server.utils.js +132 -44
- package/dist/core/utils/template-discovery.utils.d.ts +25 -1
- package/dist/core/utils/template-discovery.utils.d.ts.map +1 -1
- package/dist/core/utils/template-discovery.utils.js +61 -11
- package/dist/core/utils/typescript.utils.d.ts +27 -0
- package/dist/core/utils/typescript.utils.d.ts.map +1 -1
- package/dist/core/utils/typescript.utils.js +34 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/rss/generator.d.ts.map +1 -1
- package/dist/rss/generator.js +5 -2
- package/dist/search/generator.d.ts +27 -7
- package/dist/search/generator.d.ts.map +1 -1
- package/dist/search/generator.js +55 -12
- package/dist/search/index.d.ts +1 -1
- package/dist/search/index.d.ts.map +1 -1
- package/dist/search/index.js +1 -1
- package/dist/types/config.d.ts +61 -4
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/content.d.ts +129 -1
- package/dist/types/content.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/isg.d.ts +12 -0
- package/dist/types/isg.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { basename, join, relative, sep } from 'node:path';
|
|
2
|
+
import { getPageOutputFile, resolveSrcDir, slugify } from './utils/index.js';
|
|
3
|
+
import { findCollectionPath, getCollections, resolveCollectionPath } from './collections.js';
|
|
4
|
+
/**
|
|
5
|
+
* File name of a template that expands into one page per key, such as `[tag].md`.
|
|
6
|
+
*/
|
|
7
|
+
const TEMPLATE_FILE_PATTERN = /^\[([^\]]*)\]\.md$/;
|
|
8
|
+
/**
|
|
9
|
+
* Valid template parameter names: `[tag].md`, `[author].md`, `[release_year].md`.
|
|
10
|
+
*/
|
|
11
|
+
const PARAM_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
12
|
+
/**
|
|
13
|
+
* Valid `paginate.path` values: one URL segment.
|
|
14
|
+
*/
|
|
15
|
+
const PAGINATION_PATH_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
16
|
+
/** URL segment before the page number when `paginate.path` is not set */
|
|
17
|
+
export const DEFAULT_PAGINATION_PATH = 'page';
|
|
18
|
+
/**
|
|
19
|
+
* Returns the parameter name of a `[param].md` template, or undefined for other pages.
|
|
20
|
+
*
|
|
21
|
+
* @param page - A loaded page
|
|
22
|
+
* @returns The name between the brackets
|
|
23
|
+
*/
|
|
24
|
+
export function getTemplateParam(page) {
|
|
25
|
+
return TEMPLATE_FILE_PATTERN.exec(basename(page.sourcePath))?.[1];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Replaces `{param}` with the label in a string. `{{param}}` produces the literal `{param}`,
|
|
29
|
+
* and placeholders of other names are left as they are.
|
|
30
|
+
*
|
|
31
|
+
* @param text - The string to fill in
|
|
32
|
+
* @param param - The template's parameter name, such as `tag`
|
|
33
|
+
* @param label - The value, such as `CSS`
|
|
34
|
+
* @returns The string with its placeholders replaced
|
|
35
|
+
*
|
|
36
|
+
* @example
|
|
37
|
+
* ```typescript
|
|
38
|
+
* replacePlaceholders('Posts tagged {tag}', 'tag', 'CSS'); // 'Posts tagged CSS'
|
|
39
|
+
* replacePlaceholders('Use {{tag}} in titles', 'tag', 'CSS'); // 'Use {tag} in titles'
|
|
40
|
+
* replacePlaceholders('{author} on {tag}', 'tag', 'CSS'); // '{author} on CSS'
|
|
41
|
+
* ```
|
|
42
|
+
*/
|
|
43
|
+
export function replacePlaceholders(text, param, label) {
|
|
44
|
+
const name = param.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&');
|
|
45
|
+
return text.replace(new RegExp(`\\{\\{${name}\\}\\}|\\{${name}\\}`, 'g'), (match) => match.startsWith('{{') ? `{${param}}` : label);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Deep-copies a value from front matter (YAML data: strings, numbers, booleans, dates, lists
|
|
49
|
+
* and mappings), passing every string inside it through `mapString`.
|
|
50
|
+
*/
|
|
51
|
+
function copyValue(value, mapString = (text) => text) {
|
|
52
|
+
if (typeof value === 'string') {
|
|
53
|
+
return mapString(value);
|
|
54
|
+
}
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
return value.map((item) => copyValue(item, mapString));
|
|
57
|
+
}
|
|
58
|
+
if (value instanceof Date) {
|
|
59
|
+
return new Date(value.getTime());
|
|
60
|
+
}
|
|
61
|
+
if (value && typeof value === 'object') {
|
|
62
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, copyValue(item, mapString)]));
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Copies a template's front matter for one generated page, with `{param}` placeholders filled
|
|
68
|
+
* in. `generate` and `permalink` are copied as they are: they configure the template, and
|
|
69
|
+
* `permalink` takes the key through `[param]` instead.
|
|
70
|
+
*
|
|
71
|
+
* @param frontMatter - The template's front matter
|
|
72
|
+
* @param param - The template's parameter name
|
|
73
|
+
* @param label - The value for the page
|
|
74
|
+
* @returns The page's front matter
|
|
75
|
+
*/
|
|
76
|
+
export function substituteFrontMatter(frontMatter, param, label) {
|
|
77
|
+
const result = {};
|
|
78
|
+
for (const [key, value] of Object.entries(frontMatter)) {
|
|
79
|
+
result[key] =
|
|
80
|
+
key === 'generate' || key === 'permalink'
|
|
81
|
+
? copyValue(value)
|
|
82
|
+
: copyValue(value, (text) => replacePlaceholders(text, param, label));
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* URL of page `pageNumber` of a listing whose first page is `baseUrl`. Later pages follow the
|
|
88
|
+
* first page's trailing-slash style: `/blog` → `/blog/page/2`, `/blog/` → `/blog/page/2/`,
|
|
89
|
+
* `/` → `/page/2`.
|
|
90
|
+
*
|
|
91
|
+
* @param baseUrl - URL of the first page
|
|
92
|
+
* @param path - URL segment before the page number
|
|
93
|
+
* @param pageNumber - Page number, starting at 1
|
|
94
|
+
* @returns The page URL
|
|
95
|
+
*/
|
|
96
|
+
export function getPaginationUrl(baseUrl, path, pageNumber) {
|
|
97
|
+
if (pageNumber <= 1) {
|
|
98
|
+
return baseUrl;
|
|
99
|
+
}
|
|
100
|
+
if (baseUrl === '/') {
|
|
101
|
+
return `/${path}/${pageNumber}`;
|
|
102
|
+
}
|
|
103
|
+
return baseUrl.endsWith('/')
|
|
104
|
+
? `${baseUrl}${path}/${pageNumber}/`
|
|
105
|
+
: `${baseUrl}/${path}/${pageNumber}`;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Builds the pagination data of every page of a listing.
|
|
109
|
+
*
|
|
110
|
+
* @param baseUrl - URL of the first page
|
|
111
|
+
* @param items - Everything the listing shows, in order
|
|
112
|
+
* @param settings - Page size and URL segment
|
|
113
|
+
* @returns One entry per page; there is always at least one
|
|
114
|
+
*/
|
|
115
|
+
export function paginateItems(baseUrl, items, settings) {
|
|
116
|
+
const totalPages = Math.max(1, Math.ceil(items.length / settings.size));
|
|
117
|
+
const urls = Array.from({ length: totalPages }, (_, i) => getPaginationUrl(baseUrl, settings.path, i + 1));
|
|
118
|
+
return urls.map((_, i) => ({
|
|
119
|
+
pageNumber: i + 1,
|
|
120
|
+
totalPages,
|
|
121
|
+
items: items.slice(i * settings.size, (i + 1) * settings.size),
|
|
122
|
+
...(i > 0 && { prevUrl: urls[i - 1] }),
|
|
123
|
+
...(i < totalPages - 1 && { nextUrl: urls[i + 1] }),
|
|
124
|
+
// One array for the whole listing, not one per page; templates get their own copy
|
|
125
|
+
urls,
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Reads `paginate` settings from front matter.
|
|
130
|
+
*
|
|
131
|
+
* @param value - `paginate` or `generate.paginate`: a size, or `{ size, path }`
|
|
132
|
+
* @param warn - Reports an invalid value
|
|
133
|
+
* @returns The settings, or undefined when the value is absent or invalid
|
|
134
|
+
*/
|
|
135
|
+
function parsePaginate(value, warn) {
|
|
136
|
+
if (value === undefined || value === null || value === false) {
|
|
137
|
+
return undefined;
|
|
138
|
+
}
|
|
139
|
+
const options = typeof value === 'number' ? { size: value } : value;
|
|
140
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) {
|
|
141
|
+
warn('paginate must be a page size or { size, path }; the page is not paginated');
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
const { size, path } = options;
|
|
145
|
+
if (typeof size !== 'number' || !Number.isInteger(size) || size < 1) {
|
|
146
|
+
warn('paginate.size must be a whole number of at least 1; the page is not paginated');
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
if (path === undefined) {
|
|
150
|
+
return { size, path: DEFAULT_PAGINATION_PATH };
|
|
151
|
+
}
|
|
152
|
+
if (typeof path !== 'string' || !PAGINATION_PATH_PATTERN.test(path)) {
|
|
153
|
+
warn(`paginate.path must be a single URL segment such as "page"; using "${DEFAULT_PAGINATION_PATH}"`);
|
|
154
|
+
return { size, path: DEFAULT_PAGINATION_PATH };
|
|
155
|
+
}
|
|
156
|
+
return { size, path };
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Groups the pages of a collection by the values of a front matter field. Values are slugified
|
|
160
|
+
* into keys; values that slugify to the same key share one group, under the first value seen.
|
|
161
|
+
*
|
|
162
|
+
* @param pages - Collection pages, newest first
|
|
163
|
+
* @param field - Front matter field holding a string, a number, or a list of them
|
|
164
|
+
* @param onEmptyKey - Reports a value that slugifies to nothing
|
|
165
|
+
* @param onCollision - Reports a value merged into the group of another value
|
|
166
|
+
* @returns The groups, sorted by key
|
|
167
|
+
*/
|
|
168
|
+
function groupByField(pages, field, onEmptyKey, onCollision) {
|
|
169
|
+
const groups = new Map();
|
|
170
|
+
for (const page of pages) {
|
|
171
|
+
const value = page.frontMatter[field];
|
|
172
|
+
const values = Array.isArray(value) ? value : [value];
|
|
173
|
+
const pageKeys = new Set();
|
|
174
|
+
for (const raw of values) {
|
|
175
|
+
if (typeof raw !== 'string' && !(typeof raw === 'number' && Number.isFinite(raw))) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
const label = String(raw).trim();
|
|
179
|
+
if (!label) {
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
const key = slugify(label);
|
|
183
|
+
if (!key) {
|
|
184
|
+
onEmptyKey(label);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
let group = groups.get(key);
|
|
188
|
+
if (!group) {
|
|
189
|
+
group = { key, label, items: [] };
|
|
190
|
+
groups.set(key, group);
|
|
191
|
+
}
|
|
192
|
+
else if (group.label !== label) {
|
|
193
|
+
onCollision(key, group.label, label);
|
|
194
|
+
}
|
|
195
|
+
// A page that lists two values with the same key appears once
|
|
196
|
+
if (!pageKeys.has(key)) {
|
|
197
|
+
pageKeys.add(key);
|
|
198
|
+
group.items.push(page);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return [...groups.values()].sort((a, b) => a.key.localeCompare(b.key));
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Turns the pages loaded from Markdown into the pages of a build:
|
|
206
|
+
*
|
|
207
|
+
* - Each `[param].md` template is removed and replaced by one page per key: per value of
|
|
208
|
+
* `generate.from` in its collection, and per `collections.generate` entry that names it.
|
|
209
|
+
* A template that produces no keys produces no output.
|
|
210
|
+
* - Every page with `paginate`, and every generated page whose template has
|
|
211
|
+
* `generate.paginate`, gets one more page per additional slice of its items.
|
|
212
|
+
*
|
|
213
|
+
* Generated and pagination pages are virtual: they are never collection members and are left
|
|
214
|
+
* out of navigation, search and RSS unless they opt in. A virtual page whose URL or output
|
|
215
|
+
* file is already taken is skipped with a warning.
|
|
216
|
+
*
|
|
217
|
+
* @param loaded - Pages loaded from Markdown files
|
|
218
|
+
* @param config - Stati configuration
|
|
219
|
+
* @param logger - Logger for warnings
|
|
220
|
+
* @returns The pages to build: loaded pages without templates, then virtual pages
|
|
221
|
+
*/
|
|
222
|
+
export async function expandPages(loaded, config, logger) {
|
|
223
|
+
// Source-relative path of a page, such as blog/tags/[tag].md, for template paths and warnings
|
|
224
|
+
let srcDir;
|
|
225
|
+
const label = (page) => relative((srcDir ??= resolveSrcDir(config)), page.sourcePath).replace(/\\/g, '/');
|
|
226
|
+
const reported = new Set();
|
|
227
|
+
const warnOnce = (message) => {
|
|
228
|
+
if (!reported.has(message)) {
|
|
229
|
+
reported.add(message);
|
|
230
|
+
logger.warning(message);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
const templates = [];
|
|
234
|
+
const pages = [];
|
|
235
|
+
for (const page of loaded) {
|
|
236
|
+
const param = getTemplateParam(page);
|
|
237
|
+
if (param === undefined) {
|
|
238
|
+
pages.push(page);
|
|
239
|
+
}
|
|
240
|
+
else if (!PARAM_NAME_PATTERN.test(param)) {
|
|
241
|
+
warnOnce(`${label(page)}: "${param}" is not a valid template parameter (use letters, digits, - and _, such as [tag].md); the file is skipped`);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
templates.push({ page, param, path: label(page) });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const collections = getCollections(pages);
|
|
248
|
+
// Front matter that only works elsewhere, or names a collection that doesn't exist
|
|
249
|
+
for (const page of pages) {
|
|
250
|
+
const { collection, generate } = page.frontMatter;
|
|
251
|
+
if (collection !== undefined && findCollectionPath(collections, collection) === undefined) {
|
|
252
|
+
warnOnce(`${label(page)}: collection "${String(collection)}" is not the URL of a collection index page; the page reads its default collection`);
|
|
253
|
+
}
|
|
254
|
+
if (generate !== undefined) {
|
|
255
|
+
warnOnce(`${label(page)}: generate only works in a [param].md template, such as site/blog/tags/[tag].md`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
// Output files taken so far, with the URL that writes each. A later page that would write
|
|
259
|
+
// one is skipped: /tags/ and /tags/index both write tags/index.html.
|
|
260
|
+
const outputFileOf = (url) => join(sep, getPageOutputFile(url)).replace(/\\/g, '/');
|
|
261
|
+
const taken = new Map();
|
|
262
|
+
for (const page of pages) {
|
|
263
|
+
const file = outputFileOf(page.url);
|
|
264
|
+
if (!taken.has(file)) {
|
|
265
|
+
taken.set(file, page.url);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
const claim = (url, what) => {
|
|
269
|
+
const file = outputFileOf(url);
|
|
270
|
+
const owner = taken.get(file);
|
|
271
|
+
if (owner === url) {
|
|
272
|
+
warnOnce(`${what} would use the URL ${url}, which another page already has; it is skipped`);
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
if (owner !== undefined) {
|
|
276
|
+
warnOnce(`${what} would use the URL ${url}, which writes the same file as ${owner} (${file.slice(1)}); it is skipped`);
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
taken.set(file, url);
|
|
280
|
+
return true;
|
|
281
|
+
};
|
|
282
|
+
// Pages from collections.generate, grouped by the template they name
|
|
283
|
+
const entriesByTemplate = new Map();
|
|
284
|
+
for (const entry of await runGenerateHook(config, pages, logger)) {
|
|
285
|
+
const template = normalizeTemplatePath(entry.template);
|
|
286
|
+
const known = templates.some((candidate) => candidate.path === template);
|
|
287
|
+
if (typeof entry.url !== 'string' || !entry.url.startsWith('/')) {
|
|
288
|
+
warnOnce(`collections.generate: entry with url ${JSON.stringify(entry.url)} needs a URL starting with "/"; it is skipped`);
|
|
289
|
+
}
|
|
290
|
+
else if (!known) {
|
|
291
|
+
warnOnce(`collections.generate: ${entry.url} names the template ${JSON.stringify(entry.template)}, which is not a [param].md file in the source directory; it is skipped`);
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
const list = entriesByTemplate.get(template) ?? [];
|
|
295
|
+
list.push(entry);
|
|
296
|
+
entriesByTemplate.set(template, list);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
const generatedPages = [];
|
|
300
|
+
for (const { page: template, param, path } of templates) {
|
|
301
|
+
const fm = template.frontMatter;
|
|
302
|
+
const generate = fm.generate && typeof fm.generate === 'object' && !Array.isArray(fm.generate)
|
|
303
|
+
? fm.generate
|
|
304
|
+
: undefined;
|
|
305
|
+
const from = typeof generate?.from === 'string' ? generate.from.trim() : '';
|
|
306
|
+
const entries = entriesByTemplate.get(path) ?? [];
|
|
307
|
+
if (fm.generate !== undefined && !generate) {
|
|
308
|
+
warnOnce(`${path}: generate must be a mapping such as { from: tags }`);
|
|
309
|
+
}
|
|
310
|
+
if (fm.paginate !== undefined) {
|
|
311
|
+
warnOnce(`${path}: use generate.paginate to split generated pages; paginate is ignored here`);
|
|
312
|
+
}
|
|
313
|
+
if (!from && entries.length === 0) {
|
|
314
|
+
warnOnce(`${path}: set generate.from (such as "from: tags") or return pages for it from collections.generate; it generates no pages`);
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
// The template reads the collection its front matter names, or the one its folder is in
|
|
318
|
+
const collectionPath = resolveCollectionPath({ ...template, url: template.slug }, pages);
|
|
319
|
+
const pageUrl = createUrlBuilder(template, param, () => warnOnce(`${path}: permalink must contain [${param}] to give each page its own URL; it is ignored`));
|
|
320
|
+
const groups = [];
|
|
321
|
+
if (from) {
|
|
322
|
+
const collection = collectionPath !== undefined ? collections.byPath.get(collectionPath) : undefined;
|
|
323
|
+
if (!collection) {
|
|
324
|
+
warnOnce(`${path}: generate.from needs a collection, but the template is in none; set collection in its front matter, such as "collection: /blog"`);
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
const found = groupByField(collection.recentPages, from, (value) => warnOnce(`${path}: "${value}" has no Latin letters or digits to use in a URL; it gets no page`), (key, kept, merged) => warnOnce(`${path}: "${merged}" and "${kept}" both become the key "${key}"; their pages are listed together under "${kept}"`));
|
|
328
|
+
for (const group of found) {
|
|
329
|
+
groups.push({ ...group, url: pageUrl(group.key) });
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
for (const entry of entries) {
|
|
334
|
+
const key = typeof entry.key === 'string' && entry.key ? entry.key : lastSegment(entry.url);
|
|
335
|
+
groups.push({
|
|
336
|
+
key,
|
|
337
|
+
label: typeof entry.label === 'string' && entry.label ? entry.label : key,
|
|
338
|
+
items: Array.isArray(entry.items) ? entry.items : [],
|
|
339
|
+
url: entry.url,
|
|
340
|
+
...(entry.data !== undefined && { data: entry.data }),
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
const paginate = parsePaginate(generate?.paginate, (message) => warnOnce(`${path}: generate.${message}`));
|
|
344
|
+
for (const group of groups) {
|
|
345
|
+
if (!claim(group.url, `${path} for "${group.label}"`)) {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const generated = {
|
|
349
|
+
key: group.key,
|
|
350
|
+
label: group.label,
|
|
351
|
+
items: group.items,
|
|
352
|
+
template: path,
|
|
353
|
+
...(group.data !== undefined && { data: group.data }),
|
|
354
|
+
};
|
|
355
|
+
const frontMatter = substituteFrontMatter(fm, param, group.label);
|
|
356
|
+
if (collectionPath !== undefined) {
|
|
357
|
+
frontMatter.collection = collectionPath;
|
|
358
|
+
}
|
|
359
|
+
const page = {
|
|
360
|
+
// The template's slug with the key in place of [param]: /tags/[tag] → /tags/css
|
|
361
|
+
slug: `${template.slug.slice(0, template.slug.lastIndexOf('/') + 1)}${group.key}`,
|
|
362
|
+
url: group.url,
|
|
363
|
+
sourcePath: template.sourcePath,
|
|
364
|
+
frontMatter,
|
|
365
|
+
content: template.content,
|
|
366
|
+
...(template.publishedAt && { publishedAt: template.publishedAt }),
|
|
367
|
+
generated,
|
|
368
|
+
};
|
|
369
|
+
generatedPages.push(page);
|
|
370
|
+
if (paginate) {
|
|
371
|
+
generatedPages.push(...paginatePage(page, group.items, paginate, claim, path));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
// Listings with paginate: index pages, or pages with a collection in their front matter
|
|
376
|
+
const paginationPages = [];
|
|
377
|
+
for (const page of pages) {
|
|
378
|
+
if (page.frontMatter.paginate === undefined) {
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const where = label(page);
|
|
382
|
+
const settings = parsePaginate(page.frontMatter.paginate, (message) => warnOnce(`${where}: ${message}`));
|
|
383
|
+
if (!settings) {
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
const collectionPath = resolveCollectionPath(page, pages);
|
|
387
|
+
const collection = collectionPath !== undefined ? collections.byPath.get(collectionPath) : undefined;
|
|
388
|
+
if (!collection) {
|
|
389
|
+
warnOnce(`${where}: paginate needs a collection, but the page has none; it is not paginated`);
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
paginationPages.push(...paginatePage(page, collection.recentPages, settings, claim, where, collectionPath));
|
|
393
|
+
}
|
|
394
|
+
return [...pages, ...paginationPages, ...generatedPages];
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Sets the first page's pagination data and creates the pages after it.
|
|
398
|
+
*
|
|
399
|
+
* @param page - The first page, which keeps its URL
|
|
400
|
+
* @param items - Everything the listing shows, in order
|
|
401
|
+
* @param settings - Page size and URL segment
|
|
402
|
+
* @param claim - Reserves a URL, or reports that it is taken
|
|
403
|
+
* @param where - Source label for warnings
|
|
404
|
+
* @param collectionPath - Collection the later pages read; generated pages carry their own
|
|
405
|
+
* @returns The pages after the first
|
|
406
|
+
*/
|
|
407
|
+
function paginatePage(page, items, settings, claim, where, collectionPath) {
|
|
408
|
+
const [first, ...rest] = paginateItems(page.url, items, settings);
|
|
409
|
+
page.pagination = first;
|
|
410
|
+
const later = [];
|
|
411
|
+
for (const pagination of rest) {
|
|
412
|
+
const url = pagination.urls[pagination.pageNumber - 1];
|
|
413
|
+
if (!claim(url, `Page ${pagination.pageNumber} of ${where}`)) {
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const frontMatter = copyValue(page.frontMatter);
|
|
417
|
+
if (collectionPath !== undefined) {
|
|
418
|
+
frontMatter.collection = collectionPath;
|
|
419
|
+
}
|
|
420
|
+
later.push({
|
|
421
|
+
...page,
|
|
422
|
+
slug: url.length > 1 && url.endsWith('/') ? url.slice(0, -1) : url,
|
|
423
|
+
url,
|
|
424
|
+
frontMatter,
|
|
425
|
+
pagination,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
return later;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Returns a function that gives the URL of a template's page for a key: the template's URL
|
|
432
|
+
* with `[param]` replaced, or its `permalink` with `[param]` replaced.
|
|
433
|
+
*/
|
|
434
|
+
function createUrlBuilder(template, param, onBadPermalink) {
|
|
435
|
+
const token = `[${param}]`;
|
|
436
|
+
const permalink = template.frontMatter.permalink;
|
|
437
|
+
if (typeof permalink === 'string') {
|
|
438
|
+
if (permalink.includes(token)) {
|
|
439
|
+
return (key) => permalink.split(token).join(key);
|
|
440
|
+
}
|
|
441
|
+
onBadPermalink();
|
|
442
|
+
}
|
|
443
|
+
// The template's file-based URL ends with the token: /blog/tags/[tag]
|
|
444
|
+
const base = template.slug.slice(0, -token.length);
|
|
445
|
+
return (key) => `${base}${key}`;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Runs `collections.generate`.
|
|
449
|
+
*/
|
|
450
|
+
async function runGenerateHook(config, pages, logger) {
|
|
451
|
+
const generate = config.collections?.generate;
|
|
452
|
+
if (typeof generate !== 'function') {
|
|
453
|
+
return [];
|
|
454
|
+
}
|
|
455
|
+
let entries;
|
|
456
|
+
try {
|
|
457
|
+
entries = await generate({ config, pages });
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
throw new Error(`collections.generate failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
461
|
+
}
|
|
462
|
+
if (!Array.isArray(entries)) {
|
|
463
|
+
logger.warning('collections.generate must return an array of pages; its result is ignored');
|
|
464
|
+
return [];
|
|
465
|
+
}
|
|
466
|
+
return entries.filter((entry) => {
|
|
467
|
+
if (entry && typeof entry === 'object' && typeof entry.template === 'string') {
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
logger.warning('collections.generate: every entry needs a url and a template; an entry is skipped');
|
|
471
|
+
return false;
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* Normalizes a template path from `collections.generate` to the form expansion uses:
|
|
476
|
+
* relative to the source directory, with forward slashes and the `.md` extension.
|
|
477
|
+
*/
|
|
478
|
+
function normalizeTemplatePath(template) {
|
|
479
|
+
const path = template.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '');
|
|
480
|
+
return path.endsWith('.md') ? path : `${path}.md`;
|
|
481
|
+
}
|
|
482
|
+
function lastSegment(url) {
|
|
483
|
+
return url.split('/').filter(Boolean).at(-1) ?? '';
|
|
484
|
+
}
|
|
@@ -1,14 +1,27 @@
|
|
|
1
1
|
import type { PageModel, CacheEntry, StatiConfig } from '../../types/index.js';
|
|
2
|
+
import type { PageReads } from '../collections.js';
|
|
2
3
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
4
|
+
* What a render discovered: the source images a page displays (`assetDeps`), the generated
|
|
5
|
+
* images it references (`images`) and the shared data it read (`reads`).
|
|
5
6
|
*/
|
|
6
7
|
export interface RenderedAssets {
|
|
7
8
|
/** Absolute paths of files whose content affects the page, such as source images */
|
|
8
9
|
assetDeps: readonly string[];
|
|
9
10
|
/** Generated image files the page references, relative to the output directory */
|
|
10
11
|
images: readonly string[];
|
|
12
|
+
/** Whether the render read the collection and the navigation tree */
|
|
13
|
+
reads?: PageReads | undefined;
|
|
11
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Whether a cache entry records what its page read while rendering. Entries written before
|
|
17
|
+
* Stati recorded it don't, and their page must be rebuilt once: whether it depends on its
|
|
18
|
+
* collection or the navigation tree is unknown, and assuming it doesn't could keep stale
|
|
19
|
+
* listings or prev/next links.
|
|
20
|
+
*
|
|
21
|
+
* @param entry - The cache entry
|
|
22
|
+
* @returns True when `readsCollection` and `readsNav` are both recorded
|
|
23
|
+
*/
|
|
24
|
+
export declare function hasRecordedReads(entry: CacheEntry): boolean;
|
|
12
25
|
/**
|
|
13
26
|
* Determines if a page should be rebuilt based on ISG logic.
|
|
14
27
|
* Checks content changes, dependency changes, TTL expiration, and freeze status.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../../../src/core/isg/builder.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;
|
|
1
|
+
{"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../../../src/core/isg/builder.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAC/E,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,mBAAmB,CAAC;AAsFnD;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,oFAAoF;IACpF,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IAC7B,kFAAkF;IAClF,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B,qEAAqE;IACrE,KAAK,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC;CAC/B;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAE3D;AAuDD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,iBAAiB,CACrC,IAAI,EAAE,SAAS,EACf,KAAK,EAAE,UAAU,GAAG,SAAS,EAC7B,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,SAAS,EAAE,EACrB,GAAG,EAAE,IAAI,EACT,gBAAgB,CAAC,EAAE,MAAM,GACxB,OAAO,CAAC,OAAO,CAAC,CAuKlB;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,SAAS,EAAE,EACrB,UAAU,EAAE,IAAI,EAChB,gBAAgB,CAAC,EAAE,MAAM,EACzB,QAAQ,CAAC,EAAE,cAAc,GACxB,OAAO,CAAC,UAAU,CAAC,CAmFrB;AAkDD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,gBAAgB,CACpC,KAAK,EAAE,UAAU,EACjB,IAAI,EAAE,SAAS,EACf,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,SAAS,EAAE,EACrB,UAAU,EAAE,IAAI,EAChB,UAAU,UAAQ,EAClB,gBAAgB,CAAC,EAAE,MAAM,EACzB,QAAQ,CAAC,EAAE,cAAc,GACxB,OAAO,CAAC,UAAU,CAAC,CAiErB"}
|
package/dist/core/isg/builder.js
CHANGED
|
@@ -60,8 +60,42 @@ function isValidCacheEntry(entry) {
|
|
|
60
60
|
return false;
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
|
+
for (const flag of [entry.readsCollection, entry.readsNav]) {
|
|
64
|
+
if (flag !== undefined && typeof flag !== 'boolean') {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
63
68
|
return true;
|
|
64
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* Whether a cache entry records what its page read while rendering. Entries written before
|
|
72
|
+
* Stati recorded it don't, and their page must be rebuilt once: whether it depends on its
|
|
73
|
+
* collection or the navigation tree is unknown, and assuming it doesn't could keep stale
|
|
74
|
+
* listings or prev/next links.
|
|
75
|
+
*
|
|
76
|
+
* @param entry - The cache entry
|
|
77
|
+
* @returns True when `readsCollection` and `readsNav` are both recorded
|
|
78
|
+
*/
|
|
79
|
+
export function hasRecordedReads(entry) {
|
|
80
|
+
return typeof entry.readsCollection === 'boolean' && typeof entry.readsNav === 'boolean';
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Tags a page's cache entry carries for `stati invalidate "tag:…"`: its front matter tags,
|
|
84
|
+
* plus the key and label of a generated page, so `tag:css` also refreshes `/tags/css`.
|
|
85
|
+
*/
|
|
86
|
+
function getCacheTags(page) {
|
|
87
|
+
const tags = Array.isArray(page.frontMatter.tags)
|
|
88
|
+
? page.frontMatter.tags.filter((tag) => typeof tag === 'string')
|
|
89
|
+
: [];
|
|
90
|
+
if (page.generated) {
|
|
91
|
+
for (const tag of [page.generated.key, page.generated.label]) {
|
|
92
|
+
if (tag && !tags.includes(tag)) {
|
|
93
|
+
tags.push(tag);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return tags;
|
|
98
|
+
}
|
|
65
99
|
/**
|
|
66
100
|
* Hashes render-discovered dependencies by content. Missing files hash to a sentinel.
|
|
67
101
|
*/
|
|
@@ -74,14 +108,19 @@ async function hashAssetDeps(assetDeps) {
|
|
|
74
108
|
}
|
|
75
109
|
/**
|
|
76
110
|
* Returns the entry with its `assetDeps` and `images` replaced by this render's,
|
|
77
|
-
* dropping the fields when a render found none.
|
|
111
|
+
* dropping the fields when a render found none, and with what the render read.
|
|
78
112
|
*/
|
|
79
113
|
function withRenderedAssets(entry, rendered) {
|
|
80
|
-
const { assetDeps: _assetDeps, images: _images, ...rest } = entry;
|
|
114
|
+
const { assetDeps: _assetDeps, images: _images, readsCollection: _readsCollection, readsNav: _readsNav, ...rest } = entry;
|
|
81
115
|
return {
|
|
82
116
|
...rest,
|
|
83
117
|
...(rendered && rendered.assetDeps.length > 0 && { assetDeps: [...rendered.assetDeps] }),
|
|
84
118
|
...(rendered && rendered.images.length > 0 && { images: [...rendered.images] }),
|
|
119
|
+
// Always written, false included: a missing flag means "unknown" and forces a rebuild
|
|
120
|
+
...(rendered?.reads && {
|
|
121
|
+
readsCollection: rendered.reads.collection,
|
|
122
|
+
readsNav: rendered.reads.nav,
|
|
123
|
+
}),
|
|
85
124
|
};
|
|
86
125
|
}
|
|
87
126
|
/**
|
|
@@ -279,10 +318,7 @@ export async function createCacheEntry(page, config, allPages, renderedAt, rende
|
|
|
279
318
|
timings.depsHashes = performance.now() - start;
|
|
280
319
|
const inputsHash = computeInputsHash(contentHash, depsHashes, renderInputsHash);
|
|
281
320
|
// Extract tags from front matter
|
|
282
|
-
|
|
283
|
-
if (Array.isArray(page.frontMatter.tags)) {
|
|
284
|
-
tags = page.frontMatter.tags.filter((tag) => typeof tag === 'string');
|
|
285
|
-
}
|
|
321
|
+
const tags = getCacheTags(page);
|
|
286
322
|
// Get published date
|
|
287
323
|
const publishedAt = getPublishedDateISO(page);
|
|
288
324
|
// Compute effective TTL
|
|
@@ -418,6 +454,8 @@ export async function updateCacheEntry(entry, page, config, allPages, renderedAt
|
|
|
418
454
|
return withRenderedAssets({
|
|
419
455
|
...entry,
|
|
420
456
|
inputsHash,
|
|
457
|
+
// Front matter tags, and a generated page's label, can change without a layout change
|
|
458
|
+
tags: getCacheTags(page),
|
|
421
459
|
renderedAt: renderedAt.toISOString(),
|
|
422
460
|
}, rendered);
|
|
423
461
|
}
|
package/dist/core/isg/hash.d.ts
CHANGED
|
@@ -61,8 +61,10 @@ export declare function computeBinaryFileHash(filePath: string): Promise<string>
|
|
|
61
61
|
*/
|
|
62
62
|
export declare function computeInputsHash(contentHash: string, depsHashes: string[], renderInputsHash?: string): string;
|
|
63
63
|
/**
|
|
64
|
-
* Computes a hash of the navigation tree
|
|
65
|
-
*
|
|
64
|
+
* Computes a hash of the navigation tree.
|
|
65
|
+
* Covers every field of a navigation node (title, url, path, order, publishedAt,
|
|
66
|
+
* isCollection and children), so pages that read the navigation rebuild when any of
|
|
67
|
+
* what they can show changes.
|
|
66
68
|
*
|
|
67
69
|
* @param navigation - The navigation tree to hash
|
|
68
70
|
* @returns SHA-256 hash as a hex string
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hash.d.ts","sourceRoot":"","sources":["../../../src/core/isg/hash.ts"],"names":[],"mappings":"AAgCA;;GAEG;AACH,wBAAgB,kBAAkB,IAAI,IAAI,CAEzC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,
|
|
1
|
+
{"version":3,"file":"hash.d.ts","sourceRoot":"","sources":["../../../src/core/isg/hash.ts"],"names":[],"mappings":"AAgCA;;GAEG;AACH,wBAAgB,kBAAkB,IAAI,IAAI,CAEzC;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAmBhG;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CA8B9E;AAED;;;;;;;;GAQG;AACH,wBAAsB,qBAAqB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAG7E;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,iBAAiB,CAC/B,WAAW,EAAE,MAAM,EACnB,UAAU,EAAE,MAAM,EAAE,EACpB,gBAAgB,CAAC,EAAE,MAAM,GACxB,MAAM,CASR;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,OAAO,EAAE,GAAG,MAAM,CA+CnE"}
|
package/dist/core/isg/hash.js
CHANGED
|
@@ -52,6 +52,9 @@ export function computeContentHash(content, frontMatter) {
|
|
|
52
52
|
const sortKeys = (obj) => {
|
|
53
53
|
if (obj === null || typeof obj !== 'object')
|
|
54
54
|
return obj;
|
|
55
|
+
// A date has no keys to sort; kept as it is, it serializes as its ISO string, not {}
|
|
56
|
+
if (obj instanceof Date)
|
|
57
|
+
return obj;
|
|
55
58
|
if (Array.isArray(obj))
|
|
56
59
|
return obj.map(sortKeys);
|
|
57
60
|
const sorted = {};
|
|
@@ -144,8 +147,10 @@ export function computeInputsHash(contentHash, depsHashes, renderInputsHash) {
|
|
|
144
147
|
]);
|
|
145
148
|
}
|
|
146
149
|
/**
|
|
147
|
-
* Computes a hash of the navigation tree
|
|
148
|
-
*
|
|
150
|
+
* Computes a hash of the navigation tree.
|
|
151
|
+
* Covers every field of a navigation node (title, url, path, order, publishedAt,
|
|
152
|
+
* isCollection and children), so pages that read the navigation rebuild when any of
|
|
153
|
+
* what they can show changes.
|
|
149
154
|
*
|
|
150
155
|
* @param navigation - The navigation tree to hash
|
|
151
156
|
* @returns SHA-256 hash as a hex string
|
|
@@ -162,9 +167,18 @@ export function computeNavigationHash(navigation) {
|
|
|
162
167
|
title: String(node.title ?? ''),
|
|
163
168
|
url: String(node.url ?? ''),
|
|
164
169
|
};
|
|
170
|
+
if (typeof node.path === 'string') {
|
|
171
|
+
normalized.path = node.path;
|
|
172
|
+
}
|
|
165
173
|
if (node.order !== undefined && typeof node.order === 'number') {
|
|
166
174
|
normalized.order = node.order;
|
|
167
175
|
}
|
|
176
|
+
if (node.publishedAt instanceof Date && !Number.isNaN(node.publishedAt.getTime())) {
|
|
177
|
+
normalized.publishedAt = node.publishedAt.toISOString();
|
|
178
|
+
}
|
|
179
|
+
if (typeof node.isCollection === 'boolean') {
|
|
180
|
+
normalized.isCollection = node.isCollection;
|
|
181
|
+
}
|
|
168
182
|
if (node.children && Array.isArray(node.children) && node.children.length > 0) {
|
|
169
183
|
normalized.children = node.children.map((child) => normalizeNavNode(child));
|
|
170
184
|
}
|