mirror of
https://github.com/multipleof4/dsh-user-markdown-plugin.git
synced 2026-09-18 12:05:42 +00:00
Initial commit: add user markdown rendering plugin
This commit is contained in:
346
lib/client.js
Normal file
346
lib/client.js
Normal file
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Client half of dsh-user-markdown-plugin.
|
||||
* Registers a user message Chat Node renderer for 'user' and 'steering' keys
|
||||
* that parses and renders Markdown formatted text.
|
||||
*/
|
||||
export function apply(ctx) {
|
||||
const slots = ctx.get('slots');
|
||||
if (!slots) return;
|
||||
|
||||
function contentParts(content) {
|
||||
if (!Array.isArray(content)) {
|
||||
if (typeof content === 'string') return { text: content, images: [], rest: [] };
|
||||
return { text: '', images: [], rest: [] };
|
||||
}
|
||||
const texts = [];
|
||||
const images = [];
|
||||
const rest = [];
|
||||
for (const block of content) {
|
||||
if (block && block.type === 'text' && typeof block.text === 'string') {
|
||||
texts.push(block.text);
|
||||
} else if (block && block.type === 'image' && block.attachment !== undefined) {
|
||||
images.push({ attachment: block.attachment });
|
||||
} else if (block) {
|
||||
rest.push(block);
|
||||
}
|
||||
}
|
||||
return { text: texts.join(''), images, rest };
|
||||
}
|
||||
|
||||
function renderInline(text, referenceLabels) {
|
||||
if (!text) return null;
|
||||
const refLabels = Array.isArray(referenceLabels) ? referenceLabels : [];
|
||||
const tokens = [];
|
||||
const regex = /(`[^`]+`)|(\[([^\]]+)\]\(([^)]+)\))|(\*\*\*([^*]+)\*\*\*|___([^_]+)___)|(\*\*([^*]+)\*\*|__([^_]+)__)|(\*([^*]+)\*|_([^_]+)_)|(~~([^~]+)~~)|((?:^|\s)(?:\/[\w-]+|@"[^"\n]+"|@[^\s]+))/g;
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
tokens.push({ type: 'text', content: text.slice(lastIndex, match.index) });
|
||||
}
|
||||
if (match[1]) {
|
||||
tokens.push({ type: 'code', content: match[1].slice(1, -1) });
|
||||
} else if (match[2]) {
|
||||
tokens.push({ type: 'link', label: match[3], url: match[4] });
|
||||
} else if (match[5]) {
|
||||
tokens.push({ type: 'bold_italic', content: match[6] || match[7] });
|
||||
} else if (match[8]) {
|
||||
tokens.push({ type: 'bold', content: match[9] || match[10] });
|
||||
} else if (match[11]) {
|
||||
tokens.push({ type: 'italic', content: match[12] || match[13] });
|
||||
} else if (match[14]) {
|
||||
tokens.push({ type: 'strike', content: match[15] });
|
||||
} else if (match[16]) {
|
||||
const raw = match[16];
|
||||
const leadingSpace = raw.match(/^\s*/)[0];
|
||||
if (leadingSpace) {
|
||||
tokens.push({ type: 'text', content: leadingSpace });
|
||||
}
|
||||
tokens.push({ type: 'ref', content: raw.trim() });
|
||||
}
|
||||
lastIndex = regex.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
tokens.push({ type: 'text', content: text.slice(lastIndex) });
|
||||
}
|
||||
|
||||
return tokens.map((token, idx) => {
|
||||
switch (token.type) {
|
||||
case 'code':
|
||||
return React.createElement('code', { key: idx, className: 'dsh-user-inline-code' }, token.content);
|
||||
case 'link':
|
||||
return React.createElement('a', {
|
||||
key: idx,
|
||||
href: token.url,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
className: 'dsh-user-link'
|
||||
}, token.label);
|
||||
case 'bold_italic':
|
||||
return React.createElement('strong', { key: idx }, React.createElement('em', null, token.content));
|
||||
case 'bold':
|
||||
return React.createElement('strong', { key: idx }, token.content);
|
||||
case 'italic':
|
||||
return React.createElement('em', { key: idx }, token.content);
|
||||
case 'strike':
|
||||
return React.createElement('del', { key: idx }, token.content);
|
||||
case 'ref': {
|
||||
const label = token.content;
|
||||
const isSession = refLabels.includes(label.slice(1));
|
||||
const refKind = isSession ? 'session' : label.startsWith('@') ? (label.endsWith('/') ? 'folder' : 'file') : 'command';
|
||||
const displayLabel = refKind === 'session' ? label.slice(1) : label.startsWith('@') ? label.slice(1).replace(/^"|"$/g, '').split(/[\\/]/).filter(Boolean).pop() || label.slice(1) : label;
|
||||
return React.createElement('span', {
|
||||
key: idx,
|
||||
className: 'dsh-user-ref-chip',
|
||||
'data-ref-chip': refKind,
|
||||
title: label
|
||||
}, displayLabel);
|
||||
}
|
||||
case 'text':
|
||||
default:
|
||||
return token.content;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseMarkdownBlocks(text) {
|
||||
const blocks = [];
|
||||
const lines = text.split('\n');
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
if (line.trim().startsWith('```')) {
|
||||
const lang = line.trim().slice(3).trim();
|
||||
const codeLines = [];
|
||||
i++;
|
||||
while (i < lines.length && !lines[i].trim().startsWith('```')) {
|
||||
codeLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (i < lines.length) i++;
|
||||
blocks.push({ type: 'code', lang, code: codeLines.join('\n') });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim() === '') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (headingMatch) {
|
||||
blocks.push({ type: 'heading', level: headingMatch[1].length, text: headingMatch[2] });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*(---|\*\*\*|___)\s*$/.test(line)) {
|
||||
blocks.push({ type: 'hr' });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*>\s?(.*)$/.test(line)) {
|
||||
const quoteLines = [];
|
||||
while (i < lines.length && /^\s*>\s?(.*)$/.test(lines[i])) {
|
||||
quoteLines.push(lines[i].replace(/^\s*>\s?/, ''));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: 'blockquote', text: quoteLines.join('\n') });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*[-*+]\s+(.*)$/.test(line) || /^\s*\d+\.\s+(.*)$/.test(line)) {
|
||||
const isOrdered = /^\s*\d+\.\s+(.*)$/.test(line);
|
||||
const items = [];
|
||||
const itemRegex = isOrdered ? /^\s*\d+\.\s+(.*)$/ : /^\s*[-*+]\s+(.*)$/;
|
||||
while (i < lines.length && itemRegex.test(lines[i])) {
|
||||
const m = lines[i].match(itemRegex);
|
||||
if (m) items.push(m[1]);
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: isOrdered ? 'ol' : 'ul', items });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.trim().startsWith('|') && line.trim().endsWith('|') && i + 1 < lines.length && /^\s*\|?\s*:?-+:?\s*\|/.test(lines[i + 1])) {
|
||||
const parseRow = (l) => l.trim().slice(1, -1).split('|').map(c => c.trim());
|
||||
const headers = parseRow(lines[i]);
|
||||
i += 2;
|
||||
const rows = [];
|
||||
while (i < lines.length && lines[i].trim().startsWith('|')) {
|
||||
rows.push(parseRow(lines[i]));
|
||||
i++;
|
||||
}
|
||||
blocks.push({ type: 'table', headers, rows });
|
||||
continue;
|
||||
}
|
||||
|
||||
const paraLines = [];
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i].trim() !== '' &&
|
||||
!lines[i].trim().startsWith('```') &&
|
||||
!lines[i].match(/^#{1,6}\s+/) &&
|
||||
!/^\s*(---|\*\*\*|___)\s*$/.test(lines[i]) &&
|
||||
!/^\s*>\s?/.test(lines[i]) &&
|
||||
!/^\s*[-*+]\s+/.test(lines[i]) &&
|
||||
!/^\s*\d+\.\s+/.test(lines[i])
|
||||
) {
|
||||
paraLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (paraLines.length > 0) {
|
||||
blocks.push({ type: 'p', text: paraLines.join('\n') });
|
||||
}
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function UserMarkdownRenderer({ text, referenceLabels }) {
|
||||
const blocks = parseMarkdownBlocks(text);
|
||||
return React.createElement('div', { className: 'dsh-user-markdown-body' },
|
||||
blocks.map((block, index) => {
|
||||
switch (block.type) {
|
||||
case 'code':
|
||||
return React.createElement('pre', { key: index, className: 'dsh-user-code-block' },
|
||||
block.lang ? React.createElement('div', { className: 'dsh-user-code-lang' }, block.lang) : null,
|
||||
React.createElement('code', null, block.code)
|
||||
);
|
||||
case 'heading': {
|
||||
const tag = 'h' + block.level;
|
||||
return React.createElement(tag, { key: index, className: 'dsh-user-h' + block.level },
|
||||
renderInline(block.text, referenceLabels)
|
||||
);
|
||||
}
|
||||
case 'hr':
|
||||
return React.createElement('hr', { key: index, className: 'dsh-user-hr' });
|
||||
case 'blockquote':
|
||||
return React.createElement('blockquote', { key: index, className: 'dsh-user-blockquote' },
|
||||
renderInline(block.text, referenceLabels)
|
||||
);
|
||||
case 'ul':
|
||||
return React.createElement('ul', { key: index, className: 'dsh-user-ul' },
|
||||
block.items.map((item, itemIdx) =>
|
||||
React.createElement('li', { key: itemIdx }, renderInline(item, referenceLabels))
|
||||
)
|
||||
);
|
||||
case 'ol':
|
||||
return React.createElement('ol', { key: index, className: 'dsh-user-ol' },
|
||||
block.items.map((item, itemIdx) =>
|
||||
React.createElement('li', { key: itemIdx }, renderInline(item, referenceLabels))
|
||||
)
|
||||
);
|
||||
case 'table':
|
||||
return React.createElement('table', { key: index, className: 'dsh-user-table' },
|
||||
React.createElement('thead', null,
|
||||
React.createElement('tr', null,
|
||||
block.headers.map((h, hIdx) =>
|
||||
React.createElement('th', { key: hIdx }, renderInline(h, referenceLabels))
|
||||
)
|
||||
)
|
||||
),
|
||||
React.createElement('tbody', null,
|
||||
block.rows.map((r, rIdx) =>
|
||||
React.createElement('tr', { key: rIdx },
|
||||
r.map((cell, cIdx) =>
|
||||
React.createElement('td', { key: cIdx }, renderInline(cell, referenceLabels))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
case 'p':
|
||||
default:
|
||||
return React.createElement('p', { key: index, className: 'dsh-user-p' },
|
||||
renderInline(block.text, referenceLabels)
|
||||
);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function UserMarkdownMessageNodeView(props) {
|
||||
const node = props ? props.node : null;
|
||||
const data = node ? node.data : {};
|
||||
const content = data ? data.content : [];
|
||||
const referenceLabels = data ? data.referenceLabels : [];
|
||||
const parts = contentParts(content);
|
||||
const text = parts.text;
|
||||
const images = parts.images;
|
||||
const rest = parts.rest;
|
||||
const renderMessageImages = props ? props.renderMessageImages : null;
|
||||
|
||||
const showBubble = text !== '' || (rest && rest.length > 0);
|
||||
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const handleCopy = React.useCallback(function() {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
setCopied(true);
|
||||
setTimeout(function() { setCopied(false); }, 2000);
|
||||
});
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
return React.createElement('div', {
|
||||
className: 'dsh-user-msg-row',
|
||||
'data-time-hover-root': true
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'stack',
|
||||
className: 'dsh-user-msg-stack'
|
||||
}, [
|
||||
renderMessageImages ? renderMessageImages({ images: images, align: 'end' }) : null,
|
||||
showBubble ? React.createElement('div', {
|
||||
key: 'bubble',
|
||||
className: 'dsh-user-msg-bubble'
|
||||
}, [
|
||||
React.createElement(UserMarkdownRenderer, {
|
||||
key: 'md',
|
||||
text: text,
|
||||
referenceLabels: referenceLabels
|
||||
}),
|
||||
rest && rest.length > 0 ? rest.map(function(block, i) {
|
||||
return React.createElement('pre', { key: 'rest-' + i, className: 'dsh-user-rest-block' },
|
||||
JSON.stringify(block, null, 2)
|
||||
);
|
||||
}) : null
|
||||
]) : null,
|
||||
referenceLabels && referenceLabels.length > 0 ? React.createElement('div', {
|
||||
key: 'ref-summary',
|
||||
className: 'dsh-user-ref-summary'
|
||||
}, referenceLabels.join(', ')) : null
|
||||
]),
|
||||
React.createElement('div', {
|
||||
key: 'actions',
|
||||
className: 'dsh-user-msg-actions'
|
||||
}, [
|
||||
React.createElement('button', {
|
||||
key: 'copy',
|
||||
type: 'button',
|
||||
onClick: handleCopy,
|
||||
className: 'dsh-user-copy-btn',
|
||||
title: copied ? 'Copied!' : 'Copy message'
|
||||
}, copied ? '✓' : '📋')
|
||||
])
|
||||
]);
|
||||
}
|
||||
|
||||
slots.inject('conversation.chat.node', () => {
|
||||
slots.register({
|
||||
name: 'conversation.chat.node',
|
||||
key: 'user'
|
||||
}, UserMarkdownMessageNodeView);
|
||||
|
||||
slots.register({
|
||||
name: 'conversation.chat.node',
|
||||
key: 'steering'
|
||||
}, UserMarkdownMessageNodeView);
|
||||
});
|
||||
}
|
||||
7
lib/index.js
Normal file
7
lib/index.js
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Host half of dsh-user-markdown-plugin.
|
||||
* User message markdown rendering is entirely browser-side.
|
||||
*/
|
||||
export function apply(ctx) {
|
||||
// No host side effects required.
|
||||
}
|
||||
Reference in New Issue
Block a user