@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/build",
3
- "version": "0.1.6",
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
- "@uniweb/core": "0.1.5"
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
- * @returns {Object} Map of locale to output path
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 outputPath = join(localeOutputDir, 'site-content.json')
263
- await writeFile(outputPath, JSON.stringify(translated, null, 2))
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
- outputs[locale] = outputPath
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
- const siteRequire = createRequire(join(siteDir, 'package.json'))
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, default } from './plugin.js'
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,
@@ -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() {