@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.
Files changed (57) hide show
  1. package/dist/field/extensions/code-highlight/code-highlight-extension.js +2 -1
  2. package/dist/field/extensions/inline-image/inline-image-extension.js +4 -2
  3. package/dist/field/extensions/inline-image/inline-image-modal.css +67 -0
  4. package/dist/field/extensions/inline-image/inline-image-modal.js +173 -98
  5. package/dist/field/extensions/inline-image/inline-image-node-component.css +37 -0
  6. package/dist/field/extensions/inline-image/inline-image-node-component.d.ts +3 -1
  7. package/dist/field/extensions/inline-image/inline-image-node-component.js +39 -1
  8. package/dist/field/extensions/inline-image/inline-image-node.d.ts +13 -2
  9. package/dist/field/extensions/inline-image/inline-image-node.js +63 -13
  10. package/dist/field/extensions/inline-image/node-types.d.ts +19 -0
  11. package/dist/field/extensions/inline-image/populate.d.ts +22 -0
  12. package/dist/field/extensions/inline-image/populate.js +19 -1
  13. package/dist/field/extensions/inline-image/types.d.ts +7 -0
  14. package/dist/field/extensions/link/link-href.d.ts +30 -0
  15. package/dist/field/extensions/link/link-href.js +14 -0
  16. package/dist/field/extensions/link/link-modal.js +96 -288
  17. package/dist/field/extensions/link/link-target-fields.css +59 -0
  18. package/dist/field/extensions/link/link-target-fields.d.ts +38 -0
  19. package/dist/field/extensions/link/link-target-fields.js +227 -0
  20. package/dist/field/extensions/link/link-target-state.d.ts +54 -0
  21. package/dist/field/extensions/link/link-target-state.js +70 -0
  22. package/dist/field/extensions/link/populate.d.ts +15 -1
  23. package/dist/field/extensions/link/populate.js +55 -50
  24. package/dist/field/markdown/lexical-to-markdown.js +6 -1
  25. package/dist/field/plugins/toolbar-plugin/index.js +2 -1
  26. package/dist/server.d.ts +2 -2
  27. package/dist/server.js +4 -2
  28. package/dist/validate/createEmptyEditorState.d.ts +2 -2
  29. package/dist/validate/hasText.d.ts +2 -2
  30. package/package.json +25 -24
  31. package/src/field/extensions/code-highlight/code-highlight-extension.ts +8 -1
  32. package/src/field/extensions/inline-image/inline-image-extension.tsx +5 -0
  33. package/src/field/extensions/inline-image/inline-image-modal.css +100 -0
  34. package/src/field/extensions/inline-image/inline-image-modal.tsx +178 -85
  35. package/src/field/extensions/inline-image/inline-image-node-component.css +44 -0
  36. package/src/field/extensions/inline-image/inline-image-node-component.tsx +52 -0
  37. package/src/field/extensions/inline-image/inline-image-node.test.tsx +331 -0
  38. package/src/field/extensions/inline-image/inline-image-node.tsx +108 -11
  39. package/src/field/extensions/inline-image/node-types.ts +19 -0
  40. package/src/field/extensions/inline-image/populate-link.test.node.ts +320 -0
  41. package/src/field/extensions/inline-image/populate.ts +44 -0
  42. package/src/field/extensions/inline-image/types.ts +7 -0
  43. package/src/field/extensions/link/link-href.test.node.ts +78 -0
  44. package/src/field/extensions/link/link-href.ts +52 -0
  45. package/src/field/extensions/link/link-modal.tsx +86 -325
  46. package/src/field/extensions/link/link-target-fields.css +81 -0
  47. package/src/field/extensions/link/link-target-fields.tsx +275 -0
  48. package/src/field/extensions/link/link-target-state.test.node.ts +197 -0
  49. package/src/field/extensions/link/link-target-state.ts +158 -0
  50. package/src/field/extensions/link/populate.ts +94 -67
  51. package/src/field/markdown/lexical-to-markdown.test.node.ts +68 -0
  52. package/src/field/markdown/lexical-to-markdown.ts +10 -1
  53. package/src/field/plugins/toolbar-plugin/index.tsx +7 -4
  54. package/src/server.ts +11 -5
  55. package/src/validate/createEmptyEditorState.ts +2 -7
  56. package/src/validate/hasText.test.ts +1 -2
  57. 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
+ }