@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.
- package/dist/field/extensions/inline-image/populate.js +32 -5
- package/dist/field/extensions/link/populate.js +11 -3
- package/package.json +6 -6
- package/src/field/extensions/inline-image/populate-link.test.node.ts +70 -0
- package/src/field/extensions/inline-image/populate.test.node.ts +66 -15
- package/src/field/extensions/inline-image/populate.ts +49 -8
- package/src/field/extensions/link/populate.test.node.ts +91 -6
- package/src/field/extensions/link/populate.ts +43 -30
- package/src/field/lexical-populate-shared.test.node.ts +25 -0
|
@@ -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
|
-
|
|
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)
|
|
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
|
+
"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.
|
|
83
|
-
"@byline/
|
|
84
|
-
"@byline/
|
|
85
|
-
"@byline/client": "6.
|
|
86
|
-
"@byline/ui": "6.
|
|
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
|
|
176
|
-
it('
|
|
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.
|
|
192
|
-
expect(node.
|
|
193
|
-
expect(node.
|
|
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('
|
|
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.
|
|
202
|
-
expect(node.
|
|
203
|
-
expect(node.
|
|
204
|
-
expect(node.
|
|
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
|
|
216
|
+
// the original is the only candidate.
|
|
209
217
|
it.each([undefined, '', ' '])(
|
|
210
|
-
'
|
|
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.
|
|
217
|
-
expect(node.
|
|
218
|
-
expect(node.
|
|
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.
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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
|
-
|
|
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('
|
|
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).
|
|
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
|
|
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
|
-
//
|
|
283
|
-
expect(node.attributes?.document?.path).
|
|
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'`.
|
|
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`
|
|
19
|
-
* field
|
|
20
|
-
*
|
|
21
|
-
* `buildDocumentPath` hook
|
|
22
|
-
*
|
|
23
|
-
* `
|
|
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
|
-
* - **
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
33
|
-
*
|
|
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
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
-
//
|
|
87
|
-
//
|
|
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
|
-
|
|
119
|
+
path = built
|
|
111
120
|
} else {
|
|
112
121
|
// Generic compose fallback. Only fires when the target has a
|
|
113
|
-
// non-empty `path` —
|
|
114
|
-
//
|
|
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
|
-
|
|
126
|
+
path = `/${collectionPath}/${targetPath}`
|
|
119
127
|
}
|
|
120
128
|
}
|
|
121
129
|
}
|
|
122
130
|
|
|
123
|
-
|
|
124
|
-
|
|
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
|
+
})
|