Skip to content

Commit 0500b70

Browse files
Merge pull request #8562 from nextcloud/feat/wiki_links
feat: add support for wiki-style link and image Markdown syntax
2 parents 7779968 + 376801b commit 0500b70

11 files changed

Lines changed: 1881 additions & 1567 deletions

File tree

src/markdownit/index.js

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

2324
const markdownit = MarkdownIt('commonmark', { html: false, breaks: false })
2425
.enable('strikethrough')
@@ -33,6 +34,7 @@ const markdownit = MarkdownIt('commonmark', { html: false, breaks: false })
3334
.use(preview)
3435
.use(keepSyntax)
3536
.use(markdownitMentions)
37+
.use(wikiLinks)
3638
.use(implicitFigures)
3739
.use(mark)
3840
.use(mathematics)

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.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import type { ExtendedRegExpMatchArray } from '@tiptap/core'
77
import { getMarkRange, isMarkActive, markInputRule } from '@tiptap/core'
88
import type { LinkOptions } from '@tiptap/extension-link'
99
import TipTapLink, { isAllowedUri } from '@tiptap/extension-link'
10+
import type { Mark, Node } from '@tiptap/pm/model'
11+
import type { MarkdownSerializerState } from 'prosemirror-markdown'
12+
import { defaultMarkdownSerializer } from 'prosemirror-markdown'
1013
import { domHref, parseHref } from '../helpers/links.js'
1114
import { linkClicking } from '../plugins/links.js'
1215

@@ -107,6 +110,13 @@ const Link = TipTapLink.extend<RelativePathLinkOptions>({
107110
title: {
108111
default: null,
109112
},
113+
isWikiLink: {
114+
default: false,
115+
parseHTML: (element) =>
116+
element.getAttribute('data-wiki-link') === 'true',
117+
renderHTML: (attrs) =>
118+
attrs.isWikiLink ? { 'data-wiki-link': 'true' } : {},
119+
},
110120
}
111121
},
112122

@@ -239,6 +249,51 @@ const Link = TipTapLink.extend<RelativePathLinkOptions>({
239249
// Add our own click handler plugin
240250
return [...plugins, linkClicking()]
241251
},
252+
253+
// @ts-expect-error - toMarkdown is a custom field not part of the official Tiptap API
254+
toMarkdown: {
255+
open(
256+
state: MarkdownSerializerState,
257+
mark: Mark,
258+
parent: Node,
259+
index: number,
260+
) {
261+
if (!mark.attrs.isWikiLink) {
262+
const open = defaultMarkdownSerializer.marks.link.open
263+
return typeof open === 'function'
264+
? open(state, mark, parent, index)
265+
: open
266+
}
267+
const href = mark.attrs.href as string
268+
// Collect the display text of this mark's span to decide the form
269+
let innerText = ''
270+
parent.descendants((child) => {
271+
if (!mark.isInSet(child.marks)) {
272+
return false
273+
}
274+
if (child.isText) {
275+
innerText += child.text
276+
}
277+
})
278+
return innerText === href ? `[[` : `[[${href}|`
279+
},
280+
close(
281+
state: MarkdownSerializerState,
282+
mark: Mark,
283+
_parent: Node,
284+
_index: number,
285+
) {
286+
if (!mark.attrs.isWikiLink) {
287+
const close = defaultMarkdownSerializer.marks.link.close
288+
return typeof close === 'function'
289+
? close(state, mark, _parent, _index)
290+
: close
291+
}
292+
return ']]'
293+
},
294+
mixable: true,
295+
expelEnclosingWhitespace: false,
296+
},
242297
})
243298

244299
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)