@acms-subir/core 0.3.3-next.20260929113209 → 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 (251) 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 +55 -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 +4 -1
  117. package/src/components/import/runner.ts +1 -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 +31 -2
  151. package/src/lib/import/bundle.ts +4 -0
  152. package/src/lib/import/export.ts +1 -0
  153. package/src/lib/import/ghost.ts +1 -0
  154. package/src/lib/media/upload.ts +83 -0
  155. package/src/lib/mobile/associations.ts +25 -0
  156. package/src/lib/mobile/calendar.ts +19 -11
  157. package/src/lib/mobile/config.mjs +59 -0
  158. package/src/lib/mobile/content.ts +18 -13
  159. package/src/lib/mobile/embed.ts +83 -0
  160. package/src/lib/mobile/http.ts +8 -6
  161. package/src/lib/mobile/locale.ts +18 -0
  162. package/src/lib/mobile/openapi.ts +111 -0
  163. package/src/lib/mobile/pages.ts +71 -0
  164. package/src/lib/mobile/route.ts +3 -3
  165. package/src/lib/mobile/schemas.ts +60 -0
  166. package/src/lib/mobile/site.ts +41 -0
  167. package/src/lib/push/api.ts +158 -0
  168. package/src/lib/push/config.ts +20 -0
  169. package/src/lib/push/expo.ts +76 -0
  170. package/src/lib/push/migration.ts +60 -0
  171. package/src/lib/push/service.ts +302 -0
  172. package/src/lib/push/store.ts +144 -0
  173. package/src/lib/push/types.ts +43 -0
  174. package/src/lib/repo/migrations.ts +11 -0
  175. package/src/lib/repo/sql.ts +40 -7
  176. package/src/lib/repo/types.ts +9 -3
  177. package/src/lib/roles.ts +5 -0
  178. package/src/lib/scheduler.ts +3 -0
  179. package/src/lib/schema.ts +27 -1
  180. package/src/lib/section-ai.ts +55 -0
  181. package/src/lib/section-targets.ts +62 -0
  182. package/src/lib/sections-agent.ts +146 -0
  183. package/src/lib/sections.ts +217 -0
  184. package/src/lib/theme.ts +16 -1
  185. package/src/lib/translate.ts +97 -0
  186. package/src/lib/translations.ts +39 -0
  187. package/src/middleware.ts +32 -0
  188. package/src/pages/.well-known/apple-app-site-association.ts +6 -0
  189. package/src/pages/.well-known/assetlinks.json.ts +6 -0
  190. package/src/pages/[...slug].astro +8 -3
  191. package/src/pages/admin/app-auth.astro +159 -0
  192. package/src/pages/admin/c/[collection]/[id].astro +9 -0
  193. package/src/pages/admin/calendar/events/[id].astro +9 -0
  194. package/src/pages/admin/login.astro +6 -2
  195. package/src/pages/admin/logout.ts +5 -2
  196. package/src/pages/admin/mobile.astro +18 -0
  197. package/src/pages/admin/pages/[id].astro +62 -0
  198. package/src/pages/admin/section-preview.astro +61 -0
  199. package/src/pages/admin/users.astro +107 -11
  200. package/src/pages/api/agent/docs.ts +6 -2
  201. package/src/pages/api/agent/index.ts +1 -1
  202. package/src/pages/api/ai/settings.ts +7 -4
  203. package/src/pages/api/design/ai.ts +15 -0
  204. package/src/pages/api/design/index.ts +12 -42
  205. package/src/pages/api/design/translate.ts +15 -0
  206. package/src/pages/api/live/thumb.ts +1 -1
  207. package/src/pages/api/mcp.ts +2 -2
  208. package/src/pages/api/media/upload.ts +10 -78
  209. package/src/pages/api/push/admin.ts +27 -0
  210. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
  211. package/src/pages/api/v1/admin/bookings/index.ts +6 -0
  212. package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
  213. package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
  214. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
  215. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
  216. package/src/pages/api/v1/admin/documents/index.ts +13 -0
  217. package/src/pages/api/v1/admin/me.ts +6 -0
  218. package/src/pages/api/v1/admin/media/index.ts +32 -0
  219. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
  220. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
  221. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
  222. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
  223. package/src/pages/api/v1/admin/sections/index.ts +6 -0
  224. package/src/pages/api/v1/auth/revoke.ts +5 -0
  225. package/src/pages/api/v1/auth/token.ts +5 -0
  226. package/src/pages/api/v1/devices/[token].ts +20 -0
  227. package/src/pages/api/v1/devices/index.ts +17 -0
  228. package/src/pages/api/v1/openapi.json.ts +7 -0
  229. package/src/pages/api/v1/pages/[...slug].ts +7 -0
  230. package/src/pages/api/v1/pages/home.ts +7 -0
  231. package/src/pages/api/v1/push/topics.ts +7 -0
  232. package/src/pages/api/v1/site.ts +7 -0
  233. package/src/pages/api/v1/theme.ts +7 -0
  234. package/src/pages/blog/[slug].astro +8 -2
  235. package/src/pages/blog/index.astro +11 -4
  236. package/src/pages/embed/section/[...path].astro +42 -0
  237. package/src/pages/events/[slug].astro +7 -2
  238. package/src/pages/index.astro +11 -5
  239. package/src/pages/sitemap.xml.ts +8 -0
  240. package/src/styles/admin.css +69 -0
  241. package/src/styles/editor.css +21 -0
  242. package/src/styles/mobile-app.css +151 -0
  243. package/src/styles/sections.css +7 -1
  244. package/src/styles/theme-editor.css +255 -0
  245. package/src/theme-sdk/contract.d.mts +2193 -89
  246. package/src/theme-sdk/contract.mjs +77 -20
  247. package/src/theme-sdk/fields.d.mts +88 -0
  248. package/src/theme-sdk/fields.mjs +326 -0
  249. package/src/theme-sdk/index.ts +10 -2
  250. package/src/theme-sdk/tokens.d.mts +11 -0
  251. package/src/virtual.d.ts +1 -0
@@ -0,0 +1,146 @@
1
+ // Agent / MCP tools for editable sections: an agent can read what sections a
2
+ // page can use, arrange a layout, and change a section's data ("move Youth
3
+ // Night to 7:30") without touching theme code. Everything stages into the
4
+ // design draft; publishing one section needs the owner's publish permission.
5
+ import { z } from 'zod';
6
+ import type { ContentRepository } from './repo/types';
7
+ import { ApiError } from './api';
8
+ import { FieldValue, sectionIssues } from '../theme-sdk/contract.mjs';
9
+ import { initialData, normalizeData, translationUnits, unitStatus } from '../theme-sdk/fields.mjs';
10
+ import { extraLocales } from './i18n';
11
+ import { publishOneSection, validateSectionDesign } from './design-service';
12
+ import type { Settings } from './schema';
13
+
14
+ const Data = z.record(z.string().max(100), FieldValue);
15
+ export const SectionToolInputs = [
16
+ z.object({ tool: z.literal('list_section_types'), arguments: z.object({}).strict() }).strict(),
17
+ z.object({ tool: z.literal('get_page_layout'), arguments: z.object({ pageId: z.string().min(1).max(100) }).strict() }).strict(),
18
+ z.object({ tool: z.literal('set_page_layout'), arguments: z.object({
19
+ pageId: z.string().min(1).max(100),
20
+ mode: z.enum(['sections', 'content']).default('sections'),
21
+ sections: z.array(z.object({
22
+ id: z.string().min(1).max(100).optional(), type: z.string().min(1).max(50),
23
+ variant: z.string().max(50).optional(), hidden: z.boolean().optional(), data: Data.default({}),
24
+ }).strict()).max(50),
25
+ }).strict() }).strict(),
26
+ z.object({ tool: z.literal('update_section_data'), arguments: z.object({
27
+ pageId: z.string().min(1).max(100), sectionId: z.string().min(1).max(100),
28
+ data: Data.default({}), variant: z.string().max(50).optional(), hidden: z.boolean().optional(),
29
+ // words in another site language, by path (see get_page_layout's translationPaths); stored as AI drafts for review
30
+ translation: z.object({ locale: z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/), data: z.record(z.string().max(200), z.string().max(20_000)) }).strict().optional(),
31
+ publish: z.boolean().default(false),
32
+ }).strict() }).strict(),
33
+ ] as const;
34
+ export const SECTION_TOOL_NAMES = ['list_section_types', 'get_page_layout', 'set_page_layout', 'update_section_data'] as const;
35
+ export const SECTION_TOOL_DESCRIPTIONS: Record<(typeof SECTION_TOOL_NAMES)[number], string> = {
36
+ list_section_types: 'List the section types the active theme offers (built-in and coded), with each field’s type, variants and the theme’s starting layouts. Read this before arranging a page.',
37
+ get_page_layout: 'Read a page’s section layout: the staged draft if one exists, otherwise the live layout. Returns section ids, types, variants, data, and per site language the translatable paths with their English text and status (missing, ai, reviewed, outdated).',
38
+ set_page_layout: 'Replace a page’s whole section layout in the design draft. Never publishes. Keep existing section ids to preserve them; omit id for new sections. Data keys must match the section’s fields.',
39
+ update_section_data: 'Change one section’s data (merged over existing values; lists are replaced whole), variant or visibility in the design draft. `translation: { locale, data: { path: text } }` writes words in another site language (paths from get_page_layout) as AI drafts for review. publish: true also makes just this section live when the owner allows agent publishing.',
40
+ };
41
+
42
+ type Pages = Settings['sectionPages'];
43
+ type Input = { tool: (typeof SECTION_TOOL_NAMES)[number]; arguments: Record<string, unknown> };
44
+ const reviewUrl = (pageId: string, sectionId?: string) => `/admin/design?page=${encodeURIComponent(pageId)}${sectionId ? `&section=${encodeURIComponent(sectionId)}` : ''}#pages`;
45
+ const newId = (type: string) => `${type}-${crypto.randomUUID().slice(0, 8)}`;
46
+
47
+ export async function runSectionTool(repo: ContentRepository, input: Input, allowPublish: boolean) {
48
+ // lazy: the theme registry globs .astro templates, which only Astro can load
49
+ const { getTheme } = await import('./theme');
50
+ const settings = await repo.getSettings();
51
+ if ((input.tool === 'set_page_layout' || input.tool === 'update_section_data') && !settings.agent.allowSections) {
52
+ throw new ApiError(403, 'Section editing is off for agents. An owner can turn it on in Settings → AI & agents.');
53
+ }
54
+ const themeSlug = String(settings.draftDesign?.activeTheme ?? settings.activeTheme);
55
+ const theme = getTheme(themeSlug);
56
+ const staged = (settings.draftDesign?.sectionPages ?? settings.sectionPages) as Pages;
57
+
58
+ if (input.tool === 'list_section_types') {
59
+ return {
60
+ theme: theme.slug,
61
+ sections: Object.entries(theme.sections).map(([type, d]) => ({
62
+ type, name: d.name, description: d.description ?? '', version: d.version, variants: d.variants,
63
+ coded: type in (theme.sectionTemplates ?? {}), fields: d.fields,
64
+ })),
65
+ presets: Object.entries(theme.presets ?? {}).map(([key, p]) => ({ key, name: p.name, description: p.description ?? '', sections: p.sections })),
66
+ pages: (await repo.listPages()).map((p) => ({ id: p.id, title: p.title, slug: p.slug, isHome: p.isHome, hasLayout: Boolean(staged[p.id]) })),
67
+ };
68
+ }
69
+
70
+ const pageId = String(input.arguments.pageId);
71
+ const page = await repo.getPage(pageId);
72
+ if (!page) throw new ApiError(404, 'Page not found.');
73
+
74
+ if (input.tool === 'get_page_layout') {
75
+ const layout = staged[pageId] ?? null;
76
+ return {
77
+ pageId, title: page.title, theme: theme.slug, layout,
78
+ staged: JSON.stringify(layout) !== JSON.stringify(settings.sectionPages[pageId] ?? null),
79
+ issues: layout ? sectionIssues({ ...layout, sections: layout.sections.filter((s) => !s.hidden) }, theme.sections) : [],
80
+ translations: layout ? Object.fromEntries(extraLocales().map((l) => [l, layout.sections.flatMap((s) => {
81
+ const def = theme.sections[s.type];
82
+ return def ? (translationUnits(def, s.data) as { path: string; source: string; hash: string }[]).map((u) => ({ sectionId: s.id, path: u.path, english: u.source, status: unitStatus(u, s.translations?.[l]), text: s.translations?.[l]?.data?.[u.path] ?? '' })) : [];
83
+ })])) : {},
84
+ reviewUrl: reviewUrl(pageId),
85
+ };
86
+ }
87
+
88
+ if (input.tool === 'set_page_layout') {
89
+ const a = z.object(SectionToolInputs[2].shape.arguments.shape).parse(input.arguments);
90
+ const previous = new Map((staged[pageId]?.sections ?? []).map((s) => [s.id, s]));
91
+ const ids = new Set<string>();
92
+ const sections = a.sections.map((s) => {
93
+ const def = theme.sections[s.type];
94
+ if (!def) throw new ApiError(400, `Unknown section type “${s.type}”. Call list_section_types.`);
95
+ const unknown = Object.keys(s.data).filter((k) => !(k in def.fields));
96
+ if (unknown.length) throw new ApiError(400, `Section ${s.type} has no field(s): ${unknown.join(', ')}.`);
97
+ const id = s.id && !ids.has(s.id) ? s.id : newId(s.type);
98
+ ids.add(id);
99
+ const prev = previous.get(id);
100
+ const variant = s.variant ?? prev?.variant ?? def.variants[0];
101
+ if (!def.variants.includes(variant)) throw new ApiError(400, `Section ${s.type} has no variant “${variant}”. Use one of: ${def.variants.join(', ')}.`);
102
+ return { id, type: s.type, version: def.version, variant, hidden: s.hidden ?? prev?.hidden ?? false, ...(prev?.type === s.type && prev.translations ? { translations: prev.translations } : {}),
103
+ data: normalizeData(def, { ...initialData(def), ...(prev?.type === s.type ? prev.data : {}), ...s.data }) as Pages[string]['sections'][number]['data'] };
104
+ });
105
+ const layout = { mode: a.mode, sections };
106
+ const issues = sectionIssues({ ...layout, sections: sections.filter((s) => !s.hidden) }, theme.sections);
107
+ if (issues.length) throw new ApiError(400, `Fix these before staging: ${issues.slice(0, 5).map((i) => i.message).join('; ')}`);
108
+ await repo.setDesignDraft({ ...settings.draftDesign, sectionPages: { ...staged, [pageId]: layout } }, settings.designRevision, 'agent');
109
+ return { pageId, staged: true, sections: sections.map((s) => ({ id: s.id, type: s.type, variant: s.variant })), reviewUrl: reviewUrl(pageId) };
110
+ }
111
+
112
+ // update_section_data
113
+ const a = z.object(SectionToolInputs[3].shape.arguments.shape).parse(input.arguments);
114
+ const layout = staged[pageId];
115
+ const current = layout?.sections.find((s) => s.id === a.sectionId);
116
+ if (!layout || !current) throw new ApiError(404, 'Section not found on that page. Call get_page_layout.');
117
+ const def = theme.sections[current.type];
118
+ if (!def) throw new ApiError(409, `The active theme has no “${current.type}” section.`);
119
+ const unknown = Object.keys(a.data).filter((k) => !(k in def.fields));
120
+ if (unknown.length) throw new ApiError(400, `Section ${current.type} has no field(s): ${unknown.join(', ')}.`);
121
+ if (a.variant && !def.variants.includes(a.variant)) throw new ApiError(400, `Use one of: ${def.variants.join(', ')}.`);
122
+ let next = { ...current, variant: a.variant ?? current.variant, hidden: a.hidden ?? current.hidden, data: { ...current.data, ...a.data } };
123
+ if (a.translation) {
124
+ if (!extraLocales().includes(a.translation.locale)) throw new ApiError(400, `“${a.translation.locale}” isn’t one of this site’s extra languages.`);
125
+ const units = new Map((translationUnits(def, next.data) as { path: string; hash: string }[]).map((u) => [u.path, u]));
126
+ const bad = Object.keys(a.translation.data).filter((p) => !units.has(p));
127
+ if (bad.length) throw new ApiError(400, `No translatable text at: ${bad.join(', ')}. Call get_page_layout for paths.`);
128
+ const cur = next.translations?.[a.translation.locale] ?? { data: {}, meta: {} };
129
+ const tdata = { ...cur.data }, meta = { ...cur.meta };
130
+ for (const [path, text] of Object.entries(a.translation.data)) {
131
+ if (text.trim()) { tdata[path] = text; meta[path] = { state: 'ai', src: units.get(path)!.hash }; } else { delete tdata[path]; delete meta[path]; }
132
+ }
133
+ next = { ...next, translations: { ...(next.translations ?? {}), [a.translation.locale]: { data: tdata, meta } } };
134
+ }
135
+ const issues = sectionIssues({ mode: 'sections', sections: [next] }, theme.sections);
136
+ if (issues.length) throw new ApiError(400, `Fix these before staging: ${issues.map((i) => i.message).join('; ')}`);
137
+ const pages: Pages = { ...staged, [pageId]: { ...layout, sections: layout.sections.map((s) => (s.id === next.id ? next : s)) } };
138
+ await repo.setDesignDraft({ ...settings.draftDesign, sectionPages: pages }, settings.designRevision, 'agent');
139
+ if (!a.publish) return { pageId, sectionId: next.id, staged: true, published: false, reviewUrl: reviewUrl(pageId, next.id) };
140
+ if (!allowPublish) throw new ApiError(403, 'Staged, but agent publishing is disabled. An owner can publish it in Design → Pages.');
141
+ const fresh = await repo.getSettings();
142
+ const out = publishOneSection(fresh.sectionPages, (fresh.draftDesign?.sectionPages ?? fresh.sectionPages) as Pages, pageId, next.id);
143
+ validateSectionDesign({ ...fresh, sectionPages: out.live }, getTheme(fresh.activeTheme));
144
+ await repo.publishSectionPages(out.live, out.staged, { expectedRevision: fresh.designRevision, author: 'agent', summary: `Section: ${next.type}` });
145
+ return { pageId, sectionId: next.id, staged: false, published: true, reviewUrl: reviewUrl(pageId, next.id) };
146
+ }
@@ -0,0 +1,217 @@
1
+ import sanitizeHtml from 'sanitize-html';
2
+ import type { z } from 'zod';
3
+ import type { ContentRepository } from './repo/types';
4
+ import type { Settings } from './schema';
5
+ import type { SectionInstance as SectionInstanceSchema, SectionDefinition } from '../theme-sdk/contract.mjs';
6
+ import { normalizeData, safeUrl, SOURCE_LIMIT_MAX, isEmptyValue, safeFocus, focusKey } from '../theme-sdk/fields.mjs';
7
+ import type { Theme } from './theme';
8
+ import { CONTENT, collectionUrl } from './content';
9
+ import { formatDate, siteLocale } from './dates';
10
+ import { upcomingEvents } from './calendar/cards';
11
+ import { formatOccurrence, locationLine } from './calendar/events';
12
+ import { CALENDAR, bookingLinkUrl, eventUrl, bookingsEnabled } from './calendar/config';
13
+ import { localePath } from './i18n';
14
+ import { localizeDoc, isExtraLocale } from './translations';
15
+
16
+ // Server side of editable sections: turn a stored instance into the props a
17
+ // coded section component receives. Stored data stays exactly as the owner
18
+ // saved it; everything here is derived per request —
19
+ // • migrations/<type>.mjs upgrades older instances on the fly
20
+ // • link fields resolve their ref (page/post/event/booking) to today's URL
21
+ // • richtext is sanitized
22
+ // • source fields ("next 3 Youth events") become fresh item arrays
23
+
24
+ type Instance = z.infer<typeof SectionInstanceSchema>;
25
+ type Definition = z.infer<typeof SectionDefinition>;
26
+ type FieldDef = Definition['fields'][string];
27
+
28
+ export interface LinkValue { label: string; href: string; external: boolean }
29
+ export interface EventItem {
30
+ title: string; url: string; summary: string; image: string; imageAlt: string; location: string;
31
+ start: string; end: string; allDay: boolean; cancelled: boolean;
32
+ /** "Sat, Oct 3 · 11:00 AM" */ when: string; /** "Saturday, October 3, 2026" */ date: string;
33
+ /** "11:00 AM – 1:00 PM EDT" */ time: string; month: string; day: string; weekday: string;
34
+ cta: { label: string; href: string } | null;
35
+ }
36
+ export interface PostItem { title: string; url: string; excerpt: string; image: string; imageAlt: string; date: string; publishedAt: number | null; tags: string[] }
37
+ export interface BookingItem { title: string; url: string; description: string; resource: string; minutes: number }
38
+
39
+ /** apply a theme migration when the stored instance is older than the definition */
40
+ export function migrateSection(theme: Pick<Theme, 'sections' | 'migrations'>, s: Instance): Instance {
41
+ const def = theme.sections[s.type];
42
+ const migrate = theme.migrations?.[s.type];
43
+ if (!def || !migrate || s.version >= def.version) return s;
44
+ try {
45
+ const data = migrate(structuredClone(s.data) as Record<string, unknown>, s.version);
46
+ if (!data || typeof data !== 'object') return s;
47
+ return { ...s, version: def.version, data: data as Instance['data'] };
48
+ } catch {
49
+ return s; // a throwing migration leaves the section flagged, never crashes the page
50
+ }
51
+ }
52
+
53
+ const RICH = {
54
+ allowedTags: ['p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'a', 'ul', 'ol', 'li', 'h3', 'h4', 'blockquote', 'code'],
55
+ allowedAttributes: { a: ['href', 'title', 'target', 'rel'] },
56
+ allowedSchemes: ['http', 'https', 'mailto', 'tel'],
57
+ allowProtocolRelative: false,
58
+ transformTags: { a: sanitizeHtml.simpleTransform('a', { rel: 'noopener' }) },
59
+ };
60
+ export const sanitizeRichText = (html: string) => sanitizeHtml(String(html ?? ''), RICH);
61
+
62
+ type RefMap = Map<string, string>;
63
+
64
+ async function resolveRefs(refs: Set<string>, repo: ContentRepository): Promise<RefMap> {
65
+ const out: RefMap = new Map();
66
+ await Promise.all([...refs].map(async (ref) => {
67
+ const [kind, id] = ref.split(':');
68
+ try {
69
+ if (kind === 'page') {
70
+ const p = await repo.getPage(id);
71
+ if (p && p.status === 'published') out.set(ref, p.isHome ? '/' : `/${p.slug}`);
72
+ } else if (kind === 'post') {
73
+ const p = await repo.getPost(id);
74
+ if (p && p.status === 'published') out.set(ref, collectionUrl(p.collection, p.slug));
75
+ } else if (kind === 'event' && CALENDAR.enabled) {
76
+ const e = await repo.getEvent(id);
77
+ if (e && e.status === 'published') out.set(ref, eventUrl(e.slug));
78
+ } else if (kind === 'booking' && bookingsEnabled()) {
79
+ const l = await repo.getBookingLink(id);
80
+ const r = l ? await repo.getResource(l.resourceId) : null;
81
+ if (l && r && l.active) out.set(ref, bookingLinkUrl(r.slug, l.slug));
82
+ }
83
+ } catch { /* unresolved refs fall back to the stored href */ }
84
+ }));
85
+ return out;
86
+ }
87
+
88
+ function collectRefs(def: Definition, data: Record<string, unknown>, into: Set<string>) {
89
+ const visit = (field: FieldDef, value: unknown) => {
90
+ if (field.type === 'link' && value && typeof value === 'object' && typeof (value as { ref?: unknown }).ref === 'string') into.add((value as { ref: string }).ref);
91
+ if (field.type === 'list' && Array.isArray(value)) for (const row of value) for (const [k, sub] of Object.entries(field.fields)) visit(sub as FieldDef, (row as Record<string, unknown>)[k]);
92
+ };
93
+ for (const [key, field] of Object.entries(def.fields)) visit(field, data[key]);
94
+ }
95
+
96
+ type Lp = (path: string) => string;
97
+ const same: Lp = (p) => p;
98
+ function resolveLink(value: unknown, refs: RefMap, lp: Lp = same): LinkValue {
99
+ const v = (value ?? {}) as { label?: string; href?: string; ref?: string };
100
+ const raw = (v.ref && refs.get(v.ref)) || safeUrl(v.href);
101
+ // site-internal links stay in the visitor's language (/visit → /es/visit)
102
+ const href = /^\/(?!\/|media\/|rss\.xml|admin|api|_)/.test(raw) ? lp(raw) : raw;
103
+ return { label: String(v.label ?? ''), href, external: /^https?:\/\//i.test(href) };
104
+ }
105
+
106
+ function resolveScalar(field: FieldDef, value: unknown, refs: RefMap, lp: Lp = same): unknown {
107
+ switch (field.type) {
108
+ case 'richtext': return sanitizeRichText(String(value ?? ''));
109
+ case 'url': case 'image': return safeUrl(value);
110
+ case 'link': return resolveLink(value, refs, lp);
111
+ default: return value;
112
+ }
113
+ }
114
+
115
+ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value: unknown, ctx: ResolveContext): Promise<unknown[]> {
116
+ const cfg = (value ?? {}) as { calendar?: string; collection?: string; tag?: string; resource?: string; limit?: number };
117
+ const limit = Math.max(1, Math.min(SOURCE_LIMIT_MAX, Number(cfg.limit) || 3));
118
+ const { repo, settings, now } = ctx;
119
+ // another language (settings.locale is set to it by the renderer)
120
+ const lang = isExtraLocale(settings.locale) ? settings.locale : '';
121
+ const lp = (path: string) => (lang ? localePath(path, lang) : path);
122
+ if (field.source === 'events') {
123
+ if (!CALENDAR.enabled) return [];
124
+ let calendarIds: string[] | undefined;
125
+ if (cfg.calendar) {
126
+ const c = await repo.getCalendarBySlug(cfg.calendar);
127
+ if (!c || c.visibility === 'private') return [];
128
+ calendarIds = [c.id];
129
+ }
130
+ const tz = settings.timezone || 'UTC';
131
+ const locale = siteLocale(settings);
132
+ return (await upcomingEvents(repo, settings, now, { calendarIds, limit })).map((o): EventItem => {
133
+ const e = lang ? localizeDoc(o.event, lang) : o.event;
134
+ const f = formatOccurrence(o, tz, locale);
135
+ const ctaHref = safeUrl(e.cta.href);
136
+ return {
137
+ title: e.title || 'Untitled event', url: lp(eventUrl(e.slug)), summary: e.excerpt, image: safeUrl(e.cover), imageAlt: e.coverAlt,
138
+ location: locationLine(e.location), start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
139
+ allDay: o.allDay, cancelled: e.cancelled, when: f.range, date: f.date, time: f.time, month: f.month, day: f.day, weekday: f.weekday,
140
+ cta: e.cta.label && ctaHref && !e.cancelled ? { label: e.cta.label, href: ctaHref } : null,
141
+ };
142
+ });
143
+ }
144
+ if (field.source === 'posts') {
145
+ const collection = cfg.collection && CONTENT.collections[cfg.collection] ? cfg.collection : 'posts';
146
+ let posts = await repo.listPublishedPosts(collection);
147
+ if (cfg.tag) posts = posts.filter((p) => p.tags.some((t) => t.toLowerCase() === cfg.tag!.toLowerCase()));
148
+ return posts.slice(0, limit).map((raw): PostItem => { const p = lang ? localizeDoc(raw, lang) : raw; return {
149
+ title: p.title, url: lp(collectionUrl(p.collection, p.slug)), excerpt: p.excerpt, image: safeUrl(p.cover), imageAlt: p.coverAlt,
150
+ date: formatDate(p.publishedAt, settings, 'short'), publishedAt: p.publishedAt ?? null, tags: p.tags,
151
+ }; });
152
+ }
153
+ if (!bookingsEnabled()) return [];
154
+ const resources = (await repo.listResources()).filter((r) => r.visibility === 'public' && (!cfg.resource || r.slug === cfg.resource));
155
+ const byId = new Map(resources.map((r) => [r.id, r]));
156
+ const links = (await repo.listBookingLinks()).filter((l) => l.active && l.visibility === 'public' && byId.has(l.resourceId));
157
+ return links.slice(0, limit).map((l): BookingItem => {
158
+ const r = byId.get(l.resourceId)!;
159
+ return { title: l.name, url: bookingLinkUrl(r.slug, l.slug), description: l.description, resource: r.name, minutes: l.duration };
160
+ });
161
+ }
162
+
163
+ export interface ResolveContext { repo: ContentRepository; settings: Settings; now: number }
164
+
165
+ /** stored data → component props: defaults filled, refs/sources resolved, richtext sanitized */
166
+ export async function resolveSectionData(def: Definition, stored: Record<string, unknown>, ctx: ResolveContext): Promise<Record<string, unknown>> {
167
+ const data = normalizeData(def, stored) as Record<string, unknown>;
168
+ const refs = new Set<string>();
169
+ collectRefs(def, data, refs);
170
+ const refMap = refs.size ? await resolveRefs(refs, ctx.repo) : new Map<string, string>();
171
+ const lang = isExtraLocale(ctx.settings.locale) ? ctx.settings.locale : '';
172
+ const lp: Lp = lang ? (p) => localePath(p, lang) : same;
173
+ const out: Record<string, unknown> = {};
174
+ await Promise.all(Object.entries(def.fields).map(async ([key, field]) => {
175
+ const value = data[key];
176
+ if (field.type === 'source') out[key] = await resolveSource(field, value, ctx).catch(() => []);
177
+ else if (field.type === 'list') {
178
+ out[key] = (value as Record<string, unknown>[]).map((row) => {
179
+ const item: Record<string, unknown> = {};
180
+ for (const [k, sub] of Object.entries(field.fields)) {
181
+ item[k] = resolveScalar(sub as FieldDef, row[k], refMap, lp);
182
+ if (sub.type === 'image') item[`${k}Focus`] = safeFocus(row[focusKey(k)]);
183
+ }
184
+ return item;
185
+ });
186
+ } else {
187
+ out[key] = resolveScalar(field, value, refMap, lp);
188
+ // images also hand over their focal point, ready for object-position
189
+ if (field.type === 'image') out[`${key}Focus`] = safeFocus(stored[focusKey(key)]);
190
+ }
191
+ }));
192
+ return out;
193
+ }
194
+
195
+
196
+ /** "YYYY-MM-DDTHH:MM" for now in the site timezone (section schedules are wall-clock) */
197
+ export function wallNow(tz: string, now = Date.now()): string {
198
+ try {
199
+ const p = Object.fromEntries(new Intl.DateTimeFormat('en-CA', { timeZone: tz || 'UTC', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(now).map((x) => [x.type, x.value]));
200
+ return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}`;
201
+ } catch {
202
+ return new Date(now).toISOString().slice(0, 16);
203
+ }
204
+ }
205
+
206
+ /** admin preview only: empty fields borrow the definition's example (never saved) */
207
+ export function withExample(def: Definition, data: Record<string, unknown>): { data: Record<string, unknown>; used: boolean } {
208
+ const example = (def as { example?: Record<string, unknown> }).example;
209
+ if (!example) return { data, used: false };
210
+ const normalized = normalizeData(def, data) as Record<string, unknown>;
211
+ let used = false;
212
+ const out = { ...data };
213
+ for (const key of Object.keys(def.fields)) {
214
+ if (example[key] !== undefined && isEmptyValue(normalized[key])) { out[key] = example[key]; used = true; }
215
+ }
216
+ return { data: out, used };
217
+ }
package/src/lib/theme.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ThemeManifest, sectionDefinitions } from '../theme-sdk/contract.mjs';
1
+ import { ThemeManifest, sectionDefinitions, PagePreset as PagePresetSchema } from '../theme-sdk/contract.mjs';
2
2
  import { z } from 'zod';
3
3
  import type { Settings } from './schema';
4
4
 
@@ -58,6 +58,9 @@ export const FONT_PAIRINGS = [
58
58
  { key: 'system', label: 'System UI (no webfont)', headingFont: 'system-ui, sans-serif', bodyFont: 'system-ui, sans-serif', googleFonts: '' },
59
59
  ] as const;
60
60
 
61
+ export type SectionMigration = (data: Record<string, unknown>, fromVersion: number) => Record<string, unknown>;
62
+ export type PagePreset = z.infer<typeof PagePresetSchema>;
63
+
61
64
  export interface Theme {
62
65
  slug: string;
63
66
  name: string;
@@ -68,6 +71,10 @@ export interface Theme {
68
71
  version: string;
69
72
  sections: ReturnType<typeof sectionDefinitions>;
70
73
  sectionTemplates: Record<string, unknown>;
74
+ // migrations/<type>.mjs default exports: (data, fromVersion) => data
75
+ migrations: Record<string, SectionMigration>;
76
+ // starting layouts ("Start from …") offered on pages without sections
77
+ presets: Record<string, PagePreset>;
71
78
  // Astro component (module default) when the theme ships templates/home.astro
72
79
  HomeTemplate: unknown | null;
73
80
  // optional per-document templates: templates/post-<name>.astro and
@@ -93,6 +100,10 @@ const homes = import.meta.glob('../themes/*/templates/home.astro', { eager: true
93
100
  const postTpls = import.meta.glob('../themes/*/templates/post-*.astro', { eager: true }) as Record<string, { default: unknown }>;
94
101
  const pageTpls = import.meta.glob('../themes/*/templates/page-*.astro', { eager: true }) as Record<string, { default: unknown }>;
95
102
  const eventTpls = import.meta.glob('../themes/*/templates/event.astro', { eager: true }) as Record<string, { default: unknown }>;
103
+ const migrationMods = {
104
+ ...(import.meta.glob('../themes/*/migrations/*.mjs', { eager: true }) as Record<string, { default?: unknown }>),
105
+ ...((user as { migrations?: Record<string, { default?: unknown }> }).migrations ?? {}),
106
+ };
96
107
 
97
108
  // works for both '../themes/<slug>/…' and '/src/themes/<slug>/…'
98
109
  const slugOf = (path: string) => /themes\/([^/]+)\//.exec(path)?.[1] ?? path.split('/')[2];
@@ -124,6 +135,10 @@ function buildThemes(
124
135
  return {
125
136
  version: manifest.version,
126
137
  sections: sectionDefinitions(manifest),
138
+ migrations: Object.fromEntries(Object.entries(migrationMods)
139
+ .filter(([p, mod]) => slugOf(p) === slug && typeof mod.default === 'function')
140
+ .map(([p, mod]) => [p.split('/').pop()!.replace(/\.mjs$/, ''), mod.default as SectionMigration])),
141
+ presets: manifest.presets,
127
142
  sectionTemplates: Object.fromEntries(Object.entries(manifest.sections).flatMap(([key, definition]) => {
128
143
  const component = (definition as {component?: string}).component;
129
144
  const match = component && Object.entries(customSections).find(([p]) => p.endsWith(`/themes/${slug}/${component}`));
@@ -0,0 +1,97 @@
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
+ import { I18N, localeName } from './i18n';
7
+
8
+ // Machine drafts for translations. Engine: the site's AI provider (follows the
9
+ // glossary and keeps tone), else Cloudflare Workers AI's translation model
10
+ // when the Worker has an `AI` binding. Nothing is saved here; the caller
11
+ // stores results as "AI draft" for someone to review.
12
+
13
+ const Kind = z.enum(['text', 'textarea', 'richtext', 'link', 'select']);
14
+ export const TranslateInput = z.object({
15
+ locale: z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/),
16
+ from: z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/).optional(),
17
+ units: z.array(z.object({ id: z.string().max(300), text: z.string().max(60_000), kind: Kind.default('text'), label: z.string().max(200).default('') }).strict()).min(1).max(200),
18
+ }).strict();
19
+ export type TranslateInput = z.infer<typeof TranslateInput>;
20
+
21
+ type Unit = TranslateInput['units'][number];
22
+ interface WorkersAi { run(model: string, input: Record<string, unknown>): Promise<{ translated_text?: string }> }
23
+
24
+ export function glossaryLines(glossary: string): string[] {
25
+ return glossary.split('\n').map((l) => l.trim()).filter(Boolean).slice(0, 200);
26
+ }
27
+
28
+ const clean = (u: Unit, text: string) => {
29
+ let t = text.trim();
30
+ if (u.kind === 'richtext') {
31
+ return sanitizeHtml(t, {
32
+ allowedTags: ['p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'a', 'ul', 'ol', 'li', 'h2', 'h3', 'h4', 'blockquote', 'code', 'figure', 'figcaption', 'img', 'div', 'span', 'iframe', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'pre', 'hr', 'details', 'summary'],
33
+ allowedAttributes: { '*': ['class', 'data-*', 'id'], a: ['href', 'title', 'target', 'rel'], img: ['src', 'alt', 'title', 'width', 'height', 'loading'], iframe: ['src', 'title', 'allow', 'allowfullscreen'] },
34
+ allowedSchemes: ['http', 'https', 'mailto', 'tel'], allowProtocolRelative: false,
35
+ });
36
+ }
37
+ t = sanitizeHtml(t, { allowedTags: [], allowedAttributes: {} }).replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'");
38
+ return u.kind === 'textarea' ? t : t.replace(/\s*\n+\s*/g, ' ');
39
+ };
40
+
41
+ async function viaProvider(config: AiConfig, units: Unit[], from: string, to: string): Promise<Record<string, string>> {
42
+ const glossary = glossaryLines(config.glossary);
43
+ const instruction = [
44
+ `Translate website text from ${from} to ${to}. It is for a public site (for example a church, school or small business).`,
45
+ 'Natural, warm and plain; match the tone and length of the original. Keep names of people, places and ministries unless the glossary says otherwise.',
46
+ 'Keep numbers, times, dates, emoji, URLs and email addresses exactly. For HTML, translate only the visible text and keep every tag and attribute.',
47
+ glossary.length ? `Glossary (always follow; "A = B" means translate A as B, a bare term means keep it as is):\n${glossary.join('\n')}` : '',
48
+ 'Input is a JSON object of id → text. Reply with ONLY a JSON object with the same ids → translated text. No commentary, no code fences.',
49
+ ].filter(Boolean).join('\n');
50
+ const out: Record<string, string> = {};
51
+ // batches of ~40 items / ~12k characters keep replies complete
52
+ let batch: Unit[] = [], size = 0;
53
+ const flush = async () => {
54
+ if (!batch.length) return;
55
+ const input = Object.fromEntries(batch.map((u) => [u.id, u.text]));
56
+ const reply = await completeText(config, instruction, JSON.stringify(input));
57
+ let parsed: Record<string, unknown>;
58
+ try { parsed = JSON.parse(reply.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim()); }
59
+ catch { throw new ApiError(502, 'The AI returned an unreadable translation. Try again, or translate fewer items at once.'); }
60
+ for (const u of batch) if (typeof parsed[u.id] === 'string' && (parsed[u.id] as string).trim()) out[u.id] = clean(u, parsed[u.id] as string);
61
+ batch = []; size = 0;
62
+ };
63
+ for (const u of units) {
64
+ if (batch.length >= 40 || size + u.text.length > 12_000) await flush();
65
+ batch.push(u); size += u.text.length;
66
+ }
67
+ await flush();
68
+ return out;
69
+ }
70
+
71
+ async function viaWorkersAi(ai: WorkersAi, units: Unit[], from: string, to: string): Promise<Record<string, string>> {
72
+ const out: Record<string, string> = {};
73
+ for (const u of units) {
74
+ if (u.kind === 'richtext') continue; // the translation model is plain-text only
75
+ const r = await ai.run('@cf/meta/m2m100-1.2b', { text: u.text, source_lang: from.split('-')[0], target_lang: to.split('-')[0] });
76
+ if (r?.translated_text?.trim()) out[u.id] = clean(u, r.translated_text);
77
+ }
78
+ return out;
79
+ }
80
+
81
+ /** engine the site can use right now (for the UI): 'provider' | 'workers-ai' | null */
82
+ export function translationEngine(config: AiConfig, locals?: unknown): 'provider' | 'workers-ai' | null {
83
+ if (config.enabled && config.model) return 'provider';
84
+ return workersAi(locals) ? 'workers-ai' : null;
85
+ }
86
+ const workersAi = (locals?: unknown) => (locals as { runtime?: { env?: { AI?: WorkersAi } } } | undefined)?.runtime?.env?.AI ?? null;
87
+
88
+ export async function translateUnits(config: AiConfig, raw: unknown, locals?: unknown): Promise<{ results: Record<string, string>; engine: string }> {
89
+ const input = TranslateInput.parse(raw);
90
+ const from = input.from || I18N.defaultLocale || 'en';
91
+ const units = input.units.filter((u) => u.text.trim());
92
+ if (!units.length) return { results: {}, engine: 'none' };
93
+ const engine = translationEngine(config, locals);
94
+ if (engine === 'provider') return { results: await viaProvider(config, units, localeName(from), localeName(input.locale)), engine };
95
+ if (engine === 'workers-ai') return { results: await viaWorkersAi(workersAi(locals)!, units, from, input.locale), engine };
96
+ throw new ApiError(409, 'Turn on AI in Settings → AI & agents to draft translations automatically.');
97
+ }
@@ -0,0 +1,39 @@
1
+ import type { DocTranslation, DocTranslations } from './schema';
2
+ import { I18N } from './i18n';
3
+
4
+ // Posts, pages and events in another language: the translated words laid
5
+ // over the document; anything not translated yet stays in the main language.
6
+ // Facts (dates, covers, tags, places' addresses…) are never translated.
7
+
8
+ type Doc = { title: string; bodyHtml: string; seoTitle: string; seoDesc: string; excerpt?: string; translations?: DocTranslations; location?: { name: string }; cta?: { label: string } };
9
+
10
+ export const isExtraLocale = (locale: string | undefined | null): locale is string =>
11
+ Boolean(locale) && I18N.enabled && locale !== I18N.defaultLocale && I18N.locales.includes(locale as string);
12
+
13
+ export function translationFor(doc: { translations?: DocTranslations }, locale: string): DocTranslation | null {
14
+ if (!isExtraLocale(locale)) return null;
15
+ return doc.translations?.[locale] ?? null;
16
+ }
17
+
18
+ /** does this document have any words in the language? */
19
+ export const hasTranslation = (doc: { translations?: DocTranslations }, locale: string) => {
20
+ const t = translationFor(doc, locale);
21
+ return Boolean(t && (t.title.trim() || t.bodyHtml.trim()));
22
+ };
23
+
24
+ export function localizeDoc<T extends Doc>(doc: T, locale: string | undefined): T {
25
+ const t = locale ? translationFor(doc, locale) : null;
26
+ if (!t) return doc;
27
+ const pick = (v: string, fallback: string) => (v && v.trim() ? v : fallback);
28
+ const out: T = {
29
+ ...doc,
30
+ title: pick(t.title, doc.title),
31
+ bodyHtml: pick(t.bodyHtml, doc.bodyHtml),
32
+ seoTitle: pick(t.seoTitle, doc.seoTitle),
33
+ seoDesc: pick(t.seoDesc, doc.seoDesc),
34
+ };
35
+ if (doc.excerpt !== undefined) (out as Doc).excerpt = pick(t.excerpt, doc.excerpt);
36
+ if (doc.location) (out as Doc).location = { ...doc.location, name: pick(t.locationName, doc.location.name) };
37
+ if (doc.cta) (out as Doc).cta = { ...doc.cta, label: pick(t.ctaLabel, doc.cta.label) };
38
+ return out;
39
+ }
package/src/middleware.ts CHANGED
@@ -1,10 +1,13 @@
1
1
  import { defineMiddleware } from 'astro:middleware';
2
+ import { I18N } from './lib/i18n';
2
3
  import { validateSession, SESSION_COOKIE } from './lib/auth';
3
4
  import { getRepo } from './lib/repo';
4
5
  import { CONTENT } from './lib/content';
5
6
  import { processDueDeliveries, waitUntilFrom } from './lib/webhooks';
6
7
  import { dispatchEmail } from './lib/email/service';
7
8
  import { sendDueReminders } from './lib/booking/service';
9
+ import { PUSH } from './lib/push/config';
10
+ import { processPushQueue, runPushJobs } from './lib/push/service';
8
11
  import { previewParamRedirect, hasPreviewCookie } from './lib/designPreview';
9
12
 
10
13
  // Legacy content-URL redirects, checked only on would-be 404s (below) so they
@@ -60,6 +63,20 @@ export const onRequest = defineMiddleware(async (context, next) => {
60
63
  return context.redirect(pathname.replace(/\/+$/, '') + context.url.search, 301);
61
64
  }
62
65
 
66
+ // another language: /es/events/x renders /events/x with the request marked
67
+ // Spanish (pages lay translated words over the shared content). The URL the
68
+ // visitor sees stays /es/…; /es itself is the injected home route.
69
+ if (I18N.enabled && context.request.method !== 'OPTIONS') {
70
+ for (const l of I18N.locales) {
71
+ if (l === I18N.defaultLocale || !pathname.startsWith(`/${l}/`)) continue;
72
+ const bare = pathname.slice(l.length + 1);
73
+ if (/^\/(admin|api|_|media|embed|go)(\/|$)/.test(bare)) break;
74
+ (context.locals as { acmsLocale?: string; acmsPath?: string }).acmsLocale = l;
75
+ (context.locals as { acmsLocale?: string; acmsPath?: string }).acmsPath = pathname;
76
+ return context.rewrite(new URL(bare + context.url.search, context.url));
77
+ }
78
+ }
79
+
63
80
  // design preview links (?designPreview=1, ?designVersion=, ?preview=<token>,
64
81
  // ?preview=off) become a cookie, then the clean URL — so every link clicked
65
82
  // afterwards stays in preview mode
@@ -70,6 +87,10 @@ export const onRequest = defineMiddleware(async (context, next) => {
70
87
  if (pathname.startsWith('/admin') && pathname !== '/admin/login') {
71
88
  const token = context.cookies.get(SESSION_COOKIE)?.value;
72
89
  if (!(await validateSession(token, context.locals))) {
90
+ // editor-app sign-in comes back here after login (see login.astro's next)
91
+ if (pathname === '/admin/app-auth' && context.request.method === 'GET') {
92
+ return context.redirect(`/admin/login?next=${encodeURIComponent(pathname + context.url.search)}`);
93
+ }
73
94
  return context.redirect('/admin/login');
74
95
  }
75
96
  }
@@ -92,6 +113,10 @@ export const onRequest = defineMiddleware(async (context, next) => {
92
113
  .then((s) => sendDueReminders(repo, { origin: s.siteUrl.replace(/\/+$/, '') || context.url.origin, locals: context.locals, now }))
93
114
  .catch(() => {});
94
115
  if (wait) wait(reminders); else await reminders;
116
+ // push jobs (Node has no cron): never awaited here, errors are logged
117
+ const push = runPushJobs(repo, { locals: context.locals, fallbackOrigin: context.url.origin, now })
118
+ .catch((e) => console.error('acms push: scheduled jobs failed', e));
119
+ if (wait) wait(push);
95
120
  } catch {
96
121
  // never let the publish sweep break a request
97
122
  }
@@ -106,6 +131,13 @@ export const onRequest = defineMiddleware(async (context, next) => {
106
131
  const deliveries = getRepo(context.locals).then(repo => processDueDeliveries(repo, { limit: 5 })).catch(() => 0);
107
132
  const wait = waitUntilFrom(context.locals);
108
133
  if (wait) wait(deliveries); else await deliveries;
134
+ // first-publish push notices: background only, never delaying the response
135
+ if (PUSH.enabled) {
136
+ const push = getRepo(context.locals)
137
+ .then(repo => processPushQueue(repo, { locals: context.locals, fallbackOrigin: context.url.origin }))
138
+ .catch((e) => console.error('acms push: queue flush failed', e));
139
+ if (wait) wait(push);
140
+ }
109
141
  }
110
142
 
111
143
  // a preview render must never be cached where a visitor could get it
@@ -0,0 +1,6 @@
1
+ import { associationRoute } from '../../lib/mobile/associations';
2
+ import { methodNotAllowed } from '../../lib/mobile/http';
3
+ export { preflight as OPTIONS } from '../../lib/mobile/http';
4
+ export const GET = associationRoute('ios');
5
+ export const HEAD = GET;
6
+ export const ALL = () => methodNotAllowed();
@@ -0,0 +1,6 @@
1
+ import { associationRoute } from '../../lib/mobile/associations';
2
+ import { methodNotAllowed } from '../../lib/mobile/http';
3
+ export { preflight as OPTIONS } from '../../lib/mobile/http';
4
+ export const GET = associationRoute('android');
5
+ export const HEAD = GET;
6
+ export const ALL = () => methodNotAllowed();