@byline/richtext-lexical 3.21.0 → 4.1.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/link/floating-link-editor.js +3 -3
- package/dist/field/extensions/link/link-node.js +2 -2
- package/dist/field/lexical-populate-shared.d.ts +5 -10
- package/dist/field/lexical-populate-shared.js +4 -6
- package/dist/server.js +2 -2
- package/package.json +5 -5
- package/src/field/extensions/link/floating-link-editor.tsx +3 -3
- package/src/field/extensions/link/link-node.ts +2 -2
- package/src/field/lexical-populate-shared.test.node.ts +34 -0
- package/src/field/lexical-populate-shared.ts +7 -22
- package/src/server.ts +2 -2
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useCallback, useEffect,
|
|
4
|
-
import { getClientConfig
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { getClientConfig } from "@byline/core";
|
|
5
5
|
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
|
6
6
|
import { $findMatchingParent, mergeRegister } from "@lexical/utils";
|
|
7
7
|
import { $getSelection, $isLineBreakNode, $isRangeSelection, COMMAND_PRIORITY_CRITICAL, COMMAND_PRIORITY_HIGH, COMMAND_PRIORITY_LOW, KEY_ESCAPE_COMMAND, SELECTION_CHANGE_COMMAND } from "lexical";
|
|
@@ -20,7 +20,7 @@ function FloatingLinkEditor({ editor, isLink, setIsLink, anchorElem }) {
|
|
|
20
20
|
});
|
|
21
21
|
const [linkModalData, setLinkModalData] = useState(void 0);
|
|
22
22
|
const [modalOpen, setModalOpen] = useState(false);
|
|
23
|
-
const adminRoute =
|
|
23
|
+
const adminRoute = getClientConfig().routes.admin;
|
|
24
24
|
const $updateLinkEditor = useCallback(()=>{
|
|
25
25
|
const selection = $getSelection();
|
|
26
26
|
if ($isRangeSelection(selection)) {
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { getClientConfig
|
|
1
|
+
import { getClientConfig } from "@byline/core";
|
|
2
2
|
import { addClassNamesToElement, isHTMLAnchorElement } from "@lexical/utils";
|
|
3
3
|
import { $applyNodeReplacement, $createTextNode, $getSelection, $isElementNode, $isRangeSelection, ElementNode, createCommand } from "lexical";
|
|
4
4
|
import { sanitizeUrl } from "../../utils/url.js";
|
|
5
5
|
function adminHref(attrs) {
|
|
6
6
|
if (null == attrs || 'internal' !== attrs.linkType) return '#';
|
|
7
7
|
const internal = attrs;
|
|
8
|
-
const { admin } =
|
|
8
|
+
const { admin } = getClientConfig().routes;
|
|
9
9
|
return `${admin}/collections/${internal.targetCollectionPath}/${internal.targetDocumentId}`;
|
|
10
10
|
}
|
|
11
11
|
class LinkNode extends ElementNode {
|
|
@@ -15,8 +15,7 @@
|
|
|
15
15
|
* Pure / framework-agnostic. No React, no DOM, no Lexical runtime — safe
|
|
16
16
|
* to import from the package's `server` entry.
|
|
17
17
|
*/
|
|
18
|
-
import type {
|
|
19
|
-
import type { ReadContext } from '@byline/core';
|
|
18
|
+
import type { ReadContext, RichTextReadDocumentsFn } from '@byline/core';
|
|
20
19
|
/**
|
|
21
20
|
* Lexical-shaped node — only the fields the visitors need to read or
|
|
22
21
|
* mutate. Carries an unknown `children` array because nodes nest
|
|
@@ -82,8 +81,8 @@ export interface LexicalNodeVisitor {
|
|
|
82
81
|
match: (node: LexicalNodeLike) => PendingHydration | null;
|
|
83
82
|
}
|
|
84
83
|
interface RunPopulateOptions {
|
|
85
|
-
client: BylineClient;
|
|
86
84
|
readContext: ReadContext;
|
|
85
|
+
readDocuments: RichTextReadDocumentsFn;
|
|
87
86
|
visitors: LexicalNodeVisitor[];
|
|
88
87
|
/**
|
|
89
88
|
* One or more rich-text values to walk. The framework's read pipeline
|
|
@@ -99,13 +98,9 @@ interface RunPopulateOptions {
|
|
|
99
98
|
* hydrations across all visitors, batch-fetch by source collection, and
|
|
100
99
|
* apply.
|
|
101
100
|
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
* recurse into populate or `afterRead`, so there's no visited-set or
|
|
106
|
-
* read-budget state to share. Retained on the options shape so a future
|
|
107
|
-
* visitor that performs nested populate can opt back in without another
|
|
108
|
-
* contract churn.
|
|
101
|
+
* Callers provide the framework-owned `readDocuments` function, which enforces
|
|
102
|
+
* target abilities and `beforeRead` predicates for both read-time populate and
|
|
103
|
+
* write-time embed refreshes.
|
|
109
104
|
*/
|
|
110
105
|
export declare function runLexicalPopulate(options: RunPopulateOptions): Promise<void>;
|
|
111
106
|
export {};
|
|
@@ -17,7 +17,7 @@ function* iterAllNodes(node) {
|
|
|
17
17
|
if (Array.isArray(node.children)) for (const child of node.children)yield* iterAllNodes(child);
|
|
18
18
|
}
|
|
19
19
|
async function runLexicalPopulate(options) {
|
|
20
|
-
const {
|
|
20
|
+
const { visitors, values, readDocuments } = options;
|
|
21
21
|
const pending = [];
|
|
22
22
|
for (const value of values){
|
|
23
23
|
const root = getLexicalRoot(value);
|
|
@@ -39,11 +39,9 @@ async function runLexicalPopulate(options) {
|
|
|
39
39
|
const fetched = new Map();
|
|
40
40
|
await Promise.all(Array.from(idsByCollection.entries()).map(async ([collectionPath, idSet])=>{
|
|
41
41
|
const ids = Array.from(idSet);
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
document_ids: ids,
|
|
46
|
-
readMode: 'published'
|
|
42
|
+
const rawDocs = await readDocuments({
|
|
43
|
+
collectionPath,
|
|
44
|
+
documentIds: ids
|
|
47
45
|
});
|
|
48
46
|
const byId = new Map();
|
|
49
47
|
for (const raw of rawDocs)if ('string' == typeof raw.document_id) byId.set(raw.document_id, {
|
package/dist/server.js
CHANGED
|
@@ -10,8 +10,8 @@ function lexicalEditorPopulateServer(options) {
|
|
|
10
10
|
];
|
|
11
11
|
return async (ctx)=>{
|
|
12
12
|
await runLexicalPopulate({
|
|
13
|
-
client: options.getClient(),
|
|
14
13
|
readContext: ctx.readContext,
|
|
14
|
+
readDocuments: ctx.readDocuments,
|
|
15
15
|
visitors,
|
|
16
16
|
values: [
|
|
17
17
|
ctx.value
|
|
@@ -32,8 +32,8 @@ function lexicalEditorEmbedServer(options) {
|
|
|
32
32
|
];
|
|
33
33
|
return async (ctx)=>{
|
|
34
34
|
await runLexicalPopulate({
|
|
35
|
-
client: options.getClient(),
|
|
36
35
|
readContext: ctx.readContext,
|
|
36
|
+
readDocuments: ctx.readDocuments,
|
|
37
37
|
visitors,
|
|
38
38
|
values: [
|
|
39
39
|
ctx.value
|
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
|
+
"version": "4.1.0",
|
|
7
7
|
"engines": {
|
|
8
8
|
"node": ">=20.9.0"
|
|
9
9
|
},
|
|
@@ -73,10 +73,10 @@
|
|
|
73
73
|
"lexical": "0.46.0",
|
|
74
74
|
"npm-run-all": "^4.1.5",
|
|
75
75
|
"react-error-boundary": "^6.1.2",
|
|
76
|
-
"@byline/admin": "
|
|
77
|
-
"@byline/
|
|
78
|
-
"@byline/
|
|
79
|
-
"@byline/
|
|
76
|
+
"@byline/admin": "4.1.0",
|
|
77
|
+
"@byline/ui": "4.1.0",
|
|
78
|
+
"@byline/core": "4.1.0",
|
|
79
|
+
"@byline/client": "4.1.0"
|
|
80
80
|
},
|
|
81
81
|
"peerDependencies": {
|
|
82
82
|
"react": "^19.0.0",
|
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type * as React from 'react'
|
|
14
|
-
import { type Dispatch, useCallback, useEffect,
|
|
14
|
+
import { type Dispatch, useCallback, useEffect, useRef, useState } from 'react'
|
|
15
15
|
|
|
16
|
-
import { getClientConfig
|
|
16
|
+
import { getClientConfig } from '@byline/core'
|
|
17
17
|
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
|
18
18
|
import { $findMatchingParent, mergeRegister } from '@lexical/utils'
|
|
19
19
|
import {
|
|
@@ -70,7 +70,7 @@ function FloatingLinkEditor({
|
|
|
70
70
|
})
|
|
71
71
|
const [linkModalData, setLinkModalData] = useState<LinkData | undefined>(undefined)
|
|
72
72
|
const [modalOpen, setModalOpen] = useState(false)
|
|
73
|
-
const adminRoute =
|
|
73
|
+
const adminRoute = getClientConfig().routes.admin
|
|
74
74
|
|
|
75
75
|
const $updateLinkEditor = useCallback(() => {
|
|
76
76
|
const selection = $getSelection()
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { getClientConfig
|
|
9
|
+
import { getClientConfig } from '@byline/core'
|
|
10
10
|
import { addClassNamesToElement, isHTMLAnchorElement } from '@lexical/utils'
|
|
11
11
|
import {
|
|
12
12
|
$applyNodeReplacement,
|
|
@@ -33,7 +33,7 @@ import type { InternalLinkAttributes, LinkAttributes, SerializedLinkNode } from
|
|
|
33
33
|
function adminHref(attrs: LinkAttributes | null | undefined): string {
|
|
34
34
|
if (attrs == null || attrs.linkType !== 'internal') return '#'
|
|
35
35
|
const internal = attrs as InternalLinkAttributes
|
|
36
|
-
const { admin } =
|
|
36
|
+
const { admin } = getClientConfig().routes
|
|
37
37
|
return `${admin}/collections/${internal.targetCollectionPath}/${internal.targetDocumentId}`
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createReadContext } from '@byline/core'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { type LexicalNodeVisitor, runLexicalPopulate } from './lexical-populate-shared.js'
|
|
5
|
+
|
|
6
|
+
describe('runLexicalPopulate secure reader', () => {
|
|
7
|
+
it('uses the framework reader instead of direct adapter access', async () => {
|
|
8
|
+
const apply = vi.fn()
|
|
9
|
+
const visitor: LexicalNodeVisitor = {
|
|
10
|
+
match: (node) =>
|
|
11
|
+
node.type === 'target' ? { node, collectionPath: 'media', documentId: 'm1', apply } : null,
|
|
12
|
+
}
|
|
13
|
+
const readDocuments = vi
|
|
14
|
+
.fn()
|
|
15
|
+
.mockResolvedValue([
|
|
16
|
+
{ document_id: 'm1', path: 'asset', status: 'published', fields: { title: 'Asset' } },
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
await runLexicalPopulate({
|
|
20
|
+
readContext: createReadContext(),
|
|
21
|
+
readDocuments,
|
|
22
|
+
visitors: [visitor],
|
|
23
|
+
values: [{ root: { type: 'root', children: [{ type: 'target' }] } }],
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
expect(readDocuments).toHaveBeenCalledWith({
|
|
27
|
+
collectionPath: 'media',
|
|
28
|
+
documentIds: ['m1'],
|
|
29
|
+
})
|
|
30
|
+
expect(apply).toHaveBeenCalledWith(
|
|
31
|
+
expect.objectContaining({ id: 'm1', fields: { title: 'Asset' } })
|
|
32
|
+
)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
@@ -17,8 +17,7 @@
|
|
|
17
17
|
* to import from the package's `server` entry.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import type {
|
|
21
|
-
import type { ReadContext } from '@byline/core'
|
|
20
|
+
import type { ReadContext, RichTextReadDocumentsFn } from '@byline/core'
|
|
22
21
|
|
|
23
22
|
/**
|
|
24
23
|
* Lexical-shaped node — only the fields the visitors need to read or
|
|
@@ -124,8 +123,8 @@ export interface LexicalNodeVisitor {
|
|
|
124
123
|
}
|
|
125
124
|
|
|
126
125
|
interface RunPopulateOptions {
|
|
127
|
-
client: BylineClient
|
|
128
126
|
readContext: ReadContext
|
|
127
|
+
readDocuments: RichTextReadDocumentsFn
|
|
129
128
|
visitors: LexicalNodeVisitor[]
|
|
130
129
|
/**
|
|
131
130
|
* One or more rich-text values to walk. The framework's read pipeline
|
|
@@ -142,16 +141,12 @@ interface RunPopulateOptions {
|
|
|
142
141
|
* hydrations across all visitors, batch-fetch by source collection, and
|
|
143
142
|
* apply.
|
|
144
143
|
*
|
|
145
|
-
*
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* recurse into populate or `afterRead`, so there's no visited-set or
|
|
149
|
-
* read-budget state to share. Retained on the options shape so a future
|
|
150
|
-
* visitor that performs nested populate can opt back in without another
|
|
151
|
-
* contract churn.
|
|
144
|
+
* Callers provide the framework-owned `readDocuments` function, which enforces
|
|
145
|
+
* target abilities and `beforeRead` predicates for both read-time populate and
|
|
146
|
+
* write-time embed refreshes.
|
|
152
147
|
*/
|
|
153
148
|
export async function runLexicalPopulate(options: RunPopulateOptions): Promise<void> {
|
|
154
|
-
const {
|
|
149
|
+
const { visitors, values, readDocuments } = options
|
|
155
150
|
|
|
156
151
|
const pending: PendingHydration[] = []
|
|
157
152
|
for (const value of values) {
|
|
@@ -178,21 +173,11 @@ export async function runLexicalPopulate(options: RunPopulateOptions): Promise<v
|
|
|
178
173
|
bucket.add(p.documentId)
|
|
179
174
|
}
|
|
180
175
|
|
|
181
|
-
// Fetch directly through the adapter rather than the client's `find()` —
|
|
182
|
-
// `parseWhere` has no handler for `id`, so `find({ where: { id: { $in } } })`
|
|
183
|
-
// silently dropped the filter and returned arbitrary docs ordered by
|
|
184
|
-
// `created_at desc`. This is the same primitive relation populate uses
|
|
185
|
-
// (`packages/core/src/services/populate.ts`) when it batches by document id.
|
|
186
176
|
const fetched = new Map<string, Map<string, Record<string, any>>>()
|
|
187
177
|
await Promise.all(
|
|
188
178
|
Array.from(idsByCollection.entries()).map(async ([collectionPath, idSet]) => {
|
|
189
179
|
const ids = Array.from(idSet)
|
|
190
|
-
const
|
|
191
|
-
const rawDocs = await client.db.queries.documents.getDocumentsByDocumentIds({
|
|
192
|
-
collection_id: collectionId,
|
|
193
|
-
document_ids: ids,
|
|
194
|
-
readMode: 'published',
|
|
195
|
-
})
|
|
180
|
+
const rawDocs = await readDocuments({ collectionPath, documentIds: ids })
|
|
196
181
|
const byId = new Map<string, Record<string, any>>()
|
|
197
182
|
for (const raw of rawDocs as Array<Record<string, any>>) {
|
|
198
183
|
if (typeof raw.document_id !== 'string') continue
|
package/src/server.ts
CHANGED
|
@@ -115,8 +115,8 @@ export function lexicalEditorPopulateServer(options: LexicalServerOptions): Rich
|
|
|
115
115
|
const visitors = options.visitors ?? [inlineImageVisitor, linkVisitor]
|
|
116
116
|
return async (ctx: RichTextPopulateContext): Promise<void> => {
|
|
117
117
|
await runLexicalPopulate({
|
|
118
|
-
client: options.getClient(),
|
|
119
118
|
readContext: ctx.readContext,
|
|
119
|
+
readDocuments: ctx.readDocuments,
|
|
120
120
|
visitors,
|
|
121
121
|
values: [ctx.value],
|
|
122
122
|
})
|
|
@@ -165,8 +165,8 @@ export function lexicalEditorEmbedServer(options: LexicalServerOptions): RichTex
|
|
|
165
165
|
const visitors = options.visitors ?? [inlineImageVisitor, linkVisitor]
|
|
166
166
|
return async (ctx: RichTextEmbedContext): Promise<void> => {
|
|
167
167
|
await runLexicalPopulate({
|
|
168
|
-
client: options.getClient(),
|
|
169
168
|
readContext: ctx.readContext,
|
|
169
|
+
readDocuments: ctx.readDocuments,
|
|
170
170
|
visitors,
|
|
171
171
|
values: [ctx.value],
|
|
172
172
|
})
|