@cubud/wen 1.0.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,363 @@
1
+ import { Editor } from 'https://esm.sh/@tiptap/core@2.2.4';
2
+ import StarterKit from 'https://esm.sh/@tiptap/starter-kit@2.2.4';
3
+ import { Markdown } from 'https://esm.sh/tiptap-markdown@0.8.9';
4
+ import Link from 'https://esm.sh/@tiptap/extension-link@2.2.4';
5
+ import Image from 'https://esm.sh/@tiptap/extension-image@2.2.4';
6
+
7
+ import CodeBlockLowlight from 'https://esm.sh/@tiptap/extension-code-block-lowlight@2.2.4';
8
+ import { createLowlight, common } from 'https://esm.sh/lowlight@3.1.0';
9
+
10
+ // 1. New Table Imports
11
+ import Table from 'https://esm.sh/@tiptap/extension-table@2.2.4';
12
+ import TableRow from 'https://esm.sh/@tiptap/extension-table-row@2.2.4';
13
+ import TableHeader from 'https://esm.sh/@tiptap/extension-table-header@2.2.4';
14
+ import TableCell from 'https://esm.sh/@tiptap/extension-table-cell@2.2.4';
15
+
16
+ import TaskList from 'https://esm.sh/@tiptap/extension-task-list@2.2.4';
17
+ import TaskItem from 'https://esm.sh/@tiptap/extension-task-item@2.2.4';
18
+
19
+ // ... existing imports ...
20
+ import { Extension } from 'https://esm.sh/@tiptap/core@2.2.4';
21
+ import { Plugin, PluginKey } from 'https://esm.sh/@tiptap/pm@2.2.4/state';
22
+ import { Decoration, DecorationSet } from 'https://esm.sh/@tiptap/pm@2.2.4/view';
23
+
24
+ import { escapeHtml } from './wen-utils.js';
25
+
26
+ const lowlight = createLowlight(common);
27
+
28
+ const GitHubAlerts = Extension.create({
29
+ name: 'githubAlerts',
30
+ addProseMirrorPlugins() {
31
+ return [
32
+ new Plugin({
33
+ key: new PluginKey('githubAlerts'),
34
+ props: {
35
+ decorations(state) {
36
+ const decorations = [];
37
+ const { doc } = state;
38
+
39
+ doc.descendants((node, pos) => {
40
+ if (node.type.name === 'blockquote') {
41
+ const firstChild = node.firstChild;
42
+
43
+ // Check if the blockquote starts with a paragraph
44
+ if (firstChild && firstChild.type.name === 'paragraph') {
45
+ const text = firstChild.textContent;
46
+ const match = text.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]/i);
47
+
48
+ if (match) {
49
+ const type = match[1].toLowerCase();
50
+
51
+ // 1. Decorate the parent Blockquote wrapper
52
+ decorations.push(
53
+ Decoration.node(pos, pos + node.nodeSize, {
54
+ class: `wen-github-alert wen-github-alert-${type}`
55
+ })
56
+ );
57
+
58
+ // 2. Decorate the exact [!TYPE] text to look like a colored badge
59
+ // (pos = blockquote, pos+1 = paragraph, pos+2 = start of text node)
60
+ const startPos = pos + 2;
61
+ const endPos = startPos + match[0].length;
62
+
63
+ decorations.push(
64
+ Decoration.inline(startPos, endPos, {
65
+ class: `wen-github-alert-badge wen-github-alert-badge-${type}`
66
+ })
67
+ );
68
+ }
69
+ }
70
+ }
71
+ });
72
+ return DecorationSet.create(doc, decorations);
73
+ }
74
+ }
75
+ })
76
+ ];
77
+ }
78
+ });
79
+
80
+ const WenSmartBlocks = CodeBlockLowlight.extend({
81
+ addOptions() {
82
+ return { ...this.parent?.(), blockViews: {}, lowlight };
83
+ },
84
+ addNodeView() {
85
+ return ({ node, getPos, editor }) => {
86
+ const lang = node.attrs.language;
87
+ const ViewClass = this.options.blockViews[lang];
88
+ if (ViewClass) return new ViewClass(node, getPos, editor);
89
+
90
+ const dom = document.createElement('pre');
91
+ const contentDOM = document.createElement('code');
92
+ if (lang) contentDOM.className = `language-${lang}`;
93
+ dom.appendChild(contentDOM);
94
+ return { dom, contentDOM };
95
+ };
96
+ }
97
+ });
98
+
99
+ export class WenEditor {
100
+ constructor(options) {
101
+ this.container = options.element;
102
+ this.blockViews = options.blockViews || {};
103
+ this.useFrontmatter = options.useFrontmatter !== false;
104
+ this.useNativeComponents = options.useNativeComponents !== false;
105
+ // Safe by default: rich-render paths are sanitized and mermaid runs in 'strict' mode.
106
+ // Pass `unsafe: true` to opt out and render untrusted HTML/diagrams verbatim.
107
+ this.unsafe = options.unsafe === true;
108
+
109
+ this.wrapper = document.createElement('div');
110
+ this.wrapper.className = 'wen-editor-wrapper';
111
+
112
+ this.toolbar = document.createElement('div');
113
+ this.toolbar.className = 'wen-toolbar';
114
+
115
+ this.editorNode = document.createElement('div');
116
+
117
+ this.wrapper.appendChild(this.toolbar);
118
+ this.wrapper.appendChild(this.editorNode);
119
+ this.container.appendChild(this.wrapper);
120
+
121
+ const processedMarkdown = this.importMarkdown(options.initialMarkdown || '');
122
+
123
+ this.editor = new Editor({
124
+ element: this.editorNode,
125
+ extensions: [
126
+ StarterKit.configure({ codeBlock: false }),
127
+ WenSmartBlocks.configure({ blockViews: this.blockViews }),
128
+ GitHubAlerts,
129
+ Markdown,
130
+ Image,
131
+ Link.configure({ openOnClick: false }),
132
+ Table.configure({ resizable: true, HTMLAttributes: { class: 'wen-table' } }),
133
+ TableRow,
134
+ TableHeader,
135
+ TableCell,
136
+ TaskList,
137
+ TaskItem.configure({ nested: true }),
138
+ ],
139
+ content: processedMarkdown,
140
+ editorProps: { attributes: { class: 'wen-editor-root' } },
141
+ onTransaction: () => this.updateToolbarStates()
142
+ });
143
+
144
+ this.editor.wenEditorInstance = this;
145
+
146
+ this.buildToolbar();
147
+ }
148
+
149
+ importMarkdown(md) {
150
+ let processed = md;
151
+ if (this.useFrontmatter) {
152
+ processed = processed.replace(/^---\r?\n([\s\S]*?)\r?\n---\r?\n/, '```yaml\n$1\n```\n\n');
153
+ }
154
+ if (this.useNativeComponents) {
155
+ const tokens = processed.split(/(^```[\s\S]*?^```\r?\n?)/m);
156
+ for (let i = 0; i < tokens.length; i++) {
157
+ if (i % 2 === 0) {
158
+ let chunk = tokens[i];
159
+
160
+ // THE FIX: Prepend a newline to ensure TipTap never merges adjacent components into a single broken code block
161
+ const openCloseRegex = /^[ \t]*<([a-zA-Z0-9-]+)[^>]*>[\s\S]*?<\/\1>[ \t]*/gm;
162
+ chunk = chunk.replace(openCloseRegex, (match) => '\n```component\n' + match.trim() + '\n```\n');
163
+
164
+ const selfCloseRegex = /^[ \t]*<([a-zA-Z0-9-]+)[^>]*\/>[ \t]*/gm;
165
+ chunk = chunk.replace(selfCloseRegex, (match) => '\n```component\n' + match.trim() + '\n```\n');
166
+
167
+ tokens[i] = chunk;
168
+ }
169
+ }
170
+ processed = tokens.join('');
171
+ }
172
+ return processed;
173
+ }
174
+
175
+ loadContent(md) {
176
+ // Run the text through our pre-processor to handle YAML and Components
177
+ const processed = this.importMarkdown(md);
178
+ // Replace the entire document safely
179
+ this.editor.commands.setContent(processed);
180
+ }
181
+
182
+ exportMarkdown() {
183
+ let md = this.editor.storage.markdown.getMarkdown();
184
+ if (this.useFrontmatter) {
185
+ md = md.replace(/^```yaml\r?\n([\s\S]*?)\r?\n```\r?\n?/, '---\n$1\n---\n');
186
+ }
187
+ if (this.useNativeComponents) {
188
+ // THE FIX: Allow leading [ \t]* and trailing whitespace inside the block wrapper
189
+ const unwrapRegex = /```component\r?\n([ \t]*<(?:[a-zA-Z0-9-]+)[\s\S]*?>)[ \t]*\r?\n```\r?\n?/gm;
190
+ md = md.replace(unwrapRegex, '$1\n');
191
+ }
192
+ return md;
193
+ }
194
+
195
+ downloadMarkdown(filename = 'wen-document.md') {
196
+ const md = this.exportMarkdown();
197
+ const blob = new Blob([md], { type: 'text/markdown;charset=utf-8' });
198
+ const url = URL.createObjectURL(blob);
199
+
200
+ const link = document.createElement('a');
201
+ link.href = url;
202
+ link.download = filename;
203
+
204
+ // Append to body, click it, and instantly clean it up
205
+ document.body.appendChild(link);
206
+ link.click();
207
+ document.body.removeChild(link);
208
+ URL.revokeObjectURL(url);
209
+ }
210
+
211
+ buildToolbar() {
212
+ // 3. Upgraded createBtn to support disabled states
213
+ const createBtn = (label, action, isActiveCheck = null, isDisabledCheck = null) => {
214
+ const btn = document.createElement('button');
215
+ btn.className = 'wen-toolbar-btn';
216
+ btn.innerHTML = label;
217
+ btn.addEventListener('click', () => { action(); this.editor.view.focus(); });
218
+ if (isActiveCheck) btn.isActiveCheck = isActiveCheck;
219
+ if (isDisabledCheck) btn.isDisabledCheck = isDisabledCheck;
220
+ this.toolbar.appendChild(btn);
221
+ return btn;
222
+ };
223
+
224
+ const addDivider = () => {
225
+ const div = document.createElement('div');
226
+ div.className = 'wen-toolbar-divider';
227
+ this.toolbar.appendChild(div);
228
+ };
229
+
230
+ createBtn('<b>B</b>', () => this.editor.chain().focus().toggleBold().run(), () => this.editor.isActive('bold'));
231
+ createBtn('<i>I</i>', () => this.editor.chain().focus().toggleItalic().run(), () => this.editor.isActive('italic'));
232
+ createBtn('<s>S</s>', () => this.editor.chain().focus().toggleStrike().run(), () => this.editor.isActive('strike'));
233
+ addDivider();
234
+ createBtn('¶ Text', () => this.editor.chain().focus().setParagraph().run(), () => this.editor.isActive('paragraph'));
235
+ createBtn('H1', () => this.editor.chain().focus().toggleHeading({ level: 1 }).run(), () => this.editor.isActive('heading', { level: 1 }));
236
+ createBtn('H2', () => this.editor.chain().focus().toggleHeading({ level: 2 }).run(), () => this.editor.isActive('heading', { level: 2 }));
237
+ createBtn('H3', () => this.editor.chain().focus().toggleHeading({ level: 3 }).run(), () => this.editor.isActive('heading', { level: 3 }));
238
+ createBtn('H4', () => this.editor.chain().focus().toggleHeading({ level: 4 }).run(), () => this.editor.isActive('heading', { level: 4 }));
239
+ addDivider();
240
+ createBtn('• List', () => this.editor.chain().focus().toggleBulletList().run(), () => this.editor.isActive('bulletList'));
241
+ createBtn('1. List', () => this.editor.chain().focus().toggleOrderedList().run(), () => this.editor.isActive('orderedList'));
242
+ createBtn('☑️ Task', () => this.editor.chain().focus().toggleTaskList().run(), () => this.editor.isActive('taskList'));
243
+ createBtn('\" Quote', () => this.editor.chain().focus().toggleBlockquote().run(), () => this.editor.isActive('blockquote'));
244
+ createBtn('📢 Alert', () => {
245
+ this.editor.chain().focus().insertContent('<blockquote><p>[!NOTE]<br>Your message here...</p></blockquote>').run();
246
+ });
247
+ addDivider();
248
+
249
+ createBtn('🔗 Link', () => {
250
+ const previousUrl = this.editor.getAttributes('link').href;
251
+ this.showModal('Insert Link', 'URL', previousUrl, (url) => {
252
+ if (url === null) return;
253
+ if (url === '') this.editor.chain().focus().extendMarkRange('link').unsetLink().run();
254
+ else this.editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
255
+ });
256
+ }, () => this.editor.isActive('link'));
257
+
258
+ createBtn('🖼️ Image', () => {
259
+ const attrs = this.editor.getAttributes('image');
260
+ const previousUrl = attrs.src || '';
261
+
262
+ this.showModal(previousUrl ? 'Edit Image' : 'Insert Image', 'Image URL', previousUrl, (url) => {
263
+ if (url) {
264
+ this.editor.chain().focus().setImage({ src: url }).run();
265
+ }
266
+ });
267
+ }, () => this.editor.isActive('image'));
268
+
269
+ addDivider();
270
+
271
+ // 4. The Table Controls
272
+ createBtn('📊 Table', () => this.editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run());
273
+ createBtn('+ Row', () => this.editor.chain().focus().addRowAfter().run(), null, () => !this.editor.can().addRowAfter());
274
+ createBtn('+ Col', () => this.editor.chain().focus().addColumnAfter().run(), null, () => !this.editor.can().addColumnAfter());
275
+ createBtn('- Row', () => this.editor.chain().focus().deleteRow().run(), null, () => !this.editor.can().deleteRow());
276
+ createBtn('- Col', () => this.editor.chain().focus().deleteColumn().run(), null, () => !this.editor.can().deleteColumn());
277
+ createBtn('x Table', () => this.editor.chain().focus().deleteTable().run(), null, () => !this.editor.can().deleteTable());
278
+
279
+ // THE NEW ADDITION: The Save Button
280
+ addDivider();
281
+ createBtn('💾 Save', () => {
282
+ // You could easily hook up a prompt here to ask for a filename if you wanted!
283
+ this.downloadMarkdown('wen-document.md');
284
+ });
285
+ }
286
+
287
+ updateToolbarStates() {
288
+ this.toolbar.querySelectorAll('.wen-toolbar-btn').forEach(btn => {
289
+ if (btn.isActiveCheck) {
290
+ if (btn.isActiveCheck()) btn.classList.add('is-active');
291
+ else btn.classList.remove('is-active');
292
+ }
293
+ // Check if the button should be disabled (e.g. table actions when not in a table)
294
+ if (btn.isDisabledCheck) {
295
+ btn.disabled = btn.isDisabledCheck();
296
+ }
297
+ });
298
+ }
299
+
300
+ showModal(title, inputPlaceholder, initialValue, callback) {
301
+ const overlay = document.createElement('div');
302
+ overlay.className = 'wen-modal-overlay';
303
+ overlay.innerHTML = `
304
+ <div class="wen-modal">
305
+ <strong>${escapeHtml(title)}</strong>
306
+ <input type="text" placeholder="${escapeHtml(inputPlaceholder)}" value="${escapeHtml(initialValue || '')}" />
307
+ <div class="wen-modal-actions">
308
+ <button class="wen-modal-cancel">Cancel</button>
309
+ <button class="wen-modal-submit">Save</button>
310
+ </div>
311
+ </div>
312
+ `;
313
+ document.body.appendChild(overlay);
314
+ const input = overlay.querySelector('input');
315
+ input.focus();
316
+
317
+ const close = (value) => { document.body.removeChild(overlay); callback(value); };
318
+ overlay.querySelector('.wen-modal-cancel').addEventListener('click', () => close(null));
319
+ overlay.querySelector('.wen-modal-submit').addEventListener('click', () => close(input.value));
320
+ input.addEventListener('keydown', (e) => {
321
+ if (e.key === 'Enter') close(input.value);
322
+ if (e.key === 'Escape') close(null);
323
+ });
324
+ }
325
+
326
+ showRichModal(title, initialMarkdown, callback) {
327
+ const overlay = document.createElement('div');
328
+ overlay.className = 'wen-rich-modal-overlay';
329
+ overlay.innerHTML = `
330
+ <div class="wen-rich-modal">
331
+ <div class="wen-rich-modal-header">
332
+ <strong>Editing Content: &lt;${escapeHtml(title)}&gt;</strong>
333
+ <div>
334
+ <button class="wen-modal-cancel">Cancel</button>
335
+ <button class="wen-modal-submit">Save Changes</button>
336
+ </div>
337
+ </div>
338
+ <div class="wen-rich-modal-body" id="modal-editor-container"></div>
339
+ </div>
340
+ `;
341
+ document.body.appendChild(overlay);
342
+
343
+ // Spawn a recursive child WenEditor inside the modal!
344
+ const modalEditor = new WenEditor({
345
+ element: overlay.querySelector('#modal-editor-container'),
346
+ blockViews: this.blockViews, // Inherit all our rich UI blocks
347
+ useFrontmatter: false, // Sub-components don't need YAML frontmatter
348
+ useNativeComponents: this.useNativeComponents,
349
+ unsafe: this.unsafe, // Inherit the trust setting into recursive editors
350
+ initialMarkdown: initialMarkdown
351
+ });
352
+
353
+ const close = (save) => {
354
+ const finalMd = save ? modalEditor.exportMarkdown() : null;
355
+ modalEditor.editor.destroy(); // Clean up memory
356
+ document.body.removeChild(overlay);
357
+ if (save) callback(finalMd);
358
+ };
359
+
360
+ overlay.querySelector('.wen-modal-cancel').addEventListener('click', () => close(false));
361
+ overlay.querySelector('.wen-modal-submit').addEventListener('click', () => close(true));
362
+ }
363
+ }
@@ -0,0 +1,50 @@
1
+ .wen-mermaid-block {
2
+ border: 2px solid #0d9488;
3
+ border-radius: var(--wen-radius);
4
+ margin: 1.5rem 0;
5
+ background: var(--wen-bg);
6
+ box-sizing: border-box;
7
+ }
8
+
9
+ .wen-mermaid-block *, .wen-mermaid-block *::before, .wen-mermaid-block *::after {
10
+ box-sizing: border-box;
11
+ }
12
+
13
+ .wen-mermaid-header {
14
+ display: flex;
15
+ justify-content: space-between;
16
+ align-items: center;
17
+ border-bottom: 2px solid #0d9488;
18
+ padding: 0.5rem 1rem;
19
+ background: #0d9488;
20
+ color: white;
21
+ border-top-left-radius: calc(var(--wen-radius) - 2px);
22
+ border-top-right-radius: calc(var(--wen-radius) - 2px);
23
+ }
24
+
25
+ .wen-mermaid-title { font-size: 0.85rem; font-weight: bold; text-transform: uppercase; letter-spacing: 0.05em; }
26
+ .wen-mermaid-toggle-btn { background: var(--wen-bg); border: none; color: var(--wen-text-main); cursor: pointer; font-size: 0.75rem; padding: 0.25rem 0.6rem; border-radius: var(--wen-radius-sm); font-weight: 600; transition: background 0.15s; }
27
+ .wen-mermaid-toggle-btn:hover { background: var(--wen-surface-hover); }
28
+
29
+ /* THE FIX: Hard-lock the display states so they cannot both show at once */
30
+ .wen-mermaid-view-visual, .wen-mermaid-view-raw {
31
+ display: none !important;
32
+ padding: 1rem;
33
+ }
34
+ .wen-mermaid-view-visual.active, .wen-mermaid-view-raw.active {
35
+ display: block !important;
36
+ }
37
+
38
+ /* Ensure the SVG scales nicely and forces dark text */
39
+ .wen-mermaid-view-visual {
40
+ text-align: center;
41
+ overflow-x: auto;
42
+ background: var(--wen-surface);
43
+ border-bottom-left-radius: var(--wen-radius);
44
+ border-bottom-right-radius: var(--wen-radius);
45
+ color: #1f2937 !important;
46
+ }
47
+ .wen-mermaid-view-visual svg { max-width: 100%; height: auto !important; color: #1f2937; }
48
+
49
+ .wen-mermaid-raw-input { width: 100%; min-height: 250px; border: 1px solid var(--wen-border); border-radius: var(--wen-radius-sm); padding: 0.75rem; font-family: var(--wen-font-mono); font-size: 0.9rem; background: #1e1e1e; color: #d4d4d4; resize: vertical; outline: none; }
50
+ .wen-mermaid-error { color: var(--wen-danger); font-family: var(--wen-font-mono); font-size: 0.85rem; text-align: left; background: #fef2f2; padding: 1rem; border-radius: var(--wen-radius-sm); border-left: 4px solid var(--wen-danger); white-space: pre-wrap; }
@@ -0,0 +1,125 @@
1
+ import mermaid from 'https://esm.sh/mermaid@10.9.1';
2
+ import { escapeHtml } from './wen-utils.js';
3
+
4
+ // Initialize Mermaid with a neutral theme that works well inside the editor.
5
+ // Default to 'strict' (no inline HTML / click handlers); re-initialized per render
6
+ // to 'loose' only when the editor is in unsafe mode.
7
+ mermaid.initialize({
8
+ startOnLoad: false,
9
+ theme: 'neutral',
10
+ securityLevel: 'strict'
11
+ });
12
+
13
+ export class WenMermaidView {
14
+ constructor(node, getPos, editor) {
15
+ this.node = node;
16
+ this.getPos = getPos;
17
+ this.editor = editor;
18
+ // Mermaid requires a strictly unique ID for every diagram it renders
19
+ this.id = 'mermaid-' + Math.random().toString(36).substr(2, 9);
20
+
21
+ this.dom = document.createElement('div');
22
+ this.dom.className = 'wen-mermaid-block';
23
+ this.dom.contentEditable = 'false';
24
+
25
+ this.dom.innerHTML = `
26
+ <div class="wen-mermaid-header">
27
+ <span class="wen-mermaid-title">🧜‍♀️ Mermaid Diagram</span>
28
+ <button class="wen-mermaid-toggle-btn" data-state="visual">Edit Chart</button>
29
+ </div>
30
+ <div class="wen-mermaid-view-visual active"></div>
31
+ <div class="wen-mermaid-view-raw"><textarea class="wen-mermaid-raw-input"></textarea></div>
32
+ `;
33
+
34
+ const toggleBtn = this.dom.querySelector('.wen-mermaid-toggle-btn');
35
+ const visualView = this.dom.querySelector('.wen-mermaid-view-visual');
36
+ const rawView = this.dom.querySelector('.wen-mermaid-view-raw');
37
+ const rawInput = this.dom.querySelector('.wen-mermaid-raw-input');
38
+
39
+ rawInput.value = this.node.textContent;
40
+ let isUpdatingFromUI = false;
41
+
42
+ const updateTipTapState = (newRawContent) => {
43
+ if (newRawContent === this.node.textContent) return;
44
+ if (typeof this.getPos === 'function') {
45
+ const tr = this.editor.state.tr;
46
+ const start = this.getPos() + 1;
47
+ const end = start + this.node.nodeSize - 2;
48
+ if (newRawContent) tr.replaceWith(start, end, this.editor.schema.text(newRawContent));
49
+ else tr.delete(start, end);
50
+ this.editor.view.dispatch(tr);
51
+ }
52
+ };
53
+
54
+ const renderVisualDiagram = async () => {
55
+ visualView.innerHTML = '';
56
+ const code = rawInput.value.trim();
57
+
58
+ if (!code) {
59
+ visualView.innerHTML = `<div class="wen-mermaid-empty">Empty diagram</div>`;
60
+ return;
61
+ }
62
+
63
+ try {
64
+ // Respect the editor's trust setting: only loosen mermaid for unsafe mode.
65
+ const unsafe = this.editor.wenEditorInstance?.unsafe === true;
66
+ mermaid.initialize({ startOnLoad: false, theme: 'neutral', securityLevel: unsafe ? 'loose' : 'strict' });
67
+
68
+ // Parse and render the SVG asynchronously
69
+ const { svg } = await mermaid.render(this.id, code);
70
+ visualView.innerHTML = svg;
71
+ } catch (err) {
72
+ // If the syntax is invalid, show a safe error message instead of crashing
73
+ visualView.innerHTML = `<div class="wen-mermaid-error"><strong>Syntax Error:</strong><br/>${escapeHtml(err.message)}</div>`;
74
+
75
+ // Mermaid occasionally leaves garbage DOM nodes behind on failure, clean them up
76
+ const garbage = document.getElementById(this.id);
77
+ if (garbage) garbage.remove();
78
+ }
79
+ };
80
+
81
+ toggleBtn.addEventListener('click', () => {
82
+ const isVisual = toggleBtn.getAttribute('data-state') === 'visual';
83
+ if (isVisual) {
84
+ visualView.classList.remove('active');
85
+ rawView.classList.add('active');
86
+ toggleBtn.setAttribute('data-state', 'raw');
87
+ toggleBtn.innerText = 'View Chart';
88
+ } else {
89
+ renderVisualDiagram();
90
+ rawView.classList.remove('active');
91
+ visualView.classList.add('active');
92
+ toggleBtn.setAttribute('data-state', 'visual');
93
+ toggleBtn.innerText = 'Edit Chart';
94
+ }
95
+ });
96
+
97
+ // When the raw textarea changes, update TipTap
98
+ rawInput.addEventListener('input', () => {
99
+ isUpdatingFromUI = true;
100
+ updateTipTapState(rawInput.value);
101
+ isUpdatingFromUI = false;
102
+ });
103
+
104
+ // Initial Render
105
+ renderVisualDiagram();
106
+
107
+ // The TipTap Sync Hook
108
+ this.update = (updatedNode) => {
109
+ if (updatedNode.type.name !== 'codeBlock' || updatedNode.attrs.language !== 'mermaid') return false;
110
+
111
+ if (this.node.textContent !== updatedNode.textContent) {
112
+ this.node = updatedNode;
113
+ rawInput.value = this.node.textContent;
114
+
115
+ // If Monaco (or another external source) changed the text, re-render the visual
116
+ if (!isUpdatingFromUI) {
117
+ renderVisualDiagram();
118
+ }
119
+ }
120
+ return true;
121
+ };
122
+
123
+ this.stopEvent = () => true;
124
+ }
125
+ }
@@ -0,0 +1,22 @@
1
+ // Shared internal helpers for the ƿen editor.
2
+ // Imported by the view classes; not part of the public API surface.
3
+
4
+ import DOMPurify from 'https://esm.sh/dompurify@3.1.6';
5
+
6
+ // Escape a string for safe interpolation into HTML text or double-quoted attributes.
7
+ // This is baseline correctness (a stray `"` or `<` in a value must not break the DOM)
8
+ // and is applied regardless of `unsafe` mode.
9
+ export function escapeHtml(value) {
10
+ return String(value ?? '')
11
+ .replace(/&/g, '&amp;')
12
+ .replace(/</g, '&lt;')
13
+ .replace(/>/g, '&gt;')
14
+ .replace(/"/g, '&quot;')
15
+ .replace(/'/g, '&#39;');
16
+ }
17
+
18
+ // Sanitize a rich-HTML fragment (e.g. marked output) before it is injected via innerHTML.
19
+ // Only called on the safe-mode render paths; `unsafe: true` bypasses this entirely.
20
+ export function sanitizeHtml(dirty) {
21
+ return DOMPurify.sanitize(dirty);
22
+ }
@@ -0,0 +1,104 @@
1
+ /* Prevent width + padding horizontal blowouts */
2
+ .wen-yaml-block *, .wen-yaml-block *::before, .wen-yaml-block *::after {
3
+ box-sizing: border-box;
4
+ }
5
+
6
+ .wen-yaml-block { border: 1px solid var(--wen-border); border-radius: var(--wen-radius); margin: 1.5rem 0; background: var(--wen-bg); }
7
+ .wen-yaml-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--wen-border); padding: 0.5rem 1rem; background: var(--wen-surface); border-top-left-radius: calc(var(--wen-radius) - 1px); border-top-right-radius: calc(var(--wen-radius) - 1px); }
8
+ .wen-yaml-title { font-size: 0.85rem; font-weight: 600; color: var(--wen-text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
9
+ .wen-yaml-toggle-btn { background: var(--wen-bg); border: 1px solid var(--wen-border); color: var(--wen-text-muted); cursor: pointer; font-size: 0.75rem; padding: 0.25rem 0.6rem; border-radius: var(--wen-radius-sm); }
10
+ .wen-yaml-toggle-btn:hover { background: var(--wen-surface-hover); color: var(--wen-text-main); }
11
+
12
+ /* Collapsible Container Logic */
13
+ .wen-yaml-visual-container { position: relative; display: block; }
14
+ .wen-yaml-view-visual { padding: 0.5rem !important; }
15
+ .wen-yaml-view-raw, .wen-yaml-error-state { display: none; padding: 1rem; }
16
+ .wen-yaml-view-raw.active, .wen-yaml-error-state.active { display: block; }
17
+ .wen-yaml-visual-container.is-collapsed .wen-yaml-view-visual { max-height: 220px; overflow: hidden; }
18
+ .wen-yaml-fade { display: none; }
19
+ .wen-yaml-visual-container.is-collapsed .wen-yaml-fade { display: block; position: absolute; bottom: 33px; left: 0; right: 0; height: 60px; background: linear-gradient(transparent, var(--wen-bg)); pointer-events: none; z-index: 5; }
20
+ .wen-yaml-expand-btn { display: none; width: 100%; background: var(--wen-surface); border: none; border-top: 1px solid var(--wen-border); padding: 0.5rem; color: var(--wen-text-muted); cursor: pointer; font-size: 0.8rem; text-align: center; font-weight: 600; transition: background 0.15s; border-bottom-left-radius: calc(var(--wen-radius) - 1px); border-bottom-right-radius: calc(var(--wen-radius) - 1px); }
21
+ .wen-yaml-expand-btn:hover { color: var(--wen-text-main); background: var(--wen-border); }
22
+ .wen-yaml-visual-container.needs-collapse .wen-yaml-expand-btn { display: block; }
23
+
24
+ .wen-yaml-raw-input { width: 100%; min-height: 150px; border: 1px solid var(--wen-border); border-radius: var(--wen-radius-sm); padding: 0.75rem; font-family: var(--wen-font-mono); font-size: 0.9rem; color: var(--wen-text-main); background: var(--wen-surface); resize: vertical; outline: none; }
25
+ .wen-yaml-error-state { color: var(--wen-danger); background: #fef2f2; border-top: 1px solid #fecaca; font-size: 0.9rem; }
26
+
27
+ /* Hierarchical Vertical Layout */
28
+ .wen-type-object, .wen-type-array { display: flex; flex-direction: column; width: 100%; background: rgba(0, 0, 0, 0.02); border-radius: var(--wen-radius-sm); padding: 0.5rem; border: 1px solid rgba(0,0,0,0.05); }
29
+ .wen-row, .wen-array-item { display: flex; flex-direction: column; margin-bottom: 0.5rem; transition: background 0.15s ease; border-radius: var(--wen-radius-sm); }
30
+ .wen-row-top { display: flex; align-items: flex-start; width: 100%; padding: 0.2rem; }
31
+ .wen-row:hover > .wen-row-top, .wen-array-item:hover > .wen-row-top { background: var(--wen-surface); border-radius: var(--wen-radius-sm); }
32
+
33
+ /* Precise Typography Alignment */
34
+ .wen-key-wrap {
35
+ display: flex;
36
+ align-items: baseline; /* Aligns the input text and colon perfectly on the bottom edge */
37
+ width: 140px;
38
+ flex-shrink: 0;
39
+ padding: 0; /* Strip any old wrap padding */
40
+ }
41
+
42
+ /* Force identical box models for inputs and textareas */
43
+ .wen-key, .wen-val {
44
+ padding: 0;
45
+ margin: 0;
46
+ font-size: 0.9rem;
47
+ font-family: var(--wen-font-sans);
48
+ line-height: 1.5;
49
+ border: none;
50
+ background: transparent;
51
+ outline: none;
52
+ }
53
+
54
+ .wen-key {
55
+ width: 100%;
56
+ font-weight: 600;
57
+ color: var(--wen-text-muted);
58
+ }
59
+
60
+ .wen-val {
61
+ flex-grow: 1;
62
+ color: var(--wen-text-main);
63
+ resize: none;
64
+ }
65
+
66
+ .wen-colon {
67
+ margin: 0 0.5rem 0 0;
68
+ padding: 0;
69
+ color: var(--wen-text-muted);
70
+ font-weight: bold;
71
+ font-size: 0.9rem;
72
+ line-height: 1.5;
73
+ }
74
+
75
+ /* Italic Placeholders */
76
+ .wen-key::placeholder,
77
+ .wen-val::placeholder {
78
+ font-style: italic;
79
+ opacity: 0.6;
80
+ font-weight: normal;
81
+ }
82
+
83
+ .wen-key:focus { color: var(--wen-text-main); }
84
+
85
+ /* Primitives sit on the same line, complex drops below */
86
+ .wen-val-wrap { flex-grow: 1; min-width: 0; display: flex; align-items: flex-start; }
87
+ .wen-complex-wrap { padding-left: 1rem; width: 100%; margin-top: 0.25rem; }
88
+ .wen-type-primitive { display: flex; align-items: flex-start; gap: 0.5rem; width: 100%; }
89
+
90
+ /* The Action Buttons */
91
+ .wen-type-controls { display: flex; gap: 0.25rem; opacity: 0; transition: opacity 0.2s; }
92
+ .wen-row:hover .wen-type-controls, .wen-array-item:hover .wen-type-controls { opacity: 1; }
93
+ .wen-type-controls button { background: var(--wen-bg); border: 1px solid var(--wen-border); border-radius: var(--wen-radius-sm); cursor: pointer; padding: 0.2rem 0.4rem; font-size: 0.8rem; color: var(--wen-text-muted); }
94
+ .wen-type-controls button:hover { background: var(--wen-surface-hover); color: var(--wen-text-main); }
95
+
96
+ .wen-bullet { color: var(--wen-text-muted); font-weight: bold; padding-top: 0.2rem; width: 24px; text-align: center; }
97
+
98
+ /* Refined Add/Delete UI */
99
+ .wen-btn-delete { background: transparent; border: none; color: var(--wen-text-muted); font-size: 1.25rem; cursor: pointer; opacity: 0; padding: 0 0.5rem; margin-left: auto; }
100
+ .wen-row:hover > .wen-row-top > .wen-btn-delete, .wen-array-item:hover > .wen-row-top > .wen-btn-delete { opacity: 1; }
101
+ .wen-btn-delete:hover { color: var(--wen-danger); }
102
+
103
+ .wen-btn-add { background: transparent; border: 1px dashed var(--wen-border); color: var(--wen-text-muted); cursor: pointer; text-align: left; font-weight: 500; padding: 0.4rem 0.75rem; font-family: var(--wen-font-sans); transition: all 0.15s ease; border-radius: var(--wen-radius-sm); font-size: 0.85rem; margin-top: 0.25rem; width: 100%; }
104
+ .wen-btn-add:hover { color: var(--wen-text-main); background: var(--wen-surface); border-color: #cbd5e1; }