@byline/richtext-lexical 3.14.0 → 3.15.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.
@@ -0,0 +1,12 @@
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
+ * Extract indexable plain text from a (possibly null / stringified)
10
+ * serialized Lexical editor state. Returns `''` when there is no content.
11
+ */
12
+ export declare function lexicalToText(value: unknown): string;
@@ -0,0 +1,82 @@
1
+ const INLINE_CONTAINER_TYPES = new Set([
2
+ 'link',
3
+ 'autolink'
4
+ ]);
5
+ const SKIP_TYPES = new Set([
6
+ 'youtube',
7
+ 'vimeo',
8
+ 'horizontalrule',
9
+ 'tab'
10
+ ]);
11
+ class LexicalTextConverter {
12
+ addText(text) {
13
+ if (null != text) this.current += text;
14
+ }
15
+ endParagraph() {
16
+ const p = this.current.trim();
17
+ if (p.length > 0) this.paragraphs.push(p);
18
+ this.current = '';
19
+ }
20
+ getText() {
21
+ this.endParagraph();
22
+ return this.paragraphs.join('\n');
23
+ }
24
+ addBlockNodes(nodes) {
25
+ this.endParagraph();
26
+ for (const node of nodes ?? [])this.addNode(node);
27
+ this.endParagraph();
28
+ }
29
+ addInlineNodes(nodes) {
30
+ for (const node of nodes ?? [])this.addNode(node);
31
+ }
32
+ addNode(node) {
33
+ const type = node.type;
34
+ if ('text' === type || 'code-highlight' === type) return void this.addText(node.text);
35
+ if ('linebreak' === type) return void this.endParagraph();
36
+ if ('admonition' === type) {
37
+ this.endParagraph();
38
+ this.addText(node.title);
39
+ this.endParagraph();
40
+ this.addText(lexicalToText(node.content?.editorState).trim());
41
+ this.endParagraph();
42
+ return;
43
+ }
44
+ if ('inline-image' === type) {
45
+ this.endParagraph();
46
+ this.addText(node.altText);
47
+ if (node.showCaption) {
48
+ this.endParagraph();
49
+ this.addText(lexicalToText(node.caption?.editorState).trim());
50
+ }
51
+ this.endParagraph();
52
+ return;
53
+ }
54
+ if (null != type && SKIP_TYPES.has(type)) return;
55
+ if (null != type && INLINE_CONTAINER_TYPES.has(type)) return void this.addInlineNodes(node.children);
56
+ if (Array.isArray(node.children)) this.addBlockNodes(node.children);
57
+ }
58
+ constructor(){
59
+ this.paragraphs = [];
60
+ this.current = '';
61
+ }
62
+ }
63
+ function resolveRoot(value) {
64
+ let v = value;
65
+ if ('string' == typeof v) try {
66
+ v = JSON.parse(v);
67
+ } catch {
68
+ return null;
69
+ }
70
+ if ('object' != typeof v || null === v) return null;
71
+ const obj = v;
72
+ const root = obj.root ?? obj;
73
+ return 'root' === root.type ? root : null;
74
+ }
75
+ function lexicalToText(value) {
76
+ const root = resolveRoot(value);
77
+ if (null == root) return '';
78
+ const converter = new LexicalTextConverter();
79
+ converter.addBlockNodes(root.children);
80
+ return converter.getText();
81
+ }
82
+ export { lexicalToText };
package/dist/server.d.ts CHANGED
@@ -36,9 +36,10 @@
36
36
  * ```
37
37
  */
38
38
  import type { BylineClient } from '@byline/client';
39
- import type { RichTextEmbedFn, RichTextPopulateFn, RichTextToMarkdownFn } from '@byline/core';
39
+ import type { RichTextEmbedFn, RichTextPopulateFn, RichTextToMarkdownFn, RichTextToTextFn } from '@byline/core';
40
40
  import { type LexicalNodeVisitor } from './field/lexical-populate-shared';
41
41
  export { defaultEditorConfig } from './field/config/default';
42
+ export { lexicalToText } from './field/lexical-to-text';
42
43
  export { type LexicalToMarkdownOptions, type LexicalToMarkdownResult, type LexicalToMarkdownWarning, lexicalToMarkdown, } from './field/markdown/lexical-to-markdown';
43
44
  import { type LexicalToMarkdownOptions } from './field/markdown/lexical-to-markdown';
44
45
  export { inlineImageVisitor } from './field/extensions/inline-image/populate';
@@ -97,4 +98,12 @@ export declare function lexicalEditorPopulateServer(options: LexicalServerOption
97
98
  * for the dialect contract (GFM alerts, lossy-OK).
98
99
  */
99
100
  export declare function lexicalEditorToMarkdownServer(options?: LexicalToMarkdownOptions): RichTextToMarkdownFn;
101
+ /**
102
+ * Plain-text extractor for search indexing, shaped for
103
+ * `ServerConfig.fields.richText.toText`. Pure and synchronous — flattens
104
+ * the stored editor JSON to indexable plain text via `lexicalToText` (a
105
+ * recursive text-node accumulator, no markdown). Consumed by core's
106
+ * `buildSearchDocument` to feed a collection's searchable `body`.
107
+ */
108
+ export declare function lexicalEditorToTextServer(): RichTextToTextFn;
100
109
  export declare function lexicalEditorEmbedServer(options: LexicalServerOptions): RichTextEmbedFn;
package/dist/server.js CHANGED
@@ -2,6 +2,7 @@ import { inlineImageVisitor } from "./field/extensions/inline-image/populate.js"
2
2
  import { linkVisitor } from "./field/extensions/link/populate.js";
3
3
  import { runLexicalPopulate } from "./field/lexical-populate-shared.js";
4
4
  import { lexicalToMarkdown } from "./field/markdown/lexical-to-markdown.js";
5
+ import { lexicalToText } from "./field/lexical-to-text.js";
5
6
  function lexicalEditorPopulateServer(options) {
6
7
  const visitors = options.visitors ?? [
7
8
  inlineImageVisitor,
@@ -21,6 +22,9 @@ function lexicalEditorPopulateServer(options) {
21
22
  function lexicalEditorToMarkdownServer(options = {}) {
22
23
  return (ctx)=>lexicalToMarkdown(ctx.value, options).markdown;
23
24
  }
25
+ function lexicalEditorToTextServer() {
26
+ return (ctx)=>lexicalToText(ctx.value);
27
+ }
24
28
  function lexicalEditorEmbedServer(options) {
25
29
  const visitors = options.visitors ?? [
26
30
  inlineImageVisitor,
@@ -38,4 +42,4 @@ function lexicalEditorEmbedServer(options) {
38
42
  };
39
43
  }
40
44
  export { defaultEditorConfig } from "./field/config/default.js";
41
- export { inlineImageVisitor, lexicalEditorEmbedServer, lexicalEditorPopulateServer, lexicalEditorToMarkdownServer, lexicalToMarkdown, linkVisitor };
45
+ export { inlineImageVisitor, lexicalEditorEmbedServer, lexicalEditorPopulateServer, lexicalEditorToMarkdownServer, lexicalEditorToTextServer, lexicalToMarkdown, lexicalToText, linkVisitor };
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "private": false,
4
4
  "type": "module",
5
5
  "license": "MPL-2.0",
6
- "version": "3.14.0",
6
+ "version": "3.15.0",
7
7
  "engines": {
8
8
  "node": ">=20.9.0"
9
9
  },
@@ -77,10 +77,10 @@
77
77
  "npm-run-all": "^4.1.5",
78
78
  "prism-react-renderer": "^2.4.1",
79
79
  "react-error-boundary": "^6.1.1",
80
- "@byline/core": "3.14.0",
81
- "@byline/client": "3.14.0",
82
- "@byline/admin": "3.14.0",
83
- "@byline/ui": "3.14.0"
80
+ "@byline/core": "3.15.0",
81
+ "@byline/client": "3.15.0",
82
+ "@byline/ui": "3.15.0",
83
+ "@byline/admin": "3.15.0"
84
84
  },
85
85
  "peerDependencies": {
86
86
  "react": "^19.0.0",
@@ -0,0 +1,75 @@
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
+ import { describe, expect, it } from 'vitest'
10
+
11
+ import { lexicalToText } from './lexical-to-text'
12
+
13
+ const state = (children: unknown[]) => ({ root: { type: 'root', children } })
14
+
15
+ const para = (text: string) => ({
16
+ type: 'paragraph',
17
+ children: [{ type: 'text', text }],
18
+ })
19
+
20
+ describe('lexicalToText', () => {
21
+ it('returns empty string for null / undefined / non-editor values', () => {
22
+ expect(lexicalToText(null)).toBe('')
23
+ expect(lexicalToText(undefined)).toBe('')
24
+ expect(lexicalToText(42)).toBe('')
25
+ expect(lexicalToText({ not: 'an editor state' })).toBe('')
26
+ })
27
+
28
+ it('parses a stringified editor state', () => {
29
+ expect(lexicalToText(JSON.stringify(state([para('Hello world')])))).toBe('Hello world')
30
+ })
31
+
32
+ it('joins block-level nodes with newlines', () => {
33
+ expect(lexicalToText(state([para('First'), para('Second')]))).toBe('First\nSecond')
34
+ })
35
+
36
+ it('concatenates inline text and follows link children', () => {
37
+ const node = {
38
+ type: 'paragraph',
39
+ children: [
40
+ { type: 'text', text: 'See ' },
41
+ { type: 'link', children: [{ type: 'text', text: 'the docs' }] },
42
+ { type: 'text', text: ' now.' },
43
+ ],
44
+ }
45
+ expect(lexicalToText(state([node]))).toBe('See the docs now.')
46
+ })
47
+
48
+ it('recurses into headings, lists, and quotes', () => {
49
+ const heading = { type: 'heading', tag: 'h1', children: [{ type: 'text', text: 'Title' }] }
50
+ const list = {
51
+ type: 'list',
52
+ children: [
53
+ { type: 'listitem', children: [{ type: 'text', text: 'one' }] },
54
+ { type: 'listitem', children: [{ type: 'text', text: 'two' }] },
55
+ ],
56
+ }
57
+ expect(lexicalToText(state([heading, list]))).toBe('Title\none\ntwo')
58
+ })
59
+
60
+ it('skips embed-only nodes (youtube/vimeo)', () => {
61
+ expect(
62
+ lexicalToText(state([para('before'), { type: 'youtube', videoID: 'x' }, para('after')]))
63
+ ).toBe('before\nafter')
64
+ })
65
+
66
+ it('extracts nested editor states from inline-image captions', () => {
67
+ const node = {
68
+ type: 'inline-image',
69
+ altText: 'A diagram',
70
+ showCaption: true,
71
+ caption: { editorState: state([para('Figure 1')]) },
72
+ }
73
+ expect(lexicalToText(state([node]))).toBe('A diagram\nFigure 1')
74
+ })
75
+ })
@@ -0,0 +1,145 @@
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
+ * One-way Lexical → plain-text extractor for search indexing — flattens a
11
+ * stored `SerializedEditorState` to indexable plain text via a recursive
12
+ * text-node accumulator. No markdown syntax, no link URLs, no images: just
13
+ * the words, with paragraph breaks between block-level nodes.
14
+ *
15
+ * Registered through `ServerConfig.fields.richText.toText` (see
16
+ * `lexicalEditorToTextServer` in `../server`) and consumed by core's
17
+ * `buildSearchDocument` to feed a collection's searchable `body`. Walks the
18
+ * stored JSON directly — no editor instantiation, no DOM, no DB reads —
19
+ * exactly like the markdown serializer. Lossy by contract.
20
+ */
21
+
22
+ type AnyNode = {
23
+ type?: string
24
+ text?: string
25
+ children?: AnyNode[]
26
+ // Nested editor states (admonition body, inline-image caption).
27
+ content?: { editorState?: unknown }
28
+ caption?: { editorState?: unknown }
29
+ title?: string
30
+ altText?: string
31
+ showCaption?: boolean
32
+ }
33
+
34
+ /** Inline node types whose children continue the current paragraph. */
35
+ const INLINE_CONTAINER_TYPES = new Set(['link', 'autolink'])
36
+
37
+ /** Leaf / embed node types that contribute no indexable text. */
38
+ const SKIP_TYPES = new Set(['youtube', 'vimeo', 'horizontalrule', 'tab'])
39
+
40
+ class LexicalTextConverter {
41
+ private paragraphs: string[] = []
42
+ private current = ''
43
+
44
+ private addText(text: string | null | undefined): void {
45
+ if (text != null) this.current += text
46
+ }
47
+
48
+ private endParagraph(): void {
49
+ const p = this.current.trim()
50
+ if (p.length > 0) this.paragraphs.push(p)
51
+ this.current = ''
52
+ }
53
+
54
+ getText(): string {
55
+ this.endParagraph()
56
+ return this.paragraphs.join('\n')
57
+ }
58
+
59
+ /** Block-level children: flush before and after so blocks separate. */
60
+ addBlockNodes(nodes: AnyNode[] | null | undefined): void {
61
+ this.endParagraph()
62
+ for (const node of nodes ?? []) this.addNode(node)
63
+ this.endParagraph()
64
+ }
65
+
66
+ /** Inline children: continue the current paragraph. */
67
+ private addInlineNodes(nodes: AnyNode[] | null | undefined): void {
68
+ for (const node of nodes ?? []) this.addNode(node)
69
+ }
70
+
71
+ private addNode(node: AnyNode): void {
72
+ const type = node.type
73
+
74
+ if (type === 'text' || type === 'code-highlight') {
75
+ this.addText(node.text)
76
+ return
77
+ }
78
+ if (type === 'linebreak') {
79
+ this.endParagraph()
80
+ return
81
+ }
82
+ if (type === 'admonition') {
83
+ this.endParagraph()
84
+ this.addText(node.title)
85
+ this.endParagraph()
86
+ this.addText(lexicalToText(node.content?.editorState).trim())
87
+ this.endParagraph()
88
+ return
89
+ }
90
+ if (type === 'inline-image') {
91
+ this.endParagraph()
92
+ this.addText(node.altText)
93
+ if (node.showCaption) {
94
+ this.endParagraph()
95
+ this.addText(lexicalToText(node.caption?.editorState).trim())
96
+ }
97
+ this.endParagraph()
98
+ return
99
+ }
100
+ if (type != null && SKIP_TYPES.has(type)) return
101
+
102
+ if (type != null && INLINE_CONTAINER_TYPES.has(type)) {
103
+ this.addInlineNodes(node.children)
104
+ return
105
+ }
106
+ // Default: any node with children is treated as block-level (paragraph,
107
+ // heading, quote, code, list, listitem, table*, layout*, …). Unknown
108
+ // childless nodes contribute nothing.
109
+ if (Array.isArray(node.children)) {
110
+ this.addBlockNodes(node.children)
111
+ }
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Resolve a stored value (a `SerializedEditorState`, its `root`, or a
117
+ * stringified version of either) to the root node, mirroring the markdown
118
+ * serializer's `resolveRoot`.
119
+ */
120
+ function resolveRoot(value: unknown): AnyNode | null {
121
+ let v = value
122
+ if (typeof v === 'string') {
123
+ try {
124
+ v = JSON.parse(v)
125
+ } catch {
126
+ return null
127
+ }
128
+ }
129
+ if (typeof v !== 'object' || v === null) return null
130
+ const obj = v as { root?: AnyNode } & AnyNode
131
+ const root = (obj.root ?? obj) as AnyNode
132
+ return root.type === 'root' ? root : null
133
+ }
134
+
135
+ /**
136
+ * Extract indexable plain text from a (possibly null / stringified)
137
+ * serialized Lexical editor state. Returns `''` when there is no content.
138
+ */
139
+ export function lexicalToText(value: unknown): string {
140
+ const root = resolveRoot(value)
141
+ if (root == null) return ''
142
+ const converter = new LexicalTextConverter()
143
+ converter.addBlockNodes(root.children)
144
+ return converter.getText()
145
+ }
package/src/server.ts CHANGED
@@ -44,6 +44,7 @@ import type {
44
44
  RichTextPopulateContext,
45
45
  RichTextPopulateFn,
46
46
  RichTextToMarkdownFn,
47
+ RichTextToTextFn,
47
48
  } from '@byline/core'
48
49
 
49
50
  import { inlineImageVisitor } from './field/extensions/inline-image/populate'
@@ -57,6 +58,7 @@ import { type LexicalNodeVisitor, runLexicalPopulate } from './field/lexical-pop
57
58
  // their CSS imports; the `/server` subpath stays React-free.
58
59
  // ---------------------------------------------------------------------------
59
60
  export { defaultEditorConfig } from './field/config/default'
61
+ export { lexicalToText } from './field/lexical-to-text'
60
62
  export {
61
63
  type LexicalToMarkdownOptions,
62
64
  type LexicalToMarkdownResult,
@@ -64,6 +66,7 @@ export {
64
66
  lexicalToMarkdown,
65
67
  } from './field/markdown/lexical-to-markdown'
66
68
 
69
+ import { lexicalToText } from './field/lexical-to-text'
67
70
  import {
68
71
  type LexicalToMarkdownOptions,
69
72
  lexicalToMarkdown,
@@ -147,6 +150,17 @@ export function lexicalEditorToMarkdownServer(
147
150
  return (ctx) => lexicalToMarkdown(ctx.value, options).markdown
148
151
  }
149
152
 
153
+ /**
154
+ * Plain-text extractor for search indexing, shaped for
155
+ * `ServerConfig.fields.richText.toText`. Pure and synchronous — flattens
156
+ * the stored editor JSON to indexable plain text via `lexicalToText` (a
157
+ * recursive text-node accumulator, no markdown). Consumed by core's
158
+ * `buildSearchDocument` to feed a collection's searchable `body`.
159
+ */
160
+ export function lexicalEditorToTextServer(): RichTextToTextFn {
161
+ return (ctx) => lexicalToText(ctx.value)
162
+ }
163
+
150
164
  export function lexicalEditorEmbedServer(options: LexicalServerOptions): RichTextEmbedFn {
151
165
  const visitors = options.visitors ?? [inlineImageVisitor, linkVisitor]
152
166
  return async (ctx: RichTextEmbedContext): Promise<void> => {