@stati/core 1.28.0 → 1.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/dist/core/build.d.ts.map +1 -1
- package/dist/core/build.js +192 -71
- package/dist/core/collections.d.ts +139 -0
- package/dist/core/collections.d.ts.map +1 -0
- package/dist/core/collections.js +342 -0
- package/dist/core/content.d.ts.map +1 -1
- package/dist/core/content.js +2 -1
- package/dist/core/dev.d.ts.map +1 -1
- package/dist/core/dev.js +32 -93
- package/dist/core/expand.d.ts +86 -0
- package/dist/core/expand.d.ts.map +1 -0
- package/dist/core/expand.js +484 -0
- package/dist/core/isg/builder.d.ts +15 -2
- package/dist/core/isg/builder.d.ts.map +1 -1
- package/dist/core/isg/builder.js +44 -6
- package/dist/core/isg/hash.d.ts +4 -2
- package/dist/core/isg/hash.d.ts.map +1 -1
- package/dist/core/isg/hash.js +16 -2
- package/dist/core/isg/index.d.ts +2 -1
- package/dist/core/isg/index.d.ts.map +1 -1
- package/dist/core/isg/index.js +3 -1
- package/dist/core/isg/manifest.js +6 -0
- package/dist/core/isg/prune.d.ts +32 -0
- package/dist/core/isg/prune.d.ts.map +1 -0
- package/dist/core/isg/prune.js +113 -0
- package/dist/core/navigation.d.ts +4 -2
- package/dist/core/navigation.d.ts.map +1 -1
- package/dist/core/navigation.js +17 -2
- package/dist/core/preview.d.ts.map +1 -1
- package/dist/core/preview.js +21 -32
- package/dist/core/templates.d.ts +3 -0
- package/dist/core/templates.d.ts.map +1 -1
- package/dist/core/templates.js +67 -127
- package/dist/core/utils/bundle-matching.utils.d.ts +8 -8
- package/dist/core/utils/bundle-matching.utils.d.ts.map +1 -1
- package/dist/core/utils/bundle-matching.utils.js +10 -10
- package/dist/core/utils/fs.utils.d.ts +8 -0
- package/dist/core/utils/fs.utils.d.ts.map +1 -1
- package/dist/core/utils/fs.utils.js +13 -1
- package/dist/core/utils/index.d.ts +6 -6
- package/dist/core/utils/index.d.ts.map +1 -1
- package/dist/core/utils/index.js +5 -5
- package/dist/core/utils/paths.utils.d.ts +15 -0
- package/dist/core/utils/paths.utils.d.ts.map +1 -1
- package/dist/core/utils/paths.utils.js +20 -0
- package/dist/core/utils/server.utils.d.ts +38 -3
- package/dist/core/utils/server.utils.d.ts.map +1 -1
- package/dist/core/utils/server.utils.js +132 -44
- package/dist/core/utils/template-discovery.utils.d.ts +25 -1
- package/dist/core/utils/template-discovery.utils.d.ts.map +1 -1
- package/dist/core/utils/template-discovery.utils.js +61 -11
- package/dist/core/utils/typescript.utils.d.ts +27 -0
- package/dist/core/utils/typescript.utils.d.ts.map +1 -1
- package/dist/core/utils/typescript.utils.js +34 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/rss/generator.d.ts.map +1 -1
- package/dist/rss/generator.js +5 -2
- package/dist/search/generator.d.ts +27 -7
- package/dist/search/generator.d.ts.map +1 -1
- package/dist/search/generator.js +55 -12
- package/dist/search/index.d.ts +1 -1
- package/dist/search/index.d.ts.map +1 -1
- package/dist/search/index.js +1 -1
- package/dist/types/config.d.ts +61 -4
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/content.d.ts +129 -1
- package/dist/types/content.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/isg.d.ts +12 -0
- package/dist/types/isg.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,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,
|
|
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"}
|
package/dist/core/content.js
CHANGED
|
@@ -39,7 +39,8 @@ export async function loadContent(config, includeDrafts) {
|
|
|
39
39
|
if (frontMatter.draft && !includeDrafts) {
|
|
40
40
|
continue;
|
|
41
41
|
}
|
|
42
|
-
|
|
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 = {
|
package/dist/core/dev.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dev.d.ts","sourceRoot":"","sources":["../../src/core/dev.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"dev.d.ts","sourceRoot":"","sources":["../../src/core/dev.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAe,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAoC7D,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,CAmhBxF"}
|
package/dist/core/dev.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
-
import { join, extname } from 'node:path';
|
|
3
2
|
import { readFile } from 'node:fs/promises';
|
|
4
3
|
import { WebSocketServer } from 'ws';
|
|
5
4
|
import chokidar from 'chokidar';
|
|
@@ -8,10 +7,8 @@ import { invalidate } from './invalidate.js';
|
|
|
8
7
|
import { loadConfig } from '../config/loader.js';
|
|
9
8
|
import { clearTemplateEngineCache } from './templates.js';
|
|
10
9
|
import { clearMarkdownProcessorCache } from './markdown.js';
|
|
11
|
-
import { loadCacheManifest, saveCacheManifest,
|
|
12
|
-
import {
|
|
13
|
-
import { buildNavigation } from './navigation.js';
|
|
14
|
-
import { resolveDevPaths, resolveCacheDir, resolvePrettyUrl, createErrorOverlay, parseErrorDetails, TemplateError, createFallbackLogger, mergeServerOptions, createTypeScriptWatcher, isEnabledEnvFlag, refreshCssOutputWatcher, normalizePathForComparison, isPathWithinDirectory, assertPortAvailable, toPortUnavailableError, } from './utils/index.js';
|
|
10
|
+
import { loadCacheManifest, saveCacheManifest, DevServerLockManager } from './isg/index.js';
|
|
11
|
+
import { resolveDevPaths, resolveCacheDir, resolvePrettyUrl, resolveRequestFile, resolveNotFoundPage, getMimeType, parseRequestPath, createErrorOverlay, parseErrorDetails, TemplateError, createFallbackLogger, mergeServerOptions, createTypeScriptWatcher, isEnabledEnvFlag, refreshCssOutputWatcher, normalizePathForComparison, assertPortAvailable, toPortUnavailableError, } from './utils/index.js';
|
|
15
12
|
import { setEnv, getEnv } from '../env.js';
|
|
16
13
|
import { DEFAULT_DEV_PORT, DEFAULT_DEV_HOST, TEMPLATE_EXTENSION, DEFAULT_OUT_DIR, } from '../constants.js';
|
|
17
14
|
/**
|
|
@@ -318,67 +315,18 @@ async function handleTemplateChange(templatePath, configPath, logger) {
|
|
|
318
315
|
}
|
|
319
316
|
}
|
|
320
317
|
/**
|
|
321
|
-
* Handles markdown file changes
|
|
322
|
-
*
|
|
323
|
-
*
|
|
324
|
-
*
|
|
318
|
+
* Handles markdown file changes (added, edited or deleted pages) with an incremental build.
|
|
319
|
+
* ISG decides what re-renders: the changed page, the pages that list it through a collection,
|
|
320
|
+
* and the pages that read the navigation when the change altered it. A deleted page's output
|
|
321
|
+
* is removed by the build.
|
|
325
322
|
*/
|
|
326
323
|
async function handleMarkdownChange(_markdownPath, configPath, logger) {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
}
|
|
324
|
+
await build({
|
|
325
|
+
logger,
|
|
326
|
+
force: false,
|
|
327
|
+
clean: false,
|
|
328
|
+
...(configPath && { configPath }),
|
|
329
|
+
});
|
|
382
330
|
}
|
|
383
331
|
export async function createDevServer(options = {}) {
|
|
384
332
|
const logger = options.logger ?? createFallbackLogger();
|
|
@@ -418,30 +366,6 @@ export async function createDevServer(options = {}) {
|
|
|
418
366
|
// Diagnostics toggles used for investigating dev-server rebuild slowdown.
|
|
419
367
|
const disableCssWatcher = isEnabledEnvFlag(process.env.STATI_DEV_DISABLE_CSS_WATCHER);
|
|
420
368
|
const disableWsReload = isEnabledEnvFlag(process.env.STATI_DEV_DISABLE_WS_RELOAD);
|
|
421
|
-
/**
|
|
422
|
-
* Gets MIME type for a file based on its extension
|
|
423
|
-
*/
|
|
424
|
-
function getMimeType(filePath) {
|
|
425
|
-
const ext = extname(filePath).toLowerCase();
|
|
426
|
-
const mimeTypes = {
|
|
427
|
-
'.html': 'text/html',
|
|
428
|
-
'.js': 'application/javascript',
|
|
429
|
-
'.css': 'text/css',
|
|
430
|
-
'.json': 'application/json',
|
|
431
|
-
'.png': 'image/png',
|
|
432
|
-
'.jpg': 'image/jpeg',
|
|
433
|
-
'.jpeg': 'image/jpeg',
|
|
434
|
-
'.gif': 'image/gif',
|
|
435
|
-
'.svg': 'image/svg+xml',
|
|
436
|
-
'.ico': 'image/x-icon',
|
|
437
|
-
'.webp': 'image/webp',
|
|
438
|
-
'.woff': 'font/woff',
|
|
439
|
-
'.woff2': 'font/woff2',
|
|
440
|
-
'.ttf': 'font/ttf',
|
|
441
|
-
'.eot': 'application/vnd.ms-fontobject',
|
|
442
|
-
};
|
|
443
|
-
return mimeTypes[ext] || 'application/octet-stream';
|
|
444
|
-
}
|
|
445
369
|
/**
|
|
446
370
|
* Injects live reload script into HTML responses
|
|
447
371
|
*/
|
|
@@ -512,9 +436,9 @@ export async function createDevServer(options = {}) {
|
|
|
512
436
|
statusCode: 500,
|
|
513
437
|
};
|
|
514
438
|
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
if (
|
|
439
|
+
// Security: the file the request names, or null when it is outside the output directory
|
|
440
|
+
const requestFile = resolveRequestFile(outDir, requestPath);
|
|
441
|
+
if (requestFile === null) {
|
|
518
442
|
return {
|
|
519
443
|
content: '403 - Forbidden',
|
|
520
444
|
mimeType: 'text/plain',
|
|
@@ -522,7 +446,7 @@ export async function createDevServer(options = {}) {
|
|
|
522
446
|
};
|
|
523
447
|
}
|
|
524
448
|
// Use the shared pretty URL resolver
|
|
525
|
-
const { filePath, found } = await resolvePrettyUrl(outDir, requestPath,
|
|
449
|
+
const { filePath, found } = await resolvePrettyUrl(outDir, requestPath, requestFile);
|
|
526
450
|
if (!found || !filePath) {
|
|
527
451
|
// If we have a build error and this is an HTML request, show error overlay instead of 404
|
|
528
452
|
const errorToShow = lastBuildError;
|
|
@@ -559,6 +483,16 @@ export async function createDevServer(options = {}) {
|
|
|
559
483
|
statusCode: 500,
|
|
560
484
|
};
|
|
561
485
|
}
|
|
486
|
+
// Serve the site's 404 page, as static hosts do, with live reload so it updates on edits
|
|
487
|
+
const notFoundPage = await resolveNotFoundPage(outDir);
|
|
488
|
+
if (notFoundPage) {
|
|
489
|
+
const html = await readFile(notFoundPage, 'utf-8');
|
|
490
|
+
return {
|
|
491
|
+
content: injectLiveReloadScript(html),
|
|
492
|
+
mimeType: 'text/html',
|
|
493
|
+
statusCode: 404,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
562
496
|
return {
|
|
563
497
|
content: requestPath.endsWith('/')
|
|
564
498
|
? '404 - Directory listing not available'
|
|
@@ -652,12 +586,17 @@ export async function createDevServer(options = {}) {
|
|
|
652
586
|
await performInitialBuild(configPath, logger, setLastBuildError);
|
|
653
587
|
// Create HTTP server
|
|
654
588
|
httpServer = createServer(async (req, res) => {
|
|
655
|
-
const requestPath = req.url
|
|
589
|
+
const requestPath = parseRequestPath(req.url);
|
|
656
590
|
const requestStart = Date.now();
|
|
657
591
|
// Handle WebSocket upgrade path
|
|
658
592
|
if (requestPath === '/__ws') {
|
|
659
593
|
return; // Let WebSocket server handle this
|
|
660
594
|
}
|
|
595
|
+
if (requestPath === null) {
|
|
596
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
597
|
+
res.end('400 - Bad Request');
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
661
600
|
// Only log page requests, not static assets (files with extensions)
|
|
662
601
|
const hasFileExtension = requestPath.includes('.') && !requestPath.endsWith('.html');
|
|
663
602
|
if (!hasFileExtension) {
|
|
@@ -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"}
|