@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,59 @@
1
+ import { z } from 'zod';
2
+
3
+ export const MobileConfig = z.object({
4
+ ios: z.object({
5
+ teamId: z.string().regex(/^[A-Z0-9]{10}$/, 'Use a 10-character Apple team ID.'),
6
+ bundleId: z.string().regex(/^[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)+$/, 'Use a bundle ID such as com.example.app.'),
7
+ paths: z.array(z.string().min(1).max(2000).refine(p => /^(?:NOT )?(?:\*|\/[^\r\n]*)$/.test(p), 'Use an absolute path or *, optionally prefixed with NOT.')).max(100).default(['*']),
8
+ }).strict().optional(),
9
+ android: z.object({
10
+ package: z.string().regex(/^[A-Za-z][A-Za-z0-9_]*(?:\.[A-Za-z][A-Za-z0-9_]*)+$/, 'Use an Android package such as com.example.app.'),
11
+ sha256CertFingerprints: z.array(z.string().regex(/^(?:[A-Fa-f0-9]{2}:){31}[A-Fa-f0-9]{2}$/, 'Use a colon-separated SHA-256 certificate fingerprint.').transform(s => s.toUpperCase())).min(1),
12
+ }).strict().optional(),
13
+ // Push notifications to the site's own Expo apps. Off unless configured:
14
+ // true, or { enabled, retentionDays } (devices not seen for that many days are removed).
15
+ push: z.union([z.boolean(), z.object({
16
+ enabled: z.boolean().default(true),
17
+ retentionDays: z.number().int().min(30).max(730).default(270),
18
+ }).strict()]).optional(),
19
+ // Editor apps (staff sign in through the browser, OAuth 2.0 + PKCE). Off
20
+ // unless redirect URIs are listed: only these exact URIs receive codes.
21
+ editor: z.object({
22
+ name: z.string().trim().min(1).max(80).optional(),
23
+ redirectUris: z.array(z.string().max(500).superRefine((uri, ctx) => {
24
+ const problem = redirectUriProblem(uri);
25
+ if (problem) ctx.addIssue({ code: z.ZodIssueCode.custom, message: problem });
26
+ })).min(1).max(20),
27
+ accessTokenMinutes: z.number().int().min(5).max(24 * 60).default(60),
28
+ refreshTokenDays: z.number().int().min(1).max(365).default(90),
29
+ }).strict().optional(),
30
+ }).strict();
31
+
32
+ const BLOCKED_SCHEMES = new Set(['javascript', 'data', 'vbscript', 'file', 'about', 'blob', 'filesystem', 'ftp', 'ws', 'wss', 'mailto', 'tel', 'sms', 'intent', 'chrome', 'view-source']);
33
+ /** why a redirect URI can't be allowed, or '' (custom scheme, https, or http loopback per RFC 8252) */
34
+ export function redirectUriProblem(uri) {
35
+ let url;
36
+ try { url = new URL(uri); } catch { return 'Use an absolute URI such as com.example.staff://auth or https://example.com/app/auth.'; }
37
+ const scheme = url.protocol.slice(0, -1).toLowerCase();
38
+ if (url.hash || uri.includes('#')) return 'Redirect URIs cannot contain a fragment.';
39
+ if (url.username || url.password) return 'Redirect URIs cannot contain credentials.';
40
+ if (BLOCKED_SCHEMES.has(scheme)) return `The ${scheme}: scheme cannot receive sign-in codes.`;
41
+ if (scheme === 'http') {
42
+ const host = url.hostname.replace(/^\[|\]$/g, '');
43
+ if (!['127.0.0.1', '::1', 'localhost'].includes(host)) return 'http:// is only allowed for loopback addresses (127.0.0.1, [::1], localhost); use https:// or an app scheme.';
44
+ return '';
45
+ }
46
+ if (scheme === 'https') return url.hostname ? '' : 'https:// redirect URIs need a host.';
47
+ if (!/^[a-z][a-z0-9+.-]*$/.test(scheme)) return 'Invalid URI scheme.';
48
+ return '';
49
+ }
50
+
51
+ export function resolveMobile(value) {
52
+ const result = MobileConfig.safeParse(value === undefined ? {} : value);
53
+ if (!result.success) throw new Error(`acms: ${result.error.issues.map(i => `mobile.${i.path.join('.')}: ${i.message}`).join('; ')}`);
54
+ const { push, editor, ...rest } = result.data;
55
+ const options = typeof push === 'object' ? push : { enabled: push === true, retentionDays: 270 };
56
+ return { ...rest, push: { enabled: options.enabled, retentionDays: options.retentionDays },
57
+ editor: editor ? { enabled: true, name: editor.name ?? '', redirectUris: [...new Set(editor.redirectUris)], accessTokenMinutes: editor.accessTokenMinutes, refreshTokenDays: editor.refreshTokenDays }
58
+ : { enabled: false, name: '', redirectUris: [], accessTokenMinutes: 60, refreshTokenDays: 90 } };
59
+ }
@@ -10,10 +10,12 @@ import { renderMarkdown } from '../markdown';
10
10
  import { blocksToHtml } from '../blocksToHtml';
11
11
  import { ApiError } from '../api';
12
12
  import { pageQuery, queryParams } from './http';
13
+ import { localizeDoc } from '../translations';
14
+ import { localeQuery, mobileLocale, localizedUrl } from './locale';
13
15
 
14
16
  export const contentQuery = z.object({
15
17
  ...pageQuery, kind: z.enum(['post', 'page']).optional(),
16
- collection: z.string().min(1).max(80).optional(), locale: z.string().min(1).max(35).optional(),
18
+ collection: z.string().min(1).max(80).optional(), locale: localeQuery,
17
19
  }).strict();
18
20
  export function safeUrl(value: string, origin: string, link = false): string {
19
21
  if (!value) return '';
@@ -22,7 +24,7 @@ export function safeUrl(value: string, origin: string, link = false): string {
22
24
  return (['http:', 'https:', ...(link ? ['mailto:', 'tel:'] : [])].includes(url.protocol) && !url.username && !url.password) ? url.href : '';
23
25
  } catch { return ''; }
24
26
  }
25
- export function mobileHtml(html: string, origin: string) {
27
+ export function mobileHtml(html: string, origin: string, locale: string | null = null) {
26
28
  return sanitizeHtml(html, {
27
29
  allowedTags: [...sanitizeHtml.defaults.allowedTags, 'img'],
28
30
  allowedAttributes: { a: ['href', 'title'], img: ['src', 'alt', 'title', 'width', 'height'], th: ['colspan', 'rowspan'], td: ['colspan', 'rowspan'] },
@@ -30,20 +32,21 @@ export function mobileHtml(html: string, origin: string) {
30
32
  allowedSchemesByTag: { img: ['https', 'http'] },
31
33
  allowProtocolRelative: false,
32
34
  transformTags: {
33
- a: (_tag, attrs) => ({ tagName: 'a', attribs: { ...attrs, href: safeUrl(attrs.href ?? '', origin, true) } }),
35
+ a: (_tag, attrs) => ({ tagName: 'a', attribs: { ...attrs, href: localizedUrl(safeUrl(attrs.href ?? '', origin, true), origin, locale) } }),
34
36
  img: (_tag, attrs) => ({ tagName: 'img', attribs: { ...attrs, src: safeUrl(attrs.src ?? '', origin) } }),
35
37
  },
36
38
  });
37
39
  }
38
40
  const iso = (n: number | null) => n === null ? null : new Date(n).toISOString();
39
- function dto(doc: Page | Post, kind: 'page' | 'post', origin: string, detail: boolean) {
41
+ export function contentDto(raw: Page | Post, kind: 'page' | 'post', origin: string, detail: boolean, locale: string | null) {
42
+ const doc = localizeDoc(raw, locale ?? undefined);
40
43
  const post = kind === 'post' ? doc as Post : null;
41
44
  const page = kind === 'page' ? doc as Page : null;
42
45
  const result = {
43
46
  id: doc.id, kind, slug: doc.slug, title: doc.title,
44
47
  collection: post?.collection ?? null,
45
- locale: page?.locale || I18N.defaultLocale || null,
46
- url: safeUrl(post ? collectionUrl(post.collection, post.slug) : page!.isHome ? localeHomeUrl(page!.locale) : `/${page!.slug}`, origin),
48
+ locale,
49
+ url: localizedUrl(safeUrl(post ? collectionUrl(post.collection, post.slug) : page!.isHome ? localeHomeUrl(page!.locale) : `/${page!.slug}`, origin), origin, locale),
47
50
  excerpt: post?.excerpt ?? page!.seoDesc,
48
51
  tags: post ? publicTags(post.tags) : [],
49
52
  image: post ? safeUrl(post.cover, origin) : '', imageAlt: post?.coverAlt ?? '',
@@ -53,7 +56,7 @@ function dto(doc: Page | Post, kind: 'page' | 'post', origin: string, detail: bo
53
56
  if (!detail) return result;
54
57
  // An intentionally empty canonical editor body must not revive legacy text.
55
58
  const html = doc.bodyJson ? doc.bodyHtml : doc.bodyHtml || (post ? renderMarkdown(post.body) : blocksToHtml(page!.blocks));
56
- return { ...result, content: { format: 'sanitized-html' as const, html: mobileHtml(html, result.url), dynamicWidgets: 'website-only' as const } };
59
+ return { ...result, content: { format: 'sanitized-html' as const, html: mobileHtml(html, result.url, locale), dynamicWidgets: 'website-only' as const } };
57
60
  }
58
61
  function visible(doc: Page | Post, kind: 'page' | 'post') {
59
62
  return doc.status === 'published' && (kind === 'page' || Object.hasOwn(CONTENT.collections, (doc as Post).collection));
@@ -62,17 +65,19 @@ export async function listContent(repo: ContentRepository, url: URL) {
62
65
  const q = contentQuery.parse(queryParams(url));
63
66
  if (q.collection && !Object.hasOwn(CONTENT.collections, q.collection)) throw new ApiError(400, 'Unknown collection.');
64
67
  if (q.collection && q.kind === 'page') throw new ApiError(400, 'Pages do not belong to post collections.');
65
- if (q.locale && !I18N.locales.includes(q.locale)) throw new ApiError(400, 'Unknown locale.');
68
+ const locale = mobileLocale(q.locale);
66
69
  const origin = siteOrigin(await repo.getSettings(), url);
67
- const rows = await repo.listPublicContent({ ...q, collections: Object.keys(CONTENT.collections), defaultLocale: I18N.defaultLocale });
70
+ const rows = await repo.listPublicContent({ ...q, locale: undefined, collections: Object.keys(CONTENT.collections), defaultLocale: I18N.defaultLocale });
68
71
  const items = await Promise.all(rows.slice(0, q.limit).map(async (r) => {
69
72
  const doc = r.kind === 'post' ? await repo.getPost(r.id) : await repo.getPage(r.id);
70
- return doc && visible(doc, r.kind) ? dto(doc, r.kind, origin, false) : null;
73
+ return doc && visible(doc, r.kind) ? contentDto(doc, r.kind, origin, false, locale) : null;
71
74
  }));
72
- return { items: items.filter((x) => x !== null), pagination: { page: q.page, limit: q.limit, nextPage: rows.length > q.limit ? q.page + 1 : null } };
75
+ return { locale, items: items.filter((x) => x !== null), pagination: { page: q.page, limit: q.limit, nextPage: rows.length > q.limit ? q.page + 1 : null } };
73
76
  }
77
+ export const contentDetailQuery = z.object({ kind: z.enum(['post', 'page']).optional(), locale: localeQuery }).strict();
74
78
  export async function getContent(repo: ContentRepository, url: URL, id: string) {
75
- const q = z.object({ kind: z.enum(['post', 'page']).optional() }).strict().parse(queryParams(url));
79
+ const q = contentDetailQuery.parse(queryParams(url));
80
+ const locale = mobileLocale(q.locale);
76
81
  // IDs are UUIDs, but restored databases may contain arbitrary legacy IDs.
77
82
  if (!id || id.length > 200) throw new ApiError(404, 'Content not found.');
78
83
  const post = q.kind === 'page' ? null : await repo.getPost(id);
@@ -81,5 +86,5 @@ export async function getContent(repo: ContentRepository, url: URL, id: string)
81
86
  const kind = post ? 'post' : 'page';
82
87
  const doc = post ?? page;
83
88
  if (!doc || !visible(doc, kind)) throw new ApiError(404, 'Content not found.');
84
- return dto(doc, kind, siteOrigin(await repo.getSettings(), url), true);
89
+ return contentDto(doc, kind, siteOrigin(await repo.getSettings(), url), true, locale);
85
90
  }
@@ -0,0 +1,83 @@
1
+ import { z } from 'zod';
2
+ import type { ContentRepository } from '../repo/types';
3
+ import { ApiError } from '../api';
4
+ import { getTheme } from '../theme';
5
+ import { wallNow } from '../sections';
6
+ import { I18N } from '../i18n';
7
+ import { localeQuery, mobileLocale } from './locale';
8
+ import { queryParams } from './http';
9
+ import { publishedPage, visibleSection } from './pages';
10
+
11
+ // One published section as its own chrome-less document, for apps that have
12
+ // no native component for it (GET /embed/section/<page|home>/<sectionId>).
13
+ // The same visitor rules as the website and /api/v1/pages apply: a hidden,
14
+ // off-schedule, invalid or unknown section is a 404, never a preview.
15
+
16
+ export const embedQuery = z.object({ locale: localeQuery, scheme: z.enum(['light', 'dark']).optional() }).strict();
17
+
18
+ export interface EmbedSection {
19
+ /** The page to hand to ThemeSections (the language's own home when it has one). */
20
+ page: NonNullable<Awaited<ReturnType<ContentRepository['getPage']>>>;
21
+ /** Settings whose layout for that page holds only the requested section. */
22
+ settings: Awaited<ReturnType<ContentRepository['getSettings']>>;
23
+ sectionId: string;
24
+ type: string;
25
+ /** Language to render in (the site default when none was asked for). */
26
+ locale: string;
27
+ scheme?: 'light' | 'dark';
28
+ }
29
+
30
+ export async function embedSection(repo: ContentRepository, url: URL, path: string, now = Date.now()): Promise<EmbedSection> {
31
+ const q = embedQuery.safeParse(queryParams(url));
32
+ if (!q.success) throw new ApiError(400, 'Invalid request parameters.');
33
+ const locale = mobileLocale(q.data.locale);
34
+ const parts = path.split('/').filter(Boolean);
35
+ const sectionId = parts.pop() ?? '';
36
+ const slug = parts.join('/');
37
+ if (!slug || !sectionId || path.length > 600) throw new ApiError(404, 'Section not found.');
38
+ const settings = await repo.getSettings();
39
+ const page = await publishedPage(repo, settings, slug, locale).catch(() => { throw new ApiError(404, 'Section not found.'); });
40
+ const layout = settings.sectionPages[page.id];
41
+ const stored = layout?.mode === 'sections' ? layout.sections.find((s) => s.id === sectionId) : undefined;
42
+ const section = stored && visibleSection(getTheme(settings.activeTheme), stored, wallNow(settings.timezone || 'UTC', now));
43
+ if (!layout || !stored || !section) throw new ApiError(404, 'Section not found.');
44
+ return {
45
+ page, sectionId, type: section.type, scheme: q.data.scheme,
46
+ locale: locale ?? I18N.defaultLocale ?? '',
47
+ settings: { ...settings, sectionPages: { ...settings.sectionPages, [page.id]: { ...layout, mode: 'sections', sections: [stored] } } },
48
+ };
49
+ }
50
+
51
+ /**
52
+ * Reports the section's height and hands link taps to the app instead of
53
+ * navigating: React Native WebView (window.ReactNativeWebView) or, on the
54
+ * web, the parent window of the iframe. Messages carry only public data.
55
+ */
56
+ export function embedBridgeScript(sectionId: string): string {
57
+ return `(function () {
58
+ var id = ${JSON.stringify(sectionId).replace(/</g, '\\u003c')};
59
+ var post = function (msg) {
60
+ msg.acmsSection = id;
61
+ if (window.ReactNativeWebView) window.ReactNativeWebView.postMessage(JSON.stringify(msg));
62
+ else if (window.parent !== window) window.parent.postMessage(msg, '*');
63
+ };
64
+ var root = document.getElementById('acms-embed');
65
+ var last = 0;
66
+ var send = function () {
67
+ var h = Math.ceil(root.getBoundingClientRect().height);
68
+ if (h !== last) { last = h; post({ type: 'height', height: h }); }
69
+ };
70
+ document.addEventListener('click', function (e) {
71
+ var a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
72
+ if (!a) return;
73
+ var raw = a.getAttribute('href') || '';
74
+ if (!raw || raw.charAt(0) === '#') return;
75
+ e.preventDefault();
76
+ post({ type: 'navigate', href: a.href });
77
+ }, true);
78
+ if (window.ResizeObserver) new ResizeObserver(send).observe(root);
79
+ window.addEventListener('load', send);
80
+ document.addEventListener('toggle', send, true);
81
+ send();
82
+ })();`;
83
+ }
@@ -6,8 +6,9 @@ import type { ContentRepository } from '../repo/types';
6
6
 
7
7
  const headers = {
8
8
  'Access-Control-Allow-Origin': '*',
9
- 'Access-Control-Allow-Methods': 'GET, HEAD, POST, OPTIONS',
10
- 'Access-Control-Allow-Headers': 'Content-Type, Idempotency-Key, If-None-Match',
9
+ 'Access-Control-Allow-Methods': 'GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS',
10
+ // Authorization carries editor-app bearer tokens; no route reads cookies
11
+ 'Access-Control-Allow-Headers': 'Authorization, Content-Type, Idempotency-Key, If-None-Match',
11
12
  'Access-Control-Expose-Headers': 'ETag, Retry-After, Idempotency-Replayed',
12
13
  'X-Content-Type-Options': 'nosniff',
13
14
  'Cache-Control': 'no-store',
@@ -18,8 +19,8 @@ export const response = (data: unknown, status = 200, extra: HeadersInit = {}) =
18
19
  new Headers(extra).forEach((value, key) => h.set(key, value));
19
20
  return Response.json({ version: 1, data }, { status, headers: h });
20
21
  };
21
- export async function publicResponse(request: Request, data: unknown) {
22
- const res = response(data);
22
+ export async function publicResponse(request: Request, data: unknown, raw = false) {
23
+ const res = raw ? Response.json(data, { headers }) : response(data);
23
24
  const etag = `"${await sha256Hex(await res.clone().text())}"`;
24
25
  res.headers.set('ETag', etag);
25
26
  // Revalidate even fresh-looking content: unpublishing must take effect.
@@ -29,12 +30,13 @@ export async function publicResponse(request: Request, data: unknown) {
29
30
  }
30
31
  export function failure(error: unknown) {
31
32
  const status = error instanceof ApiError || error instanceof BookingError ? error.status : error instanceof z.ZodError ? 400 : 500;
32
- const codes: Record<number, string> = { 400: 'invalid_request', 404: 'not_found', 405: 'method_not_allowed', 409: 'conflict', 410: 'gone', 413: 'payload_too_large', 415: 'unsupported_media_type', 429: 'rate_limited' };
33
+ const codes: Record<number, string> = { 400: 'invalid_request', 401: 'unauthorized', 403: 'forbidden', 404: 'not_found', 405: 'method_not_allowed', 409: 'conflict', 410: 'gone', 413: 'payload_too_large', 415: 'unsupported_media_type', 429: 'rate_limited', 502: 'upstream_error' };
33
34
  return Response.json({ version: 1, error: {
34
35
  code: codes[status] ?? 'internal_error',
35
36
  message: status === 500 ? 'Request failed. Please try again.' : error instanceof z.ZodError ? 'Invalid request parameters.' : (error as Error).message,
36
37
  ...(error instanceof z.ZodError ? { issues: error.issues.map(({ path, message }) => ({ path, message })) } : {}),
37
- } }, { status, headers: { ...headers, ...(status === 429 ? { 'Retry-After': '60' } : {}) } });
38
+ } }, { status, headers: { ...headers, ...(status === 429 ? { 'Retry-After': '60' } : {}),
39
+ ...(status === 401 ? { 'WWW-Authenticate': 'Bearer realm="acms", error="invalid_token"' } : {}) } });
38
40
  }
39
41
  export async function limitRequest(repo: ContentRepository, address: string, write = false) {
40
42
  const key = `mobile:${write ? 'write' : 'read'}:${await sha256Hex(address)}`;
@@ -0,0 +1,18 @@
1
+ import { z } from 'zod';
2
+ import { ApiError } from '../api';
3
+ import { I18N, localePath } from '../i18n';
4
+
5
+ export const localeQuery = z.string().min(1).max(35).optional();
6
+ export function mobileLocale(value?: string) {
7
+ if (value && !I18N.locales.includes(value)) throw new ApiError(400, 'Unknown locale.');
8
+ return value ?? (I18N.defaultLocale || null);
9
+ }
10
+ export function localizedUrl(value: string, origin: string, locale: string | null) {
11
+ if (!value || !locale) return value;
12
+ try {
13
+ const url = new URL(value, origin);
14
+ if (url.origin !== new URL(origin).origin || /^\/(?:_|(?:media|api|admin|go)(?:\/|$))|\.[a-z0-9]+$/i.test(url.pathname)) return value;
15
+ url.pathname = localePath(url.pathname, locale);
16
+ return url.href;
17
+ } catch { return value; }
18
+ }
@@ -0,0 +1,111 @@
1
+ import { z } from 'zod';
2
+ import { zodToJsonSchema } from 'zod-to-json-schema';
3
+ import type { ContentRepository } from '../repo/types';
4
+ import { contentQuery, contentDetailQuery } from './content';
5
+ import { eventsQuery, availabilityQuery } from './calendar';
6
+ import { bookingInput } from './bookings';
7
+ import { pagesQuery } from './pages';
8
+ import { deviceInput, deviceUpdate } from '../push/api';
9
+ import { pageQuery, queryParams } from './http';
10
+ import * as schemas from './schemas';
11
+ import { editorPaths, editorSchemas, editorSecuritySchemes } from '../editor/openapi';
12
+
13
+ const jsonSchema = (schema: z.ZodTypeAny) => {
14
+ const { $schema, ...json } = zodToJsonSchema(schema, { $refStrategy: 'none', target: 'jsonSchema7' });
15
+ return json;
16
+ };
17
+ const ref = (name: string) => ({ $ref: `#/components/schemas/${name}` });
18
+ const media = (schema: unknown) => ({ 'application/json': { schema } });
19
+ const envelope = (schema: unknown) => ({ type: 'object', required: ['version', 'data'], properties: { version: { const: 1 }, data: schema } });
20
+ const empty = z.object({}).strict();
21
+ const readHeaders = {
22
+ ETag: { schema: { type: 'string' } }, 'Cache-Control': { schema: { type: 'string' } },
23
+ 'Access-Control-Allow-Origin': { schema: { type: 'string', const: '*' } },
24
+ };
25
+ const errors = Object.fromEntries([400, 404, 405, 429, 500].map(status => [status, {
26
+ description: status === 429 ? 'Read limit exceeded (300/minute/client address).' : 'Request failed.', content: media(ref('Error')),
27
+ ...(status === 429 ? { headers: { 'Retry-After': { schema: { type: 'string', const: '60' } } } } : {}),
28
+ }]));
29
+ function parameters(query: z.AnyZodObject) {
30
+ return Object.entries(query.shape).map(([name, schema]) => ({ name, in: 'query', required: !(schema as z.ZodTypeAny).isOptional(), schema: jsonSchema(schema as z.ZodTypeAny) }));
31
+ }
32
+ export async function getOpenApi(_repo: ContentRepository, url: URL) {
33
+ empty.parse(queryParams(url));
34
+ const paths: Record<string, unknown> = {};
35
+ const reads: [string, string, z.AnyZodObject, unknown, boolean?][] = [
36
+ ['/site', 'Site identity, navigation, languages and features', empty, ref('Site')],
37
+ ['/theme', 'Active Design tokens including overrides', empty, ref('Theme')],
38
+ ['/pages/home', 'Published home page and resolved sections', pagesQuery, ref('Page')],
39
+ ['/pages/{slug}', 'Published page and resolved sections (slug may contain slashes)', pagesQuery, ref('Page')],
40
+ ['/content', 'Published content in the requested language', contentQuery, jsonSchema(z.object({ locale: z.string().nullable(), items: z.array(schemas.ContentSummary), pagination: schemas.Pagination }))],
41
+ ['/content/{id}', 'Published content detail', contentDetailQuery, ref('Content')],
42
+ ['/events', 'Public event occurrences; requires calendar', eventsQuery, ref('Events')],
43
+ ['/calendars', 'Public calendars; requires calendar', empty, ref('Calendars')],
44
+ ['/resources', 'Public resources and booking links; requires bookings', z.object(pageQuery).strict(), ref('Resources')],
45
+ ['/availability', 'Current availability; requires bookings', availabilityQuery, ref('Availability'), false],
46
+ ['/push/topics', 'Topics an app can offer as notification toggles; requires push', empty, ref('PushTopics')],
47
+ ['/openapi.json', 'OpenAPI 3.1 document (no v1 envelope)', empty, { type: 'object', required: ['openapi', 'info', 'paths', 'components'] }],
48
+ ];
49
+ for (const [path, summary, query, schema, cache = true] of reads) {
50
+ const params: unknown[] = parameters(query);
51
+ for (const match of path.matchAll(/\{(\w+)\}/g)) params.push({ name: match[1], in: 'path', required: true, schema: { type: 'string' } });
52
+ if (cache) params.push({ name: 'If-None-Match', in: 'header', schema: { type: 'string' } });
53
+ const responses = { ...errors, 200: { description: 'Success.', headers: readHeaders, content: media(path === '/openapi.json' ? schema : envelope(schema)) },
54
+ ...(cache ? { 304: { description: 'Unchanged (no body).', headers: readHeaders } } : {}),
55
+ };
56
+ paths[`/api/v1${path}`] = {
57
+ get: { summary, parameters: params, responses },
58
+ head: { summary: `Headers for ${summary}`, parameters: params, responses: Object.fromEntries(Object.entries(responses).map(([code, res]) => { const { content, ...headersOnly } = res as { content?: unknown }; return [code, headersOnly]; })) },
59
+ options: { summary: 'Public CORS preflight', responses: { 204: { description: 'No body. Allows GET, HEAD and OPTIONS without credentials.' } } },
60
+ };
61
+ }
62
+ paths['/api/v1/bookings'] = {
63
+ post: {
64
+ summary: 'Create a guest booking or replay an existing request; requires bookings',
65
+ description: '20 attempts/minute/client address. JSON body up to 50,000 bytes. Responses use no-store. Reuse a random UUID v4 once per booking intent.',
66
+ parameters: [{ name: 'Idempotency-Key', in: 'header', required: true, schema: { type: 'string', format: 'uuid', pattern: '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$' } }],
67
+ requestBody: { required: true, content: media(jsonSchema(bookingInput)) },
68
+ responses: { ...errors,
69
+ ...Object.fromEntries([409, 410, 413, 415].map(code => [code, { description: 'Booking request rejected.', content: media(ref('Error')) }])),
70
+ ...Object.fromEntries([200, 201].map(code => [code, { description: code === 201 ? 'Created.' : 'Idempotent replay.', content: media(envelope(ref('BookingResult'))), headers: { 'Idempotency-Replayed': { schema: { type: 'string', enum: [String(code === 200)] } }, 'Cache-Control': { schema: { type: 'string', const: 'no-store' } } } }])),
71
+ },
72
+ },
73
+ options: { summary: 'Public CORS preflight', responses: { 204: { description: 'No body.' } } },
74
+ };
75
+ const writeErrors = { ...errors, 429: { ...errors[429], description: 'Write limit exceeded (20/minute/client address).' },
76
+ ...Object.fromEntries([413, 415].map(code => [code, { description: 'Request rejected.', content: media(ref('Error')) }])) };
77
+ const noStore = { 'Cache-Control': { schema: { type: 'string', const: 'no-store' } } };
78
+ const tokenParam = { name: 'token', in: 'path', required: true, description: 'The Expo push token, URL-encoded.', schema: { type: 'string' } };
79
+ const pushNote = 'Requires push (acms({ mobile: { push: true } })); 404 when off. JSON body up to 8,000 bytes. Topic ids come from GET /api/v1/push/topics; unknown ids return 400.';
80
+ paths['/api/v1/devices'] = {
81
+ post: {
82
+ summary: 'Register or refresh this device for push notifications; requires push',
83
+ description: `${pushNote} Call on every app start: it refreshes lastSeenAt (devices unseen for the retention period, default 270 days, are removed). topics replaces the device's topics; omit it to keep them. Stores the token, platform, language and topics only.`,
84
+ requestBody: { required: true, content: media(jsonSchema(deviceInput)) },
85
+ responses: { ...writeErrors, ...Object.fromEntries([200, 201].map(code => [code, { description: code === 201 ? 'Registered.' : 'Refreshed.', headers: noStore, content: media(envelope(ref('DeviceRegistration'))) }])) },
86
+ },
87
+ options: { summary: 'Public CORS preflight', responses: { 204: { description: 'No body.' } } },
88
+ };
89
+ paths['/api/v1/devices/{token}'] = {
90
+ put: {
91
+ summary: 'Change a registered device’s topics or language; requires push', description: pushNote,
92
+ parameters: [tokenParam], requestBody: { required: true, content: media(jsonSchema(deviceUpdate)) },
93
+ responses: { ...writeErrors, 200: { description: 'Updated.', headers: noStore, content: media(envelope(ref('Device'))) } },
94
+ },
95
+ delete: {
96
+ summary: 'Opt this device out of push notifications (idempotent); requires push', description: 'Removes the token. Unknown tokens also succeed.',
97
+ parameters: [tokenParam],
98
+ responses: { ...writeErrors, 200: { description: 'Removed.', headers: noStore, content: media(envelope(ref('DeviceRemoved'))) } },
99
+ },
100
+ options: { summary: 'Public CORS preflight', responses: { 204: { description: 'No body.' } } },
101
+ };
102
+ Object.assign(paths, editorPaths(jsonSchema));
103
+ return {
104
+ openapi: '3.1.0', info: { title: 'ACMS Mobile API', version: '1.0.0', description: 'Public API: no authentication. Disabled modules are not registered. Unknown or duplicate query parameters are rejected. locale selects translated words with main-language fallback; unknown locales return 400. Editor API (tag Editor): /api/v1/auth/* and /api/v1/admin/*, registered only when acms({ mobile: { editor } }) is configured; staff sign in through the browser (OAuth 2.0 authorization code + PKCE) and send the device access token as a Bearer header. Cookies never authenticate it.' },
105
+ tags: [{ name: 'Public', description: 'Visitor apps: no authentication.' }, { name: 'Editor', description: 'Editor apps: device sign-in and the admin API.' }],
106
+ paths, components: {
107
+ schemas: Object.fromEntries([...Object.entries(schemas), ...Object.entries(editorSchemas)].map(([name, schema]) => [name, jsonSchema(schema as z.ZodTypeAny)])),
108
+ securitySchemes: editorSecuritySchemes,
109
+ },
110
+ };
111
+ }
@@ -0,0 +1,71 @@
1
+ import { z } from 'zod';
2
+ import type { ContentRepository } from '../repo/types';
3
+ import type { Page, Settings } from '../schema';
4
+ import { ApiError } from '../api';
5
+ import { getTheme } from '../theme';
6
+ import { migrateSection, resolveSectionData, wallNow } from '../sections';
7
+ import { sectionIssues } from '../../theme-sdk/contract.mjs';
8
+ import { localizeData, scheduledVisible } from '../../theme-sdk/fields.mjs';
9
+ import { isExtraLocale, localizeDoc } from '../translations';
10
+ import { siteOrigin } from '../seo';
11
+ import { blocksToHtml } from '../blocksToHtml';
12
+ import { contentDto, mobileHtml, safeUrl } from './content';
13
+ import { localeQuery, mobileLocale } from './locale';
14
+ import { queryParams } from './http';
15
+
16
+ type Theme = ReturnType<typeof getTheme>;
17
+ type StoredSection = NonNullable<Settings['sectionPages'][string]>['sections'][number];
18
+ /** The website's visitor rules: known type, not hidden, inside its schedule, valid main-language data. */
19
+ export function visibleSection(theme: Theme, stored: StoredSection, nowWall: string) {
20
+ const section = migrateSection(theme, stored);
21
+ const def = theme.sections[section.type];
22
+ if (!def || section.hidden || !scheduledVisible(section, nowWall) || sectionIssues({ mode: 'sections', sections: [section] }, theme.sections).length) return null;
23
+ return section;
24
+ }
25
+
26
+ export const pagesQuery = z.object({ locale: localeQuery }).strict();
27
+ export async function pageSections(repo: ContentRepository, page: Page, settings: Settings, locale: string | null, now = Date.now()) {
28
+ const layout = settings.sectionPages[page.id];
29
+ if (layout?.mode !== 'sections') return [];
30
+ const theme = getTheme(settings.activeTheme);
31
+ const localized = isExtraLocale(locale);
32
+ const view = localized ? { ...settings, locale } : settings;
33
+ const nowWall = wallNow(settings.timezone || 'UTC', now);
34
+ const sections = await Promise.all(layout.sections.map(async stored => {
35
+ const section = visibleSection(theme, stored, nowWall);
36
+ const def = section && theme.sections[section.type];
37
+ if (!section || !def) return null;
38
+ // Validate the main language before overlaying translated words, as on the website.
39
+ const words = localized && section.translations?.[locale] ? localizeData(def, section.data, section.translations[locale]) : section.data;
40
+ return { id: section.id, type: section.type, variant: section.variant, data: await resolveSectionData(def, words, { repo, settings: view, now }) };
41
+ }));
42
+ return sections.filter(s => s !== null);
43
+ }
44
+ /** A published page by slug; "home" is the homepage (or a language's own legacy home). */
45
+ export async function publishedPage(repo: ContentRepository, settings: Settings, slug: string, locale: string | null) {
46
+ let raw = slug === 'home' ? await repo.getHomePage() : await repo.getPageBySlug(slug);
47
+ if (slug === 'home' && isExtraLocale(locale)) {
48
+ const translated = raw && settings.sectionPages[raw.id]?.sections.some(s => s.translations?.[locale]);
49
+ const separate = translated ? null : await repo.getHomePage(locale);
50
+ if (separate?.status === 'published') raw = separate;
51
+ }
52
+ if (!raw || raw.status !== 'published') throw new ApiError(404, 'Page not found.');
53
+ return raw;
54
+ }
55
+ export async function getPage(repo: ContentRepository, url: URL, slug: string) {
56
+ const q = pagesQuery.parse(queryParams(url));
57
+ const locale = mobileLocale(q.locale);
58
+ if (!slug || slug.length > 500) throw new ApiError(404, 'Page not found.');
59
+ const settings = await repo.getSettings();
60
+ const raw = await publishedPage(repo, settings, slug, locale);
61
+ const page = localizeDoc(raw, locale ?? undefined);
62
+ const origin = siteOrigin(settings, url);
63
+ const summary = contentDto(raw, 'page', origin, false, locale);
64
+ const html = page.bodyJson ? page.bodyHtml : page.bodyHtml || blocksToHtml(page.blocks);
65
+ return {
66
+ ...summary, body: { format: 'sanitized-html' as const, html: mobileHtml(html, summary.url, locale) },
67
+ cover: safeUrl(settings.siteCover, origin), seo: { title: page.seoTitle || page.title, description: page.seoDesc },
68
+ mode: settings.sectionPages[page.id]?.mode === 'sections' ? 'sections' as const : 'content' as const,
69
+ sections: await pageSections(repo, raw, settings, locale),
70
+ };
71
+ }
@@ -2,15 +2,15 @@ import type { APIRoute } from 'astro';
2
2
  import type { ContentRepository } from '../repo/types';
3
3
  import { getRepo } from '../repo';
4
4
  import { limitRequest, failure, publicResponse, response } from './http';
5
- export function readRoute(load: (repo: ContentRepository, url: URL, id: string) => Promise<unknown>, cache = true): APIRoute {
5
+ export function readRoute(load: (repo: ContentRepository, url: URL, id: string) => Promise<unknown>, cache = true, raw = false): APIRoute {
6
6
  return async context => {
7
7
  try {
8
8
  const repo = await getRepo(context.locals);
9
9
  let address = 'unknown';
10
10
  try { address = context.clientAddress || address; } catch { /* adapters without a client address share a bucket */ }
11
11
  await limitRequest(repo, address);
12
- const data = await load(repo, context.url, context.params.id ?? '');
13
- const res = cache ? await publicResponse(context.request, data) : response(data);
12
+ const data = await load(repo, context.url, context.params.id ?? context.params.slug ?? '');
13
+ const res = cache ? await publicResponse(context.request, data, raw) : response(data);
14
14
  return context.request.method === 'HEAD' ? new Response(null, { status: res.status, headers: res.headers }) : res;
15
15
  } catch (error) {
16
16
  const res = failure(error);
@@ -0,0 +1,60 @@
1
+ import { z } from 'zod';
2
+ import { CalEvent, Booking, Calendar, BookingQuestion } from '../schema';
3
+
4
+ const str = z.string(), bool = z.boolean(), strings = z.array(str);
5
+ const date = str.datetime(), locale = str.nullable();
6
+ export const Pagination = z.object({ page: z.number().int(), limit: z.number().int(), nextPage: z.number().int().nullable() });
7
+ const Palette = z.object({ bg: str, ink: str, muted: str, line: str, accent: str, panel: str, soft: str });
8
+ export const Site = z.object({
9
+ name: str, description: str, logo: str, icon: str, menu: z.array(z.object({ label: str, href: str })),
10
+ languages: z.object({ default: locale, locales: z.array(z.object({ code: str, name: str })) }),
11
+ features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str })), calendar: bool, bookings: bool, search: bool }), apiVersion: z.literal(1),
12
+ });
13
+ export const Theme = z.object({
14
+ id: str, name: str, scheme: z.enum(['auto', 'light', 'dark']), colors: z.object({ light: Palette, dark: Palette }),
15
+ fonts: z.object({ heading: str, body: str, mono: str }), radius: z.enum(['sharp', 'soft', 'round', 'smooth']),
16
+ });
17
+ export const Section = z.object({ id: str, type: str, variant: str, data: z.record(z.unknown()) });
18
+ export const ContentSummary = z.object({
19
+ id: str, kind: z.enum(['post', 'page']), slug: str, title: str, collection: str.nullable(), locale, url: str,
20
+ excerpt: str, tags: strings, image: str, imageAlt: str, publishedAt: date.nullable(), createdAt: date, updatedAt: date,
21
+ });
22
+ export const Html = z.object({ format: z.literal('sanitized-html'), html: str });
23
+ export const Content = ContentSummary.extend({ content: Html.extend({ dynamicWidgets: z.literal('website-only') }) });
24
+ export const Post = Content.extend({ kind: z.literal('post'), collection: str });
25
+ export const Page = ContentSummary.extend({
26
+ kind: z.literal('page'), body: Html, cover: str, seo: z.object({ title: str, description: str }),
27
+ mode: z.enum(['content', 'sections']), sections: z.array(Section),
28
+ });
29
+ export const Event = CalEvent.pick({
30
+ id: true, slug: true, title: true, excerpt: true, status: true, cancelled: true, featured: true, start: true, end: true,
31
+ allDay: true, timezone: true, rrule: true, exdates: true, location: true, cta: true, tags: true, fields: true,
32
+ }).extend({ url: str, icsUrl: str, repeats: str, image: str, imageAlt: str, updatedAt: date,
33
+ calendars: z.array(z.object({ id: str, slug: str, name: str, color: str })),
34
+ });
35
+ export const Events = z.object({ locale, timezone: str, from: date, to: date, pagination: Pagination,
36
+ occurrences: z.array(z.object({ id: str, start: date, end: date, allDay: bool, event: Event })),
37
+ });
38
+ export const Calendars = z.object({ calendars: z.array(Calendar.pick({ id: true, slug: true, name: true, color: true, description: true, kind: true, visibility: true }).extend({ feedUrl: str })) });
39
+ export const Resources = z.object({ pagination: Pagination, resources: z.array(z.object({
40
+ id: str, slug: str, name: str, kind: str, description: str, capacity: z.number(), amenities: strings, photo: str, timezone: str, url: str,
41
+ links: z.array(z.object({ id: str, slug: str, name: str, description: str, mode: z.enum(['slots', 'day', 'range']), lengths: z.array(z.number()), rules: str,
42
+ askPhone: bool, autoConfirm: bool, url: str, questions: z.array(BookingQuestion.omit({ help: true })),
43
+ })),
44
+ })) });
45
+ export const Availability = z.object({ timezone: str, mode: z.enum(['slots', 'day', 'range']), lengths: z.array(z.number()), autoConfirm: bool,
46
+ days: z.array(z.object({ date: str, slots: z.array(z.object({ start: date, end: date, minutes: z.number() })) })),
47
+ });
48
+ export const BookingResult = z.object({ manageToken: str, booking: Booking.pick({
49
+ id: true, kind: true, status: true, name: true, email: true, phone: true, seats: true, answers: true, source: true,
50
+ }).extend({ start: date, end: date, resourceId: str.nullable(), linkId: str.nullable(), eventId: str.nullable(), createdAt: date }) });
51
+ export const Device = z.object({
52
+ token: str, platform: z.enum(['ios', 'android']), locale: str.nullable(), topics: strings, registeredAt: date, lastSeenAt: date,
53
+ });
54
+ export const DeviceRegistration = z.object({ created: bool, device: Device });
55
+ export const DeviceRemoved = z.object({ token: str, deleted: z.literal(true) });
56
+ export const PushTopics = z.object({ topics: z.array(z.object({ id: str, label: str, kind: z.enum(['general', 'collection', 'calendar']) })) });
57
+ export const Error = z.object({ version: z.literal(1), error: z.object({
58
+ code: z.enum(['invalid_request', 'unauthorized', 'forbidden', 'not_found', 'method_not_allowed', 'conflict', 'gone', 'payload_too_large', 'unsupported_media_type', 'rate_limited', 'upstream_error', 'internal_error']),
59
+ message: str, issues: z.array(z.object({ path: z.array(z.union([str, z.number()])), message: str })).optional(),
60
+ }) });
@@ -0,0 +1,41 @@
1
+ import { z } from 'zod';
2
+ import type { ContentRepository } from '../repo/types';
3
+ import { CONTENT } from '../content';
4
+ import { I18N, localeName } from '../i18n';
5
+ import { CALENDAR, bookingsEnabled } from '../calendar/config';
6
+ import { SEARCH } from '../search-config';
7
+ import { siteOrigin } from '../seo';
8
+ import { resolveDesign } from '../theme';
9
+ import { darkPalette } from '../themeTokens';
10
+ import { safeUrl } from './content';
11
+ import { queryParams } from './http';
12
+
13
+ export async function getSite(repo: ContentRepository, url: URL) {
14
+ z.object({}).strict().parse(queryParams(url));
15
+ const [settings, nav, pages] = await Promise.all([repo.getSettings(), repo.listNavItems(), repo.listPages()]);
16
+ const origin = siteOrigin(settings, url);
17
+ const header = nav.filter(n => n.area === 'header').sort((a, b) => a.order - b.order);
18
+ const menu = header.length ? header : [
19
+ { label: 'Home', url: '/' },
20
+ ...pages.filter(p => p.status === 'published' && !p.isHome).map(p => ({ label: p.title, url: `/${p.slug}` })),
21
+ ...Object.values(CONTENT.collections).map(c => ({ label: c.plural, url: c.base })),
22
+ { label: 'Links', url: '/links' },
23
+ ];
24
+ return {
25
+ name: settings.siteTitle, description: settings.tagline,
26
+ logo: safeUrl(settings.siteLogo, origin), icon: safeUrl(settings.siteFavicon, origin),
27
+ menu: menu.map(n => ({ label: n.label, href: safeUrl(n.url, origin, true) })),
28
+ languages: { default: I18N.defaultLocale || null, locales: I18N.locales.map(code => ({ code, name: localeName(code, true) })) },
29
+ features: { blog: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, name: c.plural, url: safeUrl(c.base, origin) })), calendar: CALENDAR.enabled, bookings: bookingsEnabled(), search: SEARCH.enabled },
30
+ apiVersion: 1,
31
+ };
32
+ }
33
+ export async function getThemeTokens(repo: ContentRepository, url: URL) {
34
+ z.object({}).strict().parse(queryParams(url));
35
+ const { theme, tokens: t } = resolveDesign(await repo.getSettings());
36
+ return {
37
+ id: theme.slug, name: theme.name, scheme: t.scheme,
38
+ colors: { light: { bg: t.bg, ink: t.ink, muted: t.muted, line: t.line, accent: t.accent, panel: t.panel, soft: t.soft }, dark: darkPalette(t) },
39
+ fonts: { heading: t.headingFont, body: t.bodyFont, mono: t.monoFont }, radius: t.radius,
40
+ };
41
+ }