@jvs-milkdown/transformer 1.0.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/LICENSE +21 -0
  2. package/README.md +11 -0
  3. package/lib/index.d.ts +4 -0
  4. package/lib/index.d.ts.map +1 -0
  5. package/lib/index.js +384 -0
  6. package/lib/index.js.map +1 -0
  7. package/lib/parser/index.d.ts +3 -0
  8. package/lib/parser/index.d.ts.map +1 -0
  9. package/lib/parser/stack-element.d.ts +12 -0
  10. package/lib/parser/stack-element.d.ts.map +1 -0
  11. package/lib/parser/stack-element.spec.d.ts +2 -0
  12. package/lib/parser/stack-element.spec.d.ts.map +1 -0
  13. package/lib/parser/state.d.ts +23 -0
  14. package/lib/parser/state.d.ts.map +1 -0
  15. package/lib/parser/state.spec.d.ts +2 -0
  16. package/lib/parser/state.spec.d.ts.map +1 -0
  17. package/lib/parser/types.d.ts +13 -0
  18. package/lib/parser/types.d.ts.map +1 -0
  19. package/lib/serializer/index.d.ts +3 -0
  20. package/lib/serializer/index.d.ts.map +1 -0
  21. package/lib/serializer/stack-element.d.ts +14 -0
  22. package/lib/serializer/stack-element.d.ts.map +1 -0
  23. package/lib/serializer/stack-element.spec.d.ts +2 -0
  24. package/lib/serializer/stack-element.spec.d.ts.map +1 -0
  25. package/lib/serializer/state.d.ts +22 -0
  26. package/lib/serializer/state.d.ts.map +1 -0
  27. package/lib/serializer/state.spec.d.ts +2 -0
  28. package/lib/serializer/state.spec.d.ts.map +1 -0
  29. package/lib/serializer/types.d.ts +12 -0
  30. package/lib/serializer/types.d.ts.map +1 -0
  31. package/lib/tsconfig.tsbuildinfo +1 -0
  32. package/lib/utility/index.d.ts +3 -0
  33. package/lib/utility/index.d.ts.map +1 -0
  34. package/lib/utility/stack.d.ts +12 -0
  35. package/lib/utility/stack.d.ts.map +1 -0
  36. package/lib/utility/stack.spec.d.ts +2 -0
  37. package/lib/utility/stack.spec.d.ts.map +1 -0
  38. package/lib/utility/types.d.ts +31 -0
  39. package/lib/utility/types.d.ts.map +1 -0
  40. package/package.json +39 -0
  41. package/src/index.ts +3 -0
  42. package/src/parser/index.ts +2 -0
  43. package/src/parser/stack-element.spec.ts +25 -0
  44. package/src/parser/stack-element.ts +25 -0
  45. package/src/parser/state.spec.ts +265 -0
  46. package/src/parser/state.ts +217 -0
  47. package/src/parser/types.ts +55 -0
  48. package/src/serializer/index.ts +2 -0
  49. package/src/serializer/stack-element.spec.ts +17 -0
  50. package/src/serializer/stack-element.ts +30 -0
  51. package/src/serializer/state.spec.ts +287 -0
  52. package/src/serializer/state.ts +355 -0
  53. package/src/serializer/types.ts +51 -0
  54. package/src/utility/index.ts +2 -0
  55. package/src/utility/stack.spec.ts +30 -0
  56. package/src/utility/stack.ts +48 -0
  57. package/src/utility/types.ts +63 -0
@@ -0,0 +1,217 @@
1
+ import type {
2
+ Attrs,
3
+ MarkType,
4
+ Node,
5
+ NodeType,
6
+ Schema,
7
+ } from '@jvs-milkdown/prose/model'
8
+
9
+ import {
10
+ createNodeInParserFail,
11
+ parserMatchError,
12
+ stackOverFlow,
13
+ } from '@jvs-milkdown/exception'
14
+ import { Mark } from '@jvs-milkdown/prose/model'
15
+
16
+ import type {
17
+ MarkSchema,
18
+ MarkdownNode,
19
+ NodeSchema,
20
+ RemarkParser,
21
+ } from '../utility'
22
+ import type { Parser } from './types'
23
+
24
+ import { Stack } from '../utility'
25
+ import { ParserStackElement } from './stack-element'
26
+
27
+ /// A state machine for parser. Transform remark AST into prosemirror state.
28
+ export class ParserState extends Stack<Node, ParserStackElement> {
29
+ /// The schema in current editor.
30
+ readonly schema: Schema
31
+
32
+ /// @internal
33
+ #marks: readonly Mark[] = Mark.none
34
+
35
+ /// Create a parser from schema and remark instance.
36
+ ///
37
+ /// ```typescript
38
+ /// const parser = ParserState.create(schema, remark)
39
+ /// const prosemirrorNode = parser(SomeMarkdownText)
40
+ /// ```
41
+ static create = (schema: Schema, remark: RemarkParser): Parser => {
42
+ const state = new this(schema)
43
+ return (text) => {
44
+ state.run(remark, text)
45
+ return state.toDoc()
46
+ }
47
+ }
48
+
49
+ /// @internal
50
+ constructor(schema: Schema) {
51
+ super()
52
+ this.schema = schema
53
+ }
54
+
55
+ /// @internal
56
+ #hasText = (node: Node): node is Node & { text: string } => node.isText
57
+
58
+ /// @internal
59
+ #maybeMerge = (a: Node, b: Node): Node | undefined => {
60
+ if (this.#hasText(a) && this.#hasText(b) && Mark.sameSet(a.marks, b.marks))
61
+ return this.schema.text(a.text + b.text, a.marks)
62
+
63
+ return undefined
64
+ }
65
+
66
+ /// @internal
67
+ #matchTarget = (node: MarkdownNode): NodeType | MarkType => {
68
+ const result = Object.values({
69
+ ...this.schema.nodes,
70
+ ...this.schema.marks,
71
+ }).find((x): x is NodeType | MarkType => {
72
+ const spec = x.spec as NodeSchema | MarkSchema
73
+ return spec.parseMarkdown.match(node)
74
+ })
75
+
76
+ if (!result) throw parserMatchError(node)
77
+
78
+ return result
79
+ }
80
+
81
+ /// @internal
82
+ #runNode = (node: MarkdownNode) => {
83
+ const type = this.#matchTarget(node)
84
+ const spec = type.spec as NodeSchema | MarkSchema
85
+
86
+ spec.parseMarkdown.runner(this, node, type as NodeType & MarkType)
87
+ }
88
+
89
+ /// Inject root node for prosemirror state.
90
+ injectRoot = (node: MarkdownNode, nodeType: NodeType, attrs?: Attrs) => {
91
+ this.openNode(nodeType, attrs)
92
+ this.next(node.children)
93
+
94
+ return this
95
+ }
96
+
97
+ /// Open a new node, the next operations will
98
+ /// add nodes into that new node until `closeNode` is called.
99
+ openNode = (nodeType: NodeType, attrs?: Attrs) => {
100
+ this.open(ParserStackElement.create(nodeType, [], attrs))
101
+ return this
102
+ }
103
+
104
+ /// @internal
105
+ #closeNodeAndPush = (): Node => {
106
+ this.#marks = Mark.none
107
+ const element = this.close()
108
+
109
+ return this.#addNodeAndPush(element.type, element.attrs, element.content)
110
+ }
111
+
112
+ /// Close the current node and push it into the parent node.
113
+ closeNode = () => {
114
+ try {
115
+ this.#closeNodeAndPush()
116
+ } catch (e) {
117
+ console.error(e)
118
+ }
119
+ return this
120
+ }
121
+
122
+ /// @internal
123
+ #addNodeAndPush = (
124
+ nodeType: NodeType,
125
+ attrs?: Attrs,
126
+ content?: Node[]
127
+ ): Node => {
128
+ const node = nodeType.createAndFill(attrs, content, this.#marks)
129
+ if (!node) throw createNodeInParserFail(nodeType, attrs, content)
130
+
131
+ this.push(node)
132
+
133
+ return node
134
+ }
135
+
136
+ /// Add a node into current node.
137
+ addNode = (nodeType: NodeType, attrs?: Attrs, content?: Node[]) => {
138
+ try {
139
+ this.#addNodeAndPush(nodeType, attrs, content)
140
+ } catch (e) {
141
+ console.error(e)
142
+ }
143
+ return this
144
+ }
145
+
146
+ /// Open a new mark, the next nodes added will have that mark.
147
+ openMark = (markType: MarkType, attrs?: Attrs) => {
148
+ const mark = markType.create(attrs)
149
+
150
+ this.#marks = mark.addToSet(this.#marks)
151
+ return this
152
+ }
153
+
154
+ /// Close a opened mark.
155
+ closeMark = (markType: MarkType) => {
156
+ this.#marks = markType.removeFromSet(this.#marks)
157
+ return this
158
+ }
159
+
160
+ /// Add a text node into current node.
161
+ addText = (text: string) => {
162
+ try {
163
+ const topElement = this.top()
164
+ if (!topElement) throw stackOverFlow()
165
+
166
+ const prevNode = topElement.pop()
167
+ const currNode = this.schema.text(text, this.#marks)
168
+
169
+ if (!prevNode) {
170
+ topElement.push(currNode)
171
+ return this
172
+ }
173
+
174
+ const merged = this.#maybeMerge(prevNode, currNode)
175
+ if (merged) {
176
+ topElement.push(merged)
177
+ return this
178
+ }
179
+ topElement.push(prevNode, currNode)
180
+ return this
181
+ } catch (e) {
182
+ console.error(e)
183
+ return this
184
+ }
185
+ }
186
+
187
+ /// @internal
188
+ build = (): Node => {
189
+ let doc: Node | undefined
190
+
191
+ do doc = this.#closeNodeAndPush()
192
+ while (this.size())
193
+
194
+ return doc
195
+ }
196
+
197
+ /// Give the node or node list back to the state and
198
+ /// the state will find a proper runner (by `match` method in parser spec) to handle it.
199
+ next = (nodes: MarkdownNode | MarkdownNode[] = []) => {
200
+ ;[nodes].flat().forEach((node) => this.#runNode(node))
201
+ return this
202
+ }
203
+
204
+ /// Build the current state into a [prosemirror document](https://prosemirror.net/docs/ref/#model.Document_Structure).
205
+ toDoc = () => this.build()
206
+
207
+ /// Transform a markdown string into prosemirror state.
208
+ run = (remark: RemarkParser, markdown: string) => {
209
+ const tree = remark.runSync(
210
+ remark.parse(markdown),
211
+ markdown
212
+ ) as MarkdownNode
213
+ this.next(tree)
214
+
215
+ return this
216
+ }
217
+ }
@@ -0,0 +1,55 @@
1
+ import type { MarkType, Node, NodeType } from '@jvs-milkdown/prose/model'
2
+
3
+ import type { MarkdownNode } from '../utility/types'
4
+ import type { ParserState } from './state'
5
+
6
+ /// The parser type which is used to transform markdown text into prosemirror node.
7
+ export type Parser = (text: string) => Node
8
+
9
+ /// The spec for node parser in schema.
10
+ export interface NodeParserSpec {
11
+ /// The match function to check if the node is the target node.
12
+ /// For example:
13
+ ///
14
+ /// ```typescript
15
+ /// match: (node) => node.type === 'paragraph'
16
+ /// ```
17
+ match: (node: MarkdownNode) => boolean
18
+ /// The runner function to transform the node into prosemirror node.
19
+ /// Generally, you should call methods in `state` to add node to state.
20
+ /// For example:
21
+ ///
22
+ /// ```typescript
23
+ /// runner: (state, node, type) => {
24
+ /// state
25
+ /// .openNode(type)
26
+ /// .next(node.children)
27
+ /// .closeNode();
28
+ /// }
29
+ /// ```
30
+ runner: (state: ParserState, node: MarkdownNode, proseType: NodeType) => void
31
+ }
32
+
33
+ /// The spec for mark parser in schema.
34
+ export interface MarkParserSpec {
35
+ /// The match function to check if the node is the target mark.
36
+ /// For example:
37
+ ///
38
+ /// ```typescript
39
+ /// match: (mark) => mark.type === 'emphasis'
40
+ /// ```
41
+ match: (node: MarkdownNode) => boolean
42
+ /// The runner function to transform the node into prosemirror mark.
43
+ /// Generally, you should call methods in `state` to add mark to state.
44
+ /// For example:
45
+ ///
46
+ /// ```typescript
47
+ /// runner: (state, node, type) => {
48
+ /// state
49
+ /// .openMark(type)
50
+ /// .next(node.children)
51
+ /// .closeMark(type)
52
+ /// }
53
+ /// ```
54
+ runner: (state: ParserState, node: MarkdownNode, proseType: MarkType) => void
55
+ }
@@ -0,0 +1,2 @@
1
+ export * from './types'
2
+ export * from './state'
@@ -0,0 +1,17 @@
1
+ import { expect, it } from 'vitest'
2
+
3
+ import { SerializerStackElement } from './stack-element'
4
+
5
+ it('serializer-stack-element', () => {
6
+ const element = SerializerStackElement.create('type')
7
+
8
+ expect(element.props).toEqual({})
9
+
10
+ element.push({ type: 'text' })
11
+
12
+ expect(element.children).toEqual([{ type: 'text' }])
13
+
14
+ const node = element.pop()
15
+
16
+ expect(node).toEqual({ type: 'text' })
17
+ })
@@ -0,0 +1,30 @@
1
+ import type { MarkdownNode } from '..'
2
+ import type { JSONRecord } from '../utility'
3
+
4
+ import { StackElement } from '../utility'
5
+
6
+ export class SerializerStackElement extends StackElement<MarkdownNode> {
7
+ constructor(
8
+ public type: string,
9
+ public children?: MarkdownNode[],
10
+ public value?: string,
11
+ public props: JSONRecord = {}
12
+ ) {
13
+ super()
14
+ }
15
+
16
+ static create = (
17
+ type: string,
18
+ children?: MarkdownNode[],
19
+ value?: string,
20
+ props: JSONRecord = {}
21
+ ) => new SerializerStackElement(type, children, value, props)
22
+
23
+ push = (node: MarkdownNode, ...rest: MarkdownNode[]) => {
24
+ if (!this.children) this.children = []
25
+
26
+ this.children.push(node, ...rest)
27
+ }
28
+
29
+ pop = (): MarkdownNode | undefined => this.children?.pop()
30
+ }
@@ -0,0 +1,287 @@
1
+ import type { Mark, Schema } from '@jvs-milkdown/prose/model'
2
+
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ import { SerializerState } from './state'
6
+
7
+ const boldMark = {
8
+ isInSet: (arr: string[]) => arr.includes('bold'),
9
+ addToSet: (arr: string[]) => arr.concat('bold'),
10
+ type: {
11
+ removeFromSet: (arr: string[]) => arr.filter((x) => x !== 'bold'),
12
+ },
13
+ } as unknown as Mark
14
+
15
+ const italicMark = {
16
+ isInSet: (arr: string[]) => arr.includes('italic'),
17
+ addToSet: (arr: string[]) => arr.concat('italic'),
18
+ type: {
19
+ removeFromSet: (arr: string[]) => arr.filter((x) => x !== 'italic'),
20
+ },
21
+ } as unknown as Mark
22
+
23
+ const inlineCodeMark = {
24
+ isInSet: (arr: string[]) => arr.includes('inlineCode'),
25
+ addToSet: (arr: string[]) => arr.concat('inlineCode'),
26
+ type: {
27
+ removeFromSet: (arr: string[]) => arr.filter((x) => x !== 'inlineCode'),
28
+ },
29
+ } as unknown as Mark
30
+
31
+ const schema = {
32
+ nodes: {
33
+ paragraph: {
34
+ spec: {
35
+ toMarkdown: {
36
+ match: (node: { type: string }) => node.type === 'paragraph',
37
+ runner: (state: SerializerState, node: { value: string }) => {
38
+ state.addNode('text', [], node.value)
39
+ },
40
+ },
41
+ },
42
+ },
43
+ blockquote: {
44
+ spec: {
45
+ toMarkdown: {
46
+ match: (node: { type: string }) => node.type === 'blockquote',
47
+ runner: (state: SerializerState, node: { content: never }) => {
48
+ state.openNode('blockquote')
49
+ state.next(node.content)
50
+ state.closeNode()
51
+ },
52
+ },
53
+ },
54
+ },
55
+ },
56
+ marks: {},
57
+ text: (text: string, marks: string[]) => ({ text, marks, isText: true }),
58
+ } as unknown as Schema
59
+
60
+ describe('serializer-state', () => {
61
+ it('node', () => {
62
+ const state = new SerializerState(schema)
63
+ state.openNode('doc')
64
+ state.openNode('paragraph', 'paragraph node value', { foo: 'bar' })
65
+ state.addNode('text', [], 'text node value')
66
+ state.closeNode()
67
+
68
+ expect(state.top()).toMatchObject({
69
+ type: 'doc',
70
+ children: [
71
+ {
72
+ type: 'paragraph',
73
+ value: 'paragraph node value',
74
+ children: [
75
+ {
76
+ type: 'text',
77
+ value: 'text node value',
78
+ },
79
+ ],
80
+ },
81
+ ],
82
+ })
83
+ })
84
+
85
+ it('maybe merge children for same mark', () => {
86
+ const state = new SerializerState(schema)
87
+ state.openNode('doc')
88
+ state.openNode('paragraph')
89
+ state.withMark(boldMark, 'bold')
90
+ state.addNode('text', [], 'The lunatic is on the grass.')
91
+ state.closeMark(boldMark)
92
+ state.withMark(boldMark, 'bold')
93
+ state.addNode('text', [], 'The lunatic is in the hell.')
94
+ state.closeMark(boldMark)
95
+ state.closeNode()
96
+
97
+ expect(state.top()).toMatchObject({
98
+ type: 'doc',
99
+ children: [
100
+ {
101
+ type: 'paragraph',
102
+ children: [
103
+ {
104
+ type: 'bold',
105
+ isMark: true,
106
+ children: [
107
+ {
108
+ type: 'text',
109
+ value: 'The lunatic is on the grass.',
110
+ },
111
+ {
112
+ type: 'text',
113
+ value: 'The lunatic is in the hell.',
114
+ },
115
+ ],
116
+ },
117
+ ],
118
+ },
119
+ ],
120
+ })
121
+ })
122
+
123
+ it('build', () => {
124
+ const state = new SerializerState(schema)
125
+ state.openNode('doc')
126
+ state.openNode('paragraph', 'paragraph node value', { foo: 'bar' })
127
+ state.addNode('text', [], 'text node value')
128
+ state.closeNode()
129
+
130
+ expect(state.build()).toMatchObject({
131
+ type: 'doc',
132
+ children: [
133
+ {
134
+ type: 'paragraph',
135
+ value: 'paragraph node value',
136
+ children: [
137
+ {
138
+ type: 'text',
139
+ value: 'text node value',
140
+ },
141
+ ],
142
+ },
143
+ ],
144
+ })
145
+ })
146
+
147
+ it('next', () => {
148
+ const state = new SerializerState(schema)
149
+ state.openNode('doc')
150
+ state.next({
151
+ type: 'blockquote',
152
+ marks: [],
153
+ content: {
154
+ type: 'paragraph',
155
+ marks: [],
156
+ value: 'The lunatic is on the grass.',
157
+ },
158
+ } as any)
159
+
160
+ expect(state.build()).toMatchObject({
161
+ type: 'doc',
162
+ children: [
163
+ {
164
+ type: 'blockquote',
165
+ children: [
166
+ {
167
+ type: 'text',
168
+ value: 'The lunatic is on the grass.',
169
+ },
170
+ ],
171
+ },
172
+ ],
173
+ })
174
+ })
175
+
176
+ it('trim spaces around marks', () => {
177
+ const state = new SerializerState(schema)
178
+
179
+ state.openNode('doc')
180
+ state.openNode('paragraph')
181
+ // Open a bold mark, add a text node with surrounding spaces, then close the mark
182
+ state.withMark(boldMark, 'bold')
183
+ state.addNode('text', [], ' hello ')
184
+ state.closeMark(boldMark)
185
+ state.closeNode() // close paragraph
186
+
187
+ // Expect SerializerState to move the spaces outside of the mark node.
188
+ expect(state.top()).toMatchObject({
189
+ type: 'doc',
190
+ children: [
191
+ {
192
+ type: 'paragraph',
193
+ children: [
194
+ { type: 'text', value: ' ' },
195
+ {
196
+ type: 'bold',
197
+ isMark: true,
198
+ children: [{ type: 'text', value: 'hello' }],
199
+ },
200
+ { type: 'text', value: ' ' },
201
+ ],
202
+ },
203
+ ],
204
+ })
205
+ })
206
+
207
+ it('should not corrupt value-based marks during merge', () => {
208
+ const state = new SerializerState(schema)
209
+
210
+ state.openNode('doc')
211
+ state.openNode('paragraph')
212
+ // Simulate: inlineCode("Hello") followed by bold > inlineCode("World")
213
+ state.withMark(inlineCodeMark, 'inlineCode', 'Hello')
214
+ state.closeMark(inlineCodeMark)
215
+ state.withMark(boldMark, 'bold')
216
+ state.withMark(inlineCodeMark, 'inlineCode', 'World')
217
+ state.closeMark(inlineCodeMark)
218
+ state.closeMark(boldMark)
219
+ state.closeNode()
220
+
221
+ // The bold wrapper around inlineCode should be preserved,
222
+ // not restructured by searchType
223
+ expect(state.top()).toMatchObject({
224
+ type: 'doc',
225
+ children: [
226
+ {
227
+ type: 'paragraph',
228
+ children: [
229
+ {
230
+ type: 'inlineCode',
231
+ isMark: true,
232
+ value: 'Hello',
233
+ },
234
+ {
235
+ type: 'bold',
236
+ isMark: true,
237
+ children: [
238
+ {
239
+ type: 'inlineCode',
240
+ isMark: true,
241
+ value: 'World',
242
+ },
243
+ ],
244
+ },
245
+ ],
246
+ },
247
+ ],
248
+ })
249
+ })
250
+
251
+ it('try to merge marks', () => {
252
+ const state = new SerializerState(schema)
253
+
254
+ state.openNode('doc')
255
+ state.openNode('paragraph')
256
+ state.withMark(boldMark, 'bold')
257
+ state.withMark(italicMark, 'italic')
258
+ state.addNode('text', [], 'hello')
259
+ state.closeMark(italicMark)
260
+ state.addNode('text', [], 'world')
261
+ state.closeMark(boldMark)
262
+ state.closeNode()
263
+
264
+ expect(state.top()).toMatchObject({
265
+ type: 'doc',
266
+ children: [
267
+ {
268
+ type: 'paragraph',
269
+ children: [
270
+ {
271
+ type: 'bold',
272
+ isMark: true,
273
+ children: [
274
+ {
275
+ type: 'italic',
276
+ isMark: true,
277
+ children: [{ type: 'text', value: 'hello' }],
278
+ },
279
+ { type: 'text', value: 'world' },
280
+ ],
281
+ },
282
+ ],
283
+ },
284
+ ],
285
+ })
286
+ })
287
+ })