@stati/core 1.8.0 → 1.10.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 (42) hide show
  1. package/README.md +6 -6
  2. package/dist/core/build.d.ts.map +1 -1
  3. package/dist/core/build.js +39 -4
  4. package/dist/core/dev.d.ts.map +1 -1
  5. package/dist/core/dev.js +70 -2
  6. package/dist/core/isg/deps.js +21 -0
  7. package/dist/core/isg/hash.d.ts +14 -0
  8. package/dist/core/isg/hash.d.ts.map +1 -1
  9. package/dist/core/isg/hash.js +32 -1
  10. package/dist/core/isg/index.d.ts +1 -1
  11. package/dist/core/isg/index.d.ts.map +1 -1
  12. package/dist/core/isg/index.js +1 -1
  13. package/dist/core/isg/manifest.d.ts.map +1 -1
  14. package/dist/core/isg/manifest.js +7 -1
  15. package/dist/core/templates.d.ts.map +1 -1
  16. package/dist/core/templates.js +43 -8
  17. package/dist/core/utils/callable-partials.d.ts +60 -0
  18. package/dist/core/utils/callable-partials.d.ts.map +1 -0
  19. package/dist/core/utils/callable-partials.js +108 -0
  20. package/dist/core/utils/index.d.ts +4 -0
  21. package/dist/core/utils/index.d.ts.map +1 -1
  22. package/dist/core/utils/index.js +6 -0
  23. package/dist/core/utils/navigation-helpers.d.ts +124 -0
  24. package/dist/core/utils/navigation-helpers.d.ts.map +1 -0
  25. package/dist/core/utils/navigation-helpers.js +219 -0
  26. package/dist/core/utils/partial-validation.d.ts +5 -2
  27. package/dist/core/utils/partial-validation.d.ts.map +1 -1
  28. package/dist/core/utils/partial-validation.js +35 -7
  29. package/dist/core/utils/server.d.ts +1 -1
  30. package/dist/core/utils/server.d.ts.map +1 -1
  31. package/dist/core/utils/server.js +14 -1
  32. package/dist/core/utils/tailwind-inventory.d.ts +91 -0
  33. package/dist/core/utils/tailwind-inventory.d.ts.map +1 -0
  34. package/dist/core/utils/tailwind-inventory.js +228 -0
  35. package/dist/core/utils/template-utils.d.ts +3 -0
  36. package/dist/core/utils/template-utils.d.ts.map +1 -1
  37. package/dist/core/utils/template-utils.js +27 -3
  38. package/dist/types/content.d.ts +24 -3
  39. package/dist/types/content.d.ts.map +1 -1
  40. package/dist/types/isg.d.ts +4 -1
  41. package/dist/types/isg.d.ts.map +1 -1
  42. package/package.json +1 -1
package/README.md CHANGED
@@ -327,7 +327,7 @@ export default defineConfig({
327
327
  });
328
328
  ```
329
329
 
330
- > **For the complete configuration reference** including all options, advanced features, and detailed explanations, see the [Configuration Guide](https://docs.stati.build/configuration/).
330
+ > **For the complete configuration reference** including all options, advanced features, and detailed explanations, see the [Configuration Guide](https://docs.stati.build/configuration/file/).
331
331
 
332
332
  ---
333
333
 
@@ -621,14 +621,14 @@ Automatic navigation hierarchy based on your file structure:
621
621
  ```html
622
622
  <!-- Show breadcrumbs -->
623
623
  <nav>
624
- <% stati.page.breadcrumbs.forEach(crumb => { %>
624
+ <% stati.nav.getBreadcrumbs().forEach(crumb => { %>
625
625
  <a href="<%= crumb.url %>"><%= crumb.title %></a>
626
626
  <% }) %>
627
627
  </nav>
628
628
 
629
629
  <!-- Show navigation tree -->
630
630
  <ul>
631
- <% stati.navigation.forEach(item => { %>
631
+ <% stati.nav.tree.forEach(item => { %>
632
632
  <li><a href="<%= item.url %>"><%= item.title %></a></li>
633
633
  <% }) %>
634
634
  </ul>
@@ -739,9 +739,9 @@ export default defineConfig({
739
739
  ## Learn More
740
740
 
741
741
  - [**Full Documentation**](https://docs.stati.build) — Complete guides and tutorials
742
- - [**Configuration Guide**](https://docs.stati.build/configuration/) — All options explained
743
- - [**API Reference**](https://docs.stati.build/api/) — Detailed API docs
744
- - [**Examples**](https://docs.stati.build/examples/) — Real-world projects
742
+ - [**Configuration Guide**](https://docs.stati.build/configuration/file/) — All options explained
743
+ - [**API Reference**](https://docs.stati.build/api/reference/) — Detailed API docs
744
+ - [**Examples**](https://docs.stati.build/examples/list/) — Real-world projects
745
745
  - [**Contributing**](https://github.com/ianchak/stati/blob/main/CONTRIBUTING.md) — Help improve Stati
746
746
 
747
747
  ---
@@ -1 +1 @@
1
- {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../../src/core/build.ts"],"names":[],"mappings":"AAiCA,OAAO,KAAK,EAEV,UAAU,EACV,MAAM,EAKP,MAAM,mBAAmB,CAAC;AAE3B;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,YAAY;IAC3B,iDAAiD;IACjD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,iDAAiD;IACjD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,0CAA0C;IAC1C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uCAAuC;IACvC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qCAAqC;IACrC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uDAAuD;IACvD,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AA4FD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAsB,KAAK,CAAC,OAAO,GAAE,YAAiB,GAAG,OAAO,CAAC,UAAU,CAAC,CAW3E"}
1
+ {"version":3,"file":"build.d.ts","sourceRoot":"","sources":["../../src/core/build.ts"],"names":[],"mappings":"AAyCA,OAAO,KAAK,EAEV,UAAU,EACV,MAAM,EAKP,MAAM,mBAAmB,CAAC;AAE3B;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,YAAY;IAC3B,iDAAiD;IACjD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,iDAAiD;IACjD,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,0CAA0C;IAC1C,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uCAAuC;IACvC,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,qCAAqC;IACrC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uDAAuD;IACvD,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AA4FD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAsB,KAAK,CAAC,OAAO,GAAE,YAAiB,GAAG,OAAO,CAAC,UAAU,CAAC,CAW3E"}
@@ -1,4 +1,4 @@
1
- import { ensureDir, writeFile, remove, pathExists, stat, readdir, copyFile, resolveOutDir, resolveStaticDir, resolveCacheDir, } from './utils/index.js';
1
+ import { ensureDir, writeFile, remove, pathExists, stat, readdir, copyFile, resolveOutDir, resolveStaticDir, resolveCacheDir, enableInventoryTracking, disableInventoryTracking, clearInventory, writeTailwindClassInventory, getInventorySize, isTailwindUsed, loadPreviousInventory, } from './utils/index.js';
2
2
  import { join, dirname, relative } from 'path';
3
3
  import { posix } from 'path';
4
4
  import { loadConfig } from '../config/loader.js';
@@ -6,7 +6,7 @@ import { loadContent } from './content.js';
6
6
  import { createMarkdownProcessor, renderMarkdown } from './markdown.js';
7
7
  import { createTemplateEngine, renderPage } from './templates.js';
8
8
  import { buildNavigation } from './navigation.js';
9
- import { loadCacheManifest, saveCacheManifest, shouldRebuildPage, createCacheEntry, updateCacheEntry, withBuildLock, } from './isg/index.js';
9
+ import { loadCacheManifest, saveCacheManifest, shouldRebuildPage, createCacheEntry, updateCacheEntry, withBuildLock, computeNavigationHash, } from './isg/index.js';
10
10
  import { generateSitemap, generateRobotsTxtFromConfig, autoInjectSEO, } from '../seo/index.js';
11
11
  /**
12
12
  * Recursively calculates the total size of a directory in bytes.
@@ -172,10 +172,12 @@ async function loadContentAndBuildNavigation(config, options, logger) {
172
172
  if (logger.navigationTree) {
173
173
  logger.navigationTree(navigation);
174
174
  }
175
+ // Compute navigation hash for change detection in dev server
176
+ const navigationHash = computeNavigationHash(navigation);
175
177
  // Create processors
176
178
  const md = await createMarkdownProcessor(config);
177
179
  const eta = createTemplateEngine(config);
178
- return { pages, navigation, md, eta };
180
+ return { pages, navigation, md, eta, navigationHash };
179
181
  }
180
182
  /**
181
183
  * Processes pages with ISG caching logic.
@@ -362,17 +364,50 @@ async function buildInternal(options = {}) {
362
364
  await remove(cacheDir);
363
365
  }
364
366
  await ensureDir(outDir);
367
+ // Enable Tailwind class inventory tracking only if Tailwind is detected
368
+ const hasTailwind = await isTailwindUsed();
369
+ if (hasTailwind) {
370
+ enableInventoryTracking();
371
+ clearInventory(); // Clear any previous inventory
372
+ // Try to load from existing inventory file
373
+ const loadedCount = await loadPreviousInventory(cacheDir);
374
+ if (loadedCount > 0) {
375
+ // Write the initial inventory file immediately so Tailwind can scan it
376
+ // This is critical for dev server where Tailwind starts watching before template rendering
377
+ await writeTailwindClassInventory(cacheDir);
378
+ logger.info(`📦 Loaded ${loadedCount} classes from previous build for Tailwind scanner`);
379
+ }
380
+ else {
381
+ // No previous inventory found - write an empty placeholder file
382
+ // This ensures Tailwind has a file to scan even on first build
383
+ // It will be populated with actual classes after template rendering
384
+ await writeTailwindClassInventory(cacheDir);
385
+ logger.info(`📦 Created inventory file for Tailwind scanner (will be populated after rendering)`);
386
+ }
387
+ }
365
388
  // Load cache manifest for ISG (after potential clean operation)
366
389
  const { manifest } = await setupCacheAndManifest(cacheDir);
367
390
  // Load content and build navigation
368
391
  console.log(); // Add spacing before content loading
369
- const { pages, navigation, md, eta } = await loadContentAndBuildNavigation(config, options, logger);
392
+ const { pages, navigation, md, eta, navigationHash } = await loadContentAndBuildNavigation(config, options, logger);
393
+ // Store navigation hash in manifest for change detection in dev server
394
+ manifest.navigationHash = navigationHash;
370
395
  // Process pages with ISG caching logic
371
396
  console.log(); // Add spacing before page processing
372
397
  const buildTime = new Date();
373
398
  const pageProcessingResult = await processPagesWithCache(pages, manifest, config, outDir, md, eta, navigation, buildTime, options, logger);
374
399
  cacheHits = pageProcessingResult.cacheHits;
375
400
  cacheMisses = pageProcessingResult.cacheMisses;
401
+ // Write Tailwind class inventory after all templates have been rendered (if Tailwind is used)
402
+ if (hasTailwind) {
403
+ const inventorySize = getInventorySize();
404
+ if (inventorySize > 0) {
405
+ await writeTailwindClassInventory(cacheDir);
406
+ logger.info(`📝 Generated Tailwind class inventory (${inventorySize} classes tracked)`);
407
+ }
408
+ // Disable inventory tracking after build
409
+ disableInventoryTracking();
410
+ }
376
411
  // Save updated cache manifest
377
412
  await saveCacheManifest(cacheDir, manifest);
378
413
  // Copy static assets and count them
@@ -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;AAiB7D,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;AAoOD,wBAAsB,eAAe,CAAC,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,SAAS,CAAC,CA2XxF"}
1
+ {"version":3,"file":"dev.d.ts","sourceRoot":"","sources":["../../src/core/dev.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAe,MAAM,EAAE,MAAM,mBAAmB,CAAC;AAmB7D,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;AA4SD,wBAAsB,eAAe,CAAC,OAAO,GAAE,gBAAqB,GAAG,OAAO,CAAC,SAAS,CAAC,CA2XxF"}
package/dist/core/dev.js CHANGED
@@ -7,7 +7,9 @@ import chokidar from 'chokidar';
7
7
  import { build } from './build.js';
8
8
  import { invalidate } from './invalidate.js';
9
9
  import { loadConfig } from '../config/loader.js';
10
- import { loadCacheManifest, saveCacheManifest } from './isg/index.js';
10
+ import { loadCacheManifest, saveCacheManifest, computeNavigationHash } from './isg/index.js';
11
+ import { loadContent } from './content.js';
12
+ import { buildNavigation } from './navigation.js';
11
13
  import { resolveDevPaths, resolveCacheDir, resolvePrettyUrl, createErrorOverlay, parseErrorDetails, TemplateError, } from './utils/index.js';
12
14
  import { setEnv, getEnv } from '../env.js';
13
15
  import { DEFAULT_DEV_PORT, DEFAULT_DEV_HOST, TEMPLATE_EXTENSION } from '../constants.js';
@@ -88,8 +90,11 @@ async function performIncrementalRebuild(changedPath, configPath, logger, wsServ
88
90
  if (changedPath.endsWith(TEMPLATE_EXTENSION) || changedPath.includes('_partials')) {
89
91
  await handleTemplateChange(changedPath, configPath, devLogger);
90
92
  }
93
+ else if (changedPath.endsWith('.md')) {
94
+ await handleMarkdownChange(changedPath, configPath, devLogger);
95
+ }
91
96
  else {
92
- // Content or static file changed - use normal rebuild
97
+ // Static file changed - use normal rebuild
93
98
  await build({
94
99
  logger: devLogger,
95
100
  force: false,
@@ -198,6 +203,69 @@ async function handleTemplateChange(templatePath, configPath, logger) {
198
203
  }
199
204
  }
200
205
  }
206
+ /**
207
+ * Handles markdown file changes by comparing navigation hashes.
208
+ * Only performs a full rebuild if navigation structure actually changed.
209
+ * Navigation changes come from frontmatter modifications (title, order, description).
210
+ * Content-only changes use incremental rebuilds.
211
+ */
212
+ async function handleMarkdownChange(_markdownPath, configPath, logger) {
213
+ const cacheDir = resolveCacheDir();
214
+ try {
215
+ // Load existing cache manifest
216
+ const cacheManifest = await loadCacheManifest(cacheDir);
217
+ if (!cacheManifest || !cacheManifest.navigationHash) {
218
+ // No cache or no navigation hash exists, perform full rebuild
219
+ await build({
220
+ logger,
221
+ force: false,
222
+ clean: false,
223
+ ...(configPath && { configPath }),
224
+ });
225
+ return;
226
+ }
227
+ // Load config and content to rebuild navigation tree
228
+ const config = await loadConfig(configPath);
229
+ const pages = await loadContent(config);
230
+ const newNavigation = buildNavigation(pages);
231
+ const newNavigationHash = computeNavigationHash(newNavigation);
232
+ // Compare navigation hashes
233
+ if (newNavigationHash !== cacheManifest.navigationHash) {
234
+ // Navigation structure changed - clear cache and force full rebuild
235
+ logger.info?.('📊 Navigation structure changed, performing full rebuild...');
236
+ // Force rebuild bypasses ISG cache entirely
237
+ await build({
238
+ logger,
239
+ force: true, // Force rebuild to bypass cache
240
+ clean: false,
241
+ ...(configPath && { configPath }),
242
+ });
243
+ }
244
+ else {
245
+ // Navigation unchanged - use incremental rebuild for content changes
246
+ await build({
247
+ logger,
248
+ force: false,
249
+ clean: false,
250
+ ...(configPath && { configPath }),
251
+ });
252
+ }
253
+ }
254
+ catch (_error) {
255
+ try {
256
+ // Fallback to full rebuild
257
+ await build({
258
+ logger,
259
+ force: false,
260
+ clean: false,
261
+ ...(configPath && { configPath }),
262
+ });
263
+ }
264
+ catch (fallbackError) {
265
+ throw fallbackError instanceof Error ? fallbackError : new Error(String(fallbackError));
266
+ }
267
+ }
268
+ }
201
269
  export async function createDevServer(options = {}) {
202
270
  const { port = DEFAULT_DEV_PORT, host = DEFAULT_DEV_HOST, open = false, configPath, logger = {
203
271
  info: (msg) => console.log(msg),
@@ -244,6 +244,27 @@ async function parseTemplateDependencies(content, templatePath, srcDir) {
244
244
  }
245
245
  }
246
246
  }
247
+ // Look for Stati callable partial patterns: stati.partials.name( or stati.partials['name'](
248
+ // This catches both direct property access and bracket notation with or without arguments
249
+ // Patterns allow for optional whitespace before the opening parenthesis
250
+ const callablePartialPatterns = [
251
+ /stati\.partials\.(\w+)\s*\(/g, // stati.partials.header( or stati.partials.header (
252
+ /stati\.partials\[['"`]([^'"`]+)['"`]\]\s*\(/g, // stati.partials['header']( with whitespace
253
+ ];
254
+ for (const pattern of callablePartialPatterns) {
255
+ let match;
256
+ while ((match = pattern.exec(content)) !== null) {
257
+ const partialName = match[1];
258
+ if (partialName) {
259
+ // Resolve the partial by searching for it in underscore directories
260
+ const partialFileName = `${partialName}${TEMPLATE_EXTENSION}`;
261
+ const resolvedPath = await resolveTemplatePathInternal(partialFileName, srcDir, templateDir);
262
+ if (resolvedPath) {
263
+ dependencies.push(resolvedPath);
264
+ }
265
+ }
266
+ }
267
+ }
247
268
  return dependencies;
248
269
  }
249
270
  /**
@@ -45,4 +45,18 @@ export declare function computeFileHash(filePath: string): Promise<string | null
45
45
  * ```
46
46
  */
47
47
  export declare function computeInputsHash(contentHash: string, depsHashes: string[]): string;
48
+ /**
49
+ * Computes a hash of the navigation tree structure.
50
+ * Used to detect when navigation has changed (title, url, order, children structure).
51
+ *
52
+ * @param navigation - The navigation tree to hash
53
+ * @returns SHA-256 hash as a hex string
54
+ *
55
+ * @example
56
+ * ```typescript
57
+ * const hash = computeNavigationHash(navigationTree);
58
+ * console.log(hash); // "sha256-abc123..."
59
+ * ```
60
+ */
61
+ export declare function computeNavigationHash(navigation: unknown[]): string;
48
62
  //# sourceMappingURL=hash.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"hash.d.ts","sourceRoot":"","sources":["../../../src/core/isg/hash.ts"],"names":[],"mappings":"AAwBA;;;;;;;;;;;;;GAaG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAiBhG;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAiB9E;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,MAAM,CAKnF"}
1
+ {"version":3,"file":"hash.d.ts","sourceRoot":"","sources":["../../../src/core/isg/hash.ts"],"names":[],"mappings":"AAwBA;;;;;;;;;;;;;GAaG;AACH,wBAAgB,kBAAkB,CAAC,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAiBhG;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAiB9E;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,iBAAiB,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,MAAM,CAKnF;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,qBAAqB,CAAC,UAAU,EAAE,OAAO,EAAE,GAAG,MAAM,CAqCnE"}
@@ -97,7 +97,38 @@ export async function computeFileHash(filePath) {
97
97
  * ```
98
98
  */
99
99
  export function computeInputsHash(contentHash, depsHashes) {
100
- // Hash each dependency hash in sorted order for consistency
100
+ // Sort dependency hashes for consistency
101
101
  const sortedDepsHashes = [...depsHashes].sort();
102
102
  return createSha256Hash([contentHash, ...sortedDepsHashes]);
103
103
  }
104
+ /**
105
+ * Computes a hash of the navigation tree structure.
106
+ * Used to detect when navigation has changed (title, url, order, children structure).
107
+ *
108
+ * @param navigation - The navigation tree to hash
109
+ * @returns SHA-256 hash as a hex string
110
+ *
111
+ * @example
112
+ * ```typescript
113
+ * const hash = computeNavigationHash(navigationTree);
114
+ * console.log(hash); // "sha256-abc123..."
115
+ * ```
116
+ */
117
+ export function computeNavigationHash(navigation) {
118
+ const normalizeNavNode = (node) => {
119
+ const normalized = {
120
+ title: String(node.title ?? ''),
121
+ url: String(node.url ?? ''),
122
+ };
123
+ if (node.order !== undefined && typeof node.order === 'number') {
124
+ normalized.order = node.order;
125
+ }
126
+ if (node.children && Array.isArray(node.children) && node.children.length > 0) {
127
+ normalized.children = node.children.map((child) => normalizeNavNode(child));
128
+ }
129
+ return normalized;
130
+ };
131
+ const normalizedNav = navigation.map((node) => normalizeNavNode(node));
132
+ const navJson = JSON.stringify(normalizedNav);
133
+ return createSha256Hash(navJson);
134
+ }
@@ -10,7 +10,7 @@ export { loadCacheManifest, saveCacheManifest, createEmptyManifest } from './man
10
10
  export { shouldRebuildPage, createCacheEntry, updateCacheEntry } from './builder.js';
11
11
  export { BuildLockManager, withBuildLock } from './build-lock.js';
12
12
  export { CircularDependencyError, trackTemplateDependencies, findPartialDependencies, resolveTemplatePath, } from './deps.js';
13
- export { computeContentHash, computeFileHash, computeInputsHash } from './hash.js';
13
+ export { computeContentHash, computeFileHash, computeInputsHash, computeNavigationHash, } from './hash.js';
14
14
  export { getSafeCurrentTime, parseSafeDate, computeEffectiveTTL, computeNextRebuildAt, isPageFrozen, applyAgingRules, } from './ttl.js';
15
15
  export { ISGConfigurationError, validateISGConfig, validatePageISGOverrides, extractNumericOverride, } from './validation.js';
16
16
  //# sourceMappingURL=index.d.ts.map
@@ -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,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAGrF,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAGlE,OAAO,EACL,uBAAuB,EACvB,yBAAyB,EACzB,uBAAuB,EACvB,mBAAmB,GACpB,MAAM,WAAW,CAAC;AAGnB,OAAO,EAAE,kBAAkB,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,WAAW,CAAC;AAGnF,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"}
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,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AAGrF,OAAO,EAAE,gBAAgB,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAGlE,OAAO,EACL,uBAAuB,EACvB,yBAAyB,EACzB,uBAAuB,EACvB,mBAAmB,GACpB,MAAM,WAAW,CAAC;AAGnB,OAAO,EACL,kBAAkB,EAClB,eAAe,EACf,iBAAiB,EACjB,qBAAqB,GACtB,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"}
@@ -15,7 +15,7 @@ export { BuildLockManager, withBuildLock } from './build-lock.js';
15
15
  // Dependency tracking
16
16
  export { CircularDependencyError, trackTemplateDependencies, findPartialDependencies, resolveTemplatePath, } from './deps.js';
17
17
  // Hash computation
18
- export { computeContentHash, computeFileHash, computeInputsHash } from './hash.js';
18
+ export { computeContentHash, computeFileHash, computeInputsHash, computeNavigationHash, } from './hash.js';
19
19
  // TTL and aging
20
20
  export { getSafeCurrentTime, parseSafeDate, computeEffectiveTTL, computeNextRebuildAt, isPageFrozen, applyAgingRules, } from './ttl.js';
21
21
  // Validation
@@ -1 +1 @@
1
- {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../../src/core/isg/manifest.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAc,MAAM,sBAAsB,CAAC;AAUtE;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CA8FvF;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAmEhG;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,IAAI,aAAa,CAInD"}
1
+ {"version":3,"file":"manifest.d.ts","sourceRoot":"","sources":["../../../src/core/isg/manifest.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,aAAa,EAAc,MAAM,sBAAsB,CAAC;AAUtE;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAsGvF;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,EAAE,QAAQ,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAmEhG;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,mBAAmB,IAAI,aAAa,CAInD"}
@@ -71,7 +71,13 @@ export async function loadCacheManifest(cacheDir) {
71
71
  if (invalidEntryCount > 0) {
72
72
  console.warn(`Removed ${invalidEntryCount} invalid cache entries`);
73
73
  }
74
- return { entries: validatedEntries };
74
+ // Return manifest with entries AND navigationHash (if present)
75
+ const resultManifest = { entries: validatedEntries };
76
+ // Preserve navigationHash if it exists
77
+ if (typeof manifestObj.navigationHash === 'string') {
78
+ resultManifest.navigationHash = manifestObj.navigationHash;
79
+ }
80
+ return resultManifest;
75
81
  }
76
82
  catch (error) {
77
83
  const nodeError = error;
@@ -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,EAAE,WAAW,EAAE,SAAS,EAAE,OAAO,EAAkB,MAAM,mBAAmB,CAAC;AAuLzF,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,WAAW,GAAG,GAAG,CAW7D;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,GACrB,OAAO,CAAC,MAAM,CAAC,CA6JjB"}
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,EAAE,WAAW,EAAE,SAAS,EAAE,OAAO,EAAkB,MAAM,mBAAmB,CAAC;AAmNzF,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,WAAW,GAAG,GAAG,CAW7D;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,GACrB,OAAO,CAAC,MAAM,CAAC,CAsLjB"}
@@ -2,7 +2,7 @@ import { Eta } from 'eta';
2
2
  import { join, dirname, relative, basename, posix } from 'path';
3
3
  import glob from 'fast-glob';
4
4
  import { TEMPLATE_EXTENSION } from '../constants.js';
5
- import { getStatiVersion, isCollectionIndexPage, discoverLayout, getCollectionPathForPage, resolveSrcDir, createTemplateError, createValidatingPartialsProxy, propValue, } from './utils/index.js';
5
+ import { getStatiVersion, isCollectionIndexPage, discoverLayout, getCollectionPathForPage, resolveSrcDir, createTemplateError, createValidatingPartialsProxy, propValue, wrapPartialsAsCallable, createNavigationHelpers, } from './utils/index.js';
6
6
  import { getEnv } from '../env.js';
7
7
  import { generateSEO } from '../seo/index.js';
8
8
  /**
@@ -100,6 +100,25 @@ function buildCollectionData(currentPage, allPages) {
100
100
  },
101
101
  };
102
102
  }
103
+ /**
104
+ * Builds collection data for a child page (showing parent collection data).
105
+ * Finds the parent collection and returns its data.
106
+ *
107
+ * @param currentPage - The current page being rendered
108
+ * @param allPages - All pages in the site
109
+ * @returns Collection data for the parent collection or undefined
110
+ */
111
+ function buildParentCollectionData(currentPage, allPages) {
112
+ // Get the parent collection path
113
+ const parentPath = getCollectionPathForPage(currentPage.url);
114
+ // Find the index page for the parent collection
115
+ const parentIndexPage = allPages.find((p) => p.url === parentPath);
116
+ if (parentIndexPage) {
117
+ // Build collection data for the parent
118
+ return buildCollectionData(parentIndexPage, allPages);
119
+ }
120
+ return undefined;
121
+ }
103
122
  /**
104
123
  * Discovers partials in the hierarchy for a given page path.
105
124
  * Scans all parent directories for folders starting with underscore.
@@ -168,15 +187,26 @@ export async function renderPage(page, body, config, eta, navigation, allPages)
168
187
  const srcDir = resolveSrcDir(config);
169
188
  const relativePath = relative(srcDir, page.sourcePath);
170
189
  const partialPaths = await discoverPartials(relativePath, config);
171
- // Build collection data if this is an index page and all pages are provided
190
+ // Build collection data based on page type
172
191
  let collectionData;
173
192
  const isIndexPage = allPages && isCollectionIndexPage(page, allPages);
174
- if (isIndexPage) {
175
- collectionData = buildCollectionData(page, allPages);
193
+ if (allPages) {
194
+ if (isIndexPage) {
195
+ // For index pages, show their own collection data
196
+ collectionData = buildCollectionData(page, allPages);
197
+ }
198
+ else {
199
+ // For child pages, show parent collection data
200
+ collectionData = buildParentCollectionData(page, allPages);
201
+ }
176
202
  }
177
203
  // Discover the appropriate layout using hierarchical layout.eta convention
178
204
  // Pass isIndexPage flag to enable index.eta lookup for aggregation pages
179
205
  const layoutPath = await discoverLayout(relativePath, config, page.frontMatter.layout, isIndexPage);
206
+ // Create navigation helpers
207
+ const navTree = navigation || [];
208
+ const navHelpers = createNavigationHelpers(navTree, page);
209
+ const currentNavNode = navHelpers.getCurrentNode();
180
210
  // Create base context for partial rendering
181
211
  const baseContext = {
182
212
  site: config.site,
@@ -185,10 +215,11 @@ export async function renderPage(page, body, config, eta, navigation, allPages)
185
215
  path: page.url,
186
216
  url: page.url, // Add url property for compatibility
187
217
  content: body,
218
+ navNode: currentNavNode, // Add current page's navigation node
188
219
  },
189
220
  content: body,
190
- navigation: navigation || [],
191
- collection: collectionData, // Add collection data for index pages
221
+ nav: navHelpers, // Replace navigation with nav helpers
222
+ collection: collectionData, // Add collection data
192
223
  // Add custom filters to context
193
224
  ...(config.eta?.filters || {}),
194
225
  generator: {
@@ -223,9 +254,11 @@ export async function renderPage(page, body, config, eta, navigation, allPages)
223
254
  try {
224
255
  // Create context with all previously rendered partials available
225
256
  const combinedPartials = { ...renderedPartials, ...passRenderedPartials };
257
+ // Wrap partials as callable before passing to validation proxy
258
+ const callablePartials = wrapPartialsAsCallable(eta, combinedPartials, partialPaths, baseContext);
226
259
  const partialContext = {
227
260
  ...baseContext,
228
- partials: createValidatingPartialsProxy(combinedPartials), // Include both previous and current pass partials with validation
261
+ partials: createValidatingPartialsProxy(callablePartials), // Include both previous and current pass partials with validation
229
262
  };
230
263
  const renderedContent = await eta.renderAsync(partialPath, partialContext);
231
264
  passRenderedPartials[partialName] = renderedContent;
@@ -268,9 +301,11 @@ export async function renderPage(page, body, config, eta, navigation, allPages)
268
301
  break;
269
302
  }
270
303
  }
304
+ // Wrap final rendered partials as callable before passing to layout context
305
+ const callablePartials = wrapPartialsAsCallable(eta, renderedPartials, partialPaths, baseContext);
271
306
  const context = {
272
307
  ...baseContext,
273
- partials: createValidatingPartialsProxy(renderedPartials), // Add rendered partials with validation
308
+ partials: createValidatingPartialsProxy(callablePartials), // Add rendered partials with validation
274
309
  };
275
310
  try {
276
311
  if (!layoutPath) {
@@ -0,0 +1,60 @@
1
+ import { Eta } from 'eta';
2
+ /**
3
+ * Type definition for a callable partial function.
4
+ * Can be called with optional props or used directly as a value.
5
+ */
6
+ export type CallablePartial = {
7
+ (props?: Record<string, unknown>): string;
8
+ toString(): string;
9
+ valueOf(): string;
10
+ };
11
+ /**
12
+ * Creates a callable partial that can be used both as a value and as a function.
13
+ * This enables both syntaxes:
14
+ * - Direct usage: <%~ stati.partials.header %>
15
+ * - With props: <%~ stati.partials.hero({ title: 'Hello' }) %>
16
+ *
17
+ * @param eta - The Eta template engine instance
18
+ * @param partialPath - Absolute path to the partial template file
19
+ * @param baseContext - The base template context (without props)
20
+ * @param renderedContent - Pre-rendered content for the no-props case
21
+ * @returns A callable partial function
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * const callable = makeCallablePartial(eta, '/path/to/partial.eta', baseContext, '<div>Header</div>');
26
+ *
27
+ * // Use without props (returns pre-rendered content)
28
+ * const html1 = callable.toString(); // '<div>Header</div>'
29
+ *
30
+ * // Use with props (re-renders with merged context)
31
+ * const html2 = callable({ title: 'Custom Title' }); // Renders with custom props
32
+ * ```
33
+ */
34
+ export declare function makeCallablePartial(eta: Eta, partialPath: string, baseContext: Record<string, unknown>, renderedContent: string): CallablePartial;
35
+ /**
36
+ * Wraps all partials in a record with callable partial wrappers.
37
+ * This allows partials to be used both as values and as functions.
38
+ *
39
+ * @param eta - The Eta template engine instance
40
+ * @param partials - Record mapping partial names to their rendered content
41
+ * @param partialPaths - Record mapping partial names to their absolute file paths
42
+ * @param baseContext - The base template context (without props)
43
+ * @returns Record of callable partials
44
+ *
45
+ * @example
46
+ * ```typescript
47
+ * const callablePartials = wrapPartialsAsCallable(
48
+ * eta,
49
+ * { header: '<div>Header</div>', footer: '<div>Footer</div>' },
50
+ * { header: '/path/to/header.eta', footer: '/path/to/footer.eta' },
51
+ * baseContext
52
+ * );
53
+ *
54
+ * // Both syntaxes work
55
+ * callablePartials.header.toString(); // Direct usage
56
+ * callablePartials.header({ title: 'Custom' }); // With props
57
+ * ```
58
+ */
59
+ export declare function wrapPartialsAsCallable(eta: Eta, partials: Record<string, string>, partialPaths: Record<string, string>, baseContext: Record<string, unknown>): Record<string, CallablePartial>;
60
+ //# sourceMappingURL=callable-partials.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"callable-partials.d.ts","sourceRoot":"","sources":["../../../src/core/utils/callable-partials.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,KAAK,CAAC;AAE1B;;;GAGG;AACH,MAAM,MAAM,eAAe,GAAG;IAC5B,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC;IAC1C,QAAQ,IAAI,MAAM,CAAC;IACnB,OAAO,IAAI,MAAM,CAAC;CACnB,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,GAAG,EACR,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACpC,eAAe,EAAE,MAAM,GACtB,eAAe,CAqDjB;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,GAAG,EACR,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAChC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EACpC,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACnC,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAcjC"}