@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
|
@@ -1,36 +1,88 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { ThemeTokens } from './tokens.mjs';
|
|
3
|
+
import { normalizeData, valueIssues, fieldVisible } from './fields.mjs';
|
|
4
|
+
export * from './fields.mjs';
|
|
3
5
|
export { ThemeTokens } from './tokens.mjs';
|
|
4
6
|
export const THEME_API_VERSION = 1;
|
|
5
7
|
export const Slug = z.string().regex(/^[a-z][a-z0-9-]{0,49}$/);
|
|
6
8
|
const text = z.string().max(20000);
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
const FieldKey = z.string().regex(/^[a-zA-Z][a-zA-Z0-9]*$/);
|
|
10
|
+
const Scalar = z.union([text, z.number().finite(), z.boolean()]);
|
|
11
|
+
// link values and source configs: small flat objects
|
|
12
|
+
const FlatObject = z.record(z.string().max(100), Scalar);
|
|
13
|
+
const ListItem = z.record(z.string().max(100), z.union([Scalar, FlatObject]));
|
|
14
|
+
export const FieldValue = z.union([Scalar, FlatObject, z.array(ListItem).max(100)]);
|
|
15
|
+
/** show a field only when a sibling field matches: { field, equals | notEquals | in }; no operator = sibling is filled in */
|
|
16
|
+
export const VisibleIf = z.object({ field: FieldKey, equals: Scalar.optional(), notEquals: Scalar.optional(), in: z.array(Scalar).max(30).optional() }).strict();
|
|
17
|
+
const fieldBase = { label: z.string().min(1).max(100), help: z.string().max(300).optional(), visibleIf: VisibleIf.optional() };
|
|
18
|
+
export const ScalarField = z.object({
|
|
19
|
+
...fieldBase, type: z.enum(['text', 'textarea', 'richtext', 'image', 'url', 'link', 'select', 'number', 'toggle', 'date', 'time']), // = SCALAR_TYPES
|
|
20
|
+
default: z.union([Scalar, FlatObject]).optional(), options: z.array(z.string().max(100)).max(30).optional(),
|
|
21
|
+
placeholder: z.string().max(200).optional(),
|
|
22
|
+
required: z.boolean().optional(), maxLength: z.number().int().min(1).max(20000).optional(),
|
|
23
|
+
// AI help instruction for this field ("friendly, under 12 words"); locked = owners only
|
|
24
|
+
aiHint: z.string().max(300).optional(), locked: z.boolean().optional(),
|
|
25
|
+
// words vs facts: text/rich text/link labels translate by default; set false for
|
|
26
|
+
// e.g. a phone number, or true on a select whose options are words
|
|
27
|
+
translatable: z.boolean().optional(),
|
|
10
28
|
}).strict();
|
|
29
|
+
/** repeater: rows of scalar sub-fields (one level — a list can't hold a list) */
|
|
30
|
+
export const ListField = z.object({
|
|
31
|
+
...fieldBase, type: z.literal('list'), fields: z.record(FieldKey, ScalarField).refine(f => Object.keys(f).length > 0, 'A list needs at least one sub-field'),
|
|
32
|
+
itemLabel: FieldKey.optional(), min: z.number().int().min(0).max(100).optional(), max: z.number().int().min(1).max(100).default(50),
|
|
33
|
+
default: z.array(ListItem).max(100).optional(), locked: z.boolean().optional(),
|
|
34
|
+
}).strict();
|
|
35
|
+
/** linked data: the owner picks what to show; items resolve fresh on every request */
|
|
36
|
+
export const SourceField = z.object({
|
|
37
|
+
...fieldBase, type: z.literal('source'), source: z.enum(['events', 'posts', 'bookings']) /* = SOURCE_KINDS */, default: FlatObject.optional(), locked: z.boolean().optional(),
|
|
38
|
+
}).strict();
|
|
39
|
+
export const Field = z.union([ScalarField, ListField, SourceField]);
|
|
11
40
|
export const SectionDefinition = z.object({
|
|
12
41
|
name: z.string().min(1).max(100), version: z.number().int().positive().default(1),
|
|
13
42
|
component: z.string().regex(/^sections\/[a-zA-Z0-9-]+\.astro$/).optional(),
|
|
14
43
|
variants: z.array(Slug).min(1).max(20).default(['default']),
|
|
15
|
-
fields: z.record(
|
|
44
|
+
fields: z.record(FieldKey, Field).default({}),
|
|
45
|
+
description: z.string().max(300).optional(),
|
|
46
|
+
/** picker group: "Intro", "Events & times", "People", "Info", "Calls to action"… */
|
|
47
|
+
category: z.string().max(40).optional(),
|
|
48
|
+
/** sample content: picker previews and empty fields in the admin preview (never saved) */
|
|
49
|
+
example: z.record(FieldKey, FieldValue).optional(),
|
|
50
|
+
/** at most this many per page */
|
|
51
|
+
limit: z.number().int().min(1).max(20).optional(),
|
|
52
|
+
/** ready-filled picker entries ("Service times: holiday") */
|
|
53
|
+
presets: z.array(z.object({ name: z.string().min(1).max(100), description: z.string().max(200).optional(), variant: Slug.optional(), data: z.record(z.string().max(100), FieldValue).default({}) }).strict()).max(12).optional(),
|
|
16
54
|
}).strict();
|
|
17
55
|
const f = (label, type = 'text', value = '') => ({ label, type, default: value });
|
|
18
56
|
const base = { heading: f('Heading'), text: f('Supporting text', 'textarea') };
|
|
57
|
+
const list = (label, fields, itemLabel) => ({ label, type: 'list', fields, itemLabel, max: 50 });
|
|
19
58
|
/** @type {Record<string, import("zod").infer<typeof SectionDefinition>>} */
|
|
20
59
|
export const BUILTIN_SECTIONS = {
|
|
21
|
-
live: { name: 'Live video', version: 1, variants: ['card','player'], fields: {} },
|
|
22
|
-
'page-content': { name: 'Existing page content', version: 1, variants: ['default'], fields: {} },
|
|
23
|
-
hero: { name: 'Hero', version: 1, variants: ['split', 'centered'], fields: { ...base, image: f('Image', 'image'), alt: f('Image description'), label: f('Button label'), href: f('Button link', 'url') } },
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
60
|
+
live: { category: 'Basic', name: 'Live video', version: 1, variants: ['card','player'], fields: {} },
|
|
61
|
+
'page-content': { category: 'Basic', name: 'Existing page content', version: 1, variants: ['default'], fields: {} },
|
|
62
|
+
hero: { category: 'Basic', example: { heading: 'A place for everyone', text: 'Say what you do in one or two sentences.', label: 'Get started', href: '#' }, name: 'Hero', version: 1, variants: ['split', 'centered'], fields: { ...base, image: f('Image', 'image'), alt: f('Image description'), label: f('Button label'), href: f('Button link', 'url') } },
|
|
63
|
+
// items were "title | text" textarea lines before core 0.4; stored strings
|
|
64
|
+
// upgrade to rows through normalizeValue, so the version stays 1
|
|
65
|
+
features: { category: 'Basic', example: { heading: 'What we offer', items: [{ title: 'First thing', text: 'A short line about it.' }, { title: 'Second thing', text: 'A short line about it.' }, { title: 'Third thing', text: 'A short line about it.' }] }, name: 'Features', version: 1, variants: ['grid', 'list'], fields: { ...base, items: list('Features', { title: f('Title'), text: f('Description', 'textarea') }, 'title') } },
|
|
66
|
+
testimonials: { category: 'Basic', example: { heading: 'What people say', items: [{ title: 'Sam', text: 'A warm, specific quote.' }, { title: 'Alex', text: 'Another kind word.' }] }, name: 'Testimonials', version: 1, variants: ['grid', 'list'], fields: { ...base, items: list('Quotes', { title: f('Name'), text: f('Quote', 'textarea') }, 'title') } },
|
|
67
|
+
faq: { category: 'Basic', example: { heading: 'Questions', items: [{ title: 'A common question?', text: 'A clear answer.' }, { title: 'Another question?', text: 'Another answer.' }] }, name: 'FAQ', version: 1, variants: ['default'], fields: { ...base, items: list('Questions', { title: f('Question'), text: f('Answer', 'textarea') }, 'title') } },
|
|
68
|
+
posts: { category: 'Basic', name: 'Posts', version: 1, variants: ['grid', 'list'], fields: { heading: f('Heading', 'text', 'Latest posts'), collection: f('Collection', 'text', 'posts'), limit: f('Number of posts', 'number', 3) } },
|
|
69
|
+
cta: { category: 'Basic', example: { heading: 'Ready when you are', text: 'One clear next step.', label: 'Get in touch', href: '#' }, name: 'Call to action', version: 1, variants: ['default', 'panel'], fields: { ...base, label: f('Button label'), href: f('Button link', 'url') } },
|
|
29
70
|
};
|
|
71
|
+
/** one language's words for a section: values by path + review state per path */
|
|
72
|
+
export const SectionTranslation = z.object({
|
|
73
|
+
data: z.record(z.string().max(200), z.union([text, z.number().finite(), z.boolean()])).default({}),
|
|
74
|
+
meta: z.record(z.string().max(200), z.object({ state: z.enum(['ai', 'reviewed']), src: z.string().max(20) }).strict()).default({}),
|
|
75
|
+
}).strict();
|
|
30
76
|
export const SectionInstance = z.object({
|
|
31
77
|
id: z.string().min(1).max(100), type: Slug, version: z.number().int().positive().default(1),
|
|
32
78
|
variant: Slug.default('default'), hidden: z.boolean().default(false),
|
|
33
|
-
|
|
79
|
+
// shown only inside this window (site timezone): YYYY-MM-DD or YYYY-MM-DDTHH:MM
|
|
80
|
+
showFrom: z.string().regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/).optional(), showUntil: z.string().regex(/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/).optional(),
|
|
81
|
+
// owner-locked: editors can't change or remove it
|
|
82
|
+
locked: z.boolean().optional(),
|
|
83
|
+
// per extra language: translated words only (facts stay in `data`)
|
|
84
|
+
translations: z.record(z.string().regex(/^[a-z]{2,3}(-[a-z0-9]{2,8})?$/), SectionTranslation).optional(),
|
|
85
|
+
data: z.record(z.string().max(100), FieldValue).default({}),
|
|
34
86
|
}).strict();
|
|
35
87
|
export const SectionLayout = z.object({ mode: z.enum(['content', 'sections']).default('sections'), sections: z.array(SectionInstance).max(50) }).strict()
|
|
36
88
|
.refine(v => new Set(v.sections.map(s => s.id)).size === v.sections.length, 'Section IDs must be unique');
|
|
@@ -48,6 +100,11 @@ export const PostOptions = z.object({
|
|
|
48
100
|
toc: z.boolean().default(true),
|
|
49
101
|
});
|
|
50
102
|
|
|
103
|
+
/** starting layouts a theme offers ("Start from Church home"); the owner's copy is theirs to edit */
|
|
104
|
+
export const PagePreset = z.object({
|
|
105
|
+
name: z.string().min(1).max(100), description: z.string().max(300).optional(),
|
|
106
|
+
sections: z.array(z.object({ type: Slug, variant: Slug.optional(), hidden: z.boolean().optional(), data: z.record(z.string().max(100), FieldValue).default({}) }).strict()).min(1).max(50),
|
|
107
|
+
}).strict();
|
|
51
108
|
export const ThemeManifest = z.object({
|
|
52
109
|
name: z.string().min(1).max(120), description: z.string().max(2000).default(''), slug: Slug.optional(),
|
|
53
110
|
version: z.string().regex(/^\d+\.\d+\.\d+(?:-[a-zA-Z0-9.-]+)?$/).default('1.0.0'),
|
|
@@ -57,6 +114,7 @@ export const ThemeManifest = z.object({
|
|
|
57
114
|
templates: z.array(z.string().regex(/^(home|event|post-[a-z0-9-]+|page-[a-z0-9-]+)\.astro$/)).default([]),
|
|
58
115
|
sections: z.record(Slug, SectionDefinition).default({}),
|
|
59
116
|
assets: z.array(z.string().regex(/^assets\/(?!.*\.\.)[a-zA-Z0-9_./-]+$/)).default([]),
|
|
117
|
+
presets: z.record(Slug, PagePreset).default({}),
|
|
60
118
|
}).strict();
|
|
61
119
|
/** @param {Partial<import("zod").infer<typeof ThemeManifest>>} [manifest] */
|
|
62
120
|
export function sectionDefinitions(manifest = {}) {
|
|
@@ -65,17 +123,16 @@ export function sectionDefinitions(manifest = {}) {
|
|
|
65
123
|
/** @param {import("zod").infer<typeof SectionLayout>} layout
|
|
66
124
|
* @param {Record<string, import("zod").infer<typeof SectionDefinition>>} definitions */
|
|
67
125
|
export function sectionIssues(layout, definitions) {
|
|
126
|
+
const counts = {};
|
|
68
127
|
return layout.sections.flatMap(s => {
|
|
69
128
|
const d = definitions[s.type];
|
|
70
129
|
if (!d) return [{ id: s.id, message: `Unavailable section: ${s.type}. Its data is preserved.` }];
|
|
71
130
|
if (s.version !== d.version) return [{ id: s.id, message: `Section ${s.type} needs a migration from v${s.version} to v${d.version}.` }];
|
|
72
131
|
if (!d.variants.includes(s.variant)) return [{ id: s.id, message: `Unsupported variant: ${s.variant}` }];
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
return valid && safeLink ? [] : [{ id: s.id, message: `Invalid ${field.label}` }];
|
|
79
|
-
});
|
|
132
|
+
counts[s.type] = (counts[s.type] ?? 0) + 1;
|
|
133
|
+
const over = d.limit && counts[s.type] > d.limit ? [{ id: s.id, message: `${d.name} can appear at most ${d.limit} ${d.limit === 1 ? 'time' : 'times'} per page.` }] : [];
|
|
134
|
+
const data = normalizeData(d, s.data);
|
|
135
|
+
return [...over, ...Object.entries(d.fields).flatMap(([key, field]) =>
|
|
136
|
+
fieldVisible(field, data) ? valueIssues(field, data[key]).map(message => ({ id: s.id, message })) : [])];
|
|
80
137
|
});
|
|
81
138
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** empty value for a field when neither data nor a default exists */
|
|
2
|
+
export function emptyValue(field: any): false | 0 | "" | never[] | {
|
|
3
|
+
label: string;
|
|
4
|
+
href: string;
|
|
5
|
+
limit?: undefined;
|
|
6
|
+
} | {
|
|
7
|
+
limit: number;
|
|
8
|
+
label?: undefined;
|
|
9
|
+
href?: undefined;
|
|
10
|
+
};
|
|
11
|
+
/** the field's declared default (deep-copied), or its empty value */
|
|
12
|
+
export function defaultValue(field: any): any;
|
|
13
|
+
/**
|
|
14
|
+
* Coerce a stored value into the field's current shape. Tolerant by design:
|
|
15
|
+
* a value saved by an older schema (e.g. the v1 built-ins' "title | text"
|
|
16
|
+
* textarea lines, now a list) upgrades instead of disappearing.
|
|
17
|
+
*/
|
|
18
|
+
export function normalizeValue(field: any, value: any): any;
|
|
19
|
+
/** one list row: every declared sub-field present; unknown keys kept (orphans) */
|
|
20
|
+
export function normalizeItem(listField: any, row: any): any;
|
|
21
|
+
/** full data object for a section: declared fields normalized, unknown keys kept */
|
|
22
|
+
export function normalizeData(definition: any, data?: {}): {};
|
|
23
|
+
/** data for a brand-new section instance */
|
|
24
|
+
export function initialData(definition: any): {};
|
|
25
|
+
/** "30% 20%" (x y), clamped; anything else → centered */
|
|
26
|
+
export function safeFocus(value: any): string;
|
|
27
|
+
/** saved keys the current schema no longer declares (kept, never deleted silently) */
|
|
28
|
+
export function orphanKeys(definition: any, data?: {}): string[];
|
|
29
|
+
/** short human label for a list row, for collapsed rows in the editor */
|
|
30
|
+
export function itemTitle(listField: any, row: any, index: any): string;
|
|
31
|
+
/** a field's visibleIf against its siblings (section data, or a list row) */
|
|
32
|
+
export function fieldVisible(field: any, siblings?: {}): any;
|
|
33
|
+
/** "2026-12-01" / "2026-12-01T09:30" window check against a wall-clock "now" in the site timezone */
|
|
34
|
+
export function scheduledVisible(section: any, nowWall: any): boolean;
|
|
35
|
+
/** "19:00" → "7:00 PM" in the site's locale (times of day carry no zone) */
|
|
36
|
+
export function formatTime(value: any, locale?: string): string;
|
|
37
|
+
/** "2026-10-04" → "Oct 4, 2026" (date-only values are calendar dates, not instants) */
|
|
38
|
+
export function formatDay(value: any, locale?: string, opts?: {
|
|
39
|
+
month: string;
|
|
40
|
+
day: string;
|
|
41
|
+
year: string;
|
|
42
|
+
}): any;
|
|
43
|
+
/**
|
|
44
|
+
* Issues for one field value (after normalization). `path` prefixes messages
|
|
45
|
+
* so list rows read "Services › Youth Night: Invalid Time".
|
|
46
|
+
*/
|
|
47
|
+
export function valueIssues(field: any, value: any, path?: any): string[];
|
|
48
|
+
/** words, not facts: text-like fields and link labels; selects only when marked */
|
|
49
|
+
export function isTranslatable(field: any): boolean;
|
|
50
|
+
/** short, stable fingerprint of a source text (detects "the English changed") */
|
|
51
|
+
export function textHash(value: any): string;
|
|
52
|
+
/**
|
|
53
|
+
* Every translatable piece of a section's data, in order:
|
|
54
|
+
* { path, key, rowId?, sub?, field, label, kind, source, hash }
|
|
55
|
+
*/
|
|
56
|
+
export function translationUnits(definition: any, data?: {}): {
|
|
57
|
+
path: string;
|
|
58
|
+
key: string;
|
|
59
|
+
field: any;
|
|
60
|
+
label: any;
|
|
61
|
+
kind: any;
|
|
62
|
+
source: string;
|
|
63
|
+
hash: string;
|
|
64
|
+
}[];
|
|
65
|
+
/** 'missing' | 'ai' | 'reviewed' | 'outdated' for one unit */
|
|
66
|
+
export function unitStatus(unit: any, translation: any): "ai" | "reviewed" | "missing" | "outdated";
|
|
67
|
+
/** counts of each status for a section in one language */
|
|
68
|
+
export function translationSummary(definition: any, data: any, translation: any): {
|
|
69
|
+
total: number;
|
|
70
|
+
missing: number;
|
|
71
|
+
ai: number;
|
|
72
|
+
reviewed: number;
|
|
73
|
+
outdated: number;
|
|
74
|
+
};
|
|
75
|
+
/** section data as seen in a language: translated words over the shared facts (field-by-field fallback) */
|
|
76
|
+
export function localizeData(definition: any, data: {} | undefined, translation: any): {};
|
|
77
|
+
export const SCALAR_TYPES: readonly ["text", "textarea", "richtext", "image", "url", "link", "select", "number", "toggle", "date", "time"];
|
|
78
|
+
export const SOURCE_KINDS: readonly ["events", "posts", "bookings"];
|
|
79
|
+
export const SOURCE_LIMIT_MAX: 24;
|
|
80
|
+
export const SAFE_URL: RegExp;
|
|
81
|
+
export function safeUrl(value: any): string;
|
|
82
|
+
/** an image's focal point lives beside it: data["photo__focus"] = "30% 20%" */
|
|
83
|
+
export const FOCUS_SUFFIX: "__focus";
|
|
84
|
+
export function focusKey(key: any): string;
|
|
85
|
+
export function isEmptyValue(v: any): boolean;
|
|
86
|
+
export function hasSource(definition: any): boolean;
|
|
87
|
+
export function newRowId(): string;
|
|
88
|
+
export function rowKey(row: any, i: any): any;
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// Section field helpers shared by the server renderer, the validator and the
|
|
2
|
+
// admin editor. Deliberately zod-free so the Design screen can import it
|
|
3
|
+
// without pulling the schema library into the client bundle.
|
|
4
|
+
//
|
|
5
|
+
// Value shapes by field type:
|
|
6
|
+
// text | textarea | richtext | image | url | select | date | time → string
|
|
7
|
+
// number → number toggle → boolean
|
|
8
|
+
// link → { label, href, ref? } (ref = 'page:<id>' | 'post:<id>' | 'event:<id>' | 'booking:<id>')
|
|
9
|
+
// list → Array<Record<subKey, scalar | link>>
|
|
10
|
+
// source → { calendar?, collection?, tag?, resource?, limit? } (resolved to items at render time)
|
|
11
|
+
|
|
12
|
+
export const SCALAR_TYPES = /** @type {const} */ (['text', 'textarea', 'richtext', 'image', 'url', 'link', 'select', 'number', 'toggle', 'date', 'time']);
|
|
13
|
+
export const SOURCE_KINDS = /** @type {const} */ (['events', 'posts', 'bookings']);
|
|
14
|
+
export const SOURCE_LIMIT_MAX = 24;
|
|
15
|
+
|
|
16
|
+
export const SAFE_URL = /^(https?:\/\/|mailto:|tel:|\/(?!\/)|#)/i;
|
|
17
|
+
export const safeUrl = (value) => {
|
|
18
|
+
const s = String(value ?? '').trim();
|
|
19
|
+
return SAFE_URL.test(s) ? s : '';
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
23
|
+
|
|
24
|
+
/** empty value for a field when neither data nor a default exists */
|
|
25
|
+
export function emptyValue(field) {
|
|
26
|
+
switch (field.type) {
|
|
27
|
+
case 'number': return 0;
|
|
28
|
+
case 'toggle': return false;
|
|
29
|
+
case 'link': return { label: '', href: '' };
|
|
30
|
+
case 'list': return [];
|
|
31
|
+
case 'source': return { limit: 3 };
|
|
32
|
+
default: return '';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** the field's declared default (deep-copied), or its empty value */
|
|
37
|
+
export function defaultValue(field) {
|
|
38
|
+
if (field.default === undefined) return emptyValue(field);
|
|
39
|
+
return typeof field.default === 'object' ? structuredClone(field.default) : field.default;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Coerce a stored value into the field's current shape. Tolerant by design:
|
|
44
|
+
* a value saved by an older schema (e.g. the v1 built-ins' "title | text"
|
|
45
|
+
* textarea lines, now a list) upgrades instead of disappearing.
|
|
46
|
+
*/
|
|
47
|
+
export function normalizeValue(field, value) {
|
|
48
|
+
if (value === undefined || value === null) return defaultValue(field);
|
|
49
|
+
switch (field.type) {
|
|
50
|
+
case 'number': {
|
|
51
|
+
const n = typeof value === 'number' ? value : Number(value);
|
|
52
|
+
return Number.isFinite(n) ? n : defaultValue(field);
|
|
53
|
+
}
|
|
54
|
+
case 'toggle':
|
|
55
|
+
return typeof value === 'boolean' ? value : value === 'true' || value === 1 || value === '1';
|
|
56
|
+
case 'link':
|
|
57
|
+
if (isObject(value)) return { label: String(value.label ?? ''), href: String(value.href ?? ''), ...(value.ref ? { ref: String(value.ref) } : {}) };
|
|
58
|
+
return { label: '', href: String(value) };
|
|
59
|
+
case 'list': {
|
|
60
|
+
const keys = Object.keys(field.fields ?? {});
|
|
61
|
+
let rows = value;
|
|
62
|
+
if (typeof value === 'string') {
|
|
63
|
+
// legacy "a | b" lines → one item per line, cells mapped to sub-fields in order
|
|
64
|
+
rows = value.split('\n').map((l) => l.trim()).filter(Boolean).map((line) => {
|
|
65
|
+
const cells = line.split('|');
|
|
66
|
+
const item = {};
|
|
67
|
+
keys.forEach((k, i) => {
|
|
68
|
+
const cell = i === keys.length - 1 ? cells.slice(i).join('|') : cells[i];
|
|
69
|
+
if (cell !== undefined) item[k] = cell.trim();
|
|
70
|
+
});
|
|
71
|
+
return item;
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
if (!Array.isArray(rows)) return defaultValue(field);
|
|
75
|
+
return rows.filter(isObject).map((row) => normalizeItem(field, row));
|
|
76
|
+
}
|
|
77
|
+
case 'source':
|
|
78
|
+
return isObject(value) ? { ...value } : defaultValue(field);
|
|
79
|
+
default:
|
|
80
|
+
return typeof value === 'string' ? value : typeof value === 'number' || typeof value === 'boolean' ? String(value) : defaultValue(field);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** one list row: every declared sub-field present; unknown keys kept (orphans) */
|
|
85
|
+
export function normalizeItem(listField, row) {
|
|
86
|
+
const out = { ...row };
|
|
87
|
+
for (const [key, sub] of Object.entries(listField.fields ?? {})) out[key] = normalizeValue(sub, row[key]);
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** full data object for a section: declared fields normalized, unknown keys kept */
|
|
92
|
+
export function normalizeData(definition, data = {}) {
|
|
93
|
+
const out = { ...data };
|
|
94
|
+
for (const [key, field] of Object.entries(definition?.fields ?? {})) out[key] = normalizeValue(field, data[key]);
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** data for a brand-new section instance */
|
|
99
|
+
export function initialData(definition) {
|
|
100
|
+
const out = {};
|
|
101
|
+
for (const [key, field] of Object.entries(definition?.fields ?? {})) {
|
|
102
|
+
if (field.default !== undefined || ['list', 'source', 'toggle', 'link'].includes(field.type)) out[key] = defaultValue(field);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** an image's focal point lives beside it: data["photo__focus"] = "30% 20%" */
|
|
108
|
+
export const FOCUS_SUFFIX = '__focus';
|
|
109
|
+
export const focusKey = (key) => `${key}${FOCUS_SUFFIX}`;
|
|
110
|
+
/** "30% 20%" (x y), clamped; anything else → centered */
|
|
111
|
+
export function safeFocus(value) {
|
|
112
|
+
const m = /^(\d{1,3}(?:\.\d+)?)% (\d{1,3}(?:\.\d+)?)%$/.exec(String(value ?? '').trim());
|
|
113
|
+
if (!m) return '50% 50%';
|
|
114
|
+
const c = (n) => Math.max(0, Math.min(100, Math.round(Number(n))));
|
|
115
|
+
return `${c(m[1])}% ${c(m[2])}%`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** saved keys the current schema no longer declares (kept, never deleted silently) */
|
|
119
|
+
export function orphanKeys(definition, data = {}) {
|
|
120
|
+
const declared = definition?.fields ?? {};
|
|
121
|
+
return Object.keys(data).filter((k) => !(k in declared) && !(k.endsWith(FOCUS_SUFFIX) && declared[k.slice(0, -FOCUS_SUFFIX.length)]?.type === 'image'));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** short human label for a list row, for collapsed rows in the editor */
|
|
125
|
+
export function itemTitle(listField, row, index) {
|
|
126
|
+
const keys = Object.keys(listField.fields ?? {});
|
|
127
|
+
const key = listField.itemLabel && keys.includes(listField.itemLabel) ? listField.itemLabel : keys.find((k) => ['text', 'select'].includes(listField.fields[k].type)) ?? keys[0];
|
|
128
|
+
const v = key ? row?.[key] : '';
|
|
129
|
+
const text = isObject(v) ? v.label : v;
|
|
130
|
+
return String(text ?? '').trim() || `Item ${index + 1}`;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** a field's visibleIf against its siblings (section data, or a list row) */
|
|
134
|
+
export function fieldVisible(field, siblings = {}) {
|
|
135
|
+
const c = field?.visibleIf;
|
|
136
|
+
if (!c) return true;
|
|
137
|
+
const raw = siblings?.[c.field];
|
|
138
|
+
const v = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw.href ?? raw.label ?? '' : raw;
|
|
139
|
+
if (c.equals !== undefined) return v === c.equals;
|
|
140
|
+
if (c.notEquals !== undefined) return v !== c.notEquals;
|
|
141
|
+
if (c.in) return c.in.includes(v);
|
|
142
|
+
return Array.isArray(v) ? v.length > 0 : v !== undefined && v !== '' && v !== false && v !== null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** is a (normalized) value empty? */
|
|
146
|
+
export const isEmptyValue = (v) => v === '' || v === undefined || v === null || (Array.isArray(v) && v.length === 0) ||
|
|
147
|
+
(typeof v === 'object' && !Array.isArray(v) && 'href' in v && !String(v.href ?? '').trim() && !String(v.label ?? '').trim());
|
|
148
|
+
|
|
149
|
+
/** "2026-12-01" / "2026-12-01T09:30" window check against a wall-clock "now" in the site timezone */
|
|
150
|
+
export function scheduledVisible(section, nowWall) {
|
|
151
|
+
const day = nowWall.slice(0, 10);
|
|
152
|
+
if (section.showFrom && (section.showFrom.length === 10 ? day < section.showFrom : nowWall < section.showFrom)) return false;
|
|
153
|
+
if (section.showUntil && (section.showUntil.length === 10 ? day > section.showUntil : nowWall >= section.showUntil)) return false;
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** true when a section uses linked data (a source field) */
|
|
158
|
+
export const hasSource = (definition) => Object.values(definition?.fields ?? {}).some((f) => f.type === 'source');
|
|
159
|
+
|
|
160
|
+
/** "19:00" → "7:00 PM" in the site's locale (times of day carry no zone) */
|
|
161
|
+
export function formatTime(value, locale = 'en-US') {
|
|
162
|
+
const m = /^(\d{1,2}):(\d{2})$/.exec(String(value ?? '').trim());
|
|
163
|
+
if (!m) return String(value ?? '');
|
|
164
|
+
const d = new Date(Date.UTC(2000, 0, 1, Number(m[1]), Number(m[2])));
|
|
165
|
+
try { return new Intl.DateTimeFormat(locale, { hour: 'numeric', minute: '2-digit', timeZone: 'UTC' }).format(d); }
|
|
166
|
+
catch { return `${m[1]}:${m[2]}`; }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** "2026-10-04" → "Oct 4, 2026" (date-only values are calendar dates, not instants) */
|
|
170
|
+
export function formatDay(value, locale = 'en-US', opts = { month: 'short', day: 'numeric', year: 'numeric' }) {
|
|
171
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value ?? '').trim());
|
|
172
|
+
if (!m) return String(value ?? '');
|
|
173
|
+
const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
|
|
174
|
+
try { return new Intl.DateTimeFormat(locale, { ...opts, timeZone: 'UTC' }).format(d); }
|
|
175
|
+
catch { return value; }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Issues for one field value (after normalization). `path` prefixes messages
|
|
180
|
+
* so list rows read "Services › Youth Night: Invalid Time".
|
|
181
|
+
*/
|
|
182
|
+
export function valueIssues(field, value, path = field.label) {
|
|
183
|
+
const bad = (what = `Invalid ${field.label}`) => [path === field.label ? what : `${path}: ${what}`];
|
|
184
|
+
if (field.required && isEmptyValue(value)) return bad(`${field.label} is required`);
|
|
185
|
+
if (field.maxLength && typeof value === 'string' && (field.type === 'richtext' ? value.replace(/<[^>]+>/g, '') : value).length > field.maxLength) return bad(`${field.label} is over ${field.maxLength} characters`);
|
|
186
|
+
switch (field.type) {
|
|
187
|
+
case 'number': return typeof value === 'number' && Number.isFinite(value) ? [] : bad();
|
|
188
|
+
case 'toggle': return typeof value === 'boolean' ? [] : bad();
|
|
189
|
+
case 'select': return typeof value === 'string' && (value === '' || field.options?.includes(value)) ? [] : bad();
|
|
190
|
+
case 'url': case 'image': return typeof value === 'string' && (value === '' || SAFE_URL.test(value.trim())) ? [] : bad(`${field.label} must be an http(s) or site-relative link`);
|
|
191
|
+
case 'date': return typeof value === 'string' && (value === '' || /^\d{4}-\d{2}-\d{2}$/.test(value)) ? [] : bad(`${field.label} must be a date (YYYY-MM-DD)`);
|
|
192
|
+
case 'time': return typeof value === 'string' && (value === '' || /^([01]?\d|2[0-3]):[0-5]\d$/.test(value)) ? [] : bad(`${field.label} must be a time (HH:MM)`);
|
|
193
|
+
case 'link': {
|
|
194
|
+
if (!isObject(value)) return bad();
|
|
195
|
+
const href = String(value.href ?? '').trim();
|
|
196
|
+
if (href && !SAFE_URL.test(href)) return bad(`${field.label} must be an http(s) or site-relative link`);
|
|
197
|
+
if (value.ref !== undefined && !/^(page|post|event|booking):[A-Za-z0-9_-]{1,100}$/.test(String(value.ref))) return bad();
|
|
198
|
+
return [];
|
|
199
|
+
}
|
|
200
|
+
case 'list': {
|
|
201
|
+
if (!Array.isArray(value)) return bad();
|
|
202
|
+
const out = [];
|
|
203
|
+
if (field.min && value.length < field.min) out.push(...bad(`${field.label} needs at least ${field.min}`));
|
|
204
|
+
if (value.length > (field.max ?? 50)) out.push(...bad(`${field.label} allows at most ${field.max ?? 50}`));
|
|
205
|
+
value.forEach((row, i) => {
|
|
206
|
+
for (const [key, sub] of Object.entries(field.fields ?? {})) {
|
|
207
|
+
if (!fieldVisible(sub, row)) continue;
|
|
208
|
+
out.push(...valueIssues(sub, normalizeValue(sub, row?.[key]), `${field.label} › ${itemTitle(field, row, i)}`));
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
case 'source': {
|
|
214
|
+
if (!isObject(value)) return bad();
|
|
215
|
+
const limit = value.limit ?? 3;
|
|
216
|
+
if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > SOURCE_LIMIT_MAX) return bad(`${field.label}: show between 1 and ${SOURCE_LIMIT_MAX}`);
|
|
217
|
+
for (const k of ['calendar', 'collection', 'tag', 'resource']) {
|
|
218
|
+
if (value[k] !== undefined && (typeof value[k] !== 'string' || !/^[a-z0-9-]{0,100}$/i.test(value[k]))) return bad();
|
|
219
|
+
}
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
default: return typeof value === 'string' ? [] : bad();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---- translations ---------------------------------------------------------
|
|
227
|
+
// A section keeps one set of data (facts shared by every language) plus, per
|
|
228
|
+
// extra language, only the translated words:
|
|
229
|
+
// translations: { es: { data: { heading: '…', 'services.r4k2.note': '…', cta: 'Planea tu visita' },
|
|
230
|
+
// meta: { heading: { state: 'ai' | 'reviewed', src: '<hash of the English>' } } } }
|
|
231
|
+
// Paths: top-level keys; list rows as `<list>.<rowId>.<sub>` (rowId = row._id,
|
|
232
|
+
// or `#<index>` for rows saved before ids existed); link fields translate
|
|
233
|
+
// their label only.
|
|
234
|
+
|
|
235
|
+
/** words, not facts: text-like fields and link labels; selects only when marked */
|
|
236
|
+
export function isTranslatable(field) {
|
|
237
|
+
if (!field || field.translatable === false) return false;
|
|
238
|
+
if (['text', 'textarea', 'richtext', 'link'].includes(field.type)) return true;
|
|
239
|
+
return field.type === 'select' && field.translatable === true;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** short, stable fingerprint of a source text (detects "the English changed") */
|
|
243
|
+
export function textHash(value) {
|
|
244
|
+
let h = 5381;
|
|
245
|
+
const s = String(value ?? '');
|
|
246
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0;
|
|
247
|
+
return (h >>> 0).toString(36);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** a new list-row id */
|
|
251
|
+
export const newRowId = () => Math.random().toString(36).slice(2, 8);
|
|
252
|
+
export const rowKey = (row, i) => (row && typeof row._id === 'string' && row._id ? row._id : `#${i}`);
|
|
253
|
+
|
|
254
|
+
const sourceText = (field, value) => (field.type === 'link' ? String(value?.label ?? '') : String(value ?? ''));
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Every translatable piece of a section's data, in order:
|
|
258
|
+
* { path, key, rowId?, sub?, field, label, kind, source, hash }
|
|
259
|
+
*/
|
|
260
|
+
export function translationUnits(definition, data = {}) {
|
|
261
|
+
const out = [];
|
|
262
|
+
const d = normalizeData(definition, data);
|
|
263
|
+
for (const [key, field] of Object.entries(definition?.fields ?? {})) {
|
|
264
|
+
if (!fieldVisible(field, d)) continue;
|
|
265
|
+
if (field.type === 'list') {
|
|
266
|
+
(d[key] ?? []).forEach((row, i) => {
|
|
267
|
+
const rid = rowKey(row, i);
|
|
268
|
+
for (const [sub, sf] of Object.entries(field.fields ?? {})) {
|
|
269
|
+
if (!isTranslatable(sf) || !fieldVisible(sf, row)) continue;
|
|
270
|
+
const source = sourceText(sf, row[sub]);
|
|
271
|
+
if (!source.trim()) continue;
|
|
272
|
+
out.push({ path: `${key}.${rid}.${sub}`, key, rowId: rid, sub, field: sf, label: `${field.label} › ${itemTitle(field, row, i)} › ${sf.label}`, kind: sf.type, source, hash: textHash(source) });
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
if (!isTranslatable(field)) continue;
|
|
278
|
+
const source = sourceText(field, d[key]);
|
|
279
|
+
if (!source.trim()) continue;
|
|
280
|
+
out.push({ path: key, key, field, label: field.label, kind: field.type, source, hash: textHash(source) });
|
|
281
|
+
}
|
|
282
|
+
return out;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 'missing' | 'ai' | 'reviewed' | 'outdated' for one unit */
|
|
286
|
+
export function unitStatus(unit, translation) {
|
|
287
|
+
const value = translation?.data?.[unit.path];
|
|
288
|
+
if (value === undefined || String(value).trim() === '') return 'missing';
|
|
289
|
+
const meta = translation?.meta?.[unit.path];
|
|
290
|
+
if (meta && meta.src !== unit.hash) return 'outdated';
|
|
291
|
+
return meta?.state === 'reviewed' ? 'reviewed' : 'ai';
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** counts of each status for a section in one language */
|
|
295
|
+
export function translationSummary(definition, data, translation) {
|
|
296
|
+
const counts = { total: 0, missing: 0, ai: 0, reviewed: 0, outdated: 0 };
|
|
297
|
+
for (const u of translationUnits(definition, data)) { counts.total++; counts[unitStatus(u, translation)]++; }
|
|
298
|
+
return counts;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** section data as seen in a language: translated words over the shared facts (field-by-field fallback) */
|
|
302
|
+
export function localizeData(definition, data = {}, translation) {
|
|
303
|
+
if (!translation?.data || !Object.keys(translation.data).length) return data;
|
|
304
|
+
const out = structuredClone(data);
|
|
305
|
+
for (const [key, field] of Object.entries(definition?.fields ?? {})) {
|
|
306
|
+
if (field.type === 'list') {
|
|
307
|
+
if (!Array.isArray(out[key])) continue;
|
|
308
|
+
out[key] = out[key].map((row, i) => {
|
|
309
|
+
if (!row || typeof row !== 'object') return row;
|
|
310
|
+
const rid = rowKey(row, i);
|
|
311
|
+
const next = { ...row };
|
|
312
|
+
for (const [sub, sf] of Object.entries(field.fields ?? {})) {
|
|
313
|
+
const t = translation.data[`${key}.${rid}.${sub}`];
|
|
314
|
+
if (!isTranslatable(sf) || t === undefined || String(t).trim() === '') continue;
|
|
315
|
+
next[sub] = sf.type === 'link' ? { ...normalizeValue(sf, row[sub]), label: String(t) } : t;
|
|
316
|
+
}
|
|
317
|
+
return next;
|
|
318
|
+
});
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const t = translation.data[key];
|
|
322
|
+
if (!isTranslatable(field) || t === undefined || String(t).trim() === '') continue;
|
|
323
|
+
out[key] = field.type === 'link' ? { ...normalizeValue(field, out[key]), label: String(t) } : t;
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
}
|
package/src/theme-sdk/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
export { ThemeTokens, ThemeManifest, PostOptions, SectionDefinition, SectionInstance, SectionLayout, SectionPages, BUILTIN_SECTIONS, sectionDefinitions, sectionIssues, THEME_API_VERSION } from './contract.mjs';
|
|
1
|
+
export { ThemeTokens, ThemeManifest, PostOptions, SectionDefinition, SectionInstance, SectionLayout, SectionPages, PagePreset, BUILTIN_SECTIONS, sectionDefinitions, sectionIssues, THEME_API_VERSION } from './contract.mjs';
|
|
2
|
+
export { formatTime, formatDay } from './fields.mjs';
|
|
2
3
|
export { CONTENT, postsIndexUrl, postUrl, tagUrl, collectionUrl } from '../lib/content';
|
|
3
4
|
export { formatDate } from '../lib/dates';
|
|
4
5
|
export type { Page, Post, Settings, Tag, CalEvent, Calendar, User } from '../lib/schema';
|
|
@@ -8,7 +9,14 @@ export interface PageProps { settings: Settings; page: Page; html: string }
|
|
|
8
9
|
export interface PostProps { settings: Settings; post: Post; html: string; authors: User[]; related: Post[] }
|
|
9
10
|
import type { Occurrence } from '../lib/calendar/events';
|
|
10
11
|
export interface EventProps { settings: Settings; event: CalEvent; html: string; calendars: Calendar[]; origin: string; occurrence: Occurrence; upcoming: Pick<Occurrence, 'event' | 'start' | 'end' | 'allDay' | 'days'>[]; preview?: boolean; rsvp?: {going:number;left:number|null;maxSeats:number;error:string;open:boolean}|null }
|
|
11
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Props for a coded section. `data` holds resolved values: link fields arrive
|
|
14
|
+
* as LinkValue, lists as row arrays, source fields as fresh item arrays
|
|
15
|
+
* (EventItem / PostItem / BookingItem), richtext as sanitized HTML.
|
|
16
|
+
* `acms theme types <slug>` writes a typed Data interface per section.
|
|
17
|
+
*/
|
|
18
|
+
export interface SectionProps<D = Record<string, unknown>> { data: D; variant: string; settings: Settings; page: Page }
|
|
19
|
+
export type { LinkValue, EventItem, PostItem, BookingItem } from '../lib/sections';
|
|
12
20
|
|
|
13
21
|
export { slugify, authorSlug } from '../lib/schema';
|
|
14
22
|
|