@uniweb/projections 0.4.1 → 0.5.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.4.1",
3
+ "version": "0.5.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": {
@@ -235,10 +235,32 @@ function extractFromProseMirrorDoc(doc, options) {
235
235
  return { title, textParts }
236
236
  }
237
237
 
238
- for (const node of doc.content) {
238
+ // CONTAINERS WHOSE CHILDREN ARE ORDINARY AUTHOR PROSE. Until 2026-08-27 this
239
+ // walk was FLAT over `doc.content`, so anything nested one level down was
240
+ // invisible to search — measured: a blockquote's prose and every table cell were
241
+ // lost outright, on a site that looked perfectly indexed.
242
+ //
243
+ // ⭐ An ALLOWLIST rather than a blind recursion, deliberately: descending into
244
+ // everything would pull in `codeBlock` and `math_display`, which are not prose —
245
+ // they inflate the index and match on tokens nobody searches for. Adding a
246
+ // container here is a decision, not a default.
247
+ const DESCEND = new Set(['blockquote', 'table', 'tableRow', 'tableCell'])
248
+
249
+ const walk = (nodes) => {
250
+ for (const node of nodes || []) {
251
+ if (!node) continue
252
+ if (DESCEND.has(node.type)) {
253
+ walk(node.content)
254
+ continue
255
+ }
256
+ visit(node)
257
+ }
258
+ }
259
+
260
+ const visit = (node) => {
239
261
  if (node.type === 'heading') {
240
262
  const text = extractTextFromNode(node)
241
- if (!text) continue
263
+ if (!text) return
242
264
 
243
265
  // First H1 becomes the title
244
266
  if (!foundFirstHeading && node.attrs?.level === 1) {
@@ -269,9 +291,16 @@ function extractFromProseMirrorDoc(doc, options) {
269
291
  } else if ((node.type === 'bulletList' || node.type === 'orderedList') && includeLists) {
270
292
  const listTexts = extractFromList(node)
271
293
  textParts.push(...listTexts)
294
+ } else if (node.type === 'image') {
295
+ // ⭐ `alt` is the author describing their own image — the only words an
296
+ // image contributes, and what a reader searching for it would type.
297
+ const alt = typeof node.attrs?.alt === 'string' ? node.attrs.alt.trim() : ''
298
+ if (alt) textParts.push(alt)
272
299
  }
273
300
  }
274
301
 
302
+ walk(doc.content)
303
+
275
304
  return { title, textParts }
276
305
  }
277
306
 
@@ -79,11 +79,12 @@ const DISPLAY_VALUE_MAX = 200
79
79
  * as an index OF collections or a collection's own listing. It is neither: it
80
80
  * is a search index derived FROM records.
81
81
  *
82
- * ⚠️ The RESULT still carries the old vocabulary — `type: 'collection'`,
83
- * `collection: name`, and `id: "collection:<name>:<slug>"`. That is a data shape
84
- * with live consumers (`kit`'s endpoint search provider and hosting's search both
85
- * read `entry.collection`), so it is a separate, larger decision than this rename
86
- * and is deliberately NOT bundled into it.
82
+ * The RESULT was renamed to match, 2026-08-27 — `type: 'record'`, `group: name`,
83
+ * `id: "record:<group>:<slug>"`. That was a real data break against two live
84
+ * consumers (kit's endpoint search provider and hosting's search), taken
85
+ * deliberately rather than left to rot: doing it while the shape was already
86
+ * being discussed cost one coordinated change; leaving it would have made the
87
+ * entry the last place `collection` survived as a lane-crossing word.
87
88
  */
88
89
  export function generateRecordSearchIndex(name, config, collectionData, locale) {
89
90
  // ⛔ NO DEFAULT FIELD LIST. This was `|| ['title']` — a claim about someone
@@ -113,9 +114,18 @@ export function generateRecordSearchIndex(name, config, collectionData, locale)
113
114
  // correctly, looks plausible, and 404s on click.
114
115
  const route = item.route || composeRoute(config.route, slug)
115
116
  return {
116
- id: `collection:${name}:${slug}`,
117
- type: 'collection',
118
- collection: name,
117
+ // ⛔ RENAMED 2026-08-27 — `collection` is FRAMEWORK'S build concept (a named
118
+ // set our build compiles to one file) and the live lane has no such thing:
119
+ // a host calls this with records fetched from a folder. Same category error
120
+ // the function name carried until it became `generateRecordSearchIndex`.
121
+ //
122
+ // ⭐ `record` is symmetric with the page entry's `type: 'page'` / `id:
123
+ // "page:<route>"`, and true on both lanes. `group` names what a result UI
124
+ // actually does with it — label or group results by the set they came from —
125
+ // without borrowing either lane's word for that set.
126
+ id: `record:${name}:${slug}`,
127
+ type: 'record',
128
+ group: name,
119
129
  ...(route ? { route } : {}),
120
130
  title: item.title || item.name || slug,
121
131
  content,
@@ -130,8 +140,8 @@ export function generateRecordSearchIndex(name, config, collectionData, locale)
130
140
  // No `generated` timestamp — see the note in `generate.js`. A clock defeats
131
141
  // content-addressing and byte-parity between publishers.
132
142
  return {
133
- type: 'collection',
134
- collection: name,
143
+ type: 'record',
144
+ group: name,
135
145
  locale,
136
146
  entries,
137
147
  }