@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.
- package/LICENSE +201 -0
- package/README.md +181 -0
- package/package.json +44 -0
- package/src/config.js +77 -0
- package/src/description.js +57 -0
- package/src/index.js +53 -0
- package/src/markdown.js +69 -0
- package/src/pages.js +235 -0
- package/src/search/collections.js +55 -0
- package/src/search/extract.js +335 -0
- package/src/search/generate.js +154 -0
- package/src/search/index.js +46 -0
- package/src/site-index.js +107 -0
|
@@ -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
|
+
}
|