@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.
- package/dist/core/build.d.ts +4 -2
- package/dist/core/build.d.ts.map +1 -1
- package/dist/core/build.js +53 -8
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +2 -0
- package/dist/core/markdown.d.ts +9 -0
- package/dist/core/markdown.d.ts.map +1 -1
- package/dist/core/markdown.js +12 -0
- package/dist/core/preview.js +1 -1
- package/dist/core/utils/html.utils.d.ts +35 -0
- package/dist/core/utils/html.utils.d.ts.map +1 -0
- package/dist/core/utils/html.utils.js +46 -0
- package/dist/core/utils/index.d.ts +1 -0
- package/dist/core/utils/index.d.ts.map +1 -1
- package/dist/core/utils/index.js +2 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/metrics/index.d.ts +1 -1
- package/dist/metrics/index.d.ts.map +1 -1
- package/dist/metrics/index.js +1 -1
- package/dist/metrics/noop.d.ts.map +1 -1
- package/dist/metrics/noop.js +2 -1
- package/dist/metrics/recorder.d.ts.map +1 -1
- package/dist/metrics/recorder.js +6 -3
- package/dist/metrics/types.d.ts +9 -4
- package/dist/metrics/types.d.ts.map +1 -1
- package/dist/metrics/utils/index.d.ts +1 -1
- package/dist/metrics/utils/index.d.ts.map +1 -1
- package/dist/metrics/utils/index.js +1 -1
- package/dist/metrics/utils/writer.utils.d.ts +0 -7
- package/dist/metrics/utils/writer.utils.d.ts.map +1 -1
- package/dist/metrics/utils/writer.utils.js +0 -63
- package/dist/search/auto-inject.d.ts +21 -0
- package/dist/search/auto-inject.d.ts.map +1 -0
- package/dist/search/auto-inject.js +33 -0
- package/dist/search/constants.d.ts +28 -0
- package/dist/search/constants.d.ts.map +1 -0
- package/dist/search/constants.js +27 -0
- package/dist/search/generator.d.ts +101 -0
- package/dist/search/generator.d.ts.map +1 -0
- package/dist/search/generator.js +278 -0
- package/dist/search/index.d.ts +24 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +22 -0
- package/dist/seo/auto-inject.d.ts.map +1 -1
- package/dist/seo/auto-inject.js +5 -18
- package/dist/types/config.d.ts +3 -0
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/content.d.ts +6 -0
- package/dist/types/content.d.ts.map +1 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/search.d.ts +121 -0
- package/dist/types/search.d.ts.map +1 -0
- package/dist/types/search.js +5 -0
- 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 
|
|
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;
|
|
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"}
|