@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/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