@uniweb/projections 0.2.8 → 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 +50 -19
- package/src/pages.js +44 -2
- 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
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
|
|
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,
|
|
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[]}
|
|
84
|
+
* @param {string[]} roots - Routes carrying `knowledge: true`
|
|
83
85
|
* @returns {boolean}
|
|
84
86
|
*/
|
|
85
|
-
function isKnowledgePage(page,
|
|
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
|
-
|
|
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
|
|
95
|
+
* The pages a corpus may contain — **public by default; agent-only on request.**
|
|
94
96
|
*
|
|
95
|
-
* ⛔ **
|
|
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
|
-
*
|
|
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
|
|
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(
|
|
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
|
-
|
|
162
|
-
|
|
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:
|
|
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/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
|
-
*
|
|
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,20 @@ 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 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.
|
|
79
121
|
*
|
|
80
122
|
* @param {Object[]} pages
|
|
81
123
|
* @param {string[]} exclude
|
|
@@ -87,7 +129,7 @@ function excludedBranches(pages, exclude) {
|
|
|
87
129
|
if (!isContainer(page)) continue
|
|
88
130
|
if (page.seo?.noindex || page.hidden) branches.push(page.route)
|
|
89
131
|
}
|
|
90
|
-
return branches
|
|
132
|
+
return branches.concat(knowledgeRoots(pages))
|
|
91
133
|
}
|
|
92
134
|
|
|
93
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)
|