mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-09-19 09:27:29 +02:00
Fix Markdown mark round-trips (#219)
This commit is contained in:
@@ -51,6 +51,7 @@
|
|||||||
import { calloutGroup, calloutIcon, calloutLabel, CALLOUT_MENU, transformCalloutBlockquotes, serializeCallout } from '$lib/editor/callouts';
|
import { calloutGroup, calloutIcon, calloutLabel, CALLOUT_MENU, transformCalloutBlockquotes, serializeCallout } from '$lib/editor/callouts';
|
||||||
import { wrapTextareaSelection } from '$lib/editor/source/selectionPairs';
|
import { wrapTextareaSelection } from '$lib/editor/source/selectionPairs';
|
||||||
import { convertListNode, type MixedListName } from '$lib/editor/mixedLists';
|
import { convertListNode, type MixedListName } from '$lib/editor/mixedLists';
|
||||||
|
import { serializeInlineMarkdown } from '$lib/editor/markdown';
|
||||||
import { relativePath } from '$lib/utils/paths';
|
import { relativePath } from '$lib/utils/paths';
|
||||||
import GraphView from './GraphView.svelte';
|
import GraphView from './GraphView.svelte';
|
||||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||||
@@ -3666,68 +3667,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function serializeInline(node: any): string {
|
function serializeInline(node: any): string {
|
||||||
if (node.childCount === 0) return '';
|
return serializeInlineMarkdown(node, stripAssetSrc);
|
||||||
const parts: string[] = [];
|
|
||||||
node.forEach((child: any, _offset: number, index: number) => {
|
|
||||||
if (child.isText) {
|
|
||||||
let text = child.text || '';
|
|
||||||
// Preserve leading tabs/em-spaces as HTML entities so they survive markdown roundtrip
|
|
||||||
// (markdown parsers strip tab whitespace, but   passes through as HTML)
|
|
||||||
// Tabs come from initial indent; em-spaces (U+2003) come from prior   roundtrips
|
|
||||||
if (index === 0) {
|
|
||||||
text = text.replace(/^[\t\u2003]+/, (ws) => ' '.repeat(ws.length));
|
|
||||||
}
|
|
||||||
// Apply marks
|
|
||||||
for (const mark of child.marks) {
|
|
||||||
switch (mark.type.name) {
|
|
||||||
case 'bold': text = `**${text}**`; break;
|
|
||||||
case 'italic': text = `*${text}*`; break;
|
|
||||||
case 'strike': text = `~~${text}~~`; break;
|
|
||||||
case 'code': text = `\`${text}\``; break;
|
|
||||||
case 'underline': text = `<u>${text}</u>`; break;
|
|
||||||
case 'subscript': text = `~${text}~`; break;
|
|
||||||
case 'superscript': text = `^${text}^`; break;
|
|
||||||
case 'highlight': {
|
|
||||||
const color = mark.attrs?.color;
|
|
||||||
if (color) {
|
|
||||||
text = `<mark data-color="${color}">${text}</mark>`;
|
|
||||||
} else {
|
|
||||||
text = `==${text}==`;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'textStyle': {
|
|
||||||
const c = mark.attrs?.color;
|
|
||||||
if (c) text = `<span style="color: ${c}">${text}</span>`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'link': text = `[${text}](${mark.attrs.href})`; break;
|
|
||||||
case 'wikiLink': {
|
|
||||||
const wlTitle = mark.attrs.title || text;
|
|
||||||
// If display text differs from the reference, emit [[ref|display]] (Obsidian alias syntax)
|
|
||||||
text = wlTitle !== text ? `[[${wlTitle}|${text}]]` : `[[${wlTitle}]]`;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parts.push(text);
|
|
||||||
} else if (child.type.name === 'image') {
|
|
||||||
const src = stripAssetSrc(child.attrs.src || '');
|
|
||||||
if (!src) return; // Skip images with unresolved blob: URLs
|
|
||||||
const alt = child.attrs.alt || '';
|
|
||||||
const size = child.attrs['data-size'] || child.attrs.size || 'full';
|
|
||||||
const sizeSuffix = size && size !== 'full' ? `|size=${size}` : '';
|
|
||||||
if (parts.length > 0 && parts[parts.length - 1] !== '\n') {
|
|
||||||
parts.push('\n');
|
|
||||||
}
|
|
||||||
parts.push(``);
|
|
||||||
} else if (child.type.name === 'mathInline') {
|
|
||||||
parts.push(`$${child.attrs.tex || ''}$`);
|
|
||||||
} else if (child.type.name === 'hardBreak') {
|
|
||||||
parts.push(' \n');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return parts.join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function autofocus(el: HTMLElement) {
|
function autofocus(el: HTMLElement) {
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import type { Mark, Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||||
|
|
||||||
|
type AssetSourceNormalizer = (source: string) => string;
|
||||||
|
|
||||||
|
type MarkdownMark = {
|
||||||
|
key: string;
|
||||||
|
priority: number;
|
||||||
|
open: string;
|
||||||
|
close: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const identityAssetSource: AssetSourceNormalizer = (source) => source;
|
||||||
|
|
||||||
|
function stringAttr(mark: Mark, name: string): string {
|
||||||
|
const value = mark.attrs?.[name];
|
||||||
|
return typeof value === 'string' ? value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownMark(mark: Mark, text: string): MarkdownMark | null {
|
||||||
|
switch (mark.type.name) {
|
||||||
|
case 'bold':
|
||||||
|
return { key: 'bold', priority: 10, open: '**', close: '**' };
|
||||||
|
case 'italic':
|
||||||
|
return { key: 'italic', priority: 20, open: '*', close: '*' };
|
||||||
|
case 'strike':
|
||||||
|
return { key: 'strike', priority: 30, open: '~~', close: '~~' };
|
||||||
|
case 'underline':
|
||||||
|
return { key: 'underline', priority: 40, open: '<u>', close: '</u>' };
|
||||||
|
case 'subscript':
|
||||||
|
return { key: 'subscript', priority: 50, open: '~', close: '~' };
|
||||||
|
case 'superscript':
|
||||||
|
return { key: 'superscript', priority: 60, open: '^', close: '^' };
|
||||||
|
case 'highlight': {
|
||||||
|
const color = stringAttr(mark, 'color');
|
||||||
|
return color
|
||||||
|
? {
|
||||||
|
key: `highlight:${color}`,
|
||||||
|
priority: 70,
|
||||||
|
open: `<mark data-color="${color}">`,
|
||||||
|
close: '</mark>',
|
||||||
|
}
|
||||||
|
: { key: 'highlight', priority: 70, open: '==', close: '==' };
|
||||||
|
}
|
||||||
|
case 'textStyle': {
|
||||||
|
const color = stringAttr(mark, 'color');
|
||||||
|
return color
|
||||||
|
? {
|
||||||
|
key: `textStyle:${color}`,
|
||||||
|
priority: 80,
|
||||||
|
open: `<span style="color: ${color}">`,
|
||||||
|
close: '</span>',
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
case 'code':
|
||||||
|
return { key: 'code', priority: 90, open: '`', close: '`' };
|
||||||
|
case 'link': {
|
||||||
|
const href = stringAttr(mark, 'href');
|
||||||
|
return {
|
||||||
|
key: `link:${href}`,
|
||||||
|
priority: 100,
|
||||||
|
open: '[',
|
||||||
|
close: `](${href})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'wikiLink': {
|
||||||
|
const title = stringAttr(mark, 'title') || text;
|
||||||
|
const aliased = mark.attrs?.aliased === true || title !== text;
|
||||||
|
return {
|
||||||
|
key: `wikiLink:${title}:${aliased}`,
|
||||||
|
priority: 110,
|
||||||
|
open: aliased ? `[[${title}|` : '[[',
|
||||||
|
close: ']]',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function marksForText(node: ProseMirrorNode): MarkdownMark[] {
|
||||||
|
return node.marks
|
||||||
|
.map((mark) => markdownMark(mark, node.text || ''))
|
||||||
|
.filter((mark): mark is MarkdownMark => mark !== null)
|
||||||
|
.sort((left, right) => left.priority - right.priority || left.key.localeCompare(right.key));
|
||||||
|
}
|
||||||
|
|
||||||
|
function commonMarkCount(active: MarkdownMark[], next: MarkdownMark[]): number {
|
||||||
|
const length = Math.min(active.length, next.length);
|
||||||
|
let index = 0;
|
||||||
|
while (index < length && active[index].key === next[index].key) index += 1;
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function serializeInlineMarkdown(
|
||||||
|
node: ProseMirrorNode,
|
||||||
|
normalizeAssetSource: AssetSourceNormalizer = identityAssetSource,
|
||||||
|
): string {
|
||||||
|
let result = '';
|
||||||
|
let activeMarks: MarkdownMark[] = [];
|
||||||
|
|
||||||
|
const transitionMarks = (nextMarks: MarkdownMark[]) => {
|
||||||
|
const shared = commonMarkCount(activeMarks, nextMarks);
|
||||||
|
for (let index = activeMarks.length - 1; index >= shared; index -= 1) {
|
||||||
|
result += activeMarks[index].close;
|
||||||
|
}
|
||||||
|
for (let index = shared; index < nextMarks.length; index += 1) {
|
||||||
|
result += nextMarks[index].open;
|
||||||
|
}
|
||||||
|
activeMarks = nextMarks;
|
||||||
|
};
|
||||||
|
|
||||||
|
node.forEach((child, _offset, index) => {
|
||||||
|
if (child.isText) {
|
||||||
|
transitionMarks(marksForText(child));
|
||||||
|
let text = child.text || '';
|
||||||
|
if (index === 0) {
|
||||||
|
text = text.replace(/^[\t\u2003]+/, (whitespace) => ' '.repeat(whitespace.length));
|
||||||
|
}
|
||||||
|
result += text;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
transitionMarks([]);
|
||||||
|
|
||||||
|
if (child.type.name === 'image') {
|
||||||
|
const source = normalizeAssetSource(child.attrs.src || '');
|
||||||
|
if (!source) return;
|
||||||
|
const alt = child.attrs.alt || '';
|
||||||
|
const size = child.attrs['data-size'] || child.attrs.size || 'full';
|
||||||
|
const sizeSuffix = size && size !== 'full' ? `|size=${size}` : '';
|
||||||
|
if (result) result += '\n';
|
||||||
|
result += ``;
|
||||||
|
} else if (child.type.name === 'mathInline') {
|
||||||
|
result += `$${child.attrs.tex || ''}$`;
|
||||||
|
} else if (child.type.name === 'hardBreak') {
|
||||||
|
result += ' \n';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
transitionMarks([]);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import test from 'node:test';
|
||||||
|
import MarkdownIt from 'markdown-it';
|
||||||
|
import { Schema } from '@tiptap/pm/model';
|
||||||
|
import { transformWithEsbuild } from 'vite';
|
||||||
|
|
||||||
|
const source = await readFile(
|
||||||
|
new URL('../src/lib/editor/markdown.ts', import.meta.url),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const { code } = await transformWithEsbuild(source, 'markdown.ts', {
|
||||||
|
loader: 'ts',
|
||||||
|
format: 'esm',
|
||||||
|
target: 'esnext'
|
||||||
|
});
|
||||||
|
const { serializeInlineMarkdown } = await import(
|
||||||
|
`data:text/javascript;base64,${Buffer.from(code).toString('base64')}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Link intentionally precedes formatting marks in the schema. This matches the
|
||||||
|
// mark order produced by the app and reproduces the original per-text-node bug.
|
||||||
|
const schema = new Schema({
|
||||||
|
nodes: {
|
||||||
|
doc: { content: 'block+' },
|
||||||
|
paragraph: { content: 'text*', group: 'block' },
|
||||||
|
text: { group: 'inline' }
|
||||||
|
},
|
||||||
|
marks: {
|
||||||
|
link: { attrs: { href: {} } },
|
||||||
|
bold: {},
|
||||||
|
italic: {},
|
||||||
|
wikiLink: {
|
||||||
|
attrs: {
|
||||||
|
title: { default: null },
|
||||||
|
aliased: { default: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const markdown = new MarkdownIt({ html: true, linkify: false, breaks: false });
|
||||||
|
const bold = schema.marks.bold.create();
|
||||||
|
const italic = schema.marks.italic.create();
|
||||||
|
const link = schema.marks.link.create({ href: 'https://example.com' });
|
||||||
|
|
||||||
|
function paragraph(segments) {
|
||||||
|
return schema.node(
|
||||||
|
'paragraph',
|
||||||
|
null,
|
||||||
|
segments.map(({ text, marks }) => schema.text(text, marks))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertMarkdown(segments, expected, expectedHtml) {
|
||||||
|
const serialized = serializeInlineMarkdown(paragraph(segments));
|
||||||
|
assert.equal(serialized, expected);
|
||||||
|
assert.equal(markdown.render(serialized), `${expectedHtml}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('keeps bold open when a link ends the marked span', () => {
|
||||||
|
assertMarkdown(
|
||||||
|
[
|
||||||
|
{ text: 'Bold ', marks: [bold] },
|
||||||
|
{ text: 'link', marks: [link, bold] }
|
||||||
|
],
|
||||||
|
'**Bold [link](https://example.com)**',
|
||||||
|
'<p><strong>Bold <a href="https://example.com">link</a></strong></p>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps bold open when a link starts the marked span', () => {
|
||||||
|
assertMarkdown(
|
||||||
|
[
|
||||||
|
{ text: 'link', marks: [link, bold] },
|
||||||
|
{ text: ' bold', marks: [bold] }
|
||||||
|
],
|
||||||
|
'**[link](https://example.com) bold**',
|
||||||
|
'<p><strong><a href="https://example.com">link</a> bold</strong></p>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps one bold span around a link in the middle', () => {
|
||||||
|
assertMarkdown(
|
||||||
|
[
|
||||||
|
{ text: 'Before ', marks: [bold] },
|
||||||
|
{ text: 'link', marks: [link, bold] },
|
||||||
|
{ text: ' after', marks: [bold] }
|
||||||
|
],
|
||||||
|
'**Before [link](https://example.com) after**',
|
||||||
|
'<p><strong>Before <a href="https://example.com">link</a> after</strong></p>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps nested bold and italic marks open across a link', () => {
|
||||||
|
assertMarkdown(
|
||||||
|
[
|
||||||
|
{ text: 'Nested ', marks: [bold, italic] },
|
||||||
|
{ text: 'link', marks: [link, bold, italic] },
|
||||||
|
{ text: ' marks', marks: [bold, italic] }
|
||||||
|
],
|
||||||
|
'***Nested [link](https://example.com) marks***',
|
||||||
|
'<p><em><strong>Nested <a href="https://example.com">link</a> marks</strong></em></p>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves wiki-link aliases inside a bold span', () => {
|
||||||
|
const wikiLink = schema.marks.wikiLink.create({ title: 'Target note', aliased: true });
|
||||||
|
const serialized = serializeInlineMarkdown(
|
||||||
|
paragraph([
|
||||||
|
{ text: 'See ', marks: [bold] },
|
||||||
|
{ text: 'this note', marks: [wikiLink, bold] },
|
||||||
|
{ text: ' now', marks: [bold] }
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(serialized, '**See [[Target note|this note]] now**');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user