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; } }