@uniweb/projections 0.2.8 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/projections",
3
- "version": "0.2.8",
3
+ "version": "0.3.1",
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.5",
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/config.js CHANGED
@@ -21,6 +21,29 @@ const DEFAULTS = {
21
21
  branchMinPages: DEFAULT_BRANCH_MIN_PAGES,
22
22
  }
23
23
 
24
+ /**
25
+ * Every key the `agents:` block accepts — the author-facing vocabulary.
26
+ *
27
+ * ⚠️ **This is deliberately WIDER than what {@link resolveAgentsConfig} reads.**
28
+ * Some keys are *carried* rather than honoured: the framework passes them
29
+ * through to the host, which enforces them, and this package never looks at
30
+ * them. `expectedOrigins` is the first — the host checks the `Origin` of
31
+ * requests to its agent endpoint against it.
32
+ *
33
+ * ⛔ **A carry-only key still has to be listed here, and the reason is the whole
34
+ * point of the list.** The block reaches a backend as opaque JSON, so nothing
35
+ * downstream can reject a typo — an author who writes `expectedOrgins` gets a
36
+ * site that looks configured and checks nothing, silently, forever. The only
37
+ * lane that can catch it is the one that owns the words. `uniweb doctor` reads
38
+ * this list; if you add a key to the block and not to this list, doctor will
39
+ * call the author's correct spelling a typo.
40
+ */
41
+ export const AGENTS_KEYS = Object.freeze([
42
+ ...Object.keys(DEFAULTS),
43
+ // Carried, not honoured here — see the note above before removing.
44
+ 'expectedOrigins',
45
+ ])
46
+
24
47
  /**
25
48
  * Read the site's `agents:` block.
26
49
  *
package/src/corpus.js CHANGED
@@ -44,6 +44,8 @@ import {
44
44
  isDynamicTemplate,
45
45
  hasDraftSegment,
46
46
  isAtOrUnder,
47
+ knowledgeRoots,
48
+ isKnowledgeRoute,
47
49
  } from './pages.js'
48
50
  import { normalizeExclude } from './config.js'
49
51
 
@@ -64,13 +66,13 @@ import { normalizeExclude } from './config.js'
64
66
  export function partitionKnowledgePages(pages = []) {
65
67
  if (!Array.isArray(pages)) return { knowledgePages: [], renderedPages: [] }
66
68
 
67
- const knowledgeRoots = pages.filter(page => page?.knowledge).map(page => page.route)
69
+ const roots = knowledgeRoots(pages)
68
70
 
69
71
  const knowledgePages = []
70
72
  const renderedPages = []
71
73
 
72
74
  for (const page of pages) {
73
- if (isKnowledgePage(page, knowledgeRoots)) knowledgePages.push(page)
75
+ if (isKnowledgePage(page, roots)) knowledgePages.push(page)
74
76
  else renderedPages.push(page)
75
77
  }
76
78
 
@@ -79,20 +81,28 @@ export function partitionKnowledgePages(pages = []) {
79
81
 
80
82
  /**
81
83
  * @param {Object} page
82
- * @param {string[]} knowledgeRoots - Routes carrying `knowledge: true`
84
+ * @param {string[]} roots - Routes carrying `knowledge: true`
83
85
  * @returns {boolean}
84
86
  */
85
- function isKnowledgePage(page, knowledgeRoots) {
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.
86
90
  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))
91
+ return isKnowledgeRoute(page?.route, roots)
90
92
  }
91
93
 
92
94
  /**
93
- * The pages the agent corpus may contain.
95
+ * The pages a corpus may contain — **public by default; agent-only on request.**
94
96
  *
95
- * ⛔ **THIS IS NOT "the site's full content", AND THE DIFFERENCE IS A LEAK.**
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".**
96
106
  * It is tempting to index every page — the agent is more useful the more it
97
107
  * knows — and to treat `knowledge: true` as purely a render-subtraction. That
98
108
  * is defensible for a private tool an author runs over their own content, and
@@ -102,8 +112,8 @@ function isKnowledgePage(page, knowledgeRoots) {
102
112
  * follows from: projections are on by default, so weakening the exclusions
103
113
  * turns the default into a leak.
104
114
  *
105
- * So the corpus is the public projection **plus** what the author explicitly
106
- * marked for agents:
115
+ * With `knowledge: true` the corpus is the public projection **plus** what the
116
+ * author explicitly marked for agents:
107
117
  *
108
118
  * | signal | reach | why |
109
119
  * |---|---|---|
@@ -119,13 +129,25 @@ function isKnowledgePage(page, knowledgeRoots) {
119
129
  * agent knows less; the cost of ignoring it is private content on a public
120
130
  * endpoint. Those are not symmetric.
121
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
+ *
122
139
  * @param {Object[]} pages - `siteContent.pages`
123
140
  * @param {Object} [options]
124
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.
125
144
  * @returns {Object[]} Pages in build order, no duplicates
126
145
  */
127
- export function selectCorpusPages(pages = [], { exclude = [] } = {}) {
128
- const publicSet = new Set(selectIndexablePages(pages, { exclude }))
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)
129
151
  const branches = normalizeExclude(exclude)
130
152
  const { knowledgePages } = partitionKnowledgePages(pages)
131
153
 
@@ -147,27 +169,36 @@ export function selectCorpusPages(pages = [], { exclude = [] } = {}) {
147
169
  /**
148
170
  * Build the greppable corpus for one locale of a site.
149
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
+ *
150
175
  * @param {Object} siteContent - Parsed site-content.json (one locale)
151
176
  * @param {Object} [options]
152
177
  * @param {string[]} [options.exclude] - Additional excluded route prefixes
153
178
  * @param {boolean} [options.includeChildren=true] - Include nested sections
179
+ * @param {boolean} [options.knowledge=false] - Admit `knowledge:` pages
154
180
  * @returns {Array<CorpusPage>} Pages with content, in build order
155
181
  */
156
- export function buildCorpus(siteContent, { exclude, includeChildren = true } = {}) {
182
+ export function buildCorpus(
183
+ siteContent,
184
+ { exclude, includeChildren = true, knowledge = false } = {}
185
+ ) {
157
186
  const pages = siteContent?.pages || []
158
187
  const config = siteContent?.config || {}
159
188
  const excluded = exclude ?? config.agents?.exclude ?? []
160
189
 
161
- const knowledgeRoutes = new Set(
162
- partitionKnowledgePages(pages).knowledgePages.map(page => page.route)
163
- )
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()
164
195
 
165
196
  const corpus = []
166
197
 
167
- for (const page of selectCorpusPages(pages, { exclude: excluded })) {
198
+ for (const page of selectCorpusPages(pages, { exclude: excluded, knowledge })) {
168
199
  const built = buildCorpusPage(page, {
169
200
  includeChildren,
170
- knowledge: knowledgeRoutes.has(page.route),
201
+ knowledge: agentOnly.has(page.route),
171
202
  })
172
203
  // A page whose sections carry no projectable content is not a page the
173
204
  // agent can read. Listing it would produce read_page hits that return ''.
package/src/index.js CHANGED
@@ -37,6 +37,7 @@ export {
37
37
  export {
38
38
  INDEX_FILENAME,
39
39
  DEFAULT_BRANCH_MIN_PAGES,
40
+ AGENTS_KEYS,
40
41
  pageMarkdownFilename,
41
42
  branchIndexFilename,
42
43
  resolveAgentsConfig,
package/src/pages.js CHANGED
@@ -65,10 +65,38 @@ export function isAtOrUnder(route, prefix) {
65
65
  return route === prefix || route.startsWith(`${prefix}/`)
66
66
  }
67
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
+
68
96
  /**
69
97
  * Route prefixes whose whole branch is excluded.
70
98
  *
71
- * Two sources, with different reach — deliberately:
99
+ * Three sources, with different reach — deliberately:
72
100
  *
73
101
  * - `agents.exclude` cascades. `exclude: [/internal]` plainly means the
74
102
  * branch, not one page.
@@ -76,6 +104,31 @@ export function isAtOrUnder(route, prefix) {
76
104
  * pure structure: suppressing the heading while still listing its children
77
105
  * would orphan them under the wrong group. On a page with content it stays
78
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 about who the prose is ADDRESSED to — it is not a
113
+ * confidentiality boundary, and reading it as one produces wrong designs.**
114
+ * A knowledge page is source material for a service the site runs for its
115
+ * visitors; the explanations in it are written for that service to reason
116
+ * with, not for a person or a crawler to read. So naming it in `llms.txt`,
117
+ * `/{route}.md` or the search index is not "exposing a secret" — it is
118
+ * serving a reader prose that was written for somebody else, in a file that
119
+ * claims to describe the public site.
120
+ *
121
+ * ⚠️ **Do not build a security expectation on this.** The service can quote
122
+ * its source material back to whoever prompts it — that is what it is for —
123
+ * so knowledge content is reachable by a visitor through the service by
124
+ * design. [Diego, 2026-08-13]: *"It is not the case that it's private in the
125
+ * sense of sensitive. It is given to the agent so they can reason and respond
126
+ * prompts."*
127
+ *
128
+ * The omission still mattered, just not for the reason first written here: it
129
+ * also made {@link selectCorpusPages} degenerate. That selector is *public ∪
130
+ * knowledge*, and while knowledge rode in the public half the union added
131
+ * nothing and read as if it worked.
79
132
  *
80
133
  * @param {Object[]} pages
81
134
  * @param {string[]} exclude
@@ -87,7 +140,7 @@ function excludedBranches(pages, exclude) {
87
140
  if (!isContainer(page)) continue
88
141
  if (page.seo?.noindex || page.hidden) branches.push(page.route)
89
142
  }
90
- return branches
143
+ return branches.concat(knowledgeRoots(pages))
91
144
  }
92
145
 
93
146
  /**
@@ -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)