@acms-subir/core 0.1.0 → 0.3.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 (237) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/cli/common.mjs +35 -0
  3. package/cli/legacy.mjs +317 -0
  4. package/cli/main.mjs +66 -0
  5. package/cli/palette.mjs +4 -0
  6. package/cli/preview.mjs +50 -0
  7. package/cli/remote.mjs +55 -0
  8. package/cli/themes.mjs +127 -0
  9. package/cli.mjs +2 -315
  10. package/deploy/Caddyfile +8 -0
  11. package/deploy/Dockerfile +34 -0
  12. package/deploy/compose.https.yml +21 -0
  13. package/deploy/compose.yml +46 -0
  14. package/docs/theme-authoring.md +103 -0
  15. package/email-runner.mjs +32 -0
  16. package/integration.mjs +193 -2
  17. package/ops-check.mjs +27 -0
  18. package/package.json +35 -12
  19. package/src/actions/index.ts +744 -38
  20. package/src/components/AiCleaner.tsx +291 -0
  21. package/src/components/AiSettings.tsx +575 -0
  22. package/src/components/CommandPalette.tsx +200 -0
  23. package/src/components/ContentList.astro +246 -0
  24. package/src/components/DesignAccess.tsx +182 -0
  25. package/src/components/DesignManager.tsx +455 -47
  26. package/src/components/EmailSettings.tsx +521 -0
  27. package/src/components/EventPage.astro +178 -0
  28. package/src/components/LegalPage.astro +27 -0
  29. package/src/components/LinksManager.tsx +216 -97
  30. package/src/components/NavManager.tsx +83 -38
  31. package/src/components/ThemeScript.astro +50 -0
  32. package/src/components/ThemeSections.astro +57 -0
  33. package/src/components/ThemeSectionsEditor.tsx +513 -0
  34. package/src/components/booking/BookingFlow.astro +150 -0
  35. package/src/components/booking/HoursEditor.astro +63 -0
  36. package/src/components/builder/PageBuilder.tsx +10 -2
  37. package/src/components/editor/EventPanel.tsx +401 -0
  38. package/src/components/editor/FormatDock.tsx +81 -0
  39. package/src/components/editor/LivePane.tsx +122 -0
  40. package/src/components/editor/MobileToolbar.tsx +93 -0
  41. package/src/components/editor/PostEditor.tsx +826 -271
  42. package/src/components/editor/PreviewOverlay.tsx +242 -43
  43. package/src/components/editor/extensions/calendar.tsx +51 -7
  44. package/src/components/editor/extensions/cards.tsx +17 -1
  45. package/src/components/editor/extensions/codeBlock.tsx +1 -1
  46. package/src/components/editor/extensions/events.tsx +153 -0
  47. package/src/components/editor/extensions/infoCards.tsx +16 -1
  48. package/src/components/editor/extensions/mediaCards.tsx +36 -2
  49. package/src/components/editor/extensions/slash.tsx +77 -9
  50. package/src/components/editor/extensions/toc.tsx +89 -0
  51. package/src/components/editor/extensions/toggle.tsx +70 -0
  52. package/src/components/site/Pager.astro +24 -0
  53. package/src/components/site/PostCard.astro +64 -0
  54. package/src/components/site/TagLabel.astro +11 -0
  55. package/src/layouts/Admin.astro +289 -49
  56. package/src/layouts/Site.astro +331 -57
  57. package/src/lib/agent-api.ts +13 -0
  58. package/src/lib/agent-discovery.ts +40 -0
  59. package/src/lib/agent.ts +114 -0
  60. package/src/lib/ai-config.ts +58 -0
  61. package/src/lib/ai-schema.ts +22 -0
  62. package/src/lib/ai.ts +90 -0
  63. package/src/lib/analytics.ts +121 -0
  64. package/src/lib/api-keys.ts +65 -0
  65. package/src/lib/api.ts +52 -0
  66. package/src/lib/auth.ts +25 -5
  67. package/src/lib/backup/content.ts +36 -0
  68. package/src/lib/backup/unzip.ts +12 -1
  69. package/src/lib/backup/zip.ts +1 -1
  70. package/src/lib/booking/agent.ts +108 -0
  71. package/src/lib/booking/availability.ts +221 -0
  72. package/src/lib/booking/emails.ts +115 -0
  73. package/src/lib/booking/forms.ts +77 -0
  74. package/src/lib/booking/service.ts +277 -0
  75. package/src/lib/booking/view.ts +54 -0
  76. package/src/lib/calendar/agent-schema.ts +48 -0
  77. package/src/lib/calendar/agent.ts +136 -0
  78. package/src/lib/calendar/api.ts +81 -0
  79. package/src/lib/calendar/cards.ts +102 -0
  80. package/src/lib/calendar/config.ts +52 -0
  81. package/src/lib/calendar/data.ts +130 -0
  82. package/src/lib/calendar/events.ts +261 -0
  83. package/src/lib/calendar/feeds.ts +43 -0
  84. package/src/lib/calendar/hooks.ts +38 -0
  85. package/src/lib/calendar/http.ts +30 -0
  86. package/src/lib/calendar/ics-write.ts +120 -0
  87. package/src/lib/calendar/ics.ts +15 -5
  88. package/src/lib/calendar/import.ts +203 -0
  89. package/src/lib/calendar/render.ts +120 -37
  90. package/src/lib/calendar/rrule.ts +149 -0
  91. package/src/lib/calendar/seo.ts +48 -0
  92. package/src/lib/design-service.ts +58 -0
  93. package/src/lib/designDiff.ts +145 -0
  94. package/src/lib/designPreview.ts +112 -0
  95. package/src/lib/documents/cleanup.ts +31 -0
  96. package/src/lib/documents/extract.ts +154 -0
  97. package/src/lib/documents/vendor.d.ts +4 -0
  98. package/src/lib/drafts.ts +131 -0
  99. package/src/lib/dynamicBlocks.ts +79 -7
  100. package/src/lib/email/accounts.ts +55 -0
  101. package/src/lib/email/config.ts +86 -0
  102. package/src/lib/email/migration.ts +24 -0
  103. package/src/lib/email/providers.ts +89 -0
  104. package/src/lib/email/service.ts +78 -0
  105. package/src/lib/email/store.ts +167 -0
  106. package/src/lib/email/templates.ts +18 -0
  107. package/src/lib/email/types.ts +32 -0
  108. package/src/lib/email/webhooks.ts +87 -0
  109. package/src/lib/email.ts +15 -0
  110. package/src/lib/env.ts +1 -1
  111. package/src/lib/legal/index.ts +25 -0
  112. package/src/lib/mobile/bookings.ts +73 -0
  113. package/src/lib/mobile/calendar.ts +85 -0
  114. package/src/lib/mobile/content.ts +85 -0
  115. package/src/lib/mobile/http.ts +63 -0
  116. package/src/lib/mobile/route.ts +20 -0
  117. package/src/lib/ops.ts +8 -0
  118. package/src/lib/preview.ts +21 -1
  119. package/src/lib/repo/migrations.ts +254 -0
  120. package/src/lib/repo/sql.ts +956 -46
  121. package/src/lib/repo/types.ts +140 -3
  122. package/src/lib/scheduler.ts +25 -0
  123. package/src/lib/schema.ts +362 -0
  124. package/src/lib/search-i18n.ts +3 -0
  125. package/src/lib/socialIcons.ts +32 -0
  126. package/src/lib/tagColor.ts +11 -0
  127. package/src/lib/theme.ts +59 -40
  128. package/src/lib/themeTokens.ts +174 -0
  129. package/src/lib/umami.ts +108 -0
  130. package/src/lib/webhooks.ts +139 -0
  131. package/src/middleware.ts +36 -3
  132. package/src/pages/404.astro +23 -0
  133. package/src/pages/[...slug].astro +10 -5
  134. package/src/pages/account/[action].astro +53 -0
  135. package/src/pages/admin/ai.astro +12 -0
  136. package/src/pages/admin/c/[collection]/[id].astro +14 -4
  137. package/src/pages/admin/c/[collection]/index.astro +127 -91
  138. package/src/pages/admin/calendar/bookings.astro +400 -0
  139. package/src/pages/admin/calendar/calendars.astro +265 -0
  140. package/src/pages/admin/calendar/events/[id].astro +56 -0
  141. package/src/pages/admin/calendar/events/index.astro +408 -0
  142. package/src/pages/admin/calendar/index.ts +4 -0
  143. package/src/pages/admin/calendar/integrations.astro +221 -0
  144. package/src/pages/admin/calendar/resources/[id].astro +180 -0
  145. package/src/pages/admin/calendar/resources/index.astro +106 -0
  146. package/src/pages/admin/calendar/resources/link.astro +196 -0
  147. package/src/pages/admin/design.astro +16 -4
  148. package/src/pages/admin/email.astro +13 -0
  149. package/src/pages/admin/index.astro +237 -87
  150. package/src/pages/admin/links.astro +3 -3
  151. package/src/pages/admin/login.astro +14 -10
  152. package/src/pages/admin/navigation.astro +8 -2
  153. package/src/pages/admin/pages/[id].astro +14 -3
  154. package/src/pages/admin/pages/index.astro +41 -47
  155. package/src/pages/admin/settings.astro +475 -276
  156. package/src/pages/admin/tags.astro +4 -4
  157. package/src/pages/admin/users.astro +38 -28
  158. package/src/pages/api/agent/docs.ts +33 -0
  159. package/src/pages/api/agent/index.ts +21 -0
  160. package/src/pages/api/ai/clean.ts +13 -0
  161. package/src/pages/api/ai/draft.ts +14 -0
  162. package/src/pages/api/ai/settings.ts +53 -0
  163. package/src/pages/api/ai/test.ts +12 -0
  164. package/src/pages/api/backup.ts +8 -21
  165. package/src/pages/api/calendar/availability.ts +35 -0
  166. package/src/pages/api/calendar/bookings/[id].ts +49 -0
  167. package/src/pages/api/calendar/bookings/index.ts +73 -0
  168. package/src/pages/api/calendar/calendars.ts +35 -0
  169. package/src/pages/api/calendar/docs.ts +64 -0
  170. package/src/pages/api/calendar/events/[id].ts +63 -0
  171. package/src/pages/api/calendar/events/index.ts +112 -0
  172. package/src/pages/api/calendar/resources.ts +34 -0
  173. package/src/pages/api/design/capabilities.ts +10 -0
  174. package/src/pages/api/design/index.ts +58 -0
  175. package/src/pages/api/design/keys.ts +24 -0
  176. package/src/pages/api/design/media.ts +32 -0
  177. package/src/pages/api/email/run.ts +11 -0
  178. package/src/pages/api/email/settings.ts +61 -0
  179. package/src/pages/api/email/webhooks/[provider].ts +4 -0
  180. package/src/pages/api/mcp.ts +59 -0
  181. package/src/pages/api/media/upload.ts +2 -2
  182. package/src/pages/api/ops/status.ts +39 -0
  183. package/src/pages/api/restore.ts +27 -37
  184. package/src/pages/api/search.ts +18 -4
  185. package/src/pages/api/v1/availability.ts +7 -0
  186. package/src/pages/api/v1/bookings.ts +17 -0
  187. package/src/pages/api/v1/calendars.ts +7 -0
  188. package/src/pages/api/v1/content/[id].ts +7 -0
  189. package/src/pages/api/v1/content/index.ts +7 -0
  190. package/src/pages/api/v1/events.ts +7 -0
  191. package/src/pages/api/v1/resources.ts +7 -0
  192. package/src/pages/author/[slug].astro +43 -35
  193. package/src/pages/blog/[slug].astro +140 -66
  194. package/src/pages/blog/index.astro +95 -30
  195. package/src/pages/blog/tag/[slug].astro +45 -40
  196. package/src/pages/book/[resource]/[link].astro +70 -0
  197. package/src/pages/book/[resource]/index.astro +63 -0
  198. package/src/pages/book/[resource].ics.ts +38 -0
  199. package/src/pages/book/index.astro +47 -0
  200. package/src/pages/book/manage/[token].astro +108 -0
  201. package/src/pages/calendar/[slug].ics.ts +27 -0
  202. package/src/pages/calendar/feed.ics.ts +15 -0
  203. package/src/pages/calendar/index.astro +191 -0
  204. package/src/pages/embed/book/[resource]/[link].astro +33 -0
  205. package/src/pages/embed/calendar/[slug].astro +43 -0
  206. package/src/pages/events/[slug].astro +74 -0
  207. package/src/pages/events/[slug].ics.ts +25 -0
  208. package/src/pages/index.astro +9 -3
  209. package/src/pages/legal/privacy.astro +4 -0
  210. package/src/pages/legal/terms.astro +4 -0
  211. package/src/pages/links.astro +33 -7
  212. package/src/pages/llms.txt.ts +4 -0
  213. package/src/pages/preview/[type]/[id].astro +34 -8
  214. package/src/pages/search.astro +40 -20
  215. package/src/pages/sitemap.xml.ts +16 -1
  216. package/src/styles/admin.css +928 -105
  217. package/src/styles/cards.css +101 -60
  218. package/src/styles/editor.css +532 -102
  219. package/src/styles/email-settings.css +117 -0
  220. package/src/styles/sections.css +12 -0
  221. package/src/styles/site.css +938 -278
  222. package/src/styles/theme-editor.css +135 -0
  223. package/src/theme-sdk/contract.d.mts +619 -0
  224. package/src/theme-sdk/contract.mjs +80 -0
  225. package/src/theme-sdk/index.ts +15 -0
  226. package/src/theme-sdk/tokens.d.mts +148 -0
  227. package/src/theme-sdk/tokens.mjs +52 -0
  228. package/src/themes/editorial/templates/home.astro +4 -4
  229. package/src/themes/editorial/templates/post-minimal.astro +2 -2
  230. package/src/themes/editorial/theme.css +22 -37
  231. package/src/themes/editorial/theme.json +23 -3
  232. package/src/themes/field/theme.json +26 -9
  233. package/src/themes/ink/theme.json +30 -12
  234. package/src/themes/night/theme.css +4 -7
  235. package/src/themes/night/theme.json +27 -10
  236. package/src/virtual.d.ts +14 -0
  237. package/src/worker.ts +5 -1
@@ -1,6 +1,7 @@
1
1
  import { Node } from '@tiptap/core';
2
2
  import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
3
3
  import type { NodeViewProps } from '@tiptap/react';
4
+ import { cleanEventName } from '../../../lib/analytics';
4
5
 
5
6
  // Info cards: a *static* grid of title / badge / description / detail-line
6
7
  // cards — services, opening hours, team members, feature grids. Unlike the
@@ -25,6 +26,7 @@ export interface InfoItem {
25
26
  badgeStyle: 'filled' | 'outline';
26
27
  description: string;
27
28
  href: string; // empty = plain card; set = whole card is a link
29
+ event: string; // optional analytics event name (linked cards only)
28
30
  lines: InfoLine[];
29
31
  }
30
32
 
@@ -67,6 +69,7 @@ const sanitizeItem = (raw: unknown): InfoItem | null => {
67
69
  badgeStyle: it.badgeStyle === 'outline' ? 'outline' : 'filled',
68
70
  description: str(it.description),
69
71
  href: str(it.href),
72
+ event: str(it.event),
70
73
  lines,
71
74
  };
72
75
  };
@@ -90,6 +93,7 @@ const newItem = (): InfoItem => ({
90
93
  badgeStyle: 'filled',
91
94
  description: '',
92
95
  href: '',
96
+ event: '',
93
97
  lines: [
94
98
  { icon: 'calendar', text: '' },
95
99
  { icon: 'clock', text: '' },
@@ -140,8 +144,9 @@ export const InfoCards = Node.create({
140
144
  ]);
141
145
  }
142
146
  const href = it.href.trim();
147
+ const event = cleanEventName(it.event ?? '');
143
148
  return href
144
- ? ['a', { class: 'pc-info-card', href }, ...children]
149
+ ? ['a', { class: 'pc-info-card', href, ...(event ? { 'data-umami-event': event } : {}) }, ...children]
145
150
  : ['article', { class: 'pc-info-card' }, ...children];
146
151
  });
147
152
  return ['div', { class: 'pc-infocards', 'data-items': JSON.stringify(items) }, ...cards];
@@ -256,6 +261,16 @@ function InfoCardsView({ node, updateAttributes, deleteNode, selected }: NodeVie
256
261
  value={it.href}
257
262
  onChange={(e) => patch(i, { href: e.target.value })}
258
263
  />
264
+ {it.href.trim() !== '' && (
265
+ <input
266
+ type="text"
267
+ className="pe-infocard-href"
268
+ placeholder="Analytics event (optional)"
269
+ title="Clicks are counted under this name in Umami (and forwarded to enabled ad pixels)"
270
+ value={it.event}
271
+ onChange={(e) => patch(i, { event: e.target.value })}
272
+ />
273
+ )}
259
274
  </div>
260
275
  </div>
261
276
  ))}
@@ -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>
@@ -3,19 +3,31 @@ import type { Editor, Range } from '@tiptap/react';
3
3
  import Suggestion from '@tiptap/suggestion';
4
4
  import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
5
5
  import { CONTENT } from '../../../lib/content';
6
+ import { CALENDAR } from '../../../lib/calendar/config';
6
7
 
7
8
  // "/" command menu, Ghost-style: type / on a line to insert cards and blocks.
8
9
 
10
+ export interface SlashSnippet {
11
+ id: string;
12
+ name: string;
13
+ contentJson: string; // JSON array of ProseMirror nodes
14
+ }
15
+
9
16
  export interface SlashContext {
10
17
  openImagePicker: (insertAt: Range) => void;
18
+ // saved snippets, provided by the editor shell (loaded once per mount)
19
+ getSnippets: () => SlashSnippet[];
20
+ deleteSnippet: (id: string) => void;
11
21
  }
12
22
 
13
23
  interface SlashItem {
14
24
  title: string;
15
25
  description: string;
16
26
  keywords: string;
17
- group: 'Basic' | 'Cards';
27
+ group: 'Basic' | 'Cards' | 'Snippets';
18
28
  icon: string; // svg path (24 viewBox, stroked)
29
+ snippetId?: string;
30
+ onDelete?: () => void;
19
31
  run: (editor: Editor, range: Range, ctx: SlashContext) => void;
20
32
  }
21
33
 
@@ -58,16 +70,32 @@ const ITEMS: SlashItem[] = [
58
70
  icon: 'M12 3l9 18H3zM12 10v4M12 17.5h0',
59
71
  run: (editor, range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'callout' }).run(),
60
72
  },
73
+ {
74
+ title: 'Toggle', description: 'Collapsible section', keywords: 'toggle accordion collapse details faq expand', group: 'Cards',
75
+ icon: 'M8 5l4 4 4-4M6 13h12M6 17h8',
76
+ run: (editor, range) =>
77
+ editor.chain().focus().deleteRange(range).insertContent({ type: 'toggleCard', content: [{ type: 'paragraph' }] }).run(),
78
+ },
79
+ {
80
+ title: 'Table of contents', description: 'Linked outline of this document (dynamic)', keywords: 'toc contents outline sections navigation dynamic', group: 'Cards',
81
+ icon: 'M9 6h12M9 12h12M12 18h9M4 5.5h2v2H4zM6 11h3M7 16.5h2',
82
+ run: (editor, range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'tocCard' }).run(),
83
+ },
61
84
  {
62
85
  title: 'HTML', description: 'Raw HTML block', keywords: 'html code embed custom', group: 'Cards',
63
86
  icon: 'M8 6l-5 6 5 6M16 6l5 6-5 6',
64
87
  run: (editor, range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'htmlCard' }).run(),
65
88
  },
66
89
  {
67
- title: 'Calendar', description: 'Google Calendar or ICS feed (dynamic)', keywords: 'calendar events google ics schedule month week upcoming dynamic', group: 'Cards',
90
+ title: 'Calendar', description: 'Site calendars, Google Calendar or any ICS feed (dynamic)', keywords: 'calendar events google ics schedule month week upcoming dynamic', group: 'Cards',
68
91
  icon: 'M3 5h18v16H3zM3 9h18M8 3v4M16 3v4M7 13h2M12 13h2M7 17h2M12 17h2',
69
92
  run: (editor, range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'calendarCard' }).run(),
70
93
  },
94
+ ...(CALENDAR.enabled ? [{
95
+ title: 'Events', description: 'Coming-up event cards, or one event with its button (dynamic)', keywords: 'events event upcoming coming up calendar card rsvp cta dynamic', group: 'Cards' as const,
96
+ icon: 'M3 5h18v16H3zM3 9h18M8 3v4M16 3v4M7 13h4v4H7z',
97
+ run: (editor: Editor, range: Range) => editor.chain().focus().deleteRange(range).insertContent({ type: 'eventCard' }).run(),
98
+ } satisfies SlashItem] : []),
71
99
  {
72
100
  title: 'Info cards', description: 'Grid of cards: title, badge, details', keywords: 'info cards grid services hours schedule team features locations badge', group: 'Cards',
73
101
  icon: 'M3 5h18v14H3zM7 9h6M15.5 9h2M7 13h7M7 16h5',
@@ -89,6 +117,11 @@ const ITEMS: SlashItem[] = [
89
117
  icon: 'M3 12h18',
90
118
  run: (editor, range) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
91
119
  },
120
+ {
121
+ title: 'Heading 1', description: 'Large section heading', keywords: 'heading h1 title large', group: 'Basic',
122
+ icon: 'M4 5v14M14 5v14M4 12h10M18.5 9.5l1.5-1v7',
123
+ run: (editor, range) => editor.chain().focus().deleteRange(range).setNode('heading', { level: 1 }).run(),
124
+ },
92
125
  {
93
126
  title: 'Heading 2', description: 'Section heading', keywords: 'heading h2 title section', group: 'Basic',
94
127
  icon: 'M4 5v14M14 5v14M4 12h10M18 15c0-1 2-1.6 2-3a1.8 1.8 0 0 0-3.4-.8',
@@ -132,23 +165,27 @@ export interface SlashListHandle {
132
165
 
133
166
  const SlashList = forwardRef<SlashListHandle, ListProps>(function SlashList({ items, command }, ref) {
134
167
  const [selected, setSelected] = useState(0);
168
+ // snippets deleted from the open menu disappear immediately; the source
169
+ // list refreshes on the next menu open
170
+ const [hidden, setHidden] = useState<Set<string>>(() => new Set());
171
+ const visible = items.filter((i) => !i.snippetId || !hidden.has(i.snippetId));
135
172
  useEffect(() => setSelected(0), [items]);
136
173
 
137
174
  useImperativeHandle(ref, () => ({
138
175
  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; }
176
+ if (e.key === 'ArrowDown') { setSelected((s) => (s + 1) % visible.length); return true; }
177
+ if (e.key === 'ArrowUp') { setSelected((s) => (s - 1 + visible.length) % visible.length); return true; }
178
+ if (e.key === 'Enter') { if (visible[selected]) command(visible[selected]); return true; }
142
179
  return false;
143
180
  },
144
181
  }));
145
182
 
146
- if (!items.length) return <div className="pe-slash"><div className="pe-slash-empty">No matches</div></div>;
183
+ if (!visible.length) return <div className="pe-slash"><div className="pe-slash-empty">No matches</div></div>;
147
184
 
148
185
  let lastGroup = '';
149
186
  return (
150
187
  <div className="pe-slash">
151
- {items.map((item, i) => {
188
+ {visible.map((item, i) => {
152
189
  const header = item.group !== lastGroup ? item.group : null;
153
190
  lastGroup = item.group;
154
191
  return (
@@ -169,6 +206,22 @@ const SlashList = forwardRef<SlashListHandle, ListProps>(function SlashList({ it
169
206
  <span className="pe-slash-title">{item.title}</span>
170
207
  <span className="pe-slash-desc">{item.description}</span>
171
208
  </span>
209
+ {item.onDelete && item.snippetId && (
210
+ <span
211
+ role="button"
212
+ tabIndex={-1}
213
+ className="pe-slash-del"
214
+ title="Delete snippet"
215
+ onMouseDown={(e) => {
216
+ e.preventDefault();
217
+ e.stopPropagation();
218
+ item.onDelete!();
219
+ setHidden((h) => new Set(h).add(item.snippetId!));
220
+ }}
221
+ >
222
+ ✕
223
+ </span>
224
+ )}
172
225
  </button>
173
226
  </div>
174
227
  );
@@ -181,7 +234,7 @@ export const SlashMenu = Extension.create<{ context: SlashContext }>({
181
234
  name: 'slashMenu',
182
235
 
183
236
  addOptions() {
184
- return { context: { openImagePicker: () => {} } };
237
+ return { context: { openImagePicker: () => {}, getSnippets: () => [], deleteSnippet: () => {} } };
185
238
  },
186
239
 
187
240
  addProseMirrorPlugins() {
@@ -196,7 +249,22 @@ export const SlashMenu = Extension.create<{ context: SlashContext }>({
196
249
  },
197
250
  items: ({ query }: { query: string }) => {
198
251
  const q = query.toLowerCase();
199
- return ITEMS.filter(
252
+ const snippets: SlashItem[] = context.getSnippets().map((s) => ({
253
+ title: s.name,
254
+ description: 'Saved snippet',
255
+ keywords: `snippet ${s.name.toLowerCase()}`,
256
+ group: 'Snippets' as const,
257
+ icon: 'M4 7l8-4 8 4-8 4zM4 12l8 4 8-4M4 17l8 4 8-4',
258
+ snippetId: s.id,
259
+ onDelete: () => context.deleteSnippet(s.id),
260
+ run: (editor, range) => {
261
+ let content: unknown = null;
262
+ try { content = JSON.parse(s.contentJson); } catch { /* corrupt row: insert nothing */ }
263
+ if (!Array.isArray(content) || !content.length) return;
264
+ editor.chain().focus().deleteRange(range).insertContent(content).run();
265
+ },
266
+ }));
267
+ return [...ITEMS, ...snippets].filter(
200
268
  (i) => i.title.toLowerCase().includes(q) || i.keywords.includes(q)
201
269
  );
202
270
  },
@@ -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
+ }
@@ -0,0 +1,24 @@
1
+ ---
2
+ // Numbered pagination: ← Newer · 1 2 … n · Older →. `base` is the page-1 URL;
3
+ // later pages append ?page=N.
4
+ interface Props { base: string; page: number; count: number }
5
+ const { base, page, count } = Astro.props;
6
+ const href = (n: number) => (n === 1 ? base : `${base}${base.includes('?') ? '&' : '?'}page=${n}`);
7
+ // window of pages around the current one, with first/last always shown
8
+ const nums: (number | '…')[] = [];
9
+ for (let n = 1; n <= count; n++) {
10
+ if (n === 1 || n === count || Math.abs(n - page) <= 1) nums.push(n);
11
+ else if (nums[nums.length - 1] !== '…') nums.push('…');
12
+ }
13
+ ---
14
+ {count > 1 && (
15
+ <nav class="pager" aria-label="Pagination">
16
+ {page > 1 ? <a class="pager-step" href={href(page - 1)} rel="prev">← Newer</a> : <span class="pager-step is-off">← Newer</span>}
17
+ <span class="pager-nums">
18
+ {nums.map((n) => n === '…'
19
+ ? <span class="pager-gap">…</span>
20
+ : <a href={href(n)} aria-current={n === page ? 'page' : undefined}>{n}</a>)}
21
+ </span>
22
+ {page < count ? <a class="pager-step" href={href(page + 1)} rel="next">Older →</a> : <span class="pager-step is-off">Older →</span>}
23
+ </nav>
24
+ )}
@@ -0,0 +1,64 @@
1
+ ---
2
+ // One post teaser in three shapes, shared by the blog index, tag and author
3
+ // archives and "Keep reading":
4
+ // card — 16:10 cover (or a generated tile), meta line, title, excerpt
5
+ // row — text-first list row with a small thumbnail on the right
6
+ // feature — the big split card for the featured post
7
+ // Posts without a cover get a quiet generated tile (soft fill + first tag),
8
+ // so a grid never goes ragged.
9
+ import TagLabel from './TagLabel.astro';
10
+ import { tagColor } from '../../lib/tagColor';
11
+
12
+ interface Props {
13
+ variant?: 'card' | 'row' | 'feature';
14
+ href: string;
15
+ title: string;
16
+ excerpt?: string;
17
+ cover?: string;
18
+ coverAlt?: string;
19
+ date?: string;
20
+ datetime?: string;
21
+ readMins?: number;
22
+ tags?: { name: string; href: string }[];
23
+ featured?: boolean;
24
+ author?: { name: string; avatar?: string; href: string } | null;
25
+ headingLevel?: 2 | 3;
26
+ }
27
+ const {
28
+ variant = 'card', href, title, excerpt, cover, coverAlt = '', date, datetime, readMins,
29
+ tags = [], featured = false, author = null, headingLevel = 3,
30
+ } = Astro.props;
31
+ const H = `h${headingLevel}` as 'h2' | 'h3';
32
+ const firstTag = tags[0]?.name;
33
+ const shownTags = tags.slice(0, variant === 'feature' ? 2 : 1);
34
+ ---
35
+ <article class:list={['post-card', `is-${variant}`]}>
36
+ {variant !== 'row' && (
37
+ <a class="post-card-media" href={href} tabindex="-1" aria-hidden="true">
38
+ {cover
39
+ ? <img src={cover} alt={coverAlt} loading={variant === 'feature' ? 'eager' : 'lazy'} />
40
+ : <span class="post-card-tile" style={firstTag ? `--tc:${tagColor(firstTag)}` : undefined}><span>{firstTag ?? title}</span></span>}
41
+ </a>
42
+ )}
43
+ <div class="post-card-body">
44
+ <div class="post-card-meta">
45
+ {featured && <TagLabel name="Featured" accent />}
46
+ {shownTags.map((t) => <TagLabel name={t.name} href={t.href} />)}
47
+ {date && <time datetime={datetime}>{date}</time>}
48
+ {readMins && <span class="post-card-read">{readMins} min read</span>}
49
+ </div>
50
+ <H class="post-card-title"><a href={href}>{title}</a></H>
51
+ {excerpt && <p class="post-card-excerpt">{excerpt}</p>}
52
+ {variant === 'feature' && author && (
53
+ <a class="post-card-author" href={author.href}>
54
+ {author.avatar
55
+ ? <img class="avatar" src={author.avatar} alt="" />
56
+ : <span class="avatar avatar-empty" aria-hidden="true"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="8.5" r="3.5" /><path d="M5 20a7 7 0 0 1 14 0" /></svg></span>}
57
+ <span>{author.name}</span>
58
+ </a>
59
+ )}
60
+ </div>
61
+ {variant === 'row' && cover && (
62
+ <a class="post-card-thumb" href={href} tabindex="-1" aria-hidden="true"><img src={cover} alt="" loading="lazy" /></a>
63
+ )}
64
+ </article>
@@ -0,0 +1,11 @@
1
+ ---
2
+ // A tag as marker + text. `href` makes it a link; `accent` swaps the hashed
3
+ // color for the theme accent (used for "Featured").
4
+ import { tagColor } from '../../lib/tagColor';
5
+ interface Props { name: string; href?: string; accent?: boolean }
6
+ const { name, href, accent = false } = Astro.props;
7
+ const style = accent ? undefined : `--tc:${tagColor(name)}`;
8
+ ---
9
+ {href
10
+ ? <a class:list={['tag', { 'is-accent': accent }]} href={href} style={style}>{name}</a>
11
+ : <span class:list={['tag', { 'is-accent': accent }]} style={style}>{name}</span>}