@uniweb/projections 0.1.5 → 0.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/projections",
3
- "version": "0.1.5",
3
+ "version": "0.2.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,12 +31,12 @@
31
31
  "node": ">=20.19"
32
32
  },
33
33
  "dependencies": {
34
- "@uniweb/core": "0.7.33",
35
- "@uniweb/content-writer": "0.2.9"
34
+ "@uniweb/core": "0.8.0",
35
+ "@uniweb/content-writer": "0.3.0"
36
36
  },
37
37
  "devDependencies": {
38
38
  "vitest": "^4.1.7",
39
- "@uniweb/content-reader": "1.1.17"
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 = { index: true, markdown: true, exclude: [] }
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) return { index: false, markdown: false, exclude: [] }
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(page => isIndexable(page, branches) && !isContainer(page))
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(p => isIndexable(p, branches) && !isContainer(p))
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