@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.
- package/cli.mjs +75 -0
- package/integration.mjs +286 -0
- package/package.json +89 -0
- package/src/actions/index.ts +502 -0
- package/src/components/DesignManager.tsx +542 -0
- package/src/components/LinksManager.tsx +694 -0
- package/src/components/Modal.astro +65 -0
- package/src/components/NavManager.tsx +168 -0
- package/src/components/WidgetRenderer.astro +84 -0
- package/src/components/builder/PageBuilder.tsx +575 -0
- package/src/components/editor/ImagePicker.tsx +215 -0
- package/src/components/editor/PostEditor.tsx +1011 -0
- package/src/components/editor/PreviewOverlay.tsx +77 -0
- package/src/components/editor/extensions/calendar.tsx +230 -0
- package/src/components/editor/extensions/cards.tsx +488 -0
- package/src/components/editor/extensions/codeBlock.tsx +70 -0
- package/src/components/editor/extensions/infoCards.tsx +270 -0
- package/src/components/editor/extensions/mediaCards.tsx +376 -0
- package/src/components/editor/extensions/postList.tsx +130 -0
- package/src/components/editor/extensions/slash.tsx +250 -0
- package/src/components/editor/extensions/tableTyping.ts +63 -0
- package/src/components/editor/imaging.ts +146 -0
- package/src/components/media/CropModal.tsx +229 -0
- package/src/components/media/MediaLibrary.tsx +295 -0
- package/src/layouts/Admin.astro +119 -0
- package/src/layouts/Site.astro +218 -0
- package/src/lib/auth.ts +94 -0
- package/src/lib/backup/unzip.ts +38 -0
- package/src/lib/backup/zip.ts +105 -0
- package/src/lib/blocksToHtml.ts +68 -0
- package/src/lib/brand.ts +26 -0
- package/src/lib/calendar/ics.ts +402 -0
- package/src/lib/calendar/render.ts +275 -0
- package/src/lib/calendar/src.ts +68 -0
- package/src/lib/content.ts +53 -0
- package/src/lib/dates.ts +34 -0
- package/src/lib/dynamicBlocks.ts +115 -0
- package/src/lib/env.ts +6 -0
- package/src/lib/highlightCode.ts +44 -0
- package/src/lib/i18n.ts +40 -0
- package/src/lib/markdown.ts +7 -0
- package/src/lib/media/store.ts +95 -0
- package/src/lib/net.ts +51 -0
- package/src/lib/preview.ts +28 -0
- package/src/lib/repo/d1.ts +44 -0
- package/src/lib/repo/index.ts +23 -0
- package/src/lib/repo/migrations.ts +231 -0
- package/src/lib/repo/search.ts +14 -0
- package/src/lib/repo/sql.ts +721 -0
- package/src/lib/repo/sqlite.ts +43 -0
- package/src/lib/repo/types.ts +132 -0
- package/src/lib/schema.ts +286 -0
- package/src/lib/seo.ts +37 -0
- package/src/lib/theme.ts +206 -0
- package/src/lib/widgets.ts +108 -0
- package/src/middleware.ts +109 -0
- package/src/pages/[...slug].astro +43 -0
- package/src/pages/admin/c/[collection]/[id].astro +56 -0
- package/src/pages/admin/c/[collection]/index.astro +163 -0
- package/src/pages/admin/design.astro +42 -0
- package/src/pages/admin/index.astro +145 -0
- package/src/pages/admin/links.astro +21 -0
- package/src/pages/admin/login.astro +83 -0
- package/src/pages/admin/logout.ts +7 -0
- package/src/pages/admin/media.astro +13 -0
- package/src/pages/admin/navigation.astro +17 -0
- package/src/pages/admin/pages/[id].astro +49 -0
- package/src/pages/admin/pages/index.astro +100 -0
- package/src/pages/admin/posts/[id].astro +6 -0
- package/src/pages/admin/posts/index.astro +7 -0
- package/src/pages/admin/settings.astro +512 -0
- package/src/pages/admin/tags.astro +101 -0
- package/src/pages/admin/users.astro +149 -0
- package/src/pages/api/backup.ts +63 -0
- package/src/pages/api/drafts.ts +106 -0
- package/src/pages/api/media/upload.ts +89 -0
- package/src/pages/api/restore.ts +102 -0
- package/src/pages/author/[slug].astro +66 -0
- package/src/pages/blog/[slug].astro +240 -0
- package/src/pages/blog/index.astro +51 -0
- package/src/pages/blog/tag/[slug].astro +83 -0
- package/src/pages/embed/[pageId]/[blockId].ts +45 -0
- package/src/pages/go/[id].ts +10 -0
- package/src/pages/index.astro +52 -0
- package/src/pages/links.astro +110 -0
- package/src/pages/llms.txt.ts +47 -0
- package/src/pages/media/[...key].ts +18 -0
- package/src/pages/preview/[type]/[id].astro +52 -0
- package/src/pages/robots.txt.ts +28 -0
- package/src/pages/rss.xml.ts +58 -0
- package/src/pages/search.astro +83 -0
- package/src/pages/sitemap.xml.ts +55 -0
- package/src/styles/admin.css +652 -0
- package/src/styles/builder.css +121 -0
- package/src/styles/cards.css +176 -0
- package/src/styles/editor.css +596 -0
- package/src/styles/site.css +427 -0
- package/src/themes/editorial/templates/home.astro +127 -0
- package/src/themes/editorial/templates/post-minimal.astro +50 -0
- package/src/themes/editorial/theme.css +108 -0
- package/src/themes/editorial/theme.json +20 -0
- package/src/themes/field/theme.json +17 -0
- package/src/themes/ink/theme.json +17 -0
- package/src/themes/night/theme.css +7 -0
- package/src/themes/night/theme.json +17 -0
- package/src/virtual.d.ts +25 -0
- 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
|
+
});
|