@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 +3 -3
- package/src/corpus.js +288 -0
- package/src/index.js +7 -0
- package/src/markdown.js +48 -6
- package/src/pages.js +9 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/projections",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
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
|
|
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,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
|
}
|