@uniweb/projections 0.3.0 → 0.3.2

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.3.0",
3
+ "version": "0.3.2",
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/content-writer": "^0.3.3",
35
- "@uniweb/core": "^0.8.5"
34
+ "@uniweb/core": "^0.9.0",
35
+ "@uniweb/content-writer": "^0.3.3"
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/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
@@ -109,13 +109,24 @@ export function isKnowledgeRoute(route, roots) {
109
109
  * rendered and cannot be reached by a visitor, so nothing describing the
110
110
  * *public* site may name it.
111
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
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 ∪
119
130
  * knowledge*, and while knowledge rode in the public half the union added
120
131
  * nothing and read as if it worked.
121
132
  *
@@ -6,26 +6,57 @@
6
6
  * per-record detail files into each item before calling this function.
7
7
  */
8
8
 
9
+ /**
10
+ * Compose a record's route the way the build does, for the sources that have
11
+ * not already done it.
12
+ *
13
+ * `collections[name].route` is authored in `site.yml` (see the blog and
14
+ * international templates). When it is set, the build's collection processor
15
+ * already stamps `item.route` on every record — so the route below is a
16
+ * *fallback* for records that arrived without one (an API-backed collection a
17
+ * host assembled itself), never a second opinion about records that have one.
18
+ *
19
+ * The trailing-slash strip matches `collectItems` in
20
+ * `@uniweb/build`'s `site/collection-processor.js`. Without it a `route:
21
+ * /blog/` authored with a slash yields `/blog//my-post` here and
22
+ * `/blog/my-post` there — two answers to one question, on a value nobody
23
+ * checks until a visitor clicks it.
24
+ */
25
+ function composeRoute(configRoute, slug) {
26
+ if (typeof configRoute !== 'string' || configRoute === '') return undefined
27
+ return `${configRoute.replace(/\/$/, '')}/${slug}`
28
+ }
29
+
9
30
  /**
10
31
  * @param {string} name - Collection name (e.g. "articles")
11
32
  * @param {Object} config - Collection config from site.yml (config.collections[name])
12
- * @param {Object} collectionData - Parsed cascade JSON (`data/{name}.json`)
33
+ * @param {Object[]|Object} collectionData - Parsed cascade JSON (`data/{name}.json`),
34
+ * which the build writes as a bare array. The `{ items: [...] }` envelope is
35
+ * accepted too, since a host fetching the collection from a backend may carry one.
13
36
  * @param {string} locale - Locale code (e.g. "en")
14
37
  * @returns {Object} Collection search index
15
38
  */
16
39
  export function generateCollectionIndex(name, config, collectionData, locale) {
17
40
  const fields = config.search?.fields || ['title']
18
41
  const weight = config.search?.weight ?? 0.7
19
- const items = collectionData?.items || []
42
+ const items = Array.isArray(collectionData)
43
+ ? collectionData
44
+ : collectionData?.items || []
20
45
 
21
46
  const entries = items.map(item => {
22
47
  const content = fields.map(f => item[f] || '').filter(Boolean).join(' ')
23
48
  const slug = item.slug || item.id || String(item.title || '').toLowerCase().replace(/\s+/g, '-')
49
+ // The record's own route wins: the build already resolved it against the
50
+ // same config, so recomputing here could only disagree. `route` is omitted
51
+ // entirely when neither source can supply one — a missing key is detectable
52
+ // by a consumer, where the string "undefined/my-post" is a link that ranks
53
+ // correctly, looks plausible, and 404s on click.
54
+ const route = item.route || composeRoute(config.route, slug)
24
55
  return {
25
56
  id: `collection:${name}:${slug}`,
26
57
  type: 'collection',
27
58
  collection: name,
28
- route: `${config.route}/${slug}`,
59
+ ...(route ? { route } : {}),
29
60
  title: item.title || item.name || slug,
30
61
  content,
31
62
  excerpt: content.length > 160