clone of github.com/decent-newsroom/newsroom
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

402 lines
11 KiB

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 `![](${String(embed.image)})`;
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;
}