@acms-subir/core 0.3.2 → 0.3.3

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 (260) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +1 -1
  4. package/cli/main.mjs +82 -8
  5. package/cli/mobile-doctor.mjs +430 -0
  6. package/cli/mobile.mjs +315 -0
  7. package/cli/native-sections.mjs +168 -0
  8. package/cli/sections.mjs +186 -0
  9. package/cli/themes.mjs +52 -8
  10. package/docs/mobile.md +527 -0
  11. package/docs/sections.md +203 -0
  12. package/docs/theme-authoring.md +1 -1
  13. package/integration.mjs +60 -1
  14. package/mobile-template/client/AGENTS.md +97 -0
  15. package/mobile-template/client/README.md +65 -0
  16. package/mobile-template/client/app/_layout.tsx +56 -0
  17. package/mobile-template/client/app/index.tsx +21 -0
  18. package/mobile-template/client/app.json +21 -0
  19. package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
  20. package/mobile-template/client/eas.json +21 -0
  21. package/mobile-template/client/env.example +3 -0
  22. package/mobile-template/client/gitignore +42 -0
  23. package/mobile-template/client/package.json +38 -0
  24. package/mobile-template/client/sections/index.ts +11 -0
  25. package/mobile-template/client/tsconfig.json +7 -0
  26. package/mobile-template/editor/AGENTS.md +66 -0
  27. package/mobile-template/editor/README.md +73 -0
  28. package/mobile-template/editor/app/_layout.tsx +76 -0
  29. package/mobile-template/editor/app/auth.tsx +13 -0
  30. package/mobile-template/editor/app/index.tsx +55 -0
  31. package/mobile-template/editor/app.json +21 -0
  32. package/mobile-template/editor/components/Screen.tsx +51 -0
  33. package/mobile-template/editor/components/SignIn.tsx +36 -0
  34. package/mobile-template/editor/components/TextTabs.tsx +27 -0
  35. package/mobile-template/editor/eas.json +21 -0
  36. package/mobile-template/editor/env.example +5 -0
  37. package/mobile-template/editor/gitignore +42 -0
  38. package/mobile-template/editor/lib/appearance.tsx +12 -0
  39. package/mobile-template/editor/lib/config.ts +17 -0
  40. package/mobile-template/editor/lib/format.ts +24 -0
  41. package/mobile-template/editor/lib/tools.ts +16 -0
  42. package/mobile-template/editor/package.json +40 -0
  43. package/mobile-template/editor/tools/README.md +2 -0
  44. package/mobile-template/editor/tsconfig.json +7 -0
  45. package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
  46. package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
  47. package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
  48. package/mobile-template/recipes/booking/recipe.json +17 -0
  49. package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
  50. package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
  51. package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
  52. package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
  53. package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
  54. package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
  55. package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
  56. package/mobile-template/recipes/editor-content/recipe.json +8 -0
  57. package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
  58. package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
  59. package/mobile-template/recipes/editor-media/recipe.json +8 -0
  60. package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
  61. package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
  62. package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
  63. package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
  64. package/mobile-template/recipes/editor-sections/recipe.json +8 -0
  65. package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
  66. package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
  67. package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
  68. package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
  69. package/mobile-template/recipes/editor-translations/recipe.json +8 -0
  70. package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
  71. package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
  72. package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
  73. package/mobile-template/recipes/editor-writer/recipe.json +8 -0
  74. package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
  75. package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
  76. package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
  77. package/mobile-template/recipes/events/recipe.json +15 -0
  78. package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
  79. package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
  80. package/mobile-template/recipes/news/recipe.json +13 -0
  81. package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
  82. package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
  83. package/mobile-template/recipes/push/files/lib/push.ts +15 -0
  84. package/mobile-template/recipes/push/recipe.json +20 -0
  85. package/package.json +4 -2
  86. package/section-library/README.md +16 -0
  87. package/section-library/contact/Contact.astro +49 -0
  88. package/section-library/contact/section.json +106 -0
  89. package/section-library/cta-band/CtaBand.astro +28 -0
  90. package/section-library/cta-band/section.json +51 -0
  91. package/section-library/feature-grid/FeatureGrid.astro +54 -0
  92. package/section-library/feature-grid/section.json +107 -0
  93. package/section-library/gallery/Gallery.astro +29 -0
  94. package/section-library/gallery/section.json +51 -0
  95. package/section-library/hero-banner/HeroBanner.astro +42 -0
  96. package/section-library/hero-banner/section.json +81 -0
  97. package/section-library/latest-posts/LatestPosts.astro +22 -0
  98. package/section-library/latest-posts/section.json +37 -0
  99. package/section-library/pricing/Pricing.astro +40 -0
  100. package/section-library/pricing/section.json +117 -0
  101. package/section-library/questions/Questions.astro +38 -0
  102. package/section-library/questions/section.json +69 -0
  103. package/section-library/service-times/ServiceTimes.astro +42 -0
  104. package/section-library/service-times/section.json +127 -0
  105. package/section-library/team/Team.astro +45 -0
  106. package/section-library/team/section.json +88 -0
  107. package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
  108. package/section-library/upcoming-events/section.json +51 -0
  109. package/src/actions/index.ts +19 -171
  110. package/src/components/AiSettings.tsx +45 -5
  111. package/src/components/DesignManager.tsx +112 -16
  112. package/src/components/MobileApp.tsx +392 -0
  113. package/src/components/ThemeSections.astro +40 -21
  114. package/src/components/ThemeSectionsEditor.tsx +757 -229
  115. package/src/components/editor/PostEditor.tsx +174 -10
  116. package/src/components/import/ImportExport.tsx +522 -0
  117. package/src/components/import/runner.ts +285 -0
  118. package/src/components/sections/SectionFields.tsx +560 -0
  119. package/src/components/sections/SectionPicker.tsx +120 -0
  120. package/src/components/sections/TranslationPanel.tsx +143 -0
  121. package/src/components/sections/checks.ts +64 -0
  122. package/src/layouts/Admin.astro +6 -1
  123. package/src/layouts/Site.astro +107 -19
  124. package/src/lib/agent-discovery.ts +11 -7
  125. package/src/lib/agent.ts +35 -2
  126. package/src/lib/ai-schema.ts +5 -0
  127. package/src/lib/ai.ts +5 -0
  128. package/src/lib/api.ts +12 -0
  129. package/src/lib/content-service.ts +198 -0
  130. package/src/lib/design-service.ts +153 -5
  131. package/src/lib/drafts.ts +3 -3
  132. package/src/lib/editor/auth.ts +264 -0
  133. package/src/lib/editor/bookings.ts +60 -0
  134. package/src/lib/editor/config.ts +25 -0
  135. package/src/lib/editor/documents.ts +227 -0
  136. package/src/lib/editor/http.ts +48 -0
  137. package/src/lib/editor/me.ts +30 -0
  138. package/src/lib/editor/migration.ts +57 -0
  139. package/src/lib/editor/next.ts +11 -0
  140. package/src/lib/editor/openapi.ts +124 -0
  141. package/src/lib/editor/schemas.ts +91 -0
  142. package/src/lib/editor/sections.ts +96 -0
  143. package/src/lib/editor/store.ts +184 -0
  144. package/src/lib/editor/token-route.ts +74 -0
  145. package/src/lib/editor/translations.ts +163 -0
  146. package/src/lib/email/accounts.ts +2 -1
  147. package/src/lib/email/types.ts +2 -1
  148. package/src/lib/home-source.ts +34 -0
  149. package/src/lib/i18n.ts +34 -0
  150. package/src/lib/import/apply.ts +459 -0
  151. package/src/lib/import/bundle.ts +171 -0
  152. package/src/lib/import/export.ts +55 -0
  153. package/src/lib/import/ghost.ts +248 -0
  154. package/src/lib/import/lexical.ts +290 -0
  155. package/src/lib/import/migration.ts +27 -0
  156. package/src/lib/import/zipread.ts +72 -0
  157. package/src/lib/media/upload.ts +83 -0
  158. package/src/lib/mobile/associations.ts +25 -0
  159. package/src/lib/mobile/calendar.ts +19 -11
  160. package/src/lib/mobile/config.mjs +59 -0
  161. package/src/lib/mobile/content.ts +18 -13
  162. package/src/lib/mobile/embed.ts +83 -0
  163. package/src/lib/mobile/http.ts +8 -6
  164. package/src/lib/mobile/locale.ts +18 -0
  165. package/src/lib/mobile/openapi.ts +111 -0
  166. package/src/lib/mobile/pages.ts +71 -0
  167. package/src/lib/mobile/route.ts +3 -3
  168. package/src/lib/mobile/schemas.ts +60 -0
  169. package/src/lib/mobile/site.ts +41 -0
  170. package/src/lib/push/api.ts +158 -0
  171. package/src/lib/push/config.ts +20 -0
  172. package/src/lib/push/expo.ts +76 -0
  173. package/src/lib/push/migration.ts +60 -0
  174. package/src/lib/push/service.ts +302 -0
  175. package/src/lib/push/store.ts +144 -0
  176. package/src/lib/push/types.ts +43 -0
  177. package/src/lib/repo/migrations.ts +13 -0
  178. package/src/lib/repo/sql.ts +156 -9
  179. package/src/lib/repo/types.ts +26 -3
  180. package/src/lib/roles.ts +5 -0
  181. package/src/lib/scheduler.ts +3 -0
  182. package/src/lib/schema.ts +27 -1
  183. package/src/lib/section-ai.ts +55 -0
  184. package/src/lib/section-targets.ts +62 -0
  185. package/src/lib/sections-agent.ts +146 -0
  186. package/src/lib/sections.ts +217 -0
  187. package/src/lib/theme.ts +16 -1
  188. package/src/lib/translate.ts +97 -0
  189. package/src/lib/translations.ts +39 -0
  190. package/src/middleware.ts +32 -0
  191. package/src/pages/.well-known/apple-app-site-association.ts +6 -0
  192. package/src/pages/.well-known/assetlinks.json.ts +6 -0
  193. package/src/pages/[...slug].astro +8 -3
  194. package/src/pages/admin/app-auth.astro +159 -0
  195. package/src/pages/admin/c/[collection]/[id].astro +9 -0
  196. package/src/pages/admin/calendar/events/[id].astro +9 -0
  197. package/src/pages/admin/import.astro +16 -0
  198. package/src/pages/admin/login.astro +6 -2
  199. package/src/pages/admin/logout.ts +5 -2
  200. package/src/pages/admin/mobile.astro +18 -0
  201. package/src/pages/admin/pages/[id].astro +62 -0
  202. package/src/pages/admin/section-preview.astro +61 -0
  203. package/src/pages/admin/settings.astro +11 -0
  204. package/src/pages/admin/users.astro +107 -11
  205. package/src/pages/api/agent/docs.ts +6 -2
  206. package/src/pages/api/agent/index.ts +1 -1
  207. package/src/pages/api/ai/settings.ts +7 -4
  208. package/src/pages/api/design/ai.ts +15 -0
  209. package/src/pages/api/design/index.ts +12 -42
  210. package/src/pages/api/design/translate.ts +15 -0
  211. package/src/pages/api/export.ts +41 -0
  212. package/src/pages/api/import/asset.ts +32 -0
  213. package/src/pages/api/import/index.ts +47 -0
  214. package/src/pages/api/import/media.ts +31 -0
  215. package/src/pages/api/live/thumb.ts +1 -1
  216. package/src/pages/api/mcp.ts +2 -2
  217. package/src/pages/api/media/upload.ts +10 -78
  218. package/src/pages/api/push/admin.ts +27 -0
  219. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
  220. package/src/pages/api/v1/admin/bookings/index.ts +6 -0
  221. package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
  222. package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
  223. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
  224. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
  225. package/src/pages/api/v1/admin/documents/index.ts +13 -0
  226. package/src/pages/api/v1/admin/me.ts +6 -0
  227. package/src/pages/api/v1/admin/media/index.ts +32 -0
  228. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
  229. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
  230. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
  231. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
  232. package/src/pages/api/v1/admin/sections/index.ts +6 -0
  233. package/src/pages/api/v1/auth/revoke.ts +5 -0
  234. package/src/pages/api/v1/auth/token.ts +5 -0
  235. package/src/pages/api/v1/devices/[token].ts +20 -0
  236. package/src/pages/api/v1/devices/index.ts +17 -0
  237. package/src/pages/api/v1/openapi.json.ts +7 -0
  238. package/src/pages/api/v1/pages/[...slug].ts +7 -0
  239. package/src/pages/api/v1/pages/home.ts +7 -0
  240. package/src/pages/api/v1/push/topics.ts +7 -0
  241. package/src/pages/api/v1/site.ts +7 -0
  242. package/src/pages/api/v1/theme.ts +7 -0
  243. package/src/pages/blog/[slug].astro +8 -2
  244. package/src/pages/blog/index.astro +11 -4
  245. package/src/pages/embed/section/[...path].astro +42 -0
  246. package/src/pages/events/[slug].astro +7 -2
  247. package/src/pages/index.astro +11 -5
  248. package/src/pages/sitemap.xml.ts +8 -0
  249. package/src/styles/admin.css +69 -0
  250. package/src/styles/editor.css +21 -0
  251. package/src/styles/mobile-app.css +151 -0
  252. package/src/styles/sections.css +7 -1
  253. package/src/styles/theme-editor.css +255 -0
  254. package/src/theme-sdk/contract.d.mts +2193 -89
  255. package/src/theme-sdk/contract.mjs +77 -20
  256. package/src/theme-sdk/fields.d.mts +88 -0
  257. package/src/theme-sdk/fields.mjs +326 -0
  258. package/src/theme-sdk/index.ts +10 -2
  259. package/src/theme-sdk/tokens.d.mts +11 -0
  260. package/src/virtual.d.ts +1 -0
@@ -1,9 +1,11 @@
1
1
  import { LiveStore } from '../live/store';
2
+ import { PushStore } from '../push/store';
3
+ import { EditorStore } from '../editor/store';
2
4
  import { LiveConfig } from '../live/types';
3
5
  import { ApiError } from '../api';
4
6
  import { EmailStore, bookingJobs } from '../email/store';
5
- import type { ContentRepository, DesignVersion, EventUpdate, SearchResults, Stats } from './types';
6
- import { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
7
+ import type { ContentRepository, DesignVersion, EventUpdate, ImportRecord, ImportRecordKind, ImportRun, SearchResults, Stats } from './types';
8
+ import { DocTranslations, Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Calendar, CalEvent, ApiKey, Webhook, WebhookDelivery, Resource, BookingLink, Booking, isInternalTag, normalizeRedirectPath, publicTags, slugify, type WidgetInstance } from '../schema';
7
9
  import { designSnapshot, draftNavItems, pickDesignDraft, pickEventDraft, pickPageDraft, pickPostDraft } from '../drafts';
8
10
  import { eventInstants, eventTimeZone, normalizeEventTimes } from '../calendar/events';
9
11
  import { cleanRrule } from '../calendar/rrule';
@@ -43,6 +45,11 @@ function parseDraft(raw: unknown): Record<string, unknown> | null {
43
45
  }
44
46
  }
45
47
 
48
+ // translations column: tolerate old rows / junk (never break a page render)
49
+ function parseTranslations(v: unknown) {
50
+ const parsed = DocTranslations.safeParse(json(v, {}));
51
+ return parsed.success ? parsed.data : {};
52
+ }
46
53
  function rowToPage(r: Row): Page {
47
54
  return Page.parse({
48
55
  id: r.id, slug: r.slug, title: r.title, status: r.status,
@@ -50,7 +57,7 @@ function rowToPage(r: Row): Page {
50
57
  blocks: JSON.parse(String(r.blocks ?? '[]')),
51
58
  bodyJson: r.body_json ?? '', bodyHtml: r.body_html ?? '',
52
59
  seoTitle: r.seo_title ?? '', seoDesc: r.seo_desc ?? '',
53
- template: r.template ?? '', draft: parseDraft(r.draft),
60
+ template: r.template ?? '', draft: parseDraft(r.draft), translations: parseTranslations(r.translations),
54
61
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
55
62
  });
56
63
  }
@@ -62,12 +69,26 @@ function rowToPost(r: Row, authorIds?: string[]): Post {
62
69
  seoTitle: r.seo_title ?? '', seoDesc: r.seo_desc ?? '', status: r.status,
63
70
  featured: Boolean(r.featured), authorId: r.author_id ?? null,
64
71
  authorIds: authorIds ?? (r.author_id ? [String(r.author_id)] : []),
65
- template: r.template ?? '', draft: parseDraft(r.draft),
72
+ template: r.template ?? '', draft: parseDraft(r.draft), translations: parseTranslations(r.translations),
66
73
  publishedAt: r.published_at === null ? null : Number(r.published_at),
67
74
  scheduledAt: r.scheduled_at === null ? null : Number(r.scheduled_at),
68
75
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
69
76
  });
70
77
  }
78
+ function rowToImportRun(r: Row): ImportRun {
79
+ let report: Record<string, unknown> = {};
80
+ try { report = JSON.parse(String(r.report ?? '{}')); } catch { /* keep empty */ }
81
+ return {
82
+ id: String(r.id), source: String(r.source), label: String(r.label ?? ''), status: String(r.status) as ImportRun['status'],
83
+ report, createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
84
+ };
85
+ }
86
+ function rowToImportRecord(r: Row): ImportRecord {
87
+ return {
88
+ runId: String(r.run_id), kind: String(r.kind) as ImportRecordKind, sourceId: String(r.source_id), acmsId: String(r.acms_id),
89
+ action: String(r.action) as ImportRecord['action'], stamp: Number(r.stamp ?? 0),
90
+ };
91
+ }
71
92
  function rowToSnippet(r: Row): Snippet {
72
93
  return Snippet.parse({
73
94
  id: r.id, name: r.name, contentJson: r.content_json ?? '[]', createdAt: Number(r.created_at),
@@ -124,7 +145,7 @@ function rowToEvent(r: Row): CalEvent {
124
145
  rrule: r.rrule ?? '', exdates: json(r.exdates, []), cancelled: Boolean(r.cancelled),
125
146
  location: json(r.location, {}), cta: json(r.cta, {}), calendarIds: json(r.calendar_ids, []),
126
147
  fields: json(r.fields, {}), sourceUid: r.source_uid ?? '', rsvp: json(r.rsvp, {}),
127
- startAt: Number(r.start_at), endAt: Number(r.end_at), draft: parseDraft(r.draft),
148
+ startAt: Number(r.start_at), endAt: Number(r.end_at), draft: parseDraft(r.draft), translations: parseTranslations(r.translations),
128
149
  publishedAt: r.published_at === null || r.published_at === undefined ? null : Number(r.published_at),
129
150
  scheduledAt: r.scheduled_at === null || r.scheduled_at === undefined ? null : Number(r.scheduled_at),
130
151
  createdAt: Number(r.created_at), updatedAt: Number(r.updated_at),
@@ -219,10 +240,14 @@ export class SqlRepository implements ContentRepository {
219
240
  private ready: Promise<void>;
220
241
  readonly email: EmailStore;
221
242
  readonly live: LiveStore;
243
+ readonly push: PushStore;
244
+ readonly editor: EditorStore;
222
245
 
223
246
  constructor(driver: SqlDriver | (() => Promise<SqlDriver>)) {
224
247
  this.ready = this.init(driver);
225
248
  this.live = new LiveStore(async () => { await this.ready; return this.driver; });
249
+ this.push = new PushStore(async () => { await this.ready; return this.driver; });
250
+ this.editor = new EditorStore(async () => { await this.ready; return this.driver; });
226
251
  this.email = new EmailStore(async () => { await this.ready; return this.driver; });
227
252
  }
228
253
 
@@ -452,6 +477,7 @@ export class SqlRepository implements ContentRepository {
452
477
  if (data.seoTitle !== undefined) { sets.push('seo_title = ?'); args.push(data.seoTitle); }
453
478
  if (data.seoDesc !== undefined) { sets.push('seo_desc = ?'); args.push(data.seoDesc); }
454
479
  if (data.template !== undefined) { sets.push('template = ?'); args.push(data.template); }
480
+ if (data.translations !== undefined) { sets.push('translations = ?'); args.push(JSON.stringify(data.translations)); }
455
481
  if (data.blocks !== undefined) { sets.push('blocks = ?'); args.push(JSON.stringify(data.blocks)); }
456
482
  if (data.bodyJson !== undefined) { sets.push('body_json = ?'); args.push(data.bodyJson); }
457
483
  if (data.bodyHtml !== undefined) { sets.push('body_html = ?'); args.push(data.bodyHtml); }
@@ -584,6 +610,7 @@ export class SqlRepository implements ContentRepository {
584
610
  if (data.seoDesc !== undefined) { sets.push('seo_desc = ?'); args.push(data.seoDesc); }
585
611
  if (data.featured !== undefined) { sets.push('featured = ?'); args.push(data.featured ? 1 : 0); }
586
612
  if (data.template !== undefined) { sets.push('template = ?'); args.push(data.template); }
613
+ if (data.translations !== undefined) { sets.push('translations = ?'); args.push(JSON.stringify(data.translations)); }
587
614
  // authorIds is the source of truth; author_id mirrors the first entry.
588
615
  // Main row, join rows and webhook notifications share one transaction.
589
616
  let authorJoin: string[] | null = null;
@@ -818,7 +845,7 @@ export class SqlRepository implements ContentRepository {
818
845
  if (v !== undefined) { sets.push(`${col} = ?`); args.push(v); }
819
846
  }
820
847
  const jsonCols: Record<string, string> = {
821
- tags: 'tags', exdates: 'exdates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp',
848
+ tags: 'tags', exdates: 'exdates', location: 'location', cta: 'cta', calendarIds: 'calendar_ids', fields: 'fields', rsvp: 'rsvp', translations: 'translations',
822
849
  };
823
850
  for (const [k, col] of Object.entries(jsonCols)) {
824
851
  const v = (data as Record<string, unknown>)[k];
@@ -1577,7 +1604,7 @@ export class SqlRepository implements ContentRepository {
1577
1604
  // Suppression and re-enabling share the restore transaction. A failed
1578
1605
  // restore cannot leave notifications disabled or emit phantom deletes.
1579
1606
  { sql: 'UPDATE webhook_control SET suppressed=1 WHERE id=1' },
1580
- ...['email_jobs', 'email_attempts', 'email_delivery_events', 'email_tokens', 'posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects', 'calendars', 'events', 'resources', 'booking_links', 'bookings', 'mobile_booking_requests'].map((t) => ({
1607
+ ...['email_jobs', 'email_attempts', 'email_delivery_events', 'email_tokens', 'posts', 'pages', 'media', 'links', 'tags', 'nav_items', 'users', 'post_authors', 'redirects', 'calendars', 'events', 'resources', 'booking_links', 'bookings', 'mobile_booking_requests', 'import_runs', 'import_records'].map((t) => ({
1581
1608
  sql: `DELETE FROM ${t}`,
1582
1609
  })),
1583
1610
  ...(content.calendars ?? []).map((c) => ({
@@ -1619,6 +1646,10 @@ export class SqlRepository implements ContentRepository {
1619
1646
  args: [p.id, aid, i] as unknown[],
1620
1647
  }))
1621
1648
  ),
1649
+ // translations (v36): written after the rows so older backups need no change
1650
+ ...[['pages', content.pages], ['posts', content.posts], ['events', content.events ?? []]].flatMap(([table, rows]) =>
1651
+ (rows as { id: string; translations?: Record<string, unknown> }[]).filter((r) => r.translations && Object.keys(r.translations).length)
1652
+ .map((r) => ({ sql: `UPDATE ${table} SET translations = ? WHERE id = ?`, args: [JSON.stringify(r.translations), r.id] as unknown[] }))),
1622
1653
  ...content.media.map((m) => ({
1623
1654
  sql: 'INSERT INTO media (id, key, filename, mime, width, height, size, alt, variants, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
1624
1655
  args: [m.id, m.key, m.filename, m.mime, m.width, m.height, m.size, m.alt, JSON.stringify(m.variants), m.createdAt],
@@ -1656,6 +1687,106 @@ export class SqlRepository implements ContentRepository {
1656
1687
  await this.reindexSearch();
1657
1688
  }
1658
1689
 
1690
+ // ---- imports ----
1691
+ async createImportRun({ id, source, label }: Pick<ImportRun, 'id' | 'source' | 'label'>) {
1692
+ await this.ready;
1693
+ const now = Date.now();
1694
+ await this.driver.run('INSERT INTO import_runs (id, source, label, status, report, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [id, source, label, 'running', '{}', now, now]);
1695
+ }
1696
+ async updateImportRun(id: string, data: Partial<Pick<ImportRun, 'status' | 'report'>>) {
1697
+ await this.ready;
1698
+ const sets = ['updated_at = ?'];
1699
+ const args: unknown[] = [Date.now()];
1700
+ if (data.status !== undefined) { sets.push('status = ?'); args.push(data.status); }
1701
+ if (data.report !== undefined) { sets.push('report = ?'); args.push(JSON.stringify(data.report)); }
1702
+ await this.driver.run(`UPDATE import_runs SET ${sets.join(', ')} WHERE id = ?`, [...args, id]);
1703
+ }
1704
+ async listImportRuns() {
1705
+ await this.ready;
1706
+ return (await this.driver.all('SELECT * FROM import_runs ORDER BY created_at DESC')).map(rowToImportRun);
1707
+ }
1708
+ async getImportRun(id: string) {
1709
+ await this.ready;
1710
+ const r = await this.first('SELECT * FROM import_runs WHERE id = ?', [id]);
1711
+ return r ? rowToImportRun(r) : null;
1712
+ }
1713
+ async addImportRecords(records: ImportRecord[]) {
1714
+ await this.ready;
1715
+ const now = Date.now();
1716
+ await this.driver.batch(records.map((r) => ({
1717
+ sql: 'INSERT INTO import_records (run_id, kind, source_id, acms_id, action, stamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(run_id, kind, source_id) DO UPDATE SET acms_id = excluded.acms_id, stamp = excluded.stamp',
1718
+ args: [r.runId, r.kind, r.sourceId, r.acmsId, r.action, r.stamp, now],
1719
+ })));
1720
+ }
1721
+ async listImportRecords(runId: string) {
1722
+ await this.ready;
1723
+ return (await this.driver.all('SELECT * FROM import_records WHERE run_id = ? ORDER BY created_at ASC', [runId])).map(rowToImportRecord);
1724
+ }
1725
+ async findImportRecords(kind: ImportRecordKind, sourceIds: string[]) {
1726
+ await this.ready;
1727
+ if (!sourceIds.length) return [];
1728
+ const out: ImportRecord[] = [];
1729
+ // chunked to stay under SQLite's bound-parameter limit
1730
+ for (let i = 0; i < sourceIds.length; i += 200) {
1731
+ const chunk = sourceIds.slice(i, i + 200);
1732
+ const rows = await this.driver.all(
1733
+ `SELECT r.* FROM import_records r JOIN import_runs u ON u.id = r.run_id
1734
+ WHERE u.status != 'undone' AND r.kind = ? AND r.source_id IN (${chunk.map(() => '?').join(', ')})
1735
+ ORDER BY r.created_at ASC`,
1736
+ [kind, ...chunk]
1737
+ );
1738
+ out.push(...rows.map(rowToImportRecord));
1739
+ }
1740
+ // later runs win
1741
+ return [...new Map(out.map((r) => [r.sourceId, r])).values()];
1742
+ }
1743
+ async writeImported(data: { posts?: Post[]; pages?: Page[]; deletePosts?: string[]; deletePages?: string[] }) {
1744
+ await this.ready;
1745
+ const posts = data.posts ?? [], pages = data.pages ?? [];
1746
+ const stmts: SqlStatement[] = [{ sql: 'UPDATE webhook_control SET suppressed=1 WHERE id=1' }];
1747
+ for (const p of posts) {
1748
+ stmts.push({
1749
+ sql: `INSERT INTO posts (id, collection, slug, title, body, body_json, body_html, excerpt, tags, cover, cover_alt, seo_title, seo_desc, status, featured, author_id, template, draft, published_at, scheduled_at, created_at, updated_at)
1750
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1751
+ ON CONFLICT(id) DO UPDATE SET collection = excluded.collection, slug = excluded.slug, title = excluded.title, body = excluded.body,
1752
+ body_json = excluded.body_json, body_html = excluded.body_html, excerpt = excluded.excerpt, tags = excluded.tags, cover = excluded.cover,
1753
+ cover_alt = excluded.cover_alt, seo_title = excluded.seo_title, seo_desc = excluded.seo_desc, status = excluded.status,
1754
+ featured = excluded.featured, author_id = excluded.author_id, template = excluded.template, draft = NULL,
1755
+ published_at = excluded.published_at, scheduled_at = excluded.scheduled_at, updated_at = excluded.updated_at`,
1756
+ args: [p.id, p.collection, p.slug, p.title, p.body, p.bodyJson, p.bodyHtml, p.excerpt, JSON.stringify(p.tags), p.cover, p.coverAlt, p.seoTitle, p.seoDesc, p.status, p.featured ? 1 : 0, p.authorIds[0] ?? null, p.template, null, p.publishedAt, p.scheduledAt, p.createdAt, p.updatedAt],
1757
+ });
1758
+ stmts.push({ sql: 'DELETE FROM post_authors WHERE post_id = ?', args: [p.id] });
1759
+ p.authorIds.forEach((aid, i) => stmts.push({ sql: 'INSERT INTO post_authors (post_id, author_id, ord) VALUES (?, ?, ?)', args: [p.id, aid, i] }));
1760
+ }
1761
+ for (const p of pages) {
1762
+ stmts.push({
1763
+ sql: `INSERT INTO pages (id, slug, title, status, is_home, locale, blocks, body_json, body_html, seo_title, seo_desc, template, draft, created_at, updated_at)
1764
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1765
+ ON CONFLICT(id) DO UPDATE SET slug = excluded.slug, title = excluded.title, status = excluded.status, locale = excluded.locale,
1766
+ blocks = excluded.blocks, body_json = excluded.body_json, body_html = excluded.body_html, seo_title = excluded.seo_title,
1767
+ seo_desc = excluded.seo_desc, template = excluded.template, draft = NULL, updated_at = excluded.updated_at`,
1768
+ args: [p.id, p.slug, p.title, p.status, p.isHome ? 1 : 0, p.locale, JSON.stringify(p.blocks), p.bodyJson, p.bodyHtml, p.seoTitle, p.seoDesc, p.template, null, p.createdAt, p.updatedAt],
1769
+ });
1770
+ }
1771
+ for (const id of data.deletePosts ?? []) {
1772
+ stmts.push(
1773
+ { sql: 'DELETE FROM posts WHERE id = ?', args: [id] },
1774
+ { sql: 'DELETE FROM post_authors WHERE post_id = ?', args: [id] },
1775
+ { sql: "DELETE FROM search_index WHERE kind = 'post' AND ref_id = ?", args: [id] },
1776
+ );
1777
+ }
1778
+ for (const id of data.deletePages ?? []) {
1779
+ stmts.push(
1780
+ { sql: 'DELETE FROM pages WHERE id = ?', args: [id] },
1781
+ { sql: "DELETE FROM search_index WHERE kind = 'page' AND ref_id = ?", args: [id] },
1782
+ );
1783
+ }
1784
+ stmts.push({ sql: 'UPDATE webhook_control SET suppressed=0 WHERE id=1' });
1785
+ await this.driver.batch(stmts);
1786
+ for (const p of posts) await this.syncPostIndex(p.id);
1787
+ for (const p of pages) await this.syncPageIndex(p.id);
1788
+ }
1789
+
1659
1790
  // ---- redirects ----
1660
1791
  async listRedirects() {
1661
1792
  await this.ready;
@@ -1704,6 +1835,7 @@ export class SqlRepository implements ContentRepository {
1704
1835
  timezone: r?.timezone ?? '', locale: r?.locale ?? '',
1705
1836
  ai: JSON.parse(String(r?.ai ?? '{}')),
1706
1837
  live: JSON.parse(String(r?.live ?? '{}')),
1838
+ push: JSON.parse(String(r?.push ?? '{}')),
1707
1839
  agent: JSON.parse(String(r?.agent ?? '{}')),
1708
1840
  draftsApiKey: r?.drafts_api_key ?? '',
1709
1841
  draftsAllowedOrigins: JSON.parse(String(r?.drafts_allowed_origins ?? '[]')),
@@ -1720,9 +1852,9 @@ export class SqlRepository implements ContentRepository {
1720
1852
  siteTitle:'site_title', tagline:'tagline', footerText:'footer_text', linksName:'links_name', linksBio:'links_bio', linksAvatar:'links_avatar',
1721
1853
  activeTheme:'active_theme', designOverrides:'design_overrides', sectionPages:'section_pages', siteLogo:'site_logo', siteUrl:'site_url', siteFavicon:'site_favicon', siteCover:'site_cover',
1722
1854
  announcement:'announcement', analytics:'analytics', headCode:'head_code', footerCode:'footer_code', timezone:'timezone', locale:'locale',
1723
- draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live',
1855
+ draftsApiKey:'drafts_api_key', draftsAllowedOrigins:'drafts_allowed_origins', draftDesign:'draft_design', ai:'ai', agent:'agent', live:'live', push:'push',
1724
1856
  };
1725
- const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live']);
1857
+ const jsonKeys = new Set(['designOverrides','sectionPages','announcement','analytics','draftsAllowedOrigins','draftDesign','ai','agent','live','push']);
1726
1858
  const entries = Object.entries(data).filter(([k,v]) => columns[k] && v !== undefined);
1727
1859
  if (!entries.length) return;
1728
1860
  const design = entries.some(([k]) => ['activeTheme','designOverrides','sectionPages','siteLogo','siteFavicon','siteCover','draftDesign'].includes(k));
@@ -1787,6 +1919,21 @@ export class SqlRepository implements ContentRepository {
1787
1919
  } }));
1788
1920
  await this.designWrite(opts.expectedRevision ?? current.designRevision, opts.author ?? 'admin', 'publish', statements);
1789
1921
  }
1922
+ async publishSectionPages(live: Settings['sectionPages'], staged: Settings['sectionPages'] | null, opts: { author?: string; summary?: string; expectedRevision: number }) {
1923
+ const current = await this.getSettings();
1924
+ const liveNav = await this.listNavItems();
1925
+ const draft: Record<string, unknown> = { ...(current.draftDesign ?? {}) };
1926
+ if (staged) draft.sectionPages = staged; else delete draft.sectionPages;
1927
+ // a draft with nothing left to publish goes away entirely
1928
+ const remaining = Object.keys(pickDesignDraft(draft)).length > 0 || Array.isArray(draft.navItems);
1929
+ const statements: SqlStatement[] = [
1930
+ { sql: 'INSERT INTO design_versions (id,created_at,author,summary,snapshot) VALUES (?,?,?,?,?)', args: [crypto.randomUUID(), Date.now(), opts.author ?? '', opts.summary ?? '', JSON.stringify(designSnapshot(current, liveNav))] },
1931
+ { sql: 'DELETE FROM design_versions WHERE id NOT IN (SELECT id FROM design_versions ORDER BY created_at DESC LIMIT 20)' },
1932
+ { sql: 'UPDATE settings SET section_pages=?,draft_design=? WHERE id=1', args: [JSON.stringify(live), remaining ? JSON.stringify({ ...draft, savedAt: Date.now() }) : null] },
1933
+ webhookStatement('design.published', { design: { revision: current.designRevision + 1, activeTheme: current.activeTheme, navigationChanged: false, sectionPageIds: Object.keys(live) } }),
1934
+ ];
1935
+ await this.designWrite(opts.expectedRevision, opts.author ?? 'admin', 'publish', statements);
1936
+ }
1790
1937
  async discardDesignDraft() { await this.setDesignDraft(null); }
1791
1938
  async listDesignVersions(limit = 20) {
1792
1939
  await this.ready;
@@ -1,6 +1,11 @@
1
1
  import type { LiveStore } from '../live/store';
2
2
  import type { BookingRequest } from '../backup/content';
3
3
  import type { EmailStore } from '../email/store';
4
+ import type { PushStore } from '../push/store';
5
+ import type { EditorStore } from '../editor/store';
6
+ export interface ImportRun { id: string; source: string; label: string; status: 'running' | 'done' | 'undone'; report: Record<string, unknown>; createdAt: number; updatedAt: number }
7
+ export type ImportRecordKind = 'post' | 'page' | 'user' | 'tag' | 'media' | 'redirect' | 'nav';
8
+ export interface ImportRecord { runId: string; kind: ImportRecordKind; sourceId: string; acmsId: string; action: 'created' | 'updated'; stamp: number }
4
9
  export interface DesignVersion { id: string; createdAt: number; author: string; summary: string; snapshot: Record<string, unknown> }
5
10
 
6
11
  import type { Page, Post, Link, Media, NavItem, Redirect, Settings, Snippet, Tag, User, Role, WidgetInstance, Calendar, CalEvent, ApiKey, ApiScope, Webhook, WebhookDelivery, Resource, BookingLink, Booking, BookingStatus } from '../schema';
@@ -34,7 +39,7 @@ export type EventUpdate = Partial<
34
39
  | 'title' | 'slug' | 'bodyJson' | 'bodyHtml' | 'excerpt' | 'cover' | 'coverAlt'
35
40
  | 'seoTitle' | 'seoDesc' | 'status' | 'featured' | 'tags' | 'template'
36
41
  | 'start' | 'end' | 'allDay' | 'timezone' | 'rrule' | 'exdates' | 'cancelled'
37
- | 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp'
42
+ | 'location' | 'cta' | 'calendarIds' | 'fields' | 'sourceUid' | 'rsvp' | 'translations'
38
43
  | 'publishedAt' | 'scheduledAt'
39
44
  >
40
45
  >;
@@ -44,6 +49,8 @@ export type EventUpdate = Partial<
44
49
  export interface ContentRepository {
45
50
  readonly email: EmailStore;
46
51
  readonly live: LiveStore;
52
+ readonly push: PushStore;
53
+ readonly editor: EditorStore;
47
54
  // Bounded public catalog; fetch limit + 1 IDs without loading every body.
48
55
  listPublicContent(options: { page: number; limit: number; kind?: 'post' | 'page'; collection?: string; locale?: string; collections: string[]; defaultLocale: string }): Promise<{ id: string; kind: 'post' | 'page' }[]>;
49
56
  exportBookingState(): Promise<{ bookings: Booking[]; bookingRequests: BookingRequest[] }>;
@@ -57,7 +64,7 @@ export interface ContentRepository {
57
64
  createPage(data: { title: string; slug: string }): Promise<Page>;
58
65
  updatePage(
59
66
  id: string,
60
- data: Partial<Pick<Page, 'title' | 'slug' | 'status' | 'isHome' | 'locale' | 'seoTitle' | 'seoDesc' | 'bodyJson' | 'bodyHtml' | 'template'>> & {
67
+ data: Partial<Pick<Page, 'title' | 'slug' | 'status' | 'isHome' | 'locale' | 'seoTitle' | 'seoDesc' | 'bodyJson' | 'bodyHtml' | 'template' | 'translations'>> & {
61
68
  blocks?: WidgetInstance[];
62
69
  }
63
70
  ): Promise<void>;
@@ -87,7 +94,7 @@ export interface ContentRepository {
87
94
  | 'title' | 'slug' | 'collection' | 'body' | 'bodyJson' | 'bodyHtml' | 'excerpt'
88
95
  | 'tags' | 'cover' | 'coverAlt' | 'seoTitle' | 'seoDesc'
89
96
  | 'status' | 'featured' | 'authorId' | 'authorIds' | 'template'
90
- | 'publishedAt' | 'scheduledAt'
97
+ | 'publishedAt' | 'scheduledAt' | 'translations'
91
98
  >
92
99
  >
93
100
  ): Promise<void>;
@@ -260,6 +267,20 @@ export interface ContentRepository {
260
267
  bookingRequests?: BookingRequest[];
261
268
  }): Promise<void>;
262
269
 
270
+ // imports (lib/import/apply.ts drives these). writeImported is the
271
+ // id-preserving bulk write for imported posts/pages: original timestamps
272
+ // are kept and publication webhooks stay silent (a migration of old posts
273
+ // is not news), in one transaction with the search index refreshed after.
274
+ createImportRun(run: Pick<ImportRun, 'id' | 'source' | 'label'>): Promise<void>;
275
+ updateImportRun(id: string, data: Partial<Pick<ImportRun, 'status' | 'report'>>): Promise<void>;
276
+ listImportRuns(): Promise<ImportRun[]>;
277
+ getImportRun(id: string): Promise<ImportRun | null>;
278
+ addImportRecords(records: ImportRecord[]): Promise<void>;
279
+ listImportRecords(runId: string): Promise<ImportRecord[]>;
280
+ // latest record per source id across runs that weren't undone
281
+ findImportRecords(kind: ImportRecordKind, sourceIds: string[]): Promise<ImportRecord[]>;
282
+ writeImported(data: { posts?: Post[]; pages?: Page[]; deletePosts?: string[]; deletePages?: string[] }): Promise<void>;
283
+
263
284
  // redirects — exact-path forwards, consulted when a request would 404.
264
285
  // createRedirect upserts on fromPath and prunes self/loop entries.
265
286
  listRedirects(): Promise<Redirect[]>;
@@ -281,6 +302,8 @@ export interface ContentRepository {
281
302
  // publish: snapshot the outgoing live design into design_versions (last 20
282
303
  // kept), apply the draft's settings + staged navigation, clear the draft
283
304
  applyDesignDraft(opts?: { author?: string; summary?: string; expectedRevision?: number }): Promise<void>;
305
+ /** publish section layouts only (one section's edits); the rest of the draft stays staged */
306
+ publishSectionPages(live: Settings['sectionPages'], staged: Settings['sectionPages'] | null, opts: { author?: string; summary?: string; expectedRevision: number }): Promise<void>;
284
307
  discardDesignDraft(): Promise<void>;
285
308
  listDesignVersions(limit?: number): Promise<DesignVersion[]>;
286
309
  getDesignVersion(id: string): Promise<DesignVersion | null>;
@@ -0,0 +1,5 @@
1
+ import type { Role } from './schema';
2
+
3
+ export const roleLabels: Record<Role, string> = {
4
+ contributor: 'Contributor', author: 'Author', editor: 'Editor', administrator: 'Administrator', owner: 'Owner',
5
+ };
@@ -3,6 +3,7 @@ import type { ContentRepository } from './repo/types';
3
3
  import { processDueDeliveries } from './webhooks';
4
4
  import { dispatchEmail } from './email/service';
5
5
  import { sendDueReminders } from './booking/service';
6
+ import { runPushJobs } from './push/service';
6
7
 
7
8
  // All work is awaited on both hosts. Traffic-triggered fallbacks do not mark
8
9
  // this heartbeat: monitoring must prove the independent scheduler ran.
@@ -15,6 +16,8 @@ export async function runScheduledTasks(repo: ContentRepository, locals?: unknow
15
16
  reminders: () => sendDueReminders(repo, { origin: settings.siteUrl, locals, now: startedAt }),
16
17
  email: () => dispatchEmail(repo, locals, 10),
17
18
  live: () => syncDueLive(repo, locals),
19
+ // queued first-publish notices, starting-soon reminders, receipts, retention
20
+ push: () => runPushJobs(repo, { locals, now: startedAt }),
18
21
  };
19
22
  const results: Record<string, { ok: boolean; count?: number }> = {};
20
23
  for (const [name, run] of Object.entries(jobs)) {
package/src/lib/schema.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { LiveConfig } from './live/types';
2
+ import { PushSettings } from './push/types';
2
3
  import { SectionPages } from '../theme-sdk/contract.mjs';
3
4
  import { AiConfig, AgentConfig } from './ai-schema';
4
5
  import { z } from 'zod';
@@ -64,6 +65,26 @@ export function parseConfig<T extends WidgetType>(type: T, raw: unknown): z.infe
64
65
  export const Status = z.enum(['draft', 'scheduled', 'published', 'archived']);
65
66
  export type Status = z.infer<typeof Status>;
66
67
 
68
+ // Per-language words for a post, page or event (the facts stay shared):
69
+ // translations: { es: { title, excerpt, bodyHtml, seoTitle, seoDesc, slug,
70
+ // locationName, ctaLabel, meta: { title: { state, src } } } }
71
+ // state 'ai' = machine draft (shown, flagged for review), 'reviewed' = a person
72
+ // wrote/approved it; src = hash of the main-language text it was made from.
73
+ export const DocTranslation = z.object({
74
+ title: z.string().max(1000).default(''),
75
+ excerpt: z.string().max(5000).default(''),
76
+ bodyHtml: z.string().max(2_000_000).default(''),
77
+ seoTitle: z.string().max(1000).default(''),
78
+ seoDesc: z.string().max(5000).default(''),
79
+ slug: z.string().max(300).default(''),
80
+ locationName: z.string().max(500).default(''),
81
+ ctaLabel: z.string().max(300).default(''),
82
+ meta: z.record(z.string().max(40), z.object({ state: z.enum(['ai', 'reviewed']), src: z.string().max(20) })).default({}),
83
+ });
84
+ export type DocTranslation = z.infer<typeof DocTranslation>;
85
+ export const DocTranslations = z.record(z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/), DocTranslation).default({});
86
+ export type DocTranslations = z.infer<typeof DocTranslations>;
87
+
67
88
  export const Page = z.object({
68
89
  id: z.string(),
69
90
  slug: z.string(),
@@ -83,6 +104,7 @@ export const Page = z.object({
83
104
  // theme template name (templates/page-<name>.astro); empty = default layout
84
105
  template: z.string().default(''),
85
106
  // pending edits on a live page (see lib/drafts.ts); null = none staged
107
+ translations: DocTranslations,
86
108
  draft: z.record(z.any()).nullable().default(null),
87
109
  createdAt: z.number(),
88
110
  updatedAt: z.number(),
@@ -117,6 +139,7 @@ export const Post = z.object({
117
139
  // theme template name (templates/post-<name>.astro); empty = default layout
118
140
  template: z.string().default(''),
119
141
  // pending edits on a live post (see lib/drafts.ts); null = none staged
142
+ translations: DocTranslations,
120
143
  draft: z.record(z.any()).nullable().default(null),
121
144
  publishedAt: z.number().nullable(),
122
145
  scheduledAt: z.number().nullable().default(null),
@@ -171,7 +194,7 @@ export const Snippet = z.object({
171
194
  });
172
195
  export type Snippet = z.infer<typeof Snippet>;
173
196
 
174
- export const Role = z.enum(['owner', 'editor']);
197
+ export const Role = z.enum(['contributor', 'author', 'editor', 'administrator', 'owner']);
175
198
  export type Role = z.infer<typeof Role>;
176
199
 
177
200
  // Password hashes never leave the repo layer — User is the public shape.
@@ -334,6 +357,7 @@ export const CalEvent = z.object({
334
357
  }).default({}),
335
358
  startAt: z.number(),
336
359
  endAt: z.number(),
360
+ translations: DocTranslations,
337
361
  draft: z.record(z.any()).nullable().default(null),
338
362
  publishedAt: z.number().nullable().default(null),
339
363
  scheduledAt: z.number().nullable().default(null),
@@ -560,6 +584,8 @@ export function normalizeRedirectPath(input: string): string {
560
584
 
561
585
  export const Settings = z.object({
562
586
  live: LiveConfig.default({}),
587
+ // push notification automation (lib/push); the feature itself is acms({ mobile: { push } })
588
+ push: PushSettings.default({}),
563
589
  siteTitle: z.string().default('My Site'),
564
590
  tagline: z.string().default(''),
565
591
  // site-wide footer, markdown allowed; empty falls back to "title — tagline"
@@ -0,0 +1,55 @@
1
+ import { z } from 'zod';
2
+ import sanitizeHtml from 'sanitize-html';
3
+ import type { AiConfig } from './ai-schema';
4
+ import { completeText } from './ai';
5
+ import { ApiError } from './api';
6
+
7
+ // "Help me write" for section fields: rewrite one field's text with the
8
+ // site's configured AI provider. The owner reviews the suggestion before it
9
+ // replaces anything; nothing is saved here.
10
+
11
+ export const AssistInput = z.object({
12
+ action: z.enum(['shorter', 'friendlier', 'clearer', 'translate', 'draft']),
13
+ text: z.string().max(20_000).default(''),
14
+ label: z.string().max(100),
15
+ kind: z.enum(['text', 'textarea', 'richtext']).default('text'),
16
+ hint: z.string().max(300).optional(),
17
+ maxLength: z.number().int().min(1).max(20_000).optional(),
18
+ language: z.string().max(40).optional(),
19
+ // other filled-in fields of the same section, for context ("Heading: Join us")
20
+ context: z.string().max(4000).optional(),
21
+ }).strict();
22
+
23
+ const ACTIONS: Record<z.infer<typeof AssistInput>['action'], string> = {
24
+ shorter: 'Make it noticeably shorter while keeping every fact (names, days, times, places, prices).',
25
+ friendlier: 'Make it warmer and more welcoming, plain words, no hype or exclamation marks.',
26
+ clearer: 'Fix spelling and grammar and make it clearer. Change as little as possible.',
27
+ translate: 'Translate it into the requested language. Keep names of people and places as they are.',
28
+ draft: 'Write it from scratch for this field, using the other fields as context. Be concrete and plain.',
29
+ };
30
+
31
+ export async function assistField(config: AiConfig, raw: unknown): Promise<{ text: string }> {
32
+ const a = AssistInput.parse(raw);
33
+ if (a.action !== 'draft' && !a.text.trim()) throw new ApiError(400, 'Write something first, or use “Write a first draft”.');
34
+ if (a.action === 'translate' && !a.language) throw new ApiError(400, 'Choose a language.');
35
+ const rules = [
36
+ 'You edit short text for a section of a website (for example a church, club or small business homepage).',
37
+ `The field is “${a.label}”.`,
38
+ ACTIONS[a.action],
39
+ a.action === 'translate' ? `Target language: ${a.language}.` : 'Keep the original language.',
40
+ a.hint ? `The site owner's guidance for this field: ${a.hint}` : '',
41
+ a.maxLength ? `Stay under ${a.maxLength} characters.` : '',
42
+ a.kind === 'richtext' ? 'Return simple HTML using only <p>, <strong>, <em>, <a href>, <ul>, <ol>, <li>. Keep existing links.' : 'Return plain text only.',
43
+ a.kind === 'text' ? 'One line, no line breaks.' : '',
44
+ 'Return only the new text: no quotes, labels, notes or alternatives. Never invent facts, dates, prices or contact details.',
45
+ ].filter(Boolean).join('\n');
46
+ const input = [a.context ? `Other fields in this section:\n${a.context}` : '', a.text ? `Current text:\n${a.text}` : 'The field is empty.'].filter(Boolean).join('\n\n');
47
+ let out = (await completeText(config, rules, input)).replace(/^```\w*\n?|\n?```$/g, '').trim();
48
+ if (/^["“].*["”]$/s.test(out)) out = out.slice(1, -1).trim();
49
+ out = a.kind === 'richtext'
50
+ ? sanitizeHtml(out, { allowedTags: ['p', 'br', 'strong', 'b', 'em', 'i', 'a', 'ul', 'ol', 'li'], allowedAttributes: { a: ['href'] }, allowedSchemes: ['http', 'https', 'mailto', 'tel'] })
51
+ : sanitizeHtml(out, { allowedTags: [], allowedAttributes: {} }).replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
52
+ if (a.kind === 'text') out = out.replace(/\s*\n+\s*/g, ' ');
53
+ if (!out) throw new ApiError(502, 'The AI returned nothing useful. Try again.');
54
+ return { text: out };
55
+ }
@@ -0,0 +1,62 @@
1
+ import type { ContentRepository } from './repo/types';
2
+ import { CONTENT, collectionUrl } from './content';
3
+ import { I18N, extraLocales, localeName } from './i18n';
4
+ import { translationEngine } from './translate';
5
+ import { CALENDAR, bookingLinkUrl, bookingsEnabled, eventUrl } from './calendar/config';
6
+
7
+ const NAMES: Record<string, string> = { en: 'English', es: 'Spanish', fr: 'French', de: 'German', pt: 'Portuguese', it: 'Italian', nl: 'Dutch', ko: 'Korean', zh: 'Chinese', ja: 'Japanese', tl: 'Tagalog', vi: 'Vietnamese', ar: 'Arabic', ru: 'Russian', uk: 'Ukrainian', pl: 'Polish' };
8
+ // the site's own languages first, then common ones
9
+ const languageNames = () => [...new Set([...(I18N.enabled ? I18N.locales : []).map((l) => NAMES[l.split('-')[0]] ?? l), 'English', 'Spanish', 'French', 'Portuguese', 'Korean', 'Chinese'])];
10
+
11
+ // Choices for the Design screen's section fields: what a link field can point
12
+ // at (stored as a ref so it follows slug changes) and what a source field can
13
+ // show. Published/public things only — this is what visitors can reach.
14
+
15
+ export interface LinkTarget { ref: string; label: string; url: string; group: 'Pages' | 'Posts' | 'Events' | 'Booking' }
16
+ export interface SectionTargets {
17
+ links: LinkTarget[];
18
+ calendars: { slug: string; name: string }[];
19
+ collections: { key: string; label: string }[];
20
+ tags: string[];
21
+ resources: { slug: string; name: string }[];
22
+ features: { events: boolean; bookings: boolean };
23
+ /** AI help for text fields: provider configured + languages to translate into */
24
+ ai: { enabled: boolean; languages: string[] };
25
+ /** extra site languages to translate into, and whether machine drafts are available */
26
+ i18n: { defaultLocale: string; locales: { code: string; name: string }[]; drafts: boolean };
27
+ }
28
+
29
+ export async function sectionTargets(repo: ContentRepository, locals?: unknown): Promise<SectionTargets> {
30
+ const bookings = bookingsEnabled();
31
+ const settings = await repo.getSettings();
32
+ const [pages, posts, events, calendars, tags, resources, links] = await Promise.all([
33
+ repo.listPages(),
34
+ repo.listPublishedPosts(),
35
+ CALENDAR.enabled ? repo.listPublishedEvents() : Promise.resolve([]),
36
+ CALENDAR.enabled ? repo.listCalendars() : Promise.resolve([]),
37
+ repo.listTags(),
38
+ bookings ? repo.listResources() : Promise.resolve([]),
39
+ bookings ? repo.listBookingLinks() : Promise.resolve([]),
40
+ ]);
41
+ const publicResources = resources.filter((r) => r.visibility === 'public');
42
+ const bySlug = new Map(resources.map((r) => [r.id, r]));
43
+ const out: LinkTarget[] = [
44
+ ...pages.filter((p) => p.status === 'published').map((p): LinkTarget => ({ ref: `page:${p.id}`, label: p.title || p.slug, url: p.isHome ? '/' : `/${p.slug}`, group: 'Pages' })),
45
+ ...posts.slice(0, 200).map((p): LinkTarget => ({ ref: `post:${p.id}`, label: p.title, url: collectionUrl(p.collection, p.slug), group: 'Posts' })),
46
+ ...events.slice(0, 200).map((e): LinkTarget => ({ ref: `event:${e.id}`, label: e.title, url: eventUrl(e.slug), group: 'Events' })),
47
+ ...links.filter((l) => l.active && bySlug.has(l.resourceId)).map((l): LinkTarget => {
48
+ const r = bySlug.get(l.resourceId)!;
49
+ return { ref: `booking:${l.id}`, label: `${r.name} · ${l.name}`, url: bookingLinkUrl(r.slug, l.slug), group: 'Booking' };
50
+ }),
51
+ ];
52
+ return {
53
+ links: out,
54
+ calendars: calendars.filter((c) => c.visibility !== 'private').map((c) => ({ slug: c.slug, name: c.name })),
55
+ collections: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, label: c.plural })),
56
+ tags: [...new Set(tags.map((t) => t.name))].sort((a, b) => a.localeCompare(b)),
57
+ resources: publicResources.map((r) => ({ slug: r.slug, name: r.name })),
58
+ features: { events: CALENDAR.enabled, bookings },
59
+ ai: { enabled: Boolean(settings.ai.enabled && settings.ai.model), languages: languageNames() },
60
+ i18n: { defaultLocale: I18N.defaultLocale, locales: extraLocales().map((code) => ({ code, name: localeName(code, true) })), drafts: translationEngine(settings.ai, locals) !== null },
61
+ };
62
+ }