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