Skip to content

Commit c44a814

Browse files
authored
Merge pull request #8576 from nextcloud/backport/8562/stable33
[stable33] feat: add support for wiki-style link and image Markdown syntax
2 parents d5f10ac + 95a3130 commit c44a814

11 files changed

Lines changed: 1869 additions & 1567 deletions

File tree

src/markdownit/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import preview from './preview.js'
1818
import splitMixedLists from './splitMixedLists.js'
1919
import taskLists from './taskLists.ts'
2020
import underline from './underline.js'
21+
import wikiLinks from './wikiLinks.ts'
2122

2223
const markdownit = MarkdownIt('commonmark', { html: false, breaks: false })
2324
.enable('strikethrough')
@@ -32,6 +33,7 @@ const markdownit = MarkdownIt('commonmark', { html: false, breaks: false })
3233
.use(preview)
3334
.use(keepSyntax)
3435
.use(markdownitMentions)
36+
.use(wikiLinks)
3537
.use(implicitFigures)
3638
.use(mathematics)
3739
.use(multimdTable, {

src/markdownit/wikiLinks.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type MarkdownIt from 'markdown-it'
7+
8+
/**
9+
* markdown-it plugin: parse Obsidian-style wiki links and wiki image links
10+
*
11+
* Produces
12+
* Produces a standard `link` or `image` token so that downstream plugins
13+
* treat it exactly like a normal link/image.
14+
*
15+
* A `data-wiki-image` or `data-wiki-link` attribute is set so the ProseMirror
16+
* serializer can round-trip the syntax back to the wiki style link/image syntax.
17+
*
18+
* @param md - The markdown-it instance to extend
19+
*/
20+
export default function wikiLinks(md: MarkdownIt): void {
21+
// Parse wiki image links `![[filename]]`
22+
md.inline.ruler.before('image', 'wiki_image_link', (state, silent) => {
23+
const src = state.src
24+
const pos = state.pos
25+
26+
// Must start with ![[
27+
if (src.charCodeAt(pos) !== 0x21 /* ! */) return false
28+
if (src.charCodeAt(pos + 1) !== 0x5b /* [ */) return false
29+
if (src.charCodeAt(pos + 2) !== 0x5b /* [ */) return false
30+
31+
// Find the closing ]] — no newlines allowed inside
32+
let end = pos + 3
33+
while (end < src.length) {
34+
const ch = src.charCodeAt(end)
35+
if (ch === 0x0a /* \n */) return false
36+
if (ch === 0x5d /* ] */ && src.charCodeAt(end + 1) === 0x5d /* ] */)
37+
break
38+
end++
39+
}
40+
if (end >= src.length) return false
41+
42+
const filename = src.slice(pos + 3, end)
43+
if (!filename) return false
44+
45+
if (silent) return true
46+
47+
const token = state.push('image', 'img', 0)
48+
token.attrs = [
49+
['src', filename],
50+
['alt', ''],
51+
['data-wiki-image', 'true'],
52+
]
53+
// Alt text is derived from token.children by the renderer
54+
const altToken = new state.Token('text', '', 0)
55+
altToken.content = filename
56+
token.children = [altToken]
57+
token.content = filename
58+
59+
state.pos = end + 2
60+
return true
61+
})
62+
63+
// Parse wiki links `[[link]]`
64+
md.inline.ruler.before('link', 'wiki_link', (state, silent) => {
65+
const src = state.src
66+
const pos = state.pos
67+
68+
// Must start with [[
69+
if (src.charCodeAt(pos) !== 0x5b /* [ */) return false
70+
if (src.charCodeAt(pos + 1) !== 0x5b /* [ */) return false
71+
72+
// Prevent matching `[[` that is itself preceded by `[` (e.g. `[[[foo]]]`)
73+
if (pos > 0 && src.charCodeAt(pos - 1) === 0x5b /* [ */) return false
74+
75+
// Find the closing ]] — no newlines allowed inside
76+
let end = pos + 2
77+
while (end < src.length) {
78+
const ch = src.charCodeAt(end)
79+
if (ch === 0x0a /* \n */) return false
80+
if (ch === 0x5d /* ] */ && src.charCodeAt(end + 1) === 0x5d /* ] */)
81+
break
82+
end++
83+
}
84+
if (end >= src.length) return false
85+
86+
const content = src.slice(pos + 2, end)
87+
if (!content) return false
88+
89+
// Split on first | to get target and optional display text
90+
const pipeIdx = content.indexOf('|')
91+
const target = pipeIdx === -1 ? content : content.slice(0, pipeIdx)
92+
const displayText = pipeIdx === -1 ? content : content.slice(pipeIdx + 1)
93+
94+
if (!target) return false
95+
96+
// Reject targets containing characters that conflict with CommonMark inline syntax
97+
// ([, ], * are not valid in file names on most systems anyway)
98+
if (/[[\]*]/.test(target)) return false
99+
100+
if (silent) return true
101+
102+
const tokenOpen = state.push('link_open', 'a', 1)
103+
tokenOpen.attrs = [
104+
['href', target],
105+
['data-wiki-link', 'true'],
106+
]
107+
108+
const tokenText = state.push('text', '', 0)
109+
tokenText.content = displayText
110+
111+
state.push('link_close', 'a', -1)
112+
113+
state.pos = end + 2
114+
return true
115+
})
116+
}

src/marks/Link.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import { getMarkRange, isMarkActive, markInputRule } from '@tiptap/core'
77
import TipTapLink from '@tiptap/extension-link'
8+
import { defaultMarkdownSerializer } from 'prosemirror-markdown'
89
import { linkClicking } from '../plugins/links.js'
910
import { domHref, parseHref } from './../helpers/links.js'
1011

@@ -42,6 +43,13 @@ const Link = TipTapLink.extend({
4243
title: {
4344
default: null,
4445
},
46+
isWikiLink: {
47+
default: false,
48+
parseHTML: (element) =>
49+
element.getAttribute('data-wiki-link') === 'true',
50+
renderHTML: (attrs) =>
51+
attrs.isWikiLink ? { 'data-wiki-link': 'true' } : {},
52+
},
4553
}
4654
},
4755

@@ -176,6 +184,41 @@ const Link = TipTapLink.extend({
176184
// Custom click handler plugins
177185
return [...plugins, linkClicking()]
178186
},
187+
188+
// @ts-expect-error - toMarkdown is a custom field not part of the official Tiptap API
189+
toMarkdown: {
190+
open(state, mark, parent, index) {
191+
if (!mark.attrs.isWikiLink) {
192+
const open = defaultMarkdownSerializer.marks.link.open
193+
return typeof open === 'function'
194+
? open(state, mark, parent, index)
195+
: open
196+
}
197+
const href = mark.attrs.href
198+
// Collect the display text of this mark's span to decide the form
199+
let innerText = ''
200+
parent.descendants((child) => {
201+
if (!mark.isInSet(child.marks)) {
202+
return false
203+
}
204+
if (child.isText) {
205+
innerText += child.text
206+
}
207+
})
208+
return innerText === href ? `[[` : `[[${href}|`
209+
},
210+
close(state, mark, _parent, _index) {
211+
if (!mark.attrs.isWikiLink) {
212+
const close = defaultMarkdownSerializer.marks.link.close
213+
return typeof close === 'function'
214+
? close(state, mark, _parent, _index)
215+
: close
216+
}
217+
return ']]'
218+
},
219+
mixable: true,
220+
expelEnclosingWhitespace: false,
221+
},
179222
})
180223

181224
export default Link

src/nodes/Image.js

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,19 @@ const imageExtractAttachmentsKey = new PluginKey('imageExtractAttachments')
1717
const Image = TiptapImage.extend({
1818
selectable: false,
1919

20+
addAttributes() {
21+
return {
22+
...this.parent?.(),
23+
isWikiLink: {
24+
default: false,
25+
parseHTML: (element) =>
26+
element.getAttribute('data-wiki-image') === 'true',
27+
renderHTML: (attrs) =>
28+
attrs.isWikiLink ? { 'data-wiki-image': 'true' } : {},
29+
},
30+
}
31+
},
32+
2033
parseHTML() {
2134
return [
2235
{
@@ -118,8 +131,12 @@ const Image = TiptapImage.extend({
118131

119132
/* Serializes an image node as a block image, so it ensures an image is always a block by itself */
120133
toMarkdown(state, node, parent, index) {
121-
node.attrs.alt = node.attrs.alt.toString()
122-
defaultMarkdownSerializer.nodes.image(state, node, parent, index)
134+
if (node.attrs.isWikiLink) {
135+
state.write(`![[${node.attrs.src}]]`)
136+
} else {
137+
node.attrs.alt = node.attrs.alt.toString()
138+
defaultMarkdownSerializer.nodes.image(state, node, parent, index)
139+
}
123140
state.closeBlock(node)
124141
},
125142
})

src/nodes/ImageInline.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ const ImageInline = TiptapImage.extend({
1818

1919
selectable: false,
2020

21+
addAttributes() {
22+
return {
23+
...this.parent?.(),
24+
isWikiLink: {
25+
default: false,
26+
parseHTML: (element) =>
27+
element.getAttribute('data-wiki-image') === 'true',
28+
renderHTML: (attrs) =>
29+
attrs.isWikiLink ? { 'data-wiki-image': 'true' } : {},
30+
},
31+
}
32+
},
33+
2134
parseHTML() {
2235
return [
2336
{
@@ -50,7 +63,11 @@ const ImageInline = TiptapImage.extend({
5063
},
5164

5265
toMarkdown(state, node, parent, index) {
53-
return defaultMarkdownSerializer.nodes.image(state, node, parent, index)
66+
if (node.attrs.isWikiLink) {
67+
state.write(`![[${node.attrs.src}]]`)
68+
} else {
69+
return defaultMarkdownSerializer.nodes.image(state, node, parent, index)
70+
}
5471
},
5572
})
5673

src/tests/extensions/Markdown.spec.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,26 @@ describe('Markdown extension integrated in the editor', () => {
211211
expect(text).toBe('Hello\n\nexample@example.com')
212212
editor.destroy()
213213
})
214+
215+
it('serializes a wiki text link as [[target]]', () => {
216+
const editor = createCustomEditor(
217+
'<p><a href="WikiLink" data-wiki-link="true">WikiLink</a></p>',
218+
[Markdown, Link],
219+
)
220+
const serializer = createMarkdownSerializer(editor.schema)
221+
expect(serializer.serialize(editor.state.doc)).toBe('[[WikiLink]]')
222+
editor.destroy()
223+
})
224+
225+
it('serializes a wiki text link with display text as [[target|display]]', () => {
226+
const editor = createCustomEditor(
227+
'<p><a href="target" data-wiki-link="true">display</a></p>',
228+
[Markdown, Link],
229+
)
230+
const serializer = createMarkdownSerializer(editor.schema)
231+
expect(serializer.serialize(editor.state.doc)).toBe('[[target|display]]')
232+
editor.destroy()
233+
})
214234
})
215235

216236
const copyEditorContent = (editor, nodeType = null) => {

0 commit comments

Comments
 (0)