@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
@@ -0,0 +1,32 @@
1
+ // Brand glyphs for social links, matched by URL host. Used by the site footer
2
+ // and the /links profile row. Unknown hosts return null and the caller falls
3
+ // back to the item's text label. Paths are 24×24, filled with currentColor.
4
+
5
+ const ICONS: [RegExp, string][] = [
6
+ [/(^|\.)(x|twitter)\.com$/, 'M17.8 3h3.1l-6.8 7.7L22 21h-6.2l-4.9-6.4L5.3 21H2.2l7.2-8.3L2 3h6.4l4.4 5.8L17.8 3Zm-1.1 16.2h1.7L7.4 4.7H5.6l11.1 14.5Z'],
7
+ [/(^|\.)github\.com$/, 'M12 2.5a9.5 9.5 0 0 0-3 18.5c.5.1.7-.2.7-.5v-1.7c-2.7.6-3.2-1.2-3.2-1.2-.4-1.1-1.1-1.4-1.1-1.4-.9-.6.1-.6.1-.6 1 .1 1.5 1 1.5 1 .9 1.5 2.3 1.1 2.9.8.1-.6.3-1.1.6-1.3-2.1-.2-4.4-1.1-4.4-4.7 0-1 .4-1.9 1-2.6-.1-.2-.4-1.2.1-2.5 0 0 .8-.3 2.6 1a9 9 0 0 1 4.8 0c1.8-1.3 2.6-1 2.6-1 .5 1.3.2 2.3.1 2.5.6.7 1 1.6 1 2.6 0 3.7-2.3 4.5-4.4 4.7.3.3.6.9.6 1.8v2.6c0 .3.2.6.7.5A9.5 9.5 0 0 0 12 2.5Z'],
8
+ [/(^|\.)linkedin\.com$/, 'M5 3.5a2 2 0 1 1 0 4 2 2 0 0 1 0-4ZM3.3 9h3.4v11.5H3.3V9Zm5.6 0h3.3v1.6c.5-.9 1.6-1.9 3.4-1.9 3.6 0 4.2 2.3 4.2 5.4v6.4h-3.4v-5.7c0-1.4 0-3.1-1.9-3.1s-2.2 1.5-2.2 3v5.8H8.9V9Z'],
9
+ [/(^|\.)instagram\.com$/, 'M12 7.4a4.6 4.6 0 1 0 0 9.2 4.6 4.6 0 0 0 0-9.2Zm0 7.6a3 3 0 1 1 0-6 3 3 0 0 1 0 6Zm5.9-7.8a1.1 1.1 0 1 1-2.2 0 1.1 1.1 0 0 1 2.2 0ZM12 4.3c2.5 0 2.8 0 3.8.1 2.6.1 3.7 1.3 3.8 3.8.1 1 .1 1.3.1 3.8s0 2.8-.1 3.8c-.1 2.5-1.3 3.7-3.8 3.8-1 .1-1.3.1-3.8.1s-2.8 0-3.8-.1c-2.6-.1-3.7-1.3-3.8-3.8-.1-1-.1-1.3-.1-3.8s0-2.8.1-3.8c.1-2.5 1.3-3.7 3.8-3.8 1-.1 1.3-.1 3.8-.1Zm0-1.7c-2.6 0-2.9 0-3.9.1-3.5.2-5.4 2.1-5.6 5.6-.1 1-.1 1.3-.1 3.9s0 2.9.1 3.9c.2 3.5 2.1 5.4 5.6 5.6 1 .1 1.3.1 3.9.1s2.9 0 3.9-.1c3.5-.2 5.4-2.1 5.6-5.6.1-1 .1-1.3.1-3.9s0-2.9-.1-3.9c-.2-3.5-2.1-5.4-5.6-5.6-1-.1-1.3-.1-3.9-.1Z'],
10
+ [/(^|\.)(youtube\.com|youtu\.be)$/, 'M21.6 7.2a2.5 2.5 0 0 0-1.8-1.8C18.2 5 12 5 12 5s-6.2 0-7.8.4A2.5 2.5 0 0 0 2.4 7.2C2 8.8 2 12 2 12s0 3.2.4 4.8a2.5 2.5 0 0 0 1.8 1.8c1.6.4 7.8.4 7.8.4s6.2 0 7.8-.4a2.5 2.5 0 0 0 1.8-1.8c.4-1.6.4-4.8.4-4.8s0-3.2-.4-4.8ZM10 15V9l5.2 3L10 15Z'],
11
+ [/(^|\.)(facebook\.com|fb\.com)$/, 'M13.5 21v-7.6h2.6l.4-3h-3V8.5c0-.9.2-1.5 1.5-1.5h1.6V4.3c-.3 0-1.2-.1-2.3-.1-2.3 0-3.9 1.4-3.9 4v2.2H7.8v3h2.6V21h3.1Z'],
12
+ [/(^|\.)tiktok\.com$/, 'M16.6 3h-3.1v12.3a2.6 2.6 0 1 1-2.6-2.6c.3 0 .5 0 .8.1V9.6a5.8 5.8 0 1 0 4.9 5.7V9.1a7.3 7.3 0 0 0 4.3 1.4V7.4a4.3 4.3 0 0 1-4.3-4.4Z'],
13
+ ];
14
+
15
+ const RSS = 'M5 3a16 16 0 0 1 16 16h-3A13 13 0 0 0 5 6V3Zm0 6a10 10 0 0 1 10 10h-3a7 7 0 0 0-7-7V9Zm1.5 6.5a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z';
16
+ const MAIL = 'M3 5h18a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1Zm1.5 2v.3l7.5 5.2 7.5-5.2V7h-15Zm15 2.7-7.5 5.2-7.5-5.2V17h15V9.7Z';
17
+
18
+ // the SVG path for a social URL, or null when the host isn't recognised
19
+ export function socialIconPath(url: string): string | null {
20
+ if (url.startsWith('mailto:')) return MAIL;
21
+ if (/\/rss\.xml$|\/feed\/?$/.test(url)) return RSS;
22
+ let host = '';
23
+ try {
24
+ host = new URL(url).hostname.replace(/^www\./, '');
25
+ } catch {
26
+ return null;
27
+ }
28
+ return ICONS.find(([re]) => re.test(host))?.[1] ?? null;
29
+ }
30
+
31
+ export const socialIconSvg = (path: string) =>
32
+ `<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="${path}"/></svg>`;
@@ -0,0 +1,11 @@
1
+ // Tag marker colors. Labels render as a small rounded-square marker + text
2
+ // (never a filled pill); each tag gets a stable hue hashed from its name, so
3
+ // the same tag is the same color on every page without any stored setting.
4
+ // Mid-tone hues chosen to read on both light and dark canvases.
5
+ const PALETTE = ['#3a86d6', '#2f9e6e', '#d9822b', '#8a6cf0', '#d45a8a', '#0c8599', '#c2872a', '#6b7fd7'];
6
+
7
+ export function tagColor(name: string): string {
8
+ let h = 0;
9
+ for (const c of name.toLowerCase()) h = (h * 31 + c.charCodeAt(0)) >>> 0;
10
+ return PALETTE[h % PALETTE.length];
11
+ }
package/src/lib/theme.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { ThemeManifest, sectionDefinitions } from '../theme-sdk/contract.mjs';
1
2
  import { z } from 'zod';
2
3
  import type { Settings } from './schema';
3
4
 
@@ -11,37 +12,25 @@ import type { Settings } from './schema';
11
12
  // sparse per-token overrides (settings.designOverrides) on top; resolveDesign
12
13
  // merges the two and emits CSS variables consumed by site.css.
13
14
 
14
- export const ThemeTokens = z.object({
15
- bg: z.string().default('#faf9f7'),
16
- ink: z.string().default('#1e1f24'),
17
- muted: z.string().default('#6b6e78'),
18
- line: z.string().default('#e5e3de'),
19
- accent: z.string().default('#4f46e5'),
20
- panel: z.string().default('#ffffff'),
21
- soft: z.string().default('#eceae5'),
22
- headingFont: z.string().default("'Inter', system-ui, sans-serif"),
23
- bodyFont: z.string().default("'Inter', system-ui, sans-serif"),
24
- // the `family=` query for fonts.googleapis.com/css2; empty = no webfont
25
- googleFonts: z.string().default('family=Inter:wght@400;600;700'),
26
- width: z.coerce.number().default(760),
27
- });
28
- export type ThemeTokens = z.infer<typeof ThemeTokens>;
29
-
30
- // Token keys the Design screen may override individually.
31
- export const OVERRIDE_KEYS = ['bg', 'ink', 'muted', 'line', 'accent', 'panel', 'soft', 'width'] as const;
15
+ export { ThemeTokens } from './themeTokens';
16
+ import { ThemeTokens, tokensCss, hasCustomDark } from './themeTokens';
17
+
18
+ // Token keys the Design screen may override individually. Dark-palette
19
+ // overrides use DARK_OVERRIDE_KEYS (designOverrides.darkBg → tokens.dark.bg).
20
+ export const OVERRIDE_KEYS = [
21
+ 'bg', 'ink', 'muted', 'line', 'accent', 'panel', 'soft', 'width', 'wideWidth',
22
+ 'button', 'scheme', 'radius', 'elevation', 'density', 'header', 'footer', 'blogLayout',
23
+ ] as const;
24
+ export const DARK_OVERRIDE_KEYS = {
25
+ darkBg: 'bg', darkInk: 'ink', darkMuted: 'muted', darkLine: 'line',
26
+ darkAccent: 'accent', darkPanel: 'panel', darkSoft: 'soft',
27
+ } as const;
32
28
 
33
29
  // Post-page display options. Themes set defaults in theme.json under `post`;
34
30
  // the Design screen overrides individual fields via designOverrides
35
31
  // (postCover: 'normal'|'full'|'hidden', the rest as 'show'|'hide' strings).
36
- export const PostOptions = z.object({
37
- cover: z.enum(['normal', 'full', 'overlay', 'hidden']).default('normal'),
38
- showDate: z.boolean().default(true),
39
- showReadingTime: z.boolean().default(true),
40
- byline: z.boolean().default(true),
41
- authorCard: z.boolean().default(true),
42
- share: z.boolean().default(true),
43
- related: z.boolean().default(true),
44
- });
32
+ export { PostOptions } from '../theme-sdk/contract.mjs';
33
+ import { PostOptions } from '../theme-sdk/contract.mjs';
45
34
  export type PostOptions = z.infer<typeof PostOptions>;
46
35
 
47
36
  // override key in designOverrides → PostOptions boolean field
@@ -52,11 +41,14 @@ const POST_TOGGLES: [string, keyof PostOptions][] = [
52
41
  ['postAuthorCard', 'authorCard'],
53
42
  ['postShare', 'share'],
54
43
  ['postRelated', 'related'],
44
+ ['postProgress', 'progress'],
45
+ ['postToc', 'toc'],
55
46
  ];
56
47
 
57
48
  // Curated heading/body pairings the UI can swap in (override key: fontPairing).
58
49
  // Agent-authored themes set headingFont/bodyFont/googleFonts directly instead.
59
50
  export const FONT_PAIRINGS = [
51
+ { key: 'geist', label: 'Geist (modern sans)', headingFont: "'Geist', ui-sans-serif, system-ui, sans-serif", bodyFont: "'Geist', ui-sans-serif, system-ui, sans-serif", googleFonts: 'family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500' },
60
52
  { key: 'inter', label: 'Inter (sans)', headingFont: "'Inter', system-ui, sans-serif", bodyFont: "'Inter', system-ui, sans-serif", googleFonts: 'family=Inter:wght@400;600;700' },
61
53
  { key: 'fraunces', label: 'Fraunces + Inter (editorial serif)', headingFont: "'Fraunces', Georgia, serif", bodyFont: "'Inter', system-ui, sans-serif", googleFonts: 'family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;1,9..144,400&family=Inter:wght@400;500;600' },
62
54
  { key: 'playfair', label: 'Playfair Display + Inter', headingFont: "'Playfair Display', Georgia, serif", bodyFont: "'Inter', system-ui, sans-serif", googleFonts: 'family=Playfair+Display:ital,wght@0,500;0,600;1,500&family=Inter:wght@400;500;600' },
@@ -73,15 +65,20 @@ export interface Theme {
73
65
  tokens: ThemeTokens;
74
66
  post: PostOptions;
75
67
  css: string;
68
+ version: string;
69
+ sections: ReturnType<typeof sectionDefinitions>;
70
+ sectionTemplates: Record<string, unknown>;
76
71
  // Astro component (module default) when the theme ships templates/home.astro
77
72
  HomeTemplate: unknown | null;
78
73
  // optional per-document templates: templates/post-<name>.astro and
79
74
  // templates/page-<name>.astro, selectable in the editor sidebar
80
75
  postTemplates: Record<string, unknown>;
81
76
  pageTemplates: Record<string, unknown>;
77
+ // templates/event.astro replaces the built-in event page layout
78
+ EventTemplate: unknown | null;
82
79
  }
83
80
 
84
- interface ThemeJson { name?: string; description?: string; tokens?: Record<string, unknown>; post?: Record<string, unknown> }
81
+ interface ThemeJson { sections?: Record<string, unknown>; version?: string; name?: string; description?: string; tokens?: Record<string, unknown>; post?: Record<string, unknown> }
85
82
 
86
83
  // Built-in themes ship inside the package (relative globs); project themes
87
84
  // come from the consumer's src/themes via the integration's virtual module
@@ -89,11 +86,13 @@ interface ThemeJson { name?: string; description?: string; tokens?: Record<strin
89
86
  // theme whose slug matches a built-in replaces it.
90
87
  import * as user from 'virtual:acms-user-themes';
91
88
 
89
+ const assets = import.meta.glob(['../themes/*/assets/**/*', '!**/.img-subir-originals/**'], { eager: true, query: '?url', import: 'default' }) as Record<string, string>;
92
90
  const jsons = import.meta.glob('../themes/*/theme.json', { eager: true, import: 'default' }) as Record<string, ThemeJson>;
93
91
  const csses = import.meta.glob('../themes/*/theme.css', { eager: true, query: '?raw', import: 'default' }) as Record<string, string>;
94
92
  const homes = import.meta.glob('../themes/*/templates/home.astro', { eager: true }) as Record<string, { default: unknown }>;
95
93
  const postTpls = import.meta.glob('../themes/*/templates/post-*.astro', { eager: true }) as Record<string, { default: unknown }>;
96
94
  const pageTpls = import.meta.glob('../themes/*/templates/page-*.astro', { eager: true }) as Record<string, { default: unknown }>;
95
+ const eventTpls = import.meta.glob('../themes/*/templates/event.astro', { eager: true }) as Record<string, { default: unknown }>;
97
96
 
98
97
  // works for both '../themes/<slug>/…' and '/src/themes/<slug>/…'
99
98
  const slugOf = (path: string) => /themes\/([^/]+)\//.exec(path)?.[1] ?? path.split('/')[2];
@@ -115,31 +114,46 @@ function buildThemes(
115
114
  cssMap: Record<string, string>,
116
115
  homeMap: Record<string, { default: unknown }>,
117
116
  postMap: Record<string, { default: unknown }>,
118
- pageMap: Record<string, { default: unknown }>
117
+ pageMap: Record<string, { default: unknown }>,
118
+ eventMap: Record<string, { default: unknown }> = {}
119
119
  ): Theme[] {
120
120
  return Object.entries(jsonMap).map(([path, raw]) => {
121
121
  const slug = slugOf(path);
122
+ const manifest = ThemeManifest.parse({ ...raw, name: raw.name ?? slug });
123
+ const customSections = (user as { sectionTpls?: Record<string, { default: unknown }> }).sectionTpls ?? {};
122
124
  return {
125
+ version: manifest.version,
126
+ sections: sectionDefinitions(manifest),
127
+ sectionTemplates: Object.fromEntries(Object.entries(manifest.sections).flatMap(([key, definition]) => {
128
+ const component = (definition as {component?: string}).component;
129
+ const match = component && Object.entries(customSections).find(([p]) => p.endsWith(`/themes/${slug}/${component}`));
130
+ return match ? [[key, match[1].default]] : [];
131
+ })),
123
132
  slug,
124
133
  name: raw.name ?? slug,
125
134
  description: raw.description ?? '',
126
135
  tokens: ThemeTokens.parse(raw.tokens ?? {}),
127
136
  post: PostOptions.parse(raw.post ?? {}),
128
- css: Object.entries(cssMap).find(([p]) => slugOf(p) === slug)?.[1] ?? '',
137
+ css: (Object.entries(cssMap).find(([p]) => slugOf(p) === slug)?.[1] ?? '').replace(/url\(\s*['"]?(?:\.\/)?(assets\/[^)'"\s]+)['"]?\s*\)/g, (original, file) => {
138
+ const asset = Object.entries({ ...assets, ...((user as { assets?: Record<string,string> }).assets ?? {}) }).find(([p]) => p.endsWith(`/themes/${slug}/${file}`));
139
+ return asset ? `url(${asset[1]})` : original;
140
+ }),
129
141
  HomeTemplate: Object.entries(homeMap).find(([p]) => slugOf(p) === slug)?.[1]?.default ?? null,
130
142
  postTemplates: templatesFor(slug, postMap, 'post'),
131
143
  pageTemplates: templatesFor(slug, pageMap, 'page'),
144
+ EventTemplate: Object.entries(eventMap).find(([p]) => slugOf(p) === slug)?.[1]?.default ?? null,
132
145
  };
133
146
  });
134
147
  }
135
148
 
136
- const builtins = buildThemes(jsons, csses, homes, postTpls, pageTpls);
149
+ const builtins = buildThemes(jsons, csses, homes, postTpls, pageTpls, eventTpls);
137
150
  const projectThemes = buildThemes(
138
151
  user.jsons as Record<string, ThemeJson>,
139
152
  user.csses,
140
153
  user.homes,
141
154
  user.postTpls,
142
- user.pageTpls
155
+ user.pageTpls,
156
+ (user as { eventTpls?: Record<string, { default: unknown }> }).eventTpls ?? {}
143
157
  );
144
158
  const projectSlugs = new Set(projectThemes.map((t) => t.slug));
145
159
 
@@ -160,6 +174,7 @@ export interface ResolvedDesign {
160
174
  tokens: ThemeTokens;
161
175
  post: PostOptions;
162
176
  css: string; // ready-to-inline <style> content: :root vars + theme.css
177
+ customDark: boolean; // theme/overrides supply a dark palette (vs generated)
163
178
  fontsHref: string | null;
164
179
  homeMode: 'auto' | 'content';
165
180
  HomeTemplate: unknown | null;
@@ -181,24 +196,28 @@ export function resolveDesign(settings: Settings): ResolvedDesign {
181
196
  merged.googleFonts = p.googleFonts;
182
197
  }
183
198
  }
184
- const tokens = ThemeTokens.parse(merged);
199
+ const dark: Record<string, unknown> = { ...theme.tokens.dark };
200
+ for (const [key, field] of Object.entries(DARK_OVERRIDE_KEYS)) {
201
+ const v = o[key];
202
+ if (typeof v === 'string' && v) dark[field] = v;
203
+ }
204
+ merged.dark = dark;
205
+ // a bad stored override (stale enum value, junk width) must never take the
206
+ // site down: fall back to the theme's own tokens
207
+ const parsed = ThemeTokens.safeParse(merged);
208
+ const tokens = parsed.success ? parsed.data : theme.tokens;
185
209
  const post: PostOptions = { ...theme.post };
186
210
  if (o.postCover === 'normal' || o.postCover === 'full' || o.postCover === 'overlay' || o.postCover === 'hidden') post.cover = o.postCover;
187
211
  for (const [key, field] of POST_TOGGLES) {
188
212
  if (o[key] === 'show') (post[field] as boolean) = true;
189
213
  if (o[key] === 'hide') (post[field] as boolean) = false;
190
214
  }
191
- const vars = [
192
- `--bg:${tokens.bg}`, `--ink:${tokens.ink}`, `--muted:${tokens.muted}`,
193
- `--line:${tokens.line}`, `--accent:${tokens.accent}`, `--panel:${tokens.panel}`,
194
- `--soft:${tokens.soft}`, `--font-heading:${tokens.headingFont}`,
195
- `--font-body:${tokens.bodyFont}`, `--content-width:${tokens.width}px`,
196
- ].join(';');
197
215
  return {
198
216
  theme,
199
217
  tokens,
200
218
  post,
201
- css: `:root{${vars}}\n${theme.css}`,
219
+ css: `${tokensCss(tokens)}\n${theme.css}`,
220
+ customDark: hasCustomDark(tokens),
202
221
  fontsHref: tokens.googleFonts ? `https://fonts.googleapis.com/css2?${tokens.googleFonts}&display=swap` : null,
203
222
  homeMode: o.homeMode === 'content' ? 'content' : 'auto',
204
223
  HomeTemplate: theme.HomeTemplate,
@@ -0,0 +1,174 @@
1
+ import { z } from 'zod';
2
+
3
+ // Theme token schema v2 + the CSS it compiles to. Pure (no Astro virtual
4
+ // modules), so it's unit-tested directly; lib/theme.ts wires it to the
5
+ // discovered themes and the Design-screen overrides.
6
+ //
7
+ // Every key is optional: a theme.json that sets four colors still gets a
8
+ // complete design (shape, depth, dark palette, derived colors) from the
9
+ // Ink v2 defaults below. Old (v1) theme.json files parse unchanged.
10
+
11
+ import { DarkTokens, ThemeTokens } from '../theme-sdk/tokens.mjs';
12
+ export { DarkTokens, ThemeTokens };
13
+ export type DarkTokens = z.infer<typeof DarkTokens>;
14
+ export type ThemeTokens = z.infer<typeof ThemeTokens>;
15
+
16
+ export interface Palette {
17
+ bg: string; ink: string; muted: string; line: string; accent: string; panel: string; soft: string;
18
+ }
19
+
20
+ // ---- color math (sRGB hex only; anything else passes through untouched) ----
21
+
22
+ type RGB = [number, number, number];
23
+
24
+ export function parseHex(c: string): RGB | null {
25
+ const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(c.trim());
26
+ if (!m) return null;
27
+ const h = m[1].length === 3 ? [...m[1]].map((x) => x + x).join('') : m[1];
28
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16)) as RGB;
29
+ }
30
+
31
+ const toHex = (rgb: RGB) =>
32
+ '#' + rgb.map((v) => Math.round(Math.min(255, Math.max(0, v))).toString(16).padStart(2, '0')).join('');
33
+
34
+ export function mix(a: string, b: string, t: number): string {
35
+ const x = parseHex(a), y = parseHex(b);
36
+ if (!x || !y) return a;
37
+ return toHex([0, 1, 2].map((i) => x[i] + (y[i] - x[i]) * t) as RGB);
38
+ }
39
+
40
+ export function luminance(c: string): number {
41
+ const rgb = parseHex(c);
42
+ if (!rgb) return 0.5;
43
+ const [r, g, b] = rgb.map((v) => {
44
+ const s = v / 255;
45
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
46
+ });
47
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
48
+ }
49
+
50
+ export function contrast(a: string, b: string): number {
51
+ const [l1, l2] = [luminance(a), luminance(b)].sort((x, y) => y - x);
52
+ return (l1 + 0.05) / (l2 + 0.05);
53
+ }
54
+
55
+ // Nudge `fg` toward `toward` until it reaches `min` contrast on `bg`. Used for
56
+ // accent-as-text (links, "Featured", today) so any accent a user picks stays
57
+ // readable. Unparseable colors are returned as-is.
58
+ export function ensureContrast(fg: string, bg: string, min = 4.5, toward?: string): string {
59
+ if (!parseHex(fg) || !parseHex(bg)) return fg;
60
+ const target = toward ?? (luminance(bg) > 0.4 ? '#000000' : '#ffffff');
61
+ for (let t = 0; t <= 1.0001; t += 0.04) {
62
+ const c = mix(fg, target, t);
63
+ if (contrast(c, bg) >= min) return c;
64
+ }
65
+ return target;
66
+ }
67
+
68
+ export const isDarkColor = (c: string) => luminance(c) < 0.18;
69
+
70
+ // ---- palettes ----
71
+
72
+ // A dark palette from a light one: neutral charcoal surfaces carrying a whisper
73
+ // of the light background's warmth, plus the accent lifted until it reads on
74
+ // the dark canvas.
75
+ export function autoDark(light: Palette): Palette {
76
+ const warm = (base: string, t: number) => mix(base, light.bg, t);
77
+ const bg = warm('#121316', 0.015);
78
+ return {
79
+ bg,
80
+ panel: warm('#1a1b1f', 0.02),
81
+ soft: warm('#222328', 0.02),
82
+ line: warm('#2a2b31', 0.02),
83
+ ink: warm('#eceae5', 0.2),
84
+ muted: '#9a9ca4',
85
+ accent: ensureContrast(light.accent, bg, 4.5, '#ffffff'),
86
+ };
87
+ }
88
+
89
+ export function darkPalette(t: ThemeTokens): Palette {
90
+ const light: Palette = { bg: t.bg, ink: t.ink, muted: t.muted, line: t.line, accent: t.accent, panel: t.panel, soft: t.soft };
91
+ const gen = autoDark(light);
92
+ const d = t.dark ?? {};
93
+ return {
94
+ bg: d.bg || gen.bg, ink: d.ink || gen.ink, muted: d.muted || gen.muted, line: d.line || gen.line,
95
+ accent: d.accent || gen.accent, panel: d.panel || gen.panel, soft: d.soft || gen.soft,
96
+ };
97
+ }
98
+
99
+ // true when the theme ships its own dark palette (vs. the generated one) —
100
+ // the Design screen flags generated palettes so authors know to check them
101
+ export const hasCustomDark = (t: ThemeTokens) => Object.values(t.dark ?? {}).some(Boolean);
102
+
103
+ // ---- CSS ----
104
+
105
+ const RADII: Record<ThemeTokens['radius'], [number, number, number]> = {
106
+ sharp: [3, 4, 6],
107
+ soft: [6, 8, 12],
108
+ round: [10, 16, 24],
109
+ smooth: [12, 20, 28],
110
+ };
111
+
112
+ const DENSITY: Record<ThemeTokens['density'], number> = { compact: 0.85, cozy: 1, airy: 1.18 };
113
+
114
+ function shadows(elevation: ThemeTokens['elevation'], dark: boolean): [string, string, string] {
115
+ if (elevation === 'flat') return ['none', 'none', dark ? '0 16px 40px -16px rgb(0 0 0 / 70%)' : '0 16px 40px -16px rgb(0 0 0 / 22%)'];
116
+ const k = elevation === 'lifted' ? 1.6 : 1;
117
+ const a = (x: number) => `${Math.min(90, Math.round(x * k * (dark ? 5 : 1)))}%`;
118
+ const c = dark ? '0 0 0' : '23 24 28';
119
+ return [
120
+ `0 1px 2px rgb(${c} / ${a(5)})`,
121
+ `0 1px 2px rgb(${c} / ${a(4)}), 0 8px 24px -12px rgb(${c} / ${a(14)})`,
122
+ `0 2px 6px rgb(${c} / ${a(6)}), 0 24px 48px -20px rgb(${c} / ${a(28)})`,
123
+ ];
124
+ }
125
+
126
+ // Per-scheme variables: the palette, derived colors and shadows. Derived
127
+ // values that need a contrast guarantee are computed here; purely cosmetic
128
+ // ones use color-mix() so theme.css overrides of a base token cascade.
129
+ export function paletteVars(p: Palette, t: ThemeTokens, dark: boolean): string[] {
130
+ const accentInk = t.accentInk || (contrast('#ffffff', p.accent) >= 4.5 ? '#ffffff' : ensureContrast('#111214', p.accent, 4.5));
131
+ const [s1, s2, s3] = shadows(t.elevation, dark);
132
+ const btnInk = t.button === 'accent' ? 'var(--accent-ink)' : 'var(--bg)';
133
+ return [
134
+ `--bg:${p.bg}`, `--ink:${p.ink}`, `--muted:${ensureContrast(p.muted, p.bg, 4.5)}`,
135
+ `--line:${p.line}`, `--accent:${p.accent}`, `--panel:${p.panel}`, `--soft:${p.soft}`,
136
+ `--accent-ink:${accentInk}`,
137
+ `--accent-text:${ensureContrast(p.accent, p.bg, 4.5)}`,
138
+ `--btn:${t.button === 'accent' ? 'var(--accent)' : 'var(--ink)'}`, `--btn-ink:${btnInk}`,
139
+ `--code-bg:${dark ? '#0d0e11' : '#1b1c21'}`,
140
+ `--success:${dark ? '#5cc98e' : '#127442'}`, `--danger:${dark ? '#f07b7b' : '#b42f2f'}`, `--warning:${dark ? '#e3b456' : '#8a5f0a'}`,
141
+ `--sh-1:${s1}`, `--sh-2:${s2}`, `--sh-3:${s3}`,
142
+ `color-scheme:${dark ? 'dark' : 'light'}`,
143
+ ];
144
+ }
145
+
146
+ // Scheme-independent variables: type, shape, rhythm, layout widths.
147
+ export function staticVars(t: ThemeTokens): string[] {
148
+ const [r1, r2, r3] = RADII[t.radius];
149
+ return [
150
+ `--font-heading:${t.headingFont}`, `--font-body:${t.bodyFont}`, `--font-mono:${t.monoFont}`,
151
+ `--display-weight:${t.displayWeight}`, `--display-tracking:${t.displayTracking}`,
152
+ `--base-size:${t.baseSize}px`,
153
+ `--r-sm:${r1}px`, `--r:${r2}px`, `--r-lg:${r3}px`,
154
+ `--space:${DENSITY[t.density]}`,
155
+ `--content-width:${t.width}px`, `--wide-width:${t.wideWidth}px`,
156
+ ];
157
+ }
158
+
159
+ // The inline <style> body for the tokens: light and dark blocks wired to
160
+ // data-scheme (visitor toggle) and prefers-color-scheme (OS default).
161
+ export function tokensCss(t: ThemeTokens): string {
162
+ const light: Palette = { bg: t.bg, ink: t.ink, muted: t.muted, line: t.line, accent: t.accent, panel: t.panel, soft: t.soft };
163
+ const dark = darkPalette(t);
164
+ const stat = staticVars(t).join(';');
165
+ const L = paletteVars(light, t, false).join(';');
166
+ const D = paletteVars(dark, t, true).join(';');
167
+ if (t.scheme === 'light') return `:root{${stat};${L}}`;
168
+ if (t.scheme === 'dark') return `:root{${stat};${D}}`;
169
+ return [
170
+ `:root{${stat};${L}}`,
171
+ `:root[data-scheme=dark]{${D}}`,
172
+ `@media (prefers-color-scheme:dark){:root:not([data-scheme=light]){${D}}}`,
173
+ ].join('\n');
174
+ }
@@ -0,0 +1,108 @@
1
+ // Read-side Umami client for the admin dashboard and content rows. The public
2
+ // site only *sends* to Umami (lib/analytics.ts); reading stats needs an API
3
+ // credential that never reaches the browser:
4
+ // UMAMI_API_KEY — Umami Cloud API key (x-umami-api-key)
5
+ // UMAMI_API_TOKEN — self-hosted bearer token (from POST /api/auth/login)
6
+ // The API base is the tracker script's origin + /api (UMAMI_API_URL overrides).
7
+ // Every call fails soft: no credential, a network error, or an unexpected
8
+ // shape returns null and the admin simply hides the numbers.
9
+ import type { Settings } from './schema';
10
+ import { env } from './env';
11
+ import { UMAMI_ID_RE, normalizeScriptUrl } from './analytics';
12
+
13
+ export interface TrafficSummary {
14
+ visitors: number;
15
+ visitorsPrev: number;
16
+ pageviews: number;
17
+ pageviewsPrev: number;
18
+ avgSeconds: number;
19
+ series: { t: number; views: number; visitors: number }[];
20
+ topPaths: { path: string; views: number }[];
21
+ }
22
+
23
+ const TTL = 10 * 60_000;
24
+ const cache = new Map<string, { at: number; value: TrafficSummary | null }>();
25
+
26
+ function config(settings: Settings) {
27
+ const u = settings.analytics?.umami;
28
+ if (!u?.enabled || !UMAMI_ID_RE.test(u.websiteId)) return null;
29
+ const key = env('UMAMI_API_KEY');
30
+ const token = env('UMAMI_API_TOKEN');
31
+ if (!key && !token) return null;
32
+ let base = env('UMAMI_API_URL') || '';
33
+ if (!base) {
34
+ const src = normalizeScriptUrl(u.scriptUrl);
35
+ if (!src) return null;
36
+ const origin = new URL(src).origin;
37
+ base = origin === 'https://cloud.umami.is' ? 'https://api.umami.is/v1' : `${origin}/api`;
38
+ }
39
+ const headers: Record<string, string> = { accept: 'application/json' };
40
+ if (key) headers['x-umami-api-key'] = key;
41
+ else headers.authorization = `Bearer ${token}`;
42
+ return { base: base.replace(/\/+$/, ''), id: u.websiteId.toLowerCase(), headers, tz: settings.timezone || 'UTC' };
43
+ }
44
+
45
+ export function umamiReadEnabled(settings: Settings): boolean {
46
+ return config(settings) !== null;
47
+ }
48
+
49
+ // Umami ≥2.13 returns { pageviews: 12, comparison: { pageviews: 9 } };
50
+ // older versions return { pageviews: { value: 12, prev: 9 } }.
51
+ function stat(body: any, name: string): [number, number] {
52
+ const v = body?.[name];
53
+ if (v && typeof v === 'object') return [Number(v.value) || 0, Number(v.prev) || 0];
54
+ return [Number(v) || 0, Number(body?.comparison?.[name]) || 0];
55
+ }
56
+
57
+ export async function getTraffic(settings: Settings, days = 30): Promise<TrafficSummary | null> {
58
+ const c = config(settings);
59
+ if (!c) return null;
60
+ const cacheKey = `${c.id}:${days}`;
61
+ const hit = cache.get(cacheKey);
62
+ if (hit && Date.now() - hit.at < TTL) return hit.value;
63
+
64
+ const endAt = Date.now();
65
+ const startAt = endAt - days * 86_400_000;
66
+ const q = `startAt=${startAt}&endAt=${endAt}`;
67
+ const get = async (path: string) => {
68
+ const res = await fetch(`${c.base}/websites/${c.id}/${path}`, { headers: c.headers, signal: AbortSignal.timeout(4000) });
69
+ if (!res.ok) throw new Error(`umami ${res.status}`);
70
+ return res.json();
71
+ };
72
+ let value: TrafficSummary | null = null;
73
+ try {
74
+ const [stats, pv, urls] = await Promise.all([
75
+ get(`stats?${q}`),
76
+ get(`pageviews?${q}&unit=day&timezone=${encodeURIComponent(c.tz)}`),
77
+ get(`metrics?${q}&type=url&limit=50`).catch(() => []),
78
+ ]);
79
+ const [pageviews, pageviewsPrev] = stat(stats, 'pageviews');
80
+ const [visitors, visitorsPrev] = stat(stats, 'visitors');
81
+ const [visits] = stat(stats, 'visits');
82
+ const [totaltime] = stat(stats, 'totaltime');
83
+ const views = new Map<number, number>();
84
+ const sess = new Map<number, number>();
85
+ for (const p of pv?.pageviews ?? []) views.set(new Date(p.x).getTime(), Number(p.y) || 0);
86
+ for (const p of pv?.sessions ?? []) sess.set(new Date(p.x).getTime(), Number(p.y) || 0);
87
+ const series = [...views.keys()].sort((a, b) => a - b).map((t) => ({ t, views: views.get(t) ?? 0, visitors: sess.get(t) ?? 0 }));
88
+ value = {
89
+ visitors,
90
+ visitorsPrev,
91
+ pageviews,
92
+ pageviewsPrev,
93
+ avgSeconds: visits ? Math.round(totaltime / visits) : 0,
94
+ series,
95
+ topPaths: (Array.isArray(urls) ? urls : []).map((u: any) => ({ path: String(u.x ?? ''), views: Number(u.y) || 0 })),
96
+ };
97
+ } catch {
98
+ value = null;
99
+ }
100
+ cache.set(cacheKey, { at: Date.now(), value });
101
+ return value;
102
+ }
103
+
104
+ /** views per public path over the window, for content rows */
105
+ export async function viewsByPath(settings: Settings): Promise<Map<string, number>> {
106
+ const t = await getTraffic(settings, 30);
107
+ return new Map((t?.topPaths ?? []).map((p) => [p.path.replace(/\/+$/, '') || '/', p.views]));
108
+ }