@uniweb/projections 0.1.1

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.
@@ -0,0 +1,154 @@
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
+ // The leaf subpath, not the bare `@uniweb/core` entry: the package root pulls
9
+ // in `@uniweb/semantic-parser`, which would put this package's environment
10
+ // contract at the mercy of a transitive dependency. `locale-config` has
11
+ // zero imports of its own.
12
+ import { resolveDefaultLocale } from '@uniweb/core/locale-config'
13
+ import { extractSearchContent } from './extract.js'
14
+
15
+ /**
16
+ * Generate search index for a site
17
+ * @param {Object} siteContent - Parsed site-content.json
18
+ * @param {Object} options - Generation options
19
+ * @param {string} [options.locale] - Locale code for this index
20
+ * @param {Object} [options.extract] - Options passed to extractSearchContent
21
+ * @param {Object} [options.search] - Search configuration from site.yml
22
+ * @returns {Object} Search index object
23
+ */
24
+ export function generateSearchIndex(siteContent, options = {}) {
25
+ const {
26
+ locale = siteContent.config?.activeLocale || resolveDefaultLocale(siteContent.config),
27
+ extract: extractOptions = {},
28
+ search: searchConfig = {}
29
+ } = options
30
+
31
+ // Merge search config with extract options
32
+ const mergedExtractOptions = {
33
+ ...extractOptions,
34
+ excludeRoutes: searchConfig.exclude?.routes || extractOptions.excludeRoutes || [],
35
+ excludeComponents: searchConfig.exclude?.components || extractOptions.excludeComponents || []
36
+ }
37
+
38
+ // Check what to include from config
39
+ if (searchConfig.include) {
40
+ mergedExtractOptions.pages = searchConfig.include.pages !== false
41
+ mergedExtractOptions.sections = searchConfig.include.sections !== false
42
+ mergedExtractOptions.headings = searchConfig.include.headings !== false
43
+ mergedExtractOptions.paragraphs = searchConfig.include.paragraphs !== false
44
+ mergedExtractOptions.links = searchConfig.include.links !== false
45
+ mergedExtractOptions.lists = searchConfig.include.lists !== false
46
+ }
47
+
48
+ // Extract searchable content
49
+ const entries = extractSearchContent(siteContent, mergedExtractOptions)
50
+
51
+ // Build the index.
52
+ //
53
+ // Deliberately no `generated` timestamp. A clock in a derived artifact
54
+ // cannot be content-addressed: every publish produces different bytes, so a
55
+ // content-addressed asset store never recognizes an unchanged index and
56
+ // re-uploads it forever. It also breaks byte-parity between publishers,
57
+ // which is the property this package exists to guarantee. Nothing read the
58
+ // field. If a freshness signal is ever needed it belongs in delivery
59
+ // metadata, not in the artifact.
60
+ const index = {
61
+ version: '1.0',
62
+ locale,
63
+ count: entries.length,
64
+ entries
65
+ }
66
+
67
+ return index
68
+ }
69
+
70
+ /**
71
+ * Merge a pages index with collection indexes into the single-file form.
72
+ *
73
+ * Two layouts exist because two consumers want different things, and neither
74
+ * is wrong:
75
+ *
76
+ * - **Split** (`_search/{locale}/pages.json` + `{name}.json`) suits a *server*
77
+ * that loads only the parts a query needs.
78
+ * - **Merged** (`search-index.json`) suits a *browser* that needs all of it —
79
+ * one request rather than N+1, and no manifest to discover the parts from.
80
+ *
81
+ * The bug was never that both exist; it was that a lane emitted only one of
82
+ * them while a consumer of the other was pointed at it. Emitting both from the
83
+ * same entries costs a serialization, keeps the client working on every lane
84
+ * with no configuration, and means a host declaring search never has to
85
+ * describe *where the index lives* — only whether it serves queries.
86
+ *
87
+ * @param {Object} pagesIndex - Result of {@link generateSearchIndex}
88
+ * @param {Object[]} [collectionIndexes] - Results of `generateCollectionIndex`
89
+ * @returns {Object} One index carrying every entry
90
+ */
91
+ export function mergeSearchIndexes(pagesIndex, collectionIndexes = []) {
92
+ const entries = [
93
+ ...(pagesIndex?.entries || []),
94
+ ...collectionIndexes.flatMap(index => index?.entries || [])
95
+ ]
96
+
97
+ return {
98
+ version: pagesIndex?.version || '1.0',
99
+ locale: pagesIndex?.locale,
100
+ count: entries.length,
101
+ entries
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Check if search is enabled for a site
107
+ * @param {Object} siteContent - Parsed site-content.json
108
+ * @returns {boolean}
109
+ */
110
+ export function isSearchEnabled(siteContent) {
111
+ // Search is enabled by default unless explicitly disabled
112
+ return siteContent.config?.search?.enabled !== false
113
+ }
114
+
115
+ /**
116
+ * Get search configuration from site content
117
+ * @param {Object} siteContent - Parsed site-content.json
118
+ * @returns {Object} Search configuration
119
+ */
120
+ export function getSearchConfig(siteContent) {
121
+ const config = siteContent.config?.search || {}
122
+
123
+ return {
124
+ enabled: config.enabled !== false,
125
+ include: {
126
+ pages: config.include?.pages !== false,
127
+ sections: config.include?.sections !== false,
128
+ headings: config.include?.headings !== false,
129
+ paragraphs: config.include?.paragraphs !== false,
130
+ links: config.include?.links !== false,
131
+ lists: config.include?.lists !== false
132
+ },
133
+ exclude: {
134
+ routes: config.exclude?.routes || [],
135
+ components: config.exclude?.components || []
136
+ }
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Get the search index filename for a locale
142
+ * @param {string} locale - Locale code
143
+ * @param {string} defaultLocale - Default locale code
144
+ * @returns {string} Filename
145
+ */
146
+ export function getSearchIndexFilename(locale, defaultLocale) {
147
+ // Default locale uses root filename, others use locale prefix path
148
+ if (locale === defaultLocale) {
149
+ return 'search-index.json'
150
+ }
151
+ return `${locale}/search-index.json`
152
+ }
153
+
154
+ export default generateSearchIndex
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Search Index Generation Module
3
+ *
4
+ * Generates search indexes for Uniweb sites — a projection of the site's
5
+ * authored content, in the same family as the agent index and the per-page
6
+ * markdown. The generated indexes can be loaded at runtime for client-side
7
+ * search.
8
+ *
9
+ * Moved here from `@uniweb/build` so that **one implementation serves every
10
+ * publisher**. The code was always portable (no imports beyond a
11
+ * zero-dependency locale helper); what made sharing unsafe was the package it
12
+ * lived in, whose identity pulls Vite and Node APIs — which is why a second
13
+ * copy of this logic was hand-maintained elsewhere and drifted from it.
14
+ * `@uniweb/build` re-exports this module, so existing call sites are unchanged.
15
+ *
16
+ * @module @uniweb/projections/search
17
+ *
18
+ * @example
19
+ * import { generateSearchIndex, isSearchEnabled } from '@uniweb/projections/search'
20
+ *
21
+ * // Check if search is enabled
22
+ * if (isSearchEnabled(siteContent)) {
23
+ * // Generate index for current locale
24
+ * const index = generateSearchIndex(siteContent, {
25
+ * locale: 'en'
26
+ * })
27
+ *
28
+ * // Write to file
29
+ * writeFileSync('dist/search-index.json', JSON.stringify(index))
30
+ * }
31
+ */
32
+
33
+ export {
34
+ extractSearchContent,
35
+ extractSearchContent as default
36
+ } from './extract.js'
37
+
38
+ export {
39
+ generateSearchIndex,
40
+ mergeSearchIndexes,
41
+ isSearchEnabled,
42
+ getSearchConfig,
43
+ getSearchIndexFilename
44
+ } from './generate.js'
45
+
46
+ export { generateCollectionIndex } from './collections.js'
@@ -0,0 +1,107 @@
1
+ /**
2
+ * @fileoverview The discovery projection: an annotated index of the site.
3
+ *
4
+ * Answers the question an agent cannot answer by guessing — "does this page
5
+ * exist, and where?" — and answers it with meaning attached, so the agent can
6
+ * pick a page rather than fetch several to find out.
7
+ *
8
+ * Follows the external `llms.txt` convention: H1 title, blockquote summary,
9
+ * `##` groups of annotated links. Link targets are the `.md` projections, so
10
+ * following an entry is one hop rather than a fetch-then-strip.
11
+ */
12
+
13
+ import { groupPagesForIndex, buildPageUrl } from './pages.js'
14
+ import { resolvePageDescription } from './description.js'
15
+
16
+ /** Heading for pages that sit under no container. */
17
+ const ROOT_GROUP_TITLE = 'Pages'
18
+
19
+ /**
20
+ * Render the agent index for one locale of a site.
21
+ *
22
+ * @param {Object} siteContent - Parsed site-content.json (one locale)
23
+ * @param {Object} [options]
24
+ * @param {string} [options.baseUrl] - Site origin; links are root-relative without it
25
+ * @param {string} [options.basePath] - Subdirectory deploy prefix
26
+ * @param {string} [options.locale] - Locale being rendered
27
+ * @param {string} [options.defaultLocale] - Locale served at the root
28
+ * @param {string[]} [options.exclude] - Additional excluded route prefixes
29
+ * @param {string} [options.title] - Override the site title
30
+ * @param {string} [options.description] - Override the site summary
31
+ * @param {string} [options.rootGroupTitle] - Heading for ungrouped pages
32
+ * @param {number} [options.maxDescriptionChars=200]
33
+ * @returns {string} The index document
34
+ */
35
+ export function renderSiteIndex(siteContent, options = {}) {
36
+ const config = siteContent?.config || {}
37
+ const {
38
+ baseUrl = config.seo?.baseUrl || '',
39
+ basePath = '',
40
+ locale = config.activeLocale,
41
+ defaultLocale,
42
+ exclude = [],
43
+ title = config.title || config.name || 'Site',
44
+ description = config.description || '',
45
+ rootGroupTitle = ROOT_GROUP_TITLE,
46
+ maxDescriptionChars = 200,
47
+ } = options
48
+
49
+ const urlOptions = {
50
+ baseUrl,
51
+ basePath,
52
+ locale,
53
+ defaultLocale,
54
+ routeTranslations: config.i18n?.routeTranslations,
55
+ }
56
+
57
+ const lines = [`# ${title}`]
58
+
59
+ if (description) {
60
+ lines.push('', ...blockquote(description))
61
+ }
62
+
63
+ const groups = groupPagesForIndex(siteContent?.pages, { exclude })
64
+
65
+ for (const group of groups) {
66
+ const entries = group.pages
67
+ .map(page => renderEntry(page, urlOptions, maxDescriptionChars))
68
+ .filter(Boolean)
69
+ if (!entries.length) continue
70
+
71
+ lines.push('', `## ${group.heading || rootGroupTitle}`, '', ...entries)
72
+ }
73
+
74
+ return `${lines.join('\n')}\n`
75
+ }
76
+
77
+ /**
78
+ * One `- [Title](url): description` entry.
79
+ *
80
+ * @param {Object} page
81
+ * @param {Object} urlOptions
82
+ * @param {number} maxChars
83
+ * @returns {string}
84
+ */
85
+ function renderEntry(page, urlOptions, maxChars) {
86
+ const title = page.title || page.label || page.route
87
+ if (!title) return ''
88
+
89
+ const url = buildPageUrl(page.route, urlOptions)
90
+ const description = resolvePageDescription(page, { maxChars })
91
+
92
+ return description
93
+ ? `- [${title}](${url}): ${description}`
94
+ : `- [${title}](${url})`
95
+ }
96
+
97
+ /**
98
+ * Wrap a summary as a markdown blockquote, one line per source line.
99
+ * @param {string} text
100
+ * @returns {string[]}
101
+ */
102
+ function blockquote(text) {
103
+ return text
104
+ .trim()
105
+ .split('\n')
106
+ .map(line => `> ${line}`.trimEnd())
107
+ }