@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,342 @@
1
+ import { basename } from 'node:path';
2
+ import { getCollectionPathForPage, getFirstPaginationPage, isCollectionIndexPage, isVirtualPage, } from './utils/index.js';
3
+ import { computeContentHash } from './isg/hash.js';
4
+ import { MARKDOWN_EXTENSION } from '../constants.js';
5
+ /**
6
+ * Front matter fields of the collection summary when `collections.summaryFields` is not set.
7
+ */
8
+ export const DEFAULT_SUMMARY_FIELDS = ['title', 'description', 'tags', 'draft'];
9
+ /**
10
+ * Collections per page list, computed at the first lookup for an array and reused after that.
11
+ * Like the directory prefix index in `isCollectionIndexPage`, the result is a snapshot: in a
12
+ * build the first lookup happens in the render loop, after the `beforeAll` hook.
13
+ */
14
+ const collectionCache = new WeakMap();
15
+ /**
16
+ * Returns the collections of a page list, computing them once per array.
17
+ *
18
+ * @param allPages - All pages of the build
19
+ * @returns The collections by index page URL
20
+ */
21
+ export function getCollections(allPages) {
22
+ const cached = collectionCache.get(allPages);
23
+ if (cached) {
24
+ return cached;
25
+ }
26
+ const index = buildCollections(allPages);
27
+ collectionCache.set(allPages, index);
28
+ return index;
29
+ }
30
+ /**
31
+ * Computes every collection of a page list in one pass.
32
+ *
33
+ * A collection belongs to a collection index page (see `isCollectionIndexPage`), or to a page
34
+ * loaded from an `index.md`, whose collection is empty while its folder holds no pages. The
35
+ * root collection holds the top-level pages; any other collection holds every page below its
36
+ * folder, at any depth. The index page itself, generated pages and pagination pages are
37
+ * never members.
38
+ *
39
+ * @param allPages - All pages of the build
40
+ * @returns The collections by index page URL
41
+ */
42
+ export function buildCollections(allPages) {
43
+ const pages = allPages.filter((page) => !isVirtualPage(page));
44
+ const members = new Map();
45
+ for (const page of pages) {
46
+ if (!members.has(page.url) &&
47
+ (isFolderIndexPage(page) || isCollectionIndexPage(page, allPages))) {
48
+ members.set(page.url, []);
49
+ }
50
+ }
51
+ for (const page of pages) {
52
+ for (const path of collectionsContaining(page.url, (path) => members.has(path))) {
53
+ members.get(path).push(page);
54
+ }
55
+ }
56
+ const byPath = new Map();
57
+ for (const [path, collectionPages] of members) {
58
+ byPath.set(path, createCollectionData(path, collectionPages));
59
+ }
60
+ return { byPath };
61
+ }
62
+ /**
63
+ * Whether a page was loaded from a folder's `index.md`. The folder is a collection even before
64
+ * it holds any pages, so an empty blog lists no posts instead of reading another collection.
65
+ */
66
+ function isFolderIndexPage(page) {
67
+ return basename(page.sourcePath, MARKDOWN_EXTENSION) === 'index';
68
+ }
69
+ /**
70
+ * Lists the collections a URL belongs to, outermost first.
71
+ *
72
+ * @param url - A page URL
73
+ * @param isCollection - Whether a URL is a collection index page
74
+ * @returns Index page URLs
75
+ */
76
+ function collectionsContaining(url, isCollection) {
77
+ const found = [];
78
+ // Every folder the URL lives in, from the outermost: /blog/2024/recap is in /blog/ and /blog/2024/
79
+ for (let i = url.indexOf('/', 1); i !== -1; i = url.indexOf('/', i + 1)) {
80
+ const folder = url.slice(0, i + 1);
81
+ // A folder's index page is usually /blog, or /blog/ with a trailing-slash permalink
82
+ for (const path of [folder.slice(0, -1), folder]) {
83
+ if (path !== url && isCollection(path)) {
84
+ found.push(path);
85
+ }
86
+ }
87
+ }
88
+ // The root collection is shallow: only pages whose parent is the root belong to it
89
+ if (url !== '/' && isCollection('/') && getCollectionPathForPage(url) === '/') {
90
+ found.push('/');
91
+ }
92
+ return found;
93
+ }
94
+ /**
95
+ * Builds the collection data of one index page from its members.
96
+ */
97
+ function createCollectionData(collectionPath, collectionPages) {
98
+ // Direct children are one level below the index page
99
+ const children = collectionPages.filter((page) => {
100
+ const relativePath = page.url.substring(collectionPath.length);
101
+ const cleanPath = relativePath.startsWith('/') ? relativePath.substring(1) : relativePath;
102
+ return !cleanPath.includes('/');
103
+ });
104
+ return {
105
+ pages: collectionPages,
106
+ children,
107
+ recentPages: sortPagesByDate([...collectionPages]),
108
+ pagesByTag: groupPagesByTags(collectionPages),
109
+ metadata: {
110
+ totalPages: collectionPages.length,
111
+ hasChildren: children.length > 0,
112
+ collectionPath,
113
+ collectionName: collectionPath === '/' ? 'Home' : basename(collectionPath),
114
+ },
115
+ };
116
+ }
117
+ /**
118
+ * Groups pages by their tags.
119
+ *
120
+ * @param pages - Pages to group
121
+ * @returns Object mapping tag names to arrays of pages
122
+ */
123
+ function groupPagesByTags(pages) {
124
+ // No prototype, so tags such as `constructor` or `__proto__` are keys like any other
125
+ const pagesByTag = Object.create(null);
126
+ for (const page of pages) {
127
+ const tags = page.frontMatter.tags;
128
+ if (Array.isArray(tags)) {
129
+ for (const tag of tags) {
130
+ if (typeof tag === 'string') {
131
+ (pagesByTag[tag] ??= []).push(page);
132
+ }
133
+ }
134
+ }
135
+ }
136
+ return pagesByTag;
137
+ }
138
+ /**
139
+ * Sorts pages by publishedAt date (most recent first), falling back to alphabetical by title.
140
+ *
141
+ * @param pages - Pages to sort, in place
142
+ * @returns The sorted array
143
+ */
144
+ export function sortPagesByDate(pages) {
145
+ return pages.sort((a, b) => {
146
+ // Sort by publishedAt (newest first)
147
+ if (a.publishedAt && b.publishedAt) {
148
+ return b.publishedAt.getTime() - a.publishedAt.getTime();
149
+ }
150
+ if (a.publishedAt && !b.publishedAt)
151
+ return -1;
152
+ if (!a.publishedAt && b.publishedAt)
153
+ return 1;
154
+ // Fallback to title
155
+ const titleA = a.frontMatter.title || basename(a.url) || 'Untitled';
156
+ const titleB = b.frontMatter.title || basename(b.url) || 'Untitled';
157
+ return titleA.localeCompare(titleB);
158
+ });
159
+ }
160
+ /**
161
+ * Finds the collection a `collection` front matter value names. Accepts the index page URL
162
+ * with or without a leading or trailing slash: `posts`, `/posts` and `/posts/` all name the
163
+ * collection of `site/posts/index.md`.
164
+ *
165
+ * @param index - The collections of the build
166
+ * @param value - The front matter value
167
+ * @returns The index page URL, or undefined when the value names no collection
168
+ */
169
+ export function findCollectionPath(index, value) {
170
+ if (typeof value !== 'string' || !value.trim()) {
171
+ return undefined;
172
+ }
173
+ const trimmed = value.trim();
174
+ const url = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
175
+ if (index.byPath.has(url)) {
176
+ return url;
177
+ }
178
+ if (url === '/') {
179
+ return undefined;
180
+ }
181
+ const alternate = url.endsWith('/') ? url.slice(0, -1) : `${url}/`;
182
+ return index.byPath.has(alternate) ? alternate : undefined;
183
+ }
184
+ /**
185
+ * Finds the collection a page reads as `stati.collection`:
186
+ *
187
+ * 1. The collection its `collection` front matter field names, when that collection exists.
188
+ * 2. Its own collection, when the page is a collection index page or an `index.md`, even
189
+ * when that collection is empty.
190
+ * 3. Otherwise the nearest collection with pages that the page belongs to: `/blog/post` reads
191
+ * `/blog`, and a top-level page reads the root collection.
192
+ *
193
+ * Pagination pages after the first read the collection of the first page. Generated pages
194
+ * read only the collection their template names; expansion sets it in their front matter.
195
+ *
196
+ * @param page - The page
197
+ * @param allPages - All pages of the build
198
+ * @returns The index page URL of the collection, or undefined when the page has none
199
+ */
200
+ export function resolveCollectionPath(page, allPages) {
201
+ const index = getCollections(allPages);
202
+ const named = findCollectionPath(index, page.frontMatter.collection);
203
+ if (named !== undefined) {
204
+ return named;
205
+ }
206
+ if (page.generated) {
207
+ return undefined;
208
+ }
209
+ if ((page.pagination?.pageNumber ?? 1) > 1) {
210
+ const first = getFirstPaginationPage(page, allPages);
211
+ return first && !isVirtualPage(first) ? resolveCollectionPath(first, allPages) : undefined;
212
+ }
213
+ if (index.byPath.has(page.url)) {
214
+ return page.url;
215
+ }
216
+ // A page always has pages in the collections it belongs to; only a [param].md template can
217
+ // sit in an empty one, and site/blog/tags/[tag].md reads /blog even with a tags/index.md
218
+ return collectionsContaining(page.url, (path) => (index.byPath.get(path)?.pages.length ?? 0) > 0).at(-1);
219
+ }
220
+ /**
221
+ * Returns the front matter fields of the collection summary.
222
+ *
223
+ * @param config - Stati configuration
224
+ * @returns `collections.summaryFields`, or the default fields
225
+ */
226
+ export function resolveSummaryFields(config) {
227
+ const fields = config.collections?.summaryFields;
228
+ if (!Array.isArray(fields)) {
229
+ return DEFAULT_SUMMARY_FIELDS;
230
+ }
231
+ return fields.filter((field) => typeof field === 'string' && field !== '');
232
+ }
233
+ /**
234
+ * Hashes what a listing shows of a list of pages: each page's URL, publish date and summary
235
+ * fields, in list order. The Markdown body only counts when `fields` includes `content`.
236
+ *
237
+ * @param pages - The listed pages
238
+ * @param fields - Front matter fields to include
239
+ * @returns A `sha256-…` hash
240
+ */
241
+ export function computePagesSummaryHash(pages, fields) {
242
+ return computeContentHash('', {
243
+ fields: [...fields],
244
+ pages: pages.map((page) => [
245
+ page.url,
246
+ toIsoDate(page.publishedAt),
247
+ ...fields.map((field) => field === 'content' ? page.content : (page.frontMatter[field] ?? null)),
248
+ ]),
249
+ });
250
+ }
251
+ function toIsoDate(date) {
252
+ return date instanceof Date && !Number.isNaN(date.getTime()) ? date.toISOString() : null;
253
+ }
254
+ /**
255
+ * Creates the summary hasher of a build. Call it after the `beforeAll` hook, which can change
256
+ * the front matter the summaries include.
257
+ *
258
+ * @param allPages - All pages of the build
259
+ * @param fields - Summary fields
260
+ * @returns The hasher
261
+ */
262
+ export function createSummaryHasher(allPages, fields) {
263
+ const memo = new Map();
264
+ return {
265
+ collection(path) {
266
+ let hash = memo.get(path);
267
+ if (hash === undefined) {
268
+ const data = getCollections(allPages).byPath.get(path);
269
+ hash = data ? computePagesSummaryHash(data.pages, fields) : 'none';
270
+ memo.set(path, hash);
271
+ }
272
+ return hash;
273
+ },
274
+ pages: (pages) => computePagesSummaryHash(pages, fields),
275
+ };
276
+ }
277
+ const COLLECTION_KEYS = ['pages', 'children', 'recentPages', 'pagesByTag', 'metadata'];
278
+ /**
279
+ * Wraps collection data for one render. Reading any of its properties sets `reads.collection`,
280
+ * so the cache knows the page depends on the collection. Arrays and objects are copied on
281
+ * first read, so a template that sorts or changes them cannot affect other pages, which share
282
+ * the collection data within a build.
283
+ *
284
+ * The properties are accessors on a plain object, not a Proxy, so spreading the template
285
+ * context (as callable partials do) passes the view along without reading it.
286
+ *
287
+ * @param data - The shared collection data
288
+ * @param reads - The render's read flags
289
+ * @returns The view for `stati.collection`
290
+ */
291
+ export function createCollectionView(data, reads) {
292
+ const view = {};
293
+ for (const key of COLLECTION_KEYS) {
294
+ let value;
295
+ let copied = false;
296
+ Object.defineProperty(view, key, {
297
+ enumerable: true,
298
+ configurable: true,
299
+ get() {
300
+ reads.collection = true;
301
+ if (!copied) {
302
+ value = copyCollectionValue(data[key]);
303
+ copied = true;
304
+ }
305
+ return value;
306
+ },
307
+ set(next) {
308
+ reads.collection = true;
309
+ value = next;
310
+ copied = true;
311
+ },
312
+ });
313
+ }
314
+ return view;
315
+ }
316
+ function copyCollectionValue(value) {
317
+ if (Array.isArray(value)) {
318
+ return [...value];
319
+ }
320
+ const copy = Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, Array.isArray(entry) ? [...entry] : entry]));
321
+ // pagesByTag has no prototype, and neither has its copy, so any tag stays a plain key
322
+ return Object.setPrototypeOf(copy, Object.getPrototypeOf(value));
323
+ }
324
+ /**
325
+ * Copies a page's pagination data for `stati.pagination`, so a template that changes the
326
+ * lists cannot change what the cache hashes.
327
+ *
328
+ * @param data - The page's pagination data
329
+ * @returns A copy with its own arrays
330
+ */
331
+ export function copyPaginationData(data) {
332
+ return { ...data, items: [...data.items], urls: [...data.urls] };
333
+ }
334
+ /**
335
+ * Copies a page's generated data for `stati.generated`.
336
+ *
337
+ * @param data - The page's generated data
338
+ * @returns A copy with its own items array
339
+ */
340
+ export function copyGeneratedData(data) {
341
+ return { ...data, items: [...data.items] };
342
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"content.d.ts","sourceRoot":"","sources":["../../src/core/content.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAGhE;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,WAAW,EACnB,aAAa,CAAC,EAAE,OAAO,GACtB,OAAO,CAAC,SAAS,EAAE,CAAC,CAgDtB"}
1
+ {"version":3,"file":"content.d.ts","sourceRoot":"","sources":["../../src/core/content.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAGhE;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,WAAW,CAC/B,MAAM,EAAE,WAAW,EACnB,aAAa,CAAC,EAAE,OAAO,GACtB,OAAO,CAAC,SAAS,EAAE,CAAC,CAiDtB"}
@@ -39,7 +39,8 @@ export async function loadContent(config, includeDrafts) {
39
39
  if (frontMatter.draft && !includeDrafts) {
40
40
  continue;
41
41
  }
42
- const relativePath = relative(contentDir, file);
42
+ // POSIX separators, so a page two folders deep gets /a/b/page on Windows too
43
+ const relativePath = relative(contentDir, file).replace(/\\/g, '/');
43
44
  const slug = computeSlug(relativePath);
44
45
  const url = computeUrl(slug, frontMatter);
45
46
  const page = {
@@ -1 +1 @@
1
- {"version":3,"file":"dev.d.ts","sourceRoot":"","sources":["../../src/core/dev.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAe,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAwC7D,MAAM,WAAW,gBAAgB;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,SAAS;IACxB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,GAAG,EAAE,MAAM,CAAC;CACb;AAscD,wBAAsB,eAAe,CAAC,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,SAAS,CAAC,CA4hBxF"}
1
+ {"version":3,"file":"dev.d.ts","sourceRoot":"","sources":["../../src/core/dev.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAe,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAiC7D,MAAM,WAAW,gBAAgB;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,SAAS;IACxB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,GAAG,EAAE,MAAM,CAAC;CACb;AAmZD,wBAAsB,eAAe,CAAC,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,SAAS,CAAC,CA4hBxF"}
package/dist/core/dev.js CHANGED
@@ -8,9 +8,7 @@ import { invalidate } from './invalidate.js';
8
8
  import { loadConfig } from '../config/loader.js';
9
9
  import { clearTemplateEngineCache } from './templates.js';
10
10
  import { clearMarkdownProcessorCache } from './markdown.js';
11
- import { loadCacheManifest, saveCacheManifest, computeNavigationHash, DevServerLockManager, } from './isg/index.js';
12
- import { loadContent } from './content.js';
13
- import { buildNavigation } from './navigation.js';
11
+ import { loadCacheManifest, saveCacheManifest, DevServerLockManager } from './isg/index.js';
14
12
  import { resolveDevPaths, resolveCacheDir, resolvePrettyUrl, createErrorOverlay, parseErrorDetails, TemplateError, createFallbackLogger, mergeServerOptions, createTypeScriptWatcher, isEnabledEnvFlag, refreshCssOutputWatcher, normalizePathForComparison, isPathWithinDirectory, assertPortAvailable, toPortUnavailableError, } from './utils/index.js';
15
13
  import { setEnv, getEnv } from '../env.js';
16
14
  import { DEFAULT_DEV_PORT, DEFAULT_DEV_HOST, TEMPLATE_EXTENSION, DEFAULT_OUT_DIR, } from '../constants.js';
@@ -318,67 +316,18 @@ async function handleTemplateChange(templatePath, configPath, logger) {
318
316
  }
319
317
  }
320
318
  /**
321
- * Handles markdown file changes by comparing navigation hashes.
322
- * Only performs a full rebuild if navigation structure actually changed.
323
- * Navigation changes come from frontmatter modifications (title, order, description).
324
- * Content-only changes use incremental rebuilds.
319
+ * Handles markdown file changes (added, edited or deleted pages) with an incremental build.
320
+ * ISG decides what re-renders: the changed page, the pages that list it through a collection,
321
+ * and the pages that read the navigation when the change altered it. A deleted page's output
322
+ * is removed by the build.
325
323
  */
326
324
  async function handleMarkdownChange(_markdownPath, configPath, logger) {
327
- const cacheDir = resolveCacheDir();
328
- try {
329
- // Load existing cache manifest
330
- const cacheManifest = await loadCacheManifest(cacheDir);
331
- if (!cacheManifest || !cacheManifest.navigationHash) {
332
- // No cache or no navigation hash exists, perform full rebuild
333
- await build({
334
- logger,
335
- force: false,
336
- clean: false,
337
- ...(configPath && { configPath }),
338
- });
339
- return;
340
- }
341
- // Load config and content to rebuild navigation tree
342
- const config = await loadConfig(configPath);
343
- const pages = await loadContent(config);
344
- const newNavigation = buildNavigation(pages);
345
- const newNavigationHash = computeNavigationHash(newNavigation);
346
- // Compare navigation hashes
347
- if (newNavigationHash !== cacheManifest.navigationHash) {
348
- // Navigation structure changed - clear cache and force full rebuild
349
- logger.status('Navigation structure changed, performing full rebuild...');
350
- // Force rebuild bypasses ISG cache entirely
351
- await build({
352
- logger,
353
- force: true, // Force rebuild to bypass cache
354
- clean: false,
355
- ...(configPath && { configPath }),
356
- });
357
- }
358
- else {
359
- // Navigation unchanged - use incremental rebuild for content changes
360
- await build({
361
- logger,
362
- force: false,
363
- clean: false,
364
- ...(configPath && { configPath }),
365
- });
366
- }
367
- }
368
- catch (_error) {
369
- try {
370
- // Fallback to full rebuild
371
- await build({
372
- logger,
373
- force: false,
374
- clean: false,
375
- ...(configPath && { configPath }),
376
- });
377
- }
378
- catch (fallbackError) {
379
- throw fallbackError instanceof Error ? fallbackError : new Error(String(fallbackError));
380
- }
381
- }
325
+ await build({
326
+ logger,
327
+ force: false,
328
+ clean: false,
329
+ ...(configPath && { configPath }),
330
+ });
382
331
  }
383
332
  export async function createDevServer(options = {}) {
384
333
  const logger = options.logger ?? createFallbackLogger();
@@ -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"}