@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
package/src/pages.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Page-graph helpers: which pages a projection may describe,
|
|
3
|
+
* how they group, and what URL points at one.
|
|
4
|
+
*
|
|
5
|
+
* The exclusion rules here are load-bearing, not tidy-up. An index is *more*
|
|
6
|
+
* revealing than a sitemap because it describes pages rather than listing
|
|
7
|
+
* them: an unlinked page becomes discoverable **and** summarized. Projections
|
|
8
|
+
* are on by default, so weakening these turns the default into a leak.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { pageMarkdownFilename, normalizeExclude } from './config.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Is this page a structural container rather than a page with content?
|
|
15
|
+
*
|
|
16
|
+
* Content-less folders (a `page.yml` with no markdown) exist in the hierarchy
|
|
17
|
+
* as groups. They have no body to project, so they become index headings.
|
|
18
|
+
*
|
|
19
|
+
* @param {Object} page
|
|
20
|
+
* @returns {boolean}
|
|
21
|
+
*/
|
|
22
|
+
export function isContainer(page) {
|
|
23
|
+
return page?.hasContent === false
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Is this a dynamic route *template* (`/blog/:slug`) rather than a page?
|
|
28
|
+
*
|
|
29
|
+
* Templates are expanded into concrete pages by the prerender lane; the
|
|
30
|
+
* template itself is not a page and must never be described as one.
|
|
31
|
+
*
|
|
32
|
+
* @param {Object} page
|
|
33
|
+
* @returns {boolean}
|
|
34
|
+
*/
|
|
35
|
+
export function isDynamicTemplate(page) {
|
|
36
|
+
return Boolean(page?.isDynamic) || (page?.route || '').includes(':')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Does any route segment start with `_`? Drafts and private files never reach
|
|
41
|
+
* the collected content, but a mounted tree can still carry one.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} route
|
|
44
|
+
* @returns {boolean}
|
|
45
|
+
*/
|
|
46
|
+
function hasDraftSegment(route) {
|
|
47
|
+
return (route || '').split('/').some(segment => segment.startsWith('_'))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Is `route` at or beneath `prefix`?
|
|
52
|
+
* @param {string} route
|
|
53
|
+
* @param {string} prefix
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
function isAtOrUnder(route, prefix) {
|
|
57
|
+
if (prefix === '/') return true
|
|
58
|
+
return route === prefix || route.startsWith(`${prefix}/`)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Route prefixes whose whole branch is excluded.
|
|
63
|
+
*
|
|
64
|
+
* Two sources, with different reach — deliberately:
|
|
65
|
+
*
|
|
66
|
+
* - `agents.exclude` cascades. `exclude: [/internal]` plainly means the
|
|
67
|
+
* branch, not one page.
|
|
68
|
+
* - `noindex` / `hidden` on a **container** cascades, because a container is
|
|
69
|
+
* pure structure: suppressing the heading while still listing its children
|
|
70
|
+
* would orphan them under the wrong group. On a page with content it stays
|
|
71
|
+
* per-page, matching how the sitemap reads `noindex`.
|
|
72
|
+
*
|
|
73
|
+
* @param {Object[]} pages
|
|
74
|
+
* @param {string[]} exclude
|
|
75
|
+
* @returns {string[]}
|
|
76
|
+
*/
|
|
77
|
+
function excludedBranches(pages, exclude) {
|
|
78
|
+
const branches = normalizeExclude(exclude)
|
|
79
|
+
for (const page of pages) {
|
|
80
|
+
if (!isContainer(page)) continue
|
|
81
|
+
if (page.seo?.noindex || page.hidden) branches.push(page.route)
|
|
82
|
+
}
|
|
83
|
+
return branches
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Should this page be described in the index / projected to markdown?
|
|
88
|
+
*
|
|
89
|
+
* @param {Object} page
|
|
90
|
+
* @param {string[]} branches - Excluded route prefixes
|
|
91
|
+
* @returns {boolean}
|
|
92
|
+
*/
|
|
93
|
+
function isIndexable(page, branches) {
|
|
94
|
+
if (!page?.route) return false
|
|
95
|
+
if (isDynamicTemplate(page)) return false
|
|
96
|
+
if (page.seo?.noindex) return false
|
|
97
|
+
if (page.hidden) return false
|
|
98
|
+
if (hasDraftSegment(page.route)) return false
|
|
99
|
+
if (branches.some(prefix => isAtOrUnder(page.route, prefix))) return false
|
|
100
|
+
return true
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The pages a projection may describe: real pages, in build order.
|
|
105
|
+
*
|
|
106
|
+
* Containers are excluded — they have no body — but they survive as headings
|
|
107
|
+
* via {@link groupPagesForIndex}.
|
|
108
|
+
*
|
|
109
|
+
* @param {Object[]} pages - `siteContent.pages` (flat, already ordered)
|
|
110
|
+
* @param {Object} [options]
|
|
111
|
+
* @param {string[]} [options.exclude] - Additional route prefixes
|
|
112
|
+
* @returns {Object[]}
|
|
113
|
+
*/
|
|
114
|
+
export function selectIndexablePages(pages = [], { exclude = [] } = {}) {
|
|
115
|
+
const branches = excludedBranches(pages, exclude)
|
|
116
|
+
return pages.filter(page => isIndexable(page, branches) && !isContainer(page))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Group indexable pages under their nearest content-less container.
|
|
121
|
+
*
|
|
122
|
+
* The grouping comes free from the page graph — no new structure is invented.
|
|
123
|
+
* Pages with no container ancestor land in a leading, unheaded group.
|
|
124
|
+
*
|
|
125
|
+
* @param {Object[]} pages - `siteContent.pages` (flat, already ordered)
|
|
126
|
+
* @param {Object} [options]
|
|
127
|
+
* @param {string[]} [options.exclude]
|
|
128
|
+
* @returns {Array<{heading: string|null, route: string|null, pages: Object[]}>}
|
|
129
|
+
*/
|
|
130
|
+
export function groupPagesForIndex(pages = [], { exclude = [] } = {}) {
|
|
131
|
+
const branches = excludedBranches(pages, exclude)
|
|
132
|
+
const containers = pages.filter(p => isContainer(p) && isIndexable(p, branches))
|
|
133
|
+
const indexable = pages.filter(p => isIndexable(p, branches) && !isContainer(p))
|
|
134
|
+
|
|
135
|
+
// Nearest container ancestor = the longest container route the page sits under.
|
|
136
|
+
const groupFor = page => {
|
|
137
|
+
let best = null
|
|
138
|
+
for (const container of containers) {
|
|
139
|
+
if (container.route === page.route) continue
|
|
140
|
+
if (!isAtOrUnder(page.route, container.route)) continue
|
|
141
|
+
if (!best || container.route.length > best.route.length) best = container
|
|
142
|
+
}
|
|
143
|
+
return best
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const root = { heading: null, route: null, pages: [] }
|
|
147
|
+
const groups = new Map()
|
|
148
|
+
|
|
149
|
+
for (const page of indexable) {
|
|
150
|
+
const container = groupFor(page)
|
|
151
|
+
if (!container) {
|
|
152
|
+
root.pages.push(page)
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
if (!groups.has(container.route)) {
|
|
156
|
+
groups.set(container.route, {
|
|
157
|
+
heading: container.title || container.label || container.route,
|
|
158
|
+
route: container.route,
|
|
159
|
+
pages: [],
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
groups.get(container.route).pages.push(page)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const ordered = [...groups.values()]
|
|
166
|
+
return root.pages.length ? [root, ...ordered] : ordered
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Translate a route into a locale's URL segments.
|
|
171
|
+
*
|
|
172
|
+
* Mirrors the sitemap's behavior so an agent index and a sitemap never
|
|
173
|
+
* disagree about where a localized page lives.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} route
|
|
176
|
+
* @param {string} locale
|
|
177
|
+
* @param {Object} [routeTranslations] - `config.i18n.routeTranslations`
|
|
178
|
+
* @returns {string}
|
|
179
|
+
*/
|
|
180
|
+
export function applyRouteTranslation(route, locale, routeTranslations) {
|
|
181
|
+
const localeMap = routeTranslations?.[locale]
|
|
182
|
+
if (!localeMap) return route
|
|
183
|
+
if (localeMap[route]) return localeMap[route]
|
|
184
|
+
for (const [canonical, translated] of Object.entries(localeMap)) {
|
|
185
|
+
if (route.startsWith(`${canonical}/`)) {
|
|
186
|
+
return translated + route.slice(canonical.length)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return route
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* URL of a page's markdown projection.
|
|
194
|
+
*
|
|
195
|
+
* Absolute when a `baseUrl` is known, root-relative otherwise. A root-relative
|
|
196
|
+
* link still resolves for an agent that arrived via the index, which is why an
|
|
197
|
+
* unset `baseUrl` warns rather than suppressing the artifact the way the
|
|
198
|
+
* sitemap does.
|
|
199
|
+
*
|
|
200
|
+
* @param {string} route
|
|
201
|
+
* @param {Object} [options]
|
|
202
|
+
* @param {string} [options.baseUrl] - Site origin, e.g. `https://example.com`
|
|
203
|
+
* @param {string} [options.basePath] - Subdirectory deploy prefix, e.g. `/docs/`
|
|
204
|
+
* @param {string} [options.locale] - Active locale
|
|
205
|
+
* @param {string} [options.defaultLocale] - Locale served at the root
|
|
206
|
+
* @param {Object} [options.routeTranslations]
|
|
207
|
+
* @returns {string}
|
|
208
|
+
*/
|
|
209
|
+
export function buildPageUrl(route, options = {}) {
|
|
210
|
+
const { baseUrl = '', basePath = '', locale, defaultLocale, routeTranslations } = options
|
|
211
|
+
|
|
212
|
+
const localized =
|
|
213
|
+
locale && defaultLocale && locale !== defaultLocale
|
|
214
|
+
? applyRouteTranslation(route, locale, routeTranslations)
|
|
215
|
+
: route
|
|
216
|
+
|
|
217
|
+
const localePrefix = locale && defaultLocale && locale !== defaultLocale ? `/${locale}` : ''
|
|
218
|
+
const filename = pageMarkdownFilename(localized)
|
|
219
|
+
const prefix = normalizeBasePath(basePath)
|
|
220
|
+
|
|
221
|
+
const path = `${prefix}${localePrefix}/${filename}`
|
|
222
|
+
if (!baseUrl) return path
|
|
223
|
+
return `${baseUrl.replace(/\/+$/, '')}${path}`
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Normalize a base path to `''` or `/segment` (no trailing slash).
|
|
228
|
+
* @param {string} basePath
|
|
229
|
+
* @returns {string}
|
|
230
|
+
*/
|
|
231
|
+
function normalizeBasePath(basePath) {
|
|
232
|
+
if (!basePath || basePath === '/') return ''
|
|
233
|
+
const withSlash = basePath.startsWith('/') ? basePath : `/${basePath}`
|
|
234
|
+
return withSlash.replace(/\/+$/, '')
|
|
235
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate search index for a file-based collection.
|
|
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
|
+
// No `generated` timestamp — see the note in `generate.js`. A clock defeats
|
|
40
|
+
// content-addressing and byte-parity between publishers.
|
|
41
|
+
return {
|
|
42
|
+
type: 'collection',
|
|
43
|
+
collection: name,
|
|
44
|
+
locale,
|
|
45
|
+
entries,
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function pickDisplayFields(item) {
|
|
50
|
+
const { slug, title, name, date, image, author, excerpt, role } = item
|
|
51
|
+
return Object.fromEntries(
|
|
52
|
+
Object.entries({ slug, title, name, date, image, author, excerpt, role })
|
|
53
|
+
.filter(([, v]) => v != null)
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
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 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
|