@uniweb/projections 0.4.1 → 0.5.1
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/README.md +8 -2
- package/package.json +3 -3
- package/src/insets.js +73 -0
- package/src/markdown.js +1 -65
- package/src/search/extract.js +51 -3
- package/src/search/records.js +20 -10
package/README.md
CHANGED
|
@@ -127,8 +127,14 @@ a third-party convention that may not survive — so it appears exactly once, in
|
|
|
127
127
|
`config.js`, at the edge. A change of convention costs a constant.
|
|
128
128
|
|
|
129
129
|
**Search** — also available from the `@uniweb/projections/search` subpath:
|
|
130
|
-
`generateSearchIndex`, `extractSearchContent`, `
|
|
131
|
-
`isSearchEnabled`, `getSearchConfig`, `getSearchIndexFilename`.
|
|
130
|
+
`generateSearchIndex`, `extractSearchContent`, `generateRecordSearchIndex`,
|
|
131
|
+
`mergeSearchIndexes`, `isSearchEnabled`, `getSearchConfig`, `getSearchIndexFilename`.
|
|
132
|
+
|
|
133
|
+
> **Renamed in 0.5.0** — `generateCollectionIndex` is now `generateRecordSearchIndex`, and a
|
|
134
|
+
> search entry carries `type: 'record'` / `group` / `id: "record:<group>:<slug>"` where it
|
|
135
|
+
> carried `type: 'collection'` / `collection` / `id: "collection:…"`. *"Collection" is a
|
|
136
|
+
> build-side concept — a named set compiled to one file — and this function is called with
|
|
137
|
+
> records that may never have been one.*
|
|
132
138
|
|
|
133
139
|
```js
|
|
134
140
|
import { generateSearchIndex, isSearchEnabled } from '@uniweb/projections/search'
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniweb/projections",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
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.13.1"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
38
|
"vitest": "^4.1.7",
|
package/src/insets.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inset captions — the one place that decides what an inset contributes.
|
|
3
|
+
*
|
|
4
|
+
* Shared by BOTH projections on purpose. The markdown page and the search index
|
|
5
|
+
* are two views of ONE site, so "what words did the author write here" has to be
|
|
6
|
+
* answered once. It was answered twice — markdown resolved captions and search
|
|
7
|
+
* did not — and the two artifacts disagreed about the same page.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Replace `inset_placeholder` nodes with the author's own words.
|
|
12
|
+
*
|
|
13
|
+
* ## Why this is not "restore the inset"
|
|
14
|
+
*
|
|
15
|
+
* An inset is `` — an author's caption plus a
|
|
16
|
+
* FOUNDATION COMPONENT to render it with. The build splits them: the caption and
|
|
17
|
+
* params go to the section's `insets[]`, and the body keeps an
|
|
18
|
+
* `inset_placeholder` carrying only `{ refId, embedKind }`.
|
|
19
|
+
*
|
|
20
|
+
* ⛔ **`@Diagram` must never reach this output.** A component name is a rendering
|
|
21
|
+
* assignment, and this package's whole property is that a projection is of the
|
|
22
|
+
* SITE — identical under a swapped foundation. Emitting it here would break the
|
|
23
|
+
* same rule that keeps `type:` and params out (see the package's README/notes on
|
|
24
|
+
* why the exclusions are load-bearing rather than tidy-up).
|
|
25
|
+
*
|
|
26
|
+
* ⭐ **But the caption IS site content** — the author wrote it, and an agent
|
|
27
|
+
* retrieving this page should read it. So the placeholder becomes its title, as
|
|
28
|
+
* plain text, and nothing else.
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ Before this, `proseMirrorToMarkdown` had no serializer for the node and
|
|
31
|
+
* dropped it with a warning per build — *"this is a tracked capability gap"*. It
|
|
32
|
+
* was: every inset caption was missing from every agent-facing page.
|
|
33
|
+
*
|
|
34
|
+
* @param {Object} content - the section's ProseMirror document
|
|
35
|
+
* @param {Array} insets - the section's `insets[]` (`{ refId, title }`)
|
|
36
|
+
* @returns {Object} content with placeholders resolved to text
|
|
37
|
+
*/
|
|
38
|
+
export function resolveInsetCaptions(content, insets) {
|
|
39
|
+
if (!content?.content?.length) return content
|
|
40
|
+
// No insets array → nothing to resolve against. Dropping is then still the only
|
|
41
|
+
// option, but it is silent: the caption is genuinely not reachable from here.
|
|
42
|
+
const titleByRef = new Map(
|
|
43
|
+
(Array.isArray(insets) ? insets : [])
|
|
44
|
+
.filter((i) => i && typeof i.refId === 'string' && i.title)
|
|
45
|
+
.map((i) => [i.refId, String(i.title)])
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
// ⛔ THE REPLACEMENT'S SHAPE DEPENDS ON WHERE IT SITS, and getting this wrong
|
|
49
|
+
// fails SILENTLY IN THE WORSE DIRECTION: a bare text node at block level is not
|
|
50
|
+
// serializable, so the caption vanishes exactly as before — but the warning that
|
|
51
|
+
// used to announce it is gone. Measured while writing this: the first version
|
|
52
|
+
// emitted text unconditionally, removed the warning, and restored nothing.
|
|
53
|
+
const TEXTBLOCKS = new Set(['paragraph', 'heading'])
|
|
54
|
+
|
|
55
|
+
const visit = (nodes, inline) =>
|
|
56
|
+
nodes.flatMap((node) => {
|
|
57
|
+
if (!node) return []
|
|
58
|
+
if (node.type === 'inset_placeholder') {
|
|
59
|
+
const title = titleByRef.get(node.attrs?.refId)
|
|
60
|
+
// An inset with no caption contributes no author text — drop it, and do
|
|
61
|
+
// so quietly: there is nothing a reader is missing.
|
|
62
|
+
if (!title) return []
|
|
63
|
+
const text = { type: 'text', text: title }
|
|
64
|
+
return inline ? [text] : [{ type: 'paragraph', content: [text] }]
|
|
65
|
+
}
|
|
66
|
+
if (Array.isArray(node.content)) {
|
|
67
|
+
return [{ ...node, content: visit(node.content, TEXTBLOCKS.has(node.type)) }]
|
|
68
|
+
}
|
|
69
|
+
return [node]
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
return { ...content, content: visit(content.content, false) }
|
|
73
|
+
}
|
package/src/markdown.js
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
import { proseMirrorToMarkdown } from '@uniweb/content-writer'
|
|
21
21
|
import { sectionDomId } from '@uniweb/core/section-id'
|
|
22
|
+
import { resolveInsetCaptions } from './insets.js'
|
|
22
23
|
|
|
23
24
|
/**
|
|
24
25
|
* How blocks are joined into a page. Exported because the corpus projection
|
|
@@ -100,71 +101,6 @@ function collectSection(section, blocks, includeChildren, ancestorAnchor) {
|
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
103
|
|
|
103
|
-
/**
|
|
104
|
-
* Replace `inset_placeholder` nodes with the author's own words.
|
|
105
|
-
*
|
|
106
|
-
* ## Why this is not "restore the inset"
|
|
107
|
-
*
|
|
108
|
-
* An inset is `` — an author's caption plus a
|
|
109
|
-
* FOUNDATION COMPONENT to render it with. The build splits them: the caption and
|
|
110
|
-
* params go to the section's `insets[]`, and the body keeps an
|
|
111
|
-
* `inset_placeholder` carrying only `{ refId, embedKind }`.
|
|
112
|
-
*
|
|
113
|
-
* ⛔ **`@Diagram` must never reach this output.** A component name is a rendering
|
|
114
|
-
* assignment, and this package's whole property is that a projection is of the
|
|
115
|
-
* SITE — identical under a swapped foundation. Emitting it here would break the
|
|
116
|
-
* same rule that keeps `type:` and params out (see the package's README/notes on
|
|
117
|
-
* why the exclusions are load-bearing rather than tidy-up).
|
|
118
|
-
*
|
|
119
|
-
* ⭐ **But the caption IS site content** — the author wrote it, and an agent
|
|
120
|
-
* retrieving this page should read it. So the placeholder becomes its title, as
|
|
121
|
-
* plain text, and nothing else.
|
|
122
|
-
*
|
|
123
|
-
* ⚠️ Before this, `proseMirrorToMarkdown` had no serializer for the node and
|
|
124
|
-
* dropped it with a warning per build — *"this is a tracked capability gap"*. It
|
|
125
|
-
* was: every inset caption was missing from every agent-facing page.
|
|
126
|
-
*
|
|
127
|
-
* @param {Object} content - the section's ProseMirror document
|
|
128
|
-
* @param {Array} insets - the section's `insets[]` (`{ refId, title }`)
|
|
129
|
-
* @returns {Object} content with placeholders resolved to text
|
|
130
|
-
*/
|
|
131
|
-
function resolveInsetCaptions(content, insets) {
|
|
132
|
-
if (!content?.content?.length) return content
|
|
133
|
-
// No insets array → nothing to resolve against. Dropping is then still the only
|
|
134
|
-
// option, but it is silent: the caption is genuinely not reachable from here.
|
|
135
|
-
const titleByRef = new Map(
|
|
136
|
-
(Array.isArray(insets) ? insets : [])
|
|
137
|
-
.filter((i) => i && typeof i.refId === 'string' && i.title)
|
|
138
|
-
.map((i) => [i.refId, String(i.title)])
|
|
139
|
-
)
|
|
140
|
-
|
|
141
|
-
// ⛔ THE REPLACEMENT'S SHAPE DEPENDS ON WHERE IT SITS, and getting this wrong
|
|
142
|
-
// fails SILENTLY IN THE WORSE DIRECTION: a bare text node at block level is not
|
|
143
|
-
// serializable, so the caption vanishes exactly as before — but the warning that
|
|
144
|
-
// used to announce it is gone. Measured while writing this: the first version
|
|
145
|
-
// emitted text unconditionally, removed the warning, and restored nothing.
|
|
146
|
-
const TEXTBLOCKS = new Set(['paragraph', 'heading'])
|
|
147
|
-
|
|
148
|
-
const visit = (nodes, inline) =>
|
|
149
|
-
nodes.flatMap((node) => {
|
|
150
|
-
if (!node) return []
|
|
151
|
-
if (node.type === 'inset_placeholder') {
|
|
152
|
-
const title = titleByRef.get(node.attrs?.refId)
|
|
153
|
-
// An inset with no caption contributes no author text — drop it, and do
|
|
154
|
-
// so quietly: there is nothing a reader is missing.
|
|
155
|
-
if (!title) return []
|
|
156
|
-
const text = { type: 'text', text: title }
|
|
157
|
-
return inline ? [text] : [{ type: 'paragraph', content: [text] }]
|
|
158
|
-
}
|
|
159
|
-
if (Array.isArray(node.content)) {
|
|
160
|
-
return [{ ...node, content: visit(node.content, TEXTBLOCKS.has(node.type)) }]
|
|
161
|
-
}
|
|
162
|
-
return [node]
|
|
163
|
-
})
|
|
164
|
-
|
|
165
|
-
return { ...content, content: visit(content.content, false) }
|
|
166
|
-
}
|
|
167
|
-
|
|
168
104
|
/**
|
|
169
105
|
* @param {Object} section
|
|
170
106
|
* @returns {string}
|
package/src/search/extract.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
// Enforced by tests/environment.test.js.
|
|
11
11
|
import { sectionDomId } from '@uniweb/core/section-id'
|
|
12
12
|
import { selectIndexablePages } from '../pages.js'
|
|
13
|
+
import { resolveInsetCaptions } from '../insets.js'
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Extract all searchable content from site
|
|
@@ -165,7 +166,25 @@ function extractFromSection(section, page, options, ancestorAnchor) {
|
|
|
165
166
|
let sectionTitle = ''
|
|
166
167
|
|
|
167
168
|
if (section.content?.type === 'doc') {
|
|
168
|
-
|
|
169
|
+
// ⭐ RESOLVE INSET CAPTIONS FIRST, with the SAME function the markdown
|
|
170
|
+
// projection uses. An inset is ``: the build
|
|
171
|
+
// moves the author's caption into `insets[]` and leaves an
|
|
172
|
+
// `inset_placeholder` behind, so a walk of the body alone never sees it.
|
|
173
|
+
//
|
|
174
|
+
// ⛔ Both positions were losing text, and the INLINE one was losing it in the
|
|
175
|
+
// worse direction: a placeholder mid-sentence contributed nothing, so
|
|
176
|
+
// "See <inset> for detail." was indexed as "See for detail." — mangled
|
|
177
|
+
// prose rather than an obvious hole, and a search for the caption's words
|
|
178
|
+
// found a page whose markdown projection plainly contained them.
|
|
179
|
+
//
|
|
180
|
+
// Resolving up front rather than teaching the walkers about the node keeps
|
|
181
|
+
// ONE implementation of "a caption is content, a component name is not" —
|
|
182
|
+
// the property this whole package rests on. It also makes both positions
|
|
183
|
+
// work for free: a block placeholder becomes a paragraph, an inline one a
|
|
184
|
+
// text node, and the existing walk already handles both.
|
|
185
|
+
const content = resolveInsetCaptions(section.content, section.insets)
|
|
186
|
+
|
|
187
|
+
const extracted = extractFromProseMirrorDoc(content, {
|
|
169
188
|
includeHeadings,
|
|
170
189
|
includeParagraphs,
|
|
171
190
|
includeLinks,
|
|
@@ -235,10 +254,32 @@ function extractFromProseMirrorDoc(doc, options) {
|
|
|
235
254
|
return { title, textParts }
|
|
236
255
|
}
|
|
237
256
|
|
|
238
|
-
|
|
257
|
+
// ⛔ CONTAINERS WHOSE CHILDREN ARE ORDINARY AUTHOR PROSE. Until 2026-08-27 this
|
|
258
|
+
// walk was FLAT over `doc.content`, so anything nested one level down was
|
|
259
|
+
// invisible to search — measured: a blockquote's prose and every table cell were
|
|
260
|
+
// lost outright, on a site that looked perfectly indexed.
|
|
261
|
+
//
|
|
262
|
+
// ⭐ An ALLOWLIST rather than a blind recursion, deliberately: descending into
|
|
263
|
+
// everything would pull in `codeBlock` and `math_display`, which are not prose —
|
|
264
|
+
// they inflate the index and match on tokens nobody searches for. Adding a
|
|
265
|
+
// container here is a decision, not a default.
|
|
266
|
+
const DESCEND = new Set(['blockquote', 'table', 'tableRow', 'tableCell'])
|
|
267
|
+
|
|
268
|
+
const walk = (nodes) => {
|
|
269
|
+
for (const node of nodes || []) {
|
|
270
|
+
if (!node) continue
|
|
271
|
+
if (DESCEND.has(node.type)) {
|
|
272
|
+
walk(node.content)
|
|
273
|
+
continue
|
|
274
|
+
}
|
|
275
|
+
visit(node)
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const visit = (node) => {
|
|
239
280
|
if (node.type === 'heading') {
|
|
240
281
|
const text = extractTextFromNode(node)
|
|
241
|
-
if (!text)
|
|
282
|
+
if (!text) return
|
|
242
283
|
|
|
243
284
|
// First H1 becomes the title
|
|
244
285
|
if (!foundFirstHeading && node.attrs?.level === 1) {
|
|
@@ -269,9 +310,16 @@ function extractFromProseMirrorDoc(doc, options) {
|
|
|
269
310
|
} else if ((node.type === 'bulletList' || node.type === 'orderedList') && includeLists) {
|
|
270
311
|
const listTexts = extractFromList(node)
|
|
271
312
|
textParts.push(...listTexts)
|
|
313
|
+
} else if (node.type === 'image') {
|
|
314
|
+
// ⭐ `alt` is the author describing their own image — the only words an
|
|
315
|
+
// image contributes, and what a reader searching for it would type.
|
|
316
|
+
const alt = typeof node.attrs?.alt === 'string' ? node.attrs.alt.trim() : ''
|
|
317
|
+
if (alt) textParts.push(alt)
|
|
272
318
|
}
|
|
273
319
|
}
|
|
274
320
|
|
|
321
|
+
walk(doc.content)
|
|
322
|
+
|
|
275
323
|
return { title, textParts }
|
|
276
324
|
}
|
|
277
325
|
|
package/src/search/records.js
CHANGED
|
@@ -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
|
-
*
|
|
83
|
-
* `
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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: '
|
|
134
|
-
|
|
143
|
+
type: 'record',
|
|
144
|
+
group: name,
|
|
135
145
|
locale,
|
|
136
146
|
entries,
|
|
137
147
|
}
|