@uniweb/projections 0.1.6 → 0.2.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 +4 -4
- package/src/config.js +34 -2
- package/src/index.js +3 -0
- package/src/pages.js +71 -4
- package/src/site-index.js +15 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/projections",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.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,12 +31,12 @@
|
|
|
31
31
|
"node": ">=20.19"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@uniweb/
|
|
35
|
-
"@uniweb/
|
|
34
|
+
"@uniweb/content-writer": "0.3.1",
|
|
35
|
+
"@uniweb/core": "0.8.0"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"vitest": "^4.1.7",
|
|
39
|
-
"@uniweb/content-reader": "1.
|
|
39
|
+
"@uniweb/content-reader": "1.2.0"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
42
|
"test": "vitest run"
|
package/src/config.js
CHANGED
|
@@ -9,8 +9,17 @@
|
|
|
9
9
|
/** Filename of the agent index within a locale's output root. */
|
|
10
10
|
export const INDEX_FILENAME = 'llms.txt'
|
|
11
11
|
|
|
12
|
+
/** Default minimum indexable pages before a branch earns its own index. */
|
|
13
|
+
export const DEFAULT_BRANCH_MIN_PAGES = 5
|
|
14
|
+
|
|
12
15
|
/** Defaults for the `agents:` block. Free capability, on by default. */
|
|
13
|
-
const DEFAULTS = {
|
|
16
|
+
const DEFAULTS = {
|
|
17
|
+
index: true,
|
|
18
|
+
markdown: true,
|
|
19
|
+
exclude: [],
|
|
20
|
+
branchIndexes: true,
|
|
21
|
+
branchMinPages: DEFAULT_BRANCH_MIN_PAGES,
|
|
22
|
+
}
|
|
14
23
|
|
|
15
24
|
/**
|
|
16
25
|
* Read the site's `agents:` block.
|
|
@@ -27,16 +36,39 @@ export function resolveAgentsConfig(siteConfig = {}) {
|
|
|
27
36
|
const agents = siteConfig?.agents
|
|
28
37
|
|
|
29
38
|
// `agents: false` turns the whole capability off in one word.
|
|
30
|
-
if (agents === false)
|
|
39
|
+
if (agents === false) {
|
|
40
|
+
return { index: false, markdown: false, exclude: [], branchIndexes: false, branchMinPages: DEFAULT_BRANCH_MIN_PAGES }
|
|
41
|
+
}
|
|
31
42
|
if (!agents || typeof agents !== 'object') return { ...DEFAULTS }
|
|
32
43
|
|
|
33
44
|
return {
|
|
34
45
|
index: agents.index !== false,
|
|
35
46
|
markdown: agents.markdown !== false,
|
|
36
47
|
exclude: normalizeExclude(agents.exclude),
|
|
48
|
+
// On by default, but gated on size — so a small site gets none and a large
|
|
49
|
+
// one gets them without anyone opting in. An explicit list pins the set.
|
|
50
|
+
branchIndexes: agents.branchIndexes !== false,
|
|
51
|
+
branchMinPages: Number.isInteger(agents.branchMinPages)
|
|
52
|
+
? agents.branchMinPages
|
|
53
|
+
: DEFAULT_BRANCH_MIN_PAGES,
|
|
37
54
|
}
|
|
38
55
|
}
|
|
39
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Output path of a branch index, relative to the locale root.
|
|
59
|
+
*
|
|
60
|
+
* `/docs` → `docs/llms.txt`, sitting beside that branch's pages so the external
|
|
61
|
+
* convention ("the index lives at the root of what it indexes") holds at every
|
|
62
|
+
* level a site publishes one.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} route
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
export function branchIndexFilename(route) {
|
|
68
|
+
const clean = (route || '').replace(/^\/+/, '').replace(/\/+$/, '')
|
|
69
|
+
return clean ? `${clean}/${INDEX_FILENAME}` : INDEX_FILENAME
|
|
70
|
+
}
|
|
71
|
+
|
|
40
72
|
/**
|
|
41
73
|
* Normalize `exclude:` to a list of leading-slash route prefixes.
|
|
42
74
|
*
|
package/src/index.js
CHANGED
|
@@ -29,12 +29,15 @@ export { resolvePageDescription } from './description.js'
|
|
|
29
29
|
|
|
30
30
|
export {
|
|
31
31
|
INDEX_FILENAME,
|
|
32
|
+
DEFAULT_BRANCH_MIN_PAGES,
|
|
32
33
|
pageMarkdownFilename,
|
|
34
|
+
branchIndexFilename,
|
|
33
35
|
resolveAgentsConfig,
|
|
34
36
|
} from './config.js'
|
|
35
37
|
|
|
36
38
|
export {
|
|
37
39
|
selectIndexablePages,
|
|
40
|
+
selectIndexBranches,
|
|
38
41
|
groupPagesForIndex,
|
|
39
42
|
buildPageUrl,
|
|
40
43
|
applyRouteTranslation,
|
package/src/pages.js
CHANGED
|
@@ -111,9 +111,70 @@ function isIndexable(page, branches) {
|
|
|
111
111
|
* @param {string[]} [options.exclude] - Additional route prefixes
|
|
112
112
|
* @returns {Object[]}
|
|
113
113
|
*/
|
|
114
|
-
export function selectIndexablePages(pages = [], { exclude = [] } = {}) {
|
|
114
|
+
export function selectIndexablePages(pages = [], { exclude = [], branch = null } = {}) {
|
|
115
115
|
const branches = excludedBranches(pages, exclude)
|
|
116
|
-
return pages.filter(
|
|
116
|
+
return pages.filter(
|
|
117
|
+
page =>
|
|
118
|
+
isIndexable(page, branches) &&
|
|
119
|
+
!isContainer(page) &&
|
|
120
|
+
(!branch || isAtOrUnder(page.route, branch))
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Route depth: `/docs` → 1, `/docs/authoring` → 2. */
|
|
125
|
+
function routeDepth(route) {
|
|
126
|
+
return (route || '').split('/').filter(Boolean).length
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Branches that warrant an index of their own (`/docs/llms.txt`).
|
|
131
|
+
*
|
|
132
|
+
* **A branch index is ADDITIVE, never a delegation.** The root index keeps
|
|
133
|
+
* enumerating every page: Phase 1's exit criterion is that a cold agent reaches
|
|
134
|
+
* a leaf in *two hops* (`/llms.txt` → the `.md`), and routing it through a
|
|
135
|
+
* branch index would make that three. So these are a scoped entry point for an
|
|
136
|
+
* agent already inside a branch — not a way to shrink the root.
|
|
137
|
+
*
|
|
138
|
+
* Consequently this does **not** close the index-size question: the root is
|
|
139
|
+
* still complete by design, so a large site's root index is still large. A
|
|
140
|
+
* size-based split is a separate decision, and it has to reckon with the
|
|
141
|
+
* two-hop criterion the same way.
|
|
142
|
+
*
|
|
143
|
+
* **Top-level containers only.** A branch index at every depth multiplies files
|
|
144
|
+
* without adding reachability — everything under `/docs/authoring` is already
|
|
145
|
+
* in both `/llms.txt` and `/docs/llms.txt`.
|
|
146
|
+
*
|
|
147
|
+
* @param {Object[]} pages - `siteContent.pages` (flat, already ordered)
|
|
148
|
+
* @param {Object} [options]
|
|
149
|
+
* @param {string[]} [options.exclude]
|
|
150
|
+
* @param {number} [options.minPages=5] - Below this, a branch rides the root index alone
|
|
151
|
+
* @returns {Array<{route: string, title: string, count: number}>}
|
|
152
|
+
*/
|
|
153
|
+
export function selectIndexBranches(pages = [], { exclude = [], minPages = 5 } = {}) {
|
|
154
|
+
const branchExclusions = excludedBranches(pages, exclude)
|
|
155
|
+
const out = []
|
|
156
|
+
|
|
157
|
+
for (const container of pages) {
|
|
158
|
+
if (!isContainer(container)) continue
|
|
159
|
+
if (!isIndexable(container, branchExclusions)) continue
|
|
160
|
+
if (routeDepth(container.route) !== 1) continue
|
|
161
|
+
|
|
162
|
+
const count = pages.filter(
|
|
163
|
+
page =>
|
|
164
|
+
isIndexable(page, branchExclusions) &&
|
|
165
|
+
!isContainer(page) &&
|
|
166
|
+
isAtOrUnder(page.route, container.route)
|
|
167
|
+
).length
|
|
168
|
+
|
|
169
|
+
if (count < minPages) continue
|
|
170
|
+
out.push({
|
|
171
|
+
route: container.route,
|
|
172
|
+
title: container.title || container.label || container.route,
|
|
173
|
+
count,
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return out
|
|
117
178
|
}
|
|
118
179
|
|
|
119
180
|
/**
|
|
@@ -127,16 +188,22 @@ export function selectIndexablePages(pages = [], { exclude = [] } = {}) {
|
|
|
127
188
|
* @param {string[]} [options.exclude]
|
|
128
189
|
* @returns {Array<{heading: string|null, route: string|null, pages: Object[]}>}
|
|
129
190
|
*/
|
|
130
|
-
export function groupPagesForIndex(pages = [], { exclude = [] } = {}) {
|
|
191
|
+
export function groupPagesForIndex(pages = [], { exclude = [], branch = null } = {}) {
|
|
131
192
|
const branches = excludedBranches(pages, exclude)
|
|
132
193
|
const containers = pages.filter(p => isContainer(p) && isIndexable(p, branches))
|
|
133
|
-
const indexable = pages.filter(
|
|
194
|
+
const indexable = pages.filter(
|
|
195
|
+
p => isIndexable(p, branches) && !isContainer(p) && (!branch || isAtOrUnder(p.route, branch))
|
|
196
|
+
)
|
|
134
197
|
|
|
135
198
|
// Nearest container ancestor = the longest container route the page sits under.
|
|
136
199
|
const groupFor = page => {
|
|
137
200
|
let best = null
|
|
138
201
|
for (const container of containers) {
|
|
139
202
|
if (container.route === page.route) continue
|
|
203
|
+
// In a branch index the branch container is the document's own subject —
|
|
204
|
+
// its title is already the H1 — so it must not also become a `## ` group.
|
|
205
|
+
// Its direct children belong in the leading unheaded group instead.
|
|
206
|
+
if (branch && container.route === branch) continue
|
|
140
207
|
if (!isAtOrUnder(page.route, container.route)) continue
|
|
141
208
|
if (!best || container.route.length > best.route.length) best = container
|
|
142
209
|
}
|
package/src/site-index.js
CHANGED
|
@@ -30,18 +30,30 @@ const ROOT_GROUP_TITLE = 'Pages'
|
|
|
30
30
|
* @param {string} [options.description] - Override the site summary
|
|
31
31
|
* @param {string} [options.rootGroupTitle] - Heading for ungrouped pages
|
|
32
32
|
* @param {number} [options.maxDescriptionChars=200]
|
|
33
|
+
* @param {string} [options.branch] - Scope to one route subtree (a branch index).
|
|
34
|
+
* Title and summary then come from that branch's container page. The document
|
|
35
|
+
* is otherwise identical, so one renderer serves both — see
|
|
36
|
+
* {@link selectIndexBranches} for why a branch index is additive rather than a
|
|
37
|
+
* delegation.
|
|
33
38
|
* @returns {string} The index document
|
|
34
39
|
*/
|
|
35
40
|
export function renderSiteIndex(siteContent, options = {}) {
|
|
36
41
|
const config = siteContent?.config || {}
|
|
42
|
+
const { branch = null } = options
|
|
43
|
+
|
|
44
|
+
// A branch index is titled by its container, not by the site.
|
|
45
|
+
const branchPage = branch
|
|
46
|
+
? (siteContent?.pages || []).find(page => page.route === branch)
|
|
47
|
+
: null
|
|
48
|
+
|
|
37
49
|
const {
|
|
38
50
|
baseUrl = config.seo?.baseUrl || '',
|
|
39
51
|
basePath = '',
|
|
40
52
|
locale = config.activeLocale,
|
|
41
53
|
defaultLocale,
|
|
42
54
|
exclude = [],
|
|
43
|
-
title = config.title || config.name || 'Site',
|
|
44
|
-
description = config.description || '',
|
|
55
|
+
title = branchPage?.title || branchPage?.label || config.title || config.name || 'Site',
|
|
56
|
+
description = branchPage?.description || (branch ? '' : config.description || ''),
|
|
45
57
|
rootGroupTitle = ROOT_GROUP_TITLE,
|
|
46
58
|
maxDescriptionChars = 200,
|
|
47
59
|
} = options
|
|
@@ -60,7 +72,7 @@ export function renderSiteIndex(siteContent, options = {}) {
|
|
|
60
72
|
lines.push('', ...blockquote(description))
|
|
61
73
|
}
|
|
62
74
|
|
|
63
|
-
const groups = groupPagesForIndex(siteContent?.pages, { exclude })
|
|
75
|
+
const groups = groupPagesForIndex(siteContent?.pages, { exclude, branch })
|
|
64
76
|
|
|
65
77
|
for (const group of groups) {
|
|
66
78
|
const entries = group.pages
|