@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.
- package/CHANGELOG.md +106 -0
- package/cli/common.mjs +35 -0
- package/cli/legacy.mjs +317 -0
- package/cli/main.mjs +66 -0
- package/cli/palette.mjs +4 -0
- package/cli/preview.mjs +50 -0
- package/cli/remote.mjs +55 -0
- package/cli/themes.mjs +127 -0
- package/cli.mjs +2 -315
- package/deploy/Caddyfile +8 -0
- package/deploy/Dockerfile +34 -0
- package/deploy/compose.https.yml +21 -0
- package/deploy/compose.yml +46 -0
- package/docs/theme-authoring.md +103 -0
- package/email-runner.mjs +32 -0
- package/integration.mjs +193 -2
- package/ops-check.mjs +27 -0
- package/package.json +35 -12
- package/src/actions/index.ts +744 -38
- package/src/components/AiCleaner.tsx +291 -0
- package/src/components/AiSettings.tsx +575 -0
- package/src/components/CommandPalette.tsx +200 -0
- package/src/components/ContentList.astro +246 -0
- package/src/components/DesignAccess.tsx +182 -0
- package/src/components/DesignManager.tsx +455 -47
- package/src/components/EmailSettings.tsx +521 -0
- package/src/components/EventPage.astro +178 -0
- package/src/components/LegalPage.astro +27 -0
- package/src/components/LinksManager.tsx +216 -97
- package/src/components/NavManager.tsx +83 -38
- package/src/components/ThemeScript.astro +50 -0
- package/src/components/ThemeSections.astro +57 -0
- package/src/components/ThemeSectionsEditor.tsx +513 -0
- package/src/components/booking/BookingFlow.astro +150 -0
- package/src/components/booking/HoursEditor.astro +63 -0
- package/src/components/builder/PageBuilder.tsx +10 -2
- package/src/components/editor/EventPanel.tsx +401 -0
- package/src/components/editor/FormatDock.tsx +81 -0
- package/src/components/editor/LivePane.tsx +122 -0
- package/src/components/editor/MobileToolbar.tsx +93 -0
- package/src/components/editor/PostEditor.tsx +826 -271
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/cards.tsx +17 -1
- package/src/components/editor/extensions/codeBlock.tsx +1 -1
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/infoCards.tsx +16 -1
- package/src/components/editor/extensions/mediaCards.tsx +36 -2
- package/src/components/editor/extensions/slash.tsx +77 -9
- package/src/components/editor/extensions/toc.tsx +89 -0
- package/src/components/editor/extensions/toggle.tsx +70 -0
- package/src/components/site/Pager.astro +24 -0
- package/src/components/site/PostCard.astro +64 -0
- package/src/components/site/TagLabel.astro +11 -0
- package/src/layouts/Admin.astro +289 -49
- package/src/layouts/Site.astro +331 -57
- package/src/lib/agent-api.ts +13 -0
- package/src/lib/agent-discovery.ts +40 -0
- package/src/lib/agent.ts +114 -0
- package/src/lib/ai-config.ts +58 -0
- package/src/lib/ai-schema.ts +22 -0
- package/src/lib/ai.ts +90 -0
- package/src/lib/analytics.ts +121 -0
- package/src/lib/api-keys.ts +65 -0
- package/src/lib/api.ts +52 -0
- package/src/lib/auth.ts +25 -5
- package/src/lib/backup/content.ts +36 -0
- package/src/lib/backup/unzip.ts +12 -1
- package/src/lib/backup/zip.ts +1 -1
- package/src/lib/booking/agent.ts +108 -0
- package/src/lib/booking/availability.ts +221 -0
- package/src/lib/booking/emails.ts +115 -0
- package/src/lib/booking/forms.ts +77 -0
- package/src/lib/booking/service.ts +277 -0
- package/src/lib/booking/view.ts +54 -0
- package/src/lib/calendar/agent-schema.ts +48 -0
- package/src/lib/calendar/agent.ts +136 -0
- package/src/lib/calendar/api.ts +81 -0
- package/src/lib/calendar/cards.ts +102 -0
- package/src/lib/calendar/config.ts +52 -0
- package/src/lib/calendar/data.ts +130 -0
- package/src/lib/calendar/events.ts +261 -0
- package/src/lib/calendar/feeds.ts +43 -0
- package/src/lib/calendar/hooks.ts +38 -0
- package/src/lib/calendar/http.ts +30 -0
- package/src/lib/calendar/ics-write.ts +120 -0
- package/src/lib/calendar/ics.ts +15 -5
- package/src/lib/calendar/import.ts +203 -0
- package/src/lib/calendar/render.ts +120 -37
- package/src/lib/calendar/rrule.ts +149 -0
- package/src/lib/calendar/seo.ts +48 -0
- package/src/lib/design-service.ts +58 -0
- package/src/lib/designDiff.ts +145 -0
- package/src/lib/designPreview.ts +112 -0
- package/src/lib/documents/cleanup.ts +31 -0
- package/src/lib/documents/extract.ts +154 -0
- package/src/lib/documents/vendor.d.ts +4 -0
- package/src/lib/drafts.ts +131 -0
- package/src/lib/dynamicBlocks.ts +79 -7
- package/src/lib/email/accounts.ts +55 -0
- package/src/lib/email/config.ts +86 -0
- package/src/lib/email/migration.ts +24 -0
- package/src/lib/email/providers.ts +89 -0
- package/src/lib/email/service.ts +78 -0
- package/src/lib/email/store.ts +167 -0
- package/src/lib/email/templates.ts +18 -0
- package/src/lib/email/types.ts +32 -0
- package/src/lib/email/webhooks.ts +87 -0
- package/src/lib/email.ts +15 -0
- package/src/lib/env.ts +1 -1
- package/src/lib/legal/index.ts +25 -0
- package/src/lib/mobile/bookings.ts +73 -0
- package/src/lib/mobile/calendar.ts +85 -0
- package/src/lib/mobile/content.ts +85 -0
- package/src/lib/mobile/http.ts +63 -0
- package/src/lib/mobile/route.ts +20 -0
- package/src/lib/ops.ts +8 -0
- package/src/lib/preview.ts +21 -1
- package/src/lib/repo/migrations.ts +254 -0
- package/src/lib/repo/sql.ts +956 -46
- package/src/lib/repo/types.ts +140 -3
- package/src/lib/scheduler.ts +25 -0
- package/src/lib/schema.ts +362 -0
- package/src/lib/search-i18n.ts +3 -0
- package/src/lib/socialIcons.ts +32 -0
- package/src/lib/tagColor.ts +11 -0
- package/src/lib/theme.ts +59 -40
- package/src/lib/themeTokens.ts +174 -0
- package/src/lib/umami.ts +108 -0
- package/src/lib/webhooks.ts +139 -0
- package/src/middleware.ts +36 -3
- package/src/pages/404.astro +23 -0
- package/src/pages/[...slug].astro +10 -5
- package/src/pages/account/[action].astro +53 -0
- package/src/pages/admin/ai.astro +12 -0
- package/src/pages/admin/c/[collection]/[id].astro +14 -4
- package/src/pages/admin/c/[collection]/index.astro +127 -91
- package/src/pages/admin/calendar/bookings.astro +400 -0
- package/src/pages/admin/calendar/calendars.astro +265 -0
- package/src/pages/admin/calendar/events/[id].astro +56 -0
- package/src/pages/admin/calendar/events/index.astro +408 -0
- package/src/pages/admin/calendar/index.ts +4 -0
- package/src/pages/admin/calendar/integrations.astro +221 -0
- package/src/pages/admin/calendar/resources/[id].astro +180 -0
- package/src/pages/admin/calendar/resources/index.astro +106 -0
- package/src/pages/admin/calendar/resources/link.astro +196 -0
- package/src/pages/admin/design.astro +16 -4
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +237 -87
- package/src/pages/admin/links.astro +3 -3
- package/src/pages/admin/login.astro +14 -10
- package/src/pages/admin/navigation.astro +8 -2
- package/src/pages/admin/pages/[id].astro +14 -3
- package/src/pages/admin/pages/index.astro +41 -47
- package/src/pages/admin/settings.astro +475 -276
- package/src/pages/admin/tags.astro +4 -4
- package/src/pages/admin/users.astro +38 -28
- package/src/pages/api/agent/docs.ts +33 -0
- package/src/pages/api/agent/index.ts +21 -0
- package/src/pages/api/ai/clean.ts +13 -0
- package/src/pages/api/ai/draft.ts +14 -0
- package/src/pages/api/ai/settings.ts +53 -0
- package/src/pages/api/ai/test.ts +12 -0
- package/src/pages/api/backup.ts +8 -21
- package/src/pages/api/calendar/availability.ts +35 -0
- package/src/pages/api/calendar/bookings/[id].ts +49 -0
- package/src/pages/api/calendar/bookings/index.ts +73 -0
- package/src/pages/api/calendar/calendars.ts +35 -0
- package/src/pages/api/calendar/docs.ts +64 -0
- package/src/pages/api/calendar/events/[id].ts +63 -0
- package/src/pages/api/calendar/events/index.ts +112 -0
- package/src/pages/api/calendar/resources.ts +34 -0
- package/src/pages/api/design/capabilities.ts +10 -0
- package/src/pages/api/design/index.ts +58 -0
- package/src/pages/api/design/keys.ts +24 -0
- package/src/pages/api/design/media.ts +32 -0
- package/src/pages/api/email/run.ts +11 -0
- package/src/pages/api/email/settings.ts +61 -0
- package/src/pages/api/email/webhooks/[provider].ts +4 -0
- package/src/pages/api/mcp.ts +59 -0
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/ops/status.ts +39 -0
- package/src/pages/api/restore.ts +27 -37
- package/src/pages/api/search.ts +18 -4
- package/src/pages/api/v1/availability.ts +7 -0
- package/src/pages/api/v1/bookings.ts +17 -0
- package/src/pages/api/v1/calendars.ts +7 -0
- package/src/pages/api/v1/content/[id].ts +7 -0
- package/src/pages/api/v1/content/index.ts +7 -0
- package/src/pages/api/v1/events.ts +7 -0
- package/src/pages/api/v1/resources.ts +7 -0
- package/src/pages/author/[slug].astro +43 -35
- package/src/pages/blog/[slug].astro +140 -66
- package/src/pages/blog/index.astro +95 -30
- package/src/pages/blog/tag/[slug].astro +45 -40
- package/src/pages/book/[resource]/[link].astro +70 -0
- package/src/pages/book/[resource]/index.astro +63 -0
- package/src/pages/book/[resource].ics.ts +38 -0
- package/src/pages/book/index.astro +47 -0
- package/src/pages/book/manage/[token].astro +108 -0
- package/src/pages/calendar/[slug].ics.ts +27 -0
- package/src/pages/calendar/feed.ics.ts +15 -0
- package/src/pages/calendar/index.astro +191 -0
- package/src/pages/embed/book/[resource]/[link].astro +33 -0
- package/src/pages/embed/calendar/[slug].astro +43 -0
- package/src/pages/events/[slug].astro +74 -0
- package/src/pages/events/[slug].ics.ts +25 -0
- package/src/pages/index.astro +9 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +33 -7
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +34 -8
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +928 -105
- package/src/styles/cards.css +101 -60
- package/src/styles/editor.css +532 -102
- package/src/styles/email-settings.css +117 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -278
- package/src/styles/theme-editor.css +135 -0
- package/src/theme-sdk/contract.d.mts +619 -0
- package/src/theme-sdk/contract.mjs +80 -0
- package/src/theme-sdk/index.ts +15 -0
- package/src/theme-sdk/tokens.d.mts +148 -0
- package/src/theme-sdk/tokens.mjs +52 -0
- package/src/themes/editorial/templates/home.astro +4 -4
- package/src/themes/editorial/templates/post-minimal.astro +2 -2
- package/src/themes/editorial/theme.css +22 -37
- package/src/themes/editorial/theme.json +23 -3
- package/src/themes/field/theme.json +26 -9
- package/src/themes/ink/theme.json +30 -12
- package/src/themes/night/theme.css +4 -7
- package/src/themes/night/theme.json +27 -10
- package/src/virtual.d.ts +14 -0
- 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
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
panel
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
37
|
-
|
|
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
|
|
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:
|
|
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
|
+
}
|
package/src/lib/umami.ts
ADDED
|
@@ -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
|
+
}
|