@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,355 @@
1
+ import type {
2
+ Fragment,
3
+ MarkType,
4
+ Node,
5
+ NodeType,
6
+ Schema,
7
+ } from '@jvs-milkdown/prose/model'
8
+
9
+ import { serializerMatchError } from '@jvs-milkdown/exception'
10
+ import { Mark } from '@jvs-milkdown/prose/model'
11
+
12
+ import type {
13
+ JSONRecord,
14
+ MarkSchema,
15
+ MarkdownNode,
16
+ NodeSchema,
17
+ RemarkParser,
18
+ Root,
19
+ } from '../utility'
20
+ import type { Serializer } from './types'
21
+
22
+ import { Stack } from '../utility'
23
+ import { SerializerStackElement } from './stack-element'
24
+
25
+ const isFragment = (x: Node | Fragment): x is Fragment =>
26
+ Object.prototype.hasOwnProperty.call(x, 'size')
27
+
28
+ /// State for serializer.
29
+ /// Transform prosemirror state into remark AST.
30
+ export class SerializerState extends Stack<
31
+ MarkdownNode,
32
+ SerializerStackElement
33
+ > {
34
+ /// @internal
35
+ #marks: readonly Mark[] = Mark.none
36
+ /// Get the schema of state.
37
+ readonly schema: Schema
38
+
39
+ /// Create a serializer from schema and remark instance.
40
+ ///
41
+ /// ```typescript
42
+ /// const serializer = SerializerState.create(schema, remark)
43
+ /// const markdown = parser(prosemirrorDoc)
44
+ /// ```
45
+ static create = (schema: Schema, remark: RemarkParser): Serializer => {
46
+ const state = new this(schema)
47
+ return (content: Node) => {
48
+ state.run(content)
49
+ return state.toString(remark)
50
+ }
51
+ }
52
+
53
+ /// @internal
54
+ constructor(schema: Schema) {
55
+ super()
56
+ this.schema = schema
57
+ }
58
+
59
+ /// @internal
60
+ #matchTarget = (node: Node | Mark): NodeType | MarkType => {
61
+ const result = Object.values({
62
+ ...this.schema.nodes,
63
+ ...this.schema.marks,
64
+ }).find((x): x is NodeType | MarkType => {
65
+ const spec = x.spec as NodeSchema | MarkSchema
66
+ return spec.toMarkdown.match(node as Node & Mark)
67
+ })
68
+
69
+ if (!result) throw serializerMatchError(node.type)
70
+
71
+ return result
72
+ }
73
+
74
+ /// @internal
75
+ #runProseNode = (node: Node) => {
76
+ const type = this.#matchTarget(node)
77
+ const spec = type.spec as NodeSchema
78
+ return spec.toMarkdown.runner(this, node)
79
+ }
80
+
81
+ /// @internal
82
+ #runProseMark = (mark: Mark, node: Node) => {
83
+ const type = this.#matchTarget(mark)
84
+ const spec = type.spec as MarkSchema
85
+ return spec.toMarkdown.runner(this, mark, node)
86
+ }
87
+
88
+ /// @internal
89
+ #runNode = (node: Node) => {
90
+ const { marks } = node
91
+ const getPriority = (x: Mark) => x.type.spec.priority ?? 50
92
+ const tmp = [...marks].sort((a, b) => getPriority(a) - getPriority(b))
93
+ const unPreventNext = tmp.every((mark) => !this.#runProseMark(mark, node))
94
+ if (unPreventNext) this.#runProseNode(node)
95
+
96
+ marks.forEach((mark) => this.#closeMark(mark))
97
+ }
98
+
99
+ /// @internal
100
+ #searchType = (child: MarkdownNode, type: string): MarkdownNode => {
101
+ if (child.type === type) return child
102
+
103
+ if (child.children?.length !== 1) return child
104
+
105
+ const searchNode = (node: MarkdownNode): MarkdownNode | null => {
106
+ if (node.type === type) return node.value != null ? null : node
107
+
108
+ if (node.children?.length !== 1) return null
109
+
110
+ const [firstChild] = node.children
111
+ if (!firstChild) return null
112
+
113
+ return searchNode(firstChild)
114
+ }
115
+
116
+ const target = searchNode(child)
117
+
118
+ if (!target) return child
119
+
120
+ const tmp = target.children ? [...target.children] : undefined
121
+ const node = { ...child, children: tmp }
122
+ node.children = tmp
123
+ target.children = [node]
124
+
125
+ return target
126
+ }
127
+
128
+ /// @internal
129
+ #maybeMergeChildren = (node: MarkdownNode): MarkdownNode => {
130
+ const { children } = node
131
+ if (!children) return node
132
+
133
+ node.children = children.reduce((nextChildren, child, index) => {
134
+ if (index === 0) return [child]
135
+
136
+ const last = nextChildren.at(-1)
137
+ if (last && last.isMark && child.isMark) {
138
+ child = this.#searchType(child, last.type)
139
+ const { children: currChildren, ...currRest } = child
140
+ const { children: prevChildren, ...prevRest } = last
141
+ if (
142
+ child.type === last.type &&
143
+ currChildren &&
144
+ prevChildren &&
145
+ JSON.stringify(currRest) === JSON.stringify(prevRest)
146
+ ) {
147
+ const next = {
148
+ ...prevRest,
149
+ children: [...prevChildren, ...currChildren],
150
+ }
151
+ return nextChildren
152
+ .slice(0, -1)
153
+ .concat(this.#maybeMergeChildren(next))
154
+ }
155
+ }
156
+ return nextChildren.concat(child)
157
+ }, [] as MarkdownNode[])
158
+
159
+ return node
160
+ }
161
+
162
+ /// @internal
163
+ #createMarkdownNode = (element: SerializerStackElement) => {
164
+ const node: MarkdownNode = {
165
+ ...element.props,
166
+ type: element.type,
167
+ }
168
+
169
+ if (element.children) node.children = element.children
170
+
171
+ if (element.value) node.value = element.value
172
+
173
+ return node
174
+ }
175
+
176
+ /// Open a new node, the next operations will
177
+ /// add nodes into that new node until `closeNode` is called.
178
+ openNode = (type: string, value?: string, props?: JSONRecord) => {
179
+ this.open(SerializerStackElement.create(type, undefined, value, props))
180
+ return this
181
+ }
182
+
183
+ #moveSpaces = (
184
+ element: SerializerStackElement,
185
+ onPush: () => MarkdownNode
186
+ ) => {
187
+ let startSpaces = ''
188
+ let endSpaces = ''
189
+ const children = element.children
190
+ let first = -1
191
+ let last = -1
192
+ const findIndex = (node: MarkdownNode[]) => {
193
+ if (!node) return
194
+ node.forEach((child, index) => {
195
+ if (child.type === 'text' && child.value) {
196
+ if (first < 0) first = index
197
+
198
+ last = index
199
+ }
200
+ })
201
+ }
202
+
203
+ if (children) {
204
+ findIndex(children)
205
+ const lastChild = children?.[last] as
206
+ | (MarkdownNode & { value: string })
207
+ | undefined
208
+ const firstChild = children?.[first] as
209
+ | (MarkdownNode & { value: string })
210
+ | undefined
211
+ if (lastChild && lastChild.value.endsWith(' ')) {
212
+ const text = lastChild.value
213
+ const trimmed = text.trimEnd()
214
+ endSpaces = text.slice(trimmed.length)
215
+ lastChild.value = trimmed
216
+ }
217
+ if (firstChild && firstChild.value.startsWith(' ')) {
218
+ const text = firstChild.value
219
+ const trimmed = text.trimStart()
220
+ startSpaces = text.slice(0, text.length - trimmed.length)
221
+ firstChild.value = trimmed
222
+ }
223
+ }
224
+
225
+ if (startSpaces.length) this.#addNodeAndPush('text', undefined, startSpaces)
226
+
227
+ const result = onPush()
228
+
229
+ if (endSpaces.length) this.#addNodeAndPush('text', undefined, endSpaces)
230
+
231
+ return result
232
+ }
233
+
234
+ /// @internal
235
+ #closeNodeAndPush = (trim: boolean = false): MarkdownNode => {
236
+ const element = this.close()
237
+
238
+ const onPush = () =>
239
+ this.#addNodeAndPush(
240
+ element.type,
241
+ element.children,
242
+ element.value,
243
+ element.props
244
+ )
245
+
246
+ if (trim) return this.#moveSpaces(element, onPush)
247
+
248
+ return onPush()
249
+ }
250
+
251
+ /// Close the current node and push it into the parent node.
252
+ closeNode = () => {
253
+ this.#closeNodeAndPush()
254
+ return this
255
+ }
256
+
257
+ /// @internal
258
+ #addNodeAndPush = (
259
+ type: string,
260
+ children?: MarkdownNode[],
261
+ value?: string,
262
+ props?: JSONRecord
263
+ ): MarkdownNode => {
264
+ const element = SerializerStackElement.create(type, children, value, props)
265
+ const node: MarkdownNode = this.#maybeMergeChildren(
266
+ this.#createMarkdownNode(element)
267
+ )
268
+ this.push(node)
269
+ return node
270
+ }
271
+
272
+ /// Add a node into current node.
273
+ addNode = (
274
+ type: string,
275
+ children?: MarkdownNode[],
276
+ value?: string,
277
+ props?: JSONRecord
278
+ ) => {
279
+ this.#addNodeAndPush(type, children, value, props)
280
+ return this
281
+ }
282
+
283
+ /// @internal
284
+ #openMark = (
285
+ mark: Mark,
286
+ type: string,
287
+ value?: string,
288
+ props?: JSONRecord
289
+ ) => {
290
+ const isIn = mark.isInSet(this.#marks)
291
+
292
+ if (isIn) return this
293
+
294
+ this.#marks = mark.addToSet(this.#marks)
295
+ return this.openNode(type, value, { ...props, isMark: true })
296
+ }
297
+
298
+ /// @internal
299
+ #closeMark = (mark: Mark): void => {
300
+ const isIn = mark.isInSet(this.#marks)
301
+
302
+ if (!isIn) return
303
+
304
+ this.#marks = mark.type.removeFromSet(this.#marks)
305
+ this.#closeNodeAndPush(true)
306
+ }
307
+
308
+ /// Open a new mark, the next nodes added will have that mark.
309
+ /// The mark will be closed automatically.
310
+ withMark = (mark: Mark, type: string, value?: string, props?: JSONRecord) => {
311
+ this.#openMark(mark, type, value, props)
312
+ return this
313
+ }
314
+
315
+ /// Close a opened mark.
316
+ /// In most cases you don't need this because
317
+ /// marks will be closed automatically.
318
+ closeMark = (mark: Mark) => {
319
+ this.#closeMark(mark)
320
+ return this
321
+ }
322
+
323
+ /// @internal
324
+ build = (): MarkdownNode => {
325
+ let doc: MarkdownNode | null = null
326
+ do doc = this.#closeNodeAndPush()
327
+ while (this.size())
328
+
329
+ return doc
330
+ }
331
+
332
+ /// Give the node or node list back to the state and
333
+ /// the state will find a proper runner (by `match` method in serializer spec) to handle it.
334
+ next = (nodes: Node | Fragment) => {
335
+ if (isFragment(nodes)) {
336
+ nodes.forEach((node) => {
337
+ this.#runNode(node)
338
+ })
339
+ return this
340
+ }
341
+ this.#runNode(nodes)
342
+ return this
343
+ }
344
+
345
+ /// Use a remark parser to serialize current AST stored.
346
+ override toString = (remark: RemarkParser): string =>
347
+ remark.stringify(this.build() as Root)
348
+
349
+ /// Transform a prosemirror node tree into remark AST.
350
+ run = (tree: Node) => {
351
+ this.next(tree)
352
+
353
+ return this
354
+ }
355
+ }
@@ -0,0 +1,51 @@
1
+ import type { Mark, Node } from '@jvs-milkdown/prose/model'
2
+
3
+ import type { SerializerState } from './state'
4
+
5
+ /// The serializer type which is used to transform prosemirror node into markdown text.
6
+ export type Serializer = (content: Node) => string
7
+
8
+ /// The spec for node serializer in schema.
9
+ export interface NodeSerializerSpec {
10
+ /// The match function to check if the node is the target node.
11
+ /// For example:
12
+ ///
13
+ /// ```typescript
14
+ /// match: (node) => node.type.name === 'paragraph'
15
+ /// ```
16
+ match: (node: Node) => boolean
17
+ /// The runner function to transform the node into markdown text.
18
+ /// Generally, you should call methods in `state` to add node to state.
19
+ /// For example:
20
+ ///
21
+ /// ```typescript
22
+ /// runner: (state, node) => {
23
+ /// state
24
+ /// .openNode(node.type.name)
25
+ /// .next(node.content)
26
+ /// .closeNode();
27
+ /// }
28
+ /// ```
29
+ runner: (state: SerializerState, node: Node) => void
30
+ }
31
+
32
+ /// The spec for mark serializer in schema.
33
+ export interface MarkSerializerSpec {
34
+ /// The match function to check if the node is the target mark.
35
+ /// For example:
36
+ ///
37
+ /// ```typescript
38
+ /// match: (mark) => mark.type.name === 'emphasis'
39
+ /// ```
40
+ match: (mark: Mark) => boolean
41
+ /// The runner function to transform the node into markdown text.
42
+ /// Generally, you should call methods in `state` to add mark to state.
43
+ /// For example:
44
+ ///
45
+ /// ```typescript
46
+ /// runner: (state, mark, node) => {
47
+ /// state.withMark(mark, 'emphasis');
48
+ /// }
49
+ /// ```
50
+ runner: (state: SerializerState, mark: Mark, node: Node) => void | boolean
51
+ }
@@ -0,0 +1,2 @@
1
+ export * from './stack'
2
+ export * from './types'
@@ -0,0 +1,30 @@
1
+ import { expect, it } from 'vitest'
2
+
3
+ import { Stack } from './stack'
4
+
5
+ it('stack', () => {
6
+ const stack = new Stack<number, Array<number>>()
7
+
8
+ expect(stack.size()).toBe(0)
9
+
10
+ stack.open([])
11
+ stack.push(1)
12
+
13
+ expect(stack.size()).toBe(1)
14
+ expect(stack.top()).toEqual([1])
15
+
16
+ stack.open([])
17
+ stack.push(2)
18
+
19
+ expect(stack.size()).toBe(2)
20
+ expect(stack.top()).toEqual([2])
21
+
22
+ stack.push(3)
23
+ const a = stack.close()
24
+ expect(a).toEqual([2, 3])
25
+ expect(stack.size()).toBe(1)
26
+
27
+ const b = stack.close()
28
+ expect(b).toEqual([1])
29
+ expect(stack.size()).toBe(0)
30
+ })
@@ -0,0 +1,48 @@
1
+ import { stackOverFlow } from '@jvs-milkdown/exception'
2
+
3
+ /// The element of the stack, which holds an array of nodes.
4
+ export abstract class StackElement<Node> {
5
+ /// A method that can `push` a node into the element.
6
+ abstract push(node: Node, ...rest: Node[]): void
7
+ }
8
+
9
+ /// The stack that is used to store the elements.
10
+ ///
11
+ /// > Generally, you don't need to use this class directly.
12
+ ///
13
+ /// When using the stack, users can call `stack.open` to push a new element into the stack.
14
+ /// And use `stack.push` to push a node into the top element.
15
+ /// Then use `stack.close` to close the top element and pop it.
16
+ ///
17
+ /// For example: `stack.open(A).push(B).push(C).close()` will generate a structure like `A(B, C)`.
18
+ export class Stack<Node, Element extends StackElement<Node>> {
19
+ protected elements: Element[] = []
20
+
21
+ /// Get the size of the stack.
22
+ size = (): number => {
23
+ return this.elements.length
24
+ }
25
+
26
+ /// Get the top element of the stack.
27
+ top = (): Element | undefined => {
28
+ return this.elements.at(-1)
29
+ }
30
+
31
+ /// Push a node into the top element.
32
+ push = (node: Node): void => {
33
+ this.top()?.push(node)
34
+ }
35
+
36
+ /// Push a new element.
37
+ open = (node: Element): void => {
38
+ this.elements.push(node)
39
+ }
40
+
41
+ /// Close the top element and pop it.
42
+ close = (): Element => {
43
+ const el = this.elements.pop()
44
+ if (!el) throw stackOverFlow()
45
+
46
+ return el
47
+ }
48
+ }
@@ -0,0 +1,63 @@
1
+ import type { MarkSpec, NodeSpec } from '@jvs-milkdown/prose/model'
2
+ import type { remark } from 'remark'
3
+ import type { Plugin, Transformer } from 'unified'
4
+
5
+ import type { MarkParserSpec, NodeParserSpec } from '../parser/types'
6
+ import type {
7
+ MarkSerializerSpec,
8
+ NodeSerializerSpec,
9
+ } from '../serializer/types'
10
+
11
+ /// @internal
12
+ export type Node = Parameters<Transformer>[0]
13
+
14
+ /// @internal
15
+ export type Root = Parameters<(typeof remark)['stringify']>[0]
16
+
17
+ /// @internal
18
+ export type JSONValue =
19
+ | string
20
+ | number
21
+ | boolean
22
+ | null
23
+ | JSONValue[]
24
+ | { [key: string]: JSONValue }
25
+
26
+ /// @internal
27
+ export type JSONRecord = Record<string, JSONValue>
28
+
29
+ /// @internal
30
+ export type RemarkPluginRaw<T> = Plugin<[T], Root>
31
+
32
+ /// The universal type of a [remark plugin](https://github.com/remarkjs/remark/blob/main/doc/plugins.md).
33
+ export interface RemarkPlugin<T = Record<string, unknown>> {
34
+ plugin: Plugin<[T], Root>
35
+ options: T
36
+ }
37
+
38
+ /// The type of [remark instance](https://github.com/remarkjs/remark/tree/main/packages/remark#remark-1).
39
+ export type RemarkParser = ReturnType<typeof remark>
40
+
41
+ /// The universal type of a node in [mdast](https://github.com/syntax-tree/mdast).
42
+ export type MarkdownNode = Node & {
43
+ children?: MarkdownNode[]
44
+ [x: string]: unknown
45
+ }
46
+
47
+ /// Schema spec for node. It is a super set of [NodeSpec](https://prosemirror.net/docs/ref/#model.NodeSpec).
48
+ export interface NodeSchema extends NodeSpec {
49
+ /// To markdown serializer spec.
50
+ readonly toMarkdown: NodeSerializerSpec
51
+ /// Parse markdown serializer spec.
52
+ readonly parseMarkdown: NodeParserSpec
53
+ /// The priority of the node, by default it's 50.
54
+ readonly priority?: number
55
+ }
56
+
57
+ /// Schema spec for mark. It is a super set of [MarkSpec](https://prosemirror.net/docs/ref/#model.MarkSpec).
58
+ export interface MarkSchema extends MarkSpec {
59
+ /// To markdown serializer spec.
60
+ readonly toMarkdown: MarkSerializerSpec
61
+ /// Parse markdown serializer spec.
62
+ readonly parseMarkdown: MarkParserSpec
63
+ }