@milkdown/preset-gfm 7.21.2 → 7.22.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,131 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { defaultValueCtx, Editor, editorViewCtx, rootCtx } from '@milkdown/core'
3
+ import { commonmark } from '@milkdown/preset-commonmark'
4
+ import { expect, it } from 'vitest'
5
+
6
+ import { gfm } from '..'
7
+
8
+ const TABLE = '| aa | bb |\n| --- | --- |\n| cc | dd |\n'
9
+
10
+ async function createEditor(markdown: string) {
11
+ const root = document.createElement('div')
12
+ document.body.appendChild(root)
13
+ const editor = Editor.make()
14
+ editor.config((ctx) => {
15
+ ctx.set(rootCtx, root)
16
+ ctx.set(defaultValueCtx, markdown)
17
+ })
18
+ editor.use(commonmark)
19
+ editor.use(gfm)
20
+ await editor.create()
21
+ return editor
22
+ }
23
+
24
+ const view = (editor: Editor) => editor.ctx.get(editorViewCtx)
25
+
26
+ function posOf(editor: Editor, typeName: string, nth = 0) {
27
+ let seen = 0
28
+ let pos = -1
29
+ view(editor).state.doc.descendants((node, p) => {
30
+ if (node.type.name === typeName && seen++ === nth && pos < 0) pos = p
31
+ return true
32
+ })
33
+ return pos
34
+ }
35
+
36
+ // Every `alignment` attr in column `col`, header row first.
37
+ function columnAlignments(editor: Editor, col: number) {
38
+ const out: (string | null)[] = []
39
+ view(editor).state.doc.descendants((node) => {
40
+ if (/^table.*row$/.test(node.type.name))
41
+ out.push(node.child(col)?.attrs.alignment ?? null)
42
+ return true
43
+ })
44
+ return out
45
+ }
46
+
47
+ it('should propagate a header cell alignment to the body cells below it', async () => {
48
+ const editor = await createEditor(TABLE)
49
+ const headerPos = posOf(editor, 'table_header')
50
+ const header = view(editor).state.doc.nodeAt(headerPos)!
51
+
52
+ view(editor).dispatch(
53
+ view(editor).state.tr.setNodeMarkup(headerPos, undefined, {
54
+ ...header.attrs,
55
+ alignment: 'center',
56
+ })
57
+ )
58
+
59
+ expect(columnAlignments(editor, 0)).toEqual(['center', 'center'])
60
+ })
61
+
62
+ it('should propagate down the header cell own column, not the first one', async () => {
63
+ const editor = await createEditor(
64
+ '| aa | bb | cc |\n| --- | --- | --- |\n| dd | ee | ff |\n'
65
+ )
66
+ const headerPos = posOf(editor, 'table_header', 1)
67
+ const header = view(editor).state.doc.nodeAt(headerPos)!
68
+
69
+ view(editor).dispatch(
70
+ view(editor).state.tr.setNodeMarkup(headerPos, undefined, {
71
+ ...header.attrs,
72
+ alignment: 'right',
73
+ })
74
+ )
75
+
76
+ expect(columnAlignments(editor, 1)).toEqual(['right', 'right'])
77
+ expect(columnAlignments(editor, 0)).toEqual([null, null])
78
+ expect(columnAlignments(editor, 2)).toEqual([null, null])
79
+ })
80
+
81
+ it('should still propagate when the same transaction also inserts text', async () => {
82
+ // "Some text changed" must not be read as "nothing structural happened".
83
+ const editor = await createEditor(TABLE)
84
+ const headerPos = posOf(editor, 'table_header')
85
+ const bodyPos = posOf(editor, 'table_cell')
86
+ const header = view(editor).state.doc.nodeAt(headerPos)!
87
+
88
+ view(editor).dispatch(
89
+ view(editor)
90
+ .state.tr.insertText('zz', bodyPos + 2)
91
+ .setNodeMarkup(headerPos, undefined, {
92
+ ...header.attrs,
93
+ alignment: 'right',
94
+ })
95
+ )
96
+
97
+ expect(columnAlignments(editor, 0)).toEqual(['right', 'right'])
98
+ })
99
+
100
+ it('should not append a transaction when no cell needs re-aligning', async () => {
101
+ const editor = await createEditor(`${TABLE}\npara\n`)
102
+ const state = view(editor).state
103
+ const tr = state.tr.insertText('x', posOf(editor, 'table_cell') + 2)
104
+
105
+ expect(state.applyTransaction(tr).transactions).toHaveLength(1)
106
+ })
107
+
108
+ it('should not append a transaction for a structural change to a consistent table', async () => {
109
+ // A structural change reaches the walk, which then finds nothing to realign.
110
+ const editor = await createEditor(
111
+ '| aa | bb |\n| :---: | ---: |\n| cc | dd |\n\npara\n'
112
+ )
113
+ const state = view(editor).state
114
+ const tr = state.tr.replaceWith(
115
+ state.doc.content.size,
116
+ state.doc.content.size,
117
+ state.schema.nodes.paragraph!.create()
118
+ )
119
+
120
+ expect(tr.docChanged).toBe(true)
121
+ expect(state.applyTransaction(tr).transactions).toHaveLength(1)
122
+ })
123
+
124
+ it('should leave a parsed table internally consistent', async () => {
125
+ const editor = await createEditor(
126
+ '| aa | bb |\n| :--- | ---: |\n| cc | dd |\n'
127
+ )
128
+
129
+ expect(columnAlignments(editor, 0)).toEqual(['left', 'left'])
130
+ expect(columnAlignments(editor, 1)).toEqual(['right', 'right'])
131
+ })
@@ -0,0 +1,121 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import { Editor, editorViewCtx } from '@milkdown/core'
3
+ import { commonmark } from '@milkdown/preset-commonmark'
4
+ import { getMarkdown } from '@milkdown/utils'
5
+ import userEvent from '@testing-library/user-event'
6
+ import { describe, expect, it } from 'vitest'
7
+
8
+ import { gfm } from '..'
9
+
10
+ // https://github.com/Milkdown/milkdown/issues/2400
11
+ // The strikethrough input rule shares the same flaw as the underscore-emphasis
12
+ // rule: its regex was not anchored to the end of the input (`$`), so pasting
13
+ // text that ends with `~` twice and then pressing Space/Enter matched a `~...~`
14
+ // span across the two pastes and corrupted the document.
15
+
16
+ const PASTED = 'qa34 %~'
17
+
18
+ async function createEditor() {
19
+ const editor = Editor.make()
20
+ editor.use(commonmark)
21
+ editor.use(gfm)
22
+ await editor.create()
23
+ return editor
24
+ }
25
+
26
+ function hasStrikethrough(editor: Editor) {
27
+ const { doc } = editor.ctx.get(editorViewCtx).state
28
+ let found = false
29
+ doc.descendants((node) => {
30
+ if (node.marks.some((mark) => mark.type.name === 'strike_through'))
31
+ found = true
32
+ })
33
+ return found
34
+ }
35
+
36
+ function paste(editor: Editor, text: string) {
37
+ const view = editor.ctx.get(editorViewCtx)
38
+ view.dispatch(view.state.tr.insertText(text))
39
+ }
40
+
41
+ describe('pasting special characters — strikethrough (#2400)', () => {
42
+ it('does not delete characters when pressing Space after two pastes', async () => {
43
+ const editor = await createEditor()
44
+ const view = editor.ctx.get(editorViewCtx)
45
+
46
+ paste(editor, PASTED)
47
+ paste(editor, PASTED)
48
+
49
+ const before = view.state.doc.textContent
50
+ const end = view.state.selection.from
51
+
52
+ // The trailing `deflt` callback is required by the prop type but unused
53
+ // by the input-rule plugin.
54
+ view.someProp('handleTextInput', (f) =>
55
+ f(view, end, end, ' ', () => view.state.tr)
56
+ )
57
+
58
+ expect(view.state.doc.textContent).toBe(before)
59
+ expect(hasStrikethrough(editor)).toBe(false)
60
+ })
61
+
62
+ it('does not delete characters when pressing Enter after two pastes', async () => {
63
+ const editor = await createEditor()
64
+ const view = editor.ctx.get(editorViewCtx)
65
+
66
+ paste(editor, PASTED)
67
+ paste(editor, PASTED)
68
+
69
+ const before = view.state.doc.textContent
70
+
71
+ const event = new KeyboardEvent('keydown', { key: 'Enter' })
72
+ view.someProp('handleKeyDown', (f) => f(view, event))
73
+
74
+ expect(view.state.doc.textContent).toBe(before)
75
+ expect(hasStrikethrough(editor)).toBe(false)
76
+ })
77
+
78
+ it('still turns ~~word~~ into strikethrough when the closing ~ is typed', async () => {
79
+ const editor = await createEditor()
80
+ const view = editor.ctx.get(editorViewCtx)
81
+
82
+ paste(editor, '~~word~')
83
+
84
+ const end = view.state.selection.from
85
+ view.someProp('handleTextInput', (f) =>
86
+ f(view, end, end, '~', () => view.state.tr)
87
+ )
88
+
89
+ expect(view.state.doc.textContent).toBe('word')
90
+ expect(hasStrikethrough(editor)).toBe(true)
91
+ })
92
+ })
93
+
94
+ // The `$` anchor added to the strikethrough rule must not regress typing the
95
+ // markdown syntax by hand: these mirror the input-rule e2e tests.
96
+ describe('typing strikethrough still works', () => {
97
+ it('creates strikethrough when typing ~~on the grass~~', async () => {
98
+ const user = userEvent.setup()
99
+ const editor = await createEditor()
100
+ const view = editor.ctx.get(editorViewCtx)
101
+
102
+ await user.type(view.dom, 'The lunatic is ~~on the grass~~')
103
+
104
+ expect(hasStrikethrough(editor)).toBe(true)
105
+ })
106
+
107
+ it('does not create strikethrough for intra-word tildes', async () => {
108
+ const user = userEvent.setup()
109
+ const editor = await createEditor()
110
+
111
+ await user.type(
112
+ editor.ctx.get(editorViewCtx).dom,
113
+ 'C:/the/~lunatic~/is/on/the/grass'
114
+ )
115
+
116
+ expect(hasStrikethrough(editor)).toBe(false)
117
+ expect(editor.action(getMarkdown())).toBe(
118
+ 'C:/the/\\~lunatic\\~/is/on/the/grass\n'
119
+ )
120
+ })
121
+ })
@@ -0,0 +1,70 @@
1
+ import '@testing-library/jest-dom/vitest'
2
+ import {
3
+ defaultValueCtx,
4
+ Editor,
5
+ editorViewCtx,
6
+ schemaCtx,
7
+ } from '@milkdown/core'
8
+ import { commonmark } from '@milkdown/preset-commonmark'
9
+ import { getMarkdown } from '@milkdown/utils'
10
+ import { describe, expect, it } from 'vitest'
11
+
12
+ import { gfm } from '..'
13
+
14
+ async function createEditor(defaultValue: string) {
15
+ const editor = Editor.make()
16
+ editor.config((ctx) => {
17
+ ctx.set(defaultValueCtx, defaultValue)
18
+ })
19
+ editor.use(commonmark)
20
+ editor.use(gfm)
21
+ await editor.create()
22
+ return editor
23
+ }
24
+
25
+ async function roundTrip(markdown: string) {
26
+ const editor = await createEditor(markdown)
27
+ return editor.action(getMarkdown())
28
+ }
29
+
30
+ // https://github.com/Milkdown/milkdown/issues/2419
31
+ // Tight task lists must round-trip as tight, and the checkbox syntax
32
+ // (`- [ ]` / `- [x]`) must survive. The list_item toMarkdown fix must be
33
+ // applied *in place*: preset-gfm delegates unchecked items to the base
34
+ // list_item runner, so replacing the whole runner would drop checkboxes.
35
+ describe('task list spread round-trip (#2419)', () => {
36
+ // The serializer normalizes bullet markers to `*`, so the inputs use `*`
37
+ // to keep the assertions about spread (blank lines) and the surviving
38
+ // checkbox rather than the marker character.
39
+ const cases = [
40
+ '* [ ] a\n* [x] b\n',
41
+ '* [ ] a\n * [ ] b\n* [x] c\n',
42
+ '* [ ] a\n\n* [x] b\n',
43
+ ]
44
+
45
+ cases.forEach((markdown) => {
46
+ it(`should round-trip ${JSON.stringify(markdown)}`, async () => {
47
+ const output = await roundTrip(markdown)
48
+ expect(output).toBe(markdown)
49
+ })
50
+ })
51
+
52
+ it('should keep the checkbox on a tight unchecked task item', async () => {
53
+ const output = await roundTrip('* [ ] todo\n')
54
+ expect(output).toBe('* [ ] todo\n')
55
+ expect(output).not.toBe('* todo\n')
56
+ })
57
+
58
+ // The `spread` attribute must be a real boolean so schema.nodeFromJSON()
59
+ // does not throw on documents containing task lists.
60
+ cases.forEach((markdown) => {
61
+ it(`should round-trip through nodeFromJSON for ${JSON.stringify(markdown)}`, async () => {
62
+ const editor = await createEditor(markdown)
63
+ const doc = editor.ctx.get(editorViewCtx).state.doc
64
+ const schema = editor.ctx.get(schemaCtx)
65
+ const json = doc.toJSON()
66
+ expect(() => schema.nodeFromJSON(json)).not.toThrow()
67
+ expect(schema.nodeFromJSON(json).eq(doc)).toBe(true)
68
+ })
69
+ })
70
+ })
@@ -71,7 +71,7 @@ withMeta(toggleStrikethroughCommand, {
71
71
  /// Input rule to create the strikethrough mark.
72
72
  export const strikethroughInputRule = $inputRule((ctx) => {
73
73
  return markRule(
74
- /(?<![\w:/])(~{1,2})(.+?)\1(?!\w|\/)/,
74
+ /(?<![\w:/])(~{1,2})(.+?)\1(?!\w|\/)$/,
75
75
  strikethroughSchema.type(ctx)
76
76
  )
77
77
  })
@@ -28,7 +28,7 @@ export const extendListItemSchemaForTask = listItemSchema.extendSchema(
28
28
  return {
29
29
  label: dom.dataset.label,
30
30
  listType: dom.dataset.listType,
31
- spread: dom.dataset.spread,
31
+ spread: dom.dataset.spread === 'true',
32
32
  checked: dom.dataset.checked
33
33
  ? dom.dataset.checked === 'true'
34
34
  : null,
@@ -64,7 +64,7 @@ export const extendListItemSchemaForTask = listItemSchema.extendSchema(
64
64
  const label = node.label != null ? `${node.label}.` : '•'
65
65
  const checked = node.checked != null ? Boolean(node.checked) : null
66
66
  const listType = node.label != null ? 'ordered' : 'bullet'
67
- const spread = node.spread != null ? `${node.spread}` : 'true'
67
+ const spread = node.spread ?? true
68
68
 
69
69
  state.openNode(type, { label, listType, spread, checked })
70
70
  state.next(node.children)
@@ -81,7 +81,7 @@ export const extendListItemSchemaForTask = listItemSchema.extendSchema(
81
81
 
82
82
  const label = node.attrs.label
83
83
  const listType = node.attrs.listType
84
- const spread = node.attrs.spread === 'true'
84
+ const spread = node.attrs.spread
85
85
  const checked = node.attrs.checked
86
86
 
87
87
  state.openNode('listItem', undefined, {
@@ -16,14 +16,31 @@ function getChildIndex(node: Node, parent: Node) {
16
16
  return index
17
17
  }
18
18
 
19
+ // The span that differs between the two docs, in `next` coordinates, or `null`
20
+ // when they are value-identical. Both scans compare children by reference
21
+ // before looking inside them.
22
+ function changedRange(prev: Node, next: Node) {
23
+ const from = prev.content.findDiffStart(next.content)
24
+ if (from == null) return null
25
+
26
+ const diff = prev.content.findDiffEnd(next.content)
27
+ if (!diff) return null
28
+
29
+ // Repeated content ("aa" -> "aaa") lets the end scan overrun the start.
30
+ return { from, to: Math.max(diff.b, from) }
31
+ }
32
+
19
33
  export const keepTableAlignPlugin = $prose(() => {
20
34
  return new Plugin({
21
35
  key: pluginKey,
22
36
  appendTransaction: (_tr, oldState, state) => {
37
+ if (oldState.doc === state.doc) return
38
+
39
+ const range = changedRange(oldState.doc, state.doc)
40
+ if (!range) return
41
+
23
42
  let tr: Transaction | undefined
24
43
  const check = (node: Node, pos: number) => {
25
- if (!tr) tr = state.tr
26
-
27
44
  if (node.type.name !== 'table_cell') return
28
45
 
29
46
  const $pos = state.doc.resolve(pos)
@@ -40,9 +57,22 @@ export const keepTableAlignPlugin = $prose(() => {
40
57
  const currentAlign = node.attrs.alignment
41
58
  if (align === currentAlign) return
42
59
 
60
+ // Creating `tr` any earlier appends an empty transaction to every
61
+ // document change.
62
+ if (!tr) tr = state.tr
43
63
  tr.setNodeMarkup(pos, undefined, { ...node.attrs, alignment: align })
44
64
  }
45
- if (oldState.doc !== state.doc) state.doc.descendants(check)
65
+
66
+ // `nodesBetween` reports the range's ancestors, so an edit deep inside a
67
+ // cell still reaches its table. Each table it finds is checked in full,
68
+ // because a header cell reaches body cells outside the range.
69
+ state.doc.nodesBetween(range.from, range.to, (node, pos) => {
70
+ if (node.type.name !== 'table') return true
71
+
72
+ state.doc.nodesBetween(pos, pos + node.nodeSize, check)
73
+ // Tables do not nest.
74
+ return false
75
+ })
46
76
 
47
77
  return tr
48
78
  },