@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,4 +1,4 @@
1
- import { useRef, useState } from 'react';
1
+ import { useEffect, useMemo, useState } from 'react';
2
2
  import { DndContext, PointerSensor, KeyboardSensor, closestCenter, useSensor, useSensors } from '@dnd-kit/core';
3
3
  import type { DragEndEvent } from '@dnd-kit/core';
4
4
  import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
@@ -10,6 +10,11 @@ import type { NavItem, NavArea } from '../lib/schema';
10
10
  // auto-list of published pages (which stays as a fallback while the header
11
11
  // area is empty); footer renders above the footer text; social feeds the
12
12
  // footer social row and share UIs.
13
+ //
14
+ // Edits stay local until "Save changes", which STAGES the whole list in the
15
+ // design draft: menus go live together with the design on "Publish design"
16
+ // (here or on the Design screen), and preview mode shows them first.
17
+ // Discard restores the last saved state.
13
18
 
14
19
  type SaveState = 'saved' | 'saving' | 'error';
15
20
 
@@ -19,6 +24,8 @@ const AREAS: { key: NavArea; title: string; hint: string }[] = [
19
24
  { key: 'social', title: 'Social', hint: 'Footer social row — also used for share links on posts.' },
20
25
  ];
21
26
 
27
+ // new items carry a temp id; Publish design gives them real ids
28
+
22
29
  function Row({
23
30
  item, onEdit, onArea, onDelete,
24
31
  }: {
@@ -61,54 +68,49 @@ function Row({
61
68
  );
62
69
  }
63
70
 
64
- export default function NavManager({ items: initial }: { items: NavItem[] }) {
71
+ export default function NavManager({ items: initial, staged: stagedInitially }: { items: NavItem[]; staged: boolean }) {
65
72
  const [items, setItems] = useState<NavItem[]>(initial);
73
+ // the saved list is staged (differs from the live menus) until Publish
74
+ const [staged, setStaged] = useState(stagedInitially);
75
+ // last state the server has — dirty compares against this
76
+ const [saved, setSaved] = useState<NavItem[]>(initial);
66
77
  const [saveState, setSaveState] = useState<SaveState>('saved');
67
- const pending = useRef(0);
68
- const timers = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
78
+
79
+ const dirty = useMemo(
80
+ () =>
81
+ JSON.stringify(items.map(({ id, label, url, area }) => ({ id, label, url, area }))) !==
82
+ JSON.stringify(saved.map(({ id, label, url, area }) => ({ id, label, url, area }))),
83
+ [items, saved]
84
+ );
85
+
86
+ // unsaved edits live only in this tab — warn before losing them
87
+ useEffect(() => {
88
+ if (!dirty) return;
89
+ const onLeave = (e: BeforeUnloadEvent) => e.preventDefault();
90
+ window.addEventListener('beforeunload', onLeave);
91
+ return () => window.removeEventListener('beforeunload', onLeave);
92
+ }, [dirty]);
69
93
 
70
94
  const sensors = useSensors(
71
95
  useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
72
96
  useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
73
97
  );
74
98
 
75
- async function run(fn: () => Promise<{ error?: unknown }>) {
76
- pending.current += 1;
77
- setSaveState('saving');
78
- const r = await fn();
79
- pending.current -= 1;
80
- if (r.error) setSaveState('error');
81
- else if (pending.current === 0) setSaveState('saved');
82
- }
83
-
84
- function debounced(key: string, fn: () => void, ms = 600) {
85
- const prev = timers.current.get(key);
86
- if (prev) clearTimeout(prev);
87
- timers.current.set(key, setTimeout(() => { timers.current.delete(key); fn(); }, ms));
88
- }
89
-
90
99
  function edit(id: string, patch: Partial<Pick<NavItem, 'label' | 'url'>>) {
91
100
  setItems((ls) => ls.map((n) => (n.id === id ? { ...n, ...patch } : n)));
92
- debounced(id, () => void run(() => actions.nav.update({ id, ...patch })));
93
101
  }
94
102
 
95
103
  function setArea(id: string, area: NavArea) {
96
104
  setItems((ls) => ls.map((n) => (n.id === id ? { ...n, area } : n)));
97
- void run(() => actions.nav.update({ id, area }));
98
105
  }
99
106
 
100
- async function add(area: NavArea) {
101
- await run(async () => {
102
- const r = await actions.nav.create({ label: 'New link', url: '/', area });
103
- if (!r.error) setItems((ls) => [...ls, r.data.item]);
104
- return r;
105
- });
107
+ function add(area: NavArea) {
108
+ setItems((ls) => [...ls, { id: `tmp-${crypto.randomUUID()}`, label: 'New link', url: '/', area, order: ls.length }]);
106
109
  }
107
110
 
108
111
  function remove(id: string) {
109
- if (!window.confirm('Delete this navigation item?')) return;
112
+ // local until Save — Discard brings it back
110
113
  setItems((ls) => ls.filter((n) => n.id !== id));
111
- void run(() => actions.nav.delete({ id }));
112
114
  }
113
115
 
114
116
  function onDragEnd(area: NavArea, e: DragEndEvent) {
@@ -119,21 +121,64 @@ export default function NavManager({ items: initial }: { items: NavItem[] }) {
119
121
  const rest = ls.filter((n) => n.area !== area);
120
122
  const from = group.findIndex((n) => n.id === active.id);
121
123
  const to = group.findIndex((n) => n.id === over.id);
122
- const moved = arrayMove(group, from, to);
123
- const next = [...rest, ...moved];
124
- // persist the full display order: header, footer, social, each in order
125
- const ordered = AREAS.flatMap((a) => next.filter((n) => n.area === a.key)).map((n) => n.id);
126
- void run(() => actions.nav.reorder({ ids: ordered }));
127
- return next;
124
+ return [...rest, ...arrayMove(group, from, to)];
125
+ });
126
+ }
127
+
128
+ // one commit for everything: the full ordered list stages in the draft
129
+ async function saveAll() {
130
+ setSaveState('saving');
131
+ const ordered = AREAS.flatMap((a) => items.filter((n) => n.area === a.key));
132
+ const r = await actions.nav.saveDraft({
133
+ items: ordered.map(({ id, label, url, area }) => ({ id, label, url, area })),
128
134
  });
135
+ if (r.error) { setSaveState('error'); return; }
136
+ setItems(ordered);
137
+ setSaved(ordered);
138
+ setStaged(r.data.changes.some((c) => c.key === 'nav'));
139
+ setSaveState('saved');
140
+ }
141
+
142
+ async function publish() {
143
+ if (!window.confirm('Publish now? Staged menus and any staged design changes go live together. The current design is saved to History.')) return;
144
+ setSaveState('saving');
145
+ const r = await actions.settings.applyDesign({});
146
+ if (r.error) { setSaveState('error'); return; }
147
+ setStaged(false);
148
+ setSaveState('saved');
149
+ }
150
+
151
+ async function unstage() {
152
+ setSaveState('saving');
153
+ const r = await actions.settings.revertDesignChange({ key: 'nav' });
154
+ if (r.error) { setSaveState('error'); return; }
155
+ location.reload(); // back to the live menus
156
+ }
157
+
158
+ function discard() {
159
+ setItems(saved);
160
+ setSaveState('saved');
129
161
  }
130
162
 
131
163
  return (
132
164
  <div className="nm">
133
165
  <div className="nm-head">
134
- <span className={`lm-savestate ${saveState}`}>
135
- {saveState === 'saving' ? 'Saving…' : saveState === 'error' ? 'Save failed' : 'Saved'}
166
+ <span className={`lm-savestate ${saveState === 'saved' && dirty ? 'dirty' : saveState}`}>
167
+ {saveState === 'saving' ? 'Saving…' : saveState === 'error' ? 'Save failed' : dirty ? 'Unsaved changes' : staged ? 'Staged, not live yet' : 'Live'}
136
168
  </span>
169
+ {dirty && (
170
+ <>
171
+ <button className="btn sm ghost" type="button" onClick={discard} disabled={saveState === 'saving'}>Discard</button>
172
+ <button className="btn sm primary" type="button" onClick={() => void saveAll()} disabled={saveState === 'saving'}>Save changes</button>
173
+ </>
174
+ )}
175
+ {!dirty && staged && (
176
+ <>
177
+ <button className="btn sm ghost" type="button" onClick={() => void unstage()} disabled={saveState === 'saving'}>Discard staged menus</button>
178
+ <a className="btn sm" href="/?designPreview=1" target="_blank" rel="noopener">Preview site ↗</a>
179
+ <button className="btn sm primary" type="button" onClick={() => void publish()} disabled={saveState === 'saving'}>Publish…</button>
180
+ </>
181
+ )}
137
182
  </div>
138
183
  {AREAS.map((a) => {
139
184
  const group = items.filter((n) => n.area === a.key);
@@ -144,7 +189,7 @@ export default function NavManager({ items: initial }: { items: NavItem[] }) {
144
189
  <p className="card-title">{a.title}</p>
145
190
  <p className="hint">{a.hint}</p>
146
191
  </div>
147
- <button className="btn sm" type="button" onClick={() => void add(a.key)}>+ Add item</button>
192
+ <button className="btn sm" type="button" onClick={() => add(a.key)}>+ Add item</button>
148
193
  </div>
149
194
  {group.length === 0 && <p className="nm-empty">No items yet.</p>}
150
195
  <DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={(e) => onDragEnd(a.key, e)}>
@@ -0,0 +1,50 @@
1
+ ---
2
+ // Admin theme (Light / Dark / System). Inlined in <head> of every admin
3
+ // document so data-theme is set before first paint — no light flash on a
4
+ // dark-mode machine. The preference lives in localStorage per browser;
5
+ // "system" follows the OS live. Public-site previews (iframes) never read it.
6
+ ---
7
+
8
+ <meta name="color-scheme" content="light dark" />
9
+ <meta name="theme-color" content="#e8e6e1" />
10
+ <script is:inline>
11
+ (() => {
12
+ const KEY = 'acms-theme';
13
+ const COLORS = { light: '#e8e6e1', dark: '#141311' };
14
+ const mq = matchMedia('(prefers-color-scheme: dark)');
15
+ const root = document.documentElement;
16
+ const pref = () => {
17
+ try { return localStorage.getItem(KEY) || 'system'; } catch { return 'system'; }
18
+ };
19
+ const apply = (p = pref()) => {
20
+ const t = p === 'dark' || (p === 'system' && mq.matches) ? 'dark' : 'light';
21
+ root.dataset.theme = t;
22
+ root.dataset.themePref = p;
23
+ document.querySelector('meta[name="theme-color"]')?.setAttribute('content', COLORS[t]);
24
+ document.querySelectorAll('[data-set-theme]').forEach((el) => {
25
+ el.setAttribute('aria-pressed', String(el.getAttribute('data-set-theme') === p));
26
+ });
27
+ };
28
+ const set = (p) => {
29
+ try { localStorage.setItem(KEY, p); } catch {}
30
+ apply(p);
31
+ window.dispatchEvent(new CustomEvent('acms:theme', { detail: root.dataset.theme }));
32
+ };
33
+ window.acmsTheme = { set, pref };
34
+ apply();
35
+ mq.addEventListener('change', () => pref() === 'system' && apply());
36
+ document.addEventListener('DOMContentLoaded', () => apply());
37
+ document.addEventListener('click', (e) => {
38
+ const el = e.target instanceof Element ? e.target.closest('[data-set-theme]') : null;
39
+ if (el) set(el.getAttribute('data-set-theme'));
40
+ });
41
+ // ⌘⇧L / Ctrl+Shift+L cycles light → dark → system
42
+ document.addEventListener('keydown', (e) => {
43
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === 'l') {
44
+ e.preventDefault();
45
+ const order = ['light', 'dark', 'system'];
46
+ set(order[(order.indexOf(pref()) + 1) % order.length]);
47
+ }
48
+ });
49
+ })();
50
+ </script>
@@ -0,0 +1,57 @@
1
+ ---
2
+ import type { Page, Settings } from '../lib/schema';
3
+ import { getTheme } from '../lib/theme';
4
+ import { getRepo } from '../lib/repo';
5
+ import { CONTENT, collectionUrl } from '../lib/content';
6
+ import { sectionIssues } from '../theme-sdk/contract.mjs';
7
+ import WidgetRenderer from './WidgetRenderer.astro';
8
+ import { expandDynamicBlocks } from '../lib/dynamicBlocks';
9
+ import { highlightCodeHtml } from '../lib/highlightCode';
10
+ import PostCard from './site/PostCard.astro';
11
+ import '../styles/sections.css';
12
+ interface Props { page: Page; settings: Settings }
13
+ const { page, settings } = Astro.props;
14
+ const theme = getTheme(settings.activeTheme);
15
+ const layout = settings.sectionPages[page.id];
16
+ const repo = await getRepo(Astro.locals);
17
+ const bodyHtml = page.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo)) : '';
18
+ const safeUrl = (value: unknown) => {
19
+ const s = String(value ?? '').trim();
20
+ return /^(https?:\/\/|mailto:|tel:|\/(?!\/)|#)/i.test(s) ? s : '';
21
+ };
22
+ const sections = await Promise.all(layout.sections.map(async (section) => {
23
+ const definition = theme.sections[section.type];
24
+ const invalid = sectionIssues({ mode: 'sections', sections: [section] }, theme.sections).length > 0;
25
+ const data = Object.fromEntries(Object.entries(definition?.fields ?? {}).map(([key, field]) => [key, section.data[key] ?? (field as {default?: unknown}).default ?? '']));
26
+ const collection = String(data.collection || 'posts');
27
+ const posts = section.type === 'posts' && !section.hidden && !invalid && CONTENT.collections[collection]
28
+ ? (await repo.listPublishedPosts(collection)).slice(0, Math.max(1, Math.min(12, Number(data.limit) || 3))) : [];
29
+ return { ...section, data, invalid, posts, Template: theme.sectionTemplates[section.type] as any };
30
+ }));
31
+ ---
32
+ <div class="acms-sections">
33
+ {sections.filter(s => !s.hidden && !s.invalid).map(({ id, type, variant, data, Template, posts }) => (
34
+ <section id={`section-${id}`} class:list={['acms-section', `section-${type}`, `variant-${variant}`]}>
35
+ {type === 'page-content' ? (page.bodyHtml ? <article class="page-body" set:html={bodyHtml} /> : <WidgetRenderer pageId={page.id} blocks={page.blocks} />) : Template ? <Template data={data} variant={variant} settings={settings} page={page} /> : (
36
+ <>
37
+ <div class="section-copy">
38
+ {data.heading && <h2>{String(data.heading)}</h2>}
39
+ {data.text && <p class="section-text">{String(data.text)}</p>}
40
+ {data.label && safeUrl(data.href) && <a class="w-btn primary" href={safeUrl(data.href)}>{String(data.label)}</a>}
41
+ </div>
42
+ {type === 'hero' && safeUrl(data.image) && <img class="section-image" src={safeUrl(data.image)} alt={String(data.alt ?? '')} loading="eager" />}
43
+ {['features', 'testimonials', 'faq'].includes(type) && (
44
+ <div class="section-items">
45
+ {String(data.items ?? '').split('\n').filter(Boolean).map((row) => {
46
+ const [title, ...body] = row.split('|');
47
+ return type === 'faq' ? <details><summary>{title}</summary><p>{body.join('|')}</p></details>
48
+ : <article><h3>{title}</h3><p>{body.join('|')}</p></article>;
49
+ })}
50
+ </div>
51
+ )}
52
+ {type === 'posts' && <div class="section-items">{posts.map(post => <PostCard variant={variant === 'list' ? 'row' : 'card'} href={collectionUrl(post.collection, post.slug)} title={post.title} excerpt={post.excerpt} cover={post.cover} coverAlt={post.coverAlt} />)}</div>}
53
+ </>
54
+ )}
55
+ </section>
56
+ ))}
57
+ </div>