@uniweb/projections 0.2.7 → 0.3.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/projections",
3
- "version": "0.2.7",
3
+ "version": "0.3.0",
4
4
  "description": "Projections of a Uniweb site's content — agent index, per-page markdown, search index. Pure JS, runs anywhere.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -31,8 +31,8 @@
31
31
  "node": ">=20.19"
32
32
  },
33
33
  "dependencies": {
34
- "@uniweb/core": "^0.8.4",
35
- "@uniweb/content-writer": "^0.3.3"
34
+ "@uniweb/content-writer": "^0.3.3",
35
+ "@uniweb/core": "^0.8.5"
36
36
  },
37
37
  "devDependencies": {
38
38
  "vitest": "^4.1.7",
package/src/corpus.js ADDED
@@ -0,0 +1,319 @@
1
+ /**
2
+ * @fileoverview The exploration projection: a whole site as greppable markdown.
3
+ *
4
+ * `renderSiteIndex` answers *"what pages exist"* and `renderPageMarkdown`
5
+ * answers *"what does one page say"*. This answers the third question an agent
6
+ * asks — *"where in this site is X"* — by producing the artifact `grep`,
7
+ * `search` and `read_page` run against.
8
+ *
9
+ * ── WHY MARKDOWN, AND WHY ONE STRING PER PAGE ───────────────────────────────
10
+ *
11
+ * Tools of this kind are commonly built over a plain-text extraction, with
12
+ * markdown produced separately and left unread. That loses exactly what an
13
+ * agent navigates by: headings stop being headings, and links, code fences and
14
+ * list structure vanish from what is searched.
15
+ *
16
+ * The subtler failure is line numbers. If a matcher numbers lines *within a
17
+ * section* while a reader concatenates sections before returning them, a match
18
+ * at "line 12" does not index into what the reader returns — and an agent
19
+ * relies on those agreeing, the way they agree in a codebase.
20
+ *
21
+ * Both are addressed by *shape* rather than by care: a corpus page is **one
22
+ * markdown string**, and every tool addresses that same string. The numbers
23
+ * cannot disagree because there is only one thing to number.
24
+ *
25
+ * ── WHY SEGMENTS, RATHER THAN FIXED-SIZE CHUNKS ─────────────────────────────
26
+ *
27
+ * Keyword scoring needs a document unit, and a sliding window of ~N words is
28
+ * the usual choice. Sections are a better one and cost nothing extra: they are
29
+ * the authored boundary, they are what `search/extract.js` already indexes,
30
+ * and — decisively — they carry `sectionDomId`, the anchor the renderer
31
+ * actually emits. So a hit can be cited as `/route#section-3`, a link that
32
+ * resolves in a browser. A word-window has no anchor, which is why tools built
33
+ * on one return a route and leave the reader to find the passage.
34
+ *
35
+ * ⛔ Do NOT recover anchors by parsing headings out of the finished markdown.
36
+ * A heading is prose the author wrote; `sectionDomId` is an id the renderer
37
+ * emits. They are not the same thing and only one of them scrolls anywhere.
38
+ */
39
+
40
+ import { collectPageBlocks, BLOCK_SEPARATOR } from './markdown.js'
41
+ import {
42
+ selectIndexablePages,
43
+ isContainer,
44
+ isDynamicTemplate,
45
+ hasDraftSegment,
46
+ isAtOrUnder,
47
+ knowledgeRoots,
48
+ isKnowledgeRoute,
49
+ } from './pages.js'
50
+ import { normalizeExclude } from './config.js'
51
+
52
+ /**
53
+ * Split pages into the agent-only set and the rendered set.
54
+ *
55
+ * A page is *knowledge* when it carries `knowledge: true`, or when it sits
56
+ * beneath a route that does. The cascade is by route prefix, so `/kb` claims
57
+ * `/kb/auth` and does **not** claim `/kbase`.
58
+ *
59
+ * ⚠️ This partition tells a **renderer** what to drop. It does not by itself
60
+ * decide what the agent sees — {@link selectCorpusPages} does, and it is
61
+ * deliberately not "everything left over". See the note there.
62
+ *
63
+ * @param {Object[]} pages - `siteContent.pages`
64
+ * @returns {{knowledgePages: Object[], renderedPages: Object[]}}
65
+ */
66
+ export function partitionKnowledgePages(pages = []) {
67
+ if (!Array.isArray(pages)) return { knowledgePages: [], renderedPages: [] }
68
+
69
+ const roots = knowledgeRoots(pages)
70
+
71
+ const knowledgePages = []
72
+ const renderedPages = []
73
+
74
+ for (const page of pages) {
75
+ if (isKnowledgePage(page, roots)) knowledgePages.push(page)
76
+ else renderedPages.push(page)
77
+ }
78
+
79
+ return { knowledgePages, renderedPages }
80
+ }
81
+
82
+ /**
83
+ * @param {Object} page
84
+ * @param {string[]} roots - Routes carrying `knowledge: true`
85
+ * @returns {boolean}
86
+ */
87
+ function isKnowledgePage(page, roots) {
88
+ // The flag is checked first rather than folded into the route test: a page
89
+ // carrying it with no route at all is still not something to render.
90
+ if (page?.knowledge) return true
91
+ return isKnowledgeRoute(page?.route, roots)
92
+ }
93
+
94
+ /**
95
+ * The pages a corpus may contain — **public by default; agent-only on request.**
96
+ *
97
+ * ⛔ **THE ARGUMENT ORDER IS THE SAFETY PROPERTY. Passing nothing gets you the
98
+ * PUBLIC selection.** A caller who forgets the option, or copies a call from a
99
+ * public-tier consumer, under-discloses. That is the failure we can afford;
100
+ * the other direction puts agent-only content on a visitor-facing endpoint.
101
+ * So `knowledge` defaults to `false` and the agent corpus is the one that has
102
+ * to ask — the two tiers must not merely pass different arguments to one
103
+ * selector, the public one must be what you get by passing none.
104
+ *
105
+ * ⛔ **AND EVEN WITH `knowledge: true`, THIS IS NOT "the site's full content".**
106
+ * It is tempting to index every page — the agent is more useful the more it
107
+ * knows — and to treat `knowledge: true` as purely a render-subtraction. That
108
+ * is defensible for a private tool an author runs over their own content, and
109
+ * wrong for a corpus that answers questions **for whoever can reach the site**.
110
+ * An agent that greps `seo.noindex` pages and quotes them back is a disclosure
111
+ * the author never agreed to, and `pages.js` already states the principle this
112
+ * follows from: projections are on by default, so weakening the exclusions
113
+ * turns the default into a leak.
114
+ *
115
+ * With `knowledge: true` the corpus is the public projection **plus** what the
116
+ * author explicitly marked for agents:
117
+ *
118
+ * | signal | reach | why |
119
+ * |---|---|---|
120
+ * | the `agents:` selection | included | already the public projection's set |
121
+ * | `knowledge: true` | **added** | an explicit opt-in to agent-visible content |
122
+ * | `hidden`, `seo.noindex` | **overridden** for knowledge pages | they mean "not for browsers", and a knowledge page is *by definition* not rendered — honouring them would make `knowledge:` do nothing |
123
+ * | `agents.exclude` | **always wins** | the one signal that says "keep agents out", so it outranks a conflicting `knowledge: true` |
124
+ * | `_`-prefixed route | **always wins** | a draft is a draft |
125
+ * | container, dynamic template | always excluded | shape, not policy — neither is a page with a body |
126
+ *
127
+ * The two "always wins" rows are the fail-closed half. Where the author has
128
+ * contradicted themselves, the cost of honouring the exclusion is that the
129
+ * agent knows less; the cost of ignoring it is private content on a public
130
+ * endpoint. Those are not symmetric.
131
+ *
132
+ * ⚠️ **The `added` row is load-bearing and was not always doing anything.**
133
+ * Until `excludedBranches` learned about `knowledge:`, the public half already
134
+ * contained knowledge pages, so this union was a no-op and every test of it
135
+ * passed for the wrong reason. That is the shape to watch for when reading a
136
+ * union: it looks correct whether or not the two halves are actually disjoint.
137
+ * `corpus.test.js` now pins the public half's *absence* of them directly.
138
+ *
139
+ * @param {Object[]} pages - `siteContent.pages`
140
+ * @param {Object} [options]
141
+ * @param {string[]} [options.exclude] - Additional excluded route prefixes
142
+ * @param {boolean} [options.knowledge=false] - Admit `knowledge:` pages. Off by
143
+ * default; see the argument-order note above before changing that.
144
+ * @returns {Object[]} Pages in build order, no duplicates
145
+ */
146
+ export function selectCorpusPages(pages = [], { exclude = [], knowledge = false } = {}) {
147
+ const publicPages = selectIndexablePages(pages, { exclude })
148
+ if (!knowledge) return publicPages
149
+
150
+ const publicSet = new Set(publicPages)
151
+ const branches = normalizeExclude(exclude)
152
+ const { knowledgePages } = partitionKnowledgePages(pages)
153
+
154
+ const admissibleKnowledge = knowledgePages.filter(
155
+ page =>
156
+ page?.route &&
157
+ !isContainer(page) &&
158
+ !isDynamicTemplate(page) &&
159
+ !hasDraftSegment(page.route) &&
160
+ !branches.some(prefix => isAtOrUnder(page.route, prefix))
161
+ )
162
+
163
+ const admitted = new Set([...publicSet, ...admissibleKnowledge])
164
+
165
+ // Build order, not selection order — the corpus is read by humans debugging it.
166
+ return pages.filter(page => admitted.has(page))
167
+ }
168
+
169
+ /**
170
+ * Build the greppable corpus for one locale of a site.
171
+ *
172
+ * **Public by default.** `knowledge: true` is the agent tier and has to be
173
+ * asked for — see the argument-order note on {@link selectCorpusPages}.
174
+ *
175
+ * @param {Object} siteContent - Parsed site-content.json (one locale)
176
+ * @param {Object} [options]
177
+ * @param {string[]} [options.exclude] - Additional excluded route prefixes
178
+ * @param {boolean} [options.includeChildren=true] - Include nested sections
179
+ * @param {boolean} [options.knowledge=false] - Admit `knowledge:` pages
180
+ * @returns {Array<CorpusPage>} Pages with content, in build order
181
+ */
182
+ export function buildCorpus(
183
+ siteContent,
184
+ { exclude, includeChildren = true, knowledge = false } = {}
185
+ ) {
186
+ const pages = siteContent?.pages || []
187
+ const config = siteContent?.config || {}
188
+ const excluded = exclude ?? config.agents?.exclude ?? []
189
+
190
+ // Only meaningful when knowledge pages were admitted; an empty set otherwise
191
+ // keeps every `CorpusPage.knowledge` false, which is the truth on that tier.
192
+ const agentOnly = knowledge
193
+ ? new Set(partitionKnowledgePages(pages).knowledgePages.map(page => page.route))
194
+ : new Set()
195
+
196
+ const corpus = []
197
+
198
+ for (const page of selectCorpusPages(pages, { exclude: excluded, knowledge })) {
199
+ const built = buildCorpusPage(page, {
200
+ includeChildren,
201
+ knowledge: agentOnly.has(page.route),
202
+ })
203
+ // A page whose sections carry no projectable content is not a page the
204
+ // agent can read. Listing it would produce read_page hits that return ''.
205
+ if (built) corpus.push(built)
206
+ }
207
+
208
+ return corpus
209
+ }
210
+
211
+ /**
212
+ * @typedef {Object} CorpusSegment
213
+ * @property {string} anchor - `sectionDomId`; cite as `${route}#${anchor}`
214
+ * @property {number} startLine - 1-based, into the page's `markdown`
215
+ * @property {number} endLine - 1-based inclusive
216
+ * @property {string} title - Leading heading text, or '' when the block has none
217
+ * @property {string} markdown - This block alone
218
+ */
219
+
220
+ /**
221
+ * @typedef {Object} CorpusPage
222
+ * @property {string} route
223
+ * @property {string} title
224
+ * @property {string} description
225
+ * @property {boolean} knowledge - Agent-only (never rendered for a visitor)
226
+ * @property {string} markdown - The whole page; every tool addresses THIS string
227
+ * @property {number} lineCount
228
+ * @property {CorpusSegment[]} segments
229
+ */
230
+
231
+ /**
232
+ * One page, as markdown plus a map of where each section landed in it.
233
+ *
234
+ * @param {Object} page
235
+ * @param {Object} options
236
+ * @param {boolean} options.includeChildren
237
+ * @param {boolean} options.knowledge
238
+ * @returns {CorpusPage|null} `null` when the page projects to nothing
239
+ */
240
+ function buildCorpusPage(page, { includeChildren, knowledge }) {
241
+ const blocks = collectPageBlocks(page, { includeChildren })
242
+ if (blocks.length === 0) return null
243
+
244
+ // Joining with a separator holding N newlines puts the next block's first
245
+ // line N lines below the previous block's last: one newline merely ends that
246
+ // last line, and each further one opens a blank line.
247
+ //
248
+ // ⚠️ Counting the separator's *lines* instead of its *newlines* is an
249
+ // off-by-one that only shows up from the second block onward, which is why
250
+ // `segment line ranges index into that exact string` slices every segment
251
+ // back out of the finished markdown rather than checking arithmetic.
252
+ const separatorNewlines = (BLOCK_SEPARATOR.match(/\n/g) || []).length
253
+ const segments = []
254
+ let line = 1
255
+
256
+ for (const block of blocks) {
257
+ const endLine = line + block.markdown.split('\n').length - 1
258
+ segments.push({
259
+ anchor: block.anchor,
260
+ startLine: line,
261
+ endLine,
262
+ title: leadingHeading(block.markdown),
263
+ markdown: block.markdown,
264
+ })
265
+ line = endLine + separatorNewlines
266
+ }
267
+
268
+ return {
269
+ route: page.route,
270
+ title: page.title || page.label || page.route,
271
+ description: page.description || '',
272
+ knowledge,
273
+ markdown: blocks.map(block => block.markdown).join(BLOCK_SEPARATOR),
274
+ lineCount: segments[segments.length - 1].endLine,
275
+ segments,
276
+ }
277
+ }
278
+
279
+ /**
280
+ * The block's own heading, when it opens with one.
281
+ *
282
+ * Only the first line is considered, which is why no fenced-code guard is
283
+ * needed: a fence cannot open and reach a `#` in the same line. A general
284
+ * heading scan over the whole block WOULD need one — ``` blocks routinely
285
+ * contain `# comment` — so do not widen this without adding it.
286
+ *
287
+ * @param {string} markdown
288
+ * @returns {string}
289
+ */
290
+ function leadingHeading(markdown) {
291
+ const match = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(markdown.split('\n', 1)[0] || '')
292
+ return match ? match[2] : ''
293
+ }
294
+
295
+ /**
296
+ * The corpus table of contents — what `list_pages` answers from.
297
+ *
298
+ * Carries no body text, so an agent can hold the whole shape of a site in one
299
+ * tool call and then read only what it needs. `lines` is what makes it useful
300
+ * for planning: it is the cost of the `read_page` the agent is deciding whether
301
+ * to spend.
302
+ *
303
+ * @param {CorpusPage[]} corpus
304
+ * @returns {Array<{route: string, title: string, description: string, knowledge: boolean, lines: number, sections: Array<{anchor: string, title: string, startLine: number}>}>}
305
+ */
306
+ export function buildCorpusManifest(corpus = []) {
307
+ return corpus.map(page => ({
308
+ route: page.route,
309
+ title: page.title,
310
+ description: page.description,
311
+ knowledge: page.knowledge,
312
+ lines: page.lineCount,
313
+ sections: page.segments.map(segment => ({
314
+ anchor: segment.anchor,
315
+ title: segment.title,
316
+ startLine: segment.startLine,
317
+ })),
318
+ }))
319
+ }
package/src/index.js CHANGED
@@ -27,6 +27,13 @@ export { renderSiteIndex } from './site-index.js'
27
27
  export { renderPageMarkdown } from './markdown.js'
28
28
  export { resolvePageDescription } from './description.js'
29
29
 
30
+ export {
31
+ buildCorpus,
32
+ buildCorpusManifest,
33
+ selectCorpusPages,
34
+ partitionKnowledgePages,
35
+ } from './corpus.js'
36
+
30
37
  export {
31
38
  INDEX_FILENAME,
32
39
  DEFAULT_BRANCH_MIN_PAGES,
package/src/markdown.js CHANGED
@@ -18,6 +18,14 @@
18
18
  */
19
19
 
20
20
  import { proseMirrorToMarkdown } from '@uniweb/content-writer'
21
+ import { sectionDomId } from '@uniweb/core/section-id'
22
+
23
+ /**
24
+ * How blocks are joined into a page. Exported because the corpus projection
25
+ * reconstructs the same string while tracking line offsets into it, and the
26
+ * two must not be able to disagree about the separator.
27
+ */
28
+ export const BLOCK_SEPARATOR = '\n\n'
21
29
 
22
30
  /**
23
31
  * Render one page as markdown.
@@ -28,13 +36,40 @@ import { proseMirrorToMarkdown } from '@uniweb/content-writer'
28
36
  * @returns {string} Markdown, or '' when the page has no projectable content
29
37
  */
30
38
  export function renderPageMarkdown(page, { includeChildren = true } = {}) {
39
+ return collectPageBlocks(page, { includeChildren })
40
+ .map(block => block.markdown)
41
+ .join(BLOCK_SEPARATOR)
42
+ .trim()
43
+ }
44
+
45
+ /**
46
+ * The same blocks, still separate and each tagged with the anchor that links
47
+ * to it.
48
+ *
49
+ * Exists so the corpus projection can say *where in the page* a match landed.
50
+ * `renderPageMarkdown` throws that away by joining, and reconstructing it by
51
+ * re-parsing the finished markdown is exactly the mistake this package exists
52
+ * to prevent: headings in the output are prose, while `sectionDomId` is the id
53
+ * the renderer actually emits. Only one of those resolves in a browser.
54
+ *
55
+ * ⚠️ Blocks are non-empty and individually trimmed, which is what lets the
56
+ * corpus treat `join(BLOCK_SEPARATOR)` as equal to `renderPageMarkdown`'s
57
+ * output without re-trimming. `tests/corpus.test.js` asserts that equality
58
+ * rather than assuming it.
59
+ *
60
+ * @param {Object} page - A collected page (`siteContent.pages[n]`)
61
+ * @param {Object} [options]
62
+ * @param {boolean} [options.includeChildren=true] - Include nested sections
63
+ * @returns {Array<{anchor: string, markdown: string}>}
64
+ */
65
+ export function collectPageBlocks(page, { includeChildren = true } = {}) {
31
66
  const blocks = []
32
67
 
33
68
  for (const section of page?.sections || []) {
34
- collectSection(section, blocks, includeChildren)
69
+ collectSection(section, blocks, includeChildren, null)
35
70
  }
36
71
 
37
- return blocks.join('\n\n').trim()
72
+ return blocks
38
73
  }
39
74
 
40
75
  /**
@@ -44,17 +79,24 @@ export function renderPageMarkdown(page, { includeChildren = true } = {}) {
44
79
  * content that happens to render inside a parent. Skipping them would drop
45
80
  * body text an author wrote, which is exactly what retrieval is for.
46
81
  *
82
+ * `ancestorAnchor` is threaded down rather than each child resolving its own,
83
+ * matching `search/extract.js` — a nested section renders *inside* its parent,
84
+ * so the parent's id is the fragment that actually scrolls to it. Consequence
85
+ * worth knowing: a parent and its children share an anchor.
86
+ *
47
87
  * @param {Object} section
48
- * @param {string[]} blocks
88
+ * @param {Array<{anchor: string, markdown: string}>} blocks
49
89
  * @param {boolean} includeChildren
90
+ * @param {string|null} ancestorAnchor
50
91
  */
51
- function collectSection(section, blocks, includeChildren) {
92
+ function collectSection(section, blocks, includeChildren, ancestorAnchor) {
93
+ const anchor = ancestorAnchor || sectionDomId(section)
52
94
  const markdown = serializeSectionContent(section)
53
- if (markdown) blocks.push(markdown)
95
+ if (markdown) blocks.push({ anchor, markdown })
54
96
 
55
97
  if (!includeChildren) return
56
98
  for (const child of section?.subsections || []) {
57
- collectSection(child, blocks, includeChildren)
99
+ collectSection(child, blocks, includeChildren, anchor)
58
100
  }
59
101
  }
60
102
 
package/src/pages.js CHANGED
@@ -43,25 +43,60 @@ export function isDynamicTemplate(page) {
43
43
  * @param {string} route
44
44
  * @returns {boolean}
45
45
  */
46
- function hasDraftSegment(route) {
46
+ export function hasDraftSegment(route) {
47
47
  return (route || '').split('/').some(segment => segment.startsWith('_'))
48
48
  }
49
49
 
50
50
  /**
51
51
  * Is `route` at or beneath `prefix`?
52
+ *
53
+ * ⚠️ Exported for `corpus.js`, which composes the same exclusions with a
54
+ * different policy — NOT re-exported from `index.js`. It is a package-internal
55
+ * primitive, and one shared implementation of "is this route inside that
56
+ * branch" is the point: two copies of prefix matching is how `/kb` starts
57
+ * matching `/kbase`.
58
+ *
52
59
  * @param {string} route
53
60
  * @param {string} prefix
54
61
  * @returns {boolean}
55
62
  */
56
- function isAtOrUnder(route, prefix) {
63
+ export function isAtOrUnder(route, prefix) {
57
64
  if (prefix === '/') return true
58
65
  return route === prefix || route.startsWith(`${prefix}/`)
59
66
  }
60
67
 
68
+ /**
69
+ * The routes carrying `knowledge: true` — the roots of the agent-only branches.
70
+ *
71
+ * ⚠️ Defined here, in the lower layer, rather than in `corpus.js` where the
72
+ * concept reads like it belongs. Both files need it and the dependency only
73
+ * runs one way, so putting it there would mean a second copy of "is this route
74
+ * inside that branch" — the failure {@link isAtOrUnder} exists to prevent.
75
+ *
76
+ * @param {Object[]} pages
77
+ * @returns {string[]}
78
+ */
79
+ export function knowledgeRoots(pages = []) {
80
+ if (!Array.isArray(pages)) return []
81
+ return pages.filter(page => page?.knowledge && page.route).map(page => page.route)
82
+ }
83
+
84
+ /**
85
+ * Is `route` a knowledge route — carrying the flag, or beneath one that does?
86
+ *
87
+ * @param {string} route
88
+ * @param {string[]} roots - {@link knowledgeRoots}
89
+ * @returns {boolean}
90
+ */
91
+ export function isKnowledgeRoute(route, roots) {
92
+ if (!route) return false
93
+ return roots.some(root => isAtOrUnder(route, root))
94
+ }
95
+
61
96
  /**
62
97
  * Route prefixes whose whole branch is excluded.
63
98
  *
64
- * Two sources, with different reach — deliberately:
99
+ * Three sources, with different reach — deliberately:
65
100
  *
66
101
  * - `agents.exclude` cascades. `exclude: [/internal]` plainly means the
67
102
  * branch, not one page.
@@ -69,6 +104,20 @@ function isAtOrUnder(route, prefix) {
69
104
  * pure structure: suppressing the heading while still listing its children
70
105
  * would orphan them under the wrong group. On a page with content it stays
71
106
  * per-page, matching how the sitemap reads `noindex`.
107
+ * - **`knowledge: true` cascades**, by the same route-prefix rule
108
+ * {@link partitionKnowledgePages} renders by. A knowledge page is not
109
+ * rendered and cannot be reached by a visitor, so nothing describing the
110
+ * *public* site may name it.
111
+ *
112
+ * ⛔ That last one is a disclosure boundary, not a tidy-up, and the mistake it
113
+ * corrects was invisible for a reason worth stating. These projections are the
114
+ * **free** tier; the agent corpus that knowledge pages exist for is the
115
+ * **paid** one. Leaving `knowledge` out of this list did not merely list a
116
+ * page — it published the body an author wrote for a capability they may never
117
+ * have bought, at `llms.txt`, at `/{route}.md`, and in the search index. It
118
+ * also made {@link selectCorpusPages} degenerate: that selector is *public ∪
119
+ * knowledge*, and while knowledge rode in the public half the union added
120
+ * nothing and read as if it worked.
72
121
  *
73
122
  * @param {Object[]} pages
74
123
  * @param {string[]} exclude
@@ -80,7 +129,7 @@ function excludedBranches(pages, exclude) {
80
129
  if (!isContainer(page)) continue
81
130
  if (page.seo?.noindex || page.hidden) branches.push(page.route)
82
131
  }
83
- return branches
132
+ return branches.concat(knowledgeRoots(pages))
84
133
  }
85
134
 
86
135
  /**
@@ -1,14 +1,15 @@
1
1
  /**
2
2
  * Extract searchable content from site-content.json
3
3
  *
4
- * Walks through all pages and sections, extracting text content
5
- * for search indexing. Reuses patterns from i18n extraction.
4
+ * Walks the pages a *visitor* can reach — {@link selectIndexablePages} decides
5
+ * which those are and extracts their text for search indexing.
6
6
  */
7
7
 
8
8
  // Leaf subpath, not the bare `@uniweb/core` entry: this package's environment
9
9
  // contract forbids the package root (it pulls semantic-parser + theming).
10
10
  // Enforced by tests/environment.test.js.
11
11
  import { sectionDomId } from '@uniweb/core/section-id'
12
+ import { selectIndexablePages } from '../pages.js'
12
13
 
13
14
  /**
14
15
  * Extract all searchable content from site
@@ -20,7 +21,8 @@ import { sectionDomId } from '@uniweb/core/section-id'
20
21
  * @param {boolean} [options.paragraphs=true] - Include paragraphs
21
22
  * @param {boolean} [options.links=true] - Include link labels
22
23
  * @param {boolean} [options.lists=true] - Include list items
23
- * @param {Array<string>} [options.excludeRoutes=[]] - Routes to exclude
24
+ * @param {Array<string>} [options.excludeRoutes=[]] - Route prefixes to
25
+ * exclude; the whole branch goes, matching `agents.exclude`
24
26
  * @param {Array<string>} [options.excludeComponents=[]] - Components to exclude
25
27
  * @returns {Array<Object>} Array of search entries
26
28
  */
@@ -38,19 +40,20 @@ export function extractSearchContent(siteContent, options = {}) {
38
40
 
39
41
  const entries = []
40
42
 
41
- for (const page of siteContent.pages || []) {
42
- const pageRoute = page.route || '/'
43
-
44
- // Skip excluded routes
45
- if (excludeRoutes.some(r => pageRoute.startsWith(r))) {
46
- continue
47
- }
48
-
49
- // Skip pages marked as noindex
50
- if (page.seo?.noindex) {
51
- continue
52
- }
53
-
43
+ // Which pages may be described is `pages.js`'s decision, not this file's.
44
+ //
45
+ // ⛔ This used to be two inline `continue`s — `search.exclude.routes` and
46
+ // `seo.noindex` and the gap between that pair and the shared selector was a
47
+ // disclosure. A search index is a *visitor-facing* artifact, so it must
48
+ // describe only pages a visitor can reach; `knowledge:` pages are not
49
+ // rendered at all, and drafts, `hidden` pages and dynamic route *templates*
50
+ // are each unreachable or unlinkable in their own way. Reimplementing that
51
+ // list here is how it drifts from the one `llms.txt` and the per-page
52
+ // markdown use, which is exactly what happened.
53
+ //
54
+ // Note this also fixes the prefix test: `excludeRoutes` was matched with
55
+ // `startsWith`, so excluding `/kb` silently excluded `/kbase` too.
56
+ for (const page of selectIndexablePages(siteContent.pages || [], { exclude: excludeRoutes })) {
54
57
  // Extract page-level entry
55
58
  if (includePagesFlag) {
56
59
  const pageEntry = extractFromPage(page)