@acms-subir/core 0.1.0 → 0.2.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.
- package/CHANGELOG.md +28 -0
- package/cli.mjs +63 -9
- package/integration.mjs +11 -0
- package/package.json +10 -9
- package/src/actions/index.ts +184 -6
- package/src/components/DesignManager.tsx +53 -17
- package/src/components/LinksManager.tsx +110 -48
- package/src/components/NavManager.tsx +83 -37
- package/src/components/editor/PostEditor.tsx +228 -55
- package/src/components/editor/extensions/cards.tsx +17 -1
- package/src/components/editor/extensions/codeBlock.tsx +1 -1
- package/src/components/editor/extensions/infoCards.tsx +16 -1
- package/src/components/editor/extensions/mediaCards.tsx +36 -2
- package/src/components/editor/extensions/slash.tsx +70 -8
- package/src/components/editor/extensions/toc.tsx +89 -0
- package/src/components/editor/extensions/toggle.tsx +70 -0
- package/src/layouts/Site.astro +10 -1
- package/src/lib/analytics.ts +121 -0
- package/src/lib/designPreview.ts +14 -0
- package/src/lib/drafts.ts +77 -0
- package/src/lib/dynamicBlocks.ts +43 -3
- package/src/lib/repo/migrations.ts +37 -0
- package/src/lib/repo/sql.ts +127 -10
- package/src/lib/repo/types.ts +21 -1
- package/src/lib/schema.ts +53 -0
- package/src/pages/[...slug].astro +3 -1
- package/src/pages/admin/c/[collection]/[id].astro +10 -3
- package/src/pages/admin/c/[collection]/index.astro +1 -0
- package/src/pages/admin/design.astro +6 -1
- package/src/pages/admin/index.astro +5 -2
- package/src/pages/admin/pages/[id].astro +10 -2
- package/src/pages/admin/pages/index.astro +1 -0
- package/src/pages/admin/settings.astro +148 -0
- package/src/pages/blog/[slug].astro +4 -1
- package/src/pages/index.astro +4 -1
- package/src/pages/links.astro +10 -4
- package/src/pages/preview/[type]/[id].astro +9 -4
- package/src/styles/admin.css +4 -0
- package/src/styles/cards.css +23 -0
- package/src/styles/editor.css +53 -1
- package/src/styles/site.css +3 -0
|
@@ -5,6 +5,7 @@ import { useRef, useState } from 'react';
|
|
|
5
5
|
import { actions } from 'astro:actions';
|
|
6
6
|
import { uploadFile, mediaUrl } from '../imaging';
|
|
7
7
|
import { ImagePicker } from '../ImagePicker';
|
|
8
|
+
import { cleanEventName } from '../../../lib/analytics';
|
|
8
9
|
|
|
9
10
|
// Media cards beyond the basic image: file/download, gallery, bookmark.
|
|
10
11
|
// Same contract as cards.tsx — renderHTML emits public `.pc-*` markup,
|
|
@@ -30,6 +31,8 @@ export const FileCard = Node.create({
|
|
|
30
31
|
href: { default: '' },
|
|
31
32
|
filename: { default: '' },
|
|
32
33
|
size: { default: 0 },
|
|
34
|
+
// optional analytics event name → data-umami-event on the public <a>
|
|
35
|
+
event: { default: '' },
|
|
33
36
|
};
|
|
34
37
|
},
|
|
35
38
|
|
|
@@ -43,6 +46,7 @@ export const FileCard = Node.create({
|
|
|
43
46
|
href: e.getAttribute('href') ?? '',
|
|
44
47
|
filename: e.querySelector('.pc-file-name')?.textContent ?? e.getAttribute('download') ?? '',
|
|
45
48
|
size: Number(e.getAttribute('data-size') ?? 0),
|
|
49
|
+
event: e.getAttribute('data-umami-event') ?? '',
|
|
46
50
|
};
|
|
47
51
|
},
|
|
48
52
|
},
|
|
@@ -51,9 +55,13 @@ export const FileCard = Node.create({
|
|
|
51
55
|
|
|
52
56
|
renderHTML({ node }) {
|
|
53
57
|
const a = node.attrs;
|
|
58
|
+
const event = cleanEventName(a.event ?? '');
|
|
54
59
|
return [
|
|
55
60
|
'a',
|
|
56
|
-
{
|
|
61
|
+
{
|
|
62
|
+
class: 'pc-filecard', href: a.href, download: a.filename, 'data-size': String(a.size),
|
|
63
|
+
...(event ? { 'data-umami-event': event } : {}),
|
|
64
|
+
},
|
|
57
65
|
['span', { class: 'pc-file-icon', 'aria-hidden': 'true' }, '↓'],
|
|
58
66
|
['span', { class: 'pc-file-text' },
|
|
59
67
|
['span', { class: 'pc-file-name' }, a.filename || 'Download'],
|
|
@@ -111,6 +119,15 @@ function FileCardView({ node, updateAttributes, deleteNode, selected }: NodeView
|
|
|
111
119
|
<span className="pe-provider">File</span>
|
|
112
120
|
<button type="button" title="Remove" onClick={() => deleteNode()}>✕</button>
|
|
113
121
|
</div>
|
|
122
|
+
<div className="pe-button-fields" contentEditable={false}>
|
|
123
|
+
<input
|
|
124
|
+
type="text"
|
|
125
|
+
placeholder="Analytics event (optional)"
|
|
126
|
+
title="Downloads are counted under this name in Umami (and forwarded to enabled ad pixels)"
|
|
127
|
+
value={a.event}
|
|
128
|
+
onChange={(e) => updateAttributes({ event: e.target.value })}
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
114
131
|
<span className="pc-filecard" contentEditable={false}>
|
|
115
132
|
<span className="pc-file-icon" aria-hidden="true">↓</span>
|
|
116
133
|
<span className="pc-file-text">
|
|
@@ -262,6 +279,8 @@ export const BookmarkCard = Node.create({
|
|
|
262
279
|
image: { default: '' },
|
|
263
280
|
favicon: { default: '' },
|
|
264
281
|
siteName: { default: '' },
|
|
282
|
+
// optional analytics event name → data-umami-event on the public <a>
|
|
283
|
+
event: { default: '' },
|
|
265
284
|
};
|
|
266
285
|
},
|
|
267
286
|
|
|
@@ -278,6 +297,7 @@ export const BookmarkCard = Node.create({
|
|
|
278
297
|
image: e.querySelector<HTMLElement>('.pc-bookmark-thumb img')?.getAttribute('src') ?? '',
|
|
279
298
|
favicon: e.querySelector<HTMLElement>('.pc-bookmark-favicon')?.getAttribute('src') ?? '',
|
|
280
299
|
siteName: e.querySelector('.pc-bookmark-site')?.textContent ?? '',
|
|
300
|
+
event: e.getAttribute('data-umami-event') ?? '',
|
|
281
301
|
};
|
|
282
302
|
},
|
|
283
303
|
},
|
|
@@ -296,7 +316,12 @@ export const BookmarkCard = Node.create({
|
|
|
296
316
|
];
|
|
297
317
|
if (a.description) content.push(['div', { class: 'pc-bookmark-desc' }, a.description]);
|
|
298
318
|
content.push(meta);
|
|
299
|
-
const
|
|
319
|
+
const event = cleanEventName(a.event ?? '');
|
|
320
|
+
const out: unknown[] = [
|
|
321
|
+
'a',
|
|
322
|
+
{ class: 'pc-bookmark', href: a.url, target: '_blank', rel: 'noopener', ...(event ? { 'data-umami-event': event } : {}) },
|
|
323
|
+
content,
|
|
324
|
+
];
|
|
300
325
|
if (a.image) out.push(['div', { class: 'pc-bookmark-thumb' }, ['img', { src: a.image, alt: '', loading: 'lazy' }]]);
|
|
301
326
|
return out as [string, Record<string, string>, ...unknown[]];
|
|
302
327
|
},
|
|
@@ -352,6 +377,15 @@ function BookmarkCardView({ node, updateAttributes, deleteNode, selected }: Node
|
|
|
352
377
|
<span className="pe-provider">Bookmark</span>
|
|
353
378
|
<button type="button" title="Remove" onClick={() => deleteNode()}>✕</button>
|
|
354
379
|
</div>
|
|
380
|
+
<div className="pe-button-fields" contentEditable={false}>
|
|
381
|
+
<input
|
|
382
|
+
type="text"
|
|
383
|
+
placeholder="Analytics event (optional)"
|
|
384
|
+
title="Clicks are counted under this name in Umami (and forwarded to enabled ad pixels)"
|
|
385
|
+
value={a.event}
|
|
386
|
+
onChange={(e) => updateAttributes({ event: e.target.value })}
|
|
387
|
+
/>
|
|
388
|
+
</div>
|
|
355
389
|
<span className="pc-bookmark" contentEditable={false}>
|
|
356
390
|
<span className="pc-bookmark-content">
|
|
357
391
|
<span className="pc-bookmark-title">{a.title || a.url}</span>
|
|
@@ -6,16 +6,27 @@ import { CONTENT } from '../../../lib/content';
|
|
|
6
6
|
|
|
7
7
|
// "/" command menu, Ghost-style: type / on a line to insert cards and blocks.
|
|
8
8
|
|
|
9
|
+
export interface SlashSnippet {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
contentJson: string; // JSON array of ProseMirror nodes
|
|
13
|
+
}
|
|
14
|
+
|
|
9
15
|
export interface SlashContext {
|
|
10
16
|
openImagePicker: (insertAt: Range) => void;
|
|
17
|
+
// saved snippets, provided by the editor shell (loaded once per mount)
|
|
18
|
+
getSnippets: () => SlashSnippet[];
|
|
19
|
+
deleteSnippet: (id: string) => void;
|
|
11
20
|
}
|
|
12
21
|
|
|
13
22
|
interface SlashItem {
|
|
14
23
|
title: string;
|
|
15
24
|
description: string;
|
|
16
25
|
keywords: string;
|
|
17
|
-
group: 'Basic' | 'Cards';
|
|
26
|
+
group: 'Basic' | 'Cards' | 'Snippets';
|
|
18
27
|
icon: string; // svg path (24 viewBox, stroked)
|
|
28
|
+
snippetId?: string;
|
|
29
|
+
onDelete?: () => void;
|
|
19
30
|
run: (editor: Editor, range: Range, ctx: SlashContext) => void;
|
|
20
31
|
}
|
|
21
32
|
|
|
@@ -58,6 +69,17 @@ const ITEMS: SlashItem[] = [
|
|
|
58
69
|
icon: 'M12 3l9 18H3zM12 10v4M12 17.5h0',
|
|
59
70
|
run: (editor, range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'callout' }).run(),
|
|
60
71
|
},
|
|
72
|
+
{
|
|
73
|
+
title: 'Toggle', description: 'Collapsible section', keywords: 'toggle accordion collapse details faq expand', group: 'Cards',
|
|
74
|
+
icon: 'M8 5l4 4 4-4M6 13h12M6 17h8',
|
|
75
|
+
run: (editor, range) =>
|
|
76
|
+
editor.chain().focus().deleteRange(range).insertContent({ type: 'toggleCard', content: [{ type: 'paragraph' }] }).run(),
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
title: 'Table of contents', description: 'Linked outline of this document (dynamic)', keywords: 'toc contents outline sections navigation dynamic', group: 'Cards',
|
|
80
|
+
icon: 'M9 6h12M9 12h12M12 18h9M4 5.5h2v2H4zM6 11h3M7 16.5h2',
|
|
81
|
+
run: (editor, range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'tocCard' }).run(),
|
|
82
|
+
},
|
|
61
83
|
{
|
|
62
84
|
title: 'HTML', description: 'Raw HTML block', keywords: 'html code embed custom', group: 'Cards',
|
|
63
85
|
icon: 'M8 6l-5 6 5 6M16 6l5 6-5 6',
|
|
@@ -89,6 +111,11 @@ const ITEMS: SlashItem[] = [
|
|
|
89
111
|
icon: 'M3 12h18',
|
|
90
112
|
run: (editor, range) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
|
|
91
113
|
},
|
|
114
|
+
{
|
|
115
|
+
title: 'Heading 1', description: 'Large section heading', keywords: 'heading h1 title large', group: 'Basic',
|
|
116
|
+
icon: 'M4 5v14M14 5v14M4 12h10M18.5 9.5l1.5-1v7',
|
|
117
|
+
run: (editor, range) => editor.chain().focus().deleteRange(range).setNode('heading', { level: 1 }).run(),
|
|
118
|
+
},
|
|
92
119
|
{
|
|
93
120
|
title: 'Heading 2', description: 'Section heading', keywords: 'heading h2 title section', group: 'Basic',
|
|
94
121
|
icon: 'M4 5v14M14 5v14M4 12h10M18 15c0-1 2-1.6 2-3a1.8 1.8 0 0 0-3.4-.8',
|
|
@@ -132,23 +159,27 @@ export interface SlashListHandle {
|
|
|
132
159
|
|
|
133
160
|
const SlashList = forwardRef<SlashListHandle, ListProps>(function SlashList({ items, command }, ref) {
|
|
134
161
|
const [selected, setSelected] = useState(0);
|
|
162
|
+
// snippets deleted from the open menu disappear immediately; the source
|
|
163
|
+
// list refreshes on the next menu open
|
|
164
|
+
const [hidden, setHidden] = useState<Set<string>>(() => new Set());
|
|
165
|
+
const visible = items.filter((i) => !i.snippetId || !hidden.has(i.snippetId));
|
|
135
166
|
useEffect(() => setSelected(0), [items]);
|
|
136
167
|
|
|
137
168
|
useImperativeHandle(ref, () => ({
|
|
138
169
|
onKeyDown(e) {
|
|
139
|
-
if (e.key === 'ArrowDown') { setSelected((s) => (s + 1) %
|
|
140
|
-
if (e.key === 'ArrowUp') { setSelected((s) => (s - 1 +
|
|
141
|
-
if (e.key === 'Enter') { if (
|
|
170
|
+
if (e.key === 'ArrowDown') { setSelected((s) => (s + 1) % visible.length); return true; }
|
|
171
|
+
if (e.key === 'ArrowUp') { setSelected((s) => (s - 1 + visible.length) % visible.length); return true; }
|
|
172
|
+
if (e.key === 'Enter') { if (visible[selected]) command(visible[selected]); return true; }
|
|
142
173
|
return false;
|
|
143
174
|
},
|
|
144
175
|
}));
|
|
145
176
|
|
|
146
|
-
if (!
|
|
177
|
+
if (!visible.length) return <div className="pe-slash"><div className="pe-slash-empty">No matches</div></div>;
|
|
147
178
|
|
|
148
179
|
let lastGroup = '';
|
|
149
180
|
return (
|
|
150
181
|
<div className="pe-slash">
|
|
151
|
-
{
|
|
182
|
+
{visible.map((item, i) => {
|
|
152
183
|
const header = item.group !== lastGroup ? item.group : null;
|
|
153
184
|
lastGroup = item.group;
|
|
154
185
|
return (
|
|
@@ -169,6 +200,22 @@ const SlashList = forwardRef<SlashListHandle, ListProps>(function SlashList({ it
|
|
|
169
200
|
<span className="pe-slash-title">{item.title}</span>
|
|
170
201
|
<span className="pe-slash-desc">{item.description}</span>
|
|
171
202
|
</span>
|
|
203
|
+
{item.onDelete && item.snippetId && (
|
|
204
|
+
<span
|
|
205
|
+
role="button"
|
|
206
|
+
tabIndex={-1}
|
|
207
|
+
className="pe-slash-del"
|
|
208
|
+
title="Delete snippet"
|
|
209
|
+
onMouseDown={(e) => {
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
e.stopPropagation();
|
|
212
|
+
item.onDelete!();
|
|
213
|
+
setHidden((h) => new Set(h).add(item.snippetId!));
|
|
214
|
+
}}
|
|
215
|
+
>
|
|
216
|
+
✕
|
|
217
|
+
</span>
|
|
218
|
+
)}
|
|
172
219
|
</button>
|
|
173
220
|
</div>
|
|
174
221
|
);
|
|
@@ -181,7 +228,7 @@ export const SlashMenu = Extension.create<{ context: SlashContext }>({
|
|
|
181
228
|
name: 'slashMenu',
|
|
182
229
|
|
|
183
230
|
addOptions() {
|
|
184
|
-
return { context: { openImagePicker: () => {} } };
|
|
231
|
+
return { context: { openImagePicker: () => {}, getSnippets: () => [], deleteSnippet: () => {} } };
|
|
185
232
|
},
|
|
186
233
|
|
|
187
234
|
addProseMirrorPlugins() {
|
|
@@ -196,7 +243,22 @@ export const SlashMenu = Extension.create<{ context: SlashContext }>({
|
|
|
196
243
|
},
|
|
197
244
|
items: ({ query }: { query: string }) => {
|
|
198
245
|
const q = query.toLowerCase();
|
|
199
|
-
|
|
246
|
+
const snippets: SlashItem[] = context.getSnippets().map((s) => ({
|
|
247
|
+
title: s.name,
|
|
248
|
+
description: 'Saved snippet',
|
|
249
|
+
keywords: `snippet ${s.name.toLowerCase()}`,
|
|
250
|
+
group: 'Snippets' as const,
|
|
251
|
+
icon: 'M4 7l8-4 8 4-8 4zM4 12l8 4 8-4M4 17l8 4 8-4',
|
|
252
|
+
snippetId: s.id,
|
|
253
|
+
onDelete: () => context.deleteSnippet(s.id),
|
|
254
|
+
run: (editor, range) => {
|
|
255
|
+
let content: unknown = null;
|
|
256
|
+
try { content = JSON.parse(s.contentJson); } catch { /* corrupt row: insert nothing */ }
|
|
257
|
+
if (!Array.isArray(content) || !content.length) return;
|
|
258
|
+
editor.chain().focus().deleteRange(range).insertContent(content).run();
|
|
259
|
+
},
|
|
260
|
+
}));
|
|
261
|
+
return [...ITEMS, ...snippets].filter(
|
|
200
262
|
(i) => i.title.toLowerCase().includes(q) || i.keywords.includes(q)
|
|
201
263
|
);
|
|
202
264
|
},
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Node } from '@tiptap/core';
|
|
2
|
+
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
|
|
3
|
+
import type { NodeViewProps } from '@tiptap/react';
|
|
4
|
+
import { useEffect, useState } from 'react';
|
|
5
|
+
|
|
6
|
+
// Table-of-contents card: a *dynamic* block like the post list — it
|
|
7
|
+
// serializes as a placeholder div (data-dyn="toc") that public routes expand
|
|
8
|
+
// at request time from the body's anchored headings, so it can never go
|
|
9
|
+
// stale. The editor shows a live outline built from the current document.
|
|
10
|
+
|
|
11
|
+
export const TocCard = Node.create({
|
|
12
|
+
name: 'tocCard',
|
|
13
|
+
group: 'block',
|
|
14
|
+
atom: true,
|
|
15
|
+
draggable: true,
|
|
16
|
+
|
|
17
|
+
addAttributes() {
|
|
18
|
+
return { title: { default: 'In this article' } };
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
parseHTML() {
|
|
22
|
+
return [
|
|
23
|
+
{
|
|
24
|
+
tag: 'div[data-dyn="toc"]',
|
|
25
|
+
getAttrs: (el) => ({ title: (el as HTMLElement).getAttribute('data-title') ?? '' }),
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
renderHTML({ node }) {
|
|
31
|
+
return ['div', { 'data-dyn': 'toc', 'data-title': node.attrs.title }];
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
addNodeView() {
|
|
35
|
+
return ReactNodeViewRenderer(TocView);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
interface OutlineItem {
|
|
40
|
+
level: number;
|
|
41
|
+
text: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function TocView({ node, editor, updateAttributes, deleteNode, selected }: NodeViewProps) {
|
|
45
|
+
const [outline, setOutline] = useState<OutlineItem[]>([]);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const collect = () => {
|
|
49
|
+
const items: OutlineItem[] = [];
|
|
50
|
+
editor.state.doc.descendants((n) => {
|
|
51
|
+
if (n.type.name === 'heading' && n.textContent.trim()) {
|
|
52
|
+
items.push({ level: n.attrs.level as number, text: n.textContent });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
setOutline(items);
|
|
56
|
+
};
|
|
57
|
+
collect();
|
|
58
|
+
editor.on('update', collect);
|
|
59
|
+
return () => {
|
|
60
|
+
editor.off('update', collect);
|
|
61
|
+
};
|
|
62
|
+
}, [editor]);
|
|
63
|
+
|
|
64
|
+
const min = outline.length ? Math.min(...outline.map((i) => i.level)) : 1;
|
|
65
|
+
return (
|
|
66
|
+
<NodeViewWrapper className={`pe-card pe-toc ${selected ? 'is-selected' : ''}`} data-drag-handle>
|
|
67
|
+
<div className="pe-card-tools" contentEditable={false}>
|
|
68
|
+
<span className="pe-provider">Contents · dynamic</span>
|
|
69
|
+
<button type="button" title="Remove" onClick={() => deleteNode()}>✕</button>
|
|
70
|
+
</div>
|
|
71
|
+
<div className="pc-toc" contentEditable={false}>
|
|
72
|
+
<input
|
|
73
|
+
className="pe-toc-title"
|
|
74
|
+
type="text"
|
|
75
|
+
placeholder="Title (empty hides it)"
|
|
76
|
+
value={node.attrs.title}
|
|
77
|
+
onChange={(e) => updateAttributes({ title: e.target.value })}
|
|
78
|
+
/>
|
|
79
|
+
{outline.length ? (
|
|
80
|
+
outline.map((i, idx) => (
|
|
81
|
+
<span key={idx} className="pc-toc-link" data-depth={i.level - min}>{i.text}</span>
|
|
82
|
+
))
|
|
83
|
+
) : (
|
|
84
|
+
<span className="pe-toc-empty">No headings yet — headings you write will be listed here, linked to their section.</span>
|
|
85
|
+
)}
|
|
86
|
+
</div>
|
|
87
|
+
</NodeViewWrapper>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Node } from '@tiptap/core';
|
|
2
|
+
import { NodeViewWrapper, NodeViewContent, ReactNodeViewRenderer } from '@tiptap/react';
|
|
3
|
+
import type { NodeViewProps } from '@tiptap/react';
|
|
4
|
+
import { useState } from 'react';
|
|
5
|
+
|
|
6
|
+
// Toggle card: a collapsible section — summary line + rich block content.
|
|
7
|
+
// Publishes as native <details>/<summary> so the public site needs no JS;
|
|
8
|
+
// the editor keeps its own open/closed state (not serialized) so collapsing
|
|
9
|
+
// while writing never changes what readers see.
|
|
10
|
+
|
|
11
|
+
export const ToggleCard = Node.create({
|
|
12
|
+
name: 'toggleCard',
|
|
13
|
+
group: 'block',
|
|
14
|
+
content: 'block+',
|
|
15
|
+
defining: true,
|
|
16
|
+
|
|
17
|
+
addAttributes() {
|
|
18
|
+
return { summary: { default: '', renderHTML: () => ({}) } };
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
parseHTML() {
|
|
22
|
+
return [
|
|
23
|
+
{
|
|
24
|
+
tag: 'details.pc-toggle',
|
|
25
|
+
getAttrs: (el) => ({ summary: (el as HTMLElement).querySelector(':scope > summary')?.textContent ?? '' }),
|
|
26
|
+
contentElement: '.pc-toggle-body',
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
renderHTML({ node }) {
|
|
32
|
+
return [
|
|
33
|
+
'details',
|
|
34
|
+
{ class: 'pc-toggle' },
|
|
35
|
+
['summary', {}, node.attrs.summary],
|
|
36
|
+
['div', { class: 'pc-toggle-body' }, 0],
|
|
37
|
+
];
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
addNodeView() {
|
|
41
|
+
return ReactNodeViewRenderer(ToggleView);
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function ToggleView({ node, updateAttributes, selected }: NodeViewProps) {
|
|
46
|
+
const [open, setOpen] = useState(true);
|
|
47
|
+
return (
|
|
48
|
+
<NodeViewWrapper className={`pc-toggle pe-toggle ${selected ? 'is-selected' : ''} ${open ? 'is-open' : ''}`}>
|
|
49
|
+
<div className="pe-toggle-head" contentEditable={false}>
|
|
50
|
+
<button
|
|
51
|
+
type="button"
|
|
52
|
+
className="pe-toggle-chevron"
|
|
53
|
+
title={open ? 'Collapse' : 'Expand'}
|
|
54
|
+
onClick={() => setOpen((v) => !v)}
|
|
55
|
+
>
|
|
56
|
+
▸
|
|
57
|
+
</button>
|
|
58
|
+
<input
|
|
59
|
+
className="pe-toggle-summary"
|
|
60
|
+
type="text"
|
|
61
|
+
placeholder="Toggle heading…"
|
|
62
|
+
value={node.attrs.summary}
|
|
63
|
+
onChange={(e) => updateAttributes({ summary: e.target.value })}
|
|
64
|
+
/>
|
|
65
|
+
</div>
|
|
66
|
+
{/* stays mounted when collapsed — ProseMirror must keep its content DOM */}
|
|
67
|
+
<NodeViewContent className="pc-toggle-body" style={open ? undefined : { display: 'none' }} />
|
|
68
|
+
</NodeViewWrapper>
|
|
69
|
+
);
|
|
70
|
+
}
|
package/src/layouts/Site.astro
CHANGED
|
@@ -8,6 +8,8 @@ import { CONTENT } from '../lib/content';
|
|
|
8
8
|
import { I18N, localeFromPath, localeHomeUrl } from '../lib/i18n';
|
|
9
9
|
import { SEARCH } from '../lib/search-config';
|
|
10
10
|
import { searchStrings } from '../lib/search-i18n';
|
|
11
|
+
import { effectiveSettings } from '../lib/designPreview';
|
|
12
|
+
import { analyticsHead, analyticsBody } from '../lib/analytics';
|
|
11
13
|
import '../styles/site.css';
|
|
12
14
|
import '../styles/cards.css';
|
|
13
15
|
|
|
@@ -35,7 +37,9 @@ const {
|
|
|
35
37
|
} = Astro.props;
|
|
36
38
|
|
|
37
39
|
const repo = await getRepo(Astro.locals);
|
|
38
|
-
const [
|
|
40
|
+
const [rawSettings, pages, navItems] = await Promise.all([repo.getSettings(), repo.listPages(), repo.listNavItems()]);
|
|
41
|
+
// ?designPreview=1 (admin-only) renders the staged Design-screen changes
|
|
42
|
+
const settings = effectiveSettings(rawSettings, Astro.url, Astro.cookies);
|
|
39
43
|
const design = resolveDesign(settings);
|
|
40
44
|
const navPages = pages.filter((p) => p.status === 'published' && !p.isHome);
|
|
41
45
|
const headerNav = navItems.filter((n) => n.area === 'header');
|
|
@@ -72,6 +76,9 @@ const searchAction = reqLocale && reqLocale !== I18N.defaultLocale ? `/${reqLoca
|
|
|
72
76
|
// hreflang alternates connect the per-language home pages; other routes have
|
|
73
77
|
// no language twins yet, so alternates are emitted only on home paths
|
|
74
78
|
const isHomePath = I18N.enabled && I18N.locales.some((l) => current === localeHomeUrl(l));
|
|
79
|
+
// analytics tags render wherever headCode does (bare pages and previews too)
|
|
80
|
+
const analyticsHtml = analyticsHead(settings);
|
|
81
|
+
const analyticsBodyHtml = analyticsBody(settings);
|
|
75
82
|
const shareImage = image || settings.siteCover || '';
|
|
76
83
|
const shareImageAbs = shareImage ? absUrl(shareImage, origin) : '';
|
|
77
84
|
const faviconType =
|
|
@@ -129,6 +136,7 @@ const homeJsonLd =
|
|
|
129
136
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
130
137
|
{design.fontsHref && <link href={design.fontsHref} rel="stylesheet" />}
|
|
131
138
|
<Fragment set:html={`<style>${design.css}</style>`} />
|
|
139
|
+
{analyticsHtml && <Fragment set:html={analyticsHtml} />}
|
|
132
140
|
{settings.headCode && <Fragment set:html={settings.headCode} />}
|
|
133
141
|
</head>
|
|
134
142
|
<body>
|
|
@@ -325,6 +333,7 @@ const homeJsonLd =
|
|
|
325
333
|
<div class="footer-text" set:html={footerHtml} />
|
|
326
334
|
</footer>
|
|
327
335
|
)}
|
|
336
|
+
{analyticsBodyHtml && <Fragment set:html={analyticsBodyHtml} />}
|
|
328
337
|
{settings.footerCode && <Fragment set:html={settings.footerCode} />}
|
|
329
338
|
</body>
|
|
330
339
|
</html>
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Analytics providers (Umami, Meta Pixel, TikTok Pixel): builds the script
|
|
2
|
+
// markup Site.astro renders into the public <head> via set:html. Because the
|
|
3
|
+
// output bypasses Astro's escaping, nothing is emitted unless the stored
|
|
4
|
+
// ID/URL passes its validator — a bad value renders nothing, never broken or
|
|
5
|
+
// injected markup. The admin form rejects invalid values with the same rules.
|
|
6
|
+
import type { Settings } from './schema';
|
|
7
|
+
|
|
8
|
+
export const UMAMI_ID_RE = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
|
|
9
|
+
export const META_PIXEL_ID_RE = /^\d{5,20}$/;
|
|
10
|
+
export const TIKTOK_PIXEL_ID_RE = /^[A-Z0-9_-]{8,40}$/i;
|
|
11
|
+
|
|
12
|
+
// https only (self-hosted instances included); returns the normalized href,
|
|
13
|
+
// whose serialization percent-encodes quotes/angle brackets, so the value is
|
|
14
|
+
// attribute-safe. Belt and braces: reject anything still risky post-encode.
|
|
15
|
+
export function normalizeScriptUrl(url: string): string {
|
|
16
|
+
try {
|
|
17
|
+
const href = new URL(url).href;
|
|
18
|
+
if (new URL(url).protocol !== 'https:' || /["'<>\s]/.test(href)) return '';
|
|
19
|
+
return href;
|
|
20
|
+
} catch {
|
|
21
|
+
return '';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type Analytics = Settings['analytics'];
|
|
26
|
+
|
|
27
|
+
function umamiTag(a: Analytics['umami']): string {
|
|
28
|
+
const src = normalizeScriptUrl(a.scriptUrl);
|
|
29
|
+
if (!a.enabled || !UMAMI_ID_RE.test(a.websiteId) || !src) return '';
|
|
30
|
+
return `<script defer src="${src}" data-website-id="${a.websiteId.toLowerCase()}"></script>`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// The replay recorder lives next to the tracker on the same instance:
|
|
34
|
+
// swap the URL's last path segment for recorder.js and re-validate.
|
|
35
|
+
export function umamiRecorderUrl(scriptUrl: string): string {
|
|
36
|
+
const src = normalizeScriptUrl(scriptUrl);
|
|
37
|
+
if (!src) return '';
|
|
38
|
+
const url = new URL(src);
|
|
39
|
+
url.pathname = url.pathname.replace(/[^/]*$/, 'recorder.js');
|
|
40
|
+
return normalizeScriptUrl(url.href);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Session replay (Umami v3.1+): a second tag mirroring the dashboard-generated
|
|
44
|
+
// snippet. Attributes are re-clamped here because, like the rest of this file,
|
|
45
|
+
// the output bypasses Astro escaping — schema-invalid values render defaults,
|
|
46
|
+
// never broken markup.
|
|
47
|
+
function umamiReplayTag(a: Analytics['umami']): string {
|
|
48
|
+
const src = umamiRecorderUrl(a.scriptUrl);
|
|
49
|
+
if (!a.enabled || !a.replay.enabled || !UMAMI_ID_RE.test(a.websiteId) || !src) return '';
|
|
50
|
+
const rate =
|
|
51
|
+
Number.isFinite(a.replay.sampleRate) && a.replay.sampleRate >= 0.01 && a.replay.sampleRate <= 1
|
|
52
|
+
? a.replay.sampleRate
|
|
53
|
+
: 0.15;
|
|
54
|
+
const mask = a.replay.maskLevel === 'strict' ? 'strict' : 'moderate';
|
|
55
|
+
const duration =
|
|
56
|
+
Number.isInteger(a.replay.maxDuration) && a.replay.maxDuration > 0 ? a.replay.maxDuration : 300000;
|
|
57
|
+
return `<script defer src="${src}" data-website-id="${a.websiteId.toLowerCase()}" data-sample-rate="${rate}" data-mask-level="${mask}" data-max-duration="${duration}"></script>`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Meta's standard base code: fbq loader + PageView, with the no-JS pixel
|
|
61
|
+
// image fallback. The ID is digits-only by validation, so safe to inline.
|
|
62
|
+
function metaPixelTag(a: Analytics['metaPixel']): string {
|
|
63
|
+
if (!a.enabled || !META_PIXEL_ID_RE.test(a.pixelId)) return '';
|
|
64
|
+
return `<script>
|
|
65
|
+
!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
|
66
|
+
n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
|
|
67
|
+
n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
|
|
68
|
+
t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,
|
|
69
|
+
document,'script','https://connect.facebook.net/en_US/fbevents.js');
|
|
70
|
+
fbq('init', '${a.pixelId}');
|
|
71
|
+
fbq('track', 'PageView');
|
|
72
|
+
</script>
|
|
73
|
+
<noscript><img height="1" width="1" style="display:none" src="https://www.facebook.com/tr?id=${a.pixelId}&ev=PageView&noscript=1"/></noscript>`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// TikTok's published base code (experimental — untested live): ttq loader +
|
|
77
|
+
// page(). ID is restricted to [A-Z0-9_-] by validation.
|
|
78
|
+
function tiktokPixelTag(a: Analytics['tiktokPixel']): string {
|
|
79
|
+
if (!a.enabled || !TIKTOK_PIXEL_ID_RE.test(a.pixelId)) return '';
|
|
80
|
+
return `<script>
|
|
81
|
+
!function (w, d, t) {
|
|
82
|
+
w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie"],ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<e.length;n++)ttq.setAndDefer(e[n],ttq.methods[n]);return e},ttq.load=function(e,n){var i="https://analytics.tiktok.com/i18n/pixel/events.js";ttq._i=ttq._i||{},ttq._i[e]=[],ttq._i[e]._u=i,ttq._t=ttq._t||{},ttq._t[e]=+new Date,ttq._o=ttq._o||{},ttq._o[e]=n||{};var o=document.createElement("script");o.type="text/javascript",o.async=!0,o.src=i+"?sdkid="+e+"&lib="+t;var a=document.getElementsByTagName("script")[0];a.parentNode.insertBefore(o,a)};
|
|
83
|
+
ttq.load('${a.pixelId}');
|
|
84
|
+
ttq.page();
|
|
85
|
+
}(window, document, 'ttq');
|
|
86
|
+
</script>`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function analyticsHead(settings: Settings): string {
|
|
90
|
+
const a = settings.analytics;
|
|
91
|
+
return [umamiTag(a.umami), umamiReplayTag(a.umami), metaPixelTag(a.metaPixel), tiktokPixelTag(a.tiktokPixel)]
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.join('\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Event names are author-typed; collapse whitespace so "Sign up " and
|
|
97
|
+
// "Sign up" count as one event in the dashboards.
|
|
98
|
+
export function cleanEventName(name: string): string {
|
|
99
|
+
return name.replace(/\s+/g, ' ').trim();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Click-event bridge: elements carrying data-umami-event (editor cards, the
|
|
103
|
+
// links page) are counted by Umami natively — this dispatcher only exists to
|
|
104
|
+
// forward the same clicks to the ad pixels, so it is emitted solely when one
|
|
105
|
+
// of them is actually enabled. Umami-only sites ship zero extra JS.
|
|
106
|
+
export function analyticsBody(settings: Settings): string {
|
|
107
|
+
const a = settings.analytics;
|
|
108
|
+
const meta = a.metaPixel.enabled && META_PIXEL_ID_RE.test(a.metaPixel.pixelId);
|
|
109
|
+
const tiktok = a.tiktokPixel.enabled && TIKTOK_PIXEL_ID_RE.test(a.tiktokPixel.pixelId);
|
|
110
|
+
if (!meta && !tiktok) return '';
|
|
111
|
+
return `<script>
|
|
112
|
+
document.addEventListener('click', function (e) {
|
|
113
|
+
var el = e.target && e.target.closest && e.target.closest('[data-umami-event]');
|
|
114
|
+
if (!el) return;
|
|
115
|
+
var name = el.getAttribute('data-umami-event');
|
|
116
|
+
if (!name) return;
|
|
117
|
+
if (window.fbq) window.fbq('trackCustom', name);
|
|
118
|
+
if (window.ttq) window.ttq.track(name);
|
|
119
|
+
});
|
|
120
|
+
</script>`;
|
|
121
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { AstroCookies } from 'astro';
|
|
2
|
+
import type { Settings } from './schema';
|
|
3
|
+
import { mergeDesignDraft } from './drafts';
|
|
4
|
+
import { verifySessionToken, SESSION_COOKIE } from './auth';
|
|
5
|
+
|
|
6
|
+
// The Design screen's preview iframe appends ?designPreview=1 so admins see
|
|
7
|
+
// the STAGED design (settings.draftDesign) while every normal request keeps
|
|
8
|
+
// rendering the live one. Session-gated: the flag is inert for visitors.
|
|
9
|
+
export function effectiveSettings(settings: Settings, url: URL, cookies: AstroCookies): Settings {
|
|
10
|
+
if (!settings.draftDesign) return settings;
|
|
11
|
+
if (!url.searchParams.has('designPreview')) return settings;
|
|
12
|
+
if (!verifySessionToken(cookies.get(SESSION_COOKIE)?.value)) return settings;
|
|
13
|
+
return mergeDesignDraft(settings);
|
|
14
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Page, Post, Settings } from './schema';
|
|
2
|
+
|
|
3
|
+
// Pending-edit ("draft") snapshots for live documents. A published or
|
|
4
|
+
// scheduled post/page never receives content edits directly: they accumulate
|
|
5
|
+
// in the row's `draft` JSON column and only reach the live columns when the
|
|
6
|
+
// user hits Update (repo.applyPostDraft / applyPageDraft).
|
|
7
|
+
//
|
|
8
|
+
// The snapshot stores the same camelCase field names the save actions accept,
|
|
9
|
+
// plus `savedAt`. Status transitions, schedule times and the publish sweep
|
|
10
|
+
// never live in a snapshot — those stay deliberate, immediate actions.
|
|
11
|
+
|
|
12
|
+
export const POST_DRAFT_KEYS = [
|
|
13
|
+
'title', 'slug', 'collection', 'body', 'bodyJson', 'bodyHtml', 'excerpt',
|
|
14
|
+
'tags', 'cover', 'coverAlt', 'seoTitle', 'seoDesc', 'featured',
|
|
15
|
+
'authorIds', 'template', 'publishedAt',
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export const PAGE_DRAFT_KEYS = [
|
|
19
|
+
'title', 'slug', 'locale', 'isHome', 'seoTitle', 'seoDesc', 'template',
|
|
20
|
+
'bodyJson', 'bodyHtml', 'blocks',
|
|
21
|
+
] as const;
|
|
22
|
+
|
|
23
|
+
type PostDraftKey = (typeof POST_DRAFT_KEYS)[number];
|
|
24
|
+
type PageDraftKey = (typeof PAGE_DRAFT_KEYS)[number];
|
|
25
|
+
|
|
26
|
+
function pick(draft: Record<string, unknown>, keys: readonly string[]) {
|
|
27
|
+
const out: Record<string, unknown> = {};
|
|
28
|
+
for (const k of keys) if (draft[k] !== undefined) out[k] = draft[k];
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Only known editable fields survive the pick — a hand-edited or legacy
|
|
33
|
+
// snapshot can never smuggle status/id/timestamps into an UPDATE.
|
|
34
|
+
export function pickPostDraft(draft: Record<string, unknown>): Partial<Pick<Post, PostDraftKey>> {
|
|
35
|
+
return pick(draft, POST_DRAFT_KEYS) as Partial<Pick<Post, PostDraftKey>>;
|
|
36
|
+
}
|
|
37
|
+
export function pickPageDraft(draft: Record<string, unknown>): Partial<Pick<Page, PageDraftKey>> {
|
|
38
|
+
return pick(draft, PAGE_DRAFT_KEYS) as Partial<Pick<Page, PageDraftKey>>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The document as it will look after Update — what the editor hydrates from
|
|
42
|
+
// and what /preview renders. Live rows pass through untouched.
|
|
43
|
+
export function mergePostDraft(post: Post): Post {
|
|
44
|
+
if (!post.draft) return post;
|
|
45
|
+
const staged = pickPostDraft(post.draft);
|
|
46
|
+
const merged = { ...post, ...staged };
|
|
47
|
+
if (staged.authorIds) merged.authorId = staged.authorIds[0] ?? null;
|
|
48
|
+
return merged;
|
|
49
|
+
}
|
|
50
|
+
export function mergePageDraft(page: Page): Page {
|
|
51
|
+
if (!page.draft) return page;
|
|
52
|
+
return { ...page, ...pickPageDraft(page.draft) };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function draftSavedAt(doc: Pick<Post | Page, 'draft'>): number | null {
|
|
56
|
+
const ts = doc.draft?.savedAt;
|
|
57
|
+
return typeof ts === 'number' ? ts : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Design-screen staging: the same shape, stored in settings.draftDesign and
|
|
61
|
+
// applied by the "Publish design" button.
|
|
62
|
+
export const DESIGN_DRAFT_KEYS = [
|
|
63
|
+
'activeTheme', 'designOverrides', 'siteLogo', 'siteFavicon', 'siteCover',
|
|
64
|
+
] as const;
|
|
65
|
+
|
|
66
|
+
type DesignDraftKey = (typeof DESIGN_DRAFT_KEYS)[number];
|
|
67
|
+
|
|
68
|
+
export function pickDesignDraft(draft: Record<string, unknown>): Partial<Pick<Settings, DesignDraftKey>> {
|
|
69
|
+
return pick(draft, DESIGN_DRAFT_KEYS) as Partial<Pick<Settings, DesignDraftKey>>;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Settings as they will look after Publish design — what ?designPreview=1
|
|
73
|
+
// renders for admins while visitors keep the live design.
|
|
74
|
+
export function mergeDesignDraft(settings: Settings): Settings {
|
|
75
|
+
if (!settings.draftDesign) return settings;
|
|
76
|
+
return { ...settings, ...pickDesignDraft(settings.draftDesign) };
|
|
77
|
+
}
|