@acms-subir/core 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +1 -1
  4. package/cli/main.mjs +82 -8
  5. package/cli/mobile-doctor.mjs +430 -0
  6. package/cli/mobile.mjs +315 -0
  7. package/cli/native-sections.mjs +168 -0
  8. package/cli/sections.mjs +186 -0
  9. package/cli/themes.mjs +52 -8
  10. package/docs/mobile.md +527 -0
  11. package/docs/sections.md +203 -0
  12. package/docs/theme-authoring.md +1 -1
  13. package/integration.mjs +60 -1
  14. package/mobile-template/client/AGENTS.md +97 -0
  15. package/mobile-template/client/README.md +65 -0
  16. package/mobile-template/client/app/_layout.tsx +56 -0
  17. package/mobile-template/client/app/index.tsx +21 -0
  18. package/mobile-template/client/app.json +21 -0
  19. package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
  20. package/mobile-template/client/eas.json +21 -0
  21. package/mobile-template/client/env.example +3 -0
  22. package/mobile-template/client/gitignore +42 -0
  23. package/mobile-template/client/package.json +38 -0
  24. package/mobile-template/client/sections/index.ts +11 -0
  25. package/mobile-template/client/tsconfig.json +7 -0
  26. package/mobile-template/editor/AGENTS.md +66 -0
  27. package/mobile-template/editor/README.md +73 -0
  28. package/mobile-template/editor/app/_layout.tsx +76 -0
  29. package/mobile-template/editor/app/auth.tsx +13 -0
  30. package/mobile-template/editor/app/index.tsx +55 -0
  31. package/mobile-template/editor/app.json +21 -0
  32. package/mobile-template/editor/components/Screen.tsx +51 -0
  33. package/mobile-template/editor/components/SignIn.tsx +36 -0
  34. package/mobile-template/editor/components/TextTabs.tsx +27 -0
  35. package/mobile-template/editor/eas.json +21 -0
  36. package/mobile-template/editor/env.example +5 -0
  37. package/mobile-template/editor/gitignore +42 -0
  38. package/mobile-template/editor/lib/appearance.tsx +12 -0
  39. package/mobile-template/editor/lib/config.ts +17 -0
  40. package/mobile-template/editor/lib/format.ts +24 -0
  41. package/mobile-template/editor/lib/tools.ts +16 -0
  42. package/mobile-template/editor/package.json +40 -0
  43. package/mobile-template/editor/tools/README.md +2 -0
  44. package/mobile-template/editor/tsconfig.json +7 -0
  45. package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
  46. package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
  47. package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
  48. package/mobile-template/recipes/booking/recipe.json +17 -0
  49. package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
  50. package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
  51. package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
  52. package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
  53. package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
  54. package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
  55. package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
  56. package/mobile-template/recipes/editor-content/recipe.json +8 -0
  57. package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
  58. package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
  59. package/mobile-template/recipes/editor-media/recipe.json +8 -0
  60. package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
  61. package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
  62. package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
  63. package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
  64. package/mobile-template/recipes/editor-sections/recipe.json +8 -0
  65. package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
  66. package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
  67. package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
  68. package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
  69. package/mobile-template/recipes/editor-translations/recipe.json +8 -0
  70. package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
  71. package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
  72. package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
  73. package/mobile-template/recipes/editor-writer/recipe.json +8 -0
  74. package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
  75. package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
  76. package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
  77. package/mobile-template/recipes/events/recipe.json +15 -0
  78. package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
  79. package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
  80. package/mobile-template/recipes/news/recipe.json +13 -0
  81. package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
  82. package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
  83. package/mobile-template/recipes/push/files/lib/push.ts +15 -0
  84. package/mobile-template/recipes/push/recipe.json +20 -0
  85. package/package.json +4 -2
  86. package/section-library/README.md +16 -0
  87. package/section-library/contact/Contact.astro +49 -0
  88. package/section-library/contact/section.json +106 -0
  89. package/section-library/cta-band/CtaBand.astro +28 -0
  90. package/section-library/cta-band/section.json +51 -0
  91. package/section-library/feature-grid/FeatureGrid.astro +54 -0
  92. package/section-library/feature-grid/section.json +107 -0
  93. package/section-library/gallery/Gallery.astro +29 -0
  94. package/section-library/gallery/section.json +51 -0
  95. package/section-library/hero-banner/HeroBanner.astro +42 -0
  96. package/section-library/hero-banner/section.json +81 -0
  97. package/section-library/latest-posts/LatestPosts.astro +22 -0
  98. package/section-library/latest-posts/section.json +37 -0
  99. package/section-library/pricing/Pricing.astro +40 -0
  100. package/section-library/pricing/section.json +117 -0
  101. package/section-library/questions/Questions.astro +38 -0
  102. package/section-library/questions/section.json +69 -0
  103. package/section-library/service-times/ServiceTimes.astro +42 -0
  104. package/section-library/service-times/section.json +127 -0
  105. package/section-library/team/Team.astro +45 -0
  106. package/section-library/team/section.json +88 -0
  107. package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
  108. package/section-library/upcoming-events/section.json +51 -0
  109. package/src/actions/index.ts +19 -171
  110. package/src/components/AiSettings.tsx +45 -5
  111. package/src/components/DesignManager.tsx +112 -16
  112. package/src/components/MobileApp.tsx +392 -0
  113. package/src/components/ThemeSections.astro +40 -21
  114. package/src/components/ThemeSectionsEditor.tsx +757 -229
  115. package/src/components/editor/PostEditor.tsx +174 -10
  116. package/src/components/import/ImportExport.tsx +522 -0
  117. package/src/components/import/runner.ts +285 -0
  118. package/src/components/sections/SectionFields.tsx +560 -0
  119. package/src/components/sections/SectionPicker.tsx +120 -0
  120. package/src/components/sections/TranslationPanel.tsx +143 -0
  121. package/src/components/sections/checks.ts +64 -0
  122. package/src/layouts/Admin.astro +6 -1
  123. package/src/layouts/Site.astro +107 -19
  124. package/src/lib/agent-discovery.ts +11 -7
  125. package/src/lib/agent.ts +35 -2
  126. package/src/lib/ai-schema.ts +5 -0
  127. package/src/lib/ai.ts +5 -0
  128. package/src/lib/api.ts +12 -0
  129. package/src/lib/content-service.ts +198 -0
  130. package/src/lib/design-service.ts +153 -5
  131. package/src/lib/drafts.ts +3 -3
  132. package/src/lib/editor/auth.ts +264 -0
  133. package/src/lib/editor/bookings.ts +60 -0
  134. package/src/lib/editor/config.ts +25 -0
  135. package/src/lib/editor/documents.ts +227 -0
  136. package/src/lib/editor/http.ts +48 -0
  137. package/src/lib/editor/me.ts +30 -0
  138. package/src/lib/editor/migration.ts +57 -0
  139. package/src/lib/editor/next.ts +11 -0
  140. package/src/lib/editor/openapi.ts +124 -0
  141. package/src/lib/editor/schemas.ts +91 -0
  142. package/src/lib/editor/sections.ts +96 -0
  143. package/src/lib/editor/store.ts +184 -0
  144. package/src/lib/editor/token-route.ts +74 -0
  145. package/src/lib/editor/translations.ts +163 -0
  146. package/src/lib/email/accounts.ts +2 -1
  147. package/src/lib/email/types.ts +2 -1
  148. package/src/lib/home-source.ts +34 -0
  149. package/src/lib/i18n.ts +34 -0
  150. package/src/lib/import/apply.ts +459 -0
  151. package/src/lib/import/bundle.ts +171 -0
  152. package/src/lib/import/export.ts +55 -0
  153. package/src/lib/import/ghost.ts +248 -0
  154. package/src/lib/import/lexical.ts +290 -0
  155. package/src/lib/import/migration.ts +27 -0
  156. package/src/lib/import/zipread.ts +72 -0
  157. package/src/lib/media/upload.ts +83 -0
  158. package/src/lib/mobile/associations.ts +25 -0
  159. package/src/lib/mobile/calendar.ts +19 -11
  160. package/src/lib/mobile/config.mjs +59 -0
  161. package/src/lib/mobile/content.ts +18 -13
  162. package/src/lib/mobile/embed.ts +83 -0
  163. package/src/lib/mobile/http.ts +8 -6
  164. package/src/lib/mobile/locale.ts +18 -0
  165. package/src/lib/mobile/openapi.ts +111 -0
  166. package/src/lib/mobile/pages.ts +71 -0
  167. package/src/lib/mobile/route.ts +3 -3
  168. package/src/lib/mobile/schemas.ts +60 -0
  169. package/src/lib/mobile/site.ts +41 -0
  170. package/src/lib/push/api.ts +158 -0
  171. package/src/lib/push/config.ts +20 -0
  172. package/src/lib/push/expo.ts +76 -0
  173. package/src/lib/push/migration.ts +60 -0
  174. package/src/lib/push/service.ts +302 -0
  175. package/src/lib/push/store.ts +144 -0
  176. package/src/lib/push/types.ts +43 -0
  177. package/src/lib/repo/migrations.ts +13 -0
  178. package/src/lib/repo/sql.ts +156 -9
  179. package/src/lib/repo/types.ts +26 -3
  180. package/src/lib/roles.ts +5 -0
  181. package/src/lib/scheduler.ts +3 -0
  182. package/src/lib/schema.ts +27 -1
  183. package/src/lib/section-ai.ts +55 -0
  184. package/src/lib/section-targets.ts +62 -0
  185. package/src/lib/sections-agent.ts +146 -0
  186. package/src/lib/sections.ts +217 -0
  187. package/src/lib/theme.ts +16 -1
  188. package/src/lib/translate.ts +97 -0
  189. package/src/lib/translations.ts +39 -0
  190. package/src/middleware.ts +32 -0
  191. package/src/pages/.well-known/apple-app-site-association.ts +6 -0
  192. package/src/pages/.well-known/assetlinks.json.ts +6 -0
  193. package/src/pages/[...slug].astro +8 -3
  194. package/src/pages/admin/app-auth.astro +159 -0
  195. package/src/pages/admin/c/[collection]/[id].astro +9 -0
  196. package/src/pages/admin/calendar/events/[id].astro +9 -0
  197. package/src/pages/admin/import.astro +16 -0
  198. package/src/pages/admin/login.astro +6 -2
  199. package/src/pages/admin/logout.ts +5 -2
  200. package/src/pages/admin/mobile.astro +18 -0
  201. package/src/pages/admin/pages/[id].astro +62 -0
  202. package/src/pages/admin/section-preview.astro +61 -0
  203. package/src/pages/admin/settings.astro +11 -0
  204. package/src/pages/admin/users.astro +107 -11
  205. package/src/pages/api/agent/docs.ts +6 -2
  206. package/src/pages/api/agent/index.ts +1 -1
  207. package/src/pages/api/ai/settings.ts +7 -4
  208. package/src/pages/api/design/ai.ts +15 -0
  209. package/src/pages/api/design/index.ts +12 -42
  210. package/src/pages/api/design/translate.ts +15 -0
  211. package/src/pages/api/export.ts +41 -0
  212. package/src/pages/api/import/asset.ts +32 -0
  213. package/src/pages/api/import/index.ts +47 -0
  214. package/src/pages/api/import/media.ts +31 -0
  215. package/src/pages/api/live/thumb.ts +1 -1
  216. package/src/pages/api/mcp.ts +2 -2
  217. package/src/pages/api/media/upload.ts +10 -78
  218. package/src/pages/api/push/admin.ts +27 -0
  219. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
  220. package/src/pages/api/v1/admin/bookings/index.ts +6 -0
  221. package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
  222. package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
  223. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
  224. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
  225. package/src/pages/api/v1/admin/documents/index.ts +13 -0
  226. package/src/pages/api/v1/admin/me.ts +6 -0
  227. package/src/pages/api/v1/admin/media/index.ts +32 -0
  228. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
  229. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
  230. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
  231. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
  232. package/src/pages/api/v1/admin/sections/index.ts +6 -0
  233. package/src/pages/api/v1/auth/revoke.ts +5 -0
  234. package/src/pages/api/v1/auth/token.ts +5 -0
  235. package/src/pages/api/v1/devices/[token].ts +20 -0
  236. package/src/pages/api/v1/devices/index.ts +17 -0
  237. package/src/pages/api/v1/openapi.json.ts +7 -0
  238. package/src/pages/api/v1/pages/[...slug].ts +7 -0
  239. package/src/pages/api/v1/pages/home.ts +7 -0
  240. package/src/pages/api/v1/push/topics.ts +7 -0
  241. package/src/pages/api/v1/site.ts +7 -0
  242. package/src/pages/api/v1/theme.ts +7 -0
  243. package/src/pages/blog/[slug].astro +8 -2
  244. package/src/pages/blog/index.astro +11 -4
  245. package/src/pages/embed/section/[...path].astro +42 -0
  246. package/src/pages/events/[slug].astro +7 -2
  247. package/src/pages/index.astro +11 -5
  248. package/src/pages/sitemap.xml.ts +8 -0
  249. package/src/styles/admin.css +69 -0
  250. package/src/styles/editor.css +21 -0
  251. package/src/styles/mobile-app.css +151 -0
  252. package/src/styles/sections.css +7 -1
  253. package/src/styles/theme-editor.css +255 -0
  254. package/src/theme-sdk/contract.d.mts +2193 -89
  255. package/src/theme-sdk/contract.mjs +77 -20
  256. package/src/theme-sdk/fields.d.mts +88 -0
  257. package/src/theme-sdk/fields.mjs +326 -0
  258. package/src/theme-sdk/index.ts +10 -2
  259. package/src/theme-sdk/tokens.d.mts +11 -0
  260. package/src/virtual.d.ts +1 -0
@@ -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
+ }