@uniweb/build 0.15.5 → 0.15.6
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 +6 -5
- package/src/search/collections.js +2 -54
- package/src/search/extract.js +2 -335
- package/src/search/generate.js +8 -108
- package/src/search/index.js +8 -22
- package/src/site/build-site-data.js +80 -3
- package/src/site/content-collector.js +22 -4
- package/src/site/data-ball.js +43 -3
- package/src/site/plugin.js +130 -16
- package/src/uwx/collections-project.js +0 -1
- package/src/uwx/entity-document.js +34 -2
- package/src/uwx/index.js +9 -0
- package/src/uwx/site-diff.js +294 -0
- package/src/uwx/site-project.js +15 -4
- package/src/uwx/site.js +58 -11
- package/src/uwx/sync-package.js +81 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/build",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.6",
|
|
4
4
|
"description": "Build tooling for the Uniweb Component Web Platform",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -59,12 +59,13 @@
|
|
|
59
59
|
"js-yaml": "^4.1.0",
|
|
60
60
|
"sharp": "^0.33.2",
|
|
61
61
|
"yaml": "^2.5.0",
|
|
62
|
+
"@uniweb/projections": "0.1.1",
|
|
62
63
|
"@uniweb/theming": "0.1.14",
|
|
63
|
-
"@uniweb/content-writer": "0.2.
|
|
64
|
+
"@uniweb/content-writer": "0.2.8"
|
|
64
65
|
},
|
|
65
66
|
"optionalDependencies": {
|
|
66
|
-
"@uniweb/content-reader": "1.1.
|
|
67
|
-
"@uniweb/runtime": "0.8.
|
|
67
|
+
"@uniweb/content-reader": "1.1.16",
|
|
68
|
+
"@uniweb/runtime": "0.8.37",
|
|
68
69
|
"@uniweb/schemas": "0.2.4"
|
|
69
70
|
},
|
|
70
71
|
"peerDependencies": {
|
|
@@ -74,7 +75,7 @@
|
|
|
74
75
|
"@tailwindcss/vite": "^4.0.0",
|
|
75
76
|
"@vitejs/plugin-react": "^4.0.0 || ^5.0.0",
|
|
76
77
|
"vite-plugin-svgr": "^4.0.0",
|
|
77
|
-
"@uniweb/core": "0.7.
|
|
78
|
+
"@uniweb/core": "0.7.30"
|
|
78
79
|
},
|
|
79
80
|
"peerDependenciesMeta": {
|
|
80
81
|
"vite": {
|
|
@@ -1,54 +1,2 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* Collection cascade files (`data/{name}.json`) contain all non-deferred fields.
|
|
5
|
-
* If `search.fetchDetail: true` is set, the caller is responsible for merging
|
|
6
|
-
* per-record detail files into each item before calling this function.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* @param {string} name - Collection name (e.g. "articles")
|
|
11
|
-
* @param {Object} config - Collection config from site.yml (config.collections[name])
|
|
12
|
-
* @param {Object} collectionData - Parsed cascade JSON (`data/{name}.json`)
|
|
13
|
-
* @param {string} locale - Locale code (e.g. "en")
|
|
14
|
-
* @returns {Object} Collection search index
|
|
15
|
-
*/
|
|
16
|
-
export function generateCollectionIndex(name, config, collectionData, locale) {
|
|
17
|
-
const fields = config.search?.fields || ['title']
|
|
18
|
-
const weight = config.search?.weight ?? 0.7
|
|
19
|
-
const items = collectionData?.items || []
|
|
20
|
-
|
|
21
|
-
const entries = items.map(item => {
|
|
22
|
-
const content = fields.map(f => item[f] || '').filter(Boolean).join(' ')
|
|
23
|
-
const slug = item.slug || item.id || String(item.title || '').toLowerCase().replace(/\s+/g, '-')
|
|
24
|
-
return {
|
|
25
|
-
id: `collection:${name}:${slug}`,
|
|
26
|
-
type: 'collection',
|
|
27
|
-
collection: name,
|
|
28
|
-
route: `${config.route}/${slug}`,
|
|
29
|
-
title: item.title || item.name || slug,
|
|
30
|
-
content,
|
|
31
|
-
excerpt: content.length > 160
|
|
32
|
-
? content.slice(0, 160).trim() + '…'
|
|
33
|
-
: content,
|
|
34
|
-
weight,
|
|
35
|
-
item: pickDisplayFields(item),
|
|
36
|
-
}
|
|
37
|
-
})
|
|
38
|
-
|
|
39
|
-
return {
|
|
40
|
-
type: 'collection',
|
|
41
|
-
collection: name,
|
|
42
|
-
locale,
|
|
43
|
-
generated: new Date().toISOString(),
|
|
44
|
-
entries,
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function pickDisplayFields(item) {
|
|
49
|
-
const { slug, title, name, date, image, author, excerpt, role } = item
|
|
50
|
-
return Object.fromEntries(
|
|
51
|
-
Object.entries({ slug, title, name, date, image, author, excerpt, role })
|
|
52
|
-
.filter(([, v]) => v != null)
|
|
53
|
-
)
|
|
54
|
-
}
|
|
1
|
+
/** @deprecated Import from `@uniweb/projections` — kept so existing paths resolve. */
|
|
2
|
+
export { generateCollectionIndex } from '@uniweb/projections/search'
|
package/src/search/extract.js
CHANGED
|
@@ -1,335 +1,2 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
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 pages marked as noindex
|
|
45
|
-
if (page.seo?.noindex) {
|
|
46
|
-
continue
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Extract page-level entry
|
|
50
|
-
if (includePagesFlag) {
|
|
51
|
-
const pageEntry = extractFromPage(page)
|
|
52
|
-
if (pageEntry) {
|
|
53
|
-
entries.push(pageEntry)
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Extract section-level entries
|
|
58
|
-
if (includeSections) {
|
|
59
|
-
for (const section of page.sections || []) {
|
|
60
|
-
const sectionEntries = extractFromSection(section, page, {
|
|
61
|
-
includeHeadings,
|
|
62
|
-
includeParagraphs,
|
|
63
|
-
includeLinks,
|
|
64
|
-
includeLists,
|
|
65
|
-
excludeComponents
|
|
66
|
-
})
|
|
67
|
-
entries.push(...sectionEntries)
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
return entries
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Extract search entry from page metadata
|
|
77
|
-
* @param {Object} page - Page data
|
|
78
|
-
* @returns {Object|null} Search entry or null
|
|
79
|
-
*/
|
|
80
|
-
function extractFromPage(page) {
|
|
81
|
-
const route = page.route || '/'
|
|
82
|
-
const title = page.title || ''
|
|
83
|
-
const description = page.description || ''
|
|
84
|
-
const keywords = page.keywords || page.seo?.keywords || []
|
|
85
|
-
|
|
86
|
-
// Skip pages with no meaningful content
|
|
87
|
-
if (!title && !description) {
|
|
88
|
-
return null
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return {
|
|
92
|
-
id: `page:${route}`,
|
|
93
|
-
type: 'page',
|
|
94
|
-
route,
|
|
95
|
-
title,
|
|
96
|
-
description,
|
|
97
|
-
keywords: Array.isArray(keywords) ? keywords : [keywords].filter(Boolean),
|
|
98
|
-
content: [title, description].filter(Boolean).join(' '),
|
|
99
|
-
// Boost factor for search ranking (pages are more important)
|
|
100
|
-
weight: route === '/' ? 1.0 : 0.8
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Extract search entries from a section (and subsections)
|
|
106
|
-
* @param {Object} section - Section data
|
|
107
|
-
* @param {Object} page - Parent page
|
|
108
|
-
* @param {Object} options - Extraction options
|
|
109
|
-
* @returns {Array<Object>} Array of search entries
|
|
110
|
-
*/
|
|
111
|
-
function extractFromSection(section, page, options) {
|
|
112
|
-
const entries = []
|
|
113
|
-
const {
|
|
114
|
-
includeHeadings,
|
|
115
|
-
includeParagraphs,
|
|
116
|
-
includeLinks,
|
|
117
|
-
includeLists,
|
|
118
|
-
excludeComponents
|
|
119
|
-
} = options
|
|
120
|
-
|
|
121
|
-
const sectionId = section.id || 'unknown'
|
|
122
|
-
const component = section.component || section.type || 'unknown'
|
|
123
|
-
|
|
124
|
-
// Skip excluded components
|
|
125
|
-
if (excludeComponents.includes(component)) {
|
|
126
|
-
return entries
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Extract text from ProseMirror content
|
|
130
|
-
const textParts = []
|
|
131
|
-
let sectionTitle = ''
|
|
132
|
-
|
|
133
|
-
if (section.content?.type === 'doc') {
|
|
134
|
-
const extracted = extractFromProseMirrorDoc(section.content, {
|
|
135
|
-
includeHeadings,
|
|
136
|
-
includeParagraphs,
|
|
137
|
-
includeLinks,
|
|
138
|
-
includeLists
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
sectionTitle = extracted.title || ''
|
|
142
|
-
textParts.push(...extracted.textParts)
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// Also check params for title (from YAML frontmatter)
|
|
146
|
-
if (!sectionTitle && section.params?.title) {
|
|
147
|
-
sectionTitle = section.params.title
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Build content string
|
|
151
|
-
const content = textParts.join(' ').trim()
|
|
152
|
-
|
|
153
|
-
// Create entry if there's meaningful content
|
|
154
|
-
if (sectionTitle || content) {
|
|
155
|
-
entries.push({
|
|
156
|
-
id: `section:${page.route}:${sectionId}`,
|
|
157
|
-
type: 'section',
|
|
158
|
-
route: page.route,
|
|
159
|
-
sectionId,
|
|
160
|
-
anchor: `Section${sectionId}`,
|
|
161
|
-
component,
|
|
162
|
-
title: sectionTitle,
|
|
163
|
-
pageTitle: page.title || '',
|
|
164
|
-
content,
|
|
165
|
-
// Generate excerpt (first ~160 chars)
|
|
166
|
-
excerpt: generateExcerpt(content, 160),
|
|
167
|
-
// Section weight is lower than page
|
|
168
|
-
weight: 0.6
|
|
169
|
-
})
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Recursively process subsections
|
|
173
|
-
for (const subsection of section.subsections || []) {
|
|
174
|
-
const subEntries = extractFromSection(subsection, page, options)
|
|
175
|
-
entries.push(...subEntries)
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
return entries
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Extract text from ProseMirror document
|
|
183
|
-
* @param {Object} doc - ProseMirror document
|
|
184
|
-
* @param {Object} options - Extraction options
|
|
185
|
-
* @returns {Object} Extracted content { title, textParts }
|
|
186
|
-
*/
|
|
187
|
-
function extractFromProseMirrorDoc(doc, options) {
|
|
188
|
-
const { includeHeadings, includeParagraphs, includeLinks, includeLists } = options
|
|
189
|
-
const textParts = []
|
|
190
|
-
let title = ''
|
|
191
|
-
let foundFirstHeading = false
|
|
192
|
-
|
|
193
|
-
if (!doc.content) {
|
|
194
|
-
return { title, textParts }
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
for (const node of doc.content) {
|
|
198
|
-
if (node.type === 'heading') {
|
|
199
|
-
const text = extractTextFromNode(node)
|
|
200
|
-
if (!text) continue
|
|
201
|
-
|
|
202
|
-
// First H1 becomes the title
|
|
203
|
-
if (!foundFirstHeading && node.attrs?.level === 1) {
|
|
204
|
-
title = text
|
|
205
|
-
foundFirstHeading = true
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (includeHeadings) {
|
|
209
|
-
textParts.push(text)
|
|
210
|
-
}
|
|
211
|
-
} else if (node.type === 'paragraph') {
|
|
212
|
-
if (includeParagraphs) {
|
|
213
|
-
const text = extractTextFromNode(node)
|
|
214
|
-
if (text) {
|
|
215
|
-
textParts.push(text)
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
// Extract link labels separately if requested
|
|
220
|
-
if (includeLinks) {
|
|
221
|
-
const links = extractLinksFromNode(node)
|
|
222
|
-
for (const link of links) {
|
|
223
|
-
if (link.label) {
|
|
224
|
-
textParts.push(link.label)
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
} else if ((node.type === 'bulletList' || node.type === 'orderedList') && includeLists) {
|
|
229
|
-
const listTexts = extractFromList(node)
|
|
230
|
-
textParts.push(...listTexts)
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
return { title, textParts }
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Extract all text content from a node
|
|
239
|
-
* @param {Object} node - ProseMirror node
|
|
240
|
-
* @returns {string} Text content
|
|
241
|
-
*/
|
|
242
|
-
function extractTextFromNode(node) {
|
|
243
|
-
if (!node.content) return ''
|
|
244
|
-
|
|
245
|
-
const texts = []
|
|
246
|
-
|
|
247
|
-
for (const child of node.content) {
|
|
248
|
-
if (child.type === 'text') {
|
|
249
|
-
texts.push(child.text || '')
|
|
250
|
-
} else if (child.content) {
|
|
251
|
-
// Recurse for nested nodes
|
|
252
|
-
texts.push(extractTextFromNode(child))
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
return texts.join('').trim()
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Extract links from a paragraph node
|
|
261
|
-
* @param {Object} node - Paragraph node
|
|
262
|
-
* @returns {Array<{label: string, href: string}>} Links
|
|
263
|
-
*/
|
|
264
|
-
function extractLinksFromNode(node) {
|
|
265
|
-
const links = []
|
|
266
|
-
|
|
267
|
-
if (!node.content) return links
|
|
268
|
-
|
|
269
|
-
for (const child of node.content) {
|
|
270
|
-
if (child.type === 'text' && child.marks) {
|
|
271
|
-
const linkMark = child.marks.find(m => m.type === 'link')
|
|
272
|
-
if (linkMark) {
|
|
273
|
-
links.push({
|
|
274
|
-
label: child.text || '',
|
|
275
|
-
href: linkMark.attrs?.href || ''
|
|
276
|
-
})
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
return links
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Extract text from list items
|
|
286
|
-
* @param {Object} listNode - List node
|
|
287
|
-
* @returns {Array<string>} List item texts
|
|
288
|
-
*/
|
|
289
|
-
function extractFromList(listNode) {
|
|
290
|
-
const texts = []
|
|
291
|
-
|
|
292
|
-
if (!listNode.content) return texts
|
|
293
|
-
|
|
294
|
-
for (const listItem of listNode.content) {
|
|
295
|
-
if (listItem.type === 'listItem' && listItem.content) {
|
|
296
|
-
for (const child of listItem.content) {
|
|
297
|
-
if (child.type === 'paragraph') {
|
|
298
|
-
const text = extractTextFromNode(child)
|
|
299
|
-
if (text) {
|
|
300
|
-
texts.push(text)
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
return texts
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
/**
|
|
311
|
-
* Generate excerpt from content
|
|
312
|
-
* @param {string} content - Full content
|
|
313
|
-
* @param {number} maxLength - Maximum length
|
|
314
|
-
* @returns {string} Excerpt
|
|
315
|
-
*/
|
|
316
|
-
function generateExcerpt(content, maxLength = 160) {
|
|
317
|
-
if (!content) return ''
|
|
318
|
-
|
|
319
|
-
// Normalize whitespace
|
|
320
|
-
const normalized = content.replace(/\s+/g, ' ').trim()
|
|
321
|
-
|
|
322
|
-
if (normalized.length <= maxLength) {
|
|
323
|
-
return normalized
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// Find a good break point (word boundary)
|
|
327
|
-
let breakPoint = normalized.lastIndexOf(' ', maxLength)
|
|
328
|
-
if (breakPoint < maxLength * 0.5) {
|
|
329
|
-
breakPoint = maxLength
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
return normalized.slice(0, breakPoint).trim() + '…'
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
export default extractSearchContent
|
|
1
|
+
/** @deprecated Import from `@uniweb/projections` — kept so existing paths resolve. */
|
|
2
|
+
export { extractSearchContent, extractSearchContent as default } from '@uniweb/projections/search'
|
package/src/search/generate.js
CHANGED
|
@@ -1,108 +1,8 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import { extractSearchContent } from './extract.js'
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Generate search index for a site
|
|
13
|
-
* @param {Object} siteContent - Parsed site-content.json
|
|
14
|
-
* @param {Object} options - Generation options
|
|
15
|
-
* @param {string} [options.locale] - Locale code for this index
|
|
16
|
-
* @param {Object} [options.extract] - Options passed to extractSearchContent
|
|
17
|
-
* @param {Object} [options.search] - Search configuration from site.yml
|
|
18
|
-
* @returns {Object} Search index object
|
|
19
|
-
*/
|
|
20
|
-
export function generateSearchIndex(siteContent, options = {}) {
|
|
21
|
-
const {
|
|
22
|
-
locale = siteContent.config?.activeLocale || resolveDefaultLocale(siteContent.config),
|
|
23
|
-
extract: extractOptions = {},
|
|
24
|
-
search: searchConfig = {}
|
|
25
|
-
} = options
|
|
26
|
-
|
|
27
|
-
// Merge search config with extract options
|
|
28
|
-
const mergedExtractOptions = {
|
|
29
|
-
...extractOptions,
|
|
30
|
-
excludeRoutes: searchConfig.exclude?.routes || extractOptions.excludeRoutes || [],
|
|
31
|
-
excludeComponents: searchConfig.exclude?.components || extractOptions.excludeComponents || []
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Check what to include from config
|
|
35
|
-
if (searchConfig.include) {
|
|
36
|
-
mergedExtractOptions.pages = searchConfig.include.pages !== false
|
|
37
|
-
mergedExtractOptions.sections = searchConfig.include.sections !== false
|
|
38
|
-
mergedExtractOptions.headings = searchConfig.include.headings !== false
|
|
39
|
-
mergedExtractOptions.paragraphs = searchConfig.include.paragraphs !== false
|
|
40
|
-
mergedExtractOptions.links = searchConfig.include.links !== false
|
|
41
|
-
mergedExtractOptions.lists = searchConfig.include.lists !== false
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Extract searchable content
|
|
45
|
-
const entries = extractSearchContent(siteContent, mergedExtractOptions)
|
|
46
|
-
|
|
47
|
-
// Build the index
|
|
48
|
-
const index = {
|
|
49
|
-
version: '1.0',
|
|
50
|
-
locale,
|
|
51
|
-
generated: new Date().toISOString(),
|
|
52
|
-
count: entries.length,
|
|
53
|
-
entries
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
return index
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Check if search is enabled for a site
|
|
61
|
-
* @param {Object} siteContent - Parsed site-content.json
|
|
62
|
-
* @returns {boolean}
|
|
63
|
-
*/
|
|
64
|
-
export function isSearchEnabled(siteContent) {
|
|
65
|
-
// Search is enabled by default unless explicitly disabled
|
|
66
|
-
return siteContent.config?.search?.enabled !== false
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Get search configuration from site content
|
|
71
|
-
* @param {Object} siteContent - Parsed site-content.json
|
|
72
|
-
* @returns {Object} Search configuration
|
|
73
|
-
*/
|
|
74
|
-
export function getSearchConfig(siteContent) {
|
|
75
|
-
const config = siteContent.config?.search || {}
|
|
76
|
-
|
|
77
|
-
return {
|
|
78
|
-
enabled: config.enabled !== false,
|
|
79
|
-
include: {
|
|
80
|
-
pages: config.include?.pages !== false,
|
|
81
|
-
sections: config.include?.sections !== false,
|
|
82
|
-
headings: config.include?.headings !== false,
|
|
83
|
-
paragraphs: config.include?.paragraphs !== false,
|
|
84
|
-
links: config.include?.links !== false,
|
|
85
|
-
lists: config.include?.lists !== false
|
|
86
|
-
},
|
|
87
|
-
exclude: {
|
|
88
|
-
routes: config.exclude?.routes || [],
|
|
89
|
-
components: config.exclude?.components || []
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Get the search index filename for a locale
|
|
96
|
-
* @param {string} locale - Locale code
|
|
97
|
-
* @param {string} defaultLocale - Default locale code
|
|
98
|
-
* @returns {string} Filename
|
|
99
|
-
*/
|
|
100
|
-
export function getSearchIndexFilename(locale, defaultLocale) {
|
|
101
|
-
// Default locale uses root filename, others use locale prefix path
|
|
102
|
-
if (locale === defaultLocale) {
|
|
103
|
-
return 'search-index.json'
|
|
104
|
-
}
|
|
105
|
-
return `${locale}/search-index.json`
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export default generateSearchIndex
|
|
1
|
+
/** @deprecated Import from `@uniweb/projections` — kept so existing paths resolve. */
|
|
2
|
+
export {
|
|
3
|
+
generateSearchIndex,
|
|
4
|
+
generateSearchIndex as default,
|
|
5
|
+
isSearchEnabled,
|
|
6
|
+
getSearchConfig,
|
|
7
|
+
getSearchIndexFilename
|
|
8
|
+
} from '@uniweb/projections/search'
|
package/src/search/index.js
CHANGED
|
@@ -1,34 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Search Index Generation Module
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Re-export of `@uniweb/projections/search`. The implementation moved there so
|
|
5
|
+
* one copy serves every publisher of a site — the CLI and the app both import
|
|
6
|
+
* it, and the backend stores the result rather than generating its own. This
|
|
7
|
+
* shim keeps `@uniweb/build/search` and every existing call site working.
|
|
6
8
|
*
|
|
7
9
|
* @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
10
|
*/
|
|
23
11
|
|
|
24
12
|
export {
|
|
25
13
|
extractSearchContent,
|
|
26
|
-
extractSearchContent as default
|
|
27
|
-
} from './extract.js'
|
|
28
|
-
|
|
29
|
-
export {
|
|
14
|
+
extractSearchContent as default,
|
|
30
15
|
generateSearchIndex,
|
|
31
16
|
isSearchEnabled,
|
|
32
17
|
getSearchConfig,
|
|
33
|
-
getSearchIndexFilename
|
|
34
|
-
|
|
18
|
+
getSearchIndexFilename,
|
|
19
|
+
generateCollectionIndex
|
|
20
|
+
} from '@uniweb/projections/search'
|
|
@@ -19,15 +19,25 @@
|
|
|
19
19
|
|
|
20
20
|
import { writeFile, readFile, mkdir, cp } from 'node:fs/promises'
|
|
21
21
|
import { existsSync } from 'node:fs'
|
|
22
|
-
import { join, resolve } from 'node:path'
|
|
22
|
+
import { join, resolve, dirname } from 'node:path'
|
|
23
23
|
|
|
24
24
|
import { resolveDefaultLocale } from '@uniweb/core'
|
|
25
25
|
import { collectSiteContent } from './content-collector.js'
|
|
26
26
|
import { processCollections, writeCollectionFiles } from './collection-processor.js'
|
|
27
27
|
import { processAssets, rewriteSiteContentPaths } from './asset-processor.js'
|
|
28
28
|
import { processAdvancedAssets } from './advanced-processors.js'
|
|
29
|
-
import {
|
|
30
|
-
|
|
29
|
+
import {
|
|
30
|
+
generateSearchIndex,
|
|
31
|
+
generateCollectionIndex,
|
|
32
|
+
mergeSearchIndexes,
|
|
33
|
+
getSearchIndexFilename,
|
|
34
|
+
renderSiteIndex,
|
|
35
|
+
renderPageMarkdown,
|
|
36
|
+
resolveAgentsConfig,
|
|
37
|
+
selectIndexablePages,
|
|
38
|
+
pageMarkdownFilename,
|
|
39
|
+
INDEX_FILENAME
|
|
40
|
+
} from '@uniweb/projections'
|
|
31
41
|
|
|
32
42
|
/**
|
|
33
43
|
* Build the site data outputs needed by `uniweb deploy` (link-mode).
|
|
@@ -215,6 +225,7 @@ export async function buildSiteData({
|
|
|
215
225
|
|
|
216
226
|
// Collection indexes — one per routed + search-configured collection
|
|
217
227
|
const collections = finalContent.config?.collections || {}
|
|
228
|
+
const collectionIndexes = []
|
|
218
229
|
for (const [collName, collConfig] of Object.entries(collections)) {
|
|
219
230
|
if (!collConfig.search?.enabled || !collConfig.route) continue
|
|
220
231
|
const cascadeFile = join(resolvedDistDir, 'data', `${collName}.json`)
|
|
@@ -226,9 +237,75 @@ export async function buildSiteData({
|
|
|
226
237
|
continue
|
|
227
238
|
}
|
|
228
239
|
const collIndex = generateCollectionIndex(collName, collConfig, collectionData, defaultLocale)
|
|
240
|
+
collectionIndexes.push(collIndex)
|
|
229
241
|
await writeFile(join(searchDir, `${collName}.json`), JSON.stringify(collIndex))
|
|
230
242
|
}
|
|
243
|
+
|
|
244
|
+
// The single-file form, for the BROWSER lane.
|
|
245
|
+
//
|
|
246
|
+
// The split files above serve a server that loads only the parts a query
|
|
247
|
+
// needs. Kit's client-side `index` provider needs all of it, and asks for
|
|
248
|
+
// `search-index.json` — so without this, a site published through this lane
|
|
249
|
+
// 404s on its own search index and degrades to no results. Emitting both
|
|
250
|
+
// from the same entries is one extra serialization and means the client
|
|
251
|
+
// works identically on every lane, with no host configuration describing
|
|
252
|
+
// where the index lives.
|
|
253
|
+
await writeFile(
|
|
254
|
+
join(resolvedDistDir, getSearchIndexFilename(defaultLocale, defaultLocale)),
|
|
255
|
+
JSON.stringify(mergeSearchIndexes(pagesIndex, collectionIndexes))
|
|
256
|
+
)
|
|
231
257
|
}
|
|
232
258
|
|
|
259
|
+
// 6. Agent projections — `llms.txt` and one `.md` per page.
|
|
260
|
+
//
|
|
261
|
+
// Emitted here as well as in the vite plugin because both lanes publish
|
|
262
|
+
// a site, and an artifact derived from site content has to exist
|
|
263
|
+
// whichever lane produced it. A projection present after one publish and
|
|
264
|
+
// absent after another is worse than none: agents are told to rely on it.
|
|
265
|
+
//
|
|
266
|
+
// Ungated by `features:` — projections are free, so they carry no
|
|
267
|
+
// billing intent and no entitlement to check downstream.
|
|
268
|
+
await writeProjections(finalContent, resolvedDistDir)
|
|
269
|
+
|
|
233
270
|
return { siteContent: finalContent, distDir: resolvedDistDir }
|
|
234
271
|
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Write the agent projections into `distDir`.
|
|
275
|
+
*
|
|
276
|
+
* Mirrors the vite plugin's `emitProjections`, differing only in how bytes
|
|
277
|
+
* reach disk (`writeFile` vs. Rollup's `emitFile`) — the generators, options
|
|
278
|
+
* and filenames come from `@uniweb/projections`, so the two lanes cannot
|
|
279
|
+
* disagree about what they produce.
|
|
280
|
+
*
|
|
281
|
+
* @param {Object} siteContent - Final site content for the default locale
|
|
282
|
+
* @param {string} distDir - Resolved output directory
|
|
283
|
+
* @returns {Promise<void>}
|
|
284
|
+
*/
|
|
285
|
+
async function writeProjections(siteContent, distDir) {
|
|
286
|
+
const agents = resolveAgentsConfig(siteContent?.config)
|
|
287
|
+
if (!agents.index && !agents.markdown) return
|
|
288
|
+
if (!siteContent?.pages?.length) return
|
|
289
|
+
|
|
290
|
+
const defaultLocale = resolveDefaultLocale(siteContent.config)
|
|
291
|
+
const options = {
|
|
292
|
+
baseUrl: siteContent.config?.seo?.baseUrl || '',
|
|
293
|
+
locale: siteContent.config?.activeLocale || defaultLocale,
|
|
294
|
+
defaultLocale
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (agents.index) {
|
|
298
|
+
const index = renderSiteIndex(siteContent, { ...options, exclude: agents.exclude })
|
|
299
|
+
await writeFile(join(distDir, INDEX_FILENAME), index)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!agents.markdown) return
|
|
303
|
+
|
|
304
|
+
for (const page of selectIndexablePages(siteContent.pages, { exclude: agents.exclude })) {
|
|
305
|
+
const markdown = renderPageMarkdown(page)
|
|
306
|
+
if (!markdown) continue
|
|
307
|
+
const target = join(distDir, pageMarkdownFilename(page.route))
|
|
308
|
+
await mkdir(dirname(target), { recursive: true })
|
|
309
|
+
await writeFile(target, markdown)
|
|
310
|
+
}
|
|
311
|
+
}
|