@stati/core 1.18.0 → 1.20.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 (58) hide show
  1. package/dist/core/build.d.ts +4 -2
  2. package/dist/core/build.d.ts.map +1 -1
  3. package/dist/core/build.js +53 -8
  4. package/dist/core/index.d.ts +1 -0
  5. package/dist/core/index.d.ts.map +1 -1
  6. package/dist/core/index.js +2 -0
  7. package/dist/core/markdown.d.ts +9 -0
  8. package/dist/core/markdown.d.ts.map +1 -1
  9. package/dist/core/markdown.js +12 -0
  10. package/dist/core/preview.js +1 -1
  11. package/dist/core/utils/html.utils.d.ts +35 -0
  12. package/dist/core/utils/html.utils.d.ts.map +1 -0
  13. package/dist/core/utils/html.utils.js +46 -0
  14. package/dist/core/utils/index.d.ts +1 -0
  15. package/dist/core/utils/index.d.ts.map +1 -1
  16. package/dist/core/utils/index.js +2 -0
  17. package/dist/index.d.ts +2 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -1
  20. package/dist/metrics/index.d.ts +1 -1
  21. package/dist/metrics/index.d.ts.map +1 -1
  22. package/dist/metrics/index.js +1 -1
  23. package/dist/metrics/noop.d.ts.map +1 -1
  24. package/dist/metrics/noop.js +2 -1
  25. package/dist/metrics/recorder.d.ts.map +1 -1
  26. package/dist/metrics/recorder.js +6 -3
  27. package/dist/metrics/types.d.ts +9 -4
  28. package/dist/metrics/types.d.ts.map +1 -1
  29. package/dist/metrics/utils/index.d.ts +1 -1
  30. package/dist/metrics/utils/index.d.ts.map +1 -1
  31. package/dist/metrics/utils/index.js +1 -1
  32. package/dist/metrics/utils/writer.utils.d.ts +0 -7
  33. package/dist/metrics/utils/writer.utils.d.ts.map +1 -1
  34. package/dist/metrics/utils/writer.utils.js +0 -63
  35. package/dist/search/auto-inject.d.ts +21 -0
  36. package/dist/search/auto-inject.d.ts.map +1 -0
  37. package/dist/search/auto-inject.js +33 -0
  38. package/dist/search/constants.d.ts +28 -0
  39. package/dist/search/constants.d.ts.map +1 -0
  40. package/dist/search/constants.js +27 -0
  41. package/dist/search/generator.d.ts +101 -0
  42. package/dist/search/generator.d.ts.map +1 -0
  43. package/dist/search/generator.js +278 -0
  44. package/dist/search/index.d.ts +24 -0
  45. package/dist/search/index.d.ts.map +1 -0
  46. package/dist/search/index.js +22 -0
  47. package/dist/seo/auto-inject.d.ts.map +1 -1
  48. package/dist/seo/auto-inject.js +5 -18
  49. package/dist/types/config.d.ts +3 -0
  50. package/dist/types/config.d.ts.map +1 -1
  51. package/dist/types/content.d.ts +6 -0
  52. package/dist/types/content.d.ts.map +1 -1
  53. package/dist/types/index.d.ts +1 -0
  54. package/dist/types/index.d.ts.map +1 -1
  55. package/dist/types/search.d.ts +121 -0
  56. package/dist/types/search.d.ts.map +1 -0
  57. package/dist/types/search.js +5 -0
  58. package/package.json +1 -1
@@ -8,26 +8,6 @@ import { join, dirname } from 'node:path';
8
8
  * Default metrics output directory (relative to cache dir).
9
9
  */
10
10
  export const DEFAULT_METRICS_DIR = 'metrics';
11
- /**
12
- * Display names for build phases.
13
- * Maps raw phase keys (e.g., 'configLoadMs') to human-readable labels.
14
- */
15
- const PHASE_DISPLAY_NAMES = {
16
- configLoadMs: 'Config Load',
17
- contentDiscoveryMs: 'Content Discovery',
18
- navigationBuildMs: 'Navigation Build',
19
- cacheManifestLoadMs: 'Cache Manifest Load',
20
- typescriptCompileMs: 'TypeScript Compile',
21
- pageRenderingMs: 'Page Rendering',
22
- assetCopyMs: 'Asset Copy',
23
- cacheManifestSaveMs: 'Cache Manifest Save',
24
- sitemapGenerationMs: 'Sitemap Generation',
25
- rssGenerationMs: 'RSS Generation',
26
- hookBeforeAllMs: 'Hook: Before All',
27
- hookAfterAllMs: 'Hook: After All',
28
- hookBeforeRenderTotalMs: 'Hook: Before Render (Total)',
29
- hookAfterRenderTotalMs: 'Hook: After Render (Total)',
30
- };
31
11
  /**
32
12
  * Generate a timestamped filename for metrics output.
33
13
  *
@@ -100,46 +80,3 @@ export async function writeMetrics(metrics, options) {
100
80
  };
101
81
  }
102
82
  }
103
- /**
104
- * Format metrics for CLI summary output.
105
- *
106
- * @param metrics - Build metrics to format
107
- * @returns Array of formatted lines for CLI output
108
- */
109
- export function formatMetricsSummary(metrics) {
110
- const lines = [];
111
- // Header
112
- lines.push('');
113
- lines.push('Build Metrics Summary');
114
- lines.push('─'.repeat(40));
115
- // Total duration
116
- const totalSeconds = (metrics.totals.durationMs / 1000).toFixed(2);
117
- lines.push(`Total build time: ${totalSeconds}s`);
118
- // Page stats
119
- const { totalPages, renderedPages, cachedPages } = metrics.counts;
120
- lines.push(`Pages: ${totalPages} total, ${renderedPages} rendered, ${cachedPages} cached`);
121
- // Cache hit rate
122
- const hitRate = (metrics.isg.cacheHitRate * 100).toFixed(1);
123
- lines.push(`Cache hit rate: ${hitRate}%`);
124
- // Memory
125
- const peakMB = (metrics.totals.peakRssBytes / 1024 / 1024).toFixed(1);
126
- lines.push(`Peak memory: ${peakMB} MB`);
127
- // Top phases (sorted by duration, top 3)
128
- const phases = Object.entries(metrics.phases)
129
- .filter(([, duration]) => duration !== undefined && duration > 0)
130
- .map(([name, duration]) => ({ name, duration: duration }))
131
- .sort((a, b) => b.duration - a.duration)
132
- .slice(0, 3);
133
- if (phases.length > 0) {
134
- lines.push('');
135
- lines.push('Top phases:');
136
- for (const phase of phases) {
137
- // Use mapped display name if available, otherwise fall back to raw name
138
- const phaseName = PHASE_DISPLAY_NAMES[phase.name] || phase.name;
139
- const phaseMs = phase.duration.toFixed(0);
140
- lines.push(` ${phaseName}: ${phaseMs}ms`);
141
- }
142
- }
143
- lines.push('─'.repeat(40));
144
- return lines;
145
- }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Search index auto-injection utilities.
3
+ * @module search/auto-inject
4
+ */
5
+ /**
6
+ * Injects a meta tag with the search index path into HTML.
7
+ * The meta tag allows client-side JavaScript to discover the search index location.
8
+ *
9
+ * @param html - Rendered HTML content
10
+ * @param searchIndexPath - Path to the search index file (e.g., '/search-index-a1b2c3d4.json')
11
+ * @returns HTML with injected search meta tag
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * const html = '<html><head><title>Page</title></head><body>Content</body></html>';
16
+ * const enhanced = autoInjectSearchMeta(html, '/search-index-abc123.json');
17
+ * // Returns HTML with <meta name="stati:search-index" content="/search-index-abc123.json">
18
+ * ```
19
+ */
20
+ export declare function autoInjectSearchMeta(html: string, searchIndexPath: string): string;
21
+ //# sourceMappingURL=auto-inject.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auto-inject.d.ts","sourceRoot":"","sources":["../../src/search/auto-inject.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAKH;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,eAAe,EAAE,MAAM,GAAG,MAAM,CAgBlF"}
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Search index auto-injection utilities.
3
+ * @module search/auto-inject
4
+ */
5
+ import { injectBeforeHeadClose } from '../core/index.js';
6
+ import { SEARCH_INDEX_META_NAME } from './constants.js';
7
+ /**
8
+ * Injects a meta tag with the search index path into HTML.
9
+ * The meta tag allows client-side JavaScript to discover the search index location.
10
+ *
11
+ * @param html - Rendered HTML content
12
+ * @param searchIndexPath - Path to the search index file (e.g., '/search-index-a1b2c3d4.json')
13
+ * @returns HTML with injected search meta tag
14
+ *
15
+ * @example
16
+ * ```typescript
17
+ * const html = '<html><head><title>Page</title></head><body>Content</body></html>';
18
+ * const enhanced = autoInjectSearchMeta(html, '/search-index-abc123.json');
19
+ * // Returns HTML with <meta name="stati:search-index" content="/search-index-abc123.json">
20
+ * ```
21
+ */
22
+ export function autoInjectSearchMeta(html, searchIndexPath) {
23
+ // Check if meta tag already exists using regex to handle attribute order variations
24
+ // Matches: <meta name="stati:search-index" ...> or <meta content="..." name="stati:search-index" ...>
25
+ const existingMetaPattern = new RegExp(`<meta[^>]*name=["']${SEARCH_INDEX_META_NAME}["'][^>]*>`, 'i');
26
+ if (existingMetaPattern.test(html)) {
27
+ return html;
28
+ }
29
+ // Create the meta tag
30
+ const metaTag = `<meta name="${SEARCH_INDEX_META_NAME}" content="${searchIndexPath}">`;
31
+ // Inject before </head>
32
+ return injectBeforeHeadClose(html, metaTag);
33
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Search index constants.
3
+ * @module search/constants
4
+ */
5
+ /** Meta tag name used to expose the search index path to client-side code */
6
+ export declare const SEARCH_INDEX_META_NAME = "stati:search-index";
7
+ /** Current schema version for the search index */
8
+ export declare const SEARCH_INDEX_VERSION = "1.0.0";
9
+ /** Default configuration values for search index generation */
10
+ export declare const SEARCH_DEFAULTS: {
11
+ /** Default base filename for the search index */
12
+ readonly indexName: "search-index";
13
+ /** Whether to include content hash in filename by default */
14
+ readonly hashFilename: true;
15
+ /** Default maximum content length per section (in characters) */
16
+ readonly maxContentLength: 1000;
17
+ /** Default maximum preview length for page-level entries (in characters) */
18
+ readonly maxPreviewLength: 500;
19
+ /** Default heading levels to include in the index */
20
+ readonly headingLevels: readonly number[];
21
+ /** Default exclude patterns */
22
+ readonly exclude: readonly string[];
23
+ /** Whether to include the home page by default */
24
+ readonly includeHomePage: false;
25
+ /** Whether to auto-inject search index meta tag by default */
26
+ readonly autoInjectMetaTag: true;
27
+ };
28
+ //# sourceMappingURL=constants.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../../src/search/constants.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,6EAA6E;AAC7E,eAAO,MAAM,sBAAsB,uBAAuB,CAAC;AAE3D,kDAAkD;AAClD,eAAO,MAAM,oBAAoB,UAAU,CAAC;AAE5C,+DAA+D;AAC/D,eAAO,MAAM,eAAe;IAC1B,iDAAiD;;IAEjD,6DAA6D;;IAE7D,iEAAiE;;IAEjE,4EAA4E;;IAE5E,qDAAqD;4BACnB,SAAS,MAAM,EAAE;IACnD,+BAA+B;sBAChB,SAAS,MAAM,EAAE;IAChC,kDAAkD;;IAElD,8DAA8D;;CAEtD,CAAC"}
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Search index constants.
3
+ * @module search/constants
4
+ */
5
+ /** Meta tag name used to expose the search index path to client-side code */
6
+ export const SEARCH_INDEX_META_NAME = 'stati:search-index';
7
+ /** Current schema version for the search index */
8
+ export const SEARCH_INDEX_VERSION = '1.0.0';
9
+ /** Default configuration values for search index generation */
10
+ export const SEARCH_DEFAULTS = {
11
+ /** Default base filename for the search index */
12
+ indexName: 'search-index',
13
+ /** Whether to include content hash in filename by default */
14
+ hashFilename: true,
15
+ /** Default maximum content length per section (in characters) */
16
+ maxContentLength: 1000,
17
+ /** Default maximum preview length for page-level entries (in characters) */
18
+ maxPreviewLength: 500,
19
+ /** Default heading levels to include in the index */
20
+ headingLevels: [2, 3, 4, 5, 6],
21
+ /** Default exclude patterns */
22
+ exclude: [],
23
+ /** Whether to include the home page by default */
24
+ includeHomePage: false,
25
+ /** Whether to auto-inject search index meta tag by default */
26
+ autoInjectMetaTag: true,
27
+ };
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Search index generator utilities.
3
+ * @module search/generator
4
+ */
5
+ import type { PageModel, TocEntry } from '../types/content.js';
6
+ import type { SearchConfig, SearchDocument, SearchIndex, SearchIndexMetadata } from '../types/search.js';
7
+ /**
8
+ * Generates a short content hash for cache busting.
9
+ * Uses MD5 and returns first 8 characters.
10
+ *
11
+ * @param content - Content to hash
12
+ * @returns 8-character hash string
13
+ */
14
+ export declare function generateContentHash(content: string): string;
15
+ /**
16
+ * Builds a breadcrumb string from a URL path.
17
+ *
18
+ * @param url - Page URL path (e.g., '/getting-started/installation')
19
+ * @param pageTitle - Page title to use as the last segment
20
+ * @returns Breadcrumb string (e.g., 'Getting Started > Installation')
21
+ */
22
+ export declare function buildBreadcrumb(url: string, pageTitle: string): string;
23
+ /**
24
+ * Strips Markdown syntax to extract plain text for search indexing.
25
+ * Handles code blocks, links, images, emphasis, etc.
26
+ *
27
+ * @param markdown - Raw markdown content
28
+ * @returns Plain text content
29
+ */
30
+ export declare function stripMarkdown(markdown: string): string;
31
+ /**
32
+ * Extracts searchable sections from page data using TOC entries and markdown content.
33
+ * This is more efficient than parsing rendered HTML as it uses structured data
34
+ * already available from the markdown processing pipeline.
35
+ *
36
+ * @param toc - Table of contents entries with heading IDs, text, and levels
37
+ * @param markdownContent - Raw markdown content of the page
38
+ * @param pageUrl - Page URL path
39
+ * @param pageTitle - Page title from frontmatter
40
+ * @param tags - Optional tags from frontmatter
41
+ * @param config - Search configuration
42
+ * @returns Array of SearchDocument objects
43
+ */
44
+ export declare function extractSectionsFromMarkdown(toc: TocEntry[], markdownContent: string, pageUrl: string, pageTitle: string, tags: readonly string[] | undefined, config: SearchConfig): SearchDocument[];
45
+ /**
46
+ * Checks if a page should be excluded from the search index.
47
+ *
48
+ * @param page - Page model to check
49
+ * @param config - Search configuration
50
+ * @returns true if the page should be excluded
51
+ */
52
+ export declare function shouldExcludePage(page: PageModel, config: SearchConfig): boolean;
53
+ /**
54
+ * Page data for search indexing using markdown and TOC entries.
55
+ * Uses structured data already available from the markdown processing pipeline.
56
+ */
57
+ export interface SearchablePage {
58
+ /** The page model with metadata */
59
+ page: PageModel;
60
+ /** Table of contents entries extracted during markdown rendering */
61
+ toc: TocEntry[];
62
+ /** Raw markdown content for section extraction */
63
+ markdownContent: string;
64
+ }
65
+ /**
66
+ * Generates a search index from searchable pages using markdown and TOC data.
67
+ *
68
+ * @param searchablePages - Array of pages with TOC and markdown content
69
+ * @param config - Search configuration
70
+ * @returns SearchIndex object
71
+ */
72
+ export declare function generateSearchIndex(searchablePages: SearchablePage[], config: SearchConfig): SearchIndex;
73
+ /**
74
+ * Writes the search index to a JSON file.
75
+ *
76
+ * @param searchIndex - The search index to write
77
+ * @param outDir - Output directory path
78
+ * @param filename - Pre-computed filename (from computeSearchIndexFilename)
79
+ * @returns Metadata about the written search index
80
+ */
81
+ export declare function writeSearchIndex(searchIndex: SearchIndex, outDir: string, filename: string): Promise<SearchIndexMetadata>;
82
+ /**
83
+ * Computes the search index filename based on configuration.
84
+ * When hashFilename is true, generates a deterministic hash based on the build ID.
85
+ * This allows the filename to be known before the index is generated,
86
+ * enabling meta tag injection during initial template render.
87
+ *
88
+ * @param config - Search configuration
89
+ * @param buildId - Unique identifier for this build (e.g., timestamp or build ID)
90
+ * @returns The search index filename (without leading slash)
91
+ *
92
+ * @example
93
+ * ```typescript
94
+ * // At build start, compute the filename
95
+ * const filename = computeSearchIndexFilename(config.search, Date.now().toString());
96
+ * // filename: 'search-index-a1b2c3d4.json' (when hashFilename is true)
97
+ * // filename: 'search-index.json' (when hashFilename is false)
98
+ * ```
99
+ */
100
+ export declare function computeSearchIndexFilename(config: SearchConfig, buildId?: string): string;
101
+ //# sourceMappingURL=generator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"generator.d.ts","sourceRoot":"","sources":["../../src/search/generator.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAOH,OAAO,KAAK,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAC/D,OAAO,KAAK,EACV,YAAY,EACZ,cAAc,EACd,WAAW,EACX,mBAAmB,EACpB,MAAM,oBAAoB,CAAC;AAE5B;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAE3D;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAuBtE;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CA0CtD;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,2BAA2B,CACzC,GAAG,EAAE,QAAQ,EAAE,EACf,eAAe,EAAE,MAAM,EACvB,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,SAAS,EACnC,MAAM,EAAE,YAAY,GACnB,cAAc,EAAE,CAiGlB;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,YAAY,GAAG,OAAO,CAsBhF;AAED;;;GAGG;AACH,MAAM,WAAW,cAAc;IAC7B,mCAAmC;IACnC,IAAI,EAAE,SAAS,CAAC;IAChB,oEAAoE;IACpE,GAAG,EAAE,QAAQ,EAAE,CAAC;IAChB,kDAAkD;IAClD,eAAe,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CACjC,eAAe,EAAE,cAAc,EAAE,EACjC,MAAM,EAAE,YAAY,GACnB,WAAW,CA6Bb;AAED;;;;;;;GAOG;AACH,wBAAsB,gBAAgB,CACpC,WAAW,EAAE,WAAW,EACxB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,mBAAmB,CAAC,CAgB9B;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,0BAA0B,CAAC,MAAM,EAAE,YAAY,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAczF"}
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Search index generator utilities.
3
+ * @module search/generator
4
+ */
5
+ import { createHash } from 'node:crypto';
6
+ import { join } from 'node:path';
7
+ import { minimatch } from 'minimatch';
8
+ import { writeFile, ensureDir } from '../core/utils/fs.utils.js';
9
+ import { SEARCH_INDEX_VERSION, SEARCH_DEFAULTS } from './constants.js';
10
+ /**
11
+ * Generates a short content hash for cache busting.
12
+ * Uses MD5 and returns first 8 characters.
13
+ *
14
+ * @param content - Content to hash
15
+ * @returns 8-character hash string
16
+ */
17
+ export function generateContentHash(content) {
18
+ return createHash('md5').update(content).digest('hex').substring(0, 8);
19
+ }
20
+ /**
21
+ * Builds a breadcrumb string from a URL path.
22
+ *
23
+ * @param url - Page URL path (e.g., '/getting-started/installation')
24
+ * @param pageTitle - Page title to use as the last segment
25
+ * @returns Breadcrumb string (e.g., 'Getting Started > Installation')
26
+ */
27
+ export function buildBreadcrumb(url, pageTitle) {
28
+ if (url === '/' || url === '') {
29
+ return pageTitle;
30
+ }
31
+ const segments = url.split('/').filter(Boolean);
32
+ if (segments.length === 0) {
33
+ return pageTitle;
34
+ }
35
+ // Convert slug segments to title case
36
+ const crumbs = segments.slice(0, -1).map((segment) => segment
37
+ .split('-')
38
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
39
+ .join(' '));
40
+ // Add the page title as the last crumb
41
+ crumbs.push(pageTitle);
42
+ return crumbs.join(' > ');
43
+ }
44
+ /**
45
+ * Strips Markdown syntax to extract plain text for search indexing.
46
+ * Handles code blocks, links, images, emphasis, etc.
47
+ *
48
+ * @param markdown - Raw markdown content
49
+ * @returns Plain text content
50
+ */
51
+ export function stripMarkdown(markdown) {
52
+ let text = markdown;
53
+ // Remove code blocks (fenced and indented)
54
+ text = text.replace(/```[\s\S]*?```/g, ' ');
55
+ text = text.replace(/`[^`]+`/g, ' ');
56
+ // Remove images ![alt](url)
57
+ text = text.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ');
58
+ // Convert links [text](url) to just text
59
+ text = text.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1');
60
+ // Remove reference-style link definitions [id]: url
61
+ text = text.replace(/^\[[^\]]+\]:\s*\S+.*$/gm, '');
62
+ // Remove HTML tags
63
+ text = text.replace(/<[^>]+>/g, ' ');
64
+ // Remove emphasis markers (bold, italic)
65
+ text = text.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, '$1');
66
+ // Remove strikethrough
67
+ text = text.replace(/~~([^~]+)~~/g, '$1');
68
+ // Remove headings markers (but keep the text)
69
+ text = text.replace(/^#{1,6}\s+/gm, '');
70
+ // Remove blockquote markers
71
+ text = text.replace(/^>\s*/gm, '');
72
+ // Remove horizontal rules
73
+ text = text.replace(/^[-*_]{3,}\s*$/gm, '');
74
+ // Remove list markers
75
+ text = text.replace(/^[\s]*[-*+]\s+/gm, '');
76
+ text = text.replace(/^[\s]*\d+\.\s+/gm, '');
77
+ // Normalize whitespace
78
+ text = text.replace(/\s+/g, ' ').trim();
79
+ return text;
80
+ }
81
+ /**
82
+ * Extracts searchable sections from page data using TOC entries and markdown content.
83
+ * This is more efficient than parsing rendered HTML as it uses structured data
84
+ * already available from the markdown processing pipeline.
85
+ *
86
+ * @param toc - Table of contents entries with heading IDs, text, and levels
87
+ * @param markdownContent - Raw markdown content of the page
88
+ * @param pageUrl - Page URL path
89
+ * @param pageTitle - Page title from frontmatter
90
+ * @param tags - Optional tags from frontmatter
91
+ * @param config - Search configuration
92
+ * @returns Array of SearchDocument objects
93
+ */
94
+ export function extractSectionsFromMarkdown(toc, markdownContent, pageUrl, pageTitle, tags, config) {
95
+ const documents = [];
96
+ const headingLevels = config.headingLevels ?? SEARCH_DEFAULTS.headingLevels;
97
+ const maxContentLength = config.maxContentLength ?? SEARCH_DEFAULTS.maxContentLength;
98
+ const maxPreviewLength = config.maxPreviewLength ?? SEARCH_DEFAULTS.maxPreviewLength;
99
+ const breadcrumb = buildBreadcrumb(pageUrl, pageTitle);
100
+ // Filter TOC entries to only include configured heading levels
101
+ const filteredToc = toc.filter((entry) => headingLevels.includes(entry.level));
102
+ // Find heading positions in markdown content
103
+ // Headings in markdown are lines starting with # symbols
104
+ const lines = markdownContent.split('\n');
105
+ const headingPositions = [];
106
+ // Build a map of heading text to TOC entries for matching
107
+ let charIndex = 0;
108
+ for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
109
+ const line = lines[lineIndex] ?? '';
110
+ const headingMatch = line.match(/^(#{2,6})\s+(.+)$/);
111
+ if (headingMatch) {
112
+ const headingText = headingMatch[2]?.trim() ?? '';
113
+ // Find matching TOC entry by text (case-insensitive, normalized)
114
+ const normalizedText = headingText.toLowerCase().replace(/[*_`[\]]/g, '');
115
+ const matchingEntry = filteredToc.find((entry) => entry.text.toLowerCase() === normalizedText || entry.text === headingText);
116
+ if (matchingEntry) {
117
+ headingPositions.push({
118
+ entry: matchingEntry,
119
+ lineIndex,
120
+ charIndex,
121
+ });
122
+ }
123
+ }
124
+ charIndex += line.length + 1; // +1 for newline
125
+ }
126
+ // Extract content before first heading for page-level document
127
+ const firstHeadingChar = headingPositions[0]?.charIndex ?? markdownContent.length;
128
+ const pageContentMarkdown = markdownContent.substring(0, firstHeadingChar);
129
+ const pageContent = stripMarkdown(pageContentMarkdown).substring(0, maxPreviewLength);
130
+ // Create page-level document (level 1 = page title)
131
+ documents.push({
132
+ id: `${pageUrl}#top`,
133
+ url: pageUrl,
134
+ anchor: '',
135
+ title: pageTitle,
136
+ heading: pageTitle,
137
+ level: 1,
138
+ content: pageContent,
139
+ breadcrumb,
140
+ ...(tags && tags.length > 0 ? { tags: [...tags] } : {}),
141
+ });
142
+ // Create documents for each heading section
143
+ for (let i = 0; i < headingPositions.length; i++) {
144
+ const pos = headingPositions[i];
145
+ if (!pos)
146
+ continue;
147
+ const nextPos = headingPositions[i + 1];
148
+ const sectionStart = pos.charIndex;
149
+ const sectionEnd = nextPos ? nextPos.charIndex : markdownContent.length;
150
+ // Extract section content (skip the heading line itself)
151
+ const sectionLines = markdownContent.substring(sectionStart, sectionEnd).split('\n');
152
+ const contentLines = sectionLines.slice(1); // Skip heading line
153
+ const sectionMarkdown = contentLines.join('\n');
154
+ const sectionContent = stripMarkdown(sectionMarkdown).substring(0, maxContentLength);
155
+ // Skip empty sections
156
+ if (!sectionContent.trim()) {
157
+ continue;
158
+ }
159
+ documents.push({
160
+ id: `${pageUrl}#${pos.entry.id}`,
161
+ url: pageUrl,
162
+ anchor: pos.entry.id,
163
+ title: pageTitle,
164
+ heading: pos.entry.text,
165
+ level: pos.entry.level,
166
+ content: sectionContent,
167
+ breadcrumb: `${breadcrumb} > ${pos.entry.text}`,
168
+ ...(tags && tags.length > 0 ? { tags: [...tags] } : {}),
169
+ });
170
+ }
171
+ return documents;
172
+ }
173
+ /**
174
+ * Checks if a page should be excluded from the search index.
175
+ *
176
+ * @param page - Page model to check
177
+ * @param config - Search configuration
178
+ * @returns true if the page should be excluded
179
+ */
180
+ export function shouldExcludePage(page, config) {
181
+ // Exclude drafts
182
+ if (page.frontMatter.draft) {
183
+ return true;
184
+ }
185
+ // Check home page exclusion
186
+ const isHomePage = page.url === '/' || page.url === '';
187
+ const includeHomePage = config.includeHomePage ?? SEARCH_DEFAULTS.includeHomePage;
188
+ if (isHomePage && !includeHomePage) {
189
+ return true;
190
+ }
191
+ // Check exclude patterns
192
+ const excludePatterns = config.exclude ?? SEARCH_DEFAULTS.exclude;
193
+ for (const pattern of excludePatterns) {
194
+ if (minimatch(page.url, pattern)) {
195
+ return true;
196
+ }
197
+ }
198
+ return false;
199
+ }
200
+ /**
201
+ * Generates a search index from searchable pages using markdown and TOC data.
202
+ *
203
+ * @param searchablePages - Array of pages with TOC and markdown content
204
+ * @param config - Search configuration
205
+ * @returns SearchIndex object
206
+ */
207
+ export function generateSearchIndex(searchablePages, config) {
208
+ const documents = [];
209
+ for (const { page, toc, markdownContent } of searchablePages) {
210
+ // Skip excluded pages
211
+ if (shouldExcludePage(page, config)) {
212
+ continue;
213
+ }
214
+ const pageTitle = page.frontMatter.title || page.slug;
215
+ const tags = page.frontMatter.tags;
216
+ const pageDocs = extractSectionsFromMarkdown(toc, markdownContent, page.url, pageTitle, tags, config);
217
+ documents.push(...pageDocs);
218
+ }
219
+ return {
220
+ version: SEARCH_INDEX_VERSION,
221
+ generatedAt: new Date().toISOString(),
222
+ documentCount: documents.length,
223
+ documents,
224
+ };
225
+ }
226
+ /**
227
+ * Writes the search index to a JSON file.
228
+ *
229
+ * @param searchIndex - The search index to write
230
+ * @param outDir - Output directory path
231
+ * @param filename - Pre-computed filename (from computeSearchIndexFilename)
232
+ * @returns Metadata about the written search index
233
+ */
234
+ export async function writeSearchIndex(searchIndex, outDir, filename) {
235
+ // Serialize index
236
+ const content = JSON.stringify(searchIndex, null, 0);
237
+ // Ensure output directory exists
238
+ await ensureDir(outDir);
239
+ // Write the file
240
+ const filePath = join(outDir, filename);
241
+ await writeFile(filePath, content, 'utf-8');
242
+ return {
243
+ enabled: true,
244
+ indexPath: `/${filename}`,
245
+ documentCount: searchIndex.documentCount,
246
+ };
247
+ }
248
+ /**
249
+ * Computes the search index filename based on configuration.
250
+ * When hashFilename is true, generates a deterministic hash based on the build ID.
251
+ * This allows the filename to be known before the index is generated,
252
+ * enabling meta tag injection during initial template render.
253
+ *
254
+ * @param config - Search configuration
255
+ * @param buildId - Unique identifier for this build (e.g., timestamp or build ID)
256
+ * @returns The search index filename (without leading slash)
257
+ *
258
+ * @example
259
+ * ```typescript
260
+ * // At build start, compute the filename
261
+ * const filename = computeSearchIndexFilename(config.search, Date.now().toString());
262
+ * // filename: 'search-index-a1b2c3d4.json' (when hashFilename is true)
263
+ * // filename: 'search-index.json' (when hashFilename is false)
264
+ * ```
265
+ */
266
+ export function computeSearchIndexFilename(config, buildId) {
267
+ const indexName = config.indexName ?? SEARCH_DEFAULTS.indexName;
268
+ const hashFilename = config.hashFilename ?? SEARCH_DEFAULTS.hashFilename;
269
+ if (hashFilename) {
270
+ // Generate a deterministic hash based on build ID
271
+ // This allows the filename to be known before content is generated
272
+ const hashInput = buildId ?? Date.now().toString();
273
+ const hash = generateContentHash(hashInput);
274
+ return `${indexName}-${hash}.json`;
275
+ }
276
+ // Return base filename (without hash)
277
+ return `${indexName}.json`;
278
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Search index generation module.
3
+ * Provides build-time search index generation for Stati sites.
4
+ *
5
+ * @module search
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { generateSearchIndex, writeSearchIndex, SEARCH_INDEX_META_NAME } from '@stati/core/search';
10
+ *
11
+ * // Generate search index from searchable pages
12
+ * const searchIndex = generateSearchIndex(searchablePages, config.search);
13
+ *
14
+ * // Write to output directory
15
+ * const metadata = await writeSearchIndex(searchIndex, outDir, config.search);
16
+ * console.log(`Generated search index at ${metadata.indexPath}`);
17
+ * ```
18
+ */
19
+ export { generateSearchIndex, writeSearchIndex, extractSectionsFromMarkdown, stripMarkdown, shouldExcludePage, generateContentHash, buildBreadcrumb, computeSearchIndexFilename, } from './generator.js';
20
+ export type { SearchablePage } from './generator.js';
21
+ export { autoInjectSearchMeta } from './auto-inject.js';
22
+ export { SEARCH_INDEX_META_NAME, SEARCH_INDEX_VERSION, SEARCH_DEFAULTS } from './constants.js';
23
+ export type { SearchConfig, SearchDocument, SearchIndex, SearchIndexMetadata, } from '../types/search.js';
24
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/search/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EACL,mBAAmB,EACnB,gBAAgB,EAChB,2BAA2B,EAC3B,aAAa,EACb,iBAAiB,EACjB,mBAAmB,EACnB,eAAe,EACf,0BAA0B,GAC3B,MAAM,gBAAgB,CAAC;AACxB,YAAY,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AAGrD,OAAO,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAExD,OAAO,EAAE,sBAAsB,EAAE,oBAAoB,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AAE/F,YAAY,EACV,YAAY,EACZ,cAAc,EACd,WAAW,EACX,mBAAmB,GACpB,MAAM,oBAAoB,CAAC"}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Search index generation module.
3
+ * Provides build-time search index generation for Stati sites.
4
+ *
5
+ * @module search
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { generateSearchIndex, writeSearchIndex, SEARCH_INDEX_META_NAME } from '@stati/core/search';
10
+ *
11
+ * // Generate search index from searchable pages
12
+ * const searchIndex = generateSearchIndex(searchablePages, config.search);
13
+ *
14
+ * // Write to output directory
15
+ * const metadata = await writeSearchIndex(searchIndex, outDir, config.search);
16
+ * console.log(`Generated search index at ${metadata.indexPath}`);
17
+ * ```
18
+ */
19
+ export { generateSearchIndex, writeSearchIndex, extractSectionsFromMarkdown, stripMarkdown, shouldExcludePage, generateContentHash, buildBreadcrumb, computeSearchIndexFilename, } from './generator.js';
20
+ // Auto-injection
21
+ export { autoInjectSearchMeta } from './auto-inject.js';
22
+ export { SEARCH_INDEX_META_NAME, SEARCH_INDEX_VERSION, SEARCH_DEFAULTS } from './constants.js';
@@ -1 +1 @@
1
- {"version":3,"file":"auto-inject.d.ts","sourceRoot":"","sources":["../../src/seo/auto-inject.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEtD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAIlD;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,+CAA+C;IAC/C,IAAI,EAAE,SAAS,CAAC;IAChB,yBAAyB;IACzB,MAAM,EAAE,WAAW,CAAC;IACpB,oBAAoB;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,8BAA8B;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,2BAA2B;IAC3B,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AA8BD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,GAAG,MAAM,CAgE9E;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,WAAW,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAK/E"}
1
+ {"version":3,"file":"auto-inject.d.ts","sourceRoot":"","sources":["../../src/seo/auto-inject.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAEtD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAKlD;;GAEG;AACH,MAAM,WAAW,iBAAiB;IAChC,+CAA+C;IAC/C,IAAI,EAAE,SAAS,CAAC;IAChB,yBAAyB;IACzB,MAAM,EAAE,WAAW,CAAC;IACpB,oBAAoB;IACpB,OAAO,EAAE,MAAM,CAAC;IAChB,8BAA8B;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,2BAA2B;IAC3B,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAmBD;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,iBAAiB,GAAG,MAAM,CA0D9E;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,WAAW,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAK/E"}