Browse Source

Editor: conversion

imwald
Nuša Pukšič 2 weeks ago
parent
commit
5d90ea94ed
  1. 214
      assets/controllers/editor/conversion.js
  2. 3
      assets/controllers/publishing/quill_controller.js

214
assets/controllers/editor/conversion.js

@ -1,43 +1,33 @@
import Delta from '../../vendor/quill-delta/quill-delta.index.js'; // assets/controllers/editor/conversion.js
//
/** // Canonical Delta <-> Markdown conversion for QuillJS.
* Optional: enforce canonical delta contract during development. //
* Enable by passing { strict: true } to deltaToMarkdown(). // Canonical delta contract (enforced by markdownToDelta; assumed by deltaToMarkdown):
*/ // - Newlines are standalone ops: { insert: '\n', attributes?: { ...blockAttrs } }
function assertCanonicalDelta(delta) { // - Block attrs live only on newline ops: header, blockquote, list, indent, code-block
if (!delta || !Array.isArray(delta.ops)) throw new Error('Invalid delta: missing ops array'); // - Inline attrs live on text ops: bold, italic, strike, code, link
// - Text ops do not contain embedded '\n' (deltaToMarkdown tolerates splitting, but no block attrs from text ops)
//
// Markdown subset supported:
// - #..###### headings
// - > blockquote (single-line)
// - ordered lists ("1. item") and bullet lists ("- item" or "* item") with indentation by leading spaces
// - fenced code blocks ```
// - inline: `code`, **bold**, *italic*, ~~strike~~, [label](url)
//
// Underline intentionally unsupported.
for (const op of delta.ops) { import Delta from '../../vendor/quill-delta/quill-delta.index.js';
// 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')) { // Delta -> Markdown
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 = {}) { export function deltaToMarkdown(delta, opts = {}) {
const options = { const options = {
strict: false, // set true during dev to catch non-canonical deltas strict: false, // if true, throw on non-canonical deltas
fence: '```', fence: '```',
orderedListStyle: 'one', // 'one' or 'increment' orderedListStyle: 'increment', // 'one' | 'increment'
embedToMarkdown: (embed) => { embedToMarkdown: (embed) => {
if (!embed || typeof embed !== 'object') return ''; if (!embed || typeof embed !== 'object') return '';
if (embed.image) return `![](${String(embed.image)})`; if (embed.image) return `![](${String(embed.image)})`;
@ -59,6 +49,29 @@ export function deltaToMarkdown(delta, opts = {}) {
let inList = null; // 'ordered' | 'bullet' | null let inList = null; // 'ordered' | 'bullet' | null
let listCounter = 1; let listCounter = 1;
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;
}
};
const escapeText = (s) => const escapeText = (s) =>
String(s) String(s)
.replace(/\\/g, '\\\\') .replace(/\\/g, '\\\\')
@ -69,12 +82,11 @@ export function deltaToMarkdown(delta, opts = {}) {
const escapeLinkUrl = (s) => String(s).replace(/\s/g, '%20'); const escapeLinkUrl = (s) => String(s).replace(/\s/g, '%20');
function renderInlineText(text, attrs = {}) { function renderInline(text, attrs = {}) {
if (!text) return ''; if (!text) return '';
if (attrs.code) { if (attrs.code) {
const t = String(text).replace(/`/g, '\\`'); return `\`${String(text).replace(/`/g, '\\`')}\``;
return `\`${t}\``;
} }
let out = escapeText(text); let out = escapeText(text);
@ -83,7 +95,7 @@ export function deltaToMarkdown(delta, opts = {}) {
out = `[${escapeLinkText(out)}](${escapeLinkUrl(attrs.link)})`; out = `[${escapeLinkText(out)}](${escapeLinkUrl(attrs.link)})`;
} }
// wrapper order is a choice; keep it stable // Stable wrapper order
if (attrs.strike) out = `~~${out}~~`; if (attrs.strike) out = `~~${out}~~`;
if (attrs.bold) out = `**${out}**`; if (attrs.bold) out = `**${out}**`;
if (attrs.italic) out = `*${out}*`; if (attrs.italic) out = `*${out}*`;
@ -91,34 +103,13 @@ export function deltaToMarkdown(delta, opts = {}) {
return out; return out;
} }
const closeList = () => { function flushLine(blockAttrs = {}) {
if (inList) { const attrs = blockAttrs || {};
md += '\n';
inList = null;
listCounter = 1;
}
};
const openFence = () => {
if (!inCodeBlock) {
closeList();
md += `${options.fence}\n`;
inCodeBlock = true;
}
};
const closeFence = () => { // code-block line
if (inCodeBlock) {
md += `${options.fence}\n\n`;
inCodeBlock = false;
}
};
function flushLine(attrs = {}) {
// code block line
if (attrs['code-block']) { if (attrs['code-block']) {
openFence(); openFence();
md += `${line}\n`; // raw md += `${line}\n`; // raw content
line = ''; line = '';
return; return;
} }
@ -132,8 +123,10 @@ export function deltaToMarkdown(delta, opts = {}) {
// list line // list line
if (attrs.list === 'ordered' || attrs.list === 'bullet') { if (attrs.list === 'ordered' || attrs.list === 'bullet') {
const newType = attrs.list; const newType = attrs.list;
if (inList && inList !== newType) md += '\n'; if (inList && inList !== newType) md += '\n';
if (!inList) listCounter = 1; if (!inList) listCounter = 1;
inList = newType; inList = newType;
const marker = const marker =
@ -158,13 +151,13 @@ export function deltaToMarkdown(delta, opts = {}) {
// header // header
if (attrs.header) { if (attrs.header) {
const level = Math.min(6, Math.max(1, Number(attrs.header) || 1)); const level = clampInt(attrs.header, 1, 6);
md += `${'#'.repeat(level)} ${line}\n`; md += `${'#'.repeat(level)} ${line}\n`;
line = ''; line = '';
return; return;
} }
// normal / blank // normal / blank line
if (!line.length) { if (!line.length) {
md += '\n'; md += '\n';
return; return;
@ -175,14 +168,14 @@ export function deltaToMarkdown(delta, opts = {}) {
} }
for (const op of delta.ops) { for (const op of delta.ops) {
// embeds // embed
if (op.insert && typeof op.insert === 'object') { if (op.insert && typeof op.insert === 'object') {
const embedMd = options.embedToMarkdown(op.insert); const embedMd = options.embedToMarkdown(op.insert);
if (embedMd) line += embedMd; if (embedMd) line += embedMd;
continue; continue;
} }
// newline // newline (block attrs live here)
if (op.insert === '\n') { if (op.insert === '\n') {
flushLine(op.attributes || {}); flushLine(op.attributes || {});
continue; continue;
@ -190,19 +183,15 @@ export function deltaToMarkdown(delta, opts = {}) {
// text // text
if (typeof op.insert === 'string') { if (typeof op.insert === 'string') {
// If you truly enforce canonical, this is safe. // Tolerate embedded newlines (no block attrs here).
// 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')) { if (op.insert.includes('\n')) {
// Non-canonical: split without block formatting support
const parts = op.insert.split('\n'); const parts = op.insert.split('\n');
for (let p = 0; p < parts.length; p++) { for (let p = 0; p < parts.length; p++) {
if (parts[p]) line += renderInlineText(parts[p], op.attributes || {}); if (parts[p]) line += inCodeBlock ? parts[p] : renderInline(parts[p], op.attributes || {});
if (p < parts.length - 1) flushLine({}); if (p < parts.length - 1) flushLine({});
} }
} else { } else {
if (inCodeBlock) line += op.insert; // raw inside fence line += inCodeBlock ? op.insert : renderInline(op.insert, op.attributes || {});
else line += renderInlineText(op.insert, op.attributes || {});
} }
} }
} }
@ -222,11 +211,38 @@ export function deltaToMarkdown(delta, opts = {}) {
return md.replace(/[ \t]+\n/g, '\n').replace(/\s+$/, ''); return md.replace(/[ \t]+\n/g, '\n').replace(/\s+$/, '');
} }
// --- Markdown to Delta (canonical) --- function assertCanonicalDelta(delta) {
const blockKeys = ['header', 'blockquote', 'list', 'indent', 'code-block'];
for (const op of delta.ops) {
if (op.insert && typeof op.insert === 'object') continue;
if (typeof op.insert === 'string') {
const isNewline = op.insert === '\n';
if (!isNewline && op.insert.includes('\n')) {
throw new Error('Non-canonical delta: text op contains embedded \\n');
}
if (!isNewline && op.attributes) {
for (const k of blockKeys) {
if (k in op.attributes) {
throw new Error(`Non-canonical delta: block attr "${k}" found on text op`);
}
}
}
}
}
}
// ---------------------------
// Markdown -> Delta (canonical)
// ---------------------------
export function markdownToDelta(md, opts = {}) { export function markdownToDelta(md, opts = {}) {
const options = { const options = {
fence: '```', fence: '```',
indentSize: 2, // spaces per indent level for lists indentSize: 2, // leading spaces per list indent level
...opts, ...opts,
}; };
@ -239,13 +255,13 @@ export function markdownToDelta(md, opts = {}) {
for (const rawLine of lines) { for (const rawLine of lines) {
const line = rawLine; const line = rawLine;
// code fence toggle // fence toggle
if (line.trim().startsWith(options.fence)) { if (line.trim().startsWith(options.fence)) {
inCodeBlock = !inCodeBlock; inCodeBlock = !inCodeBlock;
continue; continue;
} }
// code block content: emit per-line canonical Quill code-block // code-block content: canonical Quill style (attrs on newline per line)
if (inCodeBlock) { if (inCodeBlock) {
if (line.length) ops.push({ insert: line }); if (line.length) ops.push({ insert: line });
ops.push({ insert: '\n', attributes: { 'code-block': true } }); ops.push({ insert: '\n', attributes: { 'code-block': true } });
@ -268,7 +284,7 @@ export function markdownToDelta(md, opts = {}) {
continue; continue;
} }
// blockquote (canonical: attrs on newline) // blockquote
const quoteMatch = line.match(/^>\s?(.*)$/); const quoteMatch = line.match(/^>\s?(.*)$/);
if (quoteMatch) { if (quoteMatch) {
const content = quoteMatch[1] ?? ''; const content = quoteMatch[1] ?? '';
@ -277,11 +293,12 @@ export function markdownToDelta(md, opts = {}) {
continue; continue;
} }
// lists with indent // list indent by leading spaces (tabs treated as 4 spaces)
const leadingSpaces = (line.match(/^(\s*)/)?.[1] ?? '').replace(/\t/g, ' ').length; const leadingSpaces = (line.match(/^(\s*)/)?.[1] ?? '').replace(/\t/g, ' ').length;
const indent = Math.floor(leadingSpaces / options.indentSize); const indent = Math.floor(leadingSpaces / options.indentSize);
const trimmed = line.trimStart(); const trimmed = line.trimStart();
// ordered list
const olMatch = trimmed.match(/^\d+\.\s+(.*)$/); const olMatch = trimmed.match(/^\d+\.\s+(.*)$/);
if (olMatch) { if (olMatch) {
const content = olMatch[1] ?? ''; const content = olMatch[1] ?? '';
@ -290,6 +307,7 @@ export function markdownToDelta(md, opts = {}) {
continue; continue;
} }
// bullet list
const ulMatch = trimmed.match(/^[-*]\s+(.*)$/); const ulMatch = trimmed.match(/^[-*]\s+(.*)$/);
if (ulMatch) { if (ulMatch) {
const content = ulMatch[1] ?? ''; const content = ulMatch[1] ?? '';
@ -304,13 +322,17 @@ export function markdownToDelta(md, opts = {}) {
} }
// ensure trailing newline // ensure trailing newline
if (ops.length === 0 || ops[ops.length - 1].insert !== '\n') ops.push({ insert: '\n' }); if (ops.length === 0 || ops[ops.length - 1].insert !== '\n') {
ops.push({ insert: '\n' });
}
return new Delta(ops); return new Delta(ops);
} }
// Deterministic inline parser for your subset. // ---------------------------
// (This replaces regex-overlap issues in parseInlineOps.) // Inline markdown parsing (subset)
// ---------------------------
function inlineMarkdownToOps(text) { function inlineMarkdownToOps(text) {
const ops = []; const ops = [];
let i = 0; let i = 0;
@ -318,7 +340,7 @@ function inlineMarkdownToOps(text) {
const pushText = (t) => { if (t) ops.push({ insert: t }); }; const pushText = (t) => { if (t) ops.push({ insert: t }); };
while (i < text.length) { while (i < text.length) {
// inline code // inline code: `...`
if (text[i] === '`') { if (text[i] === '`') {
const end = text.indexOf('`', i + 1); const end = text.indexOf('`', i + 1);
if (end !== -1) { if (end !== -1) {
@ -327,10 +349,10 @@ function inlineMarkdownToOps(text) {
i = end + 1; i = end + 1;
continue; continue;
} }
pushText('`'); i++; continue; pushText('`'); i += 1; continue;
} }
// link // link: [label](url)
if (text[i] === '[') { if (text[i] === '[') {
const closeBracket = text.indexOf(']', i + 1); const closeBracket = text.indexOf(']', i + 1);
if (closeBracket !== -1 && text[closeBracket + 1] === '(') { if (closeBracket !== -1 && text[closeBracket + 1] === '(') {
@ -343,10 +365,10 @@ function inlineMarkdownToOps(text) {
continue; continue;
} }
} }
pushText('['); i++; continue; pushText('['); i += 1; continue;
} }
// bold // bold: **...**
if (text.startsWith('**', i)) { if (text.startsWith('**', i)) {
const end = text.indexOf('**', i + 2); const end = text.indexOf('**', i + 2);
if (end !== -1) { if (end !== -1) {
@ -355,10 +377,10 @@ function inlineMarkdownToOps(text) {
i = end + 2; i = end + 2;
continue; continue;
} }
pushText('*'); i++; continue; pushText('*'); i += 1; continue;
} }
// strike // strike: ~~...~~
if (text.startsWith('~~', i)) { if (text.startsWith('~~', i)) {
const end = text.indexOf('~~', i + 2); const end = text.indexOf('~~', i + 2);
if (end !== -1) { if (end !== -1) {
@ -367,10 +389,10 @@ function inlineMarkdownToOps(text) {
i = end + 2; i = end + 2;
continue; continue;
} }
pushText('~'); i++; continue; pushText('~'); i += 1; continue;
} }
// italic // italic: *...*
if (text[i] === '*') { if (text[i] === '*') {
const end = text.indexOf('*', i + 1); const end = text.indexOf('*', i + 1);
if (end !== -1) { if (end !== -1) {
@ -379,7 +401,7 @@ function inlineMarkdownToOps(text) {
i = end + 1; i = end + 1;
continue; continue;
} }
pushText('*'); i++; continue; pushText('*'); i += 1; continue;
} }
// plain run // plain run
@ -400,3 +422,9 @@ function nextSpecialIndex(text, start) {
} }
return min; return min;
} }
function clampInt(value, min, max) {
const n = Number(value);
if (!Number.isFinite(n)) return min;
return Math.min(max, Math.max(min, Math.trunc(n)));
}

3
assets/controllers/publishing/quill_controller.js

@ -184,8 +184,7 @@ export default class extends Controller {
// --- Quill → Markdown sync --- // --- Quill → Markdown sync ---
if (this.hasMarkdownTarget) { if (this.hasMarkdownTarget) {
if (window.deltaToMarkdown) { if (window.deltaToMarkdown) {
const md = window.deltaToMarkdown(this.quill.getContents()); this.markdownTarget.value = window.deltaToMarkdown(this.quill.getContents());
this.markdownTarget.value = md;
// Trigger event for reactivity // Trigger event for reactivity
this.markdownTarget.dispatchEvent(new Event('input', { bubbles: true })); this.markdownTarget.dispatchEvent(new Event('input', { bubbles: true }));
// Also trigger a custom event for layout controller // Also trigger a custom event for layout controller

Loading…
Cancel
Save