@uniweb/projections 0.3.5 → 0.3.6

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.5",
3
+ "version": "0.3.6",
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.12.0",
35
- "@uniweb/content-writer": "^0.3.4"
34
+ "@uniweb/content-writer": "^0.3.4",
35
+ "@uniweb/core": "^0.12.1"
36
36
  },
37
37
  "devDependencies": {
38
38
  "vitest": "^4.1.7",
@@ -36,14 +36,52 @@ function composeRoute(configRoute, slug) {
36
36
  * @param {string} locale - Locale code (e.g. "en")
37
37
  * @returns {Object} Collection search index
38
38
  */
39
+ /**
40
+ * Keys whose meaning we know because WE or the backend put them there —
41
+ * identity, addressing, and derived asset URLs. Everything else belongs to the
42
+ * author and this package has no business interpreting it.
43
+ *
44
+ * ⛔ This is the ONLY list here that names field names, and it deliberately
45
+ * names OUR OWN keys rather than guessing at anyone's schema. Do not add a
46
+ * field because it "looks like" metadata.
47
+ */
48
+ const WIRING_KEYS = new Set(['$uuid', 'slug', 'id', 'route', 'image'])
49
+
50
+ /** A value a card can render — anything else is structure we cannot interpret. */
51
+ const isPrimitive = (v) =>
52
+ typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'
53
+
54
+ /**
55
+ * Cap for a value carried in `item`, the per-result display payload.
56
+ *
57
+ * A card renders a label, not a body — and the body is already represented by
58
+ * the entry's own `content`/`excerpt`. Without a cap, a collection of long
59
+ * records carries its full text TWICE in an index the browser downloads whole.
60
+ *
61
+ * ⭐ Note what this is: a claim about SIZE, which we can make. Not a claim
62
+ * about MEANING, which we cannot.
63
+ */
64
+ const DISPLAY_VALUE_MAX = 200
65
+
39
66
  export function generateCollectionIndex(name, config, collectionData, locale) {
40
- const fields = config.search?.fields || ['title']
67
+ // NO DEFAULT FIELD LIST. This was `|| ['title']` — a claim about someone
68
+ // else's schema, and wrong for any collection without a `title` (a `people`
69
+ // collection has `name`, a `products` one has `label`). The failure was
70
+ // silent in the worst way: the record still entered the index with `content`
71
+ // empty, so it was present, countable, and matched nothing.
72
+ //
73
+ // ⇒ With no authored `search.fields`, index every non-empty STRING the author
74
+ // wrote, minus our own wiring keys. That is not a schema claim; it is the
75
+ // refusal of one. An authored list still wins — an author who names fields
76
+ // has told us something we could not otherwise know.
77
+ const declaredFields = Array.isArray(config.search?.fields) ? config.search.fields : null
41
78
  const weight = config.search?.weight ?? 0.7
42
79
  const items = Array.isArray(collectionData)
43
80
  ? collectionData
44
81
  : collectionData?.items || []
45
82
 
46
83
  const entries = items.map(item => {
84
+ const fields = declaredFields ?? searchableKeys(item)
47
85
  const content = fields.map(f => item[f] || '').filter(Boolean).join(' ')
48
86
  const slug = item.slug || item.id || String(item.title || '').toLowerCase().replace(/\s+/g, '-')
49
87
  // The record's own route wins: the build already resolved it against the
@@ -77,10 +115,60 @@ export function generateCollectionIndex(name, config, collectionData, locale) {
77
115
  }
78
116
  }
79
117
 
80
- function pickDisplayFields(item) {
81
- const { slug, title, name, date, image, author, excerpt, role } = item
82
- return Object.fromEntries(
83
- Object.entries({ slug, title, name, date, image, author, excerpt, role })
84
- .filter(([, v]) => v != null)
118
+ /**
119
+ * Every non-empty string the author wrote, minus our own wiring keys — the
120
+ * default searchable surface when a collection declares no `search.fields`.
121
+ */
122
+ function searchableKeys(item) {
123
+ if (!item || typeof item !== 'object') return []
124
+ return Object.keys(item).filter(
125
+ (k) => !WIRING_KEYS.has(k) && typeof item[k] === 'string' && item[k].trim() !== '',
85
126
  )
86
127
  }
128
+
129
+ /**
130
+ * The per-result display payload — what a foundation's result card renders.
131
+ *
132
+ * ⛔ THIS USED TO PROJECT RECORDS ONTO A BLOG SHAPE. It destructured
133
+ * `{ slug, title, name, date, image, author, excerpt, role }` and dropped
134
+ * everything else, so a `products` collection lost `price`, a `people` one lost
135
+ * `department`, a `courses` one lost `credits` — fields the author defined and
136
+ * we had no standing to discard. **[Diego, 2026-08-25]** — *"anyone can design
137
+ * their own data schema… you can't claim to know the structure of it. you just
138
+ * overfit to a fictitious blog example."*
139
+ *
140
+ * ⇒ Now: keep everything the author wrote, minus our own wiring keys, minus
141
+ * values a card cannot render, and minus long strings the entry's own
142
+ * `content`/`excerpt` already represent.
143
+ *
144
+ * ### The three options, recorded so this can be revisited without re-deriving
145
+ *
146
+ * 1. **Pass the record whole.** Maximally honest, no judgement at all — and
147
+ * it carries every record's full text a SECOND time in an index the
148
+ * browser downloads in one piece. Rejected on SIZE, not on principle.
149
+ * 2. **This one.** Drop wiring keys (ours), non-primitives (a card cannot
150
+ * render an object without knowing the schema, and it is where the bulk
151
+ * lives — a ProseMirror body is an object), and strings over
152
+ * `DISPLAY_VALUE_MAX` (already represented by `content`/`excerpt`).
153
+ * ⭐ Every exclusion is a claim about SIZE or about OUR OWN keys; none is
154
+ * a claim about what an author's field means.
155
+ * 3. **Let the caller name the display fields**, as `search.fields` does for
156
+ * the searchable surface. Most precise, and it needs an authoring surface
157
+ * plus a wire key to carry it — neither exists, and `collections`
158
+ * declarations do not reach a hosted site at all.
159
+ *
160
+ * ⇒ **If the size constraint ever stops mattering** — a host that serves the
161
+ * index in chunks, say — **option 1 is strictly more honest and should be
162
+ * taken.** That is the trigger to revisit, not a vague "reconsider someday".
163
+ */
164
+ function pickDisplayFields(item) {
165
+ if (!item || typeof item !== 'object') return {}
166
+ const out = {}
167
+ for (const [k, v] of Object.entries(item)) {
168
+ if (WIRING_KEYS.has(k)) continue
169
+ if (v == null || !isPrimitive(v)) continue
170
+ if (typeof v === 'string' && v.length > DISPLAY_VALUE_MAX) continue
171
+ out[k] = v
172
+ }
173
+ return out
174
+ }