@oix1987/yjd 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Serialization for yjd content — HTML <-> Markdown and HTML <-> JSON.
3
+ *
4
+ * Targeted at the HTML yjd emits (headings, inline marks, lists, links,
5
+ * images, blockquote, code, tables, hr, and mention tokens). Browser-only
6
+ * (uses the DOM). Zero dependencies.
7
+ *
8
+ * import { htmlToMarkdown, markdownToHtml, domToJson, jsonToHtml } from '.../serialize.js'
9
+ */
10
+
11
+ /* ============================ HTML -> Markdown ============================ */
12
+
13
+ export function htmlToMarkdown(html) {
14
+ const root = document.createElement('div');
15
+ root.innerHTML = html || '';
16
+ return blocksToMd(root, 0).replace(/\n{3,}/g, '\n\n').trim() + '\n';
17
+ }
18
+
19
+ function blocksToMd(parent, depth) {
20
+ let out = '';
21
+ parent.childNodes.forEach((node) => { out += nodeBlock(node, depth); });
22
+ return out;
23
+ }
24
+
25
+ function nodeBlock(node, depth) {
26
+ if (node.nodeType === 3) {
27
+ const t = node.textContent.replace(/\s+/g, ' ');
28
+ return t.trim() ? t + '\n\n' : '';
29
+ }
30
+ if (node.nodeType !== 1) return '';
31
+ const tag = node.tagName;
32
+ switch (tag) {
33
+ case 'H1': case 'H2': case 'H3': case 'H4': case 'H5': case 'H6':
34
+ return '#'.repeat(+tag[1]) + ' ' + inline(node) + '\n\n';
35
+ case 'P': case 'DIV': {
36
+ const c = inline(node);
37
+ return c.trim() ? c + '\n\n' : '';
38
+ }
39
+ case 'BLOCKQUOTE':
40
+ return inline(node).split('\n').map((l) => '> ' + l).join('\n') + '\n\n';
41
+ case 'PRE':
42
+ return '```\n' + node.textContent.replace(/\n$/, '') + '\n```\n\n';
43
+ case 'UL': return listToMd(node, depth, false) + '\n';
44
+ case 'OL': return listToMd(node, depth, true) + '\n';
45
+ case 'HR': return '---\n\n';
46
+ case 'TABLE': return tableToMd(node) + '\n';
47
+ case 'FIGURE': return blocksToMd(node, depth);
48
+ case 'IMG': return imgToMd(node) + '\n\n';
49
+ case 'BR': return '\n';
50
+ default:
51
+ return inline(node) + '\n\n';
52
+ }
53
+ }
54
+
55
+ function listToMd(node, depth, ordered) {
56
+ let out = '', i = 1;
57
+ node.childNodes.forEach((li) => {
58
+ if (li.nodeType !== 1 || li.tagName !== 'LI') return;
59
+ const marker = ordered ? (i++) + '. ' : '- ';
60
+ const pad = ' '.repeat(depth);
61
+ let text = '', nested = '';
62
+ li.childNodes.forEach((ch) => {
63
+ if (ch.nodeType === 1 && (ch.tagName === 'UL' || ch.tagName === 'OL')) {
64
+ nested += listToMd(ch, depth + 1, ch.tagName === 'OL');
65
+ } else {
66
+ text += inlineNode(ch);
67
+ }
68
+ });
69
+ out += pad + marker + text.trim() + '\n' + nested;
70
+ });
71
+ return out;
72
+ }
73
+
74
+ function tableToMd(node) {
75
+ const rows = [...node.querySelectorAll('tr')];
76
+ if (!rows.length) return '';
77
+ const cells = (r) => [...r.children].map((c) => inline(c).replace(/\|/g, '\\|').trim());
78
+ const head = cells(rows[0]);
79
+ let out = '| ' + head.join(' | ') + ' |\n| ' + head.map(() => '---').join(' | ') + ' |\n';
80
+ rows.slice(1).forEach((r) => { out += '| ' + cells(r).join(' | ') + ' |\n'; });
81
+ return out;
82
+ }
83
+
84
+ function imgToMd(node) {
85
+ return `![${node.getAttribute('alt') || ''}](${node.getAttribute('src') || ''})`;
86
+ }
87
+
88
+ function inline(node) {
89
+ let out = '';
90
+ node.childNodes.forEach((ch) => { out += inlineNode(ch); });
91
+ return out;
92
+ }
93
+
94
+ function inlineNode(node) {
95
+ if (node.nodeType === 3) return node.textContent;
96
+ if (node.nodeType !== 1) return '';
97
+ const tag = node.tagName;
98
+ if (node.classList && node.classList.contains('mention')) {
99
+ const id = node.getAttribute('data-id') || '';
100
+ const name = (node.textContent || '').replace(/^[@#]/, '');
101
+ const trig = (node.textContent || '@')[0];
102
+ return `${trig}[${name}](${id})`;
103
+ }
104
+ switch (tag) {
105
+ case 'B': case 'STRONG': return '**' + inline(node) + '**';
106
+ case 'I': case 'EM': return '*' + inline(node) + '*';
107
+ case 'S': case 'STRIKE': case 'DEL': return '~~' + inline(node) + '~~';
108
+ case 'U': return '<u>' + inline(node) + '</u>';
109
+ case 'CODE': return '`' + node.textContent + '`';
110
+ case 'A': return '[' + inline(node) + '](' + (node.getAttribute('href') || '') + ')';
111
+ case 'IMG': return imgToMd(node);
112
+ case 'BR': return ' \n';
113
+ default: return inline(node); // spans (colour/font) → keep text only
114
+ }
115
+ }
116
+
117
+ /* ============================ Markdown -> HTML ============================ */
118
+
119
+ export function markdownToHtml(md) {
120
+ const lines = (md || '').replace(/\r\n/g, '\n').split('\n');
121
+ let html = '', i = 0;
122
+ const isList = (l) => /^\s*([-*+]|\d+\.)\s+/.test(l);
123
+ while (i < lines.length) {
124
+ const line = lines[i];
125
+ if (/^\s*$/.test(line)) { i++; continue; }
126
+ if (/^---+$/.test(line.trim())) { html += '<hr>'; i++; continue; }
127
+ const h = line.match(/^(#{1,6})\s+(.*)$/);
128
+ if (h) { html += `<h${h[1].length}>${inlineMd(h[2])}</h${h[1].length}>`; i++; continue; }
129
+ if (/^```/.test(line)) {
130
+ i++; let code = '';
131
+ while (i < lines.length && !/^```/.test(lines[i])) { code += lines[i] + '\n'; i++; }
132
+ i++; html += '<pre>' + escapeHtml(code.replace(/\n$/, '')) + '</pre>'; continue;
133
+ }
134
+ if (/^>\s?/.test(line)) {
135
+ const q = [];
136
+ while (i < lines.length && /^>\s?/.test(lines[i])) { q.push(lines[i].replace(/^>\s?/, '')); i++; }
137
+ html += '<blockquote>' + inlineMd(q.join(' ')) + '</blockquote>'; continue;
138
+ }
139
+ if (/^\|.*\|\s*$/.test(line) && i + 1 < lines.length && /^\|[\s:|-]+\|\s*$/.test(lines[i + 1])) {
140
+ const r = parseTable(lines, i); html += r.html; i = r.next; continue;
141
+ }
142
+ if (isList(line)) { const r = parseList(lines, i, 0); html += r.html; i = r.next; continue; }
143
+ const para = [line]; i++;
144
+ while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^(#{1,6}\s|>|```)/.test(lines[i]) &&
145
+ !/^---+$/.test(lines[i].trim()) && !isList(lines[i])) { para.push(lines[i]); i++; }
146
+ html += '<p>' + inlineMd(para.join('\n').trim()) + '</p>';
147
+ }
148
+ return html;
149
+ }
150
+
151
+ function indentOf(l) { return (l.match(/^(\s*)/)[1] || '').length; }
152
+
153
+ function parseList(lines, start, baseIndent) {
154
+ const ordered = /^\s*\d+\./.test(lines[start]);
155
+ let i = start, html = '<' + (ordered ? 'ol' : 'ul') + '>';
156
+ while (i < lines.length) {
157
+ const l = lines[i];
158
+ if (/^\s*$/.test(l)) { i++; continue; }
159
+ const ind = indentOf(l);
160
+ const m = l.match(/^\s*([-*+]|\d+\.)\s+(.*)$/);
161
+ if (!m || ind < baseIndent) break;
162
+ if (ind > baseIndent) { // nested list belongs to previous <li>
163
+ const r = parseList(lines, i, ind);
164
+ html = html.replace(/<\/li>$/, r.html + '</li>');
165
+ i = r.next; continue;
166
+ }
167
+ html += '<li>' + inlineMd(m[2]) + '</li>';
168
+ i++;
169
+ }
170
+ return { html: html + '</' + (ordered ? 'ol' : 'ul') + '>', next: i };
171
+ }
172
+
173
+ function parseTable(lines, start) {
174
+ const row = (l) => l.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
175
+ const head = row(lines[start]);
176
+ let i = start + 2, body = '';
177
+ while (i < lines.length && /^\|.*\|\s*$/.test(lines[i])) {
178
+ body += '<tr>' + row(lines[i]).map((c) => `<td>${inlineMd(c)}</td>`).join('') + '</tr>';
179
+ i++;
180
+ }
181
+ const thead = '<tr>' + head.map((c) => `<td><b>${inlineMd(c)}</b></td>`).join('') + '</tr>';
182
+ return { html: `<table class="rich-editor-table"><tbody>${thead}${body}</tbody></table>`, next: i };
183
+ }
184
+
185
+ function inlineMd(s) {
186
+ // images, mentions, links, then marks. Order matters.
187
+ return s
188
+ .replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, a, src) => `<img class="inserted-image" src="${attr(src)}" alt="${attr(a)}" style="max-width:100%;height:auto">`)
189
+ .replace(/([@#])\[([^\]]+)\]\(([^)]+)\)/g, (_, t, name, id) => `<span class="mention" data-id="${attr(id)}">${t}${escapeHtml(name)}</span>`)
190
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, href) => `<a href="${attr(href)}" target="_blank" rel="noopener noreferrer">${escapeHtml(t)}</a>`)
191
+ .replace(/\*\*([^*]+)\*\*/g, '<b>$1</b>')
192
+ .replace(/(^|[^*])\*([^*]+)\*/g, '$1<i>$2</i>')
193
+ .replace(/~~([^~]+)~~/g, '<s>$1</s>')
194
+ .replace(/`([^`]+)`/g, (_, c) => '<code>' + escapeHtml(c) + '</code>')
195
+ .replace(/\n/g, '<br>');
196
+ }
197
+
198
+ function escapeHtml(s) { return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); }
199
+ function attr(s) { return String(s).replace(/"/g, '&quot;').replace(/</g, '&lt;'); }
200
+
201
+ /* ============================== HTML <-> JSON ============================= */
202
+
203
+ export function domToJson(html) {
204
+ const root = document.createElement('div');
205
+ root.innerHTML = html || '';
206
+ return { type: 'doc', content: [...root.childNodes].map(nodeToJson).filter(Boolean) };
207
+ }
208
+
209
+ function nodeToJson(node) {
210
+ if (node.nodeType === 3) {
211
+ const text = node.textContent;
212
+ return text ? { text } : null;
213
+ }
214
+ if (node.nodeType !== 1) return null;
215
+ const obj = { tag: node.tagName.toLowerCase() };
216
+ if (node.attributes.length) {
217
+ obj.attrs = {};
218
+ for (const a of node.attributes) obj.attrs[a.name] = a.value;
219
+ }
220
+ const kids = [...node.childNodes].map(nodeToJson).filter(Boolean);
221
+ if (kids.length) obj.content = kids;
222
+ return obj;
223
+ }
224
+
225
+ export function jsonToHtml(json) {
226
+ const nodes = json && json.content ? json.content : (Array.isArray(json) ? json : []);
227
+ return nodes.map(jsonNodeToHtml).join('');
228
+ }
229
+
230
+ function jsonNodeToHtml(n) {
231
+ if (n == null) return '';
232
+ if (n.text != null) return escapeHtml(n.text);
233
+ if (!n.tag) return '';
234
+ const attrs = n.attrs
235
+ ? Object.entries(n.attrs).map(([k, v]) => ` ${k}="${attr(v)}"`).join('')
236
+ : '';
237
+ const inner = (n.content || []).map(jsonNodeToHtml).join('');
238
+ const VOID = new Set(['img', 'hr', 'br', 'input']);
239
+ if (VOID.has(n.tag)) return `<${n.tag}${attrs}>`;
240
+ return `<${n.tag}${attrs}>${inner}</${n.tag}>`;
241
+ }
package/lib/static.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * renderStatic — paint stored HTML into a read-only view that looks exactly
3
+ * like the editor's content area, without booting an editor.
4
+ *
5
+ * It sanitizes the HTML (same allowlist the editor uses on paste/setContent)
6
+ * and tags the host element with `.yjd-content`, the class the stylesheet uses
7
+ * to style typography, lists, tables, images, and mention tokens. Load the
8
+ * editor stylesheet (or StylesLoader.loadStyles()) on the page for it to match.
9
+ *
10
+ * import { renderStatic } from '@oix1987/yjd/core';
11
+ * renderStatic(post.body_html, document.querySelector('#post'));
12
+ *
13
+ * @param {string} html Stored HTML (untrusted — it is sanitized).
14
+ * @param {Element} [target] Element to render into. If omitted, a fresh
15
+ * <div.yjd-content> is created and returned.
16
+ * @returns {Element} the element the content was rendered into.
17
+ */
18
+ import { sanitizeHtml } from './utils/sanitize.js';
19
+
20
+ export function renderStatic(html, target) {
21
+ const safe = sanitizeHtml(html || '');
22
+ const el = target || document.createElement('div');
23
+ el.classList.add('yjd-content');
24
+ el.innerHTML = safe;
25
+ return el;
26
+ }
27
+
28
+ export default renderStatic;
package/lib/styles.css CHANGED
@@ -3371,3 +3371,110 @@
3371
3371
  stroke: currentColor;
3372
3372
  fill: none;
3373
3373
  }
3374
+
3375
+ /* ── @mention token (v2.24) ───────────────────────────────────────────────
3376
+ The atomic token inserted by the mention module. Styled the same in the
3377
+ editor and in renderStatic() read-views so tagged names look identical. */
3378
+ .mention {
3379
+ display: inline;
3380
+ padding: 0 2px;
3381
+ border-radius: 4px;
3382
+ background: #efedff;
3383
+ color: #5a48ee;
3384
+ font-weight: 500;
3385
+ text-decoration: none;
3386
+ white-space: nowrap;
3387
+ }
3388
+ .mention[data-trigger="#"] {
3389
+ background: #e8f3ff;
3390
+ color: #1f6feb;
3391
+ }
3392
+
3393
+ /* ── @mention autocomplete menu (v2.24) ───────────────────────────────────
3394
+ Mirrors the slash-menu shell. Appended to <body>, positioned at the caret. */
3395
+ .yjd-mention-menu {
3396
+ position: absolute;
3397
+ z-index: 2000;
3398
+ min-width: 220px;
3399
+ max-width: 320px;
3400
+ max-height: 280px;
3401
+ overflow-y: auto;
3402
+ padding: 6px;
3403
+ background: #ffffff;
3404
+ border: 1px solid #e9e9f1;
3405
+ border-radius: 12px;
3406
+ box-shadow: 0 12px 32px -8px rgba(20, 24, 46, 0.20), 0 4px 10px -4px rgba(20, 24, 46, 0.10);
3407
+ font: 14px/1.4 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
3408
+ animation: yjd-slash-in 90ms ease-out;
3409
+ }
3410
+ .yjd-mention-item {
3411
+ display: flex;
3412
+ align-items: center;
3413
+ gap: 10px;
3414
+ width: 100%;
3415
+ padding: 7px 10px;
3416
+ border: none;
3417
+ background: transparent;
3418
+ border-radius: 8px;
3419
+ cursor: pointer;
3420
+ text-align: left;
3421
+ color: #20242f;
3422
+ }
3423
+ .yjd-mention-item:hover,
3424
+ .yjd-mention-item.active { background: #efedff; color: #5a48ee; }
3425
+ .yjd-mention-avatar {
3426
+ width: 26px;
3427
+ height: 26px;
3428
+ border-radius: 50%;
3429
+ object-fit: cover;
3430
+ flex: 0 0 auto;
3431
+ background: #f2f2f7;
3432
+ }
3433
+ .yjd-mention-name { font-weight: 500; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
3434
+ @media (prefers-reduced-motion: reduce) { .yjd-mention-menu { animation: none; } }
3435
+
3436
+ /* ── Static read-view (.yjd-content) (v2.24) ──────────────────────────────
3437
+ renderStatic() tags its host with .yjd-content. These rules mirror the
3438
+ editor's content typography so a saved post renders identically without an
3439
+ editor instance. Kept self-contained (not scoped under .yjd-rich-editor). */
3440
+ .yjd-content {
3441
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
3442
+ font-size: 16px;
3443
+ line-height: 1.6;
3444
+ color: #20242f;
3445
+ word-wrap: break-word;
3446
+ }
3447
+ .yjd-content > :first-child { margin-top: 0; }
3448
+ .yjd-content > :last-child { margin-bottom: 0; }
3449
+ .yjd-content p { margin: 0 0 1em; }
3450
+ .yjd-content h1 { font-size: 2em; font-weight: bold; margin: 0.67em 0; }
3451
+ .yjd-content h2 { font-size: 1.5em; font-weight: bold; margin: 0.75em 0; }
3452
+ .yjd-content h3 { font-size: 1.25em; font-weight: bold; margin: 0.83em 0; }
3453
+ .yjd-content h4 { font-size: 1.1em; font-weight: bold; margin: 1em 0; }
3454
+ .yjd-content h5 { font-size: 1em; font-weight: bold; margin: 1.25em 0; }
3455
+ .yjd-content h6 { font-size: 0.875em; font-weight: bold; margin: 1.5em 0; color: #555; }
3456
+ .yjd-content ul, .yjd-content ol { margin: 0 0 1em 2em; padding: 0; }
3457
+ .yjd-content li { margin: 0.25em 0; }
3458
+ .yjd-content a { color: #2563eb; text-decoration: underline; }
3459
+ .yjd-content code {
3460
+ font-family: Consolas, Menlo, Monaco, "Courier New", monospace;
3461
+ background: #f1f2f3; padding: 2px 6px; border-radius: 4px;
3462
+ }
3463
+ .yjd-content pre {
3464
+ font-family: Consolas, Menlo, Monaco, "Courier New", monospace;
3465
+ background: #f1f2f3; padding: 12px 14px; border-radius: 6px;
3466
+ margin: 1em 0; overflow-x: auto; white-space: pre;
3467
+ }
3468
+ .yjd-content pre code { background: none; padding: 0; }
3469
+ .yjd-content blockquote {
3470
+ border-left: 4px solid #d1d5db; margin: 1em 0; padding: 4px 12px;
3471
+ color: #555; font-style: italic; background: #f9fafb;
3472
+ }
3473
+ .yjd-content img { max-width: 100%; height: auto; border-radius: 4px; }
3474
+ .yjd-content hr { border: none; border-top: 1px solid #d1d5db; margin: 1.5em 0; }
3475
+ .yjd-content table {
3476
+ border-collapse: collapse; width: 100%; margin: 1em 0;
3477
+ }
3478
+ .yjd-content table td, .yjd-content table th {
3479
+ border: 1px solid #d1d5db; padding: 8px 10px; text-align: left;
3480
+ }