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
7 changes: 7 additions & 0 deletions src/declarations.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,10 @@ declare module '@nextcloud/vue/composables/useIsMobile' {
import { Ref } from 'vue'
export function useIsMobile(): Ref<boolean>
}

// We use `StateInline` in our custom referenceLinks markdown-it plugin
declare module 'markdown-it/lib/rules_inline/link.mjs' {
import type StateInline from 'markdown-it/lib/rules_inline/state_inline.mjs'
const rule: (state: StateInline, silent: boolean) => boolean
export default rule
}
20 changes: 19 additions & 1 deletion src/extensions/Markdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,28 @@ const createMarkdownSerializer = ({ nodes, marks }) => {
extractMarksToMarkdown(marks),
),
serialize(content, options) {
return this.serializer.serialize(content, {
// Extend serialize options to carry reference definitions (populated by `toMarkdown` callbacks in link mark)
const referenceDefinitions = new Map()
const body = this.serializer.serialize(content, {
...options,
tightLists: true,
referenceDefinitions,
})

if (referenceDefinitions.size === 0) {
return body
}

// Render references at the end of the document
const referenceLines = [...referenceDefinitions.values()].map(
({ label, href, title }) => {
const titlePart = title ? ` "${title.replace(/"/g, '\\"')}"` : ''
return `[${label}]: ${href}${titlePart}`
},
)
return (
body.replace(/\s*$/, '') + '\n\n' + referenceLines.join('\n') + '\n'
)
},
}
}
Expand Down
2 changes: 2 additions & 0 deletions src/markdownit/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import hardbreak from './hardbreak.js'
import keepSyntax from './keepSyntax.js'
import mathematics from './mathematics.ts'
import preview from './preview.js'
import referenceLinks from './referenceLinks.ts'
import splitMixedLists from './splitMixedLists.js'
import taskLists from './taskLists.ts'
import underline from './underline.js'
Expand All @@ -35,6 +36,7 @@ const markdownit = MarkdownIt('commonmark', { html: false, breaks: false })
.use(keepSyntax)
.use(markdownitMentions)
.use(wikiLinks)
.use(referenceLinks)
.use(implicitFigures)
.use(mark)
.use(mathematics)
Expand Down
102 changes: 102 additions & 0 deletions src/markdownit/referenceLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type MarkdownIt from 'markdown-it'
import type StateInline from 'markdown-it/lib/rules_inline/state_inline.mjs'

import linkRule from 'markdown-it/lib/rules_inline/link.mjs'

type RefType = 'full' | 'collapsed' | 'shortcut'

/**
* Decide which reference form (if any) the wrapped link rule matched,
* by inspecting the source range it consumed.
*
* @param state - markdown-it inline parser state
* @param startPos - position the rule started at
* @param endPos - position the rule ended at
*/
function detectReferenceForm(
state: StateInline,
startPos: number,
endPos: number,
): { label: string; type: RefType } | undefined {
const savedPos = state.pos
const labelEnd = state.md.helpers.parseLinkLabel(state, startPos)
state.pos = savedPos
if (labelEnd < 0) {
return undefined
}

const labelStart = startPos + 1
const displayLabel = state.src.slice(labelStart, labelEnd)
const after = labelEnd + 1

if (after >= endPos) {
return { label: displayLabel, type: 'shortcut' }
}

const code = state.src.charCodeAt(after)

if (code === 0x28 /* ( */) {
// inline form
return undefined
}

if (code !== 0x5b /* [ */) {
return { label: displayLabel, type: 'shortcut' }
}

if (endPos === after + 2) {
return { label: displayLabel, type: 'collapsed' }
}

return { label: state.src.slice(after + 1, endPos - 1), type: 'full' }
}

/**
* Wrap an existing inline rule so that, when it succeeds via the reference
* branch, the freshly pushed token is tagged with reference metadata.
*
* @param original - upstream inline link rule
*/
function wrap(original: (state: StateInline, silent: boolean) => boolean) {
return (state: StateInline, silent: boolean): boolean => {
const start = state.pos
if (!original(state, silent)) {
return false
}
if (silent) {
return true
}

const info = detectReferenceForm(state, start, state.pos)
if (!info) {
return true
}

const targetType = 'link_open'
const target = state.tokens.findLast((t) => t.type === targetType)
if (target) {
target.attrSet('data-md-reference-label', info.label)
target.attrSet('data-md-reference-type', info.type)
}

return true
}
}

/**
* markdown-it plugin: preserve reference-style link syntax across
* the parse/serialize round-trip.
*
* Adds `data-md-reference-label` / `data-md-reference-type` attributes to
* `link_open` tokens emitted via the reference branch.
*
* @param md - markdown-it instance to extend
*/
export default function referenceLinks(md: MarkdownIt): void {
md.inline.ruler.at('link', wrap(linkRule))
}
66 changes: 60 additions & 6 deletions src/marks/Link.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ 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 { normalizeReference } from 'markdown-it/lib/common/utils.mjs'
import type { MarkdownSerializerState } from 'prosemirror-markdown'
import { defaultMarkdownSerializer } from 'prosemirror-markdown'
import { domHref, parseHref } from '../helpers/links.js'
Expand Down Expand Up @@ -119,6 +120,24 @@ const Link = TipTapLink.extend<RelativePathLinkOptions>({
renderHTML: (attrs) =>
attrs.isWikiLink ? { 'data-wiki-link': 'true' } : {},
},
referenceLabel: {
default: null,
parseHTML: (element) =>
element.getAttribute('data-md-reference-label'),
renderHTML: (attrs) =>
attrs.referenceLabel
? { 'data-md-reference-label': attrs.referenceLabel }
: {},
},
referenceType: {
default: null,
parseHTML: (element) =>
element.getAttribute('data-md-reference-type'),
renderHTML: (attrs) =>
attrs.referenceType
? { 'data-md-reference-type': attrs.referenceType }
: {},
},
}
},

Expand Down Expand Up @@ -285,13 +304,48 @@ const Link = TipTapLink.extend<RelativePathLinkOptions>({
_parent: Node,
_index: number,
) {
if (!mark.attrs.isWikiLink) {
const close = defaultMarkdownSerializer.marks.link.close
return typeof close === 'function'
? close(state, mark, _parent, _index)
: close
if (mark.attrs.isWikiLink) {
return ']]'
}
if (mark.attrs.referenceLabel && mark.attrs.referenceType) {
const label = mark.attrs.referenceLabel as string
const type = mark.attrs.referenceType as
| 'shortcut'
| 'collapsed'
| 'full'
const defs =
// Cast `state.options` locally as `referenceDefinitions` doesn't exist in upstream type definition
(
state.options as {
referenceDefinitions?: Map<
string,
{ label: string; href: string; title: string | null }
>
}
).referenceDefinitions
if (defs) {
const key = normalizeReference(label)
if (!defs.has(key)) {
defs.set(key, {
label,
href: mark.attrs.href as string,
title: (mark.attrs.title as string) ?? null,
})
}
}
switch (type) {
case 'shortcut':
return ']'
case 'collapsed':
return '][]'
case 'full':
return `][${label}]`
}
}
return ']]'
const close = defaultMarkdownSerializer.marks.link.close
return typeof close === 'function'
? close(state, mark, _parent, _index)
: close
},
mixable: true,
expelEnclosingWhitespace: false,
Expand Down
31 changes: 24 additions & 7 deletions src/tests/markdown.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,8 @@ describe('Markdown though editor', () => {
expect(markdownThroughEditor('[test](foo)')).toBe('[test](foo)')
expect(markdownThroughEditor('[test](foo "bar")')).toBe('[test](foo "bar")')
// Issue #2703
expect(markdownThroughEditor('[bar\\\\]: /uri\n\n[bar\\\\]')).toBe(
'[bar\\\\](/uri)',
)
const test2703 = '[bar\\\\]\n\n[bar\\\\]: /uri\n'
expect(markdownThroughEditor(test2703)).toBe(test2703)
// Issue #4900
expect(markdownThroughEditor('[`code`](foo)')).toBe('[`code`](foo)')
expect(markdownThroughEditor('[text with `code` inside](foo)')).toBe(
Expand All @@ -86,6 +85,28 @@ describe('Markdown though editor', () => {
expect(markdownThroughEditor('text [[wikiLink]] more')).toBe(
'text [[wikiLink]] more',
)
// Reference-style links (issue #5820)
const referenceShortcutTest =
'Test with [Case-Sensitive Reference] in it.\n\n[Case-Sensitive Reference]: https://example.org/\n'
expect(markdownThroughEditor(referenceShortcutTest)).toBe(
referenceShortcutTest,
)
const referenceCollapsedTest =
'Test with [label][] in it.\n\n[label]: https://example.org/\n'
expect(markdownThroughEditor(referenceCollapsedTest)).toBe(
referenceCollapsedTest,
)
const referenceFullTest =
'Test with [display text][label] in it.\n\n[label]: https://example.org/ "title"\n'
expect(markdownThroughEditor(referenceFullTest)).toBe(referenceFullTest)
// References moved to the end of the document
expect(
markdownThroughEditor(
'Test with [reference] in it.\n\n[reference]: /url\n\nsome extra paragraph\n',
),
).toBe(
'Test with [reference] in it.\n\nsome extra paragraph\n\n[reference]: /url\n',
)
})
test('images', () => {
// Inline images
Expand Down Expand Up @@ -123,10 +144,6 @@ describe('Markdown though editor', () => {
expect(markdownThroughEditor('```\n```')).toBe('```\n```')
})
test('markdown untouched', () => {
// Issue #2703
expect(markdownThroughEditor('[bar\\\\]: /uri\n\n[bar\\\\]')).toBe(
'[bar\\\\](/uri)',
)
expect(markdownThroughEditor('## Test \\')).toBe('## Test \\')
expect(markdownThroughEditor('- [ [asd](sdf)')).toBe('- [ [asd](sdf)')
})
Expand Down
17 changes: 16 additions & 1 deletion src/tests/markdownit/commonmark.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ describe('Commonmark', () => {
585, 586, 588, 589, 591,
]

const referenceLinkTests = [
23, 33, 192, 193, 194, 195, 196, 198, 200, 201, 202, 203, 204, 205, 206, 214,
215, 216, 217, 218, 514, 515, 516, 517, 518, 527, 528, 529, 530, 531, 532,
533, 534, 535, 539, 540, 541, 542, 543, 544, 549, 550, 553, 554, 555, 556,
557, 558, 559, 560, 561, 562, 564, 565, 566, 568, 569, 570, 571, 593,
]

spec.forEach((entry) => {
// We do not support HTML
if (entry.section === 'HTML blocks' || entry.section === 'Raw HTML') return
Expand All @@ -47,13 +54,21 @@ describe('Commonmark', () => {
.replace(/<strong>/g, '<u>')
.replace(/<\/strong>/g, '</u>')
: entry.html

if (figureImageMarkdownTests.indexOf(entry.example) !== -1) {
expected = expected
.replace(/<p>/g, '<figure>')
.replace(/<\/p>/g, '</figure>')
}

const rendered = markdownit.render(entry.markdown)
let rendered = markdownit.render(entry.markdown)

if (referenceLinkTests.indexOf(entry.example) !== -1) {
rendered = rendered.replace(
/ data-md-reference-label="[^"]+" data-md-reference-type="[a-z]+"/g,
'',
)
}

// Ignore special markup for untouched markdown
expect(normalize(rendered)).toBe(expected)
Expand Down
48 changes: 48 additions & 0 deletions src/tests/markdownit/referenceLinks.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import markdownit from '../../markdownit/index.js'

describe('reference style links (markdown-it)', () => {
it('renders a reference link of type shortcut (omitted label)', () => {
expect(
markdownit.render(
'text with [Some Reference] in it\n\n[Some Reference]: https://example.org',
),
).to.equal(
'<p>text with <a href="https://example.org" data-md-reference-label="Some Reference" data-md-reference-type="shortcut">Some Reference</a> in it</p>\n',
)
})

it('renders a reference link of type collapsed (empty label)', () => {
expect(
markdownit.render(
'text with [Some Reference][] in it\n\n[Some Reference]: https://example.org',
),
).to.equal(
'<p>text with <a href="https://example.org" data-md-reference-label="Some Reference" data-md-reference-type="collapsed">Some Reference</a> in it</p>\n',
)
})

it('renders a reference link of type full (separate label)', () => {
expect(
markdownit.render(
'text with [Some Reference][ref] in it\n\n[ref]: https://example.org',
),
).to.equal(
'<p>text with <a href="https://example.org" data-md-reference-label="ref" data-md-reference-type="full">Some Reference</a> in it</p>\n',
)
})

it('renders a reference link with paragraphs after reference', () => {
expect(
markdownit.render(
'text with [reference] in it\n\n[reference]: /url\n\nsome extra content.',
),
).to.equal(
'<p>text with <a href="/url" data-md-reference-label="reference" data-md-reference-type="shortcut">reference</a> in it</p>\n<p>some extra content.</p>\n',
)
})
})
Loading
Loading