@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 +3 -3
- package/src/search/collections.js +94 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/projections",
|
|
3
|
-
"version": "0.3.
|
|
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/
|
|
35
|
-
"@uniweb/
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
+
}
|