@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 +3 -3
- package/src/corpus.js +319 -0
- package/src/index.js +7 -0
- package/src/markdown.js +48 -6
- package/src/pages.js +53 -4
- package/src/search/extract.js +19 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/projections",
|
|
3
|
-
"version": "0.
|
|
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/
|
|
35
|
-
"@uniweb/
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|
/**
|
package/src/search/extract.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Extract searchable content from site-content.json
|
|
3
3
|
*
|
|
4
|
-
* Walks
|
|
5
|
-
*
|
|
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=[]] -
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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)
|