@acms-subir/core 0.0.1

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.
Files changed (107) hide show
  1. package/cli.mjs +75 -0
  2. package/integration.mjs +286 -0
  3. package/package.json +89 -0
  4. package/src/actions/index.ts +502 -0
  5. package/src/components/DesignManager.tsx +542 -0
  6. package/src/components/LinksManager.tsx +694 -0
  7. package/src/components/Modal.astro +65 -0
  8. package/src/components/NavManager.tsx +168 -0
  9. package/src/components/WidgetRenderer.astro +84 -0
  10. package/src/components/builder/PageBuilder.tsx +575 -0
  11. package/src/components/editor/ImagePicker.tsx +215 -0
  12. package/src/components/editor/PostEditor.tsx +1011 -0
  13. package/src/components/editor/PreviewOverlay.tsx +77 -0
  14. package/src/components/editor/extensions/calendar.tsx +230 -0
  15. package/src/components/editor/extensions/cards.tsx +488 -0
  16. package/src/components/editor/extensions/codeBlock.tsx +70 -0
  17. package/src/components/editor/extensions/infoCards.tsx +270 -0
  18. package/src/components/editor/extensions/mediaCards.tsx +376 -0
  19. package/src/components/editor/extensions/postList.tsx +130 -0
  20. package/src/components/editor/extensions/slash.tsx +250 -0
  21. package/src/components/editor/extensions/tableTyping.ts +63 -0
  22. package/src/components/editor/imaging.ts +146 -0
  23. package/src/components/media/CropModal.tsx +229 -0
  24. package/src/components/media/MediaLibrary.tsx +295 -0
  25. package/src/layouts/Admin.astro +119 -0
  26. package/src/layouts/Site.astro +218 -0
  27. package/src/lib/auth.ts +94 -0
  28. package/src/lib/backup/unzip.ts +38 -0
  29. package/src/lib/backup/zip.ts +105 -0
  30. package/src/lib/blocksToHtml.ts +68 -0
  31. package/src/lib/brand.ts +26 -0
  32. package/src/lib/calendar/ics.ts +402 -0
  33. package/src/lib/calendar/render.ts +275 -0
  34. package/src/lib/calendar/src.ts +68 -0
  35. package/src/lib/content.ts +53 -0
  36. package/src/lib/dates.ts +34 -0
  37. package/src/lib/dynamicBlocks.ts +115 -0
  38. package/src/lib/env.ts +6 -0
  39. package/src/lib/highlightCode.ts +44 -0
  40. package/src/lib/i18n.ts +40 -0
  41. package/src/lib/markdown.ts +7 -0
  42. package/src/lib/media/store.ts +95 -0
  43. package/src/lib/net.ts +51 -0
  44. package/src/lib/preview.ts +28 -0
  45. package/src/lib/repo/d1.ts +44 -0
  46. package/src/lib/repo/index.ts +23 -0
  47. package/src/lib/repo/migrations.ts +231 -0
  48. package/src/lib/repo/search.ts +14 -0
  49. package/src/lib/repo/sql.ts +721 -0
  50. package/src/lib/repo/sqlite.ts +43 -0
  51. package/src/lib/repo/types.ts +132 -0
  52. package/src/lib/schema.ts +286 -0
  53. package/src/lib/seo.ts +37 -0
  54. package/src/lib/theme.ts +206 -0
  55. package/src/lib/widgets.ts +108 -0
  56. package/src/middleware.ts +109 -0
  57. package/src/pages/[...slug].astro +43 -0
  58. package/src/pages/admin/c/[collection]/[id].astro +56 -0
  59. package/src/pages/admin/c/[collection]/index.astro +163 -0
  60. package/src/pages/admin/design.astro +42 -0
  61. package/src/pages/admin/index.astro +145 -0
  62. package/src/pages/admin/links.astro +21 -0
  63. package/src/pages/admin/login.astro +83 -0
  64. package/src/pages/admin/logout.ts +7 -0
  65. package/src/pages/admin/media.astro +13 -0
  66. package/src/pages/admin/navigation.astro +17 -0
  67. package/src/pages/admin/pages/[id].astro +49 -0
  68. package/src/pages/admin/pages/index.astro +100 -0
  69. package/src/pages/admin/posts/[id].astro +6 -0
  70. package/src/pages/admin/posts/index.astro +7 -0
  71. package/src/pages/admin/settings.astro +512 -0
  72. package/src/pages/admin/tags.astro +101 -0
  73. package/src/pages/admin/users.astro +149 -0
  74. package/src/pages/api/backup.ts +63 -0
  75. package/src/pages/api/drafts.ts +106 -0
  76. package/src/pages/api/media/upload.ts +89 -0
  77. package/src/pages/api/restore.ts +102 -0
  78. package/src/pages/author/[slug].astro +66 -0
  79. package/src/pages/blog/[slug].astro +240 -0
  80. package/src/pages/blog/index.astro +51 -0
  81. package/src/pages/blog/tag/[slug].astro +83 -0
  82. package/src/pages/embed/[pageId]/[blockId].ts +45 -0
  83. package/src/pages/go/[id].ts +10 -0
  84. package/src/pages/index.astro +52 -0
  85. package/src/pages/links.astro +110 -0
  86. package/src/pages/llms.txt.ts +47 -0
  87. package/src/pages/media/[...key].ts +18 -0
  88. package/src/pages/preview/[type]/[id].astro +52 -0
  89. package/src/pages/robots.txt.ts +28 -0
  90. package/src/pages/rss.xml.ts +58 -0
  91. package/src/pages/search.astro +83 -0
  92. package/src/pages/sitemap.xml.ts +55 -0
  93. package/src/styles/admin.css +652 -0
  94. package/src/styles/builder.css +121 -0
  95. package/src/styles/cards.css +176 -0
  96. package/src/styles/editor.css +596 -0
  97. package/src/styles/site.css +427 -0
  98. package/src/themes/editorial/templates/home.astro +127 -0
  99. package/src/themes/editorial/templates/post-minimal.astro +50 -0
  100. package/src/themes/editorial/theme.css +108 -0
  101. package/src/themes/editorial/theme.json +20 -0
  102. package/src/themes/field/theme.json +17 -0
  103. package/src/themes/ink/theme.json +17 -0
  104. package/src/themes/night/theme.css +7 -0
  105. package/src/themes/night/theme.json +17 -0
  106. package/src/virtual.d.ts +25 -0
  107. package/src/worker.ts +28 -0
@@ -0,0 +1,488 @@
1
+ import { Node } from '@tiptap/react';
2
+ import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
3
+ import type { NodeViewProps } from '@tiptap/react';
4
+ import { useState } from 'react';
5
+
6
+ // Card nodes shared by the post editor. renderHTML emits the public markup
7
+ // (`.pc-*` classes, styled in src/styles/cards.css); parseHTML round-trips it
8
+ // so saved bodyHtml — and legacy markdown img/iframe output — loads back in.
9
+
10
+ // ---- image ----------------------------------------------------------------
11
+
12
+ export const ImageCard = Node.create({
13
+ name: 'imageCard',
14
+ group: 'block',
15
+ atom: true,
16
+ draggable: true,
17
+
18
+ addAttributes() {
19
+ return {
20
+ src: { default: '' },
21
+ srcset: { default: '' },
22
+ sizes: { default: '' },
23
+ alt: { default: '' },
24
+ caption: { default: '' },
25
+ width: { default: 0 },
26
+ height: { default: 0 },
27
+ // display width on the public page: normal (content column), wide
28
+ // (breaks out past the column), full (edge-to-edge bleed)
29
+ display: { default: 'normal' },
30
+ };
31
+ },
32
+
33
+ parseHTML() {
34
+ return [
35
+ {
36
+ tag: 'figure.pc-image',
37
+ getAttrs: (el) => {
38
+ const img = (el as HTMLElement).querySelector('img');
39
+ if (!img) return false;
40
+ return {
41
+ src: img.getAttribute('src') ?? '',
42
+ srcset: img.getAttribute('srcset') ?? '',
43
+ sizes: img.getAttribute('sizes') ?? '',
44
+ alt: img.getAttribute('alt') ?? '',
45
+ width: Number(img.getAttribute('width') ?? 0),
46
+ height: Number(img.getAttribute('height') ?? 0),
47
+ caption: (el as HTMLElement).querySelector('figcaption')?.textContent ?? '',
48
+ display: (el as HTMLElement).getAttribute('data-width') ?? 'normal',
49
+ };
50
+ },
51
+ },
52
+ {
53
+ // legacy markdown images upgrade into cards on load
54
+ tag: 'img[src]',
55
+ getAttrs: (el) => ({
56
+ src: (el as HTMLElement).getAttribute('src') ?? '',
57
+ alt: (el as HTMLElement).getAttribute('alt') ?? '',
58
+ }),
59
+ },
60
+ ];
61
+ },
62
+
63
+ renderHTML({ node }) {
64
+ const a = node.attrs;
65
+ const img: [string, Record<string, string | number>] = [
66
+ 'img',
67
+ {
68
+ src: a.src,
69
+ alt: a.alt,
70
+ loading: 'lazy',
71
+ ...(a.srcset ? { srcset: a.srcset, sizes: a.sizes } : {}),
72
+ ...(a.width ? { width: a.width, height: a.height } : {}),
73
+ },
74
+ ];
75
+ const fig = { class: 'pc-image', ...(a.display !== 'normal' ? { 'data-width': a.display } : {}) };
76
+ return a.caption
77
+ ? ['figure', fig, img, ['figcaption', {}, a.caption]]
78
+ : ['figure', fig, img];
79
+ },
80
+
81
+ addNodeView() {
82
+ return ReactNodeViewRenderer(ImageCardView);
83
+ },
84
+ });
85
+
86
+ const IMAGE_WIDTHS = ['normal', 'wide', 'full'] as const;
87
+ const IMAGE_WIDTH_LABEL: Record<string, string> = { normal: 'Normal', wide: 'Wide', full: 'Full bleed' };
88
+
89
+ function ImageCardView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
90
+ const a = node.attrs;
91
+ return (
92
+ <NodeViewWrapper className={`pe-card pe-imagecard w-${a.display} ${selected ? 'is-selected' : ''}`} data-drag-handle>
93
+ <div className="pe-card-tools" contentEditable={false}>
94
+ <button
95
+ type="button"
96
+ title="Image width on the page"
97
+ onClick={() => {
98
+ const i = IMAGE_WIDTHS.indexOf(a.display as (typeof IMAGE_WIDTHS)[number]);
99
+ updateAttributes({ display: IMAGE_WIDTHS[(i + 1) % IMAGE_WIDTHS.length] });
100
+ }}
101
+ >
102
+ {IMAGE_WIDTH_LABEL[a.display] ?? 'Normal'}
103
+ </button>
104
+ <button
105
+ type="button"
106
+ title="Alt text"
107
+ className={a.alt ? 'has-value' : ''}
108
+ onClick={() => {
109
+ const alt = window.prompt('Alt text (for accessibility and SEO)', a.alt);
110
+ if (alt !== null) updateAttributes({ alt });
111
+ }}
112
+ >
113
+ ALT
114
+ </button>
115
+ <button type="button" title="Remove" onClick={() => deleteNode()}>✕</button>
116
+ </div>
117
+ <img src={a.src} srcSet={a.srcset || undefined} sizes={a.sizes || undefined} alt={a.alt} />
118
+ <input
119
+ className="pe-caption"
120
+ type="text"
121
+ placeholder="Add a caption (optional)"
122
+ value={a.caption}
123
+ onChange={(e) => updateAttributes({ caption: e.target.value })}
124
+ />
125
+ </NodeViewWrapper>
126
+ );
127
+ }
128
+
129
+ // ---- embed ----------------------------------------------------------------
130
+
131
+ export interface EmbedInfo {
132
+ provider: string;
133
+ embedSrc: string;
134
+ aspect: boolean; // true → 16:9, false → fixed height
135
+ }
136
+
137
+ export function detectEmbed(raw: string): EmbedInfo | null {
138
+ let url: URL;
139
+ try {
140
+ url = new URL(raw.trim());
141
+ } catch {
142
+ return null;
143
+ }
144
+ const host = url.hostname.replace(/^www\./, '');
145
+ if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'youtu.be' || host === 'youtube-nocookie.com') {
146
+ const id =
147
+ host === 'youtu.be'
148
+ ? url.pathname.slice(1).split('/')[0]
149
+ : url.pathname.startsWith('/shorts/') || url.pathname.startsWith('/embed/')
150
+ ? url.pathname.split('/')[2]
151
+ : url.searchParams.get('v');
152
+ if (id) return { provider: 'youtube', embedSrc: `https://www.youtube-nocookie.com/embed/${id}`, aspect: true };
153
+ }
154
+ if (host === 'vimeo.com' || host === 'player.vimeo.com') {
155
+ const id = url.pathname.split('/').filter(Boolean).pop();
156
+ if (id && /^\d+$/.test(id)) return { provider: 'vimeo', embedSrc: `https://player.vimeo.com/video/${id}`, aspect: true };
157
+ }
158
+ if (host === 'loom.com' && url.pathname.startsWith('/share/')) {
159
+ return { provider: 'loom', embedSrc: `https://www.loom.com/embed/${url.pathname.split('/')[2]}`, aspect: true };
160
+ }
161
+ if (host === 'codepen.io' && url.pathname.includes('/pen/')) {
162
+ return { provider: 'codepen', embedSrc: raw.replace('/pen/', '/embed/'), aspect: false };
163
+ }
164
+ if (url.protocol === 'https:') {
165
+ return { provider: 'iframe', embedSrc: raw, aspect: false };
166
+ }
167
+ return null;
168
+ }
169
+
170
+ export const EmbedCard = Node.create({
171
+ name: 'embedCard',
172
+ group: 'block',
173
+ atom: true,
174
+ draggable: true,
175
+
176
+ addAttributes() {
177
+ return {
178
+ url: { default: '' },
179
+ embedSrc: { default: '' },
180
+ provider: { default: '' },
181
+ height: { default: 400 },
182
+ caption: { default: '' },
183
+ };
184
+ },
185
+
186
+ parseHTML() {
187
+ return [
188
+ {
189
+ tag: 'figure.pc-embed',
190
+ getAttrs: (el) => {
191
+ const e = el as HTMLElement;
192
+ const iframe = e.querySelector('iframe');
193
+ return {
194
+ url: e.getAttribute('data-url') ?? iframe?.getAttribute('src') ?? '',
195
+ embedSrc: iframe?.getAttribute('src') ?? '',
196
+ provider: e.getAttribute('data-provider') ?? 'iframe',
197
+ height: Number(e.getAttribute('data-height') ?? 400),
198
+ caption: e.querySelector('figcaption')?.textContent ?? '',
199
+ };
200
+ },
201
+ },
202
+ ];
203
+ },
204
+
205
+ renderHTML({ node }) {
206
+ const a = node.attrs;
207
+ const aspect = a.provider === 'youtube' || a.provider === 'vimeo' || a.provider === 'loom';
208
+ const frame: [string, Record<string, string>, ...unknown[]] = [
209
+ 'div',
210
+ { class: 'pc-embed-frame', style: aspect ? 'aspect-ratio:16/9' : `height:${a.height}px` },
211
+ ['iframe', {
212
+ src: a.embedSrc, loading: 'lazy', allowfullscreen: '',
213
+ allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture',
214
+ }],
215
+ ];
216
+ const attrs = { class: 'pc-embed', 'data-provider': a.provider, 'data-url': a.url, 'data-height': String(a.height) };
217
+ return a.caption
218
+ ? ['figure', attrs, frame, ['figcaption', {}, a.caption]]
219
+ : ['figure', attrs, frame];
220
+ },
221
+
222
+ addNodeView() {
223
+ return ReactNodeViewRenderer(EmbedCardView);
224
+ },
225
+ });
226
+
227
+ function EmbedCardView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
228
+ const a = node.attrs;
229
+ const [draft, setDraft] = useState('');
230
+ const [error, setError] = useState('');
231
+
232
+ if (!a.embedSrc) {
233
+ return (
234
+ <NodeViewWrapper className={`pe-card pe-embedcard is-empty ${selected ? 'is-selected' : ''}`}>
235
+ <form
236
+ className="pe-embed-form"
237
+ contentEditable={false}
238
+ onSubmit={(e) => {
239
+ e.preventDefault();
240
+ const info = detectEmbed(draft);
241
+ if (!info) return setError('That doesn’t look like an embeddable URL.');
242
+ updateAttributes({ url: draft.trim(), embedSrc: info.embedSrc, provider: info.provider });
243
+ }}
244
+ >
245
+ <span className="pe-embed-label">Embed</span>
246
+ <input
247
+ autoFocus
248
+ type="url"
249
+ placeholder="Paste a YouTube, Vimeo, Loom, CodePen or iframe URL…"
250
+ value={draft}
251
+ onChange={(e) => { setDraft(e.target.value); setError(''); }}
252
+ onKeyDown={(e) => { if (e.key === 'Escape') deleteNode(); }}
253
+ />
254
+ <button type="submit" className="pe-btn primary sm">Embed</button>
255
+ {error && <span className="pe-embed-error">{error}</span>}
256
+ </form>
257
+ </NodeViewWrapper>
258
+ );
259
+ }
260
+
261
+ const aspect = a.provider === 'youtube' || a.provider === 'vimeo' || a.provider === 'loom';
262
+ return (
263
+ <NodeViewWrapper className={`pe-card pe-embedcard ${selected ? 'is-selected' : ''}`} data-drag-handle>
264
+ <div className="pe-card-tools" contentEditable={false}>
265
+ <span className="pe-provider">{a.provider}</span>
266
+ <button type="button" title="Remove" onClick={() => deleteNode()}>✕</button>
267
+ </div>
268
+ <div className="pc-embed-frame" style={aspect ? { aspectRatio: '16/9' } : { height: a.height }}>
269
+ <iframe src={a.embedSrc} title={a.url} allowFullScreen />
270
+ </div>
271
+ <input
272
+ className="pe-caption"
273
+ type="text"
274
+ placeholder="Add a caption (optional)"
275
+ value={a.caption}
276
+ onChange={(e) => updateAttributes({ caption: e.target.value })}
277
+ />
278
+ </NodeViewWrapper>
279
+ );
280
+ }
281
+
282
+ // ---- button ---------------------------------------------------------------
283
+
284
+ export const ButtonCard = Node.create({
285
+ name: 'buttonCard',
286
+ group: 'block',
287
+ atom: true,
288
+ draggable: true,
289
+
290
+ addAttributes() {
291
+ return {
292
+ label: { default: '' },
293
+ href: { default: '' },
294
+ align: { default: 'center' },
295
+ };
296
+ },
297
+
298
+ parseHTML() {
299
+ return [
300
+ {
301
+ tag: 'div.pc-buttoncard',
302
+ getAttrs: (el) => {
303
+ const e = el as HTMLElement;
304
+ const link = e.querySelector('a');
305
+ return {
306
+ label: link?.textContent ?? '',
307
+ href: link?.getAttribute('href') ?? '',
308
+ align: e.getAttribute('data-align') ?? 'center',
309
+ };
310
+ },
311
+ },
312
+ ];
313
+ },
314
+
315
+ renderHTML({ node }) {
316
+ const a = node.attrs;
317
+ return [
318
+ 'div',
319
+ { class: 'pc-buttoncard', 'data-align': a.align, style: `text-align:${a.align}` },
320
+ ['a', { class: 'pc-btncard', href: a.href }, a.label || 'Button'],
321
+ ];
322
+ },
323
+
324
+ addNodeView() {
325
+ return ReactNodeViewRenderer(ButtonCardView);
326
+ },
327
+ });
328
+
329
+ function ButtonCardView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
330
+ const a = node.attrs;
331
+ return (
332
+ <NodeViewWrapper className={`pe-card pe-buttoncard ${selected ? 'is-selected' : ''}`} data-drag-handle>
333
+ <div className="pe-card-tools" contentEditable={false}>
334
+ <button
335
+ type="button"
336
+ onClick={() => updateAttributes({ align: a.align === 'center' ? 'left' : 'center' })}
337
+ title="Toggle alignment"
338
+ >
339
+ {a.align === 'center' ? 'Centered' : 'Left'}
340
+ </button>
341
+ <button type="button" title="Remove" onClick={() => deleteNode()}>✕</button>
342
+ </div>
343
+ <div className="pe-button-fields" contentEditable={false}>
344
+ <input
345
+ type="text"
346
+ placeholder="Button label"
347
+ value={a.label}
348
+ onChange={(e) => updateAttributes({ label: e.target.value })}
349
+ />
350
+ <input
351
+ type="url"
352
+ placeholder="https://…"
353
+ value={a.href}
354
+ onChange={(e) => updateAttributes({ href: e.target.value })}
355
+ />
356
+ </div>
357
+ <div className="pc-buttoncard" style={{ textAlign: a.align as 'center' | 'left' }}>
358
+ <span className="pc-btncard">{a.label || 'Button'}</span>
359
+ </div>
360
+ </NodeViewWrapper>
361
+ );
362
+ }
363
+
364
+ // ---- callout --------------------------------------------------------------
365
+
366
+ const CALLOUT_EMOJI = ['💡', '⚠️', '📌', '🔥', '✅', '❓', '🎉'];
367
+
368
+ export const Callout = Node.create({
369
+ name: 'callout',
370
+ group: 'block',
371
+ content: 'inline*',
372
+ defining: true,
373
+
374
+ addAttributes() {
375
+ return { emoji: { default: '💡', renderHTML: () => ({}) } };
376
+ },
377
+
378
+ parseHTML() {
379
+ return [
380
+ {
381
+ tag: 'aside.pc-callout',
382
+ getAttrs: (el) => ({ emoji: (el as HTMLElement).getAttribute('data-emoji') ?? '💡' }),
383
+ contentElement: '.pc-callout-body',
384
+ },
385
+ ];
386
+ },
387
+
388
+ renderHTML({ node }) {
389
+ return [
390
+ 'aside',
391
+ { class: 'pc-callout', 'data-emoji': node.attrs.emoji },
392
+ ['span', { class: 'pc-callout-emoji' }, node.attrs.emoji],
393
+ ['div', { class: 'pc-callout-body' }, 0],
394
+ ];
395
+ },
396
+
397
+ addNodeView() {
398
+ return ReactNodeViewRenderer(CalloutView);
399
+ },
400
+ });
401
+
402
+ function CalloutView({ node, updateAttributes }: NodeViewProps) {
403
+ return (
404
+ <NodeViewWrapper className="pe-callout pc-callout">
405
+ <button
406
+ type="button"
407
+ className="pc-callout-emoji"
408
+ contentEditable={false}
409
+ title="Change emoji"
410
+ onClick={() => {
411
+ const i = CALLOUT_EMOJI.indexOf(node.attrs.emoji);
412
+ updateAttributes({ emoji: CALLOUT_EMOJI[(i + 1) % CALLOUT_EMOJI.length] });
413
+ }}
414
+ >
415
+ {node.attrs.emoji}
416
+ </button>
417
+ <NodeViewContent className="pc-callout-body" />
418
+ </NodeViewWrapper>
419
+ );
420
+ }
421
+
422
+ // ---- raw HTML -------------------------------------------------------------
423
+
424
+ export const HtmlCard = Node.create({
425
+ name: 'htmlCard',
426
+ group: 'block',
427
+ atom: true,
428
+ draggable: true,
429
+
430
+ addAttributes() {
431
+ return { html: { default: '' } };
432
+ },
433
+
434
+ parseHTML() {
435
+ return [
436
+ {
437
+ tag: 'div.pc-html',
438
+ getAttrs: (el) => ({ html: (el as HTMLElement).innerHTML }),
439
+ },
440
+ ];
441
+ },
442
+
443
+ renderHTML({ node }) {
444
+ // Serialized client-side only (save runs in the browser), so a real DOM
445
+ // node lets the raw HTML pass through ProseMirror's serializer intact.
446
+ if (typeof document !== 'undefined') {
447
+ const el = document.createElement('div');
448
+ el.className = 'pc-html';
449
+ el.innerHTML = node.attrs.html;
450
+ return { dom: el };
451
+ }
452
+ return ['div', { class: 'pc-html' }];
453
+ },
454
+
455
+ addNodeView() {
456
+ return ReactNodeViewRenderer(HtmlCardView);
457
+ },
458
+ });
459
+
460
+ function HtmlCardView({ node, updateAttributes, deleteNode, selected }: NodeViewProps) {
461
+ const [editing, setEditing] = useState(!node.attrs.html);
462
+ return (
463
+ <NodeViewWrapper className={`pe-card pe-htmlcard ${selected ? 'is-selected' : ''}`} data-drag-handle>
464
+ <div className="pe-card-tools" contentEditable={false}>
465
+ <span className="pe-provider">HTML</span>
466
+ <button type="button" onClick={() => setEditing((v) => !v)}>{editing ? 'Preview' : 'Edit'}</button>
467
+ <button type="button" title="Remove" onClick={() => deleteNode()}>✕</button>
468
+ </div>
469
+ {editing ? (
470
+ <textarea
471
+ className="pe-html-input"
472
+ contentEditable={false}
473
+ rows={6}
474
+ placeholder="<div>Raw HTML — rendered as-is on the public page</div>"
475
+ value={node.attrs.html}
476
+ onChange={(e) => updateAttributes({ html: e.target.value })}
477
+ />
478
+ ) : (
479
+ <iframe
480
+ className="pe-html-preview"
481
+ sandbox="allow-scripts"
482
+ title="HTML preview"
483
+ srcDoc={`<style>body{margin:8px;font-family:Inter,system-ui,sans-serif;color:#1a1d25;background:#f4f5f8}</style>${node.attrs.html}`}
484
+ />
485
+ )}
486
+ </NodeViewWrapper>
487
+ );
488
+ }
@@ -0,0 +1,70 @@
1
+ import { CodeBlockLowlight } from '@tiptap/extension-code-block-lowlight';
2
+ import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
3
+ import type { NodeViewProps } from '@tiptap/react';
4
+ import { createLowlight, common } from 'lowlight';
5
+
6
+ // Highlighted code block. lowlight's `common` grammar set is shared with the
7
+ // server-side pass (src/lib/highlightCode.ts) so the editor and the published
8
+ // page tokenize identically. renderHTML stays the stock
9
+ // `<pre><code class="language-x">` so saved bodyHtml round-trips.
10
+
11
+ const lowlight = createLowlight(common);
12
+
13
+ // Curated subset of lowlight `common` — value must be a registered grammar
14
+ // name ('' = plain text, no highlighting).
15
+ const LANGUAGES: [value: string, label: string][] = [
16
+ ['', 'Plain text'],
17
+ ['xml', 'HTML'],
18
+ ['css', 'CSS'],
19
+ ['javascript', 'JavaScript'],
20
+ ['typescript', 'TypeScript'],
21
+ ['json', 'JSON'],
22
+ ['bash', 'Bash'],
23
+ ['python', 'Python'],
24
+ ['sql', 'SQL'],
25
+ ['go', 'Go'],
26
+ ['rust', 'Rust'],
27
+ ['php', 'PHP'],
28
+ ['ruby', 'Ruby'],
29
+ ['java', 'Java'],
30
+ ['c', 'C'],
31
+ ['cpp', 'C++'],
32
+ ['csharp', 'C#'],
33
+ ['swift', 'Swift'],
34
+ ['kotlin', 'Kotlin'],
35
+ ['yaml', 'YAML'],
36
+ ['markdown', 'Markdown'],
37
+ ['diff', 'Diff'],
38
+ ];
39
+
40
+ function CodeBlockView({ node, updateAttributes }: NodeViewProps) {
41
+ const language = node.attrs.language ?? '';
42
+ return (
43
+ <NodeViewWrapper className="pe-codeblock">
44
+ <select
45
+ className="pe-codeblock-lang"
46
+ contentEditable={false}
47
+ value={LANGUAGES.some(([v]) => v === language) ? language : ''}
48
+ onChange={(e) => updateAttributes({ language: e.target.value || null })}
49
+ >
50
+ {LANGUAGES.map(([value, label]) => (
51
+ <option key={value} value={value}>
52
+ {label}
53
+ </option>
54
+ ))}
55
+ </select>
56
+ <pre>
57
+ <NodeViewContent as="code" />
58
+ </pre>
59
+ </NodeViewWrapper>
60
+ );
61
+ }
62
+
63
+ export const CodeBlock = CodeBlockLowlight.extend({
64
+ addNodeView() {
65
+ return ReactNodeViewRenderer(CodeBlockView);
66
+ },
67
+ }).configure({
68
+ lowlight,
69
+ defaultLanguage: null,
70
+ });