CI/CD / preconditions (pull_request) Successful in 2s
Native JavaScript checks / Native launcher and release tests (pull_request) Successful in 1m12s
CI/CD / test_and_check_app (pull_request) Failing after 1m59s
CI/CD / Publish immutable image (pull_request) Skipped
macOS checks / Native Rust tests and unsigned build (pull_request) Canceled after 0s
130 lines
4.1 KiB
TypeScript
130 lines
4.1 KiB
TypeScript
import type { JSONContent } from '@tiptap/core';
|
|
import { Markdown, MarkdownManager } from '@tiptap/markdown';
|
|
import StarterKit from '@tiptap/starter-kit';
|
|
import { h } from 'vue';
|
|
import type { VNodeChild } from 'vue';
|
|
|
|
export function safeDescriptionLink(value: unknown): string | null {
|
|
if (
|
|
typeof value !== 'string' ||
|
|
/[\s\\]/u.test(value) ||
|
|
[...value].some(
|
|
(character) =>
|
|
character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127,
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
if (/^(https?:\/\/|mailto:)/i.test(value)) {
|
|
try {
|
|
const url = new URL(value);
|
|
return ['http:', 'https:', 'mailto:'].includes(url.protocol)
|
|
? value
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
return /^(\/(?!\/)|#)/.test(value) ? value : null;
|
|
}
|
|
|
|
export function descriptionExtensions() {
|
|
return [
|
|
StarterKit.configure({
|
|
underline: false,
|
|
trailingNode: false,
|
|
link: {
|
|
openOnClick: false,
|
|
autolink: false,
|
|
isAllowedUri: (url) => safeDescriptionLink(url) !== null,
|
|
HTMLAttributes: {
|
|
rel: 'noopener noreferrer',
|
|
target: '_blank',
|
|
},
|
|
},
|
|
}),
|
|
Markdown.configure({ markedOptions: { gfm: true, breaks: true } }),
|
|
];
|
|
}
|
|
|
|
const markdown = new MarkdownManager({
|
|
extensions: descriptionExtensions(),
|
|
markedOptions: { gfm: true, breaks: true },
|
|
});
|
|
|
|
/** Unsupported Markdown stays editable verbatim instead of being silently discarded. */
|
|
export function supportsVisualDescription(value: string): boolean {
|
|
let supported = true;
|
|
markdown.instance.walkTokens(markdown.instance.lexer(value), (token) => {
|
|
if (
|
|
['html', 'table', 'image'].includes(token.type) ||
|
|
(token.type === 'list_item' && token.task) ||
|
|
(token.type === 'link' && !safeDescriptionLink(token.href))
|
|
) {
|
|
supported = false;
|
|
}
|
|
});
|
|
return supported;
|
|
}
|
|
|
|
export function parseDescription(value: string): JSONContent {
|
|
return markdown.parse(value);
|
|
}
|
|
|
|
/** Build Vue nodes from an explicit schema; never insert user-provided HTML. */
|
|
export function renderDescriptionNode(node: JSONContent): VNodeChild {
|
|
if (node.type === 'text') {
|
|
let content: VNodeChild = node.text ?? '';
|
|
for (const mark of node.marks ?? []) {
|
|
const tag = {
|
|
bold: 'strong',
|
|
italic: 'em',
|
|
strike: 's',
|
|
code: 'code',
|
|
}[mark.type];
|
|
if (tag) {
|
|
content = h(tag, {}, [content]);
|
|
} else if (mark.type === 'link') {
|
|
const href = safeDescriptionLink(mark.attrs?.href);
|
|
if (href) {
|
|
content = h(
|
|
'a',
|
|
{ href, target: '_blank', rel: 'noopener noreferrer' },
|
|
[content],
|
|
);
|
|
}
|
|
}
|
|
}
|
|
return content;
|
|
}
|
|
const children = (node.content ?? []).map(renderDescriptionNode);
|
|
switch (node.type) {
|
|
case 'doc':
|
|
return children;
|
|
case 'paragraph':
|
|
return h('p', {}, children);
|
|
case 'heading':
|
|
return h(
|
|
`h${Math.min(6, Math.max(1, Number(node.attrs?.level) || 1))}`,
|
|
{},
|
|
children,
|
|
);
|
|
case 'bulletList':
|
|
return h('ul', {}, children);
|
|
case 'orderedList':
|
|
return h('ol', { start: Number(node.attrs?.start) || 1 }, children);
|
|
case 'listItem':
|
|
return h('li', {}, children);
|
|
case 'blockquote':
|
|
return h('blockquote', {}, children);
|
|
case 'codeBlock':
|
|
return h('pre', {}, [h('code', {}, children)]);
|
|
case 'hardBreak':
|
|
return h('br');
|
|
case 'horizontalRule':
|
|
return h('hr');
|
|
default:
|
|
return children;
|
|
}
|
|
}
|