@uniweb/projections 0.2.6 → 0.2.8

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.6",
3
+ "version": "0.2.8",
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.3",
35
- "@uniweb/content-writer": "0.3.3"
34
+ "@uniweb/core": "^0.8.5",
35
+ "@uniweb/content-writer": "^0.3.3"
36
36
  },
37
37
  "devDependencies": {
38
38
  "vitest": "^4.1.7",
package/src/corpus.js ADDED
@@ -0,0 +1,288 @@
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
+ } from './pages.js'
48
+ import { normalizeExclude } from './config.js'
49
+
50
+ /**
51
+ * Split pages into the agent-only set and the rendered set.
52
+ *
53
+ * A page is *knowledge* when it carries `knowledge: true`, or when it sits
54
+ * beneath a route that does. The cascade is by route prefix, so `/kb` claims
55
+ * `/kb/auth` and does **not** claim `/kbase`.
56
+ *
57
+ * ⚠️ This partition tells a **renderer** what to drop. It does not by itself
58
+ * decide what the agent sees — {@link selectCorpusPages} does, and it is
59
+ * deliberately not "everything left over". See the note there.
60
+ *
61
+ * @param {Object[]} pages - `siteContent.pages`
62
+ * @returns {{knowledgePages: Object[], renderedPages: Object[]}}
63
+ */
64
+ export function partitionKnowledgePages(pages = []) {
65
+ if (!Array.isArray(pages)) return { knowledgePages: [], renderedPages: [] }
66
+
67
+ const knowledgeRoots = pages.filter(page => page?.knowledge).map(page => page.route)
68
+
69
+ const knowledgePages = []
70
+ const renderedPages = []
71
+
72
+ for (const page of pages) {
73
+ if (isKnowledgePage(page, knowledgeRoots)) knowledgePages.push(page)
74
+ else renderedPages.push(page)
75
+ }
76
+
77
+ return { knowledgePages, renderedPages }
78
+ }
79
+
80
+ /**
81
+ * @param {Object} page
82
+ * @param {string[]} knowledgeRoots - Routes carrying `knowledge: true`
83
+ * @returns {boolean}
84
+ */
85
+ function isKnowledgePage(page, knowledgeRoots) {
86
+ if (page?.knowledge) return true
87
+ const route = page?.route
88
+ if (!route) return false
89
+ return knowledgeRoots.some(root => route !== root && isAtOrUnder(route, root))
90
+ }
91
+
92
+ /**
93
+ * The pages the agent corpus may contain.
94
+ *
95
+ * ⛔ **THIS IS NOT "the site's full content", AND THE DIFFERENCE IS A LEAK.**
96
+ * It is tempting to index every page — the agent is more useful the more it
97
+ * knows — and to treat `knowledge: true` as purely a render-subtraction. That
98
+ * is defensible for a private tool an author runs over their own content, and
99
+ * wrong for a corpus that answers questions **for whoever can reach the site**.
100
+ * An agent that greps `seo.noindex` pages and quotes them back is a disclosure
101
+ * the author never agreed to, and `pages.js` already states the principle this
102
+ * follows from: projections are on by default, so weakening the exclusions
103
+ * turns the default into a leak.
104
+ *
105
+ * So the corpus is the public projection **plus** what the author explicitly
106
+ * marked for agents:
107
+ *
108
+ * | signal | reach | why |
109
+ * |---|---|---|
110
+ * | the `agents:` selection | included | already the public projection's set |
111
+ * | `knowledge: true` | **added** | an explicit opt-in to agent-visible content |
112
+ * | `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 |
113
+ * | `agents.exclude` | **always wins** | the one signal that says "keep agents out", so it outranks a conflicting `knowledge: true` |
114
+ * | `_`-prefixed route | **always wins** | a draft is a draft |
115
+ * | container, dynamic template | always excluded | shape, not policy — neither is a page with a body |
116
+ *
117
+ * The two "always wins" rows are the fail-closed half. Where the author has
118
+ * contradicted themselves, the cost of honouring the exclusion is that the
119
+ * agent knows less; the cost of ignoring it is private content on a public
120
+ * endpoint. Those are not symmetric.
121
+ *
122
+ * @param {Object[]} pages - `siteContent.pages`
123
+ * @param {Object} [options]
124
+ * @param {string[]} [options.exclude] - Additional excluded route prefixes
125
+ * @returns {Object[]} Pages in build order, no duplicates
126
+ */
127
+ export function selectCorpusPages(pages = [], { exclude = [] } = {}) {
128
+ const publicSet = new Set(selectIndexablePages(pages, { exclude }))
129
+ const branches = normalizeExclude(exclude)
130
+ const { knowledgePages } = partitionKnowledgePages(pages)
131
+
132
+ const admissibleKnowledge = knowledgePages.filter(
133
+ page =>
134
+ page?.route &&
135
+ !isContainer(page) &&
136
+ !isDynamicTemplate(page) &&
137
+ !hasDraftSegment(page.route) &&
138
+ !branches.some(prefix => isAtOrUnder(page.route, prefix))
139
+ )
140
+
141
+ const admitted = new Set([...publicSet, ...admissibleKnowledge])
142
+
143
+ // Build order, not selection order — the corpus is read by humans debugging it.
144
+ return pages.filter(page => admitted.has(page))
145
+ }
146
+
147
+ /**
148
+ * Build the greppable corpus for one locale of a site.
149
+ *
150
+ * @param {Object} siteContent - Parsed site-content.json (one locale)
151
+ * @param {Object} [options]
152
+ * @param {string[]} [options.exclude] - Additional excluded route prefixes
153
+ * @param {boolean} [options.includeChildren=true] - Include nested sections
154
+ * @returns {Array<CorpusPage>} Pages with content, in build order
155
+ */
156
+ export function buildCorpus(siteContent, { exclude, includeChildren = true } = {}) {
157
+ const pages = siteContent?.pages || []
158
+ const config = siteContent?.config || {}
159
+ const excluded = exclude ?? config.agents?.exclude ?? []
160
+
161
+ const knowledgeRoutes = new Set(
162
+ partitionKnowledgePages(pages).knowledgePages.map(page => page.route)
163
+ )
164
+
165
+ const corpus = []
166
+
167
+ for (const page of selectCorpusPages(pages, { exclude: excluded })) {
168
+ const built = buildCorpusPage(page, {
169
+ includeChildren,
170
+ knowledge: knowledgeRoutes.has(page.route),
171
+ })
172
+ // A page whose sections carry no projectable content is not a page the
173
+ // agent can read. Listing it would produce read_page hits that return ''.
174
+ if (built) corpus.push(built)
175
+ }
176
+
177
+ return corpus
178
+ }
179
+
180
+ /**
181
+ * @typedef {Object} CorpusSegment
182
+ * @property {string} anchor - `sectionDomId`; cite as `${route}#${anchor}`
183
+ * @property {number} startLine - 1-based, into the page's `markdown`
184
+ * @property {number} endLine - 1-based inclusive
185
+ * @property {string} title - Leading heading text, or '' when the block has none
186
+ * @property {string} markdown - This block alone
187
+ */
188
+
189
+ /**
190
+ * @typedef {Object} CorpusPage
191
+ * @property {string} route
192
+ * @property {string} title
193
+ * @property {string} description
194
+ * @property {boolean} knowledge - Agent-only (never rendered for a visitor)
195
+ * @property {string} markdown - The whole page; every tool addresses THIS string
196
+ * @property {number} lineCount
197
+ * @property {CorpusSegment[]} segments
198
+ */
199
+
200
+ /**
201
+ * One page, as markdown plus a map of where each section landed in it.
202
+ *
203
+ * @param {Object} page
204
+ * @param {Object} options
205
+ * @param {boolean} options.includeChildren
206
+ * @param {boolean} options.knowledge
207
+ * @returns {CorpusPage|null} `null` when the page projects to nothing
208
+ */
209
+ function buildCorpusPage(page, { includeChildren, knowledge }) {
210
+ const blocks = collectPageBlocks(page, { includeChildren })
211
+ if (blocks.length === 0) return null
212
+
213
+ // Joining with a separator holding N newlines puts the next block's first
214
+ // line N lines below the previous block's last: one newline merely ends that
215
+ // last line, and each further one opens a blank line.
216
+ //
217
+ // ⚠️ Counting the separator's *lines* instead of its *newlines* is an
218
+ // off-by-one that only shows up from the second block onward, which is why
219
+ // `segment line ranges index into that exact string` slices every segment
220
+ // back out of the finished markdown rather than checking arithmetic.
221
+ const separatorNewlines = (BLOCK_SEPARATOR.match(/\n/g) || []).length
222
+ const segments = []
223
+ let line = 1
224
+
225
+ for (const block of blocks) {
226
+ const endLine = line + block.markdown.split('\n').length - 1
227
+ segments.push({
228
+ anchor: block.anchor,
229
+ startLine: line,
230
+ endLine,
231
+ title: leadingHeading(block.markdown),
232
+ markdown: block.markdown,
233
+ })
234
+ line = endLine + separatorNewlines
235
+ }
236
+
237
+ return {
238
+ route: page.route,
239
+ title: page.title || page.label || page.route,
240
+ description: page.description || '',
241
+ knowledge,
242
+ markdown: blocks.map(block => block.markdown).join(BLOCK_SEPARATOR),
243
+ lineCount: segments[segments.length - 1].endLine,
244
+ segments,
245
+ }
246
+ }
247
+
248
+ /**
249
+ * The block's own heading, when it opens with one.
250
+ *
251
+ * Only the first line is considered, which is why no fenced-code guard is
252
+ * needed: a fence cannot open and reach a `#` in the same line. A general
253
+ * heading scan over the whole block WOULD need one — ``` blocks routinely
254
+ * contain `# comment` — so do not widen this without adding it.
255
+ *
256
+ * @param {string} markdown
257
+ * @returns {string}
258
+ */
259
+ function leadingHeading(markdown) {
260
+ const match = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(markdown.split('\n', 1)[0] || '')
261
+ return match ? match[2] : ''
262
+ }
263
+
264
+ /**
265
+ * The corpus table of contents — what `list_pages` answers from.
266
+ *
267
+ * Carries no body text, so an agent can hold the whole shape of a site in one
268
+ * tool call and then read only what it needs. `lines` is what makes it useful
269
+ * for planning: it is the cost of the `read_page` the agent is deciding whether
270
+ * to spend.
271
+ *
272
+ * @param {CorpusPage[]} corpus
273
+ * @returns {Array<{route: string, title: string, description: string, knowledge: boolean, lines: number, sections: Array<{anchor: string, title: string, startLine: number}>}>}
274
+ */
275
+ export function buildCorpusManifest(corpus = []) {
276
+ return corpus.map(page => ({
277
+ route: page.route,
278
+ title: page.title,
279
+ description: page.description,
280
+ knowledge: page.knowledge,
281
+ lines: page.lineCount,
282
+ sections: page.segments.map(segment => ({
283
+ anchor: segment.anchor,
284
+ title: segment.title,
285
+ startLine: segment.startLine,
286
+ })),
287
+ }))
288
+ }
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,17 +43,24 @@ 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
  }