@acms-subir/core 0.2.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 +78 -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 -369
- 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 +182 -2
- package/ops-check.mjs +27 -0
- package/package.json +27 -5
- package/src/actions/index.ts +575 -47
- 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 +418 -46
- 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 +114 -57
- package/src/components/NavManager.tsx +42 -43
- 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 +605 -223
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/slash.tsx +7 -1
- 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 +324 -59
- 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/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 +110 -12
- 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 +60 -6
- package/src/lib/dynamicBlocks.ts +40 -8
- 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 +217 -0
- package/src/lib/repo/sql.ts +853 -60
- package/src/lib/repo/types.ts +122 -5
- package/src/lib/scheduler.ts +25 -0
- package/src/lib/schema.ts +309 -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 +8 -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 +4 -1
- package/src/pages/admin/c/[collection]/index.astro +127 -92
- 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 +10 -3
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +232 -85
- 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 +4 -1
- package/src/pages/admin/pages/index.astro +41 -48
- package/src/pages/admin/settings.astro +418 -367
- 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 +137 -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 +6 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +23 -3
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +27 -6
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +924 -105
- package/src/styles/cards.css +92 -74
- package/src/styles/editor.css +492 -114
- package/src/styles/email-settings.css +117 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -281
- 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
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
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Signed outgoing webhooks. emitWebhook() queues one delivery row per
|
|
2
|
+
// subscribed endpoint and tries it immediately (in the background where the
|
|
3
|
+
// runtime allows); failures retry with backoff from the cron sweep
|
|
4
|
+
// (processDueDeliveries, run by the Worker's scheduled handler and by the
|
|
5
|
+
// middleware sweep on Node). Every request carries:
|
|
6
|
+
// X-Acms-Event: event.published
|
|
7
|
+
// X-Acms-Delivery: <delivery id>
|
|
8
|
+
// X-Acms-Timestamp: <unix seconds>
|
|
9
|
+
// X-Acms-Signature: sha256=<hex HMAC-SHA256 of "<timestamp>.<body>">
|
|
10
|
+
// Receivers verify by recomputing the HMAC with the endpoint secret and
|
|
11
|
+
// rejecting stale timestamps.
|
|
12
|
+
import type { ContentRepository } from './repo/types';
|
|
13
|
+
import type { Webhook, WebhookDelivery, WebhookEventType } from './schema';
|
|
14
|
+
import { isPrivateHost } from './net';
|
|
15
|
+
|
|
16
|
+
export const MAX_ATTEMPTS = 4;
|
|
17
|
+
// minutes until attempt n+1 (after the first immediate try)
|
|
18
|
+
const BACKOFF_MIN = [1, 10, 60];
|
|
19
|
+
|
|
20
|
+
const hex = (buf: ArrayBuffer) => [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
21
|
+
|
|
22
|
+
export async function signPayload(secret: string, timestamp: number, body: string): Promise<string> {
|
|
23
|
+
const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
|
|
24
|
+
return `sha256=${hex(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`${timestamp}.${body}`)))}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function generateWebhookSecret(): string {
|
|
28
|
+
return `whsec_${hex(crypto.getRandomValues(new Uint8Array(24)).buffer)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Endpoint URLs must be public http(s): a webhook must never become a way to
|
|
32
|
+
// make the server call its own network. Localhost is allowed only in dev.
|
|
33
|
+
export function validateWebhookUrl(raw: string, allowPrivate = false): string {
|
|
34
|
+
let url: URL;
|
|
35
|
+
try {
|
|
36
|
+
url = new URL(raw.trim());
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error('Enter a full URL starting with https://.');
|
|
39
|
+
}
|
|
40
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('Webhook URLs must use http(s).');
|
|
41
|
+
if (url.username || url.password) throw new Error('Put credentials in the receiver, not the URL.');
|
|
42
|
+
if (!allowPrivate && isPrivateHost(url.hostname)) throw new Error('Webhook URLs must point at a public host.');
|
|
43
|
+
return url.href;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
type Fetcher = (url: string, init: RequestInit) => Promise<Response>;
|
|
47
|
+
|
|
48
|
+
export interface EmitOptions {
|
|
49
|
+
now?: number;
|
|
50
|
+
fetcher?: Fetcher;
|
|
51
|
+
// Workers: ctx.waitUntil, so the response isn't held for delivery
|
|
52
|
+
waitUntil?: (p: Promise<unknown>) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function emitWebhook(
|
|
56
|
+
repo: ContentRepository,
|
|
57
|
+
event: WebhookEventType,
|
|
58
|
+
data: Record<string, unknown>,
|
|
59
|
+
opts: EmitOptions = {}
|
|
60
|
+
): Promise<WebhookDelivery[]> {
|
|
61
|
+
const hooks = (await repo.listWebhooks()).filter((h) => h.active && (h.events.includes(event) || h.events.includes('*')));
|
|
62
|
+
if (!hooks.length) return [];
|
|
63
|
+
const now = opts.now ?? Date.now();
|
|
64
|
+
const rows: WebhookDelivery[] = hooks.map((h) => {
|
|
65
|
+
const id = crypto.randomUUID();
|
|
66
|
+
return {
|
|
67
|
+
id, webhookId: h.id, event,
|
|
68
|
+
payload: JSON.stringify({ id, type: event, createdAt: new Date(now).toISOString(), data }),
|
|
69
|
+
status: 'pending', attempts: 0, nextAt: now, responseCode: null, createdAt: now, deliveredAt: null,
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
await repo.createDeliveries(rows);
|
|
73
|
+
const work = Promise.all(rows.map((d) => attemptDelivery(repo, d, hooks.find((h) => h.id === d.webhookId)!, opts))).catch(() => {});
|
|
74
|
+
if (opts.waitUntil) opts.waitUntil(work);
|
|
75
|
+
else await work;
|
|
76
|
+
return rows;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function attemptDelivery(repo: ContentRepository, d: WebhookDelivery, hook: Webhook, opts: EmitOptions = {}): Promise<void> {
|
|
80
|
+
const now = opts.now ?? Date.now();
|
|
81
|
+
const fetcher: Fetcher = opts.fetcher ?? ((url, init) => fetch(url, init));
|
|
82
|
+
const ts = Math.floor(now / 1000);
|
|
83
|
+
let code: number | null = null;
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetcher(hook.url, {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
redirect: 'manual',
|
|
88
|
+
headers: {
|
|
89
|
+
'content-type': 'application/json',
|
|
90
|
+
'user-agent': 'acms-webhooks/1.0',
|
|
91
|
+
'x-acms-event': d.event,
|
|
92
|
+
'x-acms-delivery': d.id,
|
|
93
|
+
'x-acms-timestamp': String(ts),
|
|
94
|
+
'x-acms-signature': await signPayload(hook.secret, ts, d.payload),
|
|
95
|
+
},
|
|
96
|
+
body: d.payload,
|
|
97
|
+
signal: AbortSignal.timeout(8000),
|
|
98
|
+
});
|
|
99
|
+
code = res.status;
|
|
100
|
+
await res.body?.cancel().catch(() => {});
|
|
101
|
+
} catch {
|
|
102
|
+
code = null; // unreachable / timeout
|
|
103
|
+
}
|
|
104
|
+
const attempts = d.attempts + 1;
|
|
105
|
+
const ok = code !== null && code >= 200 && code < 300;
|
|
106
|
+
const exhausted = attempts >= MAX_ATTEMPTS;
|
|
107
|
+
await repo.recordDelivery(d.id, {
|
|
108
|
+
status: ok ? 'delivered' : exhausted ? 'failed' : 'pending',
|
|
109
|
+
attempts,
|
|
110
|
+
nextAt: ok || exhausted ? null : now + BACKOFF_MIN[Math.min(attempts - 1, BACKOFF_MIN.length - 1)] * 60_000,
|
|
111
|
+
responseCode: code,
|
|
112
|
+
deliveredAt: ok ? now : null,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// retry sweep: due pending deliveries, oldest first, bounded per run
|
|
117
|
+
export async function processDueDeliveries(repo: ContentRepository, opts: EmitOptions = {}): Promise<number> {
|
|
118
|
+
const now = opts.now ?? Date.now();
|
|
119
|
+
const due = await repo.listDueDeliveries(now, 25);
|
|
120
|
+
if (!due.length) return 0;
|
|
121
|
+
const hooks = new Map((await repo.listWebhooks()).map((h) => [h.id, h]));
|
|
122
|
+
let n = 0;
|
|
123
|
+
for (const d of due) {
|
|
124
|
+
const hook = hooks.get(d.webhookId);
|
|
125
|
+
if (!hook || !hook.active) {
|
|
126
|
+
await repo.recordDelivery(d.id, { status: 'failed', attempts: d.attempts, nextAt: null, responseCode: null, deliveredAt: null });
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
await attemptDelivery(repo, d, hook, { ...opts, now });
|
|
130
|
+
n++;
|
|
131
|
+
}
|
|
132
|
+
return n;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// the request's background-work hook, when the runtime has one (Workers)
|
|
136
|
+
export function waitUntilFrom(locals: unknown): ((p: Promise<unknown>) => void) | undefined {
|
|
137
|
+
const ctx = (locals as { runtime?: { ctx?: { waitUntil?: (p: Promise<unknown>) => void } } } | undefined)?.runtime?.ctx;
|
|
138
|
+
return ctx?.waitUntil ? (p) => ctx.waitUntil!(p) : undefined;
|
|
139
|
+
}
|