@uniweb/projections 0.5.0 → 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 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`, `generateCollectionIndex`,
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.5.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/core": "^0.13.0",
35
- "@uniweb/content-writer": "^0.3.4"
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 `![Platform overview](@Diagram)` — 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 `![Platform overview](@Diagram)` — 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}
@@ -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
- const extracted = extractFromProseMirrorDoc(section.content, {
169
+ // RESOLVE INSET CAPTIONS FIRST, with the SAME function the markdown
170
+ // projection uses. An inset is `![Platform overview](@Diagram)`: 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,