@byline/richtext-lexical 6.4.0 → 6.5.0
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/code-highlight/code-highlight-extension.js +2 -1
- package/dist/field/extensions/inline-image/inline-image-extension.js +4 -2
- package/dist/field/extensions/inline-image/inline-image-modal.css +67 -0
- package/dist/field/extensions/inline-image/inline-image-modal.js +173 -98
- package/dist/field/extensions/inline-image/inline-image-node-component.css +37 -0
- package/dist/field/extensions/inline-image/inline-image-node-component.d.ts +3 -1
- package/dist/field/extensions/inline-image/inline-image-node-component.js +39 -1
- package/dist/field/extensions/inline-image/inline-image-node.d.ts +13 -2
- package/dist/field/extensions/inline-image/inline-image-node.js +63 -13
- package/dist/field/extensions/inline-image/node-types.d.ts +19 -0
- package/dist/field/extensions/inline-image/populate.d.ts +22 -0
- package/dist/field/extensions/inline-image/populate.js +19 -1
- package/dist/field/extensions/inline-image/types.d.ts +7 -0
- package/dist/field/extensions/link/link-href.d.ts +30 -0
- package/dist/field/extensions/link/link-href.js +14 -0
- package/dist/field/extensions/link/link-modal.js +96 -288
- package/dist/field/extensions/link/link-target-fields.css +59 -0
- package/dist/field/extensions/link/link-target-fields.d.ts +38 -0
- package/dist/field/extensions/link/link-target-fields.js +227 -0
- package/dist/field/extensions/link/link-target-state.d.ts +54 -0
- package/dist/field/extensions/link/link-target-state.js +70 -0
- package/dist/field/extensions/link/populate.d.ts +15 -1
- package/dist/field/extensions/link/populate.js +55 -50
- package/dist/field/markdown/lexical-to-markdown.js +6 -1
- package/dist/field/plugins/toolbar-plugin/index.js +2 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +4 -2
- package/dist/validate/createEmptyEditorState.d.ts +2 -2
- package/dist/validate/hasText.d.ts +2 -2
- package/package.json +25 -24
- package/src/field/extensions/code-highlight/code-highlight-extension.ts +8 -1
- package/src/field/extensions/inline-image/inline-image-extension.tsx +5 -0
- package/src/field/extensions/inline-image/inline-image-modal.css +100 -0
- package/src/field/extensions/inline-image/inline-image-modal.tsx +178 -85
- package/src/field/extensions/inline-image/inline-image-node-component.css +44 -0
- package/src/field/extensions/inline-image/inline-image-node-component.tsx +52 -0
- package/src/field/extensions/inline-image/inline-image-node.test.tsx +331 -0
- package/src/field/extensions/inline-image/inline-image-node.tsx +108 -11
- package/src/field/extensions/inline-image/node-types.ts +19 -0
- package/src/field/extensions/inline-image/populate-link.test.node.ts +320 -0
- package/src/field/extensions/inline-image/populate.ts +44 -0
- package/src/field/extensions/inline-image/types.ts +7 -0
- package/src/field/extensions/link/link-href.test.node.ts +78 -0
- package/src/field/extensions/link/link-href.ts +52 -0
- package/src/field/extensions/link/link-modal.tsx +86 -325
- package/src/field/extensions/link/link-target-fields.css +81 -0
- package/src/field/extensions/link/link-target-fields.tsx +275 -0
- package/src/field/extensions/link/link-target-state.test.node.ts +197 -0
- package/src/field/extensions/link/link-target-state.ts +158 -0
- package/src/field/extensions/link/populate.ts +94 -67
- package/src/field/markdown/lexical-to-markdown.test.node.ts +68 -0
- package/src/field/markdown/lexical-to-markdown.ts +10 -1
- package/src/field/plugins/toolbar-plugin/index.tsx +7 -4
- package/src/server.ts +11 -5
- package/src/validate/createEmptyEditorState.ts +2 -7
- package/src/validate/hasText.test.ts +1 -2
- package/src/validate/hasText.ts +2 -9
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Tests for `inlineImageLinkVisitor` — the second relation on an
|
|
11
|
+
* inline-image node. The flat relation envelope on the node addresses the
|
|
12
|
+
* *media* document; `node.link` optionally addresses a *click-through
|
|
13
|
+
* target*, which is an entirely different document in an entirely
|
|
14
|
+
* different collection.
|
|
15
|
+
*
|
|
16
|
+
* The two relations hydrate independently through the same
|
|
17
|
+
* `runLexicalPopulate` pass, because the driver runs every visitor against
|
|
18
|
+
* every node and enqueues each match separately. The final suite here
|
|
19
|
+
* pins that co-existence — it is the whole reason a second visitor is the
|
|
20
|
+
* right shape rather than widening `inlineImageVisitor`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
type BylineLogger,
|
|
25
|
+
type CollectionDefinition,
|
|
26
|
+
createReadContext,
|
|
27
|
+
defineServerConfig,
|
|
28
|
+
type StoredFileValue,
|
|
29
|
+
} from '@byline/core'
|
|
30
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
31
|
+
|
|
32
|
+
import { type LexicalNodeLike, runLexicalPopulate } from '../../lexical-populate-shared'
|
|
33
|
+
import { inlineImageLinkVisitor, inlineImageVisitor } from './populate'
|
|
34
|
+
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
// Test harness — mirrors `extensions/link/populate.test.node.ts`
|
|
37
|
+
// ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
function makeLogger(): BylineLogger {
|
|
40
|
+
return {
|
|
41
|
+
log: vi.fn(),
|
|
42
|
+
fatal: vi.fn(),
|
|
43
|
+
error: vi.fn(),
|
|
44
|
+
warn: vi.fn(),
|
|
45
|
+
info: vi.fn(),
|
|
46
|
+
debug: vi.fn(),
|
|
47
|
+
trace: vi.fn(),
|
|
48
|
+
silent: vi.fn(),
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function registerCollection(definition: Partial<CollectionDefinition> & { path: string }): void {
|
|
53
|
+
defineServerConfig({
|
|
54
|
+
collections: [
|
|
55
|
+
{
|
|
56
|
+
labels: { singular: 'Page', plural: 'Pages' },
|
|
57
|
+
fields: [{ name: 'title', type: 'text', label: 'Title' }],
|
|
58
|
+
useAsTitle: 'title',
|
|
59
|
+
...definition,
|
|
60
|
+
} as CollectionDefinition,
|
|
61
|
+
],
|
|
62
|
+
} as unknown as Parameters<typeof defineServerConfig>[0])
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function clearConfig(): void {
|
|
66
|
+
;(globalThis as any)[Symbol.for('__byline_server_config__')] = null
|
|
67
|
+
;(globalThis as any)[Symbol.for('__byline_client_config__')] = null
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function installLogger(logger: BylineLogger): void {
|
|
71
|
+
;(globalThis as any)[Symbol.for('__byline_logger__')] = logger
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Fixtures
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
interface InlineImageNodeLike extends LexicalNodeLike {
|
|
79
|
+
src?: string
|
|
80
|
+
position?: string
|
|
81
|
+
link?: Record<string, any>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* An inline-image node carrying BOTH relations: the flat envelope points
|
|
86
|
+
* at `media/media-1`, `link` points at `pages/doc-1`.
|
|
87
|
+
*/
|
|
88
|
+
function makeLinkedImageNode(link?: Record<string, any>): InlineImageNodeLike {
|
|
89
|
+
return {
|
|
90
|
+
type: 'inline-image',
|
|
91
|
+
src: 'https://cdn.example.com/media/stale-card.avif',
|
|
92
|
+
position: 'left',
|
|
93
|
+
targetCollectionPath: 'media',
|
|
94
|
+
targetDocumentId: 'media-1',
|
|
95
|
+
document: { title: 'Stale media title' },
|
|
96
|
+
link,
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const internalLink = {
|
|
101
|
+
linkType: 'internal',
|
|
102
|
+
newTab: false,
|
|
103
|
+
targetDocumentId: 'doc-1',
|
|
104
|
+
targetCollectionId: 'coll-1',
|
|
105
|
+
targetCollectionPath: 'pages',
|
|
106
|
+
document: { title: 'Stale link title', path: '/pages/stale' },
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const linkTarget = {
|
|
110
|
+
id: 'doc-1',
|
|
111
|
+
path: 'about',
|
|
112
|
+
status: 'published',
|
|
113
|
+
fields: { title: 'About Us' },
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const mediaImage = {
|
|
117
|
+
fileId: 'file-1',
|
|
118
|
+
filename: 'current.jpg',
|
|
119
|
+
originalFilename: 'current.jpg',
|
|
120
|
+
mimeType: 'image/jpeg',
|
|
121
|
+
fileSize: 123,
|
|
122
|
+
storageProvider: 's3',
|
|
123
|
+
storagePath: 'media/current.jpg',
|
|
124
|
+
storageUrl: 'https://cdn.example.com/media/current.jpg',
|
|
125
|
+
imageWidth: 4000,
|
|
126
|
+
imageHeight: 3000,
|
|
127
|
+
processingStatus: 'complete',
|
|
128
|
+
variants: [
|
|
129
|
+
{
|
|
130
|
+
name: 'card',
|
|
131
|
+
storagePath: 'media/current-card.avif',
|
|
132
|
+
storageUrl: 'https://cdn.example.com/media/current-card.avif',
|
|
133
|
+
width: 600,
|
|
134
|
+
height: 450,
|
|
135
|
+
format: 'avif',
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
} satisfies StoredFileValue
|
|
139
|
+
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
// Suite
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
describe('inlineImageLinkVisitor', () => {
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
installLogger(makeLogger())
|
|
147
|
+
clearConfig()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
afterEach(() => {
|
|
151
|
+
clearConfig()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('match', () => {
|
|
155
|
+
it('returns null for nodes that are not inline images', () => {
|
|
156
|
+
expect(inlineImageLinkVisitor.match({ type: 'paragraph' })).toBeNull()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('returns null for an inline image with no link', () => {
|
|
160
|
+
expect(inlineImageLinkVisitor.match(makeLinkedImageNode(undefined))).toBeNull()
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('returns null for a custom-URL link, which carries no relation', () => {
|
|
164
|
+
const node = makeLinkedImageNode({
|
|
165
|
+
linkType: 'custom',
|
|
166
|
+
url: 'https://example.com',
|
|
167
|
+
newTab: true,
|
|
168
|
+
})
|
|
169
|
+
expect(inlineImageLinkVisitor.match(node)).toBeNull()
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('returns null when the link relation is missing its document id', () => {
|
|
173
|
+
const node = makeLinkedImageNode({ linkType: 'internal', targetCollectionPath: 'pages' })
|
|
174
|
+
expect(inlineImageLinkVisitor.match(node)).toBeNull()
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
// The load-bearing assertion: the pending hydration addresses the LINK
|
|
178
|
+
// target, not the media document the flat envelope points at.
|
|
179
|
+
it('addresses the link target rather than the media document', () => {
|
|
180
|
+
registerCollection({ path: 'pages' })
|
|
181
|
+
const pending = inlineImageLinkVisitor.match(makeLinkedImageNode({ ...internalLink }))
|
|
182
|
+
|
|
183
|
+
expect(pending?.collectionPath).toBe('pages')
|
|
184
|
+
expect(pending?.documentId).toBe('doc-1')
|
|
185
|
+
})
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
describe('apply', () => {
|
|
189
|
+
it('refreshes the link title and path from the resolved target', () => {
|
|
190
|
+
registerCollection({ path: 'pages', useAsTitle: 'title' })
|
|
191
|
+
const node = makeLinkedImageNode({ ...internalLink })
|
|
192
|
+
|
|
193
|
+
inlineImageLinkVisitor.match(node)?.apply(linkTarget)
|
|
194
|
+
|
|
195
|
+
expect(node.link?.document).toEqual({ title: 'About Us', path: '/pages/about' })
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('uses buildDocumentPath when the collection defines one', () => {
|
|
199
|
+
registerCollection({
|
|
200
|
+
path: 'pages',
|
|
201
|
+
useAsTitle: 'title',
|
|
202
|
+
buildDocumentPath: (doc) => `/custom/${doc.path}`,
|
|
203
|
+
})
|
|
204
|
+
const node = makeLinkedImageNode({ ...internalLink })
|
|
205
|
+
|
|
206
|
+
inlineImageLinkVisitor.match(node)?.apply(linkTarget)
|
|
207
|
+
|
|
208
|
+
expect(node.link?.document?.path).toBe('/custom/about')
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('leaves the media relation completely untouched', () => {
|
|
212
|
+
registerCollection({ path: 'pages', useAsTitle: 'title' })
|
|
213
|
+
const node = makeLinkedImageNode({ ...internalLink })
|
|
214
|
+
|
|
215
|
+
inlineImageLinkVisitor.match(node)?.apply(linkTarget)
|
|
216
|
+
|
|
217
|
+
expect(node.targetCollectionPath).toBe('media')
|
|
218
|
+
expect(node.targetDocumentId).toBe('media-1')
|
|
219
|
+
expect(node.document).toEqual({ title: 'Stale media title' })
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('clears a stale _resolved flag once the target resolves again', () => {
|
|
223
|
+
registerCollection({ path: 'pages', useAsTitle: 'title' })
|
|
224
|
+
const node = makeLinkedImageNode({
|
|
225
|
+
...internalLink,
|
|
226
|
+
document: { _resolved: false },
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
inlineImageLinkVisitor.match(node)?.apply(linkTarget)
|
|
230
|
+
|
|
231
|
+
expect(node.link?.document).not.toHaveProperty('_resolved')
|
|
232
|
+
})
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
describe('applyMissing', () => {
|
|
236
|
+
it('marks the link unresolved and drops the stale title and path', () => {
|
|
237
|
+
registerCollection({ path: 'pages', useAsTitle: 'title' })
|
|
238
|
+
const node = makeLinkedImageNode({ ...internalLink })
|
|
239
|
+
|
|
240
|
+
inlineImageLinkVisitor.match(node)?.applyMissing?.()
|
|
241
|
+
|
|
242
|
+
expect(node.link?.document).toEqual({ _resolved: false })
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
// The image must keep rendering even when its click target is gone —
|
|
246
|
+
// only the anchor is dropped, by the renderer, off `_resolved: false`.
|
|
247
|
+
it('leaves the image itself renderable', () => {
|
|
248
|
+
registerCollection({ path: 'pages', useAsTitle: 'title' })
|
|
249
|
+
const node = makeLinkedImageNode({ ...internalLink })
|
|
250
|
+
|
|
251
|
+
inlineImageLinkVisitor.match(node)?.applyMissing?.()
|
|
252
|
+
|
|
253
|
+
expect(node.src).toBe('https://cdn.example.com/media/stale-card.avif')
|
|
254
|
+
expect(node.targetDocumentId).toBe('media-1')
|
|
255
|
+
})
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
describe('alongside inlineImageVisitor in one populate pass', () => {
|
|
259
|
+
it('hydrates both relations from their own collections', async () => {
|
|
260
|
+
registerCollection({ path: 'pages', useAsTitle: 'title' })
|
|
261
|
+
const node = makeLinkedImageNode({ ...internalLink })
|
|
262
|
+
|
|
263
|
+
const readDocuments = vi.fn(async ({ collectionPath }: { collectionPath: string }) =>
|
|
264
|
+
collectionPath === 'media'
|
|
265
|
+
? [
|
|
266
|
+
{
|
|
267
|
+
document_id: 'media-1',
|
|
268
|
+
path: 'shot',
|
|
269
|
+
status: 'published',
|
|
270
|
+
fields: { image: mediaImage, title: 'Current media title' },
|
|
271
|
+
},
|
|
272
|
+
]
|
|
273
|
+
: [
|
|
274
|
+
{
|
|
275
|
+
document_id: 'doc-1',
|
|
276
|
+
path: 'about',
|
|
277
|
+
status: 'published',
|
|
278
|
+
fields: { title: 'About Us' },
|
|
279
|
+
},
|
|
280
|
+
]
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
await runLexicalPopulate({
|
|
284
|
+
readContext: createReadContext(),
|
|
285
|
+
readDocuments,
|
|
286
|
+
visitors: [inlineImageVisitor, inlineImageLinkVisitor],
|
|
287
|
+
values: [{ root: { type: 'root', children: [node] } }],
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
// Media relation refreshed from `media`…
|
|
291
|
+
expect(node.src).toBe('https://cdn.example.com/media/current-card.avif')
|
|
292
|
+
expect(node.document).toEqual(
|
|
293
|
+
expect.objectContaining({ title: 'Current media title', image: mediaImage })
|
|
294
|
+
)
|
|
295
|
+
// …and the link relation refreshed from `pages`, in the same pass.
|
|
296
|
+
expect(node.link?.document).toEqual({ title: 'About Us', path: '/pages/about' })
|
|
297
|
+
})
|
|
298
|
+
|
|
299
|
+
it('batches one read per collection', async () => {
|
|
300
|
+
registerCollection({ path: 'pages', useAsTitle: 'title' })
|
|
301
|
+
const node = makeLinkedImageNode({ ...internalLink })
|
|
302
|
+
const readDocuments = vi.fn().mockResolvedValue([])
|
|
303
|
+
|
|
304
|
+
await runLexicalPopulate({
|
|
305
|
+
readContext: createReadContext(),
|
|
306
|
+
readDocuments,
|
|
307
|
+
visitors: [inlineImageVisitor, inlineImageLinkVisitor],
|
|
308
|
+
values: [{ root: { type: 'root', children: [node] } }],
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
expect(readDocuments).toHaveBeenCalledTimes(2)
|
|
312
|
+
expect(readDocuments.mock.calls.map(([arg]) => arg)).toEqual(
|
|
313
|
+
expect.arrayContaining([
|
|
314
|
+
{ collectionPath: 'media', documentIds: ['media-1'] },
|
|
315
|
+
{ collectionPath: 'pages', documentIds: ['doc-1'] },
|
|
316
|
+
])
|
|
317
|
+
)
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
})
|
|
@@ -29,8 +29,10 @@
|
|
|
29
29
|
|
|
30
30
|
import type { StoredFileValue } from '@byline/core'
|
|
31
31
|
|
|
32
|
+
import { createInternalLinkHydration } from '../link/populate'
|
|
32
33
|
import { deriveImageSizes, getPreferredSize } from './utils'
|
|
33
34
|
import type { LexicalNodeLike, LexicalNodeVisitor } from '../../lexical-populate-shared'
|
|
35
|
+
import type { LinkAttributes } from '../link'
|
|
34
36
|
import type { Position } from './node-types'
|
|
35
37
|
|
|
36
38
|
/**
|
|
@@ -44,6 +46,8 @@ interface InlineImageNodeLike extends LexicalNodeLike {
|
|
|
44
46
|
position?: Position
|
|
45
47
|
width?: number | string
|
|
46
48
|
height?: number | string
|
|
49
|
+
/** Optional click-through target — a SECOND relation, see below. */
|
|
50
|
+
link?: LinkAttributes
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
export const inlineImageVisitor: LexicalNodeVisitor = {
|
|
@@ -78,3 +82,43 @@ export const inlineImageVisitor: LexicalNodeVisitor = {
|
|
|
78
82
|
}
|
|
79
83
|
},
|
|
80
84
|
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Second visitor for the same node type. An inline image carries **two**
|
|
88
|
+
* independent relations:
|
|
89
|
+
*
|
|
90
|
+
* - the flat `DocumentRelation` envelope on the node — the *media*
|
|
91
|
+
* document the image comes from, refreshed by `inlineImageVisitor`
|
|
92
|
+
* above;
|
|
93
|
+
* - `node.link` — an optional *click-through target*, which is an
|
|
94
|
+
* unrelated document in an unrelated collection.
|
|
95
|
+
*
|
|
96
|
+
* They cannot share one visitor: `PendingHydration` addresses exactly one
|
|
97
|
+
* `{ collectionPath, documentId }` pair. `runLexicalPopulate` runs every
|
|
98
|
+
* visitor against every node and enqueues each match separately, so
|
|
99
|
+
* registering this alongside `inlineImageVisitor` hydrates both relations
|
|
100
|
+
* in the same batched pass with no change to the driver contract.
|
|
101
|
+
*
|
|
102
|
+
* Only `linkType: 'internal'` links carry a relation; custom-URL links are
|
|
103
|
+
* a literal href and are skipped. Resolution branches — found, hook threw,
|
|
104
|
+
* target missing — are the internal-link rules shared with `linkVisitor`
|
|
105
|
+
* (see `../link/populate.ts`).
|
|
106
|
+
*/
|
|
107
|
+
export const inlineImageLinkVisitor: LexicalNodeVisitor = {
|
|
108
|
+
match(node: LexicalNodeLike) {
|
|
109
|
+
if (node.type !== 'inline-image') return null
|
|
110
|
+
const link = (node as InlineImageNodeLike).link
|
|
111
|
+
if (link == null) return null
|
|
112
|
+
if (link.linkType !== 'internal') return null
|
|
113
|
+
const collectionPath = link.targetCollectionPath
|
|
114
|
+
const documentId = link.targetDocumentId
|
|
115
|
+
if (!collectionPath || !documentId) return null
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
node,
|
|
119
|
+
collectionPath,
|
|
120
|
+
documentId,
|
|
121
|
+
...createInternalLinkHydration(link, collectionPath, documentId),
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { DocumentRelation } from '../../nodes/document-relation'
|
|
10
|
+
import type { LinkAttributes } from '../link'
|
|
10
11
|
import type { Position } from './node-types'
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -25,6 +26,12 @@ export interface InlineImageData {
|
|
|
25
26
|
width?: number | string
|
|
26
27
|
height?: number | string
|
|
27
28
|
showCaption?: boolean
|
|
29
|
+
/**
|
|
30
|
+
* Optional click-through target. `undefined` means "no link" — and the
|
|
31
|
+
* modal always emits the key, so the node's `update()` can tell
|
|
32
|
+
* "unchanged" from "cleared".
|
|
33
|
+
*/
|
|
34
|
+
link?: LinkAttributes
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
export interface InlineImageModalProps {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The internal-link fallback chain, as a pure function.
|
|
11
|
+
*
|
|
12
|
+
* Returning `null` is meaningful: it is the signal that no usable target
|
|
13
|
+
* exists, and every caller reacts by degrading rather than by emitting a
|
|
14
|
+
* broken href — the editor hides its cmd-click affordance, the markdown
|
|
15
|
+
* serializer drops the wrapper, the public renderer strips the anchor.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { describe, expect, it } from 'vitest'
|
|
19
|
+
|
|
20
|
+
import { resolveLinkHref } from './link-href'
|
|
21
|
+
import type { LinkAttributes } from '.'
|
|
22
|
+
|
|
23
|
+
const internal = (document?: Record<string, unknown>): LinkAttributes => ({
|
|
24
|
+
linkType: 'internal',
|
|
25
|
+
targetDocumentId: 'doc-1',
|
|
26
|
+
targetCollectionId: 'coll-1',
|
|
27
|
+
targetCollectionPath: 'pages',
|
|
28
|
+
document: document as any,
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('resolveLinkHref', () => {
|
|
32
|
+
it('returns null for an absent link', () => {
|
|
33
|
+
expect(resolveLinkHref(undefined)).toBeNull()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('custom targets', () => {
|
|
37
|
+
it.each([
|
|
38
|
+
'https://example.com/report',
|
|
39
|
+
'/docs/getting-started',
|
|
40
|
+
'mailto:hello@example.com',
|
|
41
|
+
'#section',
|
|
42
|
+
])('passes %s through unchanged', (url) => {
|
|
43
|
+
expect(resolveLinkHref({ linkType: 'custom', url })).toBe(url)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it.each([undefined, ''])('returns null for a %j url', (url) => {
|
|
47
|
+
expect(resolveLinkHref({ linkType: 'custom', url })).toBeNull()
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('internal targets', () => {
|
|
52
|
+
it('uses a canonical path written by the embed walker', () => {
|
|
53
|
+
expect(resolveLinkHref(internal({ path: '/about-us' }))).toBe('/about-us')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('composes a bare slug against the target collection', () => {
|
|
57
|
+
expect(resolveLinkHref(internal({ path: 'about' }))).toBe('/pages/about')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
// The walker sets this when the target was deleted. The image must stay
|
|
61
|
+
// on the page; only its click target goes away.
|
|
62
|
+
it('returns null when the walker marked the target unresolved', () => {
|
|
63
|
+
expect(resolveLinkHref(internal({ path: '/about-us', _resolved: false }))).toBeNull()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it.each([undefined, {}, { path: '' }])(
|
|
67
|
+
'returns null when there is no path to build from (%j)',
|
|
68
|
+
(document) => {
|
|
69
|
+
expect(resolveLinkHref(internal(document))).toBeNull()
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
it('returns null for a bare slug with no collection path to compose against', () => {
|
|
74
|
+
const link = { ...internal({ path: 'about' }), targetCollectionPath: '' } as LinkAttributes
|
|
75
|
+
expect(resolveLinkHref(link)).toBeNull()
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
})
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Resolve a `LinkAttributes` to a renderable href, or `null` when no
|
|
11
|
+
* usable target exists.
|
|
12
|
+
*
|
|
13
|
+
* React-free and DOM-free, so the editor, the markdown serializer and any
|
|
14
|
+
* host renderer can share one definition of the internal-link fallback
|
|
15
|
+
* chain (documented in `docs/04-collections/07-rich-text.md`):
|
|
16
|
+
*
|
|
17
|
+
* 1. `document._resolved === false` — the server-side walker could not
|
|
18
|
+
* find the target on its last pass. Return null; callers degrade.
|
|
19
|
+
* 2. `document.path` starts with `/` — canonical, written by the walker
|
|
20
|
+
* via `buildDocumentPath`. Use as-is.
|
|
21
|
+
* 3. `document.path` is a bare slug — compose `/${collectionPath}/${path}`.
|
|
22
|
+
* Covers legacy nodes and picker-time writes not yet walked.
|
|
23
|
+
* 4. Anything else — null.
|
|
24
|
+
*
|
|
25
|
+
* `null` is never "render an empty href". It means the link is not
|
|
26
|
+
* renderable, and each caller has its own way of degrading: the editor
|
|
27
|
+
* withholds its cmd-click affordance, the markdown serializer emits the
|
|
28
|
+
* image without a wrapper, the public renderer drops the anchor.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import type { LinkAttributes } from '.'
|
|
32
|
+
|
|
33
|
+
export function resolveLinkHref(link: LinkAttributes | undefined | null): string | null {
|
|
34
|
+
if (link == null) return null
|
|
35
|
+
|
|
36
|
+
if (link.linkType === 'internal') {
|
|
37
|
+
// Step 1 — walker explicitly marked the target as missing.
|
|
38
|
+
if (link.document?._resolved === false) return null
|
|
39
|
+
|
|
40
|
+
const path = link.document?.path
|
|
41
|
+
if (typeof path !== 'string' || path.length === 0) return null
|
|
42
|
+
// Step 2 — canonical path.
|
|
43
|
+
if (path.startsWith('/')) return path
|
|
44
|
+
// Step 3 — bare slug, generic compose fallback.
|
|
45
|
+
if (link.targetCollectionPath) return `/${link.targetCollectionPath}/${path}`
|
|
46
|
+
// Step 4 — nothing to compose against.
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const url = link.url
|
|
51
|
+
return typeof url === 'string' && url.length > 0 ? url : null
|
|
52
|
+
}
|