@stati/core 1.28.0 → 1.29.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.
Files changed (63) hide show
  1. package/README.md +6 -1
  2. package/dist/core/build.d.ts.map +1 -1
  3. package/dist/core/build.js +122 -34
  4. package/dist/core/collections.d.ts +139 -0
  5. package/dist/core/collections.d.ts.map +1 -0
  6. package/dist/core/collections.js +342 -0
  7. package/dist/core/content.d.ts.map +1 -1
  8. package/dist/core/content.js +2 -1
  9. package/dist/core/dev.d.ts.map +1 -1
  10. package/dist/core/dev.js +11 -62
  11. package/dist/core/expand.d.ts +86 -0
  12. package/dist/core/expand.d.ts.map +1 -0
  13. package/dist/core/expand.js +484 -0
  14. package/dist/core/isg/builder.d.ts +15 -2
  15. package/dist/core/isg/builder.d.ts.map +1 -1
  16. package/dist/core/isg/builder.js +44 -6
  17. package/dist/core/isg/hash.d.ts +4 -2
  18. package/dist/core/isg/hash.d.ts.map +1 -1
  19. package/dist/core/isg/hash.js +16 -2
  20. package/dist/core/isg/index.d.ts +2 -1
  21. package/dist/core/isg/index.d.ts.map +1 -1
  22. package/dist/core/isg/index.js +3 -1
  23. package/dist/core/isg/manifest.js +6 -0
  24. package/dist/core/isg/prune.d.ts +32 -0
  25. package/dist/core/isg/prune.d.ts.map +1 -0
  26. package/dist/core/isg/prune.js +113 -0
  27. package/dist/core/navigation.d.ts +4 -2
  28. package/dist/core/navigation.d.ts.map +1 -1
  29. package/dist/core/navigation.js +17 -2
  30. package/dist/core/templates.d.ts +3 -0
  31. package/dist/core/templates.d.ts.map +1 -1
  32. package/dist/core/templates.js +67 -127
  33. package/dist/core/utils/fs.utils.d.ts +8 -0
  34. package/dist/core/utils/fs.utils.d.ts.map +1 -1
  35. package/dist/core/utils/fs.utils.js +13 -1
  36. package/dist/core/utils/index.d.ts +3 -3
  37. package/dist/core/utils/index.d.ts.map +1 -1
  38. package/dist/core/utils/index.js +3 -3
  39. package/dist/core/utils/paths.utils.d.ts +15 -0
  40. package/dist/core/utils/paths.utils.d.ts.map +1 -1
  41. package/dist/core/utils/paths.utils.js +20 -0
  42. package/dist/core/utils/template-discovery.utils.d.ts +25 -1
  43. package/dist/core/utils/template-discovery.utils.d.ts.map +1 -1
  44. package/dist/core/utils/template-discovery.utils.js +61 -11
  45. package/dist/index.d.ts +1 -1
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/rss/generator.d.ts.map +1 -1
  48. package/dist/rss/generator.js +5 -2
  49. package/dist/search/generator.d.ts +9 -0
  50. package/dist/search/generator.d.ts.map +1 -1
  51. package/dist/search/generator.js +20 -0
  52. package/dist/search/index.d.ts +1 -1
  53. package/dist/search/index.d.ts.map +1 -1
  54. package/dist/search/index.js +1 -1
  55. package/dist/types/config.d.ts +55 -0
  56. package/dist/types/config.d.ts.map +1 -1
  57. package/dist/types/content.d.ts +129 -1
  58. package/dist/types/content.d.ts.map +1 -1
  59. package/dist/types/index.d.ts +2 -2
  60. package/dist/types/index.d.ts.map +1 -1
  61. package/dist/types/isg.d.ts +12 -0
  62. package/dist/types/isg.d.ts.map +1 -1
  63. 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
- * Files a render discovered: the source images a page displays (`assetDeps`) and the
4
- * generated images it references (`images`).
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;AAgF/E;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,oFAAoF;IACpF,SAAS,EAAE,SAAS,MAAM,EAAE,CAAC;IAC7B,kFAAkF;IAClF,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC;CAC3B;AA0BD;;;;;;;;;;;;;;;;;;;;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,CAsFrB;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,CA+DrB"}
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"}
@@ -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
- let tags = [];
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
  }
@@ -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 structure.
65
- * Used to detect when navigation has changed (title, url, order, children structure).
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,CAiBhG;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;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,OAAO,EAAE,GAAG,MAAM,CAqCnE"}
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"}
@@ -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 structure.
148
- * Used to detect when navigation has changed (title, url, order, children structure).
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
  }