@stati/core 1.27.2 → 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 (136) hide show
  1. package/README.md +6 -1
  2. package/dist/config/errors.d.ts +10 -0
  3. package/dist/config/errors.d.ts.map +1 -0
  4. package/dist/config/errors.js +10 -0
  5. package/dist/config/loader.d.ts.map +1 -1
  6. package/dist/config/loader.js +15 -2
  7. package/dist/constants.d.ts +26 -0
  8. package/dist/constants.d.ts.map +1 -1
  9. package/dist/constants.js +23 -0
  10. package/dist/core/build.d.ts.map +1 -1
  11. package/dist/core/build.js +201 -41
  12. package/dist/core/collections.d.ts +139 -0
  13. package/dist/core/collections.d.ts.map +1 -0
  14. package/dist/core/collections.js +342 -0
  15. package/dist/core/content.d.ts.map +1 -1
  16. package/dist/core/content.js +2 -1
  17. package/dist/core/dev.d.ts.map +1 -1
  18. package/dist/core/dev.js +11 -62
  19. package/dist/core/expand.d.ts +86 -0
  20. package/dist/core/expand.d.ts.map +1 -0
  21. package/dist/core/expand.js +484 -0
  22. package/dist/core/isg/builder.d.ts +27 -2
  23. package/dist/core/isg/builder.d.ts.map +1 -1
  24. package/dist/core/isg/builder.js +83 -11
  25. package/dist/core/isg/deps.d.ts +9 -0
  26. package/dist/core/isg/deps.d.ts.map +1 -1
  27. package/dist/core/isg/deps.js +30 -1
  28. package/dist/core/isg/hash.d.ts +14 -2
  29. package/dist/core/isg/hash.d.ts.map +1 -1
  30. package/dist/core/isg/hash.js +30 -2
  31. package/dist/core/isg/index.d.ts +4 -2
  32. package/dist/core/isg/index.d.ts.map +1 -1
  33. package/dist/core/isg/index.js +4 -2
  34. package/dist/core/isg/manifest.js +14 -0
  35. package/dist/core/isg/prune.d.ts +32 -0
  36. package/dist/core/isg/prune.d.ts.map +1 -0
  37. package/dist/core/isg/prune.js +113 -0
  38. package/dist/core/markdown.d.ts +10 -1
  39. package/dist/core/markdown.d.ts.map +1 -1
  40. package/dist/core/markdown.js +13 -3
  41. package/dist/core/navigation.d.ts +4 -2
  42. package/dist/core/navigation.d.ts.map +1 -1
  43. package/dist/core/navigation.js +17 -2
  44. package/dist/core/templates.d.ts +15 -1
  45. package/dist/core/templates.d.ts.map +1 -1
  46. package/dist/core/templates.js +121 -126
  47. package/dist/core/utils/fs.utils.d.ts +29 -1
  48. package/dist/core/utils/fs.utils.d.ts.map +1 -1
  49. package/dist/core/utils/fs.utils.js +74 -1
  50. package/dist/core/utils/index.d.ts +3 -3
  51. package/dist/core/utils/index.d.ts.map +1 -1
  52. package/dist/core/utils/index.js +3 -3
  53. package/dist/core/utils/paths.utils.d.ts +15 -0
  54. package/dist/core/utils/paths.utils.d.ts.map +1 -1
  55. package/dist/core/utils/paths.utils.js +20 -0
  56. package/dist/core/utils/template-discovery.utils.d.ts +25 -1
  57. package/dist/core/utils/template-discovery.utils.d.ts.map +1 -1
  58. package/dist/core/utils/template-discovery.utils.js +61 -11
  59. package/dist/images/config.d.ts +52 -0
  60. package/dist/images/config.d.ts.map +1 -0
  61. package/dist/images/config.js +200 -0
  62. package/dist/images/index.d.ts +15 -0
  63. package/dist/images/index.d.ts.map +1 -0
  64. package/dist/images/index.js +11 -0
  65. package/dist/images/manifest.d.ts +36 -0
  66. package/dist/images/manifest.d.ts.map +1 -0
  67. package/dist/images/manifest.js +69 -0
  68. package/dist/images/markdown-rule.d.ts +26 -0
  69. package/dist/images/markdown-rule.d.ts.map +1 -0
  70. package/dist/images/markdown-rule.js +60 -0
  71. package/dist/images/markup.d.ts +39 -0
  72. package/dist/images/markup.d.ts.map +1 -0
  73. package/dist/images/markup.js +72 -0
  74. package/dist/images/og.d.ts +52 -0
  75. package/dist/images/og.d.ts.map +1 -0
  76. package/dist/images/og.js +165 -0
  77. package/dist/images/plan.d.ts +101 -0
  78. package/dist/images/plan.d.ts.map +1 -0
  79. package/dist/images/plan.js +128 -0
  80. package/dist/images/provider.d.ts +35 -0
  81. package/dist/images/provider.d.ts.map +1 -0
  82. package/dist/images/provider.js +70 -0
  83. package/dist/images/resolve.d.ts +45 -0
  84. package/dist/images/resolve.d.ts.map +1 -0
  85. package/dist/images/resolve.js +51 -0
  86. package/dist/images/runtime.d.ts +258 -0
  87. package/dist/images/runtime.d.ts.map +1 -0
  88. package/dist/images/runtime.js +659 -0
  89. package/dist/images/sources.d.ts +47 -0
  90. package/dist/images/sources.d.ts.map +1 -0
  91. package/dist/images/sources.js +97 -0
  92. package/dist/images/utils/image-probe.utils.d.ts +30 -0
  93. package/dist/images/utils/image-probe.utils.d.ts.map +1 -0
  94. package/dist/images/utils/image-probe.utils.js +307 -0
  95. package/dist/images/utils/index.d.ts +7 -0
  96. package/dist/images/utils/index.d.ts.map +1 -0
  97. package/dist/images/utils/index.js +5 -0
  98. package/dist/index.d.ts +2 -1
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/metrics/noop.d.ts.map +1 -1
  101. package/dist/metrics/noop.js +3 -0
  102. package/dist/metrics/recorder.d.ts.map +1 -1
  103. package/dist/metrics/recorder.js +3 -0
  104. package/dist/metrics/types.d.ts +8 -0
  105. package/dist/metrics/types.d.ts.map +1 -1
  106. package/dist/rss/generator.d.ts.map +1 -1
  107. package/dist/rss/generator.js +5 -2
  108. package/dist/search/generator.d.ts +9 -0
  109. package/dist/search/generator.d.ts.map +1 -1
  110. package/dist/search/generator.js +20 -0
  111. package/dist/search/index.d.ts +1 -1
  112. package/dist/search/index.d.ts.map +1 -1
  113. package/dist/search/index.js +1 -1
  114. package/dist/seo/auto-inject.d.ts +3 -0
  115. package/dist/seo/auto-inject.d.ts.map +1 -1
  116. package/dist/seo/auto-inject.js +1 -0
  117. package/dist/seo/generator.d.ts +24 -0
  118. package/dist/seo/generator.d.ts.map +1 -1
  119. package/dist/seo/generator.js +65 -1
  120. package/dist/seo/index.d.ts +1 -1
  121. package/dist/seo/index.d.ts.map +1 -1
  122. package/dist/seo/index.js +1 -1
  123. package/dist/types/config.d.ts +60 -0
  124. package/dist/types/config.d.ts.map +1 -1
  125. package/dist/types/content.d.ts +150 -3
  126. package/dist/types/content.d.ts.map +1 -1
  127. package/dist/types/images.d.ts +185 -0
  128. package/dist/types/images.d.ts.map +1 -0
  129. package/dist/types/images.js +8 -0
  130. package/dist/types/index.d.ts +3 -2
  131. package/dist/types/index.d.ts.map +1 -1
  132. package/dist/types/isg.d.ts +19 -0
  133. package/dist/types/isg.d.ts.map +1 -1
  134. package/dist/types/seo.d.ts +9 -0
  135. package/dist/types/seo.d.ts.map +1 -1
  136. package/package.json +1 -1
@@ -0,0 +1,86 @@
1
+ import type { FrontMatter, Logger, PageModel, PaginationData, StatiConfig } from '../types/index.js';
2
+ /** URL segment before the page number when `paginate.path` is not set */
3
+ export declare const DEFAULT_PAGINATION_PATH = "page";
4
+ /**
5
+ * Resolved pagination settings.
6
+ */
7
+ interface PaginationSettings {
8
+ size: number;
9
+ path: string;
10
+ }
11
+ /**
12
+ * Returns the parameter name of a `[param].md` template, or undefined for other pages.
13
+ *
14
+ * @param page - A loaded page
15
+ * @returns The name between the brackets
16
+ */
17
+ export declare function getTemplateParam(page: PageModel): string | undefined;
18
+ /**
19
+ * Replaces `{param}` with the label in a string. `{{param}}` produces the literal `{param}`,
20
+ * and placeholders of other names are left as they are.
21
+ *
22
+ * @param text - The string to fill in
23
+ * @param param - The template's parameter name, such as `tag`
24
+ * @param label - The value, such as `CSS`
25
+ * @returns The string with its placeholders replaced
26
+ *
27
+ * @example
28
+ * ```typescript
29
+ * replacePlaceholders('Posts tagged {tag}', 'tag', 'CSS'); // 'Posts tagged CSS'
30
+ * replacePlaceholders('Use {{tag}} in titles', 'tag', 'CSS'); // 'Use {tag} in titles'
31
+ * replacePlaceholders('{author} on {tag}', 'tag', 'CSS'); // '{author} on CSS'
32
+ * ```
33
+ */
34
+ export declare function replacePlaceholders(text: string, param: string, label: string): string;
35
+ /**
36
+ * Copies a template's front matter for one generated page, with `{param}` placeholders filled
37
+ * in. `generate` and `permalink` are copied as they are: they configure the template, and
38
+ * `permalink` takes the key through `[param]` instead.
39
+ *
40
+ * @param frontMatter - The template's front matter
41
+ * @param param - The template's parameter name
42
+ * @param label - The value for the page
43
+ * @returns The page's front matter
44
+ */
45
+ export declare function substituteFrontMatter(frontMatter: FrontMatter, param: string, label: string): FrontMatter;
46
+ /**
47
+ * URL of page `pageNumber` of a listing whose first page is `baseUrl`. Later pages follow the
48
+ * first page's trailing-slash style: `/blog` → `/blog/page/2`, `/blog/` → `/blog/page/2/`,
49
+ * `/` → `/page/2`.
50
+ *
51
+ * @param baseUrl - URL of the first page
52
+ * @param path - URL segment before the page number
53
+ * @param pageNumber - Page number, starting at 1
54
+ * @returns The page URL
55
+ */
56
+ export declare function getPaginationUrl(baseUrl: string, path: string, pageNumber: number): string;
57
+ /**
58
+ * Builds the pagination data of every page of a listing.
59
+ *
60
+ * @param baseUrl - URL of the first page
61
+ * @param items - Everything the listing shows, in order
62
+ * @param settings - Page size and URL segment
63
+ * @returns One entry per page; there is always at least one
64
+ */
65
+ export declare function paginateItems(baseUrl: string, items: readonly PageModel[], settings: PaginationSettings): PaginationData[];
66
+ /**
67
+ * Turns the pages loaded from Markdown into the pages of a build:
68
+ *
69
+ * - Each `[param].md` template is removed and replaced by one page per key: per value of
70
+ * `generate.from` in its collection, and per `collections.generate` entry that names it.
71
+ * A template that produces no keys produces no output.
72
+ * - Every page with `paginate`, and every generated page whose template has
73
+ * `generate.paginate`, gets one more page per additional slice of its items.
74
+ *
75
+ * Generated and pagination pages are virtual: they are never collection members and are left
76
+ * out of navigation, search and RSS unless they opt in. A virtual page whose URL or output
77
+ * file is already taken is skipped with a warning.
78
+ *
79
+ * @param loaded - Pages loaded from Markdown files
80
+ * @param config - Stati configuration
81
+ * @param logger - Logger for warnings
82
+ * @returns The pages to build: loaded pages without templates, then virtual pages
83
+ */
84
+ export declare function expandPages(loaded: PageModel[], config: StatiConfig, logger: Logger): Promise<PageModel[]>;
85
+ export {};
86
+ //# sourceMappingURL=expand.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"expand.d.ts","sourceRoot":"","sources":["../../src/core/expand.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,WAAW,EAGX,MAAM,EACN,SAAS,EACT,cAAc,EACd,WAAW,EACZ,MAAM,mBAAmB,CAAC;AAmB3B,yEAAyE;AACzE,eAAO,MAAM,uBAAuB,SAAS,CAAC;AAE9C;;GAEG;AACH,UAAU,kBAAkB;IAC1B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;CACd;AAWD;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,SAAS,GAAG,MAAM,GAAG,SAAS,CAEpE;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAKtF;AAwBD;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CACnC,WAAW,EAAE,WAAW,EACxB,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,GACZ,WAAW,CASb;AAED;;;;;;;;;GASG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAU1F;AAED;;;;;;;GAOG;AACH,wBAAgB,aAAa,CAC3B,OAAO,EAAE,MAAM,EACf,KAAK,EAAE,SAAS,SAAS,EAAE,EAC3B,QAAQ,EAAE,kBAAkB,GAC3B,cAAc,EAAE,CAclB;AA6FD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,SAAS,EAAE,EACnB,MAAM,EAAE,WAAW,EACnB,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,SAAS,EAAE,CAAC,CA+NtB"}
@@ -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,4 +1,27 @@
1
1
  import type { PageModel, CacheEntry, StatiConfig } from '../../types/index.js';
2
+ import type { PageReads } from '../collections.js';
3
+ /**
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`).
6
+ */
7
+ export interface RenderedAssets {
8
+ /** Absolute paths of files whose content affects the page, such as source images */
9
+ assetDeps: readonly string[];
10
+ /** Generated image files the page references, relative to the output directory */
11
+ images: readonly string[];
12
+ /** Whether the render read the collection and the navigation tree */
13
+ reads?: PageReads | undefined;
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;
2
25
  /**
3
26
  * Determines if a page should be rebuilt based on ISG logic.
4
27
  * Checks content changes, dependency changes, TTL expiration, and freeze status.
@@ -29,6 +52,7 @@ export declare function shouldRebuildPage(page: PageModel, entry: CacheEntry | u
29
52
  * @param allPages - All pages in the site, used to resolve the same layout as rendering
30
53
  * @param renderedAt - When the page was rendered
31
54
  * @param renderInputsHash - Optional hash of asset URLs and injection settings
55
+ * @param rendered - Files the render discovered, such as images
32
56
  * @returns New cache entry
33
57
  *
34
58
  * @example
@@ -36,7 +60,7 @@ export declare function shouldRebuildPage(page: PageModel, entry: CacheEntry | u
36
60
  * const entry = await createCacheEntry(page, config, pages, new Date());
37
61
  * ```
38
62
  */
39
- export declare function createCacheEntry(page: PageModel, config: StatiConfig, allPages: PageModel[], renderedAt: Date, renderInputsHash?: string): Promise<CacheEntry>;
63
+ export declare function createCacheEntry(page: PageModel, config: StatiConfig, allPages: PageModel[], renderedAt: Date, renderInputsHash?: string, rendered?: RenderedAssets): Promise<CacheEntry>;
40
64
  /**
41
65
  * Updates an existing cache entry with new information after rebuilding.
42
66
  * In dev mode, this is optimized to reuse the existing deps array since
@@ -49,6 +73,7 @@ export declare function createCacheEntry(page: PageModel, config: StatiConfig, a
49
73
  * @param renderedAt - When the page was rendered
50
74
  * @param fastUpdate - If true, skip expensive dep tracking (dev mode optimization)
51
75
  * @param renderInputsHash - Optional hash of asset URLs and injection settings
76
+ * @param rendered - Files the render discovered, such as images
52
77
  * @returns Updated cache entry
53
78
  *
54
79
  * @example
@@ -56,5 +81,5 @@ export declare function createCacheEntry(page: PageModel, config: StatiConfig, a
56
81
  * const updatedEntry = await updateCacheEntry(existingEntry, page, config, pages, new Date());
57
82
  * ```
58
83
  */
59
- export declare function updateCacheEntry(entry: CacheEntry, page: PageModel, config: StatiConfig, allPages: PageModel[], renderedAt: Date, fastUpdate?: boolean, renderInputsHash?: string): Promise<CacheEntry>;
84
+ export declare function updateCacheEntry(entry: CacheEntry, page: PageModel, config: StatiConfig, allPages: PageModel[], renderedAt: Date, fastUpdate?: boolean, renderInputsHash?: string, rendered?: RenderedAssets): Promise<CacheEntry>;
60
85
  //# sourceMappingURL=builder.d.ts.map
@@ -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;AAqE/E;;;;;;;;;;;;;;;;;;;;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,CAoKlB;AAED;;;;;;;;;;;;;;GAcG;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,GACxB,OAAO,CAAC,UAAU,CAAC,CAqFrB;AAkDD;;;;;;;;;;;;;;;;;;GAkBG;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,GACxB,OAAO,CAAC,UAAU,CAAC,CAmDrB"}
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"}