@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,74 @@
1
+ import type { APIContext } from 'astro';
2
+ import { getRepo } from '../repo';
3
+ import { ApiError, readResponseBytes } from '../api';
4
+ import { sha256Hex } from '../api-keys';
5
+ import { OAuthError, revokeToken, tokenGrant, bearerToken } from './auth';
6
+ import { requireEditorApps } from './config';
7
+
8
+ // POST /api/v1/auth/token and /api/v1/auth/revoke. OAuth-shaped responses
9
+ // (RFC 6749 §5): top-level fields, errors as { error, error_description },
10
+ // so standard clients (expo-auth-session) work unchanged. Bodies only —
11
+ // secrets are never accepted in the query string.
12
+
13
+ const headers = {
14
+ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'POST, OPTIONS', 'Access-Control-Allow-Headers': 'Authorization, Content-Type',
15
+ 'Cache-Control': 'no-store', Pragma: 'no-cache', 'X-Content-Type-Options': 'nosniff',
16
+ };
17
+ export const authPreflight = () => new Response(null, { status: 204, headers });
18
+ const reply = (body: unknown, status = 200, extra: Record<string, string> = {}) => Response.json(body, { status, headers: { ...headers, ...extra } });
19
+
20
+ async function readForm(request: Request): Promise<Record<string, string>> {
21
+ const type = request.headers.get('content-type')?.toLowerCase() ?? '';
22
+ const bytes = await readResponseBytes(request, 10_000);
23
+ const text = new TextDecoder().decode(bytes);
24
+ const out: Record<string, string> = {};
25
+ if (type.startsWith('application/x-www-form-urlencoded')) {
26
+ for (const [k, v] of new URLSearchParams(text)) {
27
+ if (k in out) throw new OAuthError(400, 'invalid_request', `Duplicate parameter: ${k}.`);
28
+ out[k] = v;
29
+ }
30
+ } else if (type.startsWith('application/json')) {
31
+ let json: unknown;
32
+ try { json = JSON.parse(text); } catch { throw new OAuthError(400, 'invalid_request', 'Body must be valid JSON.'); }
33
+ if (!json || typeof json !== 'object' || Array.isArray(json)) throw new OAuthError(400, 'invalid_request', 'Body must be an object.');
34
+ for (const [k, v] of Object.entries(json)) if (typeof v === 'string') out[k] = v;
35
+ } else throw new OAuthError(415, 'invalid_request', 'Use Content-Type: application/x-www-form-urlencoded (or application/json).');
36
+ return out;
37
+ }
38
+ const address = (context: APIContext) => { try { return context.clientAddress || 'unknown'; } catch { return 'unknown'; } };
39
+
40
+ function oauthFailure(error: unknown) {
41
+ if (error instanceof OAuthError) return reply({ error: error.error, error_description: error.message }, error.status);
42
+ if (error instanceof ApiError) {
43
+ if (error.status === 429) return reply({ error: 'rate_limited', error_description: error.message }, 429, { 'Retry-After': '60' });
44
+ if (error.status === 404) return reply({ error: 'not_found', error_description: error.message }, 404);
45
+ return reply({ error: 'invalid_request', error_description: error.message }, error.status);
46
+ }
47
+ console.error('acms editor auth: token request failed');
48
+ return reply({ error: 'server_error', error_description: 'Request failed. Please try again.' }, 500);
49
+ }
50
+
51
+ export async function tokenRoute(context: APIContext) {
52
+ try {
53
+ requireEditorApps();
54
+ if (context.url.search) throw new OAuthError(400, 'invalid_request', 'Send parameters in the request body, not the URL.');
55
+ const repo = await getRepo(context.locals);
56
+ if (!await repo.email.rateLimit(`editor:token:${await sha256Hex(address(context))}`, 30, 60_000)) throw new ApiError(429, 'Too many requests. Try again in a minute.');
57
+ return reply(await tokenGrant(repo, await readForm(context.request)));
58
+ } catch (error) { return oauthFailure(error); }
59
+ }
60
+
61
+ export async function revokeRoute(context: APIContext) {
62
+ try {
63
+ requireEditorApps();
64
+ if (context.url.search) throw new OAuthError(400, 'invalid_request', 'Send parameters in the request body, not the URL.');
65
+ const repo = await getRepo(context.locals);
66
+ if (!await repo.email.rateLimit(`editor:revoke:${await sha256Hex(address(context))}`, 30, 60_000)) throw new ApiError(429, 'Too many requests. Try again in a minute.');
67
+ const form = context.request.headers.get('content-type') ? await readForm(context.request) : {};
68
+ const token = form.token || bearerToken(context.request);
69
+ if (!token) throw new OAuthError(400, 'invalid_request', 'Send the token to revoke.');
70
+ await revokeToken(repo, token);
71
+ // RFC 7009: unknown or already-revoked tokens also succeed
72
+ return reply({ revoked: true });
73
+ } catch (error) { return oauthFailure(error); }
74
+ }
@@ -0,0 +1,163 @@
1
+ import { z } from 'zod';
2
+ import type { ContentRepository } from '../repo/types';
3
+ import { DocTranslation, type DocTranslations } from '../schema';
4
+ import { ApiError } from '../api';
5
+ import { I18N } from '../i18n';
6
+ import { isExtraLocale } from '../translations';
7
+ import { translateUnits } from '../translate';
8
+ import { textHash, translationUnits } from '../../theme-sdk/fields.mjs';
9
+ import type { SaveHooks } from '../content-service';
10
+ import type { EditorPrincipal } from './auth';
11
+ import { checkRevision, loadDoc, mergedDoc, revisionOf, saveRaw, withDocLock, type DocKind } from './documents';
12
+ import { findSection, stagedPages, stagedTheme, stageSection } from './sections';
13
+
14
+ // Translations for editor apps, same rules as the web editors: a person
15
+ // setting a text marks it reviewed; AI drafts are stored as "AI draft" for
16
+ // review; "mark reviewed" confirms every translated unit against the current
17
+ // main-language text. Documents save through the content service (live ones
18
+ // stage), sections through the shared design rules (locks apply).
19
+
20
+ type State = 'ai' | 'reviewed';
21
+ type Meta = Record<string, { state: State; src: string }>;
22
+ interface Unit { path: string; label: string; kind: 'text' | 'textarea' | 'richtext' | 'link' | 'select'; source: string; hash: string }
23
+ export type TranslationStatus = 'missing' | 'ai' | 'reviewed' | 'outdated';
24
+
25
+ const pathList = z.array(z.string().max(300)).max(500);
26
+ export const SetDocInput = z.object({ revision: z.string().max(60), values: z.record(z.string().max(300), z.string().max(2_000_000)) }).strict();
27
+ export const SetSectionInput = z.object({ revision: z.number().int().nonnegative(), values: z.record(z.string().max(300), z.string().max(60_000)) }).strict();
28
+ export const ReviewDocInput = z.object({ revision: z.string().max(60), paths: pathList.optional() }).strict();
29
+ export const ReviewSectionInput = z.object({ revision: z.number().int().nonnegative(), paths: pathList.optional() }).strict();
30
+ export const AiDocInput = ReviewDocInput.extend({ includeReviewed: z.boolean().default(false) }).strict();
31
+ export const AiSectionInput = ReviewSectionInput.extend({ includeReviewed: z.boolean().default(false) }).strict();
32
+
33
+ export function requireLocale(locale: string) {
34
+ if (!isExtraLocale(locale)) throw new ApiError(404, I18N.enabled ? `“${locale}” isn’t one of this site’s extra languages.` : 'This site has one language.');
35
+ }
36
+
37
+ // ---- documents -------------------------------------------------------------
38
+
39
+ const DOC_FIELDS: Record<DocKind, [string, string, Unit['kind']][]> = {
40
+ post: [['title', 'Title', 'text'], ['excerpt', 'Excerpt', 'textarea'], ['bodyHtml', 'Body', 'richtext'], ['seoTitle', 'Search title', 'text'], ['seoDesc', 'Search description', 'textarea']],
41
+ page: [['title', 'Title', 'text'], ['bodyHtml', 'Body', 'richtext'], ['seoTitle', 'Search title', 'text'], ['seoDesc', 'Search description', 'textarea']],
42
+ event: [['title', 'Title', 'text'], ['excerpt', 'Summary', 'textarea'], ['bodyHtml', 'Description', 'richtext'], ['seoTitle', 'Search title', 'text'], ['seoDesc', 'Search description', 'textarea'],
43
+ ['locationName', 'Place name', 'text'], ['ctaLabel', 'Button label', 'text']],
44
+ };
45
+ const emptyDocTr = () => DocTranslation.parse({});
46
+
47
+ function docUnits(kind: DocKind, doc: Record<string, unknown>): Unit[] {
48
+ const main = (f: string) => f === 'locationName' ? String((doc.location as { name?: string } | undefined)?.name ?? '')
49
+ : f === 'ctaLabel' ? String((doc.cta as { label?: string } | undefined)?.label ?? '') : String(doc[f] ?? '');
50
+ return DOC_FIELDS[kind].map(([path, label, k]) => ({ path, label, kind: k, source: main(path), hash: textHash(main(path)) })).filter((u) => u.source.trim());
51
+ }
52
+ function status(u: Unit, value: string, meta: Meta): TranslationStatus {
53
+ if (!value.trim()) return 'missing';
54
+ const m = meta[u.path];
55
+ if (m && m.src !== u.hash) return 'outdated';
56
+ return m?.state === 'reviewed' ? 'reviewed' : 'ai';
57
+ }
58
+ function view(units: Unit[], values: Record<string, unknown>, meta: Meta) {
59
+ const list = units.map((u) => { const value = String(values[u.path] ?? ''); return { path: u.path, label: u.label, kind: u.kind, source: u.source, value, status: status(u, value, meta) }; });
60
+ const summary = { total: list.length, missing: 0, ai: 0, reviewed: 0, outdated: 0 };
61
+ for (const u of list) summary[u.status]++;
62
+ return { units: list, summary };
63
+ }
64
+
65
+ type Mode = { op: 'set'; values: Record<string, string> } | { op: 'review'; paths?: string[] } | { op: 'ai'; paths?: string[]; includeReviewed: boolean };
66
+ /** apply one operation to a translation's values + review meta; returns how many units changed */
67
+ async function applyMode(units: Unit[], values: Record<string, unknown>, meta: Meta, mode: Mode, locale: string, repo: ContentRepository, locals: unknown): Promise<number> {
68
+ const byPath = new Map(units.map((u) => [u.path, u]));
69
+ const pick = (paths?: string[]) => {
70
+ if (!paths) return units;
71
+ const unknown = paths.find((p) => !byPath.has(p));
72
+ if (unknown) throw new ApiError(400, `Nothing to translate at “${unknown}”.`);
73
+ return paths.map((p) => byPath.get(p)!);
74
+ };
75
+ if (mode.op === 'set') {
76
+ const entries = Object.entries(mode.values);
77
+ if (!entries.length) throw new ApiError(400, 'Send at least one value.');
78
+ for (const [path, value] of entries) {
79
+ const u = byPath.get(path);
80
+ if (!u) throw new ApiError(400, `Nothing to translate at “${path}”.`);
81
+ values[path] = value;
82
+ // a person wrote it: reviewed, against the current main-language text
83
+ if (value.trim()) meta[path] = { state: 'reviewed', src: u.hash }; else delete meta[path];
84
+ }
85
+ return entries.length;
86
+ }
87
+ if (mode.op === 'review') {
88
+ let n = 0;
89
+ for (const u of pick(mode.paths)) if (String(values[u.path] ?? '').trim()) { meta[u.path] = { state: 'reviewed', src: u.hash }; n++; }
90
+ return n;
91
+ }
92
+ const todo = pick(mode.paths).filter((u) => {
93
+ const st = status(u, String(values[u.path] ?? ''), meta);
94
+ return st === 'missing' || (st === 'outdated' && (mode.includeReviewed || meta[u.path]?.state !== 'reviewed')) || (mode.includeReviewed && (st === 'ai' || st === 'reviewed'));
95
+ });
96
+ if (!todo.length) return 0;
97
+ const { results } = await translateUnits((await repo.getSettings()).ai, { locale, units: todo.map((u) => ({ id: u.path, text: u.source, kind: u.kind, label: u.label.slice(0, 200) })) }, locals);
98
+ let n = 0;
99
+ for (const u of todo) if (results[u.path] !== undefined) { values[u.path] = results[u.path]; meta[u.path] = { state: 'ai', src: u.hash }; n++; }
100
+ return n;
101
+ }
102
+
103
+ export async function getDocTranslation(repo: ContentRepository, kind: DocKind, id: string, locale: string) {
104
+ requireLocale(locale);
105
+ const raw = await loadDoc(repo, kind, id);
106
+ const doc = mergedDoc(kind, raw) as unknown as Record<string, unknown> & { translations?: DocTranslations };
107
+ const tr = doc.translations?.[locale] ?? emptyDocTr();
108
+ return { locale, revision: revisionOf(raw), ...view(docUnits(kind, doc), tr as unknown as Record<string, unknown>, tr.meta as Meta) };
109
+ }
110
+
111
+ export async function changeDocTranslation(repo: ContentRepository, kind: DocKind, id: string, locale: string, revision: string, mode: Mode, hooks: SaveHooks) {
112
+ requireLocale(locale);
113
+ const changed = await withDocLock(repo, kind, id, () => changeDocTranslationLocked(repo, kind, id, locale, revision, mode, hooks));
114
+ return { changed, ...(await getDocTranslation(repo, kind, id, locale)) };
115
+ }
116
+ async function changeDocTranslationLocked(repo: ContentRepository, kind: DocKind, id: string, locale: string, revision: string, mode: Mode, hooks: SaveHooks) {
117
+ const raw = await loadDoc(repo, kind, id);
118
+ checkRevision(raw, revision);
119
+ const doc = mergedDoc(kind, raw) as unknown as Record<string, unknown> & { translations?: DocTranslations };
120
+ const current = doc.translations?.[locale] ?? emptyDocTr();
121
+ const next = { ...current, meta: { ...current.meta } } as Record<string, unknown> & { meta: Meta };
122
+ const changed = await applyMode(docUnits(kind, doc), next, next.meta, mode, locale, repo, hooks.locals);
123
+ if (changed) {
124
+ const parsed = DocTranslation.parse(next);
125
+ await saveRaw(repo, kind, id, { translations: { ...(doc.translations ?? {}), [locale]: parsed } }, hooks);
126
+ }
127
+ return changed;
128
+ }
129
+
130
+ // ---- sections --------------------------------------------------------------
131
+
132
+ export async function getSectionTranslation(repo: ContentRepository, pageId: string, sectionId: string, locale: string) {
133
+ requireLocale(locale);
134
+ const settings = await repo.getSettings();
135
+ const section = findSection(stagedPages(settings), pageId, sectionId);
136
+ const def = (await stagedTheme(settings)).sections[section.type];
137
+ if (!def) throw new ApiError(409, `The “${section.type}” section isn’t available in this theme.`);
138
+ const tr = section.translations?.[locale] ?? { data: {}, meta: {} };
139
+ const units: Unit[] = translationUnits(def, section.data).map((u) => ({ path: u.path, label: u.label, kind: u.kind as Unit['kind'], source: u.source, hash: u.hash }));
140
+ return { locale, revision: settings.designRevision, ...view(units, tr.data, tr.meta as Meta) };
141
+ }
142
+
143
+ export async function changeSectionTranslation(repo: ContentRepository, who: EditorPrincipal, pageId: string, sectionId: string, locale: string, revision: number, mode: Mode, origin: string, locals: unknown) {
144
+ requireLocale(locale);
145
+ const settings = await repo.getSettings();
146
+ if (settings.designRevision !== revision) throw new ApiError(409, 'Design changed. Pull the latest revision before saving.');
147
+ const section = findSection(stagedPages(settings), pageId, sectionId);
148
+ const def = (await stagedTheme(settings)).sections[section.type];
149
+ if (!def) throw new ApiError(409, `The “${section.type}” section isn’t available in this theme.`);
150
+ if (section.locked && who.user.role !== 'owner') throw new ApiError(403, `Only an owner can edit the locked “${def.name}” section.`);
151
+ const units: Unit[] = translationUnits(def, section.data).map((u) => ({ path: u.path, label: u.label, kind: u.kind as Unit['kind'], source: u.source, hash: u.hash }));
152
+ const cur = section.translations?.[locale] ?? { data: {}, meta: {} };
153
+ const next = { data: { ...cur.data } as Record<string, unknown>, meta: { ...cur.meta } as Meta };
154
+ const changed = await applyMode(units, next.data, next.meta, mode, locale, repo, locals);
155
+ let newRevision = revision;
156
+ if (changed) {
157
+ for (const [k, v] of Object.entries(next.data)) if (v === '') { delete next.data[k]; delete next.meta[k]; }
158
+ const result = await stageSection(repo, who, revision, pageId, sectionId,
159
+ (s) => ({ ...s, translations: { ...(s.translations ?? {}), [locale]: next as NonNullable<typeof s.translations>[string] } }), origin);
160
+ newRevision = result.revision;
161
+ }
162
+ return { changed, ...(await getSectionTranslation(repo, pageId, sectionId, locale)), revision: newRevision };
163
+ }
@@ -1,3 +1,4 @@
1
+ import type { Role } from '../schema';
1
2
  import { randomBytes } from 'node:crypto';
2
3
  import type { ContentRepository } from '../repo/types';
3
4
  import { hashPassword, verifyPassword, checkPassword, OWNER_ENV } from '../auth';
@@ -27,7 +28,7 @@ export async function reauthenticate(repo:ContentRepository, userId:string, pass
27
28
  const user=await repo.getUser(userId); if(!user)return false;
28
29
  const auth=await repo.getUserAuth(user.email);return !!auth && verifyPassword(password,auth.passHash);
29
30
  }
30
- export async function issueAccountToken(repo:ContentRepository, data:{purpose:'invite'|'reset';email:string;name?:string;role?:'owner'|'editor';expiresDays?:number}, locals?:unknown) {
31
+ export async function issueAccountToken(repo:ContentRepository, data:{purpose:'invite'|'reset';email:string;name?:string;role?:Role;expiresDays?:number}, locals?:unknown) {
31
32
  const email=data.email.trim().toLowerCase();
32
33
  if(!validAddress(email))throw new Error('Enter a valid email address.');
33
34
  const auth=await repo.getUserAuth(email);
@@ -1,3 +1,4 @@
1
+ import type { Role } from '../schema';
1
2
  export type Provider = 'resend' | 'mailgun' | 'ses' | 'cloudflare';
2
3
  export type ProviderChoice = Provider | 'auto' | 'none';
3
4
  export interface Attachment { filename: string; type: string; content: string }
@@ -20,7 +21,7 @@ export interface EmailJob {
20
21
  }
21
22
  export interface NewEmailJob { id?: string; dedupe: string; kind: string; recipient: string; payload: string; configVersion: number; nextAt?: number; expiresAt?: number }
22
23
  export type SendResult = { state: 'accepted'; providerId: string } | { state: 'retrying' | 'failed' | 'unknown'; error: string; retryAfter?: number };
23
- export interface Invitation { id: string; email: string; name: string; role: 'owner' | 'editor'; state: string; expiresAt: number; createdAt: number }
24
+ export interface Invitation { id: string; email: string; name: string; role: Role; state: string; expiresAt: number; createdAt: number }
24
25
  export interface PublicEmailSettings {
25
26
  version: number; config: EmailConfig; provider: Provider | 'none'; configured: boolean; error: string;
26
27
  encryptionAvailable: boolean; environmentLocked: boolean; credentialFields: string[];
@@ -0,0 +1,34 @@
1
+ import type { Page, Settings } from './schema';
2
+ import { resolveDesign } from './theme';
3
+
4
+ // Which homepage do visitors actually get? Mirrors pages/index.astro:
5
+ // 1. the home page has a section layout in 'sections' mode → sections
6
+ // 2. else the active theme ships templates/home.astro and neither the
7
+ // Design screen nor the page forces 'content' → coded template
8
+ // 3. else → this page's own content
9
+ // The page editor uses it to point people at the right place, instead of
10
+ // letting them edit text that isn't what the homepage shows.
11
+
12
+ export type HomeSource =
13
+ | { kind: 'content' }
14
+ | { kind: 'sections'; editUrl: string; showsPageContent: boolean; sections: number }
15
+ | { kind: 'template'; editUrl: string; theme: string; file: string };
16
+
17
+ export function homeSource(settings: Settings, page: Pick<Page, 'id' | 'isHome'>): HomeSource {
18
+ if (!page.isHome) return { kind: 'content' };
19
+ const layout = settings.sectionPages?.[page.id];
20
+ if (layout?.mode === 'sections') {
21
+ const visible = layout.sections.filter((s) => !s.hidden);
22
+ return {
23
+ kind: 'sections',
24
+ editUrl: `/admin/design?page=${encodeURIComponent(page.id)}#pages`,
25
+ showsPageContent: visible.some((s) => s.type === 'page-content'),
26
+ sections: visible.length,
27
+ };
28
+ }
29
+ const design = resolveDesign(settings);
30
+ if (design.HomeTemplate && design.homeMode !== 'content' && layout?.mode !== 'content') {
31
+ return { kind: 'template', editUrl: '/admin/design', theme: design.theme.name, file: `src/themes/${design.theme.slug}/templates/home.astro` };
32
+ }
33
+ return { kind: 'content' };
34
+ }
package/src/lib/i18n.ts CHANGED
@@ -38,3 +38,37 @@ export function normalizePageLocale(locale: string): string {
38
38
  if (!locale || locale === I18N.defaultLocale) return '';
39
39
  return I18N.locales.includes(locale) ? locale : '';
40
40
  }
41
+
42
+ const NAMES: Record<string, string> = { en: 'English', es: 'Spanish', fr: 'French', de: 'German', pt: 'Portuguese', it: 'Italian', nl: 'Dutch', ko: 'Korean', zh: 'Chinese', ja: 'Japanese', tl: 'Tagalog', vi: 'Vietnamese', ar: 'Arabic', ru: 'Russian', uk: 'Ukrainian', pl: 'Polish', ht: 'Haitian Creole' };
43
+ const NATIVE: Record<string, string> = { en: 'English', es: 'Español', fr: 'Français', de: 'Deutsch', pt: 'Português', it: 'Italiano', nl: 'Nederlands', ko: '한국어', zh: '中文', ja: '日本語', tl: 'Tagalog', vi: 'Tiếng Việt', ar: 'العربية', ru: 'Русский', uk: 'Українська', pl: 'Polski', ht: 'Kreyòl' };
44
+ /** "es" → "Spanish" (for AI prompts); native: "es" → "Español" (for people) */
45
+ export function localeName(code: string, native = false): string {
46
+ const base = code.split('-')[0];
47
+ return (native ? NATIVE : NAMES)[base] ?? code;
48
+ }
49
+
50
+ /** the extra (non-default) languages */
51
+ export const extraLocales = (): string[] => (I18N.enabled ? I18N.locales.filter((l) => l !== I18N.defaultLocale) : []);
52
+
53
+ /** a request's language: set by the middleware for /<locale>/… rewrites, else from the path */
54
+ export function requestLocale(url: URL, locals?: unknown): string {
55
+ const fromLocals = (locals as { acmsLocale?: string } | undefined)?.acmsLocale;
56
+ return fromLocals || localeFromPath(url.pathname);
57
+ }
58
+
59
+ /** "/events/x" in a language: "/es/events/x"; the default language has no prefix */
60
+ export function localePath(path: string, locale: string): string {
61
+ const bare = stripLocale(path);
62
+ if (!locale || locale === I18N.defaultLocale || !I18N.enabled) return bare;
63
+ return bare === '/' ? `/${locale}` : `/${locale}${bare}`;
64
+ }
65
+
66
+ /** drop a leading non-default locale prefix: "/es/events/x" → "/events/x", "/es" → "/" */
67
+ export function stripLocale(path: string): string {
68
+ for (const l of I18N.locales) {
69
+ if (l === I18N.defaultLocale) continue;
70
+ if (path === `/${l}`) return '/';
71
+ if (path.startsWith(`/${l}/`)) return path.slice(l.length + 1);
72
+ }
73
+ return path || '/';
74
+ }
@@ -2,7 +2,7 @@ import { z } from 'zod';
2
2
  import { Page, Post, normalizeRedirectPath, slugify, type User } from '../schema';
3
3
  import { CONTENT, collectionUrl } from '../content';
4
4
  import type { ContentRepository, ImportRecord } from '../repo/types';
5
- import { ASSET_SCHEME, ImportAuthor, ImportIdentity, ImportItem, ImportNav, ImportRedirect, ImportTag, ImportWarning, type ImportBundle } from './bundle';
5
+ import { ASSET_SCHEME, ImportAuthor, ImportIdentity, ImportItem, ImportLayout, ImportNav, ImportRedirect, ImportTag, ImportWarning, type ImportBundle } from './bundle';
6
6
 
7
7
  // Server half of the import pipeline. The browser parses the source file,
8
8
  // re-hosts assets and sends the bundle in steps:
@@ -296,6 +296,7 @@ export const FinishInput = z.object({
296
296
  // sourceId → old path, for the items in this bundle
297
297
  legacy: z.array(z.object({ sourceId: z.string().max(200), type: z.enum(['post', 'page']), path: z.string().max(1000) })).max(50_000).default([]),
298
298
  redirects: z.array(ImportRedirect).max(10_000).default([]),
299
+ layouts: z.array(ImportLayout).max(1000).default([]),
299
300
  warnings: z.array(ImportWarning).max(10_000).default([]),
300
301
  failedAssets: z.array(z.object({ url: z.string().max(2000), error: z.string().max(500) })).max(50_000).default([]),
301
302
  options: ImportOptions,
@@ -347,6 +348,34 @@ export async function finishImport(repo: ContentRepository, input: z.infer<typeo
347
348
  }
348
349
  }
349
350
 
351
+ // section layouts: staged in the design draft (never live) for pages this
352
+ // run created or matched; a page that already has a layout keeps it.
353
+ // Links that pointed at the old site's pages/posts follow them here.
354
+ let layouts = 0;
355
+ if (input.layouts?.length) {
356
+ const byItem = new Map(all.filter((r) => r.kind === 'post' || r.kind === 'page').map((r) => [`${r.kind}:${r.sourceId}`, r.acmsId]));
357
+ const current = await repo.getSettings();
358
+ const staged = { ...((current.draftDesign?.sectionPages ?? current.sectionPages) as typeof current.sectionPages) };
359
+ const remap = (v: unknown): unknown => {
360
+ if (Array.isArray(v)) return v.map(remap);
361
+ if (!v || typeof v !== 'object') return v;
362
+ const o = Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([k, x]) => [k, remap(x)]));
363
+ if (typeof o.ref === 'string') {
364
+ const [kind, id] = o.ref.split(':');
365
+ const to = (kind === 'page' || kind === 'post') ? byItem.get(`${kind}:${id}`) : undefined;
366
+ if (to) o.ref = `${kind}:${to}`; else delete o.ref;
367
+ }
368
+ return o;
369
+ };
370
+ for (const l of input.layouts) {
371
+ const id = byItem.get(`page:${l.sourceId}`);
372
+ if (!id || staged[id]) continue;
373
+ staged[id] = { ...l.layout, sections: l.layout.sections.map((s) => ({ ...s, data: remap(s.data) as typeof s.data })) };
374
+ layouts++;
375
+ }
376
+ if (layouts) await repo.setDesignDraft({ ...current.draftDesign, sectionPages: staged }, current.designRevision, 'import');
377
+ }
378
+
350
379
  const settings: Record<string, string> = {};
351
380
  if (input.options.identity) {
352
381
  for (const [k, v] of Object.entries(input.identity)) {
@@ -367,7 +396,7 @@ export async function finishImport(repo: ContentRepository, input: z.infer<typeo
367
396
  counts: {
368
397
  posts: { created: count('post', 'created'), updated: count('post', 'updated') },
369
398
  pages: { created: count('page', 'created'), updated: count('page', 'updated') },
370
- users: count('user'), tags: count('tag'), media: count('media'), redirects: created.redirects, nav: created.nav,
399
+ users: count('user'), tags: count('tag'), media: count('media'), redirects: created.redirects, nav: created.nav, layouts,
371
400
  },
372
401
  settings: Object.keys(settings),
373
402
  warnings: input.warnings,
@@ -1,3 +1,4 @@
1
+ import { SectionLayout } from '../../theme-sdk/contract.mjs';
1
2
  import { z } from 'zod';
2
3
  import { Media } from '../schema';
3
4
 
@@ -114,6 +115,7 @@ export const ImportWarning = z.object({
114
115
  });
115
116
  export type ImportWarning = z.infer<typeof ImportWarning>;
116
117
 
118
+ export const ImportLayout = z.object({ sourceId: z.string().max(200), layout: SectionLayout });
117
119
  export const ImportBundle = z.object({
118
120
  format: z.literal(BUNDLE_FORMAT),
119
121
  version: z.literal(BUNDLE_VERSION),
@@ -133,6 +135,8 @@ export const ImportBundle = z.object({
133
135
  nav: z.array(ImportNav).max(500).default([]),
134
136
  // the source site's own redirect rules (acms exports)
135
137
  redirects: z.array(ImportRedirect).max(10_000).default([]),
138
+ // page section layouts (acms exports), keyed by the page's sourceId
139
+ layouts: z.array(ImportLayout).max(1000).default([]),
136
140
  warnings: z.array(ImportWarning).max(10_000).default([]),
137
141
  });
138
142
  export type ImportBundle = z.infer<typeof ImportBundle>;
@@ -49,6 +49,7 @@ export async function exportBundle(repo: ContentRepository, now = Date.now()): P
49
49
  media,
50
50
  nav: nav.map((n) => ({ label: n.label, url: n.url, area: n.area })),
51
51
  redirects: redirects.map((r) => ({ from: r.fromPath, to: r.toPath })),
52
+ layouts: pages.filter((p) => settings.sectionPages?.[p.id]).map((p) => ({ sourceId: p.id, layout: settings.sectionPages[p.id] })),
52
53
  warnings: [],
53
54
  };
54
55
  }
@@ -242,6 +242,7 @@ export function ghostToBundle(json: unknown, options: GhostOptions): ImportBundl
242
242
  media: [],
243
243
  nav,
244
244
  redirects: [],
245
+ layouts: [],
245
246
  warnings,
246
247
  };
247
248
  }
@@ -0,0 +1,83 @@
1
+ import type { ContentRepository } from '../repo/types';
2
+ import type { Media } from '../schema';
3
+ import { ApiError } from '../api';
4
+ import { getMediaStore } from './store';
5
+
6
+ // Multipart upload shared by the admin (/api/media/upload) and editor apps
7
+ // (/api/v1/admin/media). For images the client has already done the heavy
8
+ // lifting (EXIF rotation, downscale, WebP re-encode, variant generation);
9
+ // this stores the optimized file + variants and records the media row.
10
+ // SVG/GIF arrive untouched. Documents (for the file/download card) are
11
+ // stored as-is. Video/audio is deliberately rejected — embeds cover video
12
+ // until a streaming upload + Range-serving pipeline exists.
13
+ //
14
+ // Fields: file (primary), width, height, alt?, variant_<w> (File, webp)…
15
+ export const DOCUMENT_TYPES = new Set([
16
+ 'application/pdf',
17
+ 'application/zip',
18
+ 'application/x-zip-compressed',
19
+ 'application/json',
20
+ 'text/plain',
21
+ 'text/csv',
22
+ 'text/markdown',
23
+ 'application/msword',
24
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
25
+ 'application/vnd.ms-excel',
26
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
27
+ 'application/vnd.ms-powerpoint',
28
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
29
+ 'application/epub+zip',
30
+ ]);
31
+ export const MAX_IMAGE = 30 * 1024 * 1024;
32
+ export const MAX_DOCUMENT = 25 * 1024 * 1024;
33
+ /** a whole multipart body: the image plus its resized variants */
34
+ export const MAX_UPLOAD_BODY = MAX_IMAGE * 2;
35
+
36
+ export async function storeUpload(form: FormData, locals: unknown, repo: ContentRepository): Promise<Media> {
37
+ const file = form.get('file');
38
+ if (!(file instanceof File) || file.size === 0) throw new ApiError(400, 'Missing file');
39
+ const isImage = file.type.startsWith('image/');
40
+ const isDocument = DOCUMENT_TYPES.has(file.type);
41
+ if (!isImage && !isDocument) {
42
+ throw new ApiError(415, file.type.startsWith('video/') || file.type.startsWith('audio/')
43
+ ? 'Video and audio uploads aren’t supported yet — use an embed card instead.'
44
+ : 'Unsupported file type.');
45
+ }
46
+ const cap = isImage ? MAX_IMAGE : MAX_DOCUMENT;
47
+ if (file.size > cap) throw new ApiError(413, `File too large (max ${Math.round(cap / 1024 / 1024)} MB)`);
48
+
49
+ const variantFields = [...form.entries()].filter(([field, value]) => /^variant_\d+$/.test(field) && value instanceof File && value.size > 0);
50
+ if (variantFields.length > 8) throw new ApiError(400, 'Too many image sizes (max 8).');
51
+ for (const [field, value] of variantFields) {
52
+ const w = Number(field.slice(8));
53
+ if (!Number.isInteger(w) || w < 16 || w > 8192) throw new ApiError(400, 'Image sizes must be 16–8192 pixels wide.');
54
+ if ((value as File).size > MAX_IMAGE) throw new ApiError(413, `File too large (max ${Math.round(MAX_IMAGE / 1024 / 1024)} MB)`);
55
+ }
56
+
57
+ const id = crypto.randomUUID();
58
+ const ext = (file.name.split('.').pop() || 'bin').toLowerCase().replace(/[^a-z0-9]/g, '') || 'bin';
59
+ const key = `${id}/original.${ext}`;
60
+ const store = getMediaStore(locals);
61
+ await store.put(key, await file.arrayBuffer(), file.type);
62
+
63
+ const variants: { w: number; key: string }[] = [];
64
+ for (const [field, value] of variantFields) {
65
+ const w = Number(field.slice(8));
66
+ const vkey = `${id}/w${w}.webp`;
67
+ await store.put(vkey, await (value as File).arrayBuffer(), 'image/webp');
68
+ variants.push({ w, key: vkey });
69
+ }
70
+ variants.sort((a, b) => a.w - b.w);
71
+
72
+ return repo.createMedia({
73
+ id,
74
+ key,
75
+ filename: file.name,
76
+ mime: file.type,
77
+ width: Number(form.get('width') ?? 0),
78
+ height: Number(form.get('height') ?? 0),
79
+ size: file.size,
80
+ alt: String(form.get('alt') ?? ''),
81
+ variants,
82
+ });
83
+ }
@@ -0,0 +1,25 @@
1
+ import type { APIRoute } from 'astro';
2
+ import { userConfig } from 'virtual:acms-config';
3
+ import { ApiError } from '../api';
4
+ import { failure, publicResponse } from './http';
5
+
6
+ export function association(platform: 'ios' | 'android', mobile = userConfig.mobile) {
7
+ if (platform === 'ios' && mobile?.ios) {
8
+ const { teamId, bundleId, paths } = mobile.ios;
9
+ return { applinks: { apps: [], details: [{ appID: `${teamId}.${bundleId}`, paths }] } };
10
+ }
11
+ if (platform === 'android' && mobile?.android) {
12
+ return [{ relation: ['delegate_permission/common.handle_all_urls'], target: {
13
+ namespace: 'android_app', package_name: mobile.android.package, sha256_cert_fingerprints: mobile.android.sha256CertFingerprints,
14
+ } }];
15
+ }
16
+ throw new ApiError(404, 'App association not configured.');
17
+ }
18
+ export function associationRoute(platform: 'ios' | 'android'): APIRoute {
19
+ return async ({ request }) => {
20
+ let res: Response;
21
+ try { res = await publicResponse(request, association(platform), true); }
22
+ catch (error) { res = failure(error); }
23
+ return request.method === 'HEAD' ? new Response(null, { status: res.status, headers: res.headers }) : res;
24
+ };
25
+ }