@byline/richtext-lexical 6.6.0 → 6.7.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.
@@ -1,5 +1,16 @@
1
1
  import { createInternalLinkHydration } from "../link/populate.js";
2
2
  import { deriveImageSizes, getPreferredSize } from "./utils.js";
3
+ const DERIVED_KEYS = [
4
+ 'title',
5
+ 'altText',
6
+ 'image',
7
+ 'sizes'
8
+ ];
9
+ function clearPreview(node) {
10
+ node.src = '';
11
+ delete node.width;
12
+ delete node.height;
13
+ }
3
14
  const inlineImageVisitor = {
4
15
  match (node) {
5
16
  if ('inline-image' !== node.type) return null;
@@ -14,22 +25,38 @@ const inlineImageVisitor = {
14
25
  apply (target) {
15
26
  const targetFields = target.fields ?? {};
16
27
  const image = targetFields.image;
17
- const sizes = image ? deriveImageSizes(image) : [];
18
28
  const next = {
19
29
  ...node.document ?? {}
20
30
  };
31
+ for (const key of DERIVED_KEYS)delete next[key];
21
32
  if ('string' == typeof targetFields.title) next.title = targetFields.title;
22
33
  if ('string' == typeof targetFields.altText) next.altText = targetFields.altText;
23
- if (null != image) next.image = image;
24
- if (sizes.length > 0) next.sizes = sizes;
25
- node.document = next;
26
34
  const preferred = getPreferredSize(imageNode.position, image);
27
35
  const url = preferred?.url?.trim();
28
- if (url) {
36
+ if (null != image && url) {
37
+ const sizes = deriveImageSizes(image);
38
+ next.image = image;
39
+ if (sizes.length > 0) next.sizes = sizes;
40
+ delete next._resolved;
29
41
  imageNode.src = url;
30
42
  if (preferred?.width != null) imageNode.width = preferred.width;
43
+ else delete imageNode.width;
31
44
  if (preferred?.height != null) imageNode.height = preferred.height;
45
+ else delete imageNode.height;
46
+ } else {
47
+ next._resolved = false;
48
+ clearPreview(imageNode);
32
49
  }
50
+ node.document = next;
51
+ },
52
+ applyMissing () {
53
+ const next = {
54
+ ...node.document ?? {}
55
+ };
56
+ for (const key of DERIVED_KEYS)delete next[key];
57
+ next._resolved = false;
58
+ node.document = next;
59
+ clearPreview(imageNode);
33
60
  }
34
61
  };
35
62
  }
@@ -10,6 +10,7 @@ function createInternalLinkHydration(envelope, collectionPath, documentId) {
10
10
  };
11
11
  const title = targetFields[useAsTitle];
12
12
  if ('string' == typeof title && title.length > 0) next.title = title;
13
+ else delete next.title;
13
14
  let pathThrew = false;
14
15
  let built;
15
16
  if (definition?.buildDocumentPath != null) try {
@@ -29,12 +30,19 @@ function createInternalLinkHydration(envelope, collectionPath, documentId) {
29
30
  err
30
31
  }, 'buildDocumentPath threw');
31
32
  }
32
- if (!pathThrew) if ('string' == typeof built) next.path = built;
33
+ let path;
34
+ if (!pathThrew) if ('string' == typeof built) path = built;
33
35
  else {
34
36
  const targetPath = target.path;
35
- if ('string' == typeof targetPath && targetPath.length > 0) next.path = `/${collectionPath}/${targetPath}`;
37
+ if ('string' == typeof targetPath && targetPath.length > 0) path = `/${collectionPath}/${targetPath}`;
38
+ }
39
+ if (null != path) {
40
+ next.path = path;
41
+ delete next._resolved;
42
+ } else {
43
+ delete next.path;
44
+ next._resolved = false;
36
45
  }
37
- if ('_resolved' in next) delete next._resolved;
38
46
  envelope.document = next;
39
47
  },
40
48
  applyMissing () {
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "private": false,
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
- "version": "6.6.0",
6
+ "version": "6.7.1",
7
7
  "engines": {
8
8
  "node": ">=20.9.0"
9
9
  },
@@ -79,11 +79,11 @@
79
79
  "clsx": "^2.1.1",
80
80
  "lexical": "0.51.0",
81
81
  "react-error-boundary": "^6.1.5",
82
- "@byline/admin": "6.6.0",
83
- "@byline/core": "6.6.0",
84
- "@byline/i18n": "6.6.0",
85
- "@byline/client": "6.6.0",
86
- "@byline/ui": "6.6.0"
82
+ "@byline/admin": "6.7.1",
83
+ "@byline/i18n": "6.7.1",
84
+ "@byline/core": "6.7.1",
85
+ "@byline/client": "6.7.1",
86
+ "@byline/ui": "6.7.1"
87
87
  },
88
88
  "peerDependencies": {
89
89
  "react": "^19.0.0",
@@ -318,3 +318,73 @@ describe('inlineImageLinkVisitor', () => {
318
318
  })
319
319
  })
320
320
  })
321
+
322
+ describe('inlineImageLinkVisitor never reuses a stale click-through path', () => {
323
+ beforeEach(() => {
324
+ installLogger(makeLogger())
325
+ clearConfig()
326
+ })
327
+
328
+ afterEach(() => {
329
+ clearConfig()
330
+ })
331
+
332
+ it('marks the click-through unresolved when the path hook throws, without reactivating it', () => {
333
+ registerCollection({
334
+ path: 'pages',
335
+ buildDocumentPath: () => {
336
+ throw new Error('boom')
337
+ },
338
+ })
339
+ const node = makeLinkedImageNode({
340
+ ...internalLink,
341
+ document: { title: 'Stale', path: '/pages/stale', _resolved: false },
342
+ })
343
+
344
+ inlineImageLinkVisitor.match(node)?.apply(linkTarget)
345
+
346
+ expect(node.link?.document?.path).toBeUndefined()
347
+ expect(node.link?.document?._resolved).toBe(false)
348
+ expect(node.link?.targetDocumentId, 'identity kept').toBe('doc-1')
349
+ })
350
+
351
+ it('marks the click-through unresolved when the target has no usable path', () => {
352
+ registerCollection({ path: 'pages' })
353
+ const node = makeLinkedImageNode({ ...internalLink })
354
+
355
+ inlineImageLinkVisitor.match(node)?.apply({ ...linkTarget, path: '' })
356
+
357
+ expect(node.link?.document?.path).toBeUndefined()
358
+ expect(node.link?.document?._resolved).toBe(false)
359
+ })
360
+
361
+ it('a missing click-through target does not suppress an independently readable image', async () => {
362
+ registerCollection({ path: 'pages' })
363
+ const node = makeLinkedImageNode({ ...internalLink })
364
+ const readDocuments = vi.fn(async ({ collectionPath }: { collectionPath: string }) =>
365
+ collectionPath === 'media'
366
+ ? [
367
+ {
368
+ document_id: 'media-1',
369
+ path: 'm',
370
+ status: 'published',
371
+ fields: { image: mediaImage },
372
+ },
373
+ ]
374
+ : []
375
+ )
376
+
377
+ await runLexicalPopulate({
378
+ readContext: createReadContext(),
379
+ readDocuments,
380
+ visitors: [inlineImageVisitor, inlineImageLinkVisitor],
381
+ values: [{ root: { type: 'root', children: [node] } }],
382
+ })
383
+
384
+ expect(node.link?.document?._resolved).toBe(false)
385
+ expect(node.link?.document?.path).toBeUndefined()
386
+ expect(node.document?._resolved).toBeUndefined()
387
+ expect(node.document?.image).toEqual(mediaImage)
388
+ expect(node.src).toBe('https://cdn.example.com/media/current-card.avif')
389
+ })
390
+ })
@@ -10,6 +10,7 @@ import { createReadContext, type StoredFileValue } from '@byline/core'
10
10
  import { describe, expect, it, vi } from 'vitest'
11
11
 
12
12
  import { type LexicalNodeLike, runLexicalPopulate } from '../../lexical-populate-shared'
13
+ import { lexicalToMarkdown } from '../../markdown/lexical-to-markdown'
13
14
  import { inlineImageVisitor } from './populate'
14
15
  import type { Position } from './node-types'
15
16
 
@@ -172,9 +173,10 @@ describe('inlineImageVisitor', () => {
172
173
  })
173
174
  })
174
175
 
175
- describe('never leaves the node emptier than it found it', () => {
176
- it('preserves src, width and height when the target cannot be resolved', async () => {
176
+ describe('never serves a stale image when no current image is available', () => {
177
+ it('clears the copied image and preview when the target cannot be read', async () => {
177
178
  const node = makeNode('left')
179
+ node.document = { title: 'Stale title', altText: 'Stale alt', image: currentImage }
178
180
  const readDocuments = vi.fn().mockResolvedValue([])
179
181
 
180
182
  await runLexicalPopulate({
@@ -188,36 +190,69 @@ describe('inlineImageVisitor', () => {
188
190
  collectionPath: 'media',
189
191
  documentIds: ['media-1'],
190
192
  })
191
- expect(node.src).toBe(STALE_SRC)
192
- expect(node.width).toBe(STALE_WIDTH)
193
- expect(node.height).toBe(STALE_HEIGHT)
193
+ expect(node.document).toEqual({ _resolved: false })
194
+ expect(node.src).toBe('')
195
+ expect(node.width).toBeUndefined()
196
+ expect(node.height).toBeUndefined()
197
+ // Identity and authored state survive.
198
+ expect(node.targetDocumentId).toBe('media-1')
199
+ expect(node.position).toBe('left')
200
+ expect(node.children).toEqual([{ type: 'caption', children: [{ type: 'text' }] }])
194
201
  })
195
202
 
196
- it('preserves the preview when the target resolves without an image', () => {
203
+ it('clears the copied image and preview when the target resolves without an image', () => {
197
204
  const node = makeNode('left')
205
+ node.document = { title: 'Old', image: currentImage, sizes: [{ name: 'card' }] }
198
206
 
199
207
  applyTo(node, undefined, 'Current title')
200
208
 
201
- expect(node.src).toBe(STALE_SRC)
202
- expect(node.width).toBe(STALE_WIDTH)
203
- expect(node.height).toBe(STALE_HEIGHT)
204
- expect(node.document).toEqual(expect.objectContaining({ title: 'Current title' }))
209
+ expect(node.document).toEqual({ title: 'Current title', _resolved: false })
210
+ expect(node.src).toBe('')
211
+ expect(node.width).toBeUndefined()
212
+ expect(node.height).toBeUndefined()
205
213
  })
206
214
 
207
215
  // Variant-less, so there is no usable variant URL to fall back to and
208
- // the original is the only candidate — exactly the case the guard is for.
216
+ // the original is the only candidate.
209
217
  it.each([undefined, '', ' '])(
210
- 'preserves the preview when the resolved storageUrl is %j',
218
+ 'clears the copied image and preview when the resolved storageUrl is %j',
211
219
  (storageUrl) => {
212
220
  const node = makeNode('left')
221
+ node.document = { image: currentImage }
213
222
 
214
223
  applyTo(node, { ...variantlessImage, storageUrl })
215
224
 
216
- expect(node.src).toBe(STALE_SRC)
217
- expect(node.width).toBe(STALE_WIDTH)
218
- expect(node.height).toBe(STALE_HEIGHT)
225
+ expect(node.document).toEqual({ _resolved: false })
226
+ expect(node.src).toBe('')
227
+ expect(node.width).toBeUndefined()
228
+ expect(node.height).toBeUndefined()
219
229
  }
220
230
  )
231
+
232
+ it('marks a node resolved again once a current image is available', () => {
233
+ const node = makeNode('left')
234
+ node.document = { _resolved: false }
235
+
236
+ applyTo(node, currentImage)
237
+
238
+ expect(node.document?._resolved).toBeUndefined()
239
+ expect(node.document?.image).toEqual(currentImage)
240
+ expect(node.src).toBe('https://cdn.example.com/media/current-card.avif')
241
+ })
242
+
243
+ it('exports no stale image to Markdown once the target is unavailable', async () => {
244
+ const node = { ...makeNode('left'), altText: 'authored alt' }
245
+ await runLexicalPopulate({
246
+ readContext: createReadContext(),
247
+ readDocuments: vi.fn().mockResolvedValue([]),
248
+ visitors: [inlineImageVisitor],
249
+ values: [{ root: { type: 'root', children: [node] } }],
250
+ })
251
+ const markdown = lexicalToMarkdown({ root: { type: 'root', children: [node] } }).markdown
252
+
253
+ expect(markdown).not.toContain(STALE_SRC)
254
+ expect(node.altText, 'authored alt text is not target data').toBe('authored alt')
255
+ })
221
256
  })
222
257
 
223
258
  it('is idempotent and leaves unrelated node state untouched', () => {
@@ -235,3 +270,19 @@ describe('inlineImageVisitor', () => {
235
270
  expect(node.children).toEqual([{ type: 'caption', children: [{ type: 'text' }] }])
236
271
  })
237
272
  })
273
+
274
+ describe('inlineImageVisitor replaces derived values instead of merging them', () => {
275
+ it('removes a stale title and alt text while a valid current image still renders', () => {
276
+ const node = makeNode('left')
277
+ node.document = { title: 'Withheld ES', altText: 'Texto alternativo', caption: 'kept' }
278
+
279
+ inlineImageVisitor.match(node)?.apply({ fields: { image: currentImage } })
280
+
281
+ expect(node.document).not.toHaveProperty('title')
282
+ expect(node.document).not.toHaveProperty('altText')
283
+ expect(node.document?.caption, 'unrelated envelope keys are kept').toBe('kept')
284
+ expect(node.document?.image).toEqual(currentImage)
285
+ expect(node.document?._resolved).toBeUndefined()
286
+ expect(node.src).toBe('https://cdn.example.com/media/current-card.avif')
287
+ })
288
+ })
@@ -22,9 +22,17 @@
22
22
  * fallback), so the refresh must go through it rather than assigning
23
23
  * `image.storageUrl` directly — otherwise every save would swap the
24
24
  * variant for the full-size original and leave the variant's dimensions
25
- * describing different bytes. Missing targets, missing images, and
26
- * blank URLs leave all three fields untouched: the visitor never leaves
27
- * a node emptier than it found it.
25
+ * describing different bytes.
26
+ *
27
+ * Every copied media value — the envelope's `title`, `altText`, `image` and
28
+ * `sizes`, and the node's `src` / `width` / `height` — is **replaced, never
29
+ * merged**. When the media target is missing or denied to this reader, or it
30
+ * resolves without an image or without a usable URL, the copied image data
31
+ * and preview are removed and `document._resolved = false` marks the node
32
+ * unresolved, so no stale image is rendered, serialized or exported to
33
+ * Markdown. A valid current image still renders when only its title or alt
34
+ * text is absent. The node's identity, position, authored `altText`, caption
35
+ * and click-through link are never touched here.
28
36
  */
29
37
 
30
38
  import type { StoredFileValue } from '@byline/core'
@@ -50,6 +58,20 @@ interface InlineImageNodeLike extends LexicalNodeLike {
50
58
  link?: LinkAttributes
51
59
  }
52
60
 
61
+ /** Envelope values copied from the media document on every refresh. */
62
+ const DERIVED_KEYS = ['title', 'altText', 'image', 'sizes'] as const
63
+
64
+ /**
65
+ * Remove the copied preview. `src` is a required string on the serialized
66
+ * node, so it becomes empty (the Markdown serializer and editor already treat
67
+ * an empty `src` as "no image") rather than being deleted.
68
+ */
69
+ function clearPreview(node: InlineImageNodeLike): void {
70
+ node.src = ''
71
+ delete node.width
72
+ delete node.height
73
+ }
74
+
53
75
  export const inlineImageVisitor: LexicalNodeVisitor = {
54
76
  match(node: LexicalNodeLike) {
55
77
  if (node.type !== 'inline-image') return null
@@ -64,20 +86,39 @@ export const inlineImageVisitor: LexicalNodeVisitor = {
64
86
  apply(target: Record<string, any>) {
65
87
  const targetFields = (target.fields ?? {}) as Record<string, any>
66
88
  const image = targetFields.image as StoredFileValue | undefined
67
- const sizes = image ? deriveImageSizes(image) : []
68
89
  const next: Record<string, any> = { ...(node.document ?? {}) }
90
+ for (const key of DERIVED_KEYS) delete next[key]
69
91
  if (typeof targetFields.title === 'string') next.title = targetFields.title
70
92
  if (typeof targetFields.altText === 'string') next.altText = targetFields.altText
71
- if (image != null) next.image = image
72
- if (sizes.length > 0) next.sizes = sizes
73
- node.document = next
93
+
74
94
  const preferred = getPreferredSize(imageNode.position, image)
75
95
  const url = preferred?.url?.trim()
76
- if (url) {
96
+ if (image != null && url) {
97
+ const sizes = deriveImageSizes(image)
98
+ next.image = image
99
+ if (sizes.length > 0) next.sizes = sizes
100
+ delete next._resolved
77
101
  imageNode.src = url
78
102
  if (preferred?.width != null) imageNode.width = preferred.width
103
+ else delete imageNode.width
79
104
  if (preferred?.height != null) imageNode.height = preferred.height
105
+ else delete imageNode.height
106
+ } else {
107
+ // Resolved without an image, or without a usable URL.
108
+ next._resolved = false
109
+ clearPreview(imageNode)
80
110
  }
111
+ node.document = next
112
+ },
113
+ applyMissing() {
114
+ // The media target could not be read (deleted, unpublished, or
115
+ // denied to this reader). Remove every copied value rather than
116
+ // serve it as though it were a live resolution.
117
+ const next: Record<string, any> = { ...(node.document ?? {}) }
118
+ for (const key of DERIVED_KEYS) delete next[key]
119
+ next._resolved = false
120
+ node.document = next
121
+ clearPreview(imageNode)
81
122
  },
82
123
  }
83
124
  },
@@ -26,8 +26,11 @@
26
26
  import { type BylineLogger, type CollectionDefinition, defineServerConfig } from '@byline/core'
27
27
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
28
28
 
29
+ import { lexicalToMarkdown } from '../../markdown/lexical-to-markdown'
30
+ import { resolveLinkHref } from './link-href'
29
31
  import { linkVisitor } from './populate'
30
32
  import type { LexicalNodeLike } from '../../lexical-populate-shared'
33
+ import type { LinkAttributes } from './types'
31
34
 
32
35
  // ---------------------------------------------------------------------------
33
36
  // Test harness — logger + config registration
@@ -246,9 +249,9 @@ describe('linkVisitor', () => {
246
249
  expect(node.attributes?.document?.path).toBe('/pages/about')
247
250
  })
248
251
 
249
- it('leaves path untouched when target.path is empty and no hook is defined', () => {
252
+ it('marks the link unresolved when target.path is empty and no hook is defined', () => {
250
253
  // Generic fallback bails when target.path is empty rather than
251
- // emitting `/pages/undefined`.
254
+ // emitting `/pages/undefined`; the old copied path is not reused.
252
255
  registerCollection({ path: 'pages' })
253
256
  const node = makeInternalLinkNode({
254
257
  document: { title: 'Old', path: '/previous' },
@@ -256,7 +259,9 @@ describe('linkVisitor', () => {
256
259
  const pending = linkVisitor.match(node)
257
260
  pending?.apply({ ...targetFixture, path: '' })
258
261
 
259
- expect(node.attributes?.document?.path).toBe('/previous')
262
+ expect(node.attributes?.document?.path).toBeUndefined()
263
+ expect(node.attributes?.document?._resolved).toBe(false)
264
+ expect(resolveLinkHref(node.attributes as LinkAttributes)).toBeNull()
260
265
  })
261
266
  })
262
267
 
@@ -265,7 +270,7 @@ describe('linkVisitor', () => {
265
270
  // -------------------------------------------------------------------------
266
271
 
267
272
  describe('apply (branch A — hook threw)', () => {
268
- it('logs at info and leaves document.path untouched', () => {
273
+ it('logs at info, removes the old path and marks the link unresolved', () => {
269
274
  registerCollection({
270
275
  path: 'pages',
271
276
  useAsTitle: 'title',
@@ -279,8 +284,10 @@ describe('linkVisitor', () => {
279
284
  const pending = linkVisitor.match(node)
280
285
  pending?.apply(targetFixture)
281
286
 
282
- // Path preserved (branch A's whole point).
283
- expect(node.attributes?.document?.path).toBe('/previous')
287
+ // The old copied path is never reused: no current path was produced.
288
+ expect(node.attributes?.document?.path).toBeUndefined()
289
+ expect(node.attributes?.document?._resolved).toBe(false)
290
+ expect(resolveLinkHref(node.attributes as LinkAttributes)).toBeNull()
284
291
  // Title still refreshes — only the path resolution failed.
285
292
  expect(node.attributes?.document?.title).toBe('About Us')
286
293
  expect(logger.info).toHaveBeenCalledWith(
@@ -306,6 +313,37 @@ describe('linkVisitor', () => {
306
313
 
307
314
  // No path written — neither hook output nor generic fallback fired.
308
315
  expect(node.attributes?.document?.path).toBeUndefined()
316
+ expect(node.attributes?.document?._resolved).toBe(false)
317
+ })
318
+
319
+ it('does not reactivate a previously unresolved link with its old path', () => {
320
+ registerCollection({
321
+ path: 'pages',
322
+ buildDocumentPath: () => {
323
+ throw new Error('boom')
324
+ },
325
+ })
326
+ const node = makeInternalLinkNode({
327
+ document: { title: 'Old', path: '/stale-path', _resolved: false },
328
+ })
329
+ linkVisitor.match(node)?.apply(targetFixture)
330
+
331
+ expect(node.attributes?.document?._resolved).toBe(false)
332
+ expect(node.attributes?.document?.path).toBeUndefined()
333
+ expect(resolveLinkHref(node.attributes as LinkAttributes)).toBeNull()
334
+ const markdown = lexicalToMarkdown({
335
+ root: {
336
+ type: 'root',
337
+ children: [
338
+ {
339
+ type: 'paragraph',
340
+ children: [{ ...node, children: [{ type: 'text', text: 'authored text' }] }],
341
+ },
342
+ ],
343
+ },
344
+ }).markdown
345
+ expect(markdown).not.toContain('/stale-path')
346
+ expect(markdown).toContain('authored text')
309
347
  })
310
348
  })
311
349
 
@@ -349,3 +387,50 @@ describe('linkVisitor', () => {
349
387
  })
350
388
  })
351
389
  })
390
+
391
+ // ---------------------------------------------------------------------------
392
+ // apply() — derived values are replaced, never merged
393
+ // ---------------------------------------------------------------------------
394
+
395
+ describe('linkVisitor replaces derived values instead of merging them', () => {
396
+ beforeEach(() => {
397
+ installLogger(makeLogger())
398
+ clearConfig()
399
+ registerCollection({ path: 'pages' })
400
+ })
401
+
402
+ afterEach(() => {
403
+ clearConfig()
404
+ })
405
+
406
+ it('removes a stale title when the current target result supplies none', () => {
407
+ // A withheld or redacted title arrives as an absent field; the copy
408
+ // saved by an earlier refresh must not survive.
409
+ const node = makeInternalLinkNode({ document: { title: 'Withheld ES', path: '/pages/about' } })
410
+ linkVisitor.match(node)?.apply({ ...targetFixture, fields: { area: 'root' } })
411
+
412
+ expect(node.attributes?.document).not.toHaveProperty('title')
413
+ expect(node.attributes?.document?.path).toBe('/pages/about')
414
+ })
415
+
416
+ it('replaces a stale title with the current one', () => {
417
+ const node = makeInternalLinkNode({ document: { title: 'Withheld ES' } })
418
+ linkVisitor.match(node)?.apply({ ...targetFixture, fields: { title: 'About EN' } })
419
+
420
+ expect(node.attributes?.document?.title).toBe('About EN')
421
+ })
422
+
423
+ it('keeps reference identity, authored children and unrelated envelope keys', () => {
424
+ const node: LexicalNodeLike = {
425
+ ...makeInternalLinkNode({ document: { title: 'Old', note: 'kept' } }),
426
+ children: [{ type: 'text', text: 'authored link text' } as LexicalNodeLike],
427
+ }
428
+ linkVisitor.match(node)?.apply({ ...targetFixture, fields: {} })
429
+
430
+ expect(node.attributes?.targetDocumentId).toBe('doc-1')
431
+ expect(node.attributes?.targetCollectionPath).toBe('pages')
432
+ expect(node.attributes?.linkType).toBe('internal')
433
+ expect(node.attributes?.document?.note).toBe('kept')
434
+ expect(node.children).toEqual([{ type: 'text', text: 'authored link text' }])
435
+ })
436
+ })
@@ -13,30 +13,34 @@
13
13
  * the same visitor; only the trigger point differs.
14
14
  *
15
15
  * Refreshes `attributes.document` on `link` nodes whose
16
- * `attributes.linkType` is `'internal'`. Three branches:
16
+ * `attributes.linkType` is `'internal'`. The copied target values (`title`,
17
+ * `path`) are **replaced, never merged**: a value the current, permitted
18
+ * target result does not supply is removed rather than kept from an earlier
19
+ * copy. The link's identity (`targetDocumentId` / `targetCollectionPath`)
20
+ * and its authored children are never touched. Branches:
17
21
  *
18
- * - **Found** — sets `document.title` to the target's `useAsTitle`
19
- * field value (falling back to `title` when `useAsTitle` is not
20
- * defined), composes `document.path` via the collection's
21
- * `buildDocumentPath` hook (with `/${collectionPath}/${target.path}`
22
- * as the generic fallback when the hook is absent or returns
23
- * `null`), and clears any prior `document._resolved` flag.
22
+ * - **Found, path produced** — sets `document.title` from the target's
23
+ * `useAsTitle` field (falling back to `title`), or removes it when the
24
+ * result has none; sets `document.path` from the collection's
25
+ * `buildDocumentPath` hook, or from `/${collectionPath}/${target.path}`
26
+ * when the hook is absent or returns `null`; and clears any prior
27
+ * `document._resolved` flag.
24
28
  *
25
- * - **Hook threw** (branch A) — logs at `info` level and leaves
26
- * `document.path` and `document._resolved` untouched. The picker-
27
- * time embedded value (if any) stays in place; the renderer's
28
- * fallback chain copes.
29
+ * - **Found, no path** (branch A) — the hook threw (logged at `info`) or
30
+ * no usable path could be composed. The old copied path is removed and
31
+ * `document._resolved = false`, so the renderer strips the `<a>` and
32
+ * renders the children as plain text. A stale path is never reused, and
33
+ * an unresolved link is only reactivated by a path actually produced.
29
34
  *
30
35
  * - **Target not found** (branch B) — logs at `warn` level, deletes
31
36
  * `document.title` and `document.path`, and sets
32
- * `document._resolved = false` so the renderer strips the `<a>`
33
- * wrapper and renders the link's children as plain text. Persisted
34
- * state remains a complete record — re-linking is possible whenever
35
- * the editor returns.
37
+ * `document._resolved = false`. Persisted state remains a complete
38
+ * record — re-linking is possible whenever the editor returns.
36
39
  *
37
- * Hard errors (DB unreachable, transport-level failures) propagate to
38
- * the caller — `document-lifecycle` / the read pipeline — which catch
39
- * per-field and leave the persisted state untouched (branch C).
40
+ * Hard reader errors (DB unreachable, transport failures) propagate (branch
41
+ * C). On a read, the whole read fails, so no stale snapshot is served. On a
42
+ * save, `document-lifecycle` catches per leaf and leaves the persisted leaf
43
+ * untouched.
40
44
  *
41
45
  * `linkType: 'custom'` links carry a literal URL and have no relation
42
46
  * envelope; they're skipped. Auto-link nodes (`type: 'autolink'`) are
@@ -76,16 +80,20 @@ export function createInternalLinkHydration(
76
80
  const targetFields = (target.fields ?? {}) as Record<string, any>
77
81
  const next: Record<string, any> = { ...(envelope.document ?? {}) }
78
82
 
79
- // Title — `useAsTitle` lookup with `title` fallback.
83
+ // Title — `useAsTitle` lookup with `title` fallback. Replaced, never
84
+ // merged: when the current (permitted) target result supplies no title,
85
+ // a previously copied title is removed rather than kept. A withheld or
86
+ // redacted translation must not survive as a stale copy.
80
87
  const title = targetFields[useAsTitle]
81
88
  if (typeof title === 'string' && title.length > 0) {
82
89
  next.title = title
90
+ } else {
91
+ delete next.title
83
92
  }
84
93
 
85
- // Path — buildDocumentPath, then generic compose fallback.
86
- // Branch A: hook threw — leave any existing `document.path`
87
- // untouched and surface a log line so operators can find the
88
- // bug without it taking the save / read down with it.
94
+ // Path — buildDocumentPath, then generic compose fallback. A hook that
95
+ // throws is logged (so operators can find the bug without it taking the
96
+ // save / read down) and treated as "no path produced" (branch A).
89
97
  let pathThrew = false
90
98
  let built: string | null | undefined
91
99
  if (definition?.buildDocumentPath != null) {
@@ -105,24 +113,29 @@ export function createInternalLinkHydration(
105
113
  }
106
114
  }
107
115
 
116
+ let path: string | undefined
108
117
  if (!pathThrew) {
109
118
  if (typeof built === 'string') {
110
- next.path = built
119
+ path = built
111
120
  } else {
112
121
  // Generic compose fallback. Only fires when the target has a
113
- // non-empty `path` — otherwise we'd produce `/${collectionPath}/`
114
- // or `/${collectionPath}/undefined`, both of which are worse
115
- // than leaving the previous value alone.
122
+ // non-empty `path` — `/${collectionPath}/` or
123
+ // `/${collectionPath}/undefined` are not usable paths.
116
124
  const targetPath = target.path as string | undefined
117
125
  if (typeof targetPath === 'string' && targetPath.length > 0) {
118
- next.path = `/${collectionPath}/${targetPath}`
126
+ path = `/${collectionPath}/${targetPath}`
119
127
  }
120
128
  }
121
129
  }
122
130
 
123
- // Found-and-resolved: clear any stale miss flag from a prior pass.
124
- if ('_resolved' in next) {
131
+ if (path != null) {
132
+ next.path = path
133
+ // Found-and-resolved: clear any stale miss flag from a prior pass.
125
134
  delete next._resolved
135
+ } else {
136
+ // Branch A — no usable current path. Never reuse the old copy.
137
+ delete next.path
138
+ next._resolved = false
126
139
  }
127
140
 
128
141
  envelope.document = next
@@ -32,3 +32,28 @@ describe('runLexicalPopulate secure reader', () => {
32
32
  )
33
33
  })
34
34
  })
35
+
36
+ describe('runLexicalPopulate failed refresh', () => {
37
+ it('rejects without applying, so a stale snapshot is never returned as a live resolution', async () => {
38
+ const node = { type: 'x', document: { title: 'Stale' } }
39
+ const apply = vi.fn()
40
+ const applyMissing = vi.fn()
41
+ const visitor: LexicalNodeVisitor = {
42
+ match: (candidate) =>
43
+ candidate.type === 'x'
44
+ ? { node: candidate, collectionPath: 'pages', documentId: 'doc-1', apply, applyMissing }
45
+ : null,
46
+ }
47
+
48
+ await expect(
49
+ runLexicalPopulate({
50
+ readContext: createReadContext(),
51
+ readDocuments: vi.fn().mockRejectedValue(new Error('database unavailable')),
52
+ visitors: [visitor],
53
+ values: [{ root: { type: 'root', children: [node] } }],
54
+ })
55
+ ).rejects.toThrow('database unavailable')
56
+ expect(apply).not.toHaveBeenCalled()
57
+ expect(applyMissing).not.toHaveBeenCalled()
58
+ })
59
+ })