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