@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/cli.mjs +128 -16
  3. package/integration.mjs +96 -3
  4. package/package.json +14 -12
  5. package/src/actions/index.ts +184 -6
  6. package/src/components/DesignManager.tsx +53 -17
  7. package/src/components/LinksManager.tsx +110 -48
  8. package/src/components/NavManager.tsx +83 -37
  9. package/src/components/editor/PostEditor.tsx +228 -55
  10. package/src/components/editor/extensions/cards.tsx +17 -1
  11. package/src/components/editor/extensions/codeBlock.tsx +1 -1
  12. package/src/components/editor/extensions/infoCards.tsx +16 -1
  13. package/src/components/editor/extensions/mediaCards.tsx +36 -2
  14. package/src/components/editor/extensions/slash.tsx +70 -8
  15. package/src/components/editor/extensions/toc.tsx +89 -0
  16. package/src/components/editor/extensions/toggle.tsx +70 -0
  17. package/src/layouts/Site.astro +122 -1
  18. package/src/lib/analytics.ts +121 -0
  19. package/src/lib/designPreview.ts +14 -0
  20. package/src/lib/drafts.ts +77 -0
  21. package/src/lib/dynamicBlocks.ts +43 -3
  22. package/src/lib/plaintext.ts +22 -0
  23. package/src/lib/repo/migrations.ts +52 -0
  24. package/src/lib/repo/sql.ts +289 -30
  25. package/src/lib/repo/types.ts +40 -4
  26. package/src/lib/schema.ts +53 -0
  27. package/src/lib/search-config.ts +10 -0
  28. package/src/lib/search-i18n.ts +45 -0
  29. package/src/pages/[...slug].astro +3 -1
  30. package/src/pages/admin/c/[collection]/[id].astro +10 -3
  31. package/src/pages/admin/c/[collection]/index.astro +1 -0
  32. package/src/pages/admin/design.astro +6 -1
  33. package/src/pages/admin/index.astro +27 -10
  34. package/src/pages/admin/pages/[id].astro +10 -2
  35. package/src/pages/admin/pages/index.astro +1 -0
  36. package/src/pages/admin/settings.astro +148 -0
  37. package/src/pages/api/search.ts +44 -0
  38. package/src/pages/blog/[slug].astro +4 -1
  39. package/src/pages/index.astro +4 -1
  40. package/src/pages/links.astro +10 -4
  41. package/src/pages/preview/[type]/[id].astro +9 -4
  42. package/src/pages/search.astro +83 -18
  43. package/src/styles/admin.css +4 -0
  44. package/src/styles/cards.css +23 -0
  45. package/src/styles/editor.css +53 -1
  46. package/src/styles/site.css +88 -0
  47. 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
- { class: 'pc-filecard', href: a.href, download: a.filename, 'data-size': String(a.size) },
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 out: unknown[] = ['a', { class: 'pc-bookmark', href: a.url, target: '_blank', rel: 'noopener' }, content];
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) % items.length); return true; }
140
- if (e.key === 'ArrowUp') { setSelected((s) => (s - 1 + items.length) % items.length); return true; }
141
- if (e.key === 'Enter') { if (items[selected]) command(items[selected]); return true; }
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 (!items.length) return <div className="pe-slash"><div className="pe-slash-empty">No matches</div></div>;
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
- {items.map((item, i) => {
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
- return ITEMS.filter(
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
+ }
@@ -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 [settings, pages, navItems] = await Promise.all([repo.getSettings(), repo.listPages(), repo.listNavItems()]);
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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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>