@uniweb/build 0.1.6 → 0.1.8
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/package.json +17 -3
- package/src/i18n/index.js +25 -5
- package/src/prerender.js +4 -2
- package/src/search/extract.js +340 -0
- package/src/search/generate.js +107 -0
- package/src/search/index.js +34 -0
- package/src/site/config.js +310 -0
- package/src/site/index.js +2 -1
- package/src/site/plugin.js +51 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
"./generate-entry": "./src/generate-entry.js",
|
|
11
11
|
"./vite-plugin": "./src/vite-foundation-plugin.js",
|
|
12
12
|
"./site": "./src/site/index.js",
|
|
13
|
+
"./site/config": "./src/site/config.js",
|
|
13
14
|
"./dev": "./src/dev/index.js",
|
|
14
15
|
"./prerender": "./src/prerender.js",
|
|
15
|
-
"./i18n": "./src/i18n/index.js"
|
|
16
|
+
"./i18n": "./src/i18n/index.js",
|
|
17
|
+
"./search": "./src/search/index.js"
|
|
16
18
|
},
|
|
17
19
|
"files": [
|
|
18
20
|
"src"
|
|
@@ -52,7 +54,10 @@
|
|
|
52
54
|
"vite": "^5.0.0 || ^6.0.0 || ^7.0.0",
|
|
53
55
|
"react": "^18.0.0 || ^19.0.0",
|
|
54
56
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
55
|
-
"@
|
|
57
|
+
"@tailwindcss/vite": "^4.0.0",
|
|
58
|
+
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
59
|
+
"vite-plugin-svgr": "^4.0.0",
|
|
60
|
+
"@uniweb/core": "0.1.6"
|
|
56
61
|
},
|
|
57
62
|
"peerDependenciesMeta": {
|
|
58
63
|
"vite": {
|
|
@@ -66,6 +71,15 @@
|
|
|
66
71
|
},
|
|
67
72
|
"@uniweb/core": {
|
|
68
73
|
"optional": true
|
|
74
|
+
},
|
|
75
|
+
"@tailwindcss/vite": {
|
|
76
|
+
"optional": true
|
|
77
|
+
},
|
|
78
|
+
"@vitejs/plugin-react": {
|
|
79
|
+
"optional": true
|
|
80
|
+
},
|
|
81
|
+
"vite-plugin-svgr": {
|
|
82
|
+
"optional": true
|
|
69
83
|
}
|
|
70
84
|
},
|
|
71
85
|
"scripts": {
|
package/src/i18n/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { computeHash, normalizeText } from './hash.js'
|
|
|
15
15
|
import { extractTranslatableContent } from './extract.js'
|
|
16
16
|
import { syncManifests, formatSyncReport } from './sync.js'
|
|
17
17
|
import { mergeTranslations, generateAllLocales } from './merge.js'
|
|
18
|
+
import { generateSearchIndex, isSearchEnabled } from '../search/index.js'
|
|
18
19
|
|
|
19
20
|
export {
|
|
20
21
|
// Hash utilities
|
|
@@ -219,14 +220,16 @@ export async function getTranslationStatus(siteRoot, options = {}) {
|
|
|
219
220
|
* Build translated site content for all locales
|
|
220
221
|
* @param {string} siteRoot - Site root directory
|
|
221
222
|
* @param {Object} options - Options
|
|
222
|
-
* @
|
|
223
|
+
* @param {boolean} [options.generateSearchIndex=true] - Generate search indexes for each locale
|
|
224
|
+
* @returns {Object} Map of locale to output paths
|
|
223
225
|
*/
|
|
224
226
|
export async function buildLocalizedContent(siteRoot, options = {}) {
|
|
225
227
|
const {
|
|
226
228
|
localesDir = DEFAULTS.localesDir,
|
|
227
229
|
locales = [],
|
|
228
230
|
outputDir = join(siteRoot, 'dist'),
|
|
229
|
-
fallbackToSource = true
|
|
231
|
+
fallbackToSource = true,
|
|
232
|
+
generateSearchIndexes = true
|
|
230
233
|
} = options
|
|
231
234
|
|
|
232
235
|
const localesPath = join(siteRoot, localesDir)
|
|
@@ -253,16 +256,33 @@ export async function buildLocalizedContent(siteRoot, options = {}) {
|
|
|
253
256
|
fallbackToSource
|
|
254
257
|
})
|
|
255
258
|
|
|
259
|
+
// Mark the active locale in the translated content
|
|
260
|
+
translated.config = { ...translated.config, activeLocale: locale }
|
|
261
|
+
|
|
256
262
|
// Write to locale subdirectory
|
|
257
263
|
const localeOutputDir = join(outputDir, locale)
|
|
258
264
|
if (!existsSync(localeOutputDir)) {
|
|
259
265
|
await mkdir(localeOutputDir, { recursive: true })
|
|
260
266
|
}
|
|
261
267
|
|
|
262
|
-
const
|
|
263
|
-
await writeFile(
|
|
268
|
+
const contentOutputPath = join(localeOutputDir, 'site-content.json')
|
|
269
|
+
await writeFile(contentOutputPath, JSON.stringify(translated, null, 2))
|
|
270
|
+
|
|
271
|
+
outputs[locale] = { content: contentOutputPath }
|
|
272
|
+
|
|
273
|
+
// Generate search index for this locale if search is enabled
|
|
274
|
+
if (generateSearchIndexes && isSearchEnabled(translated)) {
|
|
275
|
+
const searchConfig = translated.config?.search || {}
|
|
276
|
+
const searchIndex = generateSearchIndex(translated, {
|
|
277
|
+
locale,
|
|
278
|
+
search: searchConfig
|
|
279
|
+
})
|
|
264
280
|
|
|
265
|
-
|
|
281
|
+
const searchOutputPath = join(localeOutputDir, 'search-index.json')
|
|
282
|
+
await writeFile(searchOutputPath, JSON.stringify(searchIndex, null, 2))
|
|
283
|
+
|
|
284
|
+
outputs[locale].searchIndex = searchOutputPath
|
|
285
|
+
}
|
|
266
286
|
}
|
|
267
287
|
|
|
268
288
|
return outputs
|
package/src/prerender.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { readFile, writeFile, mkdir } from 'node:fs/promises'
|
|
9
9
|
import { existsSync } from 'node:fs'
|
|
10
|
-
import { join, dirname } from 'node:path'
|
|
10
|
+
import { join, dirname, resolve } from 'node:path'
|
|
11
11
|
import { pathToFileURL } from 'node:url'
|
|
12
12
|
import { createRequire } from 'node:module'
|
|
13
13
|
|
|
@@ -25,7 +25,9 @@ async function loadDependencies(siteDir) {
|
|
|
25
25
|
|
|
26
26
|
// Create a require function that resolves from the site's perspective
|
|
27
27
|
// This ensures we get the same React instance that the foundation uses
|
|
28
|
-
|
|
28
|
+
// Note: createRequire requires an absolute path
|
|
29
|
+
const absoluteSiteDir = resolve(siteDir)
|
|
30
|
+
const siteRequire = createRequire(join(absoluteSiteDir, 'package.json'))
|
|
29
31
|
|
|
30
32
|
try {
|
|
31
33
|
// Try to load React from site's node_modules
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extract searchable content from site-content.json
|
|
3
|
+
*
|
|
4
|
+
* Walks through all pages and sections, extracting text content
|
|
5
|
+
* for search indexing. Reuses patterns from i18n extraction.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extract all searchable content from site
|
|
10
|
+
* @param {Object} siteContent - Parsed site-content.json
|
|
11
|
+
* @param {Object} options - Extraction options
|
|
12
|
+
* @param {boolean} [options.pages=true] - Include page metadata
|
|
13
|
+
* @param {boolean} [options.sections=true] - Include section content
|
|
14
|
+
* @param {boolean} [options.headings=true] - Include headings
|
|
15
|
+
* @param {boolean} [options.paragraphs=true] - Include paragraphs
|
|
16
|
+
* @param {boolean} [options.links=true] - Include link labels
|
|
17
|
+
* @param {boolean} [options.lists=true] - Include list items
|
|
18
|
+
* @param {Array<string>} [options.excludeRoutes=[]] - Routes to exclude
|
|
19
|
+
* @param {Array<string>} [options.excludeComponents=[]] - Components to exclude
|
|
20
|
+
* @returns {Array<Object>} Array of search entries
|
|
21
|
+
*/
|
|
22
|
+
export function extractSearchContent(siteContent, options = {}) {
|
|
23
|
+
const {
|
|
24
|
+
pages: includePagesFlag = true,
|
|
25
|
+
sections: includeSections = true,
|
|
26
|
+
headings: includeHeadings = true,
|
|
27
|
+
paragraphs: includeParagraphs = true,
|
|
28
|
+
links: includeLinks = true,
|
|
29
|
+
lists: includeLists = true,
|
|
30
|
+
excludeRoutes = [],
|
|
31
|
+
excludeComponents = []
|
|
32
|
+
} = options
|
|
33
|
+
|
|
34
|
+
const entries = []
|
|
35
|
+
|
|
36
|
+
for (const page of siteContent.pages || []) {
|
|
37
|
+
const pageRoute = page.route || '/'
|
|
38
|
+
|
|
39
|
+
// Skip excluded routes
|
|
40
|
+
if (excludeRoutes.some(r => pageRoute.startsWith(r))) {
|
|
41
|
+
continue
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Skip special pages (header, footer, etc.)
|
|
45
|
+
if (pageRoute.startsWith('/@')) {
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Skip pages marked as noindex
|
|
50
|
+
if (page.seo?.noindex) {
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Extract page-level entry
|
|
55
|
+
if (includePagesFlag) {
|
|
56
|
+
const pageEntry = extractFromPage(page)
|
|
57
|
+
if (pageEntry) {
|
|
58
|
+
entries.push(pageEntry)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Extract section-level entries
|
|
63
|
+
if (includeSections) {
|
|
64
|
+
for (const section of page.sections || []) {
|
|
65
|
+
const sectionEntries = extractFromSection(section, page, {
|
|
66
|
+
includeHeadings,
|
|
67
|
+
includeParagraphs,
|
|
68
|
+
includeLinks,
|
|
69
|
+
includeLists,
|
|
70
|
+
excludeComponents
|
|
71
|
+
})
|
|
72
|
+
entries.push(...sectionEntries)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return entries
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Extract search entry from page metadata
|
|
82
|
+
* @param {Object} page - Page data
|
|
83
|
+
* @returns {Object|null} Search entry or null
|
|
84
|
+
*/
|
|
85
|
+
function extractFromPage(page) {
|
|
86
|
+
const route = page.route || '/'
|
|
87
|
+
const title = page.title || ''
|
|
88
|
+
const description = page.description || ''
|
|
89
|
+
const keywords = page.keywords || page.seo?.keywords || []
|
|
90
|
+
|
|
91
|
+
// Skip pages with no meaningful content
|
|
92
|
+
if (!title && !description) {
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
id: `page:${route}`,
|
|
98
|
+
type: 'page',
|
|
99
|
+
route,
|
|
100
|
+
title,
|
|
101
|
+
description,
|
|
102
|
+
keywords: Array.isArray(keywords) ? keywords : [keywords].filter(Boolean),
|
|
103
|
+
content: [title, description].filter(Boolean).join(' '),
|
|
104
|
+
// Boost factor for search ranking (pages are more important)
|
|
105
|
+
weight: route === '/' ? 1.0 : 0.8
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Extract search entries from a section (and subsections)
|
|
111
|
+
* @param {Object} section - Section data
|
|
112
|
+
* @param {Object} page - Parent page
|
|
113
|
+
* @param {Object} options - Extraction options
|
|
114
|
+
* @returns {Array<Object>} Array of search entries
|
|
115
|
+
*/
|
|
116
|
+
function extractFromSection(section, page, options) {
|
|
117
|
+
const entries = []
|
|
118
|
+
const {
|
|
119
|
+
includeHeadings,
|
|
120
|
+
includeParagraphs,
|
|
121
|
+
includeLinks,
|
|
122
|
+
includeLists,
|
|
123
|
+
excludeComponents
|
|
124
|
+
} = options
|
|
125
|
+
|
|
126
|
+
const sectionId = section.id || 'unknown'
|
|
127
|
+
const component = section.component || section.type || 'unknown'
|
|
128
|
+
|
|
129
|
+
// Skip excluded components
|
|
130
|
+
if (excludeComponents.includes(component)) {
|
|
131
|
+
return entries
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Extract text from ProseMirror content
|
|
135
|
+
const textParts = []
|
|
136
|
+
let sectionTitle = ''
|
|
137
|
+
|
|
138
|
+
if (section.content?.type === 'doc') {
|
|
139
|
+
const extracted = extractFromProseMirrorDoc(section.content, {
|
|
140
|
+
includeHeadings,
|
|
141
|
+
includeParagraphs,
|
|
142
|
+
includeLinks,
|
|
143
|
+
includeLists
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
sectionTitle = extracted.title || ''
|
|
147
|
+
textParts.push(...extracted.textParts)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Also check params for title (from YAML frontmatter)
|
|
151
|
+
if (!sectionTitle && section.params?.title) {
|
|
152
|
+
sectionTitle = section.params.title
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Build content string
|
|
156
|
+
const content = textParts.join(' ').trim()
|
|
157
|
+
|
|
158
|
+
// Create entry if there's meaningful content
|
|
159
|
+
if (sectionTitle || content) {
|
|
160
|
+
entries.push({
|
|
161
|
+
id: `section:${page.route}:${sectionId}`,
|
|
162
|
+
type: 'section',
|
|
163
|
+
route: page.route,
|
|
164
|
+
sectionId,
|
|
165
|
+
anchor: `Section${sectionId}`,
|
|
166
|
+
component,
|
|
167
|
+
title: sectionTitle,
|
|
168
|
+
pageTitle: page.title || '',
|
|
169
|
+
content,
|
|
170
|
+
// Generate excerpt (first ~160 chars)
|
|
171
|
+
excerpt: generateExcerpt(content, 160),
|
|
172
|
+
// Section weight is lower than page
|
|
173
|
+
weight: 0.6
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Recursively process subsections
|
|
178
|
+
for (const subsection of section.subsections || []) {
|
|
179
|
+
const subEntries = extractFromSection(subsection, page, options)
|
|
180
|
+
entries.push(...subEntries)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return entries
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Extract text from ProseMirror document
|
|
188
|
+
* @param {Object} doc - ProseMirror document
|
|
189
|
+
* @param {Object} options - Extraction options
|
|
190
|
+
* @returns {Object} Extracted content { title, textParts }
|
|
191
|
+
*/
|
|
192
|
+
function extractFromProseMirrorDoc(doc, options) {
|
|
193
|
+
const { includeHeadings, includeParagraphs, includeLinks, includeLists } = options
|
|
194
|
+
const textParts = []
|
|
195
|
+
let title = ''
|
|
196
|
+
let foundFirstHeading = false
|
|
197
|
+
|
|
198
|
+
if (!doc.content) {
|
|
199
|
+
return { title, textParts }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
for (const node of doc.content) {
|
|
203
|
+
if (node.type === 'heading') {
|
|
204
|
+
const text = extractTextFromNode(node)
|
|
205
|
+
if (!text) continue
|
|
206
|
+
|
|
207
|
+
// First H1 becomes the title
|
|
208
|
+
if (!foundFirstHeading && node.attrs?.level === 1) {
|
|
209
|
+
title = text
|
|
210
|
+
foundFirstHeading = true
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (includeHeadings) {
|
|
214
|
+
textParts.push(text)
|
|
215
|
+
}
|
|
216
|
+
} else if (node.type === 'paragraph') {
|
|
217
|
+
if (includeParagraphs) {
|
|
218
|
+
const text = extractTextFromNode(node)
|
|
219
|
+
if (text) {
|
|
220
|
+
textParts.push(text)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Extract link labels separately if requested
|
|
225
|
+
if (includeLinks) {
|
|
226
|
+
const links = extractLinksFromNode(node)
|
|
227
|
+
for (const link of links) {
|
|
228
|
+
if (link.label) {
|
|
229
|
+
textParts.push(link.label)
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
} else if ((node.type === 'bulletList' || node.type === 'orderedList') && includeLists) {
|
|
234
|
+
const listTexts = extractFromList(node)
|
|
235
|
+
textParts.push(...listTexts)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return { title, textParts }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Extract all text content from a node
|
|
244
|
+
* @param {Object} node - ProseMirror node
|
|
245
|
+
* @returns {string} Text content
|
|
246
|
+
*/
|
|
247
|
+
function extractTextFromNode(node) {
|
|
248
|
+
if (!node.content) return ''
|
|
249
|
+
|
|
250
|
+
const texts = []
|
|
251
|
+
|
|
252
|
+
for (const child of node.content) {
|
|
253
|
+
if (child.type === 'text') {
|
|
254
|
+
texts.push(child.text || '')
|
|
255
|
+
} else if (child.content) {
|
|
256
|
+
// Recurse for nested nodes
|
|
257
|
+
texts.push(extractTextFromNode(child))
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return texts.join('').trim()
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Extract links from a paragraph node
|
|
266
|
+
* @param {Object} node - Paragraph node
|
|
267
|
+
* @returns {Array<{label: string, href: string}>} Links
|
|
268
|
+
*/
|
|
269
|
+
function extractLinksFromNode(node) {
|
|
270
|
+
const links = []
|
|
271
|
+
|
|
272
|
+
if (!node.content) return links
|
|
273
|
+
|
|
274
|
+
for (const child of node.content) {
|
|
275
|
+
if (child.type === 'text' && child.marks) {
|
|
276
|
+
const linkMark = child.marks.find(m => m.type === 'link')
|
|
277
|
+
if (linkMark) {
|
|
278
|
+
links.push({
|
|
279
|
+
label: child.text || '',
|
|
280
|
+
href: linkMark.attrs?.href || ''
|
|
281
|
+
})
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return links
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Extract text from list items
|
|
291
|
+
* @param {Object} listNode - List node
|
|
292
|
+
* @returns {Array<string>} List item texts
|
|
293
|
+
*/
|
|
294
|
+
function extractFromList(listNode) {
|
|
295
|
+
const texts = []
|
|
296
|
+
|
|
297
|
+
if (!listNode.content) return texts
|
|
298
|
+
|
|
299
|
+
for (const listItem of listNode.content) {
|
|
300
|
+
if (listItem.type === 'listItem' && listItem.content) {
|
|
301
|
+
for (const child of listItem.content) {
|
|
302
|
+
if (child.type === 'paragraph') {
|
|
303
|
+
const text = extractTextFromNode(child)
|
|
304
|
+
if (text) {
|
|
305
|
+
texts.push(text)
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return texts
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Generate excerpt from content
|
|
317
|
+
* @param {string} content - Full content
|
|
318
|
+
* @param {number} maxLength - Maximum length
|
|
319
|
+
* @returns {string} Excerpt
|
|
320
|
+
*/
|
|
321
|
+
function generateExcerpt(content, maxLength = 160) {
|
|
322
|
+
if (!content) return ''
|
|
323
|
+
|
|
324
|
+
// Normalize whitespace
|
|
325
|
+
const normalized = content.replace(/\s+/g, ' ').trim()
|
|
326
|
+
|
|
327
|
+
if (normalized.length <= maxLength) {
|
|
328
|
+
return normalized
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Find a good break point (word boundary)
|
|
332
|
+
let breakPoint = normalized.lastIndexOf(' ', maxLength)
|
|
333
|
+
if (breakPoint < maxLength * 0.5) {
|
|
334
|
+
breakPoint = maxLength
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return normalized.slice(0, breakPoint).trim() + '…'
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export default extractSearchContent
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate search index from site content
|
|
3
|
+
*
|
|
4
|
+
* Creates a JSON search index file that can be loaded at runtime
|
|
5
|
+
* for client-side search functionality.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { extractSearchContent } from './extract.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate search index for a site
|
|
12
|
+
* @param {Object} siteContent - Parsed site-content.json
|
|
13
|
+
* @param {Object} options - Generation options
|
|
14
|
+
* @param {string} [options.locale] - Locale code for this index
|
|
15
|
+
* @param {Object} [options.extract] - Options passed to extractSearchContent
|
|
16
|
+
* @param {Object} [options.search] - Search configuration from site.yml
|
|
17
|
+
* @returns {Object} Search index object
|
|
18
|
+
*/
|
|
19
|
+
export function generateSearchIndex(siteContent, options = {}) {
|
|
20
|
+
const {
|
|
21
|
+
locale = siteContent.config?.activeLocale || siteContent.config?.defaultLanguage || 'en',
|
|
22
|
+
extract: extractOptions = {},
|
|
23
|
+
search: searchConfig = {}
|
|
24
|
+
} = options
|
|
25
|
+
|
|
26
|
+
// Merge search config with extract options
|
|
27
|
+
const mergedExtractOptions = {
|
|
28
|
+
...extractOptions,
|
|
29
|
+
excludeRoutes: searchConfig.exclude?.routes || extractOptions.excludeRoutes || [],
|
|
30
|
+
excludeComponents: searchConfig.exclude?.components || extractOptions.excludeComponents || []
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Check what to include from config
|
|
34
|
+
if (searchConfig.include) {
|
|
35
|
+
mergedExtractOptions.pages = searchConfig.include.pages !== false
|
|
36
|
+
mergedExtractOptions.sections = searchConfig.include.sections !== false
|
|
37
|
+
mergedExtractOptions.headings = searchConfig.include.headings !== false
|
|
38
|
+
mergedExtractOptions.paragraphs = searchConfig.include.paragraphs !== false
|
|
39
|
+
mergedExtractOptions.links = searchConfig.include.links !== false
|
|
40
|
+
mergedExtractOptions.lists = searchConfig.include.lists !== false
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Extract searchable content
|
|
44
|
+
const entries = extractSearchContent(siteContent, mergedExtractOptions)
|
|
45
|
+
|
|
46
|
+
// Build the index
|
|
47
|
+
const index = {
|
|
48
|
+
version: '1.0',
|
|
49
|
+
locale,
|
|
50
|
+
generated: new Date().toISOString(),
|
|
51
|
+
count: entries.length,
|
|
52
|
+
entries
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return index
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Check if search is enabled for a site
|
|
60
|
+
* @param {Object} siteContent - Parsed site-content.json
|
|
61
|
+
* @returns {boolean}
|
|
62
|
+
*/
|
|
63
|
+
export function isSearchEnabled(siteContent) {
|
|
64
|
+
// Search is enabled by default unless explicitly disabled
|
|
65
|
+
return siteContent.config?.search?.enabled !== false
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Get search configuration from site content
|
|
70
|
+
* @param {Object} siteContent - Parsed site-content.json
|
|
71
|
+
* @returns {Object} Search configuration
|
|
72
|
+
*/
|
|
73
|
+
export function getSearchConfig(siteContent) {
|
|
74
|
+
const config = siteContent.config?.search || {}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
enabled: config.enabled !== false,
|
|
78
|
+
include: {
|
|
79
|
+
pages: config.include?.pages !== false,
|
|
80
|
+
sections: config.include?.sections !== false,
|
|
81
|
+
headings: config.include?.headings !== false,
|
|
82
|
+
paragraphs: config.include?.paragraphs !== false,
|
|
83
|
+
links: config.include?.links !== false,
|
|
84
|
+
lists: config.include?.lists !== false
|
|
85
|
+
},
|
|
86
|
+
exclude: {
|
|
87
|
+
routes: config.exclude?.routes || [],
|
|
88
|
+
components: config.exclude?.components || []
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get the search index filename for a locale
|
|
95
|
+
* @param {string} locale - Locale code
|
|
96
|
+
* @param {string} defaultLocale - Default locale code
|
|
97
|
+
* @returns {string} Filename
|
|
98
|
+
*/
|
|
99
|
+
export function getSearchIndexFilename(locale, defaultLocale) {
|
|
100
|
+
// Default locale uses root filename, others use locale prefix path
|
|
101
|
+
if (locale === defaultLocale) {
|
|
102
|
+
return 'search-index.json'
|
|
103
|
+
}
|
|
104
|
+
return `${locale}/search-index.json`
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export default generateSearchIndex
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Search Index Generation Module
|
|
3
|
+
*
|
|
4
|
+
* Generates search indexes for Uniweb sites at build time.
|
|
5
|
+
* The generated indexes can be loaded at runtime for client-side search.
|
|
6
|
+
*
|
|
7
|
+
* @module @uniweb/build/search
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* import { generateSearchIndex, isSearchEnabled } from '@uniweb/build/search'
|
|
11
|
+
*
|
|
12
|
+
* // Check if search is enabled
|
|
13
|
+
* if (isSearchEnabled(siteContent)) {
|
|
14
|
+
* // Generate index for current locale
|
|
15
|
+
* const index = generateSearchIndex(siteContent, {
|
|
16
|
+
* locale: 'en'
|
|
17
|
+
* })
|
|
18
|
+
*
|
|
19
|
+
* // Write to file
|
|
20
|
+
* writeFileSync('dist/search-index.json', JSON.stringify(index))
|
|
21
|
+
* }
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
export {
|
|
25
|
+
extractSearchContent,
|
|
26
|
+
extractSearchContent as default
|
|
27
|
+
} from './extract.js'
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
generateSearchIndex,
|
|
31
|
+
isSearchEnabled,
|
|
32
|
+
getSearchConfig,
|
|
33
|
+
getSearchIndexFilename
|
|
34
|
+
} from './generate.js'
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Site Vite Configuration
|
|
3
|
+
*
|
|
4
|
+
* Provides a zero-config or minimal-config Vite setup for Uniweb sites.
|
|
5
|
+
* Reads configuration from site.yml and sets up all necessary plugins.
|
|
6
|
+
*
|
|
7
|
+
* @module @uniweb/build/site/config
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* // Minimal vite.config.js (recommended)
|
|
11
|
+
* export { default } from '@uniweb/build/site/config'
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* // With customization
|
|
15
|
+
* import { defineSiteConfig } from '@uniweb/build/site'
|
|
16
|
+
*
|
|
17
|
+
* export default defineSiteConfig({
|
|
18
|
+
* server: { port: 4000 },
|
|
19
|
+
* plugins: [myCustomPlugin()],
|
|
20
|
+
* })
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
24
|
+
import { resolve, dirname } from 'node:path'
|
|
25
|
+
import yaml from 'js-yaml'
|
|
26
|
+
|
|
27
|
+
// Virtual module ID for the site entry
|
|
28
|
+
const VIRTUAL_ENTRY_ID = 'virtual:uniweb-site-entry'
|
|
29
|
+
const RESOLVED_VIRTUAL_ENTRY_ID = '\0' + VIRTUAL_ENTRY_ID
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Detect foundation type from the foundation config value
|
|
33
|
+
*
|
|
34
|
+
* @param {string|Object} foundation - Foundation config from site.yml
|
|
35
|
+
* @returns {{ type: 'local'|'npm'|'url', name?: string, url?: string, cssUrl?: string, path?: string }}
|
|
36
|
+
*/
|
|
37
|
+
function detectFoundationType(foundation, siteRoot) {
|
|
38
|
+
// Object form with explicit URL
|
|
39
|
+
if (foundation && typeof foundation === 'object') {
|
|
40
|
+
if (foundation.url) {
|
|
41
|
+
return {
|
|
42
|
+
type: 'url',
|
|
43
|
+
url: foundation.url,
|
|
44
|
+
cssUrl: foundation.css || foundation.cssUrl || null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// Object form with name
|
|
48
|
+
foundation = foundation.name || 'foundation'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// String form
|
|
52
|
+
const name = foundation || 'foundation'
|
|
53
|
+
|
|
54
|
+
// Check if it's a URL
|
|
55
|
+
if (name.startsWith('http://') || name.startsWith('https://')) {
|
|
56
|
+
// Try to infer CSS URL from JS URL
|
|
57
|
+
const cssUrl = name.replace(/\.js$/, '.css').replace(/foundation\.js/, 'assets/style.css')
|
|
58
|
+
return {
|
|
59
|
+
type: 'url',
|
|
60
|
+
url: name,
|
|
61
|
+
cssUrl
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Check if it's a local workspace sibling
|
|
66
|
+
const localPath = resolve(siteRoot, '..', name)
|
|
67
|
+
if (existsSync(localPath)) {
|
|
68
|
+
return {
|
|
69
|
+
type: 'local',
|
|
70
|
+
name,
|
|
71
|
+
path: localPath
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Check in foundations/ directory (for multi-site projects)
|
|
76
|
+
const foundationsPath = resolve(siteRoot, '..', '..', 'foundations', name)
|
|
77
|
+
if (existsSync(foundationsPath)) {
|
|
78
|
+
return {
|
|
79
|
+
type: 'local',
|
|
80
|
+
name,
|
|
81
|
+
path: foundationsPath
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Assume npm package
|
|
86
|
+
return {
|
|
87
|
+
type: 'npm',
|
|
88
|
+
name,
|
|
89
|
+
path: resolve(siteRoot, 'node_modules', name)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Generate the virtual entry module code based on foundation config
|
|
95
|
+
*
|
|
96
|
+
* @param {{ type: string, url?: string, cssUrl?: string }} foundationInfo
|
|
97
|
+
* @param {boolean} isRuntimeMode
|
|
98
|
+
* @returns {string}
|
|
99
|
+
*/
|
|
100
|
+
function generateEntryCode(foundationInfo, isRuntimeMode) {
|
|
101
|
+
if (isRuntimeMode || foundationInfo.type === 'url') {
|
|
102
|
+
// Runtime loading - foundation loaded dynamically
|
|
103
|
+
const url = foundationInfo.url || '/foundation/foundation.js'
|
|
104
|
+
const cssUrl = foundationInfo.cssUrl || '/foundation/assets/style.css'
|
|
105
|
+
|
|
106
|
+
return `
|
|
107
|
+
import { initRuntime } from '@uniweb/runtime'
|
|
108
|
+
|
|
109
|
+
initRuntime({
|
|
110
|
+
url: '${url}',
|
|
111
|
+
cssUrl: '${cssUrl}'
|
|
112
|
+
})
|
|
113
|
+
`
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Bundled mode - foundation imported at build time
|
|
117
|
+
return `
|
|
118
|
+
import { initRuntime } from '@uniweb/runtime'
|
|
119
|
+
import foundation from '#foundation'
|
|
120
|
+
import '#foundation/styles'
|
|
121
|
+
|
|
122
|
+
initRuntime(foundation)
|
|
123
|
+
`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Create the virtual entry plugin
|
|
128
|
+
*/
|
|
129
|
+
function virtualEntryPlugin(foundationInfo, isRuntimeMode) {
|
|
130
|
+
const entryCode = generateEntryCode(foundationInfo, isRuntimeMode)
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
name: 'uniweb:virtual-entry',
|
|
134
|
+
resolveId(id) {
|
|
135
|
+
if (id === VIRTUAL_ENTRY_ID) {
|
|
136
|
+
return RESOLVED_VIRTUAL_ENTRY_ID
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
load(id) {
|
|
140
|
+
if (id === RESOLVED_VIRTUAL_ENTRY_ID) {
|
|
141
|
+
return entryCode
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Read and parse site.yml configuration
|
|
149
|
+
*
|
|
150
|
+
* @param {string} siteRoot - Path to site directory
|
|
151
|
+
* @returns {Object}
|
|
152
|
+
*/
|
|
153
|
+
function readSiteConfig(siteRoot) {
|
|
154
|
+
const configPath = resolve(siteRoot, 'site.yml')
|
|
155
|
+
if (!existsSync(configPath)) {
|
|
156
|
+
return {}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
return yaml.load(readFileSync(configPath, 'utf8')) || {}
|
|
161
|
+
} catch (err) {
|
|
162
|
+
console.warn('[site-config] Failed to read site.yml:', err.message)
|
|
163
|
+
return {}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Create a complete Vite configuration for a Uniweb site
|
|
169
|
+
*
|
|
170
|
+
* @param {Object} [options={}] - Configuration overrides
|
|
171
|
+
* @param {Object} [options.server] - Vite server options
|
|
172
|
+
* @param {Array} [options.plugins] - Additional Vite plugins
|
|
173
|
+
* @param {Object} [options.build] - Vite build options
|
|
174
|
+
* @param {Object} [options.resolve] - Vite resolve options
|
|
175
|
+
* @param {Object} [options.seo] - SEO configuration for siteContentPlugin
|
|
176
|
+
* @param {Object} [options.assets] - Asset processing configuration
|
|
177
|
+
* @param {Object} [options.search] - Search index configuration
|
|
178
|
+
* @returns {Promise<Object>} Vite configuration
|
|
179
|
+
*/
|
|
180
|
+
export async function defineSiteConfig(options = {}) {
|
|
181
|
+
const {
|
|
182
|
+
plugins: extraPlugins = [],
|
|
183
|
+
server: serverOverrides = {},
|
|
184
|
+
build: buildOverrides = {},
|
|
185
|
+
resolve: resolveOverrides = {},
|
|
186
|
+
seo = {},
|
|
187
|
+
assets = {},
|
|
188
|
+
search = {},
|
|
189
|
+
...restOptions
|
|
190
|
+
} = options
|
|
191
|
+
|
|
192
|
+
// Determine site root (where vite.config.js is)
|
|
193
|
+
const siteRoot = process.cwd()
|
|
194
|
+
|
|
195
|
+
// Read site.yml
|
|
196
|
+
const siteConfig = readSiteConfig(siteRoot)
|
|
197
|
+
|
|
198
|
+
// Detect foundation type
|
|
199
|
+
const foundationInfo = detectFoundationType(siteConfig.foundation, siteRoot)
|
|
200
|
+
|
|
201
|
+
// Check for runtime mode (env variable or URL-based foundation)
|
|
202
|
+
const isRuntimeMode =
|
|
203
|
+
process.env.VITE_FOUNDATION_MODE === 'runtime' || foundationInfo.type === 'url'
|
|
204
|
+
|
|
205
|
+
// Dynamic imports for optional peer dependencies
|
|
206
|
+
// These are imported dynamically to avoid requiring them when not needed
|
|
207
|
+
const [
|
|
208
|
+
{ default: tailwindcss },
|
|
209
|
+
{ default: react },
|
|
210
|
+
{ default: svgr },
|
|
211
|
+
{ siteContentPlugin },
|
|
212
|
+
{ foundationDevPlugin }
|
|
213
|
+
] = await Promise.all([
|
|
214
|
+
import('@tailwindcss/vite'),
|
|
215
|
+
import('@vitejs/plugin-react'),
|
|
216
|
+
import('vite-plugin-svgr'),
|
|
217
|
+
import('./plugin.js'),
|
|
218
|
+
import('../dev/plugin.js')
|
|
219
|
+
])
|
|
220
|
+
|
|
221
|
+
// Build the plugins array
|
|
222
|
+
const plugins = [
|
|
223
|
+
// Virtual entry module
|
|
224
|
+
virtualEntryPlugin(foundationInfo, isRuntimeMode),
|
|
225
|
+
|
|
226
|
+
// Standard plugins
|
|
227
|
+
tailwindcss(),
|
|
228
|
+
react(),
|
|
229
|
+
svgr(),
|
|
230
|
+
|
|
231
|
+
// Site content collection and injection
|
|
232
|
+
siteContentPlugin({
|
|
233
|
+
sitePath: './',
|
|
234
|
+
inject: true,
|
|
235
|
+
seo,
|
|
236
|
+
assets,
|
|
237
|
+
search
|
|
238
|
+
}),
|
|
239
|
+
|
|
240
|
+
// Foundation dev server (only in runtime mode with local foundation)
|
|
241
|
+
isRuntimeMode &&
|
|
242
|
+
foundationInfo.type === 'local' &&
|
|
243
|
+
foundationDevPlugin({
|
|
244
|
+
name: foundationInfo.name,
|
|
245
|
+
path: foundationInfo.path,
|
|
246
|
+
serve: '/foundation',
|
|
247
|
+
watch: true
|
|
248
|
+
}),
|
|
249
|
+
|
|
250
|
+
// User-provided plugins
|
|
251
|
+
...extraPlugins
|
|
252
|
+
].filter(Boolean)
|
|
253
|
+
|
|
254
|
+
// Build resolve.alias configuration
|
|
255
|
+
const alias = {}
|
|
256
|
+
|
|
257
|
+
// Set up #foundation alias for bundled mode
|
|
258
|
+
if (!isRuntimeMode && foundationInfo.type !== 'url') {
|
|
259
|
+
alias['#foundation'] = foundationInfo.name
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Merge with user overrides
|
|
263
|
+
const resolveConfig = {
|
|
264
|
+
alias: {
|
|
265
|
+
...alias,
|
|
266
|
+
...resolveOverrides.alias
|
|
267
|
+
},
|
|
268
|
+
...resolveOverrides
|
|
269
|
+
}
|
|
270
|
+
delete resolveConfig.alias // We'll add it back properly
|
|
271
|
+
resolveConfig.alias = { ...alias, ...resolveOverrides.alias }
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
plugins,
|
|
275
|
+
|
|
276
|
+
resolve: {
|
|
277
|
+
alias: {
|
|
278
|
+
...alias,
|
|
279
|
+
...resolveOverrides?.alias
|
|
280
|
+
}
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
server: {
|
|
284
|
+
fs: { allow: ['..'] },
|
|
285
|
+
port: siteConfig.build?.port || 3000,
|
|
286
|
+
...serverOverrides
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
build: {
|
|
290
|
+
...buildOverrides
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
optimizeDeps: {
|
|
294
|
+
include: ['react', 'react-dom', 'react-dom/client', 'react-router-dom']
|
|
295
|
+
},
|
|
296
|
+
|
|
297
|
+
...restOptions
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Default export - an async function that can be used directly as vite.config.js
|
|
303
|
+
*
|
|
304
|
+
* @example
|
|
305
|
+
* // vite.config.js - simplest form
|
|
306
|
+
* export { default } from '@uniweb/build/site/config'
|
|
307
|
+
*/
|
|
308
|
+
export default function (overrides = {}) {
|
|
309
|
+
return defineSiteConfig(overrides)
|
|
310
|
+
}
|
package/src/site/index.js
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
* @module @uniweb/build/site
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
export { siteContentPlugin
|
|
9
|
+
export { siteContentPlugin } from './plugin.js'
|
|
10
|
+
export { defineSiteConfig, default } from './config.js'
|
|
10
11
|
export { collectSiteContent } from './content-collector.js'
|
|
11
12
|
export {
|
|
12
13
|
resolveAssetPath,
|
package/src/site/plugin.js
CHANGED
|
@@ -35,6 +35,7 @@ import { watch } from 'node:fs'
|
|
|
35
35
|
import { collectSiteContent } from './content-collector.js'
|
|
36
36
|
import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
|
|
37
37
|
import { processAdvancedAssets } from './advanced-processors.js'
|
|
38
|
+
import { generateSearchIndex, isSearchEnabled, getSearchIndexFilename } from '../search/index.js'
|
|
38
39
|
|
|
39
40
|
/**
|
|
40
41
|
* Generate sitemap.xml content
|
|
@@ -225,6 +226,9 @@ function escapeHtml(str) {
|
|
|
225
226
|
* @param {string} [options.assets.outputDir='assets'] - Output subdirectory for processed assets
|
|
226
227
|
* @param {boolean} [options.assets.videoPosters=true] - Extract poster frames from videos (requires ffmpeg)
|
|
227
228
|
* @param {boolean} [options.assets.pdfThumbnails=true] - Generate thumbnails for PDFs (requires pdf-lib)
|
|
229
|
+
* @param {Object} [options.search] - Search index configuration
|
|
230
|
+
* @param {boolean} [options.search.enabled=true] - Generate search index (uses site.yml config by default)
|
|
231
|
+
* @param {string} [options.search.filename='search-index.json'] - Search index filename
|
|
228
232
|
*/
|
|
229
233
|
export function siteContentPlugin(options = {}) {
|
|
230
234
|
const {
|
|
@@ -235,7 +239,8 @@ export function siteContentPlugin(options = {}) {
|
|
|
235
239
|
filename = 'site-content.json',
|
|
236
240
|
watch: shouldWatch = true,
|
|
237
241
|
seo = {},
|
|
238
|
-
assets: assetsConfig = {}
|
|
242
|
+
assets: assetsConfig = {},
|
|
243
|
+
search: searchPluginConfig = {}
|
|
239
244
|
} = options
|
|
240
245
|
|
|
241
246
|
// Extract asset processing options
|
|
@@ -340,6 +345,28 @@ export function siteContentPlugin(options = {}) {
|
|
|
340
345
|
return
|
|
341
346
|
}
|
|
342
347
|
|
|
348
|
+
// Serve search-index.json in dev mode (supports locale prefixes)
|
|
349
|
+
const searchIndexMatch = req.url.match(/^(?:\/([a-z]{2}))?\/search-index\.json$/)
|
|
350
|
+
if (searchIndexMatch && siteContent) {
|
|
351
|
+
const searchEnabled = searchPluginConfig.enabled !== false && isSearchEnabled(siteContent)
|
|
352
|
+
if (searchEnabled) {
|
|
353
|
+
const searchConfig = siteContent.config?.search || {}
|
|
354
|
+
const defaultLocale = siteContent.config?.defaultLanguage || 'en'
|
|
355
|
+
// Use requested locale from URL, fall back to active or default
|
|
356
|
+
const requestedLocale = searchIndexMatch[1]
|
|
357
|
+
const activeLocale = requestedLocale || siteContent.config?.activeLocale || defaultLocale
|
|
358
|
+
|
|
359
|
+
const searchIndex = generateSearchIndex(siteContent, {
|
|
360
|
+
locale: activeLocale,
|
|
361
|
+
search: searchConfig
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
res.setHeader('Content-Type', 'application/json')
|
|
365
|
+
res.end(JSON.stringify(searchIndex, null, 2))
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
343
370
|
next()
|
|
344
371
|
})
|
|
345
372
|
},
|
|
@@ -482,6 +509,29 @@ export function siteContentPlugin(options = {}) {
|
|
|
482
509
|
})
|
|
483
510
|
console.log('[site-content] Generated robots.txt')
|
|
484
511
|
}
|
|
512
|
+
|
|
513
|
+
// Generate search index if enabled
|
|
514
|
+
const searchEnabled = searchPluginConfig.enabled !== false && isSearchEnabled(finalContent)
|
|
515
|
+
if (searchEnabled) {
|
|
516
|
+
const searchConfig = finalContent.config?.search || {}
|
|
517
|
+
const defaultLocale = finalContent.config?.defaultLanguage || 'en'
|
|
518
|
+
const activeLocale = finalContent.config?.activeLocale || defaultLocale
|
|
519
|
+
|
|
520
|
+
// Generate search index for current locale
|
|
521
|
+
const searchIndex = generateSearchIndex(finalContent, {
|
|
522
|
+
locale: activeLocale,
|
|
523
|
+
search: searchConfig
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
const searchFilename = getSearchIndexFilename(activeLocale, defaultLocale)
|
|
527
|
+
this.emitFile({
|
|
528
|
+
type: 'asset',
|
|
529
|
+
fileName: searchFilename,
|
|
530
|
+
source: JSON.stringify(searchIndex, null, 2)
|
|
531
|
+
})
|
|
532
|
+
|
|
533
|
+
console.log(`[site-content] Generated ${searchFilename} (${searchIndex.count} entries)`)
|
|
534
|
+
}
|
|
485
535
|
},
|
|
486
536
|
|
|
487
537
|
closeBundle() {
|