@acms-subir/core 0.3.10 → 0.3.12

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 (97) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +2 -1
  4. package/cli/main.mjs +11 -3
  5. package/cli/migrate.mjs +102 -0
  6. package/cli/migrations.generated.mjs +1044 -0
  7. package/cli/preview.mjs +1 -1
  8. package/deploy/Dockerfile +5 -1
  9. package/deploy/compose.yml +21 -0
  10. package/docs/live-video.md +1 -1
  11. package/docs/theme-authoring.md +31 -0
  12. package/integration.mjs +39 -0
  13. package/migrate.mjs +5 -0
  14. package/package.json +9 -11
  15. package/src/actions/index.ts +5 -5
  16. package/src/components/DesignManager.tsx +29 -10
  17. package/src/components/EmailSettings.tsx +53 -11
  18. package/src/components/ThemeTransfer.tsx +211 -0
  19. package/src/components/auth/ClerkSession.astro +80 -0
  20. package/src/components/auth/ReverifyForms.astro +61 -0
  21. package/src/components/auth/clerk.ts +169 -0
  22. package/src/components/import/ImportExport.tsx +16 -6
  23. package/src/components/import/runner.ts +3 -0
  24. package/src/layouts/Admin.astro +40 -9
  25. package/src/layouts/Site.astro +6 -1
  26. package/src/lib/api.ts +6 -3
  27. package/src/lib/auth/accounts.ts +94 -0
  28. package/src/lib/auth/config.ts +22 -0
  29. package/src/lib/auth/login-limit.ts +15 -0
  30. package/src/lib/auth/request.ts +161 -0
  31. package/src/lib/auth/store.ts +72 -0
  32. package/src/lib/auth.ts +3 -0
  33. package/src/lib/backup/archive.ts +26 -0
  34. package/src/lib/backup/offsite.ts +173 -0
  35. package/src/lib/backup/store.ts +28 -0
  36. package/src/lib/cache/config.ts +7 -0
  37. package/src/lib/cache/memory.ts +39 -0
  38. package/src/lib/cache/policy.ts +89 -0
  39. package/src/lib/cache/purge.ts +89 -0
  40. package/src/lib/designPreview.ts +2 -2
  41. package/src/lib/dynamicBlocks.ts +1 -1
  42. package/src/lib/editor/auth.ts +35 -5
  43. package/src/lib/editor/http.ts +4 -1
  44. package/src/lib/editor/store.ts +13 -7
  45. package/src/lib/editor/token-route.ts +2 -1
  46. package/src/lib/email/accounts.ts +4 -0
  47. package/src/lib/email/service.ts +1 -0
  48. package/src/lib/email/templates.ts +2 -1
  49. package/src/lib/email/types.ts +1 -0
  50. package/src/lib/mobile/http.ts +4 -3
  51. package/src/lib/mobile/route.ts +41 -4
  52. package/src/lib/net.ts +82 -14
  53. package/src/lib/repo/migrate-runner.ts +96 -0
  54. package/src/lib/repo/migrations.ts +76 -0
  55. package/src/lib/repo/sql.ts +56 -24
  56. package/src/lib/repo/sqlite.ts +14 -1
  57. package/src/lib/repo/types.ts +12 -1
  58. package/src/lib/scheduler.ts +7 -0
  59. package/src/lib/theme-transfer.ts +163 -0
  60. package/src/lib/theme.ts +5 -0
  61. package/src/lib/webhooks.ts +7 -2
  62. package/src/middleware.ts +41 -11
  63. package/src/pages/[...slug].astro +3 -2
  64. package/src/pages/account/[action].astro +6 -0
  65. package/src/pages/admin/app-auth.astro +21 -7
  66. package/src/pages/admin/design.astro +2 -1
  67. package/src/pages/admin/import.astro +5 -3
  68. package/src/pages/admin/index.astro +3 -2
  69. package/src/pages/admin/integrations.astro +3 -2
  70. package/src/pages/admin/login.astro +236 -25
  71. package/src/pages/admin/logout.ts +16 -2
  72. package/src/pages/admin/settings.astro +31 -5
  73. package/src/pages/admin/users.astro +350 -59
  74. package/src/pages/api/backup.ts +10 -26
  75. package/src/pages/api/design/export.ts +72 -0
  76. package/src/pages/api/design/import.ts +41 -0
  77. package/src/pages/api/email/settings.ts +9 -5
  78. package/src/pages/api/live/oauth/youtube/callback.ts +5 -4
  79. package/src/pages/api/live/settings.ts +2 -2
  80. package/src/pages/api/media/upload.ts +2 -2
  81. package/src/pages/api/push/admin.ts +3 -2
  82. package/src/pages/api/restore.ts +6 -3
  83. package/src/pages/api/v1/calendars.ts +1 -1
  84. package/src/pages/api/v1/links.ts +1 -1
  85. package/src/pages/api/v1/openapi.json.ts +1 -1
  86. package/src/pages/api/v1/push/topics.ts +1 -1
  87. package/src/pages/api/v1/resources.ts +1 -1
  88. package/src/pages/api/v1/theme.ts +1 -1
  89. package/src/pages/blog/[slug].astro +3 -2
  90. package/src/pages/events/[slug].astro +3 -2
  91. package/src/pages/preview/[type]/[id].astro +3 -3
  92. package/src/styles/admin.css +103 -16
  93. package/src/styles/email-settings.css +6 -0
  94. package/src/theme-sdk/calendar.ts +48 -0
  95. package/src/theme-sdk/index.ts +2 -0
  96. package/src/virtual.d.ts +1 -0
  97. package/src/worker.ts +7 -1
package/cli/preview.mjs CHANGED
@@ -17,7 +17,7 @@ export async function prepareFixture(p,dir,slug){
17
17
  await writeFile(join(root,'package.json'),JSON.stringify({name:'acms-isolated-preview',type:'module',private:true}));
18
18
  await writeFile(join(root,'astro.config.mjs'),`import {defineConfig,passthroughImageService} from 'astro/config';\nimport react from '@astrojs/react';\nimport node from '@astrojs/node';\nimport cloudflare from '@astrojs/cloudflare';\nimport acms from '@acms-subir/core';\nexport default defineConfig({output:'server',vite:{resolve:{preserveSymlinks:true}},integrations:[react(),acms({seed:{siteTitle:'Theme preview — synthetic content'}})],adapter:process.env.DEPLOY_TARGET==='cloudflare'?cloudflare({platformProxy:{enabled:false},imageService:'passthrough'}):node({mode:'standalone'}),image:{service:passthroughImageService()}});\n`);
19
19
  await writeFile(join(root,'src/pages/acms-theme-preview/setup.astro'),`---
20
- import {getRepo} from '@acms-subir/core/lib/repo/index';
20
+ import {getRepo} from ${JSON.stringify(join(CORE,'src/lib/repo/index.ts'))};
21
21
  const repo=await getRepo(Astro.locals);
22
22
  let home=await repo.getPageBySlug('preview-home');
23
23
  if(!home){
package/deploy/Dockerfile CHANGED
@@ -25,10 +25,14 @@ ENV NODE_ENV=production HOST=0.0.0.0 PORT=4321
25
25
  RUN npm install --no-save --no-audit --no-fund "@libsql/client@^0.15.0"
26
26
  COPY --from=build /app/dist ./dist
27
27
  COPY --from=build /app/node_modules/@acms-subir/core/email-runner.mjs ./email-runner.mjs
28
+ # acms migrate: applies pending migrations under the database lock before
29
+ # the server starts (the app's own first-request migration is the fallback)
30
+ COPY --from=build /app/node_modules/@acms-subir/core/migrate.mjs ./acms/migrate.mjs
31
+ COPY --from=build /app/node_modules/@acms-subir/core/cli/migrate.mjs /app/node_modules/@acms-subir/core/cli/migrations.generated.mjs ./acms/cli/
28
32
  # ./data holds the SQLite database and uploaded media — mount a volume here.
29
33
  # Only data/ needs to be writable (a chown -R of /app would copy-on-write
30
34
  # duplicate every node_modules file into a fresh layer)
31
35
  RUN mkdir -p data && chown node:node data
32
36
  USER node
33
37
  EXPOSE 4321
34
- CMD ["node", "./dist/server/entry.mjs"]
38
+ CMD ["sh", "-c", "node ./acms/migrate.mjs && exec node ./dist/server/entry.mjs"]
@@ -14,8 +14,29 @@ services:
14
14
  - PORT=4321
15
15
  - ADMIN_PASSWORD=${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
16
16
  - SESSION_SECRET=${SESSION_SECRET:?set SESSION_SECRET in .env}
17
+ # Optional Clerk staff authentication (provider selected in astro.config).
18
+ - PUBLIC_CLERK_PUBLISHABLE_KEY=${PUBLIC_CLERK_PUBLISHABLE_KEY:-}
19
+ - CLERK_SECRET_KEY=${CLERK_SECRET_KEY:-}
20
+ - CLERK_AUTHORIZED_PARTIES=${CLERK_AUTHORIZED_PARTIES:-}
21
+ - CLERK_JWT_KEY=${CLERK_JWT_KEY:-}
22
+ - CLERK_OWNER_USER_ID=${CLERK_OWNER_USER_ID:-}
23
+ - CLERK_OWNER_LOCAL_USER_ID=${CLERK_OWNER_LOCAL_USER_ID:-}
17
24
  - EMAIL_RUNNER_SECRET=${EMAIL_RUNNER_SECRET:?set EMAIL_RUNNER_SECRET in .env}
18
25
  - EMAIL_ENCRYPTION_KEY=${EMAIL_ENCRYPTION_KEY:-}
26
+ # Optional: behind the Cloudflare proxy, clear its cache on every content
27
+ # change (token needs Zone → Cache Purge). See docs/caching.md.
28
+ - CLOUDFLARE_ZONE_ID=${CLOUDFLARE_ZONE_ID:-}
29
+ - CLOUDFLARE_API_TOKEN=${CLOUDFLARE_API_TOKEN:-}
30
+ # Optional scheduled off-site backups to S3-compatible storage (R2,
31
+ # Oracle Object Storage, AWS S3). See docs/production-deployment.md.
32
+ - BACKUP_S3_ENDPOINT=${BACKUP_S3_ENDPOINT:-}
33
+ - BACKUP_S3_BUCKET=${BACKUP_S3_BUCKET:-}
34
+ - BACKUP_S3_REGION=${BACKUP_S3_REGION:-}
35
+ - BACKUP_S3_ACCESS_KEY_ID=${BACKUP_S3_ACCESS_KEY_ID:-}
36
+ - BACKUP_S3_SECRET_ACCESS_KEY=${BACKUP_S3_SECRET_ACCESS_KEY:-}
37
+ - BACKUP_PREFIX=${BACKUP_PREFIX:-}
38
+ - BACKUP_KEEP=${BACKUP_KEEP:-}
39
+ - BACKUP_EVERY_HOURS=${BACKUP_EVERY_HOURS:-}
19
40
  - SQLITE_URL=file:./data/acms.db
20
41
  healthcheck:
21
42
  test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4321/api/v1/content?limit=1').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
@@ -45,7 +45,7 @@ Rate-limit/quota errors back off. The UI reports estimated workload and provider
45
45
  - **Design → Pages:** add a **Live video** section; choose `card` or `player`, preview and publish using the existing design flow.
46
46
  - **Links:** add a **Live video** block and save. This is a compact card referencing the same site-wide status.
47
47
  - **Older widget pages:** add a **Live video** widget and choose its display mode. Rich-text-only/custom coded homepages must opt into a Design section or explicitly render the component.
48
- - **Custom themes:** import `LiveCard.astro` from `@acms-subir/core/components/LiveCard.astro`, or use the built-in `live` section. ACMS never injects a new placement automatically into a custom template. If a custom route renders the component, set `Astro.response.headers.set('Cache-Control', 'no-store')` in that route's frontmatter; the built-in page routes handle this for you.
48
+ - **Custom themes:** import `LiveCard.astro` from `@acms-subir/core/theme/components/LiveCard.astro`, or use the built-in `live` section. ACMS never injects a new placement automatically into a custom template. If a custom route renders the component, set `Astro.response.headers.set('Cache-Control', 'no-store')` in that route's frontmatter; the built-in page routes handle this for you.
49
49
 
50
50
  ### How it looks
51
51
 
@@ -34,6 +34,31 @@ const { page, posts } = Astro.props;
34
34
 
35
35
  Public types: ChromeProps (site), HomeProps (settings, page, posts, tags), PageProps (settings, page, html), PostProps (settings, post, html, authors, related), EventProps (settings, event, occurrence, calendars, html, upcoming, origin, preview, rsvp), SectionProps (settings, page, variant, data). Public components: PostCard with card/row/feature variants, Pager with base/page/count, TagLabel with name/href/accent. Helpers include collectionUrl, postUrl, postsIndexUrl, tagUrl, formatDate, slugify, authorSlug, localeFromPath, localeHomeUrl and CONTENT. Use collectionUrl for non-default collections; postUrl is the built-in posts alias.
36
36
 
37
+ ### Upcoming events in coded templates
38
+
39
+ Use the server-side calendar helpers from the public theme entry point. Replace imports from `@acms-subir/core/lib/repo/*` and `@acms-subir/core/lib/calendar/*`; internal folders are not package exports.
40
+
41
+ ```astro
42
+ ---
43
+ import { upcomingEvents, calendarConfig, eventUrl } from '@acms-subir/core/theme';
44
+ const calendar = calendarConfig();
45
+ const upcoming = await upcomingEvents(Astro, { limit: 4, horizonDays: 90 });
46
+ const date = new Intl.DateTimeFormat('en', { dateStyle: 'medium', timeZone: Astro.props.settings.timezone || 'UTC' });
47
+ ---
48
+ {calendar.enabled && <section>
49
+ <a href={calendar.base}>Calendar</a>
50
+ {upcoming.map(({ event, start }) => (
51
+ <a href={eventUrl(event.slug)}>
52
+ {event.title} <time datetime={new Date(start).toISOString()}>{date.format(start)}</time>
53
+ </a>
54
+ ))}
55
+ </section>}
56
+ ```
57
+
58
+ `calendarConfig()` returns a fresh `{ enabled, base, eventsBase }` object; `eventUrl(slug)` uses the configured event base. `upcomingEvents(Astro, options?)` resolves storage through `Astro.locals` and loads the site's timezone settings. It returns the next or ongoing occurrence of each distinct published, publicly listed native event, soonest first, including recurring and all-day events. Private/unlisted calendars and external feed entries are excluded; cancelled public events remain visible so templates can label them. Disabled calendars and a zero limit return `[]` without accessing storage. Storage errors propagate to the template.
59
+
60
+ Options default to `{ limit: 6, horizonDays: 400 }`. The limit must be a non-negative safe integer and the horizon a positive finite number. Each `UpcomingEvent` contains `{ event, start, end, allDay, days }`, with start/end in Unix milliseconds. Format occurrence timestamps using the site's timezone rather than the authored `event.start` when displaying recurring events. `UpcomingEvent`, `UpcomingEventsOptions`, and `ThemeCalendarConfig` are exported types.
61
+
37
62
  Template filenames: `home.astro`, `event.astro`, `header.astro`, `footer.astro`, `post-<name>.astro`, `page-<name>.astro`.
38
63
 
39
64
  `header.astro` and `footer.astro` replace the site header and footer on every public page. They receive `ChromeProps` — one `site` prop (`SiteChrome`) with everything already resolved for the request: `title`, `tagline`, `logo`, `logoDark`, `homeUrl`, `links` (header nav, each `{ label, url, current, external }`, already localized), `footerLinks`, `social` (with inline SVG `icon`), `readLinks`, `footerHtml`, `languages` (`{ code, label, url, current }`, where `label` is the language's own name, e.g. "Español"), `search` (`{ url, label }` or null), `schemeToggle`, `liveHeader`, `headerStyle`, `footerStyle`. Core behaviors attach through data hooks, so keep them on your own elements: `data-search-open` (opens search), `data-scheme-toggle` (light/dark), `data-menu-open` (phone menu sheet, built from `site.links`) and a hidden `data-live-header` link (shown during a live stream).
@@ -91,6 +116,12 @@ The theme API version covers documented template/section contracts and public co
91
116
 
92
117
  `theme diff` compares against the creation snapshot; `--baseline <folder>` selects another version. Runtime settings/navigation differences are available in `design pull`.
93
118
 
119
+ ## Moving a theme between sites or machines
120
+
121
+ Design → **Export** downloads the design as the Design screen shows it (draft over live) as one `<slug>-<date>.acms-theme.zip`: `manifest.json` (format `acms-theme` v1, theme slug/name/version, core version), `design.json` (token overrides, logo/dark logo/favicon/cover, section layouts keyed by page slug, link targets by URL, and navigation only when ticked) and `media/<key>` with `media.json` for the images it uses. It never contains posts, page text, keys or other settings. Design → **Import** previews the file against the receiving site, uploads its images (kept by id, so `/media/` URLs stay valid), and stages everything as a draft; publish it from Review as usual. Layouts land on the page with the same slug (the exported home page falls back to this site's home); pages with no match are reported and skipped. Section links follow the item at the same URL, otherwise keep their typed address. Owners only; editors cannot export or import.
122
+
123
+ Theme **code** is not in the file: it is compiled into the build. When the file names a theme this site lacks, the import keeps the current theme and says so. Move `src/themes/<slug>/` with git, or `acms theme pack` / `acms theme install`, then rebuild. The same endpoints are scriptable with a scoped key: `GET /api/design/export` (`design:read`, plus `media:read` unless `?media=0`; `?nav=1` adds menus) and `POST /api/design/import` with `{ revision, design, nav?, dryRun? }` (`design:write-draft`).
124
+
94
125
  ## Sharing, versions and rollback
95
126
 
96
127
  `theme pack` produces a deterministic theme-only tgz. `theme install` validates the archive before writing, rejects traversal/links/unexpected files/incompatible core versions, and never runs install scripts. Use `--force` for intentional replacement: the previous directory is retained in `.acms/theme-backups/`. `.acms/themes.lock.json` records the pinned version, digest and backup path. Copy a retained backup to a new theme identity or re-pack/install it to roll code back; rebuild and redeploy. No automatic download of theme dependencies occurs.
package/integration.mjs CHANGED
@@ -88,6 +88,8 @@ const ROUTES = [
88
88
  ['/api/design/keys', 'api/design/keys.ts'],
89
89
  ['/api/design/capabilities', 'api/design/capabilities.ts'],
90
90
  ['/api/design/media', 'api/design/media.ts'],
91
+ ['/api/design/export', 'api/design/export.ts'],
92
+ ['/api/design/import', 'api/design/import.ts'],
91
93
  ['/api/design/ai', 'api/design/ai.ts'],
92
94
  ['/api/design/translate', 'api/design/translate.ts'],
93
95
  ['/api/agent', 'api/agent/index.ts'],
@@ -235,6 +237,28 @@ function resolveI18n(i18n, contentBases) {
235
237
  // Defaults + validation for acms({ search }). Search ships on; disabling it
236
238
  // removes the /search page(s), /api/search and /api/v1/search from the route table, and the
237
239
  // layout skips the header trigger + modal (lib/search-config.ts).
240
+ // acms({ cache }): CDN caching of public pages and the public app API.
241
+ // `false` turns it off (every response is private, no-store); `memory` is the
242
+ // Node-only in-process cache of public API data, keyed by content version.
243
+ function resolveCache(cache) {
244
+ const defaults = { enabled: true, maxAge: 60, staleWhileRevalidate: 86400, memory: true };
245
+ if (cache === undefined || cache === true) return defaults;
246
+ if (cache === false) return { ...defaults, enabled: false, memory: false };
247
+ if (!cache || typeof cache !== 'object' || Array.isArray(cache)) throw new Error('acms: cache must be false or { maxAge?, staleWhileRevalidate?, memory? }');
248
+ const unknown = Object.keys(cache).filter((k) => !['maxAge', 'staleWhileRevalidate', 'memory'].includes(k));
249
+ if (unknown.length > 0) {
250
+ throw new Error(`acms: unknown cache key${unknown.length > 1 ? 's' : ''} ${unknown.map((k) => `"${k}"`).join(', ')} — valid keys are maxAge, staleWhileRevalidate and memory`);
251
+ }
252
+ const out = { ...defaults, ...cache };
253
+ for (const k of ['maxAge', 'staleWhileRevalidate']) {
254
+ if (!Number.isInteger(out[k]) || out[k] < 0 || out[k] > 31_536_000) throw new Error(`acms: cache.${k} must be a whole number of seconds (0–31536000)`);
255
+ }
256
+ if (typeof out.memory !== 'boolean') throw new Error('acms: cache.memory must be true or false');
257
+ // maxAge 0 means "don't let the CDN store anything" — same as cache: false
258
+ if (out.maxAge === 0) return { ...out, enabled: false };
259
+ return out;
260
+ }
261
+
238
262
  function resolveSearch(search) {
239
263
  if (search === undefined) return { enabled: true };
240
264
  const unknown = Object.keys(search).filter((k) => k !== 'enabled');
@@ -493,6 +517,15 @@ function acmsVitePlugin(userConfig) {
493
517
  * pasted map links still work.
494
518
  * @param {false | {enabled?: boolean, operatorName?: string, contactEmail?: string, updatedAt?: string}} [options.legal]
495
519
  * Default privacy/terms routes and footer links; published CMS pages at those slugs override the text.
520
+ * @param {{provider?: 'builtin' | 'clerk'}} [options.auth] Staff authentication provider. Defaults to builtin.
521
+ * @param {false | {maxAge?: number, staleWhileRevalidate?: number, memory?: boolean}} [options.cache]
522
+ * Shared caching of public pages and the public app API (`/api/v1/*`):
523
+ * `Cache-Control: public, s-maxage=<maxAge>, stale-while-revalidate=<staleWhileRevalidate>`
524
+ * (defaults 60 and 86400). Admin, previews, signed-in visitors, bookings,
525
+ * forms and every non-GET request stay `private, no-store`. Every content
526
+ * change clears the cache (Workers Cache, or the Cloudflare zone when
527
+ * CLOUDFLARE_ZONE_ID + CLOUDFLARE_API_TOKEN are set). `memory: false` turns
528
+ * off the Node in-process API cache. `cache: false` turns all of it off.
496
529
  * @returns {import('astro').AstroIntegration}
497
530
  */
498
531
  export default function acms(options = {}) {
@@ -503,6 +536,7 @@ export default function acms(options = {}) {
503
536
  ]);
504
537
  const i18n = resolveI18n(options.i18n, contentBases);
505
538
  const search = resolveSearch(options.search);
539
+ const cache = resolveCache(options.cache);
506
540
  const mobile = resolveMobile(options.mobile);
507
541
  const calendar = resolveCalendar(options.calendar, contentBases, i18n.locales);
508
542
  const maps = resolveMaps(options.maps);
@@ -510,13 +544,18 @@ export default function acms(options = {}) {
510
544
  if (typeof legal.enabled !== 'boolean' || ['operatorName','contactEmail','updatedAt'].some(k => typeof legal[k] !== 'string')) throw new Error('acms: invalid legal configuration');
511
545
  if (legal.contactEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(legal.contactEmail)) throw new Error('acms: legal.contactEmail must be an email address');
512
546
  if (legal.updatedAt && (!/^\d{4}-\d{2}-\d{2}$/.test(legal.updatedAt) || !Number.isFinite(Date.parse(legal.updatedAt)) || new Date(legal.updatedAt).toISOString().slice(0,10) !== legal.updatedAt)) throw new Error('acms: legal.updatedAt must be a valid YYYY-MM-DD date');
547
+ const auth = { provider: options.auth?.provider === undefined ? 'builtin' : options.auth.provider };
548
+ if (options.auth !== undefined && (!options.auth || typeof options.auth !== 'object' || Array.isArray(options.auth) || Object.keys(options.auth).some(k => k !== 'provider'))) throw new Error('acms: auth accepts only provider');
549
+ if (!['builtin', 'clerk'].includes(auth.provider)) throw new Error('acms: auth.provider must be builtin or clerk');
513
550
  const userConfig = {
551
+ auth,
514
552
  coreVersion: CORE_VERSION,
515
553
  brand: options.brand ?? {},
516
554
  seed: options.seed ?? {},
517
555
  content,
518
556
  i18n,
519
557
  search,
558
+ cache,
520
559
  mobile,
521
560
  calendar,
522
561
  maps,
package/migrate.mjs ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ // acms-migrate: apply pending schema migrations under the database lock.
3
+ // The Docker image runs this before the server; see cli/migrate.mjs.
4
+ import { main } from './cli/migrate.mjs';
5
+ await main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.10",
3
+ "version": "0.3.12",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -24,22 +24,16 @@
24
24
  "bin": {
25
25
  "acms": "cli.mjs",
26
26
  "acms-email-runner": "email-runner.mjs",
27
- "acms-check-deployment": "ops-check.mjs"
27
+ "acms-check-deployment": "ops-check.mjs",
28
+ "acms-migrate": "migrate.mjs"
28
29
  },
29
30
  "exports": {
30
31
  ".": "./integration.mjs",
31
32
  "./package.json": "./package.json",
32
33
  "./actions": "./src/actions/index.ts",
33
- "./middleware": "./src/middleware.ts",
34
- "./worker": "./src/worker.ts",
35
- "./lib/*": "./src/lib/*",
36
- "./pages/*": "./src/pages/*",
37
- "./components/*": "./src/components/*",
38
- "./layouts/*": "./src/layouts/*",
39
- "./styles/*": "./src/styles/*",
40
- "./themes/*": "./src/themes/*",
41
34
  "./theme": "./src/theme-sdk/index.ts",
42
35
  "./theme/schema": "./src/theme-sdk/contract.mjs",
36
+ "./theme/components/LiveCard.astro": "./src/components/LiveCard.astro",
43
37
  "./theme/components/*": "./src/components/site/*"
44
38
  },
45
39
  "files": [
@@ -50,12 +44,14 @@
50
44
  "email-runner.mjs",
51
45
  "deploy",
52
46
  "ops-check.mjs",
47
+ "migrate.mjs",
53
48
  "cli",
54
49
  "docs",
55
50
  "section-library",
56
51
  "mobile-template"
57
52
  ],
58
53
  "dependencies": {
54
+ "@clerk/backend": "^3.22.0",
59
55
  "@cloudflare/workers-types": "^5.20260927.1",
60
56
  "@dnd-kit/core": "^6.3.1",
61
57
  "@dnd-kit/sortable": "^10.0.0",
@@ -109,10 +105,12 @@
109
105
  "@types/react-dom": "^19.2.4",
110
106
  "@types/sanitize-html": "^2.16.1",
111
107
  "@types/turndown": "^5.0.6",
108
+ "esbuild": "^0.28.1",
112
109
  "miniflare": "^5.20260801.0-alpha",
113
110
  "vitest": "^3.2.0"
114
111
  },
115
112
  "scripts": {
116
- "test": "vitest run"
113
+ "test": "vitest run",
114
+ "gen:migrations": "node scripts/gen-migrations.mjs"
117
115
  }
118
116
  }
@@ -7,12 +7,12 @@ import { z } from 'astro:schema';
7
7
  import type { ActionAPIContext } from 'astro:actions';
8
8
  import { getRepo } from '../lib/repo';
9
9
  import { getMediaStore } from '../lib/media/store';
10
- import { validateSession, SESSION_COOKIE } from '../lib/auth';
10
+ import { validateRequestSession } from '../lib/auth/request';
11
11
  import { DocTranslations, LinkKind, parseConfig, slugify, widgetTypes, type WidgetType } from '../lib/schema';
12
12
  import { env } from '../lib/env';
13
13
  import { CONTENT, collectionUrl } from '../lib/content';
14
14
  import { invalidateRedirectCache } from '../middleware';
15
- import { isPrivateHost } from '../lib/net';
15
+ import { resolvesToPrivate } from '../lib/net';
16
16
  import { normalizeCalendarSrc } from '../lib/calendar/src';
17
17
  import { saveCalendar } from '../lib/calendar/admin';
18
18
  import { getCalendarEvents } from '../lib/calendar/ics';
@@ -50,7 +50,7 @@ async function designChanges(repo: Awaited<ReturnType<typeof getRepo>>) {
50
50
  // All admin mutations live here. The middleware only guards /admin pages,
51
51
  // so every action re-checks the session itself.
52
52
  async function requireAdmin(ctx: ActionAPIContext) {
53
- if (!(await validateSession(ctx.cookies.get(SESSION_COOKIE)?.value, ctx.locals))) {
53
+ if (!(await validateRequestSession(ctx.request, ctx.cookies, ctx.locals))) {
54
54
  throw new ActionError({ code: 'UNAUTHORIZED', message: 'Not signed in.' });
55
55
  }
56
56
  }
@@ -537,7 +537,7 @@ export const server = {
537
537
  applyDesign: defineAction({
538
538
  input: z.object({}),
539
539
  handler: async (_input, ctx) => {
540
- const userId = await validateSession(ctx.cookies.get(SESSION_COOKIE)?.value, ctx.locals);
540
+ const userId = await validateRequestSession(ctx.request, ctx.cookies, ctx.locals);
541
541
  if (!userId) throw new ActionError({ code: 'UNAUTHORIZED', message: 'Not signed in.' });
542
542
  const repo = await getRepo(ctx.locals);
543
543
  const changes = await designChanges(repo);
@@ -776,7 +776,7 @@ export const server = {
776
776
  let res: Response | null = null;
777
777
  for (let hop = 0; hop < 5; hop++) {
778
778
  const u = new URL(current);
779
- if (!/^https?:$/.test(u.protocol) || isPrivateHost(u.hostname)) {
779
+ if (!/^https?:$/.test(u.protocol) || await resolvesToPrivate(u.hostname)) {
780
780
  throw new ActionError({ code: 'BAD_REQUEST', message: 'That URL can’t be bookmarked.' });
781
781
  }
782
782
  res = await fetch(u.href, {
@@ -7,12 +7,13 @@ import ThemeSectionsEditor, { SectionsReview, useDesignApi, type SectionFocus }
7
7
  import { checkLayouts, type Check } from './sections/checks';
8
8
  import { EMPTY_TARGETS } from './sections/SectionFields';
9
9
  import DesignAccess from './DesignAccess';
10
+ import ThemeTransfer, { type ThemeTransferHandle } from './ThemeTransfer';
10
11
  import '../styles/theme-editor.css';
11
12
 
12
13
  // Design screen: pick a code-first theme (src/themes/<slug>/), layer sparse
13
14
  // token overrides on top (saved to settings.designOverrides — the theme files
14
- // are never written to), set the site logo, and export the effective design
15
- // as a committable theme.json.
15
+ // are never written to), set the site logo, and export/import the design as
16
+ // an .acms-theme.zip (ThemeTransfer) or the effective tokens as theme.json.
16
17
  //
17
18
  // Every change stages into settings.draftDesign — the preview iframe renders
18
19
  // the staged design (?designPreview=1, admin-only) while visitors keep the
@@ -30,6 +31,8 @@ interface ThemeInfo {
30
31
  version: string;
31
32
  templateCount: number;
32
33
  sectionCount: number;
34
+ /** from the project's src/themes/ (its code lives in the site repo) */
35
+ project: boolean;
33
36
  }
34
37
 
35
38
  // post-page toggles: designOverrides key + which theme default backs it
@@ -147,6 +150,8 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
147
150
  const [faviconPickerOpen, setFaviconPickerOpen] = useState(false);
148
151
  const [coverPickerOpen, setCoverPickerOpen] = useState(false);
149
152
  const [copied, setCopied] = useState(false);
153
+ const transferRef = useRef<ThemeTransferHandle>(null);
154
+ const [justPublished, setJustPublished] = useState(false);
150
155
  const [tick, setTick] = useState(0);
151
156
  const [previewPage, setPreviewPage] = useState<'home' | 'post'>('home');
152
157
  // open on the device you're most likely designing for: desktop on a wide
@@ -301,6 +306,7 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
301
306
  setPending(false);
302
307
  setChanges([]);
303
308
  setVersions(null);
309
+ setJustPublished(true);
304
310
  setTick((t) => t + 1);
305
311
  void designApi.reload();
306
312
  };
@@ -348,6 +354,12 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
348
354
  location.reload();
349
355
  };
350
356
 
357
+ // export/import read the stored draft: land queued edits first
358
+ const flushAll = async () => {
359
+ if (timer.current) { clearTimeout(timer.current); timer.current = null; if (!(await doSave())) return false; }
360
+ return flushSections.current();
361
+ };
362
+
351
363
  const discardDesign = async () => {
352
364
  if (!window.confirm('Discard the staged design changes and go back to the live design? This cannot be undone.')) return;
353
365
  if (timer.current) { clearTimeout(timer.current); timer.current = null; }
@@ -433,7 +445,11 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
433
445
  <span className={`lm-savestate ${saveState}`}>
434
446
  {saveState === 'saving' ? 'Saving…' : saveState === 'error' ? (saveMessage || 'Save failed') : null}
435
447
  </span>
436
- {!editorOnly && <button className="btn sm ghost" type="button" onClick={() => void openHistory()}>History</button>}
448
+ {!editorOnly && <>
449
+ <button className="btn sm ghost" type="button" onClick={() => transferRef.current?.openImport()} title="Bring in a theme exported from another site or machine">Import</button>
450
+ <button className="btn sm ghost" type="button" onClick={() => transferRef.current?.openExport()} title="Download this design as a theme file">Export</button>
451
+ <button className="btn sm ghost" type="button" onClick={() => void openHistory()}>History</button>
452
+ </>}
437
453
  </div>
438
454
  </div>
439
455
 
@@ -442,7 +458,9 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
442
458
  {hasDraft ? (
443
459
  <><span className="dm-st is-draft">Draft</span><span className="dm-st-sub">· {changes.length > 0 ? `${changes.length} ${changes.length === 1 ? 'change' : 'changes'}` : 'page layouts'}, not live</span></>
444
460
  ) : (
445
- <><span className="dm-st is-live">Live</span><span className="dm-st-sub">· no unpublished changes</span></>
461
+ <><span className="dm-st is-live">Live</span><span className="dm-st-sub">· {justPublished && !editorOnly
462
+ ? <>published. Working on another machine? <button className="dm-resetlink" type="button" onClick={() => transferRef.current?.openExport()}>Export this theme</button></>
463
+ : 'no unpublished changes'}</span></>
446
464
  )}
447
465
  <span className="dm-status-actions">
448
466
  {overrideCount > 0 && !editorOnly && (
@@ -633,7 +651,7 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
633
651
  <span className="dm-theme-name">{t.name}</span>
634
652
  <span className="dm-theme-desc">{t.description.split(' — ')[0]}</span>
635
653
  <span className="dm-chip">v{t.version} · {t.sectionCount} sections</span>
636
- <span className="dm-theme-desc">{t.templateCount} custom templates · installed</span>
654
+ <span className="dm-theme-desc">{t.templateCount} custom templates · {t.project ? 'project theme' : 'built-in'}</span>
637
655
  </button>
638
656
  ))}
639
657
  </div>
@@ -789,13 +807,12 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
789
807
 
790
808
  <div className="dm-row">
791
809
  <div className="dm-lab">
792
- <span>Export</span>
793
- <small>Effective design (theme + your overrides) as a committable <code>theme.json</code></small>
810
+ <span>Move this theme</span>
811
+ <small>Export your design as one file and import it on another site or machine</small>
794
812
  </div>
795
813
  <div className="dm-ctl">
796
- <button className="btn sm" type="button" onClick={copyThemeJson}>
797
- {copied ? 'Copied ✓' : 'Copy as theme.json'}
798
- </button>
814
+ <button className="btn sm" type="button" onClick={() => transferRef.current?.openExport()}>Export…</button>
815
+ <button className="btn sm" type="button" onClick={() => transferRef.current?.openImport()}>Import…</button>
799
816
  </div>
800
817
  </div>
801
818
  </div>
@@ -1032,6 +1049,8 @@ export default function DesignManager({ settings, pendingDesign, themes, pairing
1032
1049
  </div>
1033
1050
  </dialog>
1034
1051
 
1052
+ <ThemeTransfer ref={transferRef} theme={theme} flush={flushAll} onCopyThemeJson={copyThemeJson} copied={copied} />
1053
+
1035
1054
  <ImagePicker
1036
1055
  open={logoPickerOpen}
1037
1056
  unsplashEnabled={unsplashEnabled}
@@ -1,11 +1,14 @@
1
1
  import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
2
2
  import type { Credentials, EmailConfig, PublicEmailSettings } from '../lib/email/types';
3
+ import { checkReverified, signInAgain } from './auth/clerk';
3
4
  import '../styles/email-settings.css';
4
5
 
5
6
  // Settings → Email: Setup, Notifications, Templates, Activity over /api/email/settings.
6
7
  // Secrets are write-only: inputs start empty, blank keeps the saved value, and a
7
8
  // successful save wipes password and secret fields. Nothing goes to browser
8
9
  // storage, nothing polls, and no email is sent unless the owner types an address.
10
+ // Saves are confirmed with the current password, or, when the site signs in
11
+ // with Clerk, by a sign-in from the last ten minutes.
9
12
 
10
13
  type Choice = EmailConfig['provider'];
11
14
  type Job = PublicEmailSettings['activity'][number];
@@ -72,11 +75,12 @@ async function api<T>(body?: unknown, signal?: AbortSignal): Promise<T> {
72
75
  body: body === undefined ? undefined : JSON.stringify(body),
73
76
  });
74
77
  const data = await res.json().catch(() => null);
75
- if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText || 'Request failed'}`);
78
+ if (!res.ok) throw Object.assign(new Error(data?.error || `${res.status} ${res.statusText || 'Request failed'}`), { status: res.status });
76
79
  if (!data) throw new Error('The server sent an empty response.');
77
80
  return data as T;
78
81
  }
79
82
  const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
83
+ const refused = (e: unknown) => (e as { status?: number } | null)?.status === 403;
80
84
  const validAddress = (s: string) => s.length <= 254 && /^[^\s<>@,;]+@[^\s<>@,;]+\.[^\s<>@,;]+$/.test(s);
81
85
  // mirrors the server's parseFrom: `address` or `Name <address>`
82
86
  function validSender(raw: string) {
@@ -115,6 +119,8 @@ export default function EmailSettings() {
115
119
  const [notifyMsg, setNotifyMsg] = useState(NO_MSG);
116
120
  const [testMsg, setTestMsg] = useState(NO_MSG);
117
121
  const [actMsg, setActMsg] = useState(NO_MSG);
122
+ // Clerk: the sign-in is too old for a save; offer to sign in again
123
+ const [stale, setStale] = useState(false);
118
124
 
119
125
  const live = (c = ctl.current) => !!c && !c.signal.aborted;
120
126
 
@@ -175,6 +181,22 @@ export default function EmailSettings() {
175
181
  const hbStale = !data.heartbeat || Date.now() - data.heartbeat > 15 * 60000;
176
182
  const set = <K extends keyof EmailConfig>(k: K, v: EmailConfig[K]) => setForm((f) => f && { ...f, [k]: v });
177
183
  const savedOff = data.config.provider === 'none' || !data.config.enabled;
184
+ const clerk = data.authProvider === 'clerk';
185
+
186
+ // Clerk: check for a recent sign-in before saving, keeping the edits on the
187
+ // page when it's too old. If Clerk can't be reached the save still goes
188
+ // through and the server decides.
189
+ async function confirmIdentity(fail: (err: string) => void) {
190
+ if (!clerk) return true;
191
+ const r = await checkReverified();
192
+ if (!live()) return false;
193
+ if (r === 'needed') { setStale(true); fail('Saving needs a sign-in from the last ten minutes. Nothing was saved; your edits are still here.'); return false; }
194
+ setStale(false);
195
+ return true;
196
+ }
197
+ function reSignIn() {
198
+ if (confirm('Sign in again? You’ll come back to this page, but unsaved edits here are cleared.')) void signInAgain();
199
+ }
178
200
 
179
201
  function pickProvider(next: Choice) {
180
202
  setCreds({});
@@ -194,31 +216,33 @@ export default function EmailSettings() {
194
216
  if (f.from && !validSender(f.from)) return fail('Sender must be an address or Name <address>.');
195
217
  if (f.replyTo && !validSender(f.replyTo)) return fail('Reply-to must be an address or Name <address>.');
196
218
  if (f.provider === 'mailgun' && !usesEnv && !/^[a-z0-9.-]+$/i.test(f.domain)) return fail('Enter the Mailgun sending domain, like mg.example.com.');
197
- if (!setupPw) return fail('Enter your current password to save.');
219
+ if (!clerk && !setupPw) return fail('Enter your current password to save.');
198
220
  const credentials: Credentials = {};
199
221
  if (!usesEnv) for (const c of CREDS[f.provider]) { const v = creds[c.key]?.trim(); if (v) credentials[c.key] = v; }
200
222
  await run('save', async () => {
201
223
  try {
202
- const next = await api<PublicEmailSettings>({ action: 'save', config: f, credentials, reauthPassword: setupPw });
224
+ if (!await confirmIdentity(fail)) return;
225
+ const next = await api<PublicEmailSettings>({ action: 'save', config: f, credentials, reauthPassword: clerk ? '' : setupPw });
203
226
  if (!live()) return;
204
227
  adopt(next); setTested(false);
205
228
  setSetupMsg({
206
229
  ok: next.configured ? 'Saved. The configuration is valid; send a test to check the connection.' : `Saved, but email isn’t ready yet: ${next.error || 'no provider is configured.'}`,
207
230
  err: '',
208
231
  });
209
- } catch (e) { if (live()) { setSetupPw(''); fail(errText(e)); } }
232
+ } catch (e) { if (live()) { setSetupPw(''); fail(errText(e)); setStale(clerk && refused(e)); } }
210
233
  });
211
234
  }
212
235
 
213
236
  async function saveNotify() {
214
237
  setNotifyMsg(NO_MSG);
215
- if (!notify.pw) return setNotifyMsg({ ok: '', err: 'Enter your current password to save.' });
238
+ if (!clerk && !notify.pw) return setNotifyMsg({ ok: '', err: 'Enter your current password to save.' });
216
239
  const config = { ...data!.config, reminders: notify.reminders, staffNotifications: notify.staff };
217
240
  await run('notify', async () => {
218
241
  try {
219
- const next = await api<PublicEmailSettings>({ action: 'save', config, credentials: {}, reauthPassword: notify.pw });
242
+ if (!await confirmIdentity((err) => setNotifyMsg({ ok: '', err }))) return;
243
+ const next = await api<PublicEmailSettings>({ action: 'save', config, credentials: {}, reauthPassword: clerk ? '' : notify.pw });
220
244
  if (live()) { adopt(next, 'notify'); setNotifyMsg({ ok: 'Notification settings saved.', err: '' }); }
221
- } catch (e) { if (live()) { setNotify((n) => ({ ...n, pw: '' })); setNotifyMsg({ ok: '', err: errText(e) }); } }
245
+ } catch (e) { if (live()) { setNotify((n) => ({ ...n, pw: '' })); setNotifyMsg({ ok: '', err: errText(e) }); setStale(clerk && refused(e)); } }
222
246
  });
223
247
  }
224
248
 
@@ -262,7 +286,22 @@ export default function EmailSettings() {
262
286
  <div className="ems-status" role="alert">{m.err && <p className="flash error">{m.err}</p>}</div>
263
287
  </>
264
288
  );
265
- const pwField = (value: string, onChange: (v: string) => void, key: string) => (
289
+ const pwField = (value: string, onChange: (v: string) => void, key: string) => clerk ? (
290
+ <div className="ems-verify" id={id(key)}>
291
+ <span className="ems-verify-mark" aria-hidden="true">
292
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 3l7 3v5c0 4.5-3 8.3-7 10-4-1.7-7-5.5-7-10V6zM9 12l2 2 4-4" /></svg>
293
+ </span>
294
+ <p>
295
+ <b>Confirmed with your sign-in.</b>{' '}
296
+ {stale ? 'Your sign-in is more than ten minutes old. Sign in again, then save; your edits stay here until you do.' : 'Saving needs a sign-in from the last ten minutes. If yours is older, you’ll be asked to sign in again.'}
297
+ </p>
298
+ {stale && (
299
+ <span className="ems-verify-actions">
300
+ <button type="button" className="btn sm" onClick={reSignIn} disabled={!!busy}>Sign in again</button>
301
+ </span>
302
+ )}
303
+ </div>
304
+ ) : (
266
305
  <label className="field ems-pw">
267
306
  <span>Your current password</span>
268
307
  <input type="password" autoComplete="current-password" value={value} onChange={(e) => onChange(e.target.value)} aria-describedby={id(key)} />
@@ -427,9 +466,10 @@ export default function EmailSettings() {
427
466
  <form className="ems-card" onSubmit={(e) => { e.preventDefault(); void saveNotify(); }} noValidate>
428
467
  <header className="ems-head"><div><h2>Always sent</h2><p>Essential messages go out whenever email is on and a provider is configured, unless the address is suppressed after a bounce or complaint.</p></div></header>
429
468
  <ul className="ems-list">
430
- <li>Account invitations, password resets and password-changed notices</li>
469
+ {!clerk && <li>Account invitations, password resets and password-changed notices</li>}
431
470
  <li>Booking received, confirmed, declined, cancelled and rescheduled</li>
432
471
  </ul>
472
+ {clerk && <p className="hint">Sign-in email (invitations, verification codes and password recovery) comes from Clerk, not from this connection.</p>}
433
473
  <h3 className="ems-sub">Optional</h3>
434
474
  <label className="ems-toggle">
435
475
  <input type="checkbox" checked={notify.reminders} onChange={(e) => setNotify((n) => ({ ...n, reminders: e.target.checked }))} />
@@ -452,7 +492,7 @@ export default function EmailSettings() {
452
492
  <header className="ems-head"><div><h2>Built-in templates</h2><p>Every message has HTML and plain-text versions. The logo, name and colors come from your site settings. This sample uses made-up example.com data; real account links are never previewed.</p></div></header>
453
493
  <div className="ems-row">
454
494
  <div className="seg" role="group" aria-label="Sample message">
455
- {(Object.keys(SAMPLES) as (keyof typeof SAMPLES)[]).map((k) => (
495
+ {(Object.keys(SAMPLES) as (keyof typeof SAMPLES)[]).filter((k) => !clerk || !ACCOUNT_SAMPLES.has(k)).map((k) => (
456
496
  <button key={k} type="button" aria-pressed={tpl === k} onClick={() => setTpl(k)}>{SAMPLES[k].label}</button>
457
497
  ))}
458
498
  </div>
@@ -461,7 +501,7 @@ export default function EmailSettings() {
461
501
  <button type="button" aria-pressed={plain} onClick={() => setPlain(true)}>Plain text</button>
462
502
  </div>
463
503
  </div>
464
- <Preview s={SAMPLES[tpl]} plain={plain} />
504
+ <Preview s={SAMPLES[clerk && ACCOUNT_SAMPLES.has(tpl) ? 'booking' : tpl]} plain={plain} />
465
505
  </div>
466
506
  </section>
467
507
 
@@ -506,6 +546,8 @@ const SAMPLES = {
506
546
  reset: { label: 'Password reset', subject: 'Reset your Example Studio password', heading: 'Reset your password', body: ['Someone asked to reset the password for sam@example.com.', 'The link works once and expires in 30 minutes.'], action: 'Choose a new password', foot: 'If this wasn’t you, ignore this email; your password stays the same.' },
507
547
  booking: { label: 'Booking confirmed', subject: 'Confirmed: Studio tour, Tue 14 Oct', heading: 'Your booking is confirmed', body: ['Studio tour with Example Studio', 'Tuesday 14 October 2026, 10:00–10:45 (America/New_York)', '123 Example Street, Springfield'], action: 'Manage booking', ics: true, foot: 'Need to change it? Use the link above or reply to this email.' },
508
548
  } satisfies Record<string, Sample>;
549
+ // sign-in email that Clerk sends instead when the site uses Clerk
550
+ const ACCOUNT_SAMPLES = new Set<keyof typeof SAMPLES>(['invite', 'reset']);
509
551
 
510
552
  function Preview({ s, plain }: { s: Sample; plain: boolean }) {
511
553
  const text = [s.heading, '', ...s.body, '', `${s.action}: [link omitted in preview]`, '', s.foot, '', '— Example Studio'].join('\n');