Files
KanbanGit/resources/js/lib/ticket-description.ts
T
Twyco 80d911bcc1
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
feat(KAN-68): add visual Markdown ticket description editor
2026-09-30 11:02:50 +02:00

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