Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/markdownit/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import preview from './preview.js'
import splitMixedLists from './splitMixedLists.js'
import taskLists from './taskLists.ts'
import underline from './underline.js'
import wikiLinks from './wikiLinks.ts'

const markdownit = MarkdownIt('commonmark', { html: false, breaks: false })
.enable('strikethrough')
Expand All @@ -33,6 +34,7 @@ const markdownit = MarkdownIt('commonmark', { html: false, breaks: false })
.use(preview)
.use(keepSyntax)
.use(markdownitMentions)
.use(wikiLinks)
.use(implicitFigures)
.use(mark)
.use(mathematics)
Expand Down
116 changes: 116 additions & 0 deletions src/markdownit/wikiLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type MarkdownIt from 'markdown-it'

/**
* markdown-it plugin: parse Obsidian-style wiki links and wiki image links
*
* Produces
* Produces a standard `link` or `image` token so that downstream plugins
* treat it exactly like a normal link/image.
*
* A `data-wiki-image` or `data-wiki-link` attribute is set so the ProseMirror
* serializer can round-trip the syntax back to the wiki style link/image syntax.
*
* @param md - The markdown-it instance to extend
*/
export default function wikiLinks(md: MarkdownIt): void {
// Parse wiki image links `![[filename]]`
md.inline.ruler.before('image', 'wiki_image_link', (state, silent) => {
const src = state.src
const pos = state.pos

// Must start with ![[
if (src.charCodeAt(pos) !== 0x21 /* ! */) return false
if (src.charCodeAt(pos + 1) !== 0x5b /* [ */) return false
if (src.charCodeAt(pos + 2) !== 0x5b /* [ */) return false

// Find the closing ]] — no newlines allowed inside
let end = pos + 3
while (end < src.length) {
const ch = src.charCodeAt(end)
if (ch === 0x0a /* \n */) return false
if (ch === 0x5d /* ] */ && src.charCodeAt(end + 1) === 0x5d /* ] */)
break
end++
}
if (end >= src.length) return false

const filename = src.slice(pos + 3, end)
if (!filename) return false

if (silent) return true

const token = state.push('image', 'img', 0)
token.attrs = [
['src', filename],
['alt', ''],
['data-wiki-image', 'true'],
]
// Alt text is derived from token.children by the renderer
const altToken = new state.Token('text', '', 0)
altToken.content = filename
token.children = [altToken]
token.content = filename

state.pos = end + 2
return true
})

// Parse wiki links `[[link]]`
md.inline.ruler.before('link', 'wiki_link', (state, silent) => {
const src = state.src
const pos = state.pos

// Must start with [[
if (src.charCodeAt(pos) !== 0x5b /* [ */) return false
if (src.charCodeAt(pos + 1) !== 0x5b /* [ */) return false

// Prevent matching `[[` that is itself preceded by `[` (e.g. `[[[foo]]]`)
if (pos > 0 && src.charCodeAt(pos - 1) === 0x5b /* [ */) return false

// Find the closing ]] — no newlines allowed inside
let end = pos + 2
while (end < src.length) {
const ch = src.charCodeAt(end)
if (ch === 0x0a /* \n */) return false
if (ch === 0x5d /* ] */ && src.charCodeAt(end + 1) === 0x5d /* ] */)
break
end++
}
if (end >= src.length) return false

const content = src.slice(pos + 2, end)
if (!content) return false

// Split on first | to get target and optional display text
const pipeIdx = content.indexOf('|')
const target = pipeIdx === -1 ? content : content.slice(0, pipeIdx)
const displayText = pipeIdx === -1 ? content : content.slice(pipeIdx + 1)

if (!target) return false

// Reject targets containing characters that conflict with CommonMark inline syntax
// ([, ], * are not valid in file names on most systems anyway)
if (/[[\]*]/.test(target)) return false

if (silent) return true

const tokenOpen = state.push('link_open', 'a', 1)
tokenOpen.attrs = [
['href', target],
['data-wiki-link', 'true'],
]

const tokenText = state.push('text', '', 0)
tokenText.content = displayText

state.push('link_close', 'a', -1)

state.pos = end + 2
return true
})
}
55 changes: 55 additions & 0 deletions src/marks/Link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import type { ExtendedRegExpMatchArray } from '@tiptap/core'
import { getMarkRange, isMarkActive, markInputRule } from '@tiptap/core'
import type { LinkOptions } from '@tiptap/extension-link'
import TipTapLink, { isAllowedUri } from '@tiptap/extension-link'
import type { Mark, Node } from '@tiptap/pm/model'
import type { MarkdownSerializerState } from 'prosemirror-markdown'
import { defaultMarkdownSerializer } from 'prosemirror-markdown'
import { domHref, parseHref } from '../helpers/links.js'
import { linkClicking } from '../plugins/links.js'

Expand Down Expand Up @@ -107,6 +110,13 @@ const Link = TipTapLink.extend<RelativePathLinkOptions>({
title: {
default: null,
},
isWikiLink: {
default: false,
parseHTML: (element) =>
element.getAttribute('data-wiki-link') === 'true',
renderHTML: (attrs) =>
attrs.isWikiLink ? { 'data-wiki-link': 'true' } : {},
},
}
},

Expand Down Expand Up @@ -239,6 +249,51 @@ const Link = TipTapLink.extend<RelativePathLinkOptions>({
// Add our own click handler plugin
return [...plugins, linkClicking()]
},

// @ts-expect-error - toMarkdown is a custom field not part of the official Tiptap API
toMarkdown: {
open(
state: MarkdownSerializerState,
mark: Mark,
parent: Node,
index: number,
) {
if (!mark.attrs.isWikiLink) {
const open = defaultMarkdownSerializer.marks.link.open
return typeof open === 'function'
? open(state, mark, parent, index)
: open
}
const href = mark.attrs.href as string
// Collect the display text of this mark's span to decide the form
let innerText = ''
parent.descendants((child) => {
if (!mark.isInSet(child.marks)) {
return false
}
if (child.isText) {
innerText += child.text
}
})
return innerText === href ? `[[` : `[[${href}|`
},
close(
state: MarkdownSerializerState,
mark: Mark,
_parent: Node,
_index: number,
) {
if (!mark.attrs.isWikiLink) {
const close = defaultMarkdownSerializer.marks.link.close
return typeof close === 'function'
? close(state, mark, _parent, _index)
: close
}
return ']]'
},
mixable: true,
expelEnclosingWhitespace: false,
},
})

export default Link
21 changes: 19 additions & 2 deletions src/nodes/Image.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,19 @@ const imageExtractAttachmentsKey = new PluginKey('imageExtractAttachments')
const Image = TiptapImage.extend({
selectable: false,

addAttributes() {
return {
...this.parent?.(),
isWikiLink: {
default: false,
parseHTML: (element) =>
element.getAttribute('data-wiki-image') === 'true',
renderHTML: (attrs) =>
attrs.isWikiLink ? { 'data-wiki-image': 'true' } : {},
},
}
},

parseHTML() {
return [
{
Expand Down Expand Up @@ -118,8 +131,12 @@ const Image = TiptapImage.extend({

/* Serializes an image node as a block image, so it ensures an image is always a block by itself */
toMarkdown(state, node, parent, index) {
node.attrs.alt = node.attrs.alt.toString()
defaultMarkdownSerializer.nodes.image(state, node, parent, index)
if (node.attrs.isWikiLink) {
state.write(`![[${node.attrs.src}]]`)
} else {
node.attrs.alt = node.attrs.alt.toString()
defaultMarkdownSerializer.nodes.image(state, node, parent, index)
}
state.closeBlock(node)
},
})
Expand Down
19 changes: 18 additions & 1 deletion src/nodes/ImageInline.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ const ImageInline = TiptapImage.extend({

selectable: false,

addAttributes() {
return {
...this.parent?.(),
isWikiLink: {
default: false,
parseHTML: (element) =>
element.getAttribute('data-wiki-image') === 'true',
renderHTML: (attrs) =>
attrs.isWikiLink ? { 'data-wiki-image': 'true' } : {},
},
}
},

parseHTML() {
return [
{
Expand Down Expand Up @@ -50,7 +63,11 @@ const ImageInline = TiptapImage.extend({
},

toMarkdown(state, node, parent, index) {
return defaultMarkdownSerializer.nodes.image(state, node, parent, index)
if (node.attrs.isWikiLink) {
state.write(`![[${node.attrs.src}]]`)
} else {
return defaultMarkdownSerializer.nodes.image(state, node, parent, index)
}
},
})

Expand Down
20 changes: 20 additions & 0 deletions src/tests/extensions/Markdown.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,26 @@ describe('Markdown extension integrated in the editor', () => {
expect(text).toBe('Hello\n\nexample@example.com')
editor.destroy()
})

it('serializes a wiki text link as [[target]]', () => {
const editor = createCustomEditor(
'<p><a href="WikiLink" data-wiki-link="true">WikiLink</a></p>',
[Markdown, Link],
)
const serializer = createMarkdownSerializer(editor.schema)
expect(serializer.serialize(editor.state.doc)).toBe('[[WikiLink]]')
editor.destroy()
})

it('serializes a wiki text link with display text as [[target|display]]', () => {
const editor = createCustomEditor(
'<p><a href="target" data-wiki-link="true">display</a></p>',
[Markdown, Link],
)
const serializer = createMarkdownSerializer(editor.schema)
expect(serializer.serialize(editor.state.doc)).toBe('[[target|display]]')
editor.destroy()
})
})

const copyEditorContent = (editor, nodeType = null) => {
Expand Down
Loading
Loading