@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,139 @@
1
+ import type { PageModel, CollectionData, PaginationData, GeneratedPageData, StatiConfig } from '../types/index.js';
2
+ /**
3
+ * Front matter fields of the collection summary when `collections.summaryFields` is not set.
4
+ */
5
+ export declare const DEFAULT_SUMMARY_FIELDS: readonly string[];
6
+ /**
7
+ * The collections of a page list.
8
+ */
9
+ export interface CollectionIndex {
10
+ /** Collection data by the URL of its index page */
11
+ byPath: Map<string, CollectionData>;
12
+ }
13
+ /**
14
+ * What a render read from data shared between pages. Filled in while the page renders.
15
+ */
16
+ export interface PageReads {
17
+ /** The page read `stati.collection` */
18
+ collection: boolean;
19
+ /** The page read the navigation tree (`stati.nav` or `stati.page.navNode`) */
20
+ nav: boolean;
21
+ }
22
+ /**
23
+ * Returns the collections of a page list, computing them once per array.
24
+ *
25
+ * @param allPages - All pages of the build
26
+ * @returns The collections by index page URL
27
+ */
28
+ export declare function getCollections(allPages: PageModel[]): CollectionIndex;
29
+ /**
30
+ * Computes every collection of a page list in one pass.
31
+ *
32
+ * A collection belongs to a collection index page (see `isCollectionIndexPage`), or to a page
33
+ * loaded from an `index.md`, whose collection is empty while its folder holds no pages. The
34
+ * root collection holds the top-level pages; any other collection holds every page below its
35
+ * folder, at any depth. The index page itself, generated pages and pagination pages are
36
+ * never members.
37
+ *
38
+ * @param allPages - All pages of the build
39
+ * @returns The collections by index page URL
40
+ */
41
+ export declare function buildCollections(allPages: PageModel[]): CollectionIndex;
42
+ /**
43
+ * Sorts pages by publishedAt date (most recent first), falling back to alphabetical by title.
44
+ *
45
+ * @param pages - Pages to sort, in place
46
+ * @returns The sorted array
47
+ */
48
+ export declare function sortPagesByDate(pages: PageModel[]): PageModel[];
49
+ /**
50
+ * Finds the collection a `collection` front matter value names. Accepts the index page URL
51
+ * with or without a leading or trailing slash: `posts`, `/posts` and `/posts/` all name the
52
+ * collection of `site/posts/index.md`.
53
+ *
54
+ * @param index - The collections of the build
55
+ * @param value - The front matter value
56
+ * @returns The index page URL, or undefined when the value names no collection
57
+ */
58
+ export declare function findCollectionPath(index: CollectionIndex, value: unknown): string | undefined;
59
+ /**
60
+ * Finds the collection a page reads as `stati.collection`:
61
+ *
62
+ * 1. The collection its `collection` front matter field names, when that collection exists.
63
+ * 2. Its own collection, when the page is a collection index page or an `index.md`, even
64
+ * when that collection is empty.
65
+ * 3. Otherwise the nearest collection with pages that the page belongs to: `/blog/post` reads
66
+ * `/blog`, and a top-level page reads the root collection.
67
+ *
68
+ * Pagination pages after the first read the collection of the first page. Generated pages
69
+ * read only the collection their template names; expansion sets it in their front matter.
70
+ *
71
+ * @param page - The page
72
+ * @param allPages - All pages of the build
73
+ * @returns The index page URL of the collection, or undefined when the page has none
74
+ */
75
+ export declare function resolveCollectionPath(page: PageModel, allPages: PageModel[]): string | undefined;
76
+ /**
77
+ * Returns the front matter fields of the collection summary.
78
+ *
79
+ * @param config - Stati configuration
80
+ * @returns `collections.summaryFields`, or the default fields
81
+ */
82
+ export declare function resolveSummaryFields(config: StatiConfig): readonly string[];
83
+ /**
84
+ * Hashes what a listing shows of a list of pages: each page's URL, publish date and summary
85
+ * fields, in list order. The Markdown body only counts when `fields` includes `content`.
86
+ *
87
+ * @param pages - The listed pages
88
+ * @param fields - Front matter fields to include
89
+ * @returns A `sha256-…` hash
90
+ */
91
+ export declare function computePagesSummaryHash(pages: readonly PageModel[], fields: readonly string[]): string;
92
+ /**
93
+ * Collection summary hashes for one build, computed at most once per collection.
94
+ */
95
+ export interface SummaryHasher {
96
+ /** Summary hash of a collection, or `'none'` when there is no such collection */
97
+ collection(path: string): string;
98
+ /** Summary hash of a list of pages */
99
+ pages(pages: readonly PageModel[]): string;
100
+ }
101
+ /**
102
+ * Creates the summary hasher of a build. Call it after the `beforeAll` hook, which can change
103
+ * the front matter the summaries include.
104
+ *
105
+ * @param allPages - All pages of the build
106
+ * @param fields - Summary fields
107
+ * @returns The hasher
108
+ */
109
+ export declare function createSummaryHasher(allPages: PageModel[], fields: readonly string[]): SummaryHasher;
110
+ /**
111
+ * Wraps collection data for one render. Reading any of its properties sets `reads.collection`,
112
+ * so the cache knows the page depends on the collection. Arrays and objects are copied on
113
+ * first read, so a template that sorts or changes them cannot affect other pages, which share
114
+ * the collection data within a build.
115
+ *
116
+ * The properties are accessors on a plain object, not a Proxy, so spreading the template
117
+ * context (as callable partials do) passes the view along without reading it.
118
+ *
119
+ * @param data - The shared collection data
120
+ * @param reads - The render's read flags
121
+ * @returns The view for `stati.collection`
122
+ */
123
+ export declare function createCollectionView(data: CollectionData, reads: PageReads): CollectionData;
124
+ /**
125
+ * Copies a page's pagination data for `stati.pagination`, so a template that changes the
126
+ * lists cannot change what the cache hashes.
127
+ *
128
+ * @param data - The page's pagination data
129
+ * @returns A copy with its own arrays
130
+ */
131
+ export declare function copyPaginationData(data: PaginationData): PaginationData;
132
+ /**
133
+ * Copies a page's generated data for `stati.generated`.
134
+ *
135
+ * @param data - The page's generated data
136
+ * @returns A copy with its own items array
137
+ */
138
+ export declare function copyGeneratedData(data: GeneratedPageData): GeneratedPageData;
139
+ //# sourceMappingURL=collections.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"collections.d.ts","sourceRoot":"","sources":["../../src/core/collections.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EACV,SAAS,EACT,cAAc,EACd,cAAc,EACd,iBAAiB,EACjB,WAAW,EACZ,MAAM,mBAAmB,CAAC;AAU3B;;GAEG;AACH,eAAO,MAAM,sBAAsB,EAAE,SAAS,MAAM,EAA8C,CAAC;AAEnG;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,mDAAmD;IACnD,MAAM,EAAE,GAAG,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;CACrC;AAED;;GAEG;AACH,MAAM,WAAW,SAAS;IACxB,uCAAuC;IACvC,UAAU,EAAE,OAAO,CAAC;IACpB,8EAA8E;IAC9E,GAAG,EAAE,OAAO,CAAC;CACd;AASD;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,SAAS,EAAE,GAAG,eAAe,CAQrE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,EAAE,SAAS,EAAE,GAAG,eAAe,CAwBvE;AAwFD;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,KAAK,EAAE,SAAS,EAAE,GAAG,SAAS,EAAE,CAc/D;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,eAAe,EAAE,KAAK,EAAE,OAAO,GAAG,MAAM,GAAG,SAAS,CAc7F;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,SAAS,EAAE,QAAQ,EAAE,SAAS,EAAE,GAAG,MAAM,GAAG,SAAS,CAuBhG;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,WAAW,GAAG,SAAS,MAAM,EAAE,CAM3E;AAED;;;;;;;GAOG;AACH,wBAAgB,uBAAuB,CACrC,KAAK,EAAE,SAAS,SAAS,EAAE,EAC3B,MAAM,EAAE,SAAS,MAAM,EAAE,GACxB,MAAM,CAWR;AAMD;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,iFAAiF;IACjF,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAAC;IACjC,sCAAsC;IACtC,KAAK,CAAC,KAAK,EAAE,SAAS,SAAS,EAAE,GAAG,MAAM,CAAC;CAC5C;AAED;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,SAAS,EAAE,EACrB,MAAM,EAAE,SAAS,MAAM,EAAE,GACxB,aAAa,CAcf;AAID;;;;;;;;;;;;GAYG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,cAAc,EAAE,KAAK,EAAE,SAAS,GAAG,cAAc,CAwB3F;AAaD;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,cAAc,GAAG,cAAc,CAEvE;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,iBAAiB,GAAG,iBAAiB,CAE5E"}
@@ -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();