@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
package/dist/core/isg/index.d.ts
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* @module isg
|
|
8
8
|
*/
|
|
9
9
|
export { loadCacheManifest, saveCacheManifest, createEmptyManifest } from './manifest.js';
|
|
10
|
-
export { shouldRebuildPage, createCacheEntry, updateCacheEntry } from './builder.js';
|
|
10
|
+
export { shouldRebuildPage, createCacheEntry, updateCacheEntry, hasRecordedReads, } from './builder.js';
|
|
11
11
|
export type { RenderedAssets } from './builder.js';
|
|
12
|
+
export { pruneOrphanedPages } from './prune.js';
|
|
12
13
|
export { BuildLockManager, withBuildLock } from './build-lock.js';
|
|
13
14
|
export { DevServerLockManager } from './dev-server-lock.js';
|
|
14
15
|
export { CircularDependencyError, trackTemplateDependencies, resolveTemplatePath, clearTemplatePathCache, } from './deps.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/core/isg/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAG1F,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/core/isg/index.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AAG1F,OAAO,EACL,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,gBAAgB,GACjB,MAAM,cAAc,CAAC;AACtB,YAAY,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAGnD,OAAO,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAGhD,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAClE,OAAO,EAAE,oBAAoB,EAAE,MAAM,sBAAsB,CAAC;AAG5D,OAAO,EACL,uBAAuB,EACvB,yBAAyB,EACzB,mBAAmB,EACnB,sBAAsB,GACvB,MAAM,WAAW,CAAC;AAGnB,OAAO,EACL,kBAAkB,EAClB,eAAe,EACf,qBAAqB,EACrB,iBAAiB,EACjB,qBAAqB,EACrB,kBAAkB,GACnB,MAAM,WAAW,CAAC;AAGnB,OAAO,EACL,kBAAkB,EAClB,aAAa,EACb,mBAAmB,EACnB,oBAAoB,EACpB,YAAY,EACZ,eAAe,GAChB,MAAM,UAAU,CAAC;AAGlB,OAAO,EACL,qBAAqB,EACrB,iBAAiB,EACjB,wBAAwB,EACxB,sBAAsB,GACvB,MAAM,iBAAiB,CAAC"}
|
package/dist/core/isg/index.js
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
// Manifest management
|
|
10
10
|
export { loadCacheManifest, saveCacheManifest, createEmptyManifest } from './manifest.js';
|
|
11
11
|
// Build decision logic
|
|
12
|
-
export { shouldRebuildPage, createCacheEntry, updateCacheEntry } from './builder.js';
|
|
12
|
+
export { shouldRebuildPage, createCacheEntry, updateCacheEntry, hasRecordedReads, } from './builder.js';
|
|
13
|
+
// Removal of pages that no longer exist
|
|
14
|
+
export { pruneOrphanedPages } from './prune.js';
|
|
13
15
|
// Build locking
|
|
14
16
|
export { BuildLockManager, withBuildLock } from './build-lock.js';
|
|
15
17
|
export { DevServerLockManager } from './dev-server-lock.js';
|
|
@@ -237,6 +237,12 @@ function validateCacheEntry(entry, path) {
|
|
|
237
237
|
return false;
|
|
238
238
|
}
|
|
239
239
|
}
|
|
240
|
+
for (const field of ['readsCollection', 'readsNav']) {
|
|
241
|
+
if (entryObj[field] !== undefined && typeof entryObj[field] !== 'boolean') {
|
|
242
|
+
console.warn(`Invalid cache entry for ${path}: "${field}" must be a boolean if present`);
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
240
246
|
// Validate that deps and tags arrays contain strings
|
|
241
247
|
if (!entryObj.deps.every((dep) => typeof dep === 'string')) {
|
|
242
248
|
console.warn(`Invalid cache entry for ${path}: all "deps" must be strings`);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { CacheManifest } from '../../types/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Result of {@link pruneOrphanedPages}.
|
|
4
|
+
*/
|
|
5
|
+
export interface PruneResult {
|
|
6
|
+
/** Cache keys whose output file was deleted */
|
|
7
|
+
removed: string[];
|
|
8
|
+
/** Output files that could not be deleted, with the reason */
|
|
9
|
+
failed: Array<{
|
|
10
|
+
key: string;
|
|
11
|
+
message: string;
|
|
12
|
+
}>;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Removes the output of pages that no longer exist, such as a deleted post or a tag that no
|
|
16
|
+
* post uses anymore.
|
|
17
|
+
*
|
|
18
|
+
* Every manifest entry whose cache key is not one of this build's pages is an orphan: its
|
|
19
|
+
* HTML file is deleted, then its entry is dropped from the manifest, and so are the folders
|
|
20
|
+
* that deleting it leaves empty. Only files the manifest records are deleted, so files from
|
|
21
|
+
* the static directory or from other tools in the output directory stay. Nothing is deleted
|
|
22
|
+
* outside the output directory, including through a folder that links to somewhere else, and
|
|
23
|
+
* neither is a file that one of this build's pages writes under another path.
|
|
24
|
+
*
|
|
25
|
+
* @param manifest - The cache manifest; orphaned entries are removed from it, except those
|
|
26
|
+
* whose file could not be deleted, so the next build tries again
|
|
27
|
+
* @param liveKeys - Cache keys of this build's pages, rendered or served from the cache
|
|
28
|
+
* @param outDir - The output directory
|
|
29
|
+
* @returns The deleted outputs and the ones that could not be deleted
|
|
30
|
+
*/
|
|
31
|
+
export declare function pruneOrphanedPages(manifest: CacheManifest, liveKeys: ReadonlySet<string>, outDir: string): Promise<PruneResult>;
|
|
32
|
+
//# sourceMappingURL=prune.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"prune.d.ts","sourceRoot":"","sources":["../../../src/core/isg/prune.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAW1D;;GAEG;AACH,MAAM,WAAW,WAAW;IAC1B,+CAA+C;IAC/C,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,8DAA8D;IAC9D,MAAM,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CACjD;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAsB,kBAAkB,CACtC,QAAQ,EAAE,aAAa,EACvB,QAAQ,EAAE,WAAW,CAAC,MAAM,CAAC,EAC7B,MAAM,EAAE,MAAM,GACb,OAAO,CAAC,WAAW,CAAC,CAuEtB"}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { dirname, resolve } from 'node:path';
|
|
2
|
+
import { isPathWithinDirectory, lstat, pathExists, readdir, realpath, remove, stat, } from '../utils/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Removes the output of pages that no longer exist, such as a deleted post or a tag that no
|
|
5
|
+
* post uses anymore.
|
|
6
|
+
*
|
|
7
|
+
* Every manifest entry whose cache key is not one of this build's pages is an orphan: its
|
|
8
|
+
* HTML file is deleted, then its entry is dropped from the manifest, and so are the folders
|
|
9
|
+
* that deleting it leaves empty. Only files the manifest records are deleted, so files from
|
|
10
|
+
* the static directory or from other tools in the output directory stay. Nothing is deleted
|
|
11
|
+
* outside the output directory, including through a folder that links to somewhere else, and
|
|
12
|
+
* neither is a file that one of this build's pages writes under another path.
|
|
13
|
+
*
|
|
14
|
+
* @param manifest - The cache manifest; orphaned entries are removed from it, except those
|
|
15
|
+
* whose file could not be deleted, so the next build tries again
|
|
16
|
+
* @param liveKeys - Cache keys of this build's pages, rendered or served from the cache
|
|
17
|
+
* @param outDir - The output directory
|
|
18
|
+
* @returns The deleted outputs and the ones that could not be deleted
|
|
19
|
+
*/
|
|
20
|
+
export async function pruneOrphanedPages(manifest, liveKeys, outDir) {
|
|
21
|
+
const result = { removed: [], failed: [] };
|
|
22
|
+
const outRoot = resolve(outDir);
|
|
23
|
+
let realRoot;
|
|
24
|
+
// Files of this build's pages, read the first time a file is about to be deleted. A stale
|
|
25
|
+
// entry can name one of them by another path: through a linked folder, or in other letter
|
|
26
|
+
// case after renaming About.md to about.md on a case-insensitive file system.
|
|
27
|
+
let liveFiles;
|
|
28
|
+
const isLiveFile = async (path) => {
|
|
29
|
+
const id = await fileIdentity(path);
|
|
30
|
+
if (id === undefined) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
if (!liveFiles) {
|
|
34
|
+
liveFiles = new Set();
|
|
35
|
+
for (const key of liveKeys) {
|
|
36
|
+
const liveId = await fileIdentity(resolve(outRoot, key.replace(/^\/+/, '')));
|
|
37
|
+
if (liveId !== undefined) {
|
|
38
|
+
liveFiles.add(liveId);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return liveFiles.has(id);
|
|
43
|
+
};
|
|
44
|
+
for (const key of Object.keys(manifest.entries)) {
|
|
45
|
+
if (liveKeys.has(key)) {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
// Cache keys are output paths relative to the output directory, such as /blog/post.html
|
|
49
|
+
const relativePath = key.replace(/^\/+/, '');
|
|
50
|
+
const outputPath = resolve(outRoot, relativePath);
|
|
51
|
+
let removed = false;
|
|
52
|
+
try {
|
|
53
|
+
if (relativePath.endsWith('.html') &&
|
|
54
|
+
outputPath !== outRoot &&
|
|
55
|
+
isPathWithinDirectory(outRoot, outputPath) &&
|
|
56
|
+
(await pathExists(outputPath)) &&
|
|
57
|
+
(await stat(outputPath)).isFile()) {
|
|
58
|
+
// Resolves links, so a folder linked to somewhere outside is left alone
|
|
59
|
+
realRoot ??= await realpath(outRoot);
|
|
60
|
+
if (isPathWithinDirectory(realRoot, await realpath(dirname(outputPath))) &&
|
|
61
|
+
!(await isLiveFile(outputPath))) {
|
|
62
|
+
await remove(outputPath);
|
|
63
|
+
removed = true;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
// The entry stays, so the next build tries again
|
|
69
|
+
result.failed.push({ key, message: error instanceof Error ? error.message : String(error) });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
delete manifest.entries[key];
|
|
73
|
+
if (removed) {
|
|
74
|
+
result.removed.push(key);
|
|
75
|
+
try {
|
|
76
|
+
await removeEmptyFolders(dirname(outputPath), outRoot);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
// An empty folder left behind does no harm
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Identifies a file by its device and file number, which every path to the file shares.
|
|
87
|
+
*
|
|
88
|
+
* @returns The identity, or undefined when the file is missing or the file system reports no
|
|
89
|
+
* file numbers
|
|
90
|
+
*/
|
|
91
|
+
async function fileIdentity(path) {
|
|
92
|
+
try {
|
|
93
|
+
const stats = await stat(path);
|
|
94
|
+
return stats.ino ? `${stats.dev}:${stats.ino}` : undefined;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return undefined;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Deletes a folder and its parents while they are empty, stopping at the output directory
|
|
102
|
+
* and at links, which stay even when the folder they point to is empty.
|
|
103
|
+
*/
|
|
104
|
+
async function removeEmptyFolders(folder, outRoot) {
|
|
105
|
+
let current = folder;
|
|
106
|
+
while (current !== outRoot && isPathWithinDirectory(outRoot, current)) {
|
|
107
|
+
if ((await lstat(current)).isSymbolicLink() || (await readdir(current)).length > 0) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
await remove(current);
|
|
111
|
+
current = dirname(current);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -2,8 +2,10 @@ import type { PageModel, NavNode } from '../types/index.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* Builds a hierarchical navigation structure from pages.
|
|
4
4
|
* Groups pages by directory structure and sorts them appropriately.
|
|
5
|
+
* Generated pages are left out unless their template sets `nav: true`, and so are the
|
|
6
|
+
* later pages of paginated listings.
|
|
5
7
|
*
|
|
6
|
-
* @param
|
|
8
|
+
* @param allPages - Array of page models to build navigation from
|
|
7
9
|
* @returns Array of top-level navigation nodes
|
|
8
10
|
*
|
|
9
11
|
* @example
|
|
@@ -17,5 +19,5 @@ import type { PageModel, NavNode } from '../types/index.js';
|
|
|
17
19
|
* // Results in hierarchical structure with sorted blog posts
|
|
18
20
|
* ```
|
|
19
21
|
*/
|
|
20
|
-
export declare function buildNavigation(
|
|
22
|
+
export declare function buildNavigation(allPages: PageModel[]): NavNode[];
|
|
21
23
|
//# sourceMappingURL=navigation.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"navigation.d.ts","sourceRoot":"","sources":["../../src/core/navigation.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"navigation.d.ts","sourceRoot":"","sources":["../../src/core/navigation.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,OAAO,EAAE,MAAM,mBAAmB,CAAC;AAe5D;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,SAAS,EAAE,GAAG,OAAO,EAAE,CA2ChE"}
|
package/dist/core/navigation.js
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
1
|
import { basename } from 'node:path';
|
|
2
|
+
import { isVirtualPage } from './utils/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* Whether a page appears in navigation. Pages generated from a `[param].md` template appear
|
|
5
|
+
* only when the template sets `nav: true`; later pages of a paginated listing never do.
|
|
6
|
+
*/
|
|
7
|
+
function isInNavigation(page) {
|
|
8
|
+
if (!isVirtualPage(page)) {
|
|
9
|
+
return true;
|
|
10
|
+
}
|
|
11
|
+
const firstPage = (page.pagination?.pageNumber ?? 1) === 1;
|
|
12
|
+
return page.generated !== undefined && firstPage && page.frontMatter.nav === true;
|
|
13
|
+
}
|
|
2
14
|
/**
|
|
3
15
|
* Builds a hierarchical navigation structure from pages.
|
|
4
16
|
* Groups pages by directory structure and sorts them appropriately.
|
|
17
|
+
* Generated pages are left out unless their template sets `nav: true`, and so are the
|
|
18
|
+
* later pages of paginated listings.
|
|
5
19
|
*
|
|
6
|
-
* @param
|
|
20
|
+
* @param allPages - Array of page models to build navigation from
|
|
7
21
|
* @returns Array of top-level navigation nodes
|
|
8
22
|
*
|
|
9
23
|
* @example
|
|
@@ -17,7 +31,8 @@ import { basename } from 'node:path';
|
|
|
17
31
|
* // Results in hierarchical structure with sorted blog posts
|
|
18
32
|
* ```
|
|
19
33
|
*/
|
|
20
|
-
export function buildNavigation(
|
|
34
|
+
export function buildNavigation(allPages) {
|
|
35
|
+
const pages = allPages.filter(isInNavigation);
|
|
21
36
|
// Group pages by their collection (directory)
|
|
22
37
|
const collections = new Map();
|
|
23
38
|
for (const page of pages) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"preview.d.ts","sourceRoot":"","sources":["../../src/core/preview.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"preview.d.ts","sourceRoot":"","sources":["../../src/core/preview.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAe,MAAM,mBAAmB,CAAC;AAgB7D,MAAM,WAAW,oBAAoB;IACnC,8CAA8C;IAC9C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,qDAAqD;IACrD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,6DAA6D;IAC7D,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,0BAA0B;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,sBAAsB;IACtB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACtB,GAAG,EAAE,MAAM,CAAC;CACb;AAyBD;;;GAGG;AACH,wBAAsB,mBAAmB,CACvC,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,aAAa,CAAC,CAwJxB"}
|
package/dist/core/preview.js
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
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 { loadConfig } from '../config/loader.js';
|
|
5
|
-
import { resolveDevPaths, resolvePrettyUrl, createFallbackLogger, mergeServerOptions,
|
|
4
|
+
import { resolveDevPaths, resolvePrettyUrl, resolveRequestFile, resolveNotFoundPage, getMimeType, parseRequestPath, createFallbackLogger, mergeServerOptions, assertPortAvailable, toPortUnavailableError, } from './utils/index.js';
|
|
6
5
|
import { DEFAULT_PREVIEW_PORT, DEFAULT_DEV_HOST } from '../constants.js';
|
|
7
6
|
/**
|
|
8
7
|
* Loads and validates configuration for the preview server.
|
|
@@ -41,37 +40,13 @@ export async function createPreviewServer(options = {}) {
|
|
|
41
40
|
});
|
|
42
41
|
const url = `http://${host}:${port}`;
|
|
43
42
|
let httpServer = null;
|
|
44
|
-
/**
|
|
45
|
-
* Gets MIME type for a file based on its extension
|
|
46
|
-
*/
|
|
47
|
-
function getMimeType(filePath) {
|
|
48
|
-
const ext = extname(filePath).toLowerCase();
|
|
49
|
-
const mimeTypes = {
|
|
50
|
-
'.html': 'text/html',
|
|
51
|
-
'.js': 'application/javascript',
|
|
52
|
-
'.css': 'text/css',
|
|
53
|
-
'.json': 'application/json',
|
|
54
|
-
'.png': 'image/png',
|
|
55
|
-
'.jpg': 'image/jpeg',
|
|
56
|
-
'.jpeg': 'image/jpeg',
|
|
57
|
-
'.gif': 'image/gif',
|
|
58
|
-
'.svg': 'image/svg+xml',
|
|
59
|
-
'.ico': 'image/x-icon',
|
|
60
|
-
'.webp': 'image/webp',
|
|
61
|
-
'.woff': 'font/woff',
|
|
62
|
-
'.woff2': 'font/woff2',
|
|
63
|
-
'.ttf': 'font/ttf',
|
|
64
|
-
'.eot': 'application/vnd.ms-fontobject',
|
|
65
|
-
};
|
|
66
|
-
return mimeTypes[ext] || 'application/octet-stream';
|
|
67
|
-
}
|
|
68
43
|
/**
|
|
69
44
|
* Serves files from the dist directory
|
|
70
45
|
*/
|
|
71
46
|
async function serveFile(requestPath) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
if (
|
|
47
|
+
// Security: the file the request names, or null when it is outside the output directory
|
|
48
|
+
const requestFile = resolveRequestFile(outDir, requestPath);
|
|
49
|
+
if (requestFile === null) {
|
|
75
50
|
return {
|
|
76
51
|
content: '403 - Forbidden',
|
|
77
52
|
mimeType: 'text/plain',
|
|
@@ -79,8 +54,17 @@ export async function createPreviewServer(options = {}) {
|
|
|
79
54
|
};
|
|
80
55
|
}
|
|
81
56
|
// Use the shared pretty URL resolver
|
|
82
|
-
const { filePath, found } = await resolvePrettyUrl(outDir, requestPath,
|
|
57
|
+
const { filePath, found } = await resolvePrettyUrl(outDir, requestPath, requestFile);
|
|
83
58
|
if (!found || !filePath) {
|
|
59
|
+
// Serve the site's 404 page, as static hosts do
|
|
60
|
+
const notFoundPage = await resolveNotFoundPage(outDir);
|
|
61
|
+
if (notFoundPage) {
|
|
62
|
+
return {
|
|
63
|
+
content: await readFile(notFoundPage),
|
|
64
|
+
mimeType: 'text/html',
|
|
65
|
+
statusCode: 404,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
84
68
|
return {
|
|
85
69
|
content: requestPath.endsWith('/')
|
|
86
70
|
? '404 - Directory listing not available'
|
|
@@ -114,8 +98,13 @@ export async function createPreviewServer(options = {}) {
|
|
|
114
98
|
await assertPortAvailable(port, host, 'preview.port');
|
|
115
99
|
// Create HTTP server
|
|
116
100
|
httpServer = createServer(async (req, res) => {
|
|
117
|
-
|
|
118
|
-
|
|
101
|
+
logger.processing?.(`${req.method} ${req.url || '/'}`);
|
|
102
|
+
const requestPath = parseRequestPath(req.url);
|
|
103
|
+
if (requestPath === null) {
|
|
104
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
105
|
+
res.end('400 - Bad Request');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
119
108
|
try {
|
|
120
109
|
const { content, mimeType, statusCode } = await serveFile(requestPath);
|
|
121
110
|
res.writeHead(statusCode, {
|
package/dist/core/templates.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Eta } from 'eta';
|
|
2
2
|
import type { StatiConfig, PageModel, NavNode, TocEntry, Logger } from '../types/index.js';
|
|
3
|
+
import type { PageReads } from './collections.js';
|
|
3
4
|
import type { PageImages } from '../images/runtime.js';
|
|
4
5
|
import type { GeneratedOgImage } from '../types/index.js';
|
|
5
6
|
/**
|
|
@@ -12,6 +13,8 @@ export interface RenderResult {
|
|
|
12
13
|
templatesLoaded: number;
|
|
13
14
|
/** Open Graph image generated for the page, if any */
|
|
14
15
|
ogImage?: GeneratedOgImage | undefined;
|
|
16
|
+
/** Shared data the render read, which the cache entry records */
|
|
17
|
+
reads: PageReads;
|
|
15
18
|
}
|
|
16
19
|
/**
|
|
17
20
|
* Page-specific state for {@link renderPage}.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../../src/core/templates.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,KAAK,CAAC;AAG1B,OAAO,KAAK,EACV,WAAW,EACX,SAAS,EACT,OAAO,EAEP,QAAQ,EACR,MAAM,EACP,MAAM,mBAAmB,CAAC;
|
|
1
|
+
{"version":3,"file":"templates.d.ts","sourceRoot":"","sources":["../../src/core/templates.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,KAAK,CAAC;AAG1B,OAAO,KAAK,EACV,WAAW,EACX,SAAS,EACT,OAAO,EAEP,QAAQ,EACR,MAAM,EACP,MAAM,mBAAmB,CAAC;AAuB3B,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAGlD,OAAO,KAAK,EAAE,UAAU,EAAqB,MAAM,sBAAsB,CAAC;AAC1E,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAE1D;;GAEG;AACH,MAAM,WAAW,YAAY;IAC3B,gCAAgC;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,qDAAqD;IACrD,eAAe,EAAE,MAAM,CAAC;IACxB,sDAAsD;IACtD,OAAO,CAAC,EAAE,gBAAgB,GAAG,SAAS,CAAC;IACvC,iEAAiE;IACjE,KAAK,EAAE,SAAS,CAAC;CAClB;AAED;;GAEG;AACH,MAAM,WAAW,gBAAgB;IAC/B,gFAAgF;IAChF,MAAM,CAAC,EAAE,UAAU,GAAG,SAAS,CAAC;CACjC;AAuHD;;;GAGG;AACH,wBAAgB,wBAAwB,IAAI,IAAI,CAE/C;AAED,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,WAAW,GAAG,GAAG,CA2B7D;AAED,wBAAsB,UAAU,CAC9B,IAAI,EAAE,SAAS,EACf,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,WAAW,EACnB,GAAG,EAAE,GAAG,EACR,UAAU,CAAC,EAAE,OAAO,EAAE,EACtB,QAAQ,CAAC,EAAE,SAAS,EAAE,EACtB,MAAM,CAAC,EAAE,OAAO,mBAAmB,EAAE,WAAW,EAChD,GAAG,CAAC,EAAE,QAAQ,EAAE,EAChB,MAAM,CAAC,EAAE,MAAM,EACf,MAAM,CAAC,EAAE,gBAAgB,GACxB,OAAO,CAAC,YAAY,CAAC,CA8RvB"}
|
package/dist/core/templates.js
CHANGED
|
@@ -3,7 +3,8 @@ import { join, dirname, relative, basename, posix } from 'node:path';
|
|
|
3
3
|
import glob from 'fast-glob';
|
|
4
4
|
import { createFallbackLogger } from './utils/index.js';
|
|
5
5
|
import { TEMPLATE_EXTENSION } from '../constants.js';
|
|
6
|
-
import { getStatiVersion, isCollectionIndexPage, discoverLayout,
|
|
6
|
+
import { getStatiVersion, isCollectionIndexPage, discoverLayout, resolveSrcDir, createTemplateError, createValidatingPartialsProxy, createValidatingContextProxy, propValue, slugify, wrapPartialsAsCallable, createNavigationHelpers, } from './utils/index.js';
|
|
7
|
+
import { getCollections, resolveCollectionPath, createCollectionView, copyPaginationData, copyGeneratedData, } from './collections.js';
|
|
7
8
|
import { isDevelopment, isProduction } from '../env.js';
|
|
8
9
|
import { generateSEO, resolveGeneratedImagePlaceholders } from '../seo/index.js';
|
|
9
10
|
/**
|
|
@@ -22,118 +23,28 @@ function renderPlainImage(src, options = {}) {
|
|
|
22
23
|
return `<img ${attributes.join(' ')}>`;
|
|
23
24
|
}
|
|
24
25
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* @returns Object mapping tag names to arrays of pages
|
|
29
|
-
*/
|
|
30
|
-
function groupPagesByTags(pages) {
|
|
31
|
-
const pagesByTag = {};
|
|
32
|
-
for (const page of pages) {
|
|
33
|
-
const tags = page.frontMatter.tags;
|
|
34
|
-
if (Array.isArray(tags)) {
|
|
35
|
-
for (const tag of tags) {
|
|
36
|
-
if (typeof tag === 'string') {
|
|
37
|
-
if (!pagesByTag[tag]) {
|
|
38
|
-
pagesByTag[tag] = [];
|
|
39
|
-
}
|
|
40
|
-
pagesByTag[tag].push(page);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
return pagesByTag;
|
|
46
|
-
}
|
|
47
|
-
/**
|
|
48
|
-
* Sorts pages by publishedAt date (most recent first), falling back to alphabetical by title.
|
|
49
|
-
*
|
|
50
|
-
* @param pages - Pages to sort
|
|
51
|
-
* @returns Sorted array of pages
|
|
52
|
-
*/
|
|
53
|
-
function sortPagesByDate(pages) {
|
|
54
|
-
return pages.sort((a, b) => {
|
|
55
|
-
// Sort by publishedAt (newest first)
|
|
56
|
-
if (a.publishedAt && b.publishedAt) {
|
|
57
|
-
return b.publishedAt.getTime() - a.publishedAt.getTime();
|
|
58
|
-
}
|
|
59
|
-
if (a.publishedAt && !b.publishedAt)
|
|
60
|
-
return -1;
|
|
61
|
-
if (!a.publishedAt && b.publishedAt)
|
|
62
|
-
return 1;
|
|
63
|
-
// Fallback to title
|
|
64
|
-
const titleA = a.frontMatter.title || basename(a.url) || 'Untitled';
|
|
65
|
-
const titleB = b.frontMatter.title || basename(b.url) || 'Untitled';
|
|
66
|
-
return titleA.localeCompare(titleB);
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* Builds collection aggregation data for index pages.
|
|
71
|
-
* Provides all the data needed for index pages to list and organize their collection's content.
|
|
72
|
-
*
|
|
73
|
-
* @param currentPage - The current page being rendered (must be an index page)
|
|
74
|
-
* @param allPages - All pages in the site
|
|
75
|
-
* @returns Collection data for template rendering
|
|
76
|
-
*/
|
|
77
|
-
function buildCollectionData(currentPage, allPages) {
|
|
78
|
-
const collectionPath = currentPage.url === '/' ? '/' : currentPage.url;
|
|
79
|
-
const collectionName = collectionPath === '/' ? 'Home' : basename(collectionPath);
|
|
80
|
-
// Find all pages that belong to this collection
|
|
81
|
-
const collectionPages = allPages.filter((page) => {
|
|
82
|
-
if (page.url === currentPage.url) {
|
|
83
|
-
return false; // Don't include the index page itself
|
|
84
|
-
}
|
|
85
|
-
if (collectionPath === '/') {
|
|
86
|
-
// For root collection, include pages that are direct children of root
|
|
87
|
-
const pageCollectionPath = getCollectionPathForPage(page.url);
|
|
88
|
-
return pageCollectionPath === '/';
|
|
89
|
-
}
|
|
90
|
-
else {
|
|
91
|
-
// For other collections, include pages that start with the collection path
|
|
92
|
-
const pageUrlAsDir = collectionPath.endsWith('/') ? collectionPath : collectionPath + '/';
|
|
93
|
-
return page.url.startsWith(pageUrlAsDir);
|
|
94
|
-
}
|
|
95
|
-
});
|
|
96
|
-
// Find direct children (one level down)
|
|
97
|
-
const children = collectionPages.filter((page) => {
|
|
98
|
-
const relativePath = page.url.substring(collectionPath.length);
|
|
99
|
-
const cleanPath = relativePath.startsWith('/') ? relativePath.substring(1) : relativePath;
|
|
100
|
-
return !cleanPath.includes('/'); // No further slashes means direct child
|
|
101
|
-
});
|
|
102
|
-
// Sort pages by date for recent posts
|
|
103
|
-
const recentPages = sortPagesByDate([...collectionPages]);
|
|
104
|
-
// Group by tags
|
|
105
|
-
const pagesByTag = groupPagesByTags(collectionPages);
|
|
106
|
-
return {
|
|
107
|
-
pages: collectionPages,
|
|
108
|
-
children,
|
|
109
|
-
recentPages,
|
|
110
|
-
pagesByTag,
|
|
111
|
-
metadata: {
|
|
112
|
-
totalPages: collectionPages.length,
|
|
113
|
-
hasChildren: children.length > 0,
|
|
114
|
-
collectionPath,
|
|
115
|
-
collectionName,
|
|
116
|
-
},
|
|
117
|
-
};
|
|
118
|
-
}
|
|
119
|
-
/**
|
|
120
|
-
* Builds collection data for a child page (showing parent collection data).
|
|
121
|
-
* Finds the parent collection and returns its data.
|
|
122
|
-
*
|
|
123
|
-
* @param currentPage - The current page being rendered
|
|
124
|
-
* @param allPages - All pages in the site
|
|
125
|
-
* @returns Collection data for the parent collection or undefined
|
|
26
|
+
* Wraps the navigation helpers for one render so that reading any of them sets `reads.nav`.
|
|
27
|
+
* Like the collection view, it uses accessors on a plain object, so spreading the template
|
|
28
|
+
* context does not count as a read. The helpers return the shared nodes unchanged.
|
|
126
29
|
*/
|
|
127
|
-
function
|
|
128
|
-
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
30
|
+
function createNavView(helpers, reads) {
|
|
31
|
+
const view = {};
|
|
32
|
+
for (const [key, initial] of Object.entries(helpers)) {
|
|
33
|
+
let value = initial;
|
|
34
|
+
Object.defineProperty(view, key, {
|
|
35
|
+
enumerable: true,
|
|
36
|
+
configurable: true,
|
|
37
|
+
get() {
|
|
38
|
+
reads.nav = true;
|
|
39
|
+
return value;
|
|
40
|
+
},
|
|
41
|
+
set(next) {
|
|
42
|
+
reads.nav = true;
|
|
43
|
+
value = next;
|
|
44
|
+
},
|
|
45
|
+
});
|
|
135
46
|
}
|
|
136
|
-
return
|
|
47
|
+
return view;
|
|
137
48
|
}
|
|
138
49
|
/**
|
|
139
50
|
* Discovers partials in the hierarchy for a given page path.
|
|
@@ -245,17 +156,19 @@ export async function renderPage(page, body, config, eta, navigation, allPages,
|
|
|
245
156
|
const partialPaths = await discoverPartials(relativePath, config);
|
|
246
157
|
// Count templates: partials + layout (if found)
|
|
247
158
|
const partialsCount = Object.keys(partialPaths).length;
|
|
248
|
-
//
|
|
159
|
+
// What this render reads from shared data, so the cache can rebuild the page when it changes
|
|
160
|
+
const reads = { collection: false, nav: false };
|
|
161
|
+
// The page's collection: its own on index pages, the one it belongs to on other pages, or
|
|
162
|
+
// the one its front matter names. Collections are computed once per page list.
|
|
249
163
|
let collectionData;
|
|
250
164
|
const isIndexPage = allPages && isCollectionIndexPage(page, allPages);
|
|
251
165
|
if (allPages) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
collectionData = buildParentCollectionData(page, allPages);
|
|
166
|
+
const collectionPath = resolveCollectionPath(page, allPages);
|
|
167
|
+
const data = collectionPath !== undefined
|
|
168
|
+
? getCollections(allPages).byPath.get(collectionPath)
|
|
169
|
+
: undefined;
|
|
170
|
+
if (data) {
|
|
171
|
+
collectionData = createCollectionView(data, reads);
|
|
259
172
|
}
|
|
260
173
|
}
|
|
261
174
|
// Discover the appropriate layout using hierarchical layout.eta convention
|
|
@@ -280,16 +193,28 @@ export async function renderPage(page, body, config, eta, navigation, allPages,
|
|
|
280
193
|
if (conflictingKeys.length > 0) {
|
|
281
194
|
log.warning(`Frontmatter in "${page.sourcePath}" contains reserved keys that will be overwritten: ${conflictingKeys.join(', ')}`);
|
|
282
195
|
}
|
|
196
|
+
// The current page's navigation node counts as a navigation read
|
|
197
|
+
const pageContext = { ...page.frontMatter, ...pageOverrides };
|
|
198
|
+
Object.defineProperty(pageContext, 'navNode', {
|
|
199
|
+
enumerable: true,
|
|
200
|
+
configurable: true,
|
|
201
|
+
get() {
|
|
202
|
+
reads.nav = true;
|
|
203
|
+
return currentNavNode;
|
|
204
|
+
},
|
|
205
|
+
});
|
|
283
206
|
// Create base context for partial rendering
|
|
284
207
|
const baseContext = {
|
|
285
208
|
site: config.site,
|
|
286
|
-
page:
|
|
287
|
-
...page.frontMatter,
|
|
288
|
-
...pageOverrides,
|
|
289
|
-
},
|
|
209
|
+
page: pageContext,
|
|
290
210
|
content: body,
|
|
291
|
-
nav: navHelpers, // Replace navigation with nav helpers
|
|
211
|
+
nav: createNavView(navHelpers, reads), // Replace navigation with nav helpers
|
|
292
212
|
collection: collectionData, // Add collection data
|
|
213
|
+
// Set on paginated listings and on pages generated from [param].md templates. Placed before
|
|
214
|
+
// the filters, like slugify, so that an existing filter with the same name keeps working.
|
|
215
|
+
pagination: page.pagination ? copyPaginationData(page.pagination) : undefined,
|
|
216
|
+
generated: page.generated ? copyGeneratedData(page.generated) : undefined,
|
|
217
|
+
slugify,
|
|
293
218
|
// Add custom filters to context
|
|
294
219
|
...(config.eta?.filters || {}),
|
|
295
220
|
generator: {
|
|
@@ -382,10 +307,14 @@ export async function renderPage(page, body, config, eta, navigation, allPages,
|
|
|
382
307
|
// Render the Open Graph template with the layout's context, minus the page body, so the
|
|
383
308
|
// generated image is known before the layout renders its SEO tags
|
|
384
309
|
if (pageImages && ogState.pending) {
|
|
310
|
+
// Copy the page's property descriptors so that navNode stays a getter: spreading would
|
|
311
|
+
// read it and count every page with a card as reading the navigation
|
|
312
|
+
const ogPage = Object.defineProperties({}, Object.getOwnPropertyDescriptors(baseContext.page));
|
|
313
|
+
ogPage.content = '';
|
|
385
314
|
const ogBaseContext = {
|
|
386
315
|
...baseContext,
|
|
387
316
|
content: '',
|
|
388
|
-
page:
|
|
317
|
+
page: ogPage,
|
|
389
318
|
og: pageImages.ogSize(),
|
|
390
319
|
};
|
|
391
320
|
const ogContext = createValidatingContextProxy({
|
|
@@ -405,13 +334,19 @@ export async function renderPage(page, body, config, eta, navigation, allPages,
|
|
|
405
334
|
try {
|
|
406
335
|
if (!layoutPath) {
|
|
407
336
|
log.warning('No layout template found, using fallback');
|
|
408
|
-
return {
|
|
337
|
+
return {
|
|
338
|
+
html: createFallbackHtml(page, body),
|
|
339
|
+
templatesLoaded,
|
|
340
|
+
ogImage: ogState.image,
|
|
341
|
+
reads,
|
|
342
|
+
};
|
|
409
343
|
}
|
|
410
344
|
const html = await eta.renderAsync(layoutPath, context);
|
|
411
345
|
return {
|
|
412
346
|
html: resolveGeneratedImagePlaceholders(html, ogState.image),
|
|
413
347
|
templatesLoaded,
|
|
414
348
|
ogImage: ogState.image,
|
|
349
|
+
reads,
|
|
415
350
|
};
|
|
416
351
|
}
|
|
417
352
|
catch (error) {
|
|
@@ -421,7 +356,12 @@ export async function renderPage(page, body, config, eta, navigation, allPages,
|
|
|
421
356
|
const templateError = createTemplateError(error instanceof Error ? error : new Error(String(error)), layoutPath || undefined);
|
|
422
357
|
throw templateError;
|
|
423
358
|
}
|
|
424
|
-
return {
|
|
359
|
+
return {
|
|
360
|
+
html: createFallbackHtml(page, body),
|
|
361
|
+
templatesLoaded,
|
|
362
|
+
ogImage: ogState.image,
|
|
363
|
+
reads,
|
|
364
|
+
};
|
|
425
365
|
}
|
|
426
366
|
}
|
|
427
367
|
function createFallbackHtml(page, body) {
|