@acms-subir/core 0.0.2 → 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 +75 -0
- package/cli.mjs +128 -16
- package/integration.mjs +96 -3
- package/package.json +14 -12
- 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 +122 -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/plaintext.ts +22 -0
- package/src/lib/repo/migrations.ts +52 -0
- package/src/lib/repo/sql.ts +289 -30
- package/src/lib/repo/types.ts +40 -4
- package/src/lib/schema.ts +53 -0
- package/src/lib/search-config.ts +10 -0
- package/src/lib/search-i18n.ts +45 -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 +27 -10
- 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/api/search.ts +44 -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/pages/search.astro +83 -18
- 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 +88 -0
- package/src/lib/repo/search.ts +0 -14
|
@@ -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
|
@@ -6,6 +6,10 @@ import { siteOrigin, absUrl, canonicalPath } from '../lib/seo';
|
|
|
6
6
|
import { siteLang } from '../lib/dates';
|
|
7
7
|
import { CONTENT } from '../lib/content';
|
|
8
8
|
import { I18N, localeFromPath, localeHomeUrl } from '../lib/i18n';
|
|
9
|
+
import { SEARCH } from '../lib/search-config';
|
|
10
|
+
import { searchStrings } from '../lib/search-i18n';
|
|
11
|
+
import { effectiveSettings } from '../lib/designPreview';
|
|
12
|
+
import { analyticsHead, analyticsBody } from '../lib/analytics';
|
|
9
13
|
import '../styles/site.css';
|
|
10
14
|
import '../styles/cards.css';
|
|
11
15
|
|
|
@@ -33,7 +37,9 @@ const {
|
|
|
33
37
|
} = Astro.props;
|
|
34
38
|
|
|
35
39
|
const repo = await getRepo(Astro.locals);
|
|
36
|
-
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);
|
|
37
43
|
const design = resolveDesign(settings);
|
|
38
44
|
const navPages = pages.filter((p) => p.status === 'published' && !p.isHome);
|
|
39
45
|
const headerNav = navItems.filter((n) => n.area === 'header');
|
|
@@ -62,9 +68,17 @@ const metaDescription = description || settings.tagline || undefined;
|
|
|
62
68
|
// locale setting (a date-formatting concern) stays the fallback
|
|
63
69
|
const reqLocale = I18N.enabled ? localeFromPath(current) : '';
|
|
64
70
|
const htmlLang = reqLocale || siteLang(settings);
|
|
71
|
+
// search modal chrome: strings follow the document language, the form's
|
|
72
|
+
// no-JS fallback goes to the (locale-prefixed) /search page
|
|
73
|
+
const showSearch = !bare && SEARCH.enabled;
|
|
74
|
+
const searchT = searchStrings(htmlLang.slice(0, 2));
|
|
75
|
+
const searchAction = reqLocale && reqLocale !== I18N.defaultLocale ? `/${reqLocale}/search` : '/search';
|
|
65
76
|
// hreflang alternates connect the per-language home pages; other routes have
|
|
66
77
|
// no language twins yet, so alternates are emitted only on home paths
|
|
67
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);
|
|
68
82
|
const shareImage = image || settings.siteCover || '';
|
|
69
83
|
const shareImageAbs = shareImage ? absUrl(shareImage, origin) : '';
|
|
70
84
|
const faviconType =
|
|
@@ -122,6 +136,7 @@ const homeJsonLd =
|
|
|
122
136
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
123
137
|
{design.fontsHref && <link href={design.fontsHref} rel="stylesheet" />}
|
|
124
138
|
<Fragment set:html={`<style>${design.css}</style>`} />
|
|
139
|
+
{analyticsHtml && <Fragment set:html={analyticsHtml} />}
|
|
125
140
|
{settings.headCode && <Fragment set:html={settings.headCode} />}
|
|
126
141
|
</head>
|
|
127
142
|
<body>
|
|
@@ -188,9 +203,114 @@ const homeJsonLd =
|
|
|
188
203
|
))}
|
|
189
204
|
</span>
|
|
190
205
|
)}
|
|
206
|
+
{showSearch && (
|
|
207
|
+
<a class="pc-search-trigger" href={searchAction} data-search-open aria-label={searchT.label}>
|
|
208
|
+
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.6-3.6" /></svg>
|
|
209
|
+
</a>
|
|
210
|
+
)}
|
|
191
211
|
</nav>
|
|
192
212
|
</header>
|
|
193
213
|
)}
|
|
214
|
+
{showSearch && (
|
|
215
|
+
<dialog class="pc-search" aria-label={searchT.label}>
|
|
216
|
+
{/* a real GET form: with JS disabled (or the fetch failing) the
|
|
217
|
+
query submits to the /search page instead of the live modal */}
|
|
218
|
+
<form class="pc-search-box" action={searchAction} method="get" role="search">
|
|
219
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="m20 20-3.6-3.6" /></svg>
|
|
220
|
+
<input class="pc-search-input" type="search" name="q" placeholder={searchT.placeholder} aria-label={searchT.label} autocomplete="off" />
|
|
221
|
+
<button type="button" class="pc-search-kbd" data-search-close>esc</button>
|
|
222
|
+
</form>
|
|
223
|
+
<div class="pc-search-results" hidden></div>
|
|
224
|
+
</dialog>
|
|
225
|
+
)}
|
|
226
|
+
{showSearch && (
|
|
227
|
+
<script is:inline define:vars={{ searchLocale: reqLocale, t: searchT }}>
|
|
228
|
+
(() => {
|
|
229
|
+
const dlg = document.querySelector('dialog.pc-search');
|
|
230
|
+
if (!dlg || !dlg.showModal) return;
|
|
231
|
+
const input = dlg.querySelector('.pc-search-input');
|
|
232
|
+
const box = dlg.querySelector('.pc-search-results');
|
|
233
|
+
let ctl = null;
|
|
234
|
+
let timer = 0;
|
|
235
|
+
let sel = -1;
|
|
236
|
+
|
|
237
|
+
const open = () => { if (!dlg.open) dlg.showModal(); input.focus(); input.select(); };
|
|
238
|
+
document.querySelectorAll('[data-search-open]').forEach((el) =>
|
|
239
|
+
el.addEventListener('click', (e) => { e.preventDefault(); open(); })
|
|
240
|
+
);
|
|
241
|
+
dlg.querySelectorAll('[data-search-close]').forEach((el) => el.addEventListener('click', () => dlg.close()));
|
|
242
|
+
// clicks on ::backdrop (and the dialog's transparent frame) land on
|
|
243
|
+
// the dialog element itself — descendants stop at the panel
|
|
244
|
+
dlg.addEventListener('click', (e) => { if (e.target === dlg) dlg.close(); });
|
|
245
|
+
document.addEventListener('keydown', (e) => {
|
|
246
|
+
if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) {
|
|
247
|
+
e.preventDefault();
|
|
248
|
+
dlg.open ? dlg.close() : open();
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
const a = document.activeElement;
|
|
252
|
+
const typing = a && (/^(INPUT|TEXTAREA|SELECT)$/.test(a.tagName) || a.isContentEditable);
|
|
253
|
+
if (e.key === '/' && !typing && !dlg.open) { e.preventDefault(); open(); }
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
257
|
+
// snippet arrives pre-escaped from /api/search (only <mark> inside)
|
|
258
|
+
const hit = (url, title, meta, snippet) =>
|
|
259
|
+
`<a class="pc-search-hit" href="${esc(url)}"><span class="pc-search-t">${esc(title)}` +
|
|
260
|
+
(snippet ? `<span class="pc-search-snip">${snippet}</span>` : '') +
|
|
261
|
+
`</span>${meta ? `<span class="pc-search-m">${esc(meta)}</span>` : ''}</a>`;
|
|
262
|
+
|
|
263
|
+
const render = (d) => {
|
|
264
|
+
const groups = [];
|
|
265
|
+
if (d.posts.length) groups.push([t.posts, d.posts.map((p) => hit(p.url, p.title, p.date, p.snippet))]);
|
|
266
|
+
if (d.pages.length) groups.push([t.pages, d.pages.map((p) => hit(p.url, p.title, (p.locale || '').toUpperCase(), p.snippet))]);
|
|
267
|
+
if (d.tags.length) groups.push([t.tags, d.tags.map((g) => hit(g.url, g.name, `${g.count} ${t.posts.toLowerCase()}`))]);
|
|
268
|
+
if (d.authors.length) groups.push([t.authors, d.authors.map((u) => hit(u.url, u.name, ''))]);
|
|
269
|
+
box.innerHTML = groups.length
|
|
270
|
+
? groups.map(([label, items]) => `<div class="pc-search-group">${esc(label)}</div>${items.join('')}`).join('')
|
|
271
|
+
: `<div class="pc-search-empty">${esc(t.empty)} “${esc(input.value.trim())}”</div>`;
|
|
272
|
+
box.hidden = false;
|
|
273
|
+
sel = -1;
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
input.addEventListener('input', () => {
|
|
277
|
+
clearTimeout(timer);
|
|
278
|
+
const q = input.value.trim();
|
|
279
|
+
if (!q) {
|
|
280
|
+
if (ctl) ctl.abort();
|
|
281
|
+
box.hidden = true;
|
|
282
|
+
box.innerHTML = '';
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
timer = setTimeout(async () => {
|
|
286
|
+
if (ctl) ctl.abort();
|
|
287
|
+
ctl = new AbortController();
|
|
288
|
+
try {
|
|
289
|
+
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}&locale=${encodeURIComponent(searchLocale)}`, { signal: ctl.signal });
|
|
290
|
+
if (res.ok) render(await res.json());
|
|
291
|
+
} catch { /* aborted or offline — the form still submits to /search */ }
|
|
292
|
+
}, 150);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
const move = (dir) => {
|
|
296
|
+
const hits = [...box.querySelectorAll('.pc-search-hit')];
|
|
297
|
+
if (!hits.length) return;
|
|
298
|
+
sel = (sel + dir + hits.length) % hits.length;
|
|
299
|
+
hits.forEach((h, i) => h.classList.toggle('sel', i === sel));
|
|
300
|
+
hits[sel].scrollIntoView({ block: 'nearest' });
|
|
301
|
+
};
|
|
302
|
+
input.addEventListener('keydown', (e) => {
|
|
303
|
+
if (e.key === 'ArrowDown') { e.preventDefault(); move(1); }
|
|
304
|
+
else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); }
|
|
305
|
+
else if (e.key === 'Enter') {
|
|
306
|
+
const cur = box.querySelector('.pc-search-hit.sel');
|
|
307
|
+
if (cur) { e.preventDefault(); cur.click(); }
|
|
308
|
+
// no selection: the form submits to the /search page
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
})();
|
|
312
|
+
</script>
|
|
313
|
+
)}
|
|
194
314
|
<main class="site-main">
|
|
195
315
|
<slot />
|
|
196
316
|
</main>
|
|
@@ -213,6 +333,7 @@ const homeJsonLd =
|
|
|
213
333
|
<div class="footer-text" set:html={footerHtml} />
|
|
214
334
|
</footer>
|
|
215
335
|
)}
|
|
336
|
+
{analyticsBodyHtml && <Fragment set:html={analyticsBodyHtml} />}
|
|
216
337
|
{settings.footerCode && <Fragment set:html={settings.footerCode} />}
|
|
217
338
|
</body>
|
|
218
339
|
</html>
|