@acms-subir/core 0.3.3-next.20260929113209 → 0.3.4

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 (289) hide show
  1. package/CHANGELOG.md +105 -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/preview.mjs +8 -4
  9. package/cli/sections.mjs +188 -0
  10. package/cli/themes.mjs +52 -8
  11. package/docs/mobile.md +527 -0
  12. package/docs/sections.md +203 -0
  13. package/docs/theme-authoring.md +19 -3
  14. package/integration.mjs +76 -5
  15. package/mobile-template/client/AGENTS.md +97 -0
  16. package/mobile-template/client/README.md +65 -0
  17. package/mobile-template/client/app/_layout.tsx +56 -0
  18. package/mobile-template/client/app/index.tsx +21 -0
  19. package/mobile-template/client/app.json +21 -0
  20. package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
  21. package/mobile-template/client/eas.json +21 -0
  22. package/mobile-template/client/env.example +3 -0
  23. package/mobile-template/client/gitignore +42 -0
  24. package/mobile-template/client/package.json +38 -0
  25. package/mobile-template/client/sections/index.ts +11 -0
  26. package/mobile-template/client/tsconfig.json +7 -0
  27. package/mobile-template/editor/AGENTS.md +66 -0
  28. package/mobile-template/editor/README.md +73 -0
  29. package/mobile-template/editor/app/_layout.tsx +76 -0
  30. package/mobile-template/editor/app/auth.tsx +13 -0
  31. package/mobile-template/editor/app/index.tsx +55 -0
  32. package/mobile-template/editor/app.json +21 -0
  33. package/mobile-template/editor/components/Screen.tsx +51 -0
  34. package/mobile-template/editor/components/SignIn.tsx +36 -0
  35. package/mobile-template/editor/components/TextTabs.tsx +27 -0
  36. package/mobile-template/editor/eas.json +21 -0
  37. package/mobile-template/editor/env.example +5 -0
  38. package/mobile-template/editor/gitignore +42 -0
  39. package/mobile-template/editor/lib/appearance.tsx +12 -0
  40. package/mobile-template/editor/lib/config.ts +17 -0
  41. package/mobile-template/editor/lib/format.ts +24 -0
  42. package/mobile-template/editor/lib/tools.ts +16 -0
  43. package/mobile-template/editor/package.json +40 -0
  44. package/mobile-template/editor/tools/README.md +2 -0
  45. package/mobile-template/editor/tsconfig.json +7 -0
  46. package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
  47. package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
  48. package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
  49. package/mobile-template/recipes/booking/recipe.json +17 -0
  50. package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
  51. package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
  52. package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
  53. package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
  54. package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
  55. package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
  56. package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
  57. package/mobile-template/recipes/editor-content/recipe.json +8 -0
  58. package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
  59. package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
  60. package/mobile-template/recipes/editor-media/recipe.json +8 -0
  61. package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
  62. package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
  63. package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
  64. package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
  65. package/mobile-template/recipes/editor-sections/recipe.json +8 -0
  66. package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
  67. package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
  68. package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
  69. package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
  70. package/mobile-template/recipes/editor-translations/recipe.json +8 -0
  71. package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
  72. package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
  73. package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
  74. package/mobile-template/recipes/editor-writer/recipe.json +8 -0
  75. package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
  76. package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
  77. package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
  78. package/mobile-template/recipes/events/recipe.json +15 -0
  79. package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
  80. package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
  81. package/mobile-template/recipes/news/recipe.json +13 -0
  82. package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
  83. package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
  84. package/mobile-template/recipes/push/files/lib/push.ts +15 -0
  85. package/mobile-template/recipes/push/recipe.json +20 -0
  86. package/package.json +17 -13
  87. package/section-library/README.md +16 -0
  88. package/section-library/contact/Contact.astro +49 -0
  89. package/section-library/contact/section.json +106 -0
  90. package/section-library/cta-band/CtaBand.astro +28 -0
  91. package/section-library/cta-band/section.json +51 -0
  92. package/section-library/feature-grid/FeatureGrid.astro +54 -0
  93. package/section-library/feature-grid/section.json +107 -0
  94. package/section-library/gallery/Gallery.astro +29 -0
  95. package/section-library/gallery/section.json +51 -0
  96. package/section-library/hero-banner/HeroBanner.astro +42 -0
  97. package/section-library/hero-banner/section.json +81 -0
  98. package/section-library/latest-posts/LatestPosts.astro +22 -0
  99. package/section-library/latest-posts/section.json +37 -0
  100. package/section-library/pricing/Pricing.astro +40 -0
  101. package/section-library/pricing/section.json +117 -0
  102. package/section-library/questions/Questions.astro +38 -0
  103. package/section-library/questions/section.json +69 -0
  104. package/section-library/service-times/ServiceTimes.astro +42 -0
  105. package/section-library/service-times/section.json +127 -0
  106. package/section-library/team/Team.astro +45 -0
  107. package/section-library/team/section.json +88 -0
  108. package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
  109. package/section-library/upcoming-events/section.json +51 -0
  110. package/src/actions/index.ts +205 -174
  111. package/src/components/AiSettings.tsx +45 -5
  112. package/src/components/DesignManager.tsx +112 -16
  113. package/src/components/MobileApp.tsx +392 -0
  114. package/src/components/ThemeSections.astro +40 -21
  115. package/src/components/ThemeSectionsEditor.tsx +757 -229
  116. package/src/components/booking/BookingFlow.astro +6 -3
  117. package/src/components/calendar/BookingsHead.astro +51 -0
  118. package/src/components/calendar/CalendarBoard.tsx +618 -0
  119. package/src/components/calendar/MiniMonth.tsx +54 -0
  120. package/src/components/calendar/NewEventDialog.tsx +44 -0
  121. package/src/components/calendar/QuickAdd.tsx +263 -0
  122. package/src/components/calendar/RoomHolds.tsx +203 -0
  123. package/src/components/calendar/WhereItLands.tsx +51 -0
  124. package/src/components/editor/EventPanel.tsx +23 -2
  125. package/src/components/editor/PostEditor.tsx +206 -14
  126. package/src/components/import/ImportExport.tsx +4 -1
  127. package/src/components/import/runner.ts +1 -0
  128. package/src/components/sections/SectionFields.tsx +560 -0
  129. package/src/components/sections/SectionPicker.tsx +120 -0
  130. package/src/components/sections/TranslationPanel.tsx +143 -0
  131. package/src/components/sections/checks.ts +64 -0
  132. package/src/layouts/Admin.astro +11 -7
  133. package/src/layouts/Site.astro +136 -21
  134. package/src/lib/agent-discovery.ts +11 -7
  135. package/src/lib/agent.ts +35 -2
  136. package/src/lib/ai-schema.ts +5 -0
  137. package/src/lib/ai.ts +5 -0
  138. package/src/lib/api.ts +12 -0
  139. package/src/lib/backup/content.ts +6 -1
  140. package/src/lib/booking/agent.ts +3 -2
  141. package/src/lib/booking/approval.ts +70 -0
  142. package/src/lib/booking/holds.ts +120 -0
  143. package/src/lib/booking/service.ts +37 -3
  144. package/src/lib/calendar/agent-schema.ts +9 -1
  145. package/src/lib/calendar/agent.ts +16 -1
  146. package/src/lib/calendar/board.ts +171 -0
  147. package/src/lib/calendar/config.ts +3 -0
  148. package/src/lib/calendar/grid.ts +169 -0
  149. package/src/lib/calendar/migration.ts +16 -0
  150. package/src/lib/calendar/render.ts +9 -7
  151. package/src/lib/calendar/schedule.ts +166 -0
  152. package/src/lib/content-service.ts +203 -0
  153. package/src/lib/design-service.ts +153 -5
  154. package/src/lib/drafts.ts +3 -3
  155. package/src/lib/editor/auth.ts +264 -0
  156. package/src/lib/editor/bookings.ts +60 -0
  157. package/src/lib/editor/config.ts +25 -0
  158. package/src/lib/editor/documents.ts +227 -0
  159. package/src/lib/editor/http.ts +48 -0
  160. package/src/lib/editor/me.ts +30 -0
  161. package/src/lib/editor/migration.ts +57 -0
  162. package/src/lib/editor/next.ts +11 -0
  163. package/src/lib/editor/openapi.ts +124 -0
  164. package/src/lib/editor/schemas.ts +91 -0
  165. package/src/lib/editor/sections.ts +96 -0
  166. package/src/lib/editor/store.ts +184 -0
  167. package/src/lib/editor/token-route.ts +74 -0
  168. package/src/lib/editor/translations.ts +163 -0
  169. package/src/lib/email/accounts.ts +2 -1
  170. package/src/lib/email/types.ts +2 -1
  171. package/src/lib/home-source.ts +34 -0
  172. package/src/lib/i18n.ts +34 -0
  173. package/src/lib/import/apply.ts +31 -2
  174. package/src/lib/import/bundle.ts +4 -0
  175. package/src/lib/import/export.ts +1 -0
  176. package/src/lib/import/ghost.ts +1 -0
  177. package/src/lib/media/upload.ts +83 -0
  178. package/src/lib/mobile/associations.ts +25 -0
  179. package/src/lib/mobile/calendar.ts +26 -14
  180. package/src/lib/mobile/config.mjs +59 -0
  181. package/src/lib/mobile/content.ts +18 -13
  182. package/src/lib/mobile/embed.ts +83 -0
  183. package/src/lib/mobile/http.ts +14 -6
  184. package/src/lib/mobile/locale.ts +18 -0
  185. package/src/lib/mobile/openapi.ts +111 -0
  186. package/src/lib/mobile/pages.ts +71 -0
  187. package/src/lib/mobile/route.ts +3 -3
  188. package/src/lib/mobile/schemas.ts +60 -0
  189. package/src/lib/mobile/site.ts +41 -0
  190. package/src/lib/push/api.ts +158 -0
  191. package/src/lib/push/config.ts +20 -0
  192. package/src/lib/push/expo.ts +76 -0
  193. package/src/lib/push/migration.ts +60 -0
  194. package/src/lib/push/service.ts +302 -0
  195. package/src/lib/push/store.ts +144 -0
  196. package/src/lib/push/types.ts +43 -0
  197. package/src/lib/repo/migrations.ts +14 -0
  198. package/src/lib/repo/sql.ts +65 -20
  199. package/src/lib/repo/types.ts +10 -4
  200. package/src/lib/roles.ts +5 -0
  201. package/src/lib/scheduler.ts +6 -1
  202. package/src/lib/schema.ts +76 -1
  203. package/src/lib/section-ai.ts +55 -0
  204. package/src/lib/section-targets.ts +62 -0
  205. package/src/lib/sections-agent.ts +146 -0
  206. package/src/lib/sections.ts +242 -0
  207. package/src/lib/site-chrome.ts +34 -0
  208. package/src/lib/theme-assets.ts +16 -0
  209. package/src/lib/theme.ts +33 -4
  210. package/src/lib/translate.ts +97 -0
  211. package/src/lib/translations.ts +39 -0
  212. package/src/middleware.ts +38 -2
  213. package/src/pages/.well-known/apple-app-site-association.ts +6 -0
  214. package/src/pages/.well-known/assetlinks.json.ts +6 -0
  215. package/src/pages/[...slug].astro +8 -3
  216. package/src/pages/admin/app-auth.astro +159 -0
  217. package/src/pages/admin/c/[collection]/[id].astro +9 -0
  218. package/src/pages/admin/calendar/bookings.astro +32 -26
  219. package/src/pages/admin/calendar/calendars.astro +4 -263
  220. package/src/pages/admin/calendar/events/[id].astro +9 -0
  221. package/src/pages/admin/calendar/events/index.astro +41 -83
  222. package/src/pages/admin/calendar/index.astro +43 -0
  223. package/src/pages/admin/calendar/resources/[id].astro +27 -5
  224. package/src/pages/admin/calendar/resources/index.astro +16 -17
  225. package/src/pages/admin/calendar/resources/link.astro +52 -12
  226. package/src/pages/admin/calendar/settings.astro +458 -0
  227. package/src/pages/admin/integrations.astro +5 -11
  228. package/src/pages/admin/login.astro +6 -2
  229. package/src/pages/admin/logout.ts +5 -2
  230. package/src/pages/admin/mobile.astro +18 -0
  231. package/src/pages/admin/pages/[id].astro +62 -0
  232. package/src/pages/admin/section-preview.astro +61 -0
  233. package/src/pages/admin/users.astro +107 -11
  234. package/src/pages/api/agent/docs.ts +6 -2
  235. package/src/pages/api/agent/index.ts +1 -1
  236. package/src/pages/api/ai/settings.ts +7 -4
  237. package/src/pages/api/design/ai.ts +15 -0
  238. package/src/pages/api/design/index.ts +12 -42
  239. package/src/pages/api/design/translate.ts +15 -0
  240. package/src/pages/api/live/thumb.ts +1 -1
  241. package/src/pages/api/mcp.ts +2 -2
  242. package/src/pages/api/media/upload.ts +10 -78
  243. package/src/pages/api/push/admin.ts +27 -0
  244. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
  245. package/src/pages/api/v1/admin/bookings/index.ts +6 -0
  246. package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
  247. package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
  248. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
  249. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
  250. package/src/pages/api/v1/admin/documents/index.ts +13 -0
  251. package/src/pages/api/v1/admin/me.ts +6 -0
  252. package/src/pages/api/v1/admin/media/index.ts +32 -0
  253. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
  254. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
  255. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
  256. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
  257. package/src/pages/api/v1/admin/sections/index.ts +6 -0
  258. package/src/pages/api/v1/auth/revoke.ts +5 -0
  259. package/src/pages/api/v1/auth/token.ts +5 -0
  260. package/src/pages/api/v1/devices/[token].ts +20 -0
  261. package/src/pages/api/v1/devices/index.ts +17 -0
  262. package/src/pages/api/v1/openapi.json.ts +7 -0
  263. package/src/pages/api/v1/pages/[...slug].ts +7 -0
  264. package/src/pages/api/v1/pages/home.ts +7 -0
  265. package/src/pages/api/v1/push/topics.ts +7 -0
  266. package/src/pages/api/v1/site.ts +7 -0
  267. package/src/pages/api/v1/theme.ts +7 -0
  268. package/src/pages/blog/[slug].astro +8 -2
  269. package/src/pages/blog/index.astro +11 -4
  270. package/src/pages/book/[resource].ics.ts +7 -1
  271. package/src/pages/embed/section/[...path].astro +42 -0
  272. package/src/pages/events/[slug].astro +7 -2
  273. package/src/pages/index.astro +11 -5
  274. package/src/pages/sitemap.xml.ts +8 -0
  275. package/src/styles/admin.css +117 -0
  276. package/src/styles/cards.css +5 -1
  277. package/src/styles/editor.css +57 -0
  278. package/src/styles/mobile-app.css +151 -0
  279. package/src/styles/schedule.css +325 -0
  280. package/src/styles/sections.css +21 -12
  281. package/src/styles/theme-editor.css +255 -0
  282. package/src/theme-sdk/contract.d.mts +2193 -89
  283. package/src/theme-sdk/contract.mjs +77 -20
  284. package/src/theme-sdk/fields.d.mts +88 -0
  285. package/src/theme-sdk/fields.mjs +330 -0
  286. package/src/theme-sdk/index.ts +14 -2
  287. package/src/theme-sdk/tokens.d.mts +11 -0
  288. package/src/virtual.d.ts +4 -0
  289. package/src/pages/admin/calendar/index.ts +0 -4
@@ -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,242 @@
1
+ import sanitizeHtml from 'sanitize-html';
2
+ import type { z } from 'zod';
3
+ import type { ContentRepository } from './repo/types';
4
+ import { authorSlug, type Settings, type User } from './schema';
5
+ import { htmlToPlaintext } from './plaintext';
6
+ import type { SectionInstance as SectionInstanceSchema, SectionDefinition } from '../theme-sdk/contract.mjs';
7
+ import { normalizeData, safeUrl, SOURCE_LIMIT_MAX, isEmptyValue, safeFocus, focusKey } from '../theme-sdk/fields.mjs';
8
+ import type { Theme } from './theme';
9
+ import { themeAssetUrl } from './theme-assets';
10
+ import { CONTENT, collectionUrl } from './content';
11
+ import { formatDate, siteLocale } from './dates';
12
+ import { upcomingEvents } from './calendar/cards';
13
+ import { formatOccurrence, locationLine } from './calendar/events';
14
+ import { CALENDAR, bookingLinkUrl, eventUrl, bookingsEnabled } from './calendar/config';
15
+ import { localePath } from './i18n';
16
+ import { localizeDoc, isExtraLocale } from './translations';
17
+
18
+ // Server side of editable sections: turn a stored instance into the props a
19
+ // coded section component receives. Stored data stays exactly as the owner
20
+ // saved it; everything here is derived per request —
21
+ // • migrations/<type>.mjs upgrades older instances on the fly
22
+ // • link fields resolve their ref (page/post/event/booking) to today's URL
23
+ // • richtext is sanitized
24
+ // • source fields ("next 3 Youth events") become fresh item arrays
25
+
26
+ type Instance = z.infer<typeof SectionInstanceSchema>;
27
+ type Definition = z.infer<typeof SectionDefinition>;
28
+ type FieldDef = Definition['fields'][string];
29
+
30
+ export interface LinkValue { label: string; href: string; external: boolean }
31
+ export interface EventItem {
32
+ title: string; url: string; summary: string; image: string; imageAlt: string; location: string;
33
+ start: string; end: string; allDay: boolean; cancelled: boolean;
34
+ /** "Sat, Oct 3 · 11:00 AM" */ when: string; /** "Saturday, October 3, 2026" */ date: string;
35
+ /** "11:00 AM – 1:00 PM EDT" */ time: string; month: string; day: string; weekday: string;
36
+ cta: { label: string; href: string } | null;
37
+ }
38
+ export interface PostAuthor { name: string; url: string; avatar: string }
39
+ // excerpt: the post's own, else the first ~160 characters of its text
40
+ export interface PostItem { title: string; url: string; excerpt: string; image: string; imageAlt: string; date: string; publishedAt: number | null; tags: string[]; authors: PostAuthor[] }
41
+ export interface BookingItem { title: string; url: string; description: string; resource: string; minutes: number }
42
+
43
+ /** apply a theme migration when the stored instance is older than the definition */
44
+ export function migrateSection(theme: Pick<Theme, 'sections' | 'migrations'>, s: Instance): Instance {
45
+ const def = theme.sections[s.type];
46
+ const migrate = theme.migrations?.[s.type];
47
+ if (!def || !migrate || s.version >= def.version) return s;
48
+ try {
49
+ const data = migrate(structuredClone(s.data) as Record<string, unknown>, s.version);
50
+ if (!data || typeof data !== 'object') return s;
51
+ return { ...s, version: def.version, data: data as Instance['data'] };
52
+ } catch {
53
+ return s; // a throwing migration leaves the section flagged, never crashes the page
54
+ }
55
+ }
56
+
57
+ const RICH = {
58
+ allowedTags: ['p', 'br', 'strong', 'b', 'em', 'i', 'u', 's', 'a', 'ul', 'ol', 'li', 'h3', 'h4', 'blockquote', 'code'],
59
+ allowedAttributes: { a: ['href', 'title', 'target', 'rel'] },
60
+ allowedSchemes: ['http', 'https', 'mailto', 'tel'],
61
+ allowProtocolRelative: false,
62
+ transformTags: { a: sanitizeHtml.simpleTransform('a', { rel: 'noopener' }) },
63
+ };
64
+ export const sanitizeRichText = (html: string) => sanitizeHtml(String(html ?? ''), RICH);
65
+
66
+ type RefMap = Map<string, string>;
67
+
68
+ async function resolveRefs(refs: Set<string>, repo: ContentRepository): Promise<RefMap> {
69
+ const out: RefMap = new Map();
70
+ await Promise.all([...refs].map(async (ref) => {
71
+ const [kind, id] = ref.split(':');
72
+ try {
73
+ if (kind === 'page') {
74
+ const p = await repo.getPage(id);
75
+ if (p && p.status === 'published') out.set(ref, p.isHome ? '/' : `/${p.slug}`);
76
+ } else if (kind === 'post') {
77
+ const p = await repo.getPost(id);
78
+ if (p && p.status === 'published') out.set(ref, collectionUrl(p.collection, p.slug));
79
+ } else if (kind === 'event' && CALENDAR.enabled) {
80
+ const e = await repo.getEvent(id);
81
+ if (e && e.status === 'published') out.set(ref, eventUrl(e.slug));
82
+ } else if (kind === 'booking' && bookingsEnabled()) {
83
+ const l = await repo.getBookingLink(id);
84
+ const r = l ? await repo.getResource(l.resourceId) : null;
85
+ if (l && r && l.active) out.set(ref, bookingLinkUrl(r.slug, l.slug));
86
+ }
87
+ } catch { /* unresolved refs fall back to the stored href */ }
88
+ }));
89
+ return out;
90
+ }
91
+
92
+ function collectRefs(def: Definition, data: Record<string, unknown>, into: Set<string>) {
93
+ const visit = (field: FieldDef, value: unknown) => {
94
+ if (field.type === 'link' && value && typeof value === 'object' && typeof (value as { ref?: unknown }).ref === 'string') into.add((value as { ref: string }).ref);
95
+ 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]);
96
+ };
97
+ for (const [key, field] of Object.entries(def.fields)) visit(field, data[key]);
98
+ }
99
+
100
+ type Lp = (path: string) => string;
101
+ const same: Lp = (p) => p;
102
+ function resolveLink(value: unknown, refs: RefMap, lp: Lp = same): LinkValue {
103
+ const v = (value ?? {}) as { label?: string; href?: string; ref?: string };
104
+ const raw = (v.ref && refs.get(v.ref)) || safeUrl(v.href);
105
+ // site-internal links stay in the visitor's language (/visit → /es/visit)
106
+ const href = /^\/(?!\/|media\/|rss\.xml|admin|api|_)/.test(raw) ? lp(raw) : raw;
107
+ return { label: String(v.label ?? ''), href, external: /^https?:\/\//i.test(href) };
108
+ }
109
+
110
+ type Asset = (path: string) => string;
111
+ const noAsset: Asset = () => '';
112
+ function resolveScalar(field: FieldDef, value: unknown, refs: RefMap, lp: Lp = same, asset: Asset = noAsset): unknown {
113
+ switch (field.type) {
114
+ case 'richtext': return sanitizeRichText(String(value ?? ''));
115
+ // images may name a file the theme ships: "assets/hero.jpg" → its built URL
116
+ case 'image': return typeof value === 'string' && /^(\.\/)?assets\//.test(value) ? asset(value) : safeUrl(value);
117
+ case 'url': return safeUrl(value);
118
+ case 'link': return resolveLink(value, refs, lp);
119
+ default: return value;
120
+ }
121
+ }
122
+
123
+ async function resolveSource(field: Extract<FieldDef, { type: 'source' }>, value: unknown, ctx: ResolveContext): Promise<unknown[]> {
124
+ const cfg = (value ?? {}) as { calendar?: string; collection?: string; tag?: string; resource?: string; limit?: number };
125
+ const limit = Math.max(1, Math.min(SOURCE_LIMIT_MAX, Number(cfg.limit) || 3));
126
+ const { repo, settings, now } = ctx;
127
+ // another language (settings.locale is set to it by the renderer)
128
+ const lang = isExtraLocale(settings.locale) ? settings.locale : '';
129
+ const lp = (path: string) => (lang ? localePath(path, lang) : path);
130
+ if (field.source === 'events') {
131
+ if (!CALENDAR.enabled) return [];
132
+ let calendarIds: string[] | undefined;
133
+ if (cfg.calendar) {
134
+ const c = await repo.getCalendarBySlug(cfg.calendar);
135
+ if (!c || c.visibility === 'private') return [];
136
+ calendarIds = [c.id];
137
+ }
138
+ const tz = settings.timezone || 'UTC';
139
+ const locale = siteLocale(settings);
140
+ return (await upcomingEvents(repo, settings, now, { calendarIds, limit })).map((o): EventItem => {
141
+ const e = lang ? localizeDoc(o.event, lang) : o.event;
142
+ const f = formatOccurrence(o, tz, locale);
143
+ const ctaHref = safeUrl(e.cta.href);
144
+ return {
145
+ title: e.title || 'Untitled event', url: lp(eventUrl(e.slug)), summary: e.excerpt, image: safeUrl(e.cover), imageAlt: e.coverAlt,
146
+ location: locationLine(e.location), start: new Date(o.start).toISOString(), end: new Date(o.end).toISOString(),
147
+ allDay: o.allDay, cancelled: e.cancelled, when: f.range, date: f.date, time: f.time, month: f.month, day: f.day, weekday: f.weekday,
148
+ cta: e.cta.label && ctaHref && !e.cancelled ? { label: e.cta.label, href: ctaHref } : null,
149
+ };
150
+ });
151
+ }
152
+ if (field.source === 'posts') {
153
+ const collection = cfg.collection && CONTENT.collections[cfg.collection] ? cfg.collection : 'posts';
154
+ let posts = await repo.listPublishedPosts(collection);
155
+ if (cfg.tag) posts = posts.filter((p) => p.tags.some((t) => t.toLowerCase() === cfg.tag!.toLowerCase()));
156
+ const shown = posts.slice(0, limit);
157
+ // public byline only: name, profile URL, avatar — never emails or roles
158
+ const users = shown.some((p) => p.authorIds.length || p.authorId) ? new Map((await repo.listUsers()).map((u) => [u.id, u])) : new Map();
159
+ return shown.map((raw): PostItem => { const p = lang ? localizeDoc(raw, lang) : raw; return {
160
+ title: p.title, url: lp(collectionUrl(p.collection, p.slug)), excerpt: p.excerpt || autoExcerpt(p.bodyHtml), image: safeUrl(p.cover), imageAlt: p.coverAlt,
161
+ date: formatDate(p.publishedAt, settings, 'short'), publishedAt: p.publishedAt ?? null, tags: p.tags,
162
+ authors: (raw.authorIds.length ? raw.authorIds : raw.authorId ? [raw.authorId] : [])
163
+ .map((id) => users.get(id)).filter((u): u is User => Boolean(u))
164
+ .map((u) => ({ name: u.name, url: lp(`/author/${authorSlug(u)}`), avatar: safeUrl(u.avatar) })),
165
+ }; });
166
+ }
167
+ if (!bookingsEnabled()) return [];
168
+ const resources = (await repo.listResources()).filter((r) => r.visibility === 'public' && (!cfg.resource || r.slug === cfg.resource));
169
+ const byId = new Map(resources.map((r) => [r.id, r]));
170
+ const links = (await repo.listBookingLinks()).filter((l) => l.active && l.visibility === 'public' && byId.has(l.resourceId));
171
+ return links.slice(0, limit).map((l): BookingItem => {
172
+ const r = byId.get(l.resourceId)!;
173
+ return { title: l.name, url: bookingLinkUrl(r.slug, l.slug), description: l.description, resource: r.name, minutes: l.duration };
174
+ });
175
+ }
176
+
177
+ // first ~160 characters of the body, cut at a word, for posts without an excerpt
178
+ export function autoExcerpt(html: string, max = 160): string {
179
+ const text = htmlToPlaintext(html);
180
+ if (text.length <= max) return text;
181
+ const cut = text.slice(0, max + 1);
182
+ const at = cut.lastIndexOf(' ');
183
+ return `${(at > max * 0.6 ? cut.slice(0, at) : text.slice(0, max)).replace(/[\s,.;:!?—–-]+$/, '')}…`;
184
+ }
185
+
186
+ export interface ResolveContext { repo: ContentRepository; settings: Settings; now: number }
187
+
188
+ /** stored data → component props: defaults filled, refs/sources resolved, richtext sanitized */
189
+ export async function resolveSectionData(def: Definition, stored: Record<string, unknown>, ctx: ResolveContext): Promise<Record<string, unknown>> {
190
+ const data = normalizeData(def, stored) as Record<string, unknown>;
191
+ const refs = new Set<string>();
192
+ collectRefs(def, data, refs);
193
+ const refMap = refs.size ? await resolveRefs(refs, ctx.repo) : new Map<string, string>();
194
+ const lang = isExtraLocale(ctx.settings.locale) ? ctx.settings.locale : '';
195
+ const lp: Lp = lang ? (p) => localePath(p, lang) : same;
196
+ // 'ink' = DEFAULT_THEME (importing it would load every theme template)
197
+ const asset: Asset = (p) => themeAssetUrl(ctx.settings.activeTheme || 'ink', p);
198
+ const out: Record<string, unknown> = {};
199
+ await Promise.all(Object.entries(def.fields).map(async ([key, field]) => {
200
+ const value = data[key];
201
+ if (field.type === 'source') out[key] = await resolveSource(field, value, ctx).catch(() => []);
202
+ else if (field.type === 'list') {
203
+ out[key] = (value as Record<string, unknown>[]).map((row) => {
204
+ const item: Record<string, unknown> = {};
205
+ for (const [k, sub] of Object.entries(field.fields)) {
206
+ item[k] = resolveScalar(sub as FieldDef, row[k], refMap, lp, asset);
207
+ if (sub.type === 'image') item[`${k}Focus`] = safeFocus(row[focusKey(k)]);
208
+ }
209
+ return item;
210
+ });
211
+ } else {
212
+ out[key] = resolveScalar(field, value, refMap, lp, asset);
213
+ // images also hand over their focal point, ready for object-position
214
+ if (field.type === 'image') out[`${key}Focus`] = safeFocus(stored[focusKey(key)]);
215
+ }
216
+ }));
217
+ return out;
218
+ }
219
+
220
+
221
+ /** "YYYY-MM-DDTHH:MM" for now in the site timezone (section schedules are wall-clock) */
222
+ export function wallNow(tz: string, now = Date.now()): string {
223
+ try {
224
+ 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]));
225
+ return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}`;
226
+ } catch {
227
+ return new Date(now).toISOString().slice(0, 16);
228
+ }
229
+ }
230
+
231
+ /** admin preview only: empty fields borrow the definition's example (never saved) */
232
+ export function withExample(def: Definition, data: Record<string, unknown>): { data: Record<string, unknown>; used: boolean } {
233
+ const example = (def as { example?: Record<string, unknown> }).example;
234
+ if (!example) return { data, used: false };
235
+ const normalized = normalizeData(def, data) as Record<string, unknown>;
236
+ let used = false;
237
+ const out = { ...data };
238
+ for (const key of Object.keys(def.fields)) {
239
+ if (example[key] !== undefined && isEmptyValue(normalized[key])) { out[key] = example[key]; used = true; }
240
+ }
241
+ return { data: out, used };
242
+ }
@@ -0,0 +1,34 @@
1
+ // The `site` prop given to a theme's templates/header.astro and
2
+ // templates/footer.astro. Everything is resolved for the current request:
3
+ // links are already localized, `current` marks the page you're on, languages
4
+ // carry their own names ("Español"), and social links carry inline SVG icons.
5
+ //
6
+ // Core behaviors keep working inside a custom header through data hooks:
7
+ // [data-search-open] opens the search dialog (when site.search is set)
8
+ // [data-scheme-toggle] flips light/dark (when site.schemeToggle is true)
9
+ // [data-menu-open] opens the phone menu sheet (built from site.links)
10
+ // [data-live-header] (hidden) becomes the "Live" link during a stream
11
+ export interface ChromeLink {
12
+ label: string;
13
+ url: string;
14
+ current: boolean;
15
+ external: boolean;
16
+ }
17
+
18
+ export interface SiteChrome {
19
+ title: string;
20
+ tagline: string;
21
+ logo: string; // '' when the site has no logo
22
+ homeUrl: string;
23
+ links: ChromeLink[]; // header navigation
24
+ footerLinks: ChromeLink[];
25
+ social: (ChromeLink & { icon: string })[]; // icon: inline SVG ('' = text link)
26
+ readLinks: ChromeLink[]; // collections, search, RSS
27
+ footerHtml: string; // Settings → footer text, rendered
28
+ languages: { code: string; label: string; url: string; current: boolean }[]; // [] when single-language
29
+ search: { url: string; label: string } | null;
30
+ schemeToggle: boolean;
31
+ liveHeader: boolean;
32
+ headerStyle: string; // theme token `header`
33
+ footerStyle: string; // theme token `footer`
34
+ }
@@ -0,0 +1,16 @@
1
+ // Files a theme ships under assets/, as built URLs. Kept apart from theme.ts
2
+ // (which loads every .astro template) so section data resolution can use it
3
+ // without pulling the templates in.
4
+ import * as user from 'virtual:acms-user-themes';
5
+
6
+ const builtin = import.meta.glob(['../themes/*/assets/**/*', '!**/.img-subir-originals/**'], { eager: true, query: '?url', import: 'default' }) as Record<string, string>;
7
+
8
+ // "assets/hero.jpg" (relative to the theme folder) → the built asset URL, so
9
+ // section image fields and defaults can point at files the theme ships.
10
+ // '' when the theme has no such file. Project themes win over built-ins.
11
+ export function themeAssetUrl(slug: string, path: string): string {
12
+ const file = path.replace(/^\.\//, '');
13
+ if (!/^assets\/[^?#]+$/.test(file) || file.includes('..')) return '';
14
+ const all = { ...builtin, ...((user as { assets?: Record<string, string> }).assets ?? {}) };
15
+ return Object.entries(all).find(([p]) => p.endsWith(`/themes/${slug}/${file}`))?.[1] ?? '';
16
+ }
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
@@ -76,6 +83,10 @@ export interface Theme {
76
83
  pageTemplates: Record<string, unknown>;
77
84
  // templates/event.astro replaces the built-in event page layout
78
85
  EventTemplate: unknown | null;
86
+ // templates/header.astro / footer.astro replace the site header and footer;
87
+ // they receive a `site` prop (lib/site-chrome.ts) with links already resolved
88
+ HeaderTemplate: unknown | null;
89
+ FooterTemplate: unknown | null;
79
90
  }
80
91
 
81
92
  interface ThemeJson { sections?: Record<string, unknown>; version?: string; name?: string; description?: string; tokens?: Record<string, unknown>; post?: Record<string, unknown> }
@@ -93,6 +104,12 @@ const homes = import.meta.glob('../themes/*/templates/home.astro', { eager: true
93
104
  const postTpls = import.meta.glob('../themes/*/templates/post-*.astro', { eager: true }) as Record<string, { default: unknown }>;
94
105
  const pageTpls = import.meta.glob('../themes/*/templates/page-*.astro', { eager: true }) as Record<string, { default: unknown }>;
95
106
  const eventTpls = import.meta.glob('../themes/*/templates/event.astro', { eager: true }) as Record<string, { default: unknown }>;
107
+ const headerTpls = import.meta.glob('../themes/*/templates/header.astro', { eager: true }) as Record<string, { default: unknown }>;
108
+ const footerTpls = import.meta.glob('../themes/*/templates/footer.astro', { eager: true }) as Record<string, { default: unknown }>;
109
+ const migrationMods = {
110
+ ...(import.meta.glob('../themes/*/migrations/*.mjs', { eager: true }) as Record<string, { default?: unknown }>),
111
+ ...((user as { migrations?: Record<string, { default?: unknown }> }).migrations ?? {}),
112
+ };
96
113
 
97
114
  // works for both '../themes/<slug>/…' and '/src/themes/<slug>/…'
98
115
  const slugOf = (path: string) => /themes\/([^/]+)\//.exec(path)?.[1] ?? path.split('/')[2];
@@ -115,7 +132,9 @@ function buildThemes(
115
132
  homeMap: Record<string, { default: unknown }>,
116
133
  postMap: Record<string, { default: unknown }>,
117
134
  pageMap: Record<string, { default: unknown }>,
118
- eventMap: Record<string, { default: unknown }> = {}
135
+ eventMap: Record<string, { default: unknown }> = {},
136
+ headerMap: Record<string, { default: unknown }> = {},
137
+ footerMap: Record<string, { default: unknown }> = {}
119
138
  ): Theme[] {
120
139
  return Object.entries(jsonMap).map(([path, raw]) => {
121
140
  const slug = slugOf(path);
@@ -124,6 +143,10 @@ function buildThemes(
124
143
  return {
125
144
  version: manifest.version,
126
145
  sections: sectionDefinitions(manifest),
146
+ migrations: Object.fromEntries(Object.entries(migrationMods)
147
+ .filter(([p, mod]) => slugOf(p) === slug && typeof mod.default === 'function')
148
+ .map(([p, mod]) => [p.split('/').pop()!.replace(/\.mjs$/, ''), mod.default as SectionMigration])),
149
+ presets: manifest.presets,
127
150
  sectionTemplates: Object.fromEntries(Object.entries(manifest.sections).flatMap(([key, definition]) => {
128
151
  const component = (definition as {component?: string}).component;
129
152
  const match = component && Object.entries(customSections).find(([p]) => p.endsWith(`/themes/${slug}/${component}`));
@@ -142,18 +165,22 @@ function buildThemes(
142
165
  postTemplates: templatesFor(slug, postMap, 'post'),
143
166
  pageTemplates: templatesFor(slug, pageMap, 'page'),
144
167
  EventTemplate: Object.entries(eventMap).find(([p]) => slugOf(p) === slug)?.[1]?.default ?? null,
168
+ HeaderTemplate: Object.entries(headerMap).find(([p]) => slugOf(p) === slug)?.[1]?.default ?? null,
169
+ FooterTemplate: Object.entries(footerMap).find(([p]) => slugOf(p) === slug)?.[1]?.default ?? null,
145
170
  };
146
171
  });
147
172
  }
148
173
 
149
- const builtins = buildThemes(jsons, csses, homes, postTpls, pageTpls, eventTpls);
174
+ const builtins = buildThemes(jsons, csses, homes, postTpls, pageTpls, eventTpls, headerTpls, footerTpls);
150
175
  const projectThemes = buildThemes(
151
176
  user.jsons as Record<string, ThemeJson>,
152
177
  user.csses,
153
178
  user.homes,
154
179
  user.postTpls,
155
180
  user.pageTpls,
156
- (user as { eventTpls?: Record<string, { default: unknown }> }).eventTpls ?? {}
181
+ (user as { eventTpls?: Record<string, { default: unknown }> }).eventTpls ?? {},
182
+ (user as { headerTpls?: Record<string, { default: unknown }> }).headerTpls ?? {},
183
+ (user as { footerTpls?: Record<string, { default: unknown }> }).footerTpls ?? {}
157
184
  );
158
185
  const projectSlugs = new Set(projectThemes.map((t) => t.slug));
159
186
 
@@ -161,6 +188,8 @@ const themes: Theme[] = [...builtins.filter((t) => !projectSlugs.has(t.slug)), .
161
188
  (a, b) => (a.slug === DEFAULT_THEME ? -1 : b.slug === DEFAULT_THEME ? 1 : a.name.localeCompare(b.name))
162
189
  );
163
190
 
191
+ export { themeAssetUrl } from './theme-assets';
192
+
164
193
  export function listThemes(): Theme[] {
165
194
  return themes;
166
195
  }
@@ -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
+ }