7 changed files with 559 additions and 64 deletions
@ -0,0 +1,402 @@ |
|||||||
|
import Delta from '../../vendor/quill-delta/quill-delta.index.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Optional: enforce canonical delta contract during development. |
||||||
|
* Enable by passing { strict: true } to deltaToMarkdown(). |
||||||
|
*/ |
||||||
|
function assertCanonicalDelta(delta) { |
||||||
|
if (!delta || !Array.isArray(delta.ops)) throw new Error('Invalid delta: missing ops array'); |
||||||
|
|
||||||
|
for (const op of delta.ops) { |
||||||
|
// Embeds are allowed
|
||||||
|
if (op.insert && typeof op.insert === 'object') continue; |
||||||
|
|
||||||
|
if (typeof op.insert === 'string') { |
||||||
|
const isNewline = op.insert === '\n'; |
||||||
|
|
||||||
|
// Canonical rule: text ops must not contain embedded newlines
|
||||||
|
if (!isNewline && op.insert.includes('\n')) { |
||||||
|
throw new Error('Non-canonical delta: text op contains embedded \\n'); |
||||||
|
} |
||||||
|
|
||||||
|
// Canonical rule: block attrs must appear only on newline ops
|
||||||
|
if (!isNewline && op.attributes) { |
||||||
|
const blockKeys = ['header', 'blockquote', 'list', 'indent', 'code-block']; |
||||||
|
for (const k of blockKeys) { |
||||||
|
if (k in op.attributes) { |
||||||
|
throw new Error(`Non-canonical delta: block attr "${k}" found on text op`); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// --- Delta to Markdown (canonical) ---
|
||||||
|
export function deltaToMarkdown(delta, opts = {}) { |
||||||
|
const options = { |
||||||
|
strict: false, // set true during dev to catch non-canonical deltas
|
||||||
|
fence: '```', |
||||||
|
orderedListStyle: 'one', // 'one' or 'increment'
|
||||||
|
embedToMarkdown: (embed) => { |
||||||
|
if (!embed || typeof embed !== 'object') return ''; |
||||||
|
if (embed.image) return `})`; |
||||||
|
if (embed.video) return String(embed.video); |
||||||
|
if (embed.nostr) return String(embed.nostr); |
||||||
|
return ''; |
||||||
|
}, |
||||||
|
...opts, |
||||||
|
}; |
||||||
|
|
||||||
|
if (!delta || !Array.isArray(delta.ops)) return ''; |
||||||
|
if (options.strict) assertCanonicalDelta(delta); |
||||||
|
|
||||||
|
let md = ''; |
||||||
|
let line = ''; |
||||||
|
|
||||||
|
// Block state
|
||||||
|
let inCodeBlock = false; |
||||||
|
let inList = null; // 'ordered' | 'bullet' | null
|
||||||
|
let listCounter = 1; |
||||||
|
|
||||||
|
const escapeText = (s) => |
||||||
|
String(s) |
||||||
|
.replace(/\\/g, '\\\\') |
||||||
|
.replace(/([*_`[\]~])/g, '\\$1'); |
||||||
|
|
||||||
|
const escapeLinkText = (s) => |
||||||
|
String(s).replace(/\\/g, '\\\\').replace(/([\[\]])/g, '\\$1'); |
||||||
|
|
||||||
|
const escapeLinkUrl = (s) => String(s).replace(/\s/g, '%20'); |
||||||
|
|
||||||
|
function renderInlineText(text, attrs = {}) { |
||||||
|
if (!text) return ''; |
||||||
|
|
||||||
|
if (attrs.code) { |
||||||
|
const t = String(text).replace(/`/g, '\\`'); |
||||||
|
return `\`${t}\``; |
||||||
|
} |
||||||
|
|
||||||
|
let out = escapeText(text); |
||||||
|
|
||||||
|
if (attrs.link) { |
||||||
|
out = `[${escapeLinkText(out)}](${escapeLinkUrl(attrs.link)})`; |
||||||
|
} |
||||||
|
|
||||||
|
// wrapper order is a choice; keep it stable
|
||||||
|
if (attrs.strike) out = `~~${out}~~`; |
||||||
|
if (attrs.bold) out = `**${out}**`; |
||||||
|
if (attrs.italic) out = `*${out}*`; |
||||||
|
|
||||||
|
return out; |
||||||
|
} |
||||||
|
|
||||||
|
const closeList = () => { |
||||||
|
if (inList) { |
||||||
|
md += '\n'; |
||||||
|
inList = null; |
||||||
|
listCounter = 1; |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const openFence = () => { |
||||||
|
if (!inCodeBlock) { |
||||||
|
closeList(); |
||||||
|
md += `${options.fence}\n`; |
||||||
|
inCodeBlock = true; |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
const closeFence = () => { |
||||||
|
if (inCodeBlock) { |
||||||
|
md += `${options.fence}\n\n`; |
||||||
|
inCodeBlock = false; |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
function flushLine(attrs = {}) { |
||||||
|
// code block line
|
||||||
|
if (attrs['code-block']) { |
||||||
|
openFence(); |
||||||
|
md += `${line}\n`; // raw
|
||||||
|
line = ''; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// leaving code block?
|
||||||
|
closeFence(); |
||||||
|
|
||||||
|
const indent = Number.isFinite(attrs.indent) ? attrs.indent : 0; |
||||||
|
const indentPrefix = indent > 0 ? ' '.repeat(indent) : ''; |
||||||
|
|
||||||
|
// list line
|
||||||
|
if (attrs.list === 'ordered' || attrs.list === 'bullet') { |
||||||
|
const newType = attrs.list; |
||||||
|
if (inList && inList !== newType) md += '\n'; |
||||||
|
if (!inList) listCounter = 1; |
||||||
|
inList = newType; |
||||||
|
|
||||||
|
const marker = |
||||||
|
newType === 'ordered' |
||||||
|
? (options.orderedListStyle === 'increment' ? `${listCounter++}. ` : '1. ') |
||||||
|
: '- '; |
||||||
|
|
||||||
|
md += `${indentPrefix}${marker}${line}\n`; |
||||||
|
line = ''; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// not list anymore
|
||||||
|
closeList(); |
||||||
|
|
||||||
|
// blockquote
|
||||||
|
if (attrs.blockquote) { |
||||||
|
md += line.length ? `> ${line}\n` : '>\n'; |
||||||
|
line = ''; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// header
|
||||||
|
if (attrs.header) { |
||||||
|
const level = Math.min(6, Math.max(1, Number(attrs.header) || 1)); |
||||||
|
md += `${'#'.repeat(level)} ${line}\n`; |
||||||
|
line = ''; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
// normal / blank
|
||||||
|
if (!line.length) { |
||||||
|
md += '\n'; |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
md += `${line}\n`; |
||||||
|
line = ''; |
||||||
|
} |
||||||
|
|
||||||
|
for (const op of delta.ops) { |
||||||
|
// embeds
|
||||||
|
if (op.insert && typeof op.insert === 'object') { |
||||||
|
const embedMd = options.embedToMarkdown(op.insert); |
||||||
|
if (embedMd) line += embedMd; |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// newline
|
||||||
|
if (op.insert === '\n') { |
||||||
|
flushLine(op.attributes || {}); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// text
|
||||||
|
if (typeof op.insert === 'string') { |
||||||
|
// If you truly enforce canonical, this is safe.
|
||||||
|
// If you want mild robustness without supporting "weird attrs",
|
||||||
|
// you can split embedded newlines but apply NO block attrs here:
|
||||||
|
if (op.insert.includes('\n')) { |
||||||
|
// Non-canonical: split without block formatting support
|
||||||
|
const parts = op.insert.split('\n'); |
||||||
|
for (let p = 0; p < parts.length; p++) { |
||||||
|
if (parts[p]) line += renderInlineText(parts[p], op.attributes || {}); |
||||||
|
if (p < parts.length - 1) flushLine({}); |
||||||
|
} |
||||||
|
} else { |
||||||
|
if (inCodeBlock) line += op.insert; // raw inside fence
|
||||||
|
else line += renderInlineText(op.insert, op.attributes || {}); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// finalize
|
||||||
|
if (line.length) { |
||||||
|
closeFence(); |
||||||
|
closeList(); |
||||||
|
md += `${line}\n`; |
||||||
|
line = ''; |
||||||
|
} |
||||||
|
|
||||||
|
closeFence(); |
||||||
|
closeList(); |
||||||
|
|
||||||
|
md = md.replace(/\n{4,}/g, '\n\n\n'); |
||||||
|
return md.replace(/[ \t]+\n/g, '\n').replace(/\s+$/, ''); |
||||||
|
} |
||||||
|
|
||||||
|
// --- Markdown to Delta (canonical) ---
|
||||||
|
export function markdownToDelta(md, opts = {}) { |
||||||
|
const options = { |
||||||
|
fence: '```', |
||||||
|
indentSize: 2, // spaces per indent level for lists
|
||||||
|
...opts, |
||||||
|
}; |
||||||
|
|
||||||
|
if (!md) return new Delta([{ insert: '\n' }]); |
||||||
|
|
||||||
|
const lines = md.replace(/\r\n/g, '\n').split('\n'); |
||||||
|
const ops = []; |
||||||
|
let inCodeBlock = false; |
||||||
|
|
||||||
|
for (const rawLine of lines) { |
||||||
|
const line = rawLine; |
||||||
|
|
||||||
|
// code fence toggle
|
||||||
|
if (line.trim().startsWith(options.fence)) { |
||||||
|
inCodeBlock = !inCodeBlock; |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// code block content: emit per-line canonical Quill code-block
|
||||||
|
if (inCodeBlock) { |
||||||
|
if (line.length) ops.push({ insert: line }); |
||||||
|
ops.push({ insert: '\n', attributes: { 'code-block': true } }); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// blank line
|
||||||
|
if (line.trim() === '') { |
||||||
|
ops.push({ insert: '\n' }); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// header
|
||||||
|
const headerMatch = line.match(/^(#{1,6})\s+(.*)$/); |
||||||
|
if (headerMatch) { |
||||||
|
const level = headerMatch[1].length; |
||||||
|
const content = headerMatch[2] ?? ''; |
||||||
|
ops.push(...inlineMarkdownToOps(content)); |
||||||
|
ops.push({ insert: '\n', attributes: { header: level } }); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// blockquote (canonical: attrs on newline)
|
||||||
|
const quoteMatch = line.match(/^>\s?(.*)$/); |
||||||
|
if (quoteMatch) { |
||||||
|
const content = quoteMatch[1] ?? ''; |
||||||
|
ops.push(...inlineMarkdownToOps(content)); |
||||||
|
ops.push({ insert: '\n', attributes: { blockquote: true } }); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// lists with indent
|
||||||
|
const leadingSpaces = (line.match(/^(\s*)/)?.[1] ?? '').replace(/\t/g, ' ').length; |
||||||
|
const indent = Math.floor(leadingSpaces / options.indentSize); |
||||||
|
const trimmed = line.trimStart(); |
||||||
|
|
||||||
|
const olMatch = trimmed.match(/^\d+\.\s+(.*)$/); |
||||||
|
if (olMatch) { |
||||||
|
const content = olMatch[1] ?? ''; |
||||||
|
ops.push(...inlineMarkdownToOps(content)); |
||||||
|
ops.push({ insert: '\n', attributes: { list: 'ordered', ...(indent ? { indent } : {}) } }); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
const ulMatch = trimmed.match(/^[-*]\s+(.*)$/); |
||||||
|
if (ulMatch) { |
||||||
|
const content = ulMatch[1] ?? ''; |
||||||
|
ops.push(...inlineMarkdownToOps(content)); |
||||||
|
ops.push({ insert: '\n', attributes: { list: 'bullet', ...(indent ? { indent } : {}) } }); |
||||||
|
continue; |
||||||
|
} |
||||||
|
|
||||||
|
// paragraph
|
||||||
|
ops.push(...inlineMarkdownToOps(line)); |
||||||
|
ops.push({ insert: '\n' }); |
||||||
|
} |
||||||
|
|
||||||
|
// ensure trailing newline
|
||||||
|
if (ops.length === 0 || ops[ops.length - 1].insert !== '\n') ops.push({ insert: '\n' }); |
||||||
|
|
||||||
|
return new Delta(ops); |
||||||
|
} |
||||||
|
|
||||||
|
// Deterministic inline parser for your subset.
|
||||||
|
// (This replaces regex-overlap issues in parseInlineOps.)
|
||||||
|
function inlineMarkdownToOps(text) { |
||||||
|
const ops = []; |
||||||
|
let i = 0; |
||||||
|
|
||||||
|
const pushText = (t) => { if (t) ops.push({ insert: t }); }; |
||||||
|
|
||||||
|
while (i < text.length) { |
||||||
|
// inline code
|
||||||
|
if (text[i] === '`') { |
||||||
|
const end = text.indexOf('`', i + 1); |
||||||
|
if (end !== -1) { |
||||||
|
const content = text.slice(i + 1, end); |
||||||
|
if (content) ops.push({ insert: content, attributes: { code: true } }); |
||||||
|
i = end + 1; |
||||||
|
continue; |
||||||
|
} |
||||||
|
pushText('`'); i++; continue; |
||||||
|
} |
||||||
|
|
||||||
|
// link
|
||||||
|
if (text[i] === '[') { |
||||||
|
const closeBracket = text.indexOf(']', i + 1); |
||||||
|
if (closeBracket !== -1 && text[closeBracket + 1] === '(') { |
||||||
|
const closeParen = text.indexOf(')', closeBracket + 2); |
||||||
|
if (closeParen !== -1) { |
||||||
|
const label = text.slice(i + 1, closeBracket); |
||||||
|
const url = text.slice(closeBracket + 2, closeParen); |
||||||
|
if (label) ops.push({ insert: label, attributes: { link: url } }); |
||||||
|
i = closeParen + 1; |
||||||
|
continue; |
||||||
|
} |
||||||
|
} |
||||||
|
pushText('['); i++; continue; |
||||||
|
} |
||||||
|
|
||||||
|
// bold
|
||||||
|
if (text.startsWith('**', i)) { |
||||||
|
const end = text.indexOf('**', i + 2); |
||||||
|
if (end !== -1) { |
||||||
|
const content = text.slice(i + 2, end); |
||||||
|
if (content) ops.push({ insert: content, attributes: { bold: true } }); |
||||||
|
i = end + 2; |
||||||
|
continue; |
||||||
|
} |
||||||
|
pushText('*'); i++; continue; |
||||||
|
} |
||||||
|
|
||||||
|
// strike
|
||||||
|
if (text.startsWith('~~', i)) { |
||||||
|
const end = text.indexOf('~~', i + 2); |
||||||
|
if (end !== -1) { |
||||||
|
const content = text.slice(i + 2, end); |
||||||
|
if (content) ops.push({ insert: content, attributes: { strike: true } }); |
||||||
|
i = end + 2; |
||||||
|
continue; |
||||||
|
} |
||||||
|
pushText('~'); i++; continue; |
||||||
|
} |
||||||
|
|
||||||
|
// italic
|
||||||
|
if (text[i] === '*') { |
||||||
|
const end = text.indexOf('*', i + 1); |
||||||
|
if (end !== -1) { |
||||||
|
const content = text.slice(i + 1, end); |
||||||
|
if (content) ops.push({ insert: content, attributes: { italic: true } }); |
||||||
|
i = end + 1; |
||||||
|
continue; |
||||||
|
} |
||||||
|
pushText('*'); i++; continue; |
||||||
|
} |
||||||
|
|
||||||
|
// plain run
|
||||||
|
const next = nextSpecialIndex(text, i); |
||||||
|
pushText(text.slice(i, next)); |
||||||
|
i = next; |
||||||
|
} |
||||||
|
|
||||||
|
return ops; |
||||||
|
} |
||||||
|
|
||||||
|
function nextSpecialIndex(text, start) { |
||||||
|
const specials = ['`', '[', '*', '~']; |
||||||
|
let min = text.length; |
||||||
|
for (const ch of specials) { |
||||||
|
const idx = text.indexOf(ch, start); |
||||||
|
if (idx !== -1 && idx < min) min = idx; |
||||||
|
} |
||||||
|
return min; |
||||||
|
} |
||||||
Loading…
Reference in new issue