@acms-subir/core 0.2.0 → 0.3.1
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 +87 -0
- package/cli/common.mjs +35 -0
- package/cli/legacy.mjs +317 -0
- package/cli/main.mjs +66 -0
- package/cli/palette.mjs +4 -0
- package/cli/preview.mjs +50 -0
- package/cli/remote.mjs +55 -0
- package/cli/themes.mjs +127 -0
- package/cli.mjs +2 -369
- package/deploy/Caddyfile +8 -0
- package/deploy/Dockerfile +34 -0
- package/deploy/compose.https.yml +21 -0
- package/deploy/compose.yml +46 -0
- package/docs/live-video.md +91 -0
- package/docs/theme-authoring.md +103 -0
- package/email-runner.mjs +32 -0
- package/integration.mjs +189 -2
- package/ops-check.mjs +27 -0
- package/package.json +27 -5
- package/src/actions/index.ts +578 -49
- package/src/components/AiCleaner.tsx +291 -0
- package/src/components/AiSettings.tsx +575 -0
- package/src/components/CommandPalette.tsx +200 -0
- package/src/components/ContentList.astro +246 -0
- package/src/components/DesignAccess.tsx +182 -0
- package/src/components/DesignManager.tsx +418 -46
- package/src/components/EmailSettings.tsx +521 -0
- package/src/components/EventPage.astro +178 -0
- package/src/components/LegalPage.astro +27 -0
- package/src/components/LinksManager.tsx +119 -58
- package/src/components/LiveCard.astro +174 -0
- package/src/components/LiveManager.tsx +910 -0
- package/src/components/NavManager.tsx +42 -43
- package/src/components/ThemeScript.astro +50 -0
- package/src/components/ThemeSections.astro +58 -0
- package/src/components/ThemeSectionsEditor.tsx +513 -0
- package/src/components/WidgetRenderer.astro +3 -0
- package/src/components/booking/BookingFlow.astro +150 -0
- package/src/components/booking/HoursEditor.astro +63 -0
- package/src/components/builder/PageBuilder.tsx +10 -2
- package/src/components/editor/EventPanel.tsx +401 -0
- package/src/components/editor/FormatDock.tsx +81 -0
- package/src/components/editor/LivePane.tsx +122 -0
- package/src/components/editor/MobileToolbar.tsx +93 -0
- package/src/components/editor/PostEditor.tsx +605 -223
- package/src/components/editor/PreviewOverlay.tsx +242 -43
- package/src/components/editor/extensions/calendar.tsx +51 -7
- package/src/components/editor/extensions/events.tsx +153 -0
- package/src/components/editor/extensions/slash.tsx +7 -1
- package/src/components/site/Pager.astro +24 -0
- package/src/components/site/PostCard.astro +64 -0
- package/src/components/site/TagLabel.astro +11 -0
- package/src/layouts/Admin.astro +289 -49
- package/src/layouts/Site.astro +352 -59
- package/src/lib/agent-api.ts +13 -0
- package/src/lib/agent-discovery.ts +40 -0
- package/src/lib/agent.ts +114 -0
- package/src/lib/ai-config.ts +58 -0
- package/src/lib/ai-schema.ts +22 -0
- package/src/lib/ai.ts +90 -0
- package/src/lib/api-keys.ts +65 -0
- package/src/lib/api.ts +56 -0
- package/src/lib/auth.ts +25 -5
- package/src/lib/backup/content.ts +36 -0
- package/src/lib/backup/unzip.ts +12 -1
- package/src/lib/backup/zip.ts +1 -1
- package/src/lib/booking/agent.ts +108 -0
- package/src/lib/booking/availability.ts +221 -0
- package/src/lib/booking/emails.ts +115 -0
- package/src/lib/booking/forms.ts +77 -0
- package/src/lib/booking/service.ts +277 -0
- package/src/lib/booking/view.ts +54 -0
- package/src/lib/calendar/agent-schema.ts +48 -0
- package/src/lib/calendar/agent.ts +136 -0
- package/src/lib/calendar/api.ts +81 -0
- package/src/lib/calendar/cards.ts +102 -0
- package/src/lib/calendar/config.ts +52 -0
- package/src/lib/calendar/data.ts +130 -0
- package/src/lib/calendar/events.ts +261 -0
- package/src/lib/calendar/feeds.ts +43 -0
- package/src/lib/calendar/hooks.ts +38 -0
- package/src/lib/calendar/http.ts +30 -0
- package/src/lib/calendar/ics-write.ts +120 -0
- package/src/lib/calendar/ics.ts +15 -5
- package/src/lib/calendar/import.ts +203 -0
- package/src/lib/calendar/render.ts +120 -37
- package/src/lib/calendar/rrule.ts +149 -0
- package/src/lib/calendar/seo.ts +48 -0
- package/src/lib/design-service.ts +58 -0
- package/src/lib/designDiff.ts +145 -0
- package/src/lib/designPreview.ts +110 -12
- package/src/lib/documents/cleanup.ts +31 -0
- package/src/lib/documents/extract.ts +154 -0
- package/src/lib/documents/vendor.d.ts +4 -0
- package/src/lib/drafts.ts +60 -6
- package/src/lib/dynamicBlocks.ts +40 -8
- package/src/lib/email/accounts.ts +55 -0
- package/src/lib/email/config.ts +86 -0
- package/src/lib/email/migration.ts +24 -0
- package/src/lib/email/providers.ts +89 -0
- package/src/lib/email/service.ts +78 -0
- package/src/lib/email/store.ts +167 -0
- package/src/lib/email/templates.ts +18 -0
- package/src/lib/email/types.ts +32 -0
- package/src/lib/email/webhooks.ts +87 -0
- package/src/lib/email.ts +15 -0
- package/src/lib/env.ts +1 -1
- package/src/lib/legal/index.ts +25 -0
- package/src/lib/live/client.ts +88 -0
- package/src/lib/live/crypto.ts +28 -0
- package/src/lib/live/migration.ts +15 -0
- package/src/lib/live/providers.ts +152 -0
- package/src/lib/live/service.ts +170 -0
- package/src/lib/live/signals.ts +89 -0
- package/src/lib/live/store.ts +49 -0
- package/src/lib/live/types.ts +52 -0
- package/src/lib/live/view.ts +78 -0
- package/src/lib/mobile/bookings.ts +73 -0
- package/src/lib/mobile/calendar.ts +85 -0
- package/src/lib/mobile/content.ts +85 -0
- package/src/lib/mobile/http.ts +63 -0
- package/src/lib/mobile/route.ts +20 -0
- package/src/lib/ops.ts +8 -0
- package/src/lib/preview.ts +21 -1
- package/src/lib/repo/migrations.ts +221 -0
- package/src/lib/repo/sql.ts +907 -68
- package/src/lib/repo/types.ts +125 -5
- package/src/lib/scheduler.ts +27 -0
- package/src/lib/schema.ts +316 -1
- package/src/lib/search-i18n.ts +3 -0
- package/src/lib/socialIcons.ts +32 -0
- package/src/lib/tagColor.ts +11 -0
- package/src/lib/theme.ts +59 -40
- package/src/lib/themeTokens.ts +183 -0
- package/src/lib/umami.ts +108 -0
- package/src/lib/webhook-sql.ts +68 -0
- package/src/lib/webhooks.ts +147 -0
- package/src/lib/widgets.ts +1 -0
- package/src/middleware.ts +45 -3
- package/src/pages/404.astro +23 -0
- package/src/pages/[...slug].astro +14 -5
- package/src/pages/account/[action].astro +53 -0
- package/src/pages/admin/ai.astro +12 -0
- package/src/pages/admin/c/[collection]/[id].astro +4 -1
- package/src/pages/admin/c/[collection]/index.astro +127 -92
- package/src/pages/admin/calendar/bookings.astro +400 -0
- package/src/pages/admin/calendar/calendars.astro +265 -0
- package/src/pages/admin/calendar/events/[id].astro +56 -0
- package/src/pages/admin/calendar/events/index.astro +408 -0
- package/src/pages/admin/calendar/index.ts +4 -0
- package/src/pages/admin/calendar/integrations.astro +4 -0
- package/src/pages/admin/calendar/resources/[id].astro +180 -0
- package/src/pages/admin/calendar/resources/index.astro +106 -0
- package/src/pages/admin/calendar/resources/link.astro +196 -0
- package/src/pages/admin/design.astro +10 -3
- package/src/pages/admin/email.astro +13 -0
- package/src/pages/admin/index.astro +232 -85
- package/src/pages/admin/integrations.astro +237 -0
- package/src/pages/admin/links.astro +3 -3
- package/src/pages/admin/live.astro +12 -0
- package/src/pages/admin/login.astro +14 -10
- package/src/pages/admin/navigation.astro +8 -2
- package/src/pages/admin/pages/[id].astro +4 -1
- package/src/pages/admin/pages/index.astro +41 -48
- package/src/pages/admin/settings.astro +418 -367
- package/src/pages/admin/tags.astro +4 -4
- package/src/pages/admin/users.astro +38 -28
- package/src/pages/api/agent/docs.ts +33 -0
- package/src/pages/api/agent/index.ts +21 -0
- package/src/pages/api/ai/clean.ts +13 -0
- package/src/pages/api/ai/draft.ts +14 -0
- package/src/pages/api/ai/settings.ts +53 -0
- package/src/pages/api/ai/test.ts +12 -0
- package/src/pages/api/backup.ts +8 -21
- package/src/pages/api/calendar/availability.ts +35 -0
- package/src/pages/api/calendar/bookings/[id].ts +49 -0
- package/src/pages/api/calendar/bookings/index.ts +73 -0
- package/src/pages/api/calendar/calendars.ts +35 -0
- package/src/pages/api/calendar/docs.ts +66 -0
- package/src/pages/api/calendar/events/[id].ts +63 -0
- package/src/pages/api/calendar/events/index.ts +112 -0
- package/src/pages/api/calendar/resources.ts +34 -0
- package/src/pages/api/design/capabilities.ts +10 -0
- package/src/pages/api/design/index.ts +58 -0
- package/src/pages/api/design/keys.ts +24 -0
- package/src/pages/api/design/media.ts +32 -0
- package/src/pages/api/email/run.ts +11 -0
- package/src/pages/api/email/settings.ts +61 -0
- package/src/pages/api/email/webhooks/[provider].ts +4 -0
- package/src/pages/api/live/oauth/youtube/callback.ts +19 -0
- package/src/pages/api/live/settings.ts +45 -0
- package/src/pages/api/live/status.ts +7 -0
- package/src/pages/api/live/thumb.ts +48 -0
- package/src/pages/api/mcp.ts +59 -0
- package/src/pages/api/media/upload.ts +2 -2
- package/src/pages/api/ops/status.ts +39 -0
- package/src/pages/api/restore.ts +27 -37
- package/src/pages/api/search.ts +18 -4
- package/src/pages/api/v1/availability.ts +7 -0
- package/src/pages/api/v1/bookings.ts +17 -0
- package/src/pages/api/v1/calendars.ts +7 -0
- package/src/pages/api/v1/content/[id].ts +7 -0
- package/src/pages/api/v1/content/index.ts +7 -0
- package/src/pages/api/v1/events.ts +7 -0
- package/src/pages/api/v1/resources.ts +7 -0
- package/src/pages/api/webhooks/docs.ts +61 -0
- package/src/pages/author/[slug].astro +43 -35
- package/src/pages/blog/[slug].astro +137 -66
- package/src/pages/blog/index.astro +95 -30
- package/src/pages/blog/tag/[slug].astro +45 -40
- package/src/pages/book/[resource]/[link].astro +70 -0
- package/src/pages/book/[resource]/index.astro +63 -0
- package/src/pages/book/[resource].ics.ts +38 -0
- package/src/pages/book/index.astro +47 -0
- package/src/pages/book/manage/[token].astro +108 -0
- package/src/pages/calendar/[slug].ics.ts +27 -0
- package/src/pages/calendar/feed.ics.ts +15 -0
- package/src/pages/calendar/index.astro +191 -0
- package/src/pages/embed/book/[resource]/[link].astro +33 -0
- package/src/pages/embed/calendar/[slug].astro +43 -0
- package/src/pages/events/[slug].astro +74 -0
- package/src/pages/events/[slug].ics.ts +25 -0
- package/src/pages/index.astro +12 -3
- package/src/pages/legal/privacy.astro +4 -0
- package/src/pages/legal/terms.astro +4 -0
- package/src/pages/links.astro +29 -3
- package/src/pages/llms.txt.ts +4 -0
- package/src/pages/preview/[type]/[id].astro +27 -6
- package/src/pages/search.astro +40 -20
- package/src/pages/sitemap.xml.ts +16 -1
- package/src/styles/admin.css +924 -105
- package/src/styles/cards.css +92 -74
- package/src/styles/editor.css +492 -114
- package/src/styles/email-settings.css +117 -0
- package/src/styles/live-admin.css +126 -0
- package/src/styles/live.css +199 -0
- package/src/styles/sections.css +12 -0
- package/src/styles/site.css +938 -281
- package/src/styles/theme-editor.css +135 -0
- package/src/theme-sdk/contract.d.mts +619 -0
- package/src/theme-sdk/contract.mjs +81 -0
- package/src/theme-sdk/index.ts +15 -0
- package/src/theme-sdk/tokens.d.mts +148 -0
- package/src/theme-sdk/tokens.mjs +55 -0
- package/src/themes/editorial/templates/home.astro +4 -4
- package/src/themes/editorial/templates/post-minimal.astro +2 -2
- package/src/themes/editorial/theme.css +27 -37
- package/src/themes/editorial/theme.json +25 -3
- package/src/themes/field/theme.json +27 -9
- package/src/themes/ink/theme.json +30 -12
- package/src/themes/night/theme.css +4 -7
- package/src/themes/night/theme.json +28 -10
- package/src/virtual.d.ts +14 -0
- package/src/worker.ts +5 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Connect your existing proxy to this Compose network and route to site:4321.
|
|
2
|
+
# Optional standalone TLS: docker compose -f compose.yml -f compose.https.yml up -d --build
|
|
3
|
+
services:
|
|
4
|
+
site:
|
|
5
|
+
build: &site-build .
|
|
6
|
+
init: true
|
|
7
|
+
restart: unless-stopped
|
|
8
|
+
expose:
|
|
9
|
+
- "4321"
|
|
10
|
+
volumes:
|
|
11
|
+
- data:/app/data
|
|
12
|
+
environment:
|
|
13
|
+
- HOST=0.0.0.0
|
|
14
|
+
- PORT=4321
|
|
15
|
+
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?set ADMIN_PASSWORD in .env}
|
|
16
|
+
- SESSION_SECRET=${SESSION_SECRET:?set SESSION_SECRET in .env}
|
|
17
|
+
- EMAIL_RUNNER_SECRET=${EMAIL_RUNNER_SECRET:?set EMAIL_RUNNER_SECRET in .env}
|
|
18
|
+
- EMAIL_ENCRYPTION_KEY=${EMAIL_ENCRYPTION_KEY:-}
|
|
19
|
+
- SQLITE_URL=file:./data/acms.db
|
|
20
|
+
healthcheck:
|
|
21
|
+
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:4321/api/v1/content?limit=1').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
|
|
22
|
+
interval: 60s
|
|
23
|
+
timeout: 15s
|
|
24
|
+
start_period: 30s
|
|
25
|
+
scheduler:
|
|
26
|
+
build: *site-build
|
|
27
|
+
init: true
|
|
28
|
+
restart: unless-stopped
|
|
29
|
+
# Share only the app network namespace. No public port or database volume.
|
|
30
|
+
network_mode: service:site
|
|
31
|
+
depends_on:
|
|
32
|
+
site:
|
|
33
|
+
condition: service_healthy
|
|
34
|
+
restart: true
|
|
35
|
+
command: ["node", "/app/email-runner.mjs"]
|
|
36
|
+
environment:
|
|
37
|
+
- EMAIL_RUNNER_URL=http://127.0.0.1:4321
|
|
38
|
+
- EMAIL_RUNNER_SECRET=${EMAIL_RUNNER_SECRET:?set EMAIL_RUNNER_SECRET in .env}
|
|
39
|
+
- SCHEDULER_HEARTBEAT_FILE=/tmp/acms-scheduler-heartbeat
|
|
40
|
+
healthcheck:
|
|
41
|
+
test: ["CMD", "node", "-e", "const fs=require('fs');try{process.exit(Date.now()-Number(fs.readFileSync('/tmp/acms-scheduler-heartbeat','utf8'))<180000?0:1)}catch{process.exit(1)}"]
|
|
42
|
+
interval: 60s
|
|
43
|
+
timeout: 5s
|
|
44
|
+
start_period: 120s
|
|
45
|
+
volumes:
|
|
46
|
+
data:
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Live video connections
|
|
2
|
+
|
|
3
|
+
ACMS can discover a site's current YouTube or Twitch broadcast and display it on its homepage and links page. Each site owner registers their own developer application and supplies its credentials. ACMS makes API requests from the deployed site's server; there is no shared ACMS developer app, hosted proxy or shared quota. The streaming platform continues to broadcast and host the video.
|
|
4
|
+
|
|
5
|
+
Open **Integrations → Live video** (`/admin/live`) as an owner. Connect a provider, run a successful test, turn the connection on, and save the display mode as **Automatic**. A healthy connection can be offline: starting a broadcast happens on the streaming platform, not in ACMS.
|
|
6
|
+
|
|
7
|
+
## Before connecting
|
|
8
|
+
|
|
9
|
+
- Set the site's canonical URL in Settings. Use that exact origin to sign in and authorize YouTube, including whether it uses `www`. Production callbacks require HTTPS; localhost HTTP is allowed for development.
|
|
10
|
+
- Supply a strong `SESSION_SECRET` (at least 32 random characters), or a separate `LIVE_ENCRYPTION_KEY` of that length, using the host's secret configuration. Live credentials use AES-256-GCM and a separate encryption context. An explicit live key takes precedence. Losing/changing the effective encryption key requires re-entering credentials and reauthorizing.
|
|
11
|
+
- Ensure the independent scheduler runs every minute. Workers deployments use the existing scheduled handler/cron trigger; self-hosted Docker uses the existing supervised scheduler. Custom Node deployments must invoke the existing authenticated `/api/email/run` runner, which now also runs live checks. It is a shared operational runner despite its historical name. See [production deployment](../../../docs/production-deployment.md). Visitor traffic does not drive live-provider polling.
|
|
12
|
+
- The UI reports the live scheduler heartbeat, each connection's last successful check, next due check and any provider error. Public visitors only receive a sanitized snapshot.
|
|
13
|
+
|
|
14
|
+
## Twitch
|
|
15
|
+
|
|
16
|
+
1. Open the [Twitch application console](https://dev.twitch.tv/console/apps) and register a **Confidential** application. Follow Twitch's account/2FA requirements.
|
|
17
|
+
2. If registration requests an OAuth redirect URI, use the copyable address shown by ACMS (`https://your-site.example/admin/live`). This app-only lookup does not use a Twitch OAuth callback or broadcaster sign-in.
|
|
18
|
+
3. Copy the Client ID and generate/copy the Client Secret. Enter them with the channel username or HTTPS channel URL in ACMS.
|
|
19
|
+
4. Save, test, and turn the connection on. The test resolves the channel's stable ID.
|
|
20
|
+
|
|
21
|
+
ACMS obtains an app token with the client-credentials flow and checks Get Streams for that broadcaster. App tokens are [validated](https://dev.twitch.tv/docs/authentication/validate-tokens/) when acquired and hourly during use. Expired or revoked app tokens are reacquired; these tokens do not use refresh tokens. ACMS requests no stream key or broadcast-management permissions. See [app registration](https://dev.twitch.tv/docs/authentication/register-app/), [token flow](https://dev.twitch.tv/docs/authentication/getting-tokens-oauth/#client-credentials-grant-flow), and [Get Streams](https://dev.twitch.tv/docs/api/reference/#get-streams).
|
|
22
|
+
|
|
23
|
+
## YouTube
|
|
24
|
+
|
|
25
|
+
1. Create/select a Google Cloud project and enable **YouTube Data API v3**.
|
|
26
|
+
2. Configure OAuth consent for the appropriate audience. During external-app testing, add the Google account that owns the intended channel as a test user.
|
|
27
|
+
3. Create an OAuth **Web application** client. Copy the exact callback shown in ACMS into Google's authorized redirect URIs: `https://your-site.example/api/live/oauth/youtube/callback`.
|
|
28
|
+
4. Save the Client ID and Client Secret in ACMS, then choose **Authorize YouTube**. Approve YouTube read-only access using the correct Google/Brand channel identity.
|
|
29
|
+
5. Confirm the available channel, test, and turn the connection on. Selecting another channel requires a new test. A typed channel ID cannot override the authorized identity.
|
|
30
|
+
|
|
31
|
+
The authorization-code flow uses session-bound, one-use state and PKCE. Access/refresh tokens stay encrypted on the server. ACMS lists active broadcasts, filters to the selected channel and public `live` broadcasts, and checks public video visibility/embedding permission. Private, unlisted and test broadcasts are excluded. Multiple current broadcasts use the latest actual start time with a stable ID tie-break. See [liveBroadcasts.list](https://developers.google.com/youtube/v3/live/docs/liveBroadcasts/list) and [server OAuth](https://developers.google.com/identity/protocols/oauth2/web-server).
|
|
32
|
+
|
|
33
|
+
External OAuth apps left in **Testing** receive refresh tokens for YouTube scopes that expire after seven days. The UI's Testing/Production choice is owner-reported; ACMS cannot verify the app's publishing status. Follow Google's applicable publishing and verification requirements before relying on a long-running connection. A revoked or expired grant requires reauthorization. See [Google token expiration](https://developers.google.com/identity/protocols/oauth2#expiration).
|
|
34
|
+
|
|
35
|
+
### Quota and timing
|
|
36
|
+
|
|
37
|
+
The normal target is a check each minute and visible changes within roughly one to two minutes, subject to provider latency, scheduler operation and quota. One single-page active-broadcast list per minute is about 1,440 units/day before extra calls. Live-video visibility checks, connection tests, pagination and optional upcoming lookups add usage. Check the owner's actual project limits and other apps sharing that project.
|
|
38
|
+
|
|
39
|
+
Current Google documentation assigns `liveBroadcasts.list` a cost of one unit, with a default shared 10,000-unit daily allocation for non-search/non-upload endpoints. Public `search.list` has a separate default 100-call daily allowance. ACMS therefore implements owner-authorized OAuth, not minute-by-minute public search. API-key-only discovery is not included in this release. See [Google's current quota table](https://developers.google.com/youtube/v3/determine_quota_cost).
|
|
40
|
+
|
|
41
|
+
Rate-limit/quota errors back off. The UI reports estimated workload and provider errors, not an invented remaining quota. Optional YouTube upcoming lookup is cached for up to 15 minutes while active detection continues normally. A scheduled start time never becomes a live badge on its own.
|
|
42
|
+
|
|
43
|
+
## Place the live card
|
|
44
|
+
|
|
45
|
+
- **Design → Pages:** add a **Live video** section; choose `card` or `player`, preview and publish using the existing design flow.
|
|
46
|
+
- **Links:** add a **Live video** block and save. This is a compact card referencing the same site-wide status.
|
|
47
|
+
- **Older widget pages:** add a **Live video** widget and choose its display mode. Rich-text-only/custom coded homepages must opt into a Design section or explicitly render the component.
|
|
48
|
+
- **Custom themes:** import `LiveCard.astro` from `@acms-subir/core/components/LiveCard.astro`, or use the built-in `live` section. ACMS never injects a new placement automatically into a custom template. If a custom route renders the component, set `Astro.response.headers.set('Cache-Control', 'no-store')` in that route's frontmatter; the built-in page routes handle this for you.
|
|
49
|
+
|
|
50
|
+
### How it looks
|
|
51
|
+
|
|
52
|
+
**What your site shows → How it looks** (shown unless live video is Off) controls presentation:
|
|
53
|
+
|
|
54
|
+
- **Card style** for sections set to `card`: **Poster** (thumbnail with a live tag, title below; default), **Split** (thumbnail beside the title; stacks in columns narrower than 700px) or **Compact row**. Sections set to `player` always use the split layout, with the poster as the play button. The Links block is its own compact layout and moves to the top of the Links page while you're live.
|
|
55
|
+
- **Thumbnail** (on by default): served from `/api/live/thumb`, which fetches the current broadcast's still from YouTube (`i.ytimg.com`) or Twitch (`static-cdn.jtvnw.net`) on the server and caches it for a minute. Visitors never contact the platform until they press play. The upstream URL is derived only from the current snapshot's validated id; the query string is a cache version.
|
|
56
|
+
- **Viewer count** (off by default): Twitch reports it in the existing streams call; YouTube adds `liveStreamingDetails` to the existing `videos.list` check at the same one-unit cost. It is only sent to visitors when this is on.
|
|
57
|
+
- **Site-wide signals** (all off by default), shown on every page only while a broadcast is live: a **live bar** that temporarily replaces the announcement bar, a **"Live" marker** in the header, and a **floating card** in the corner (hidden on pages that already show a live card). The bar and card can be dismissed for the rest of that broadcast in the visitor's session. They are filled in by the browser from `/api/live/status`, so pages keep their normal caching.
|
|
58
|
+
|
|
59
|
+
Offline, a card shows a single "Not live right now · Watch our channel" row when an offline link is set, and hides otherwise. Upcoming broadcasts show their start time on the poster ("Sat 7:00 PM · in 2 h").
|
|
60
|
+
|
|
61
|
+
### Theming
|
|
62
|
+
|
|
63
|
+
The markup is a stable class API: `.pc-live` with `data-layout` (`poster`, `split`, `row`, `links`) and `data-status` (`live`, `upcoming`, `offline`); parts `.pc-live-poster`, `.pc-live-tag`, `.pc-live-viewers`, `.pc-live-kicker`, `.pc-live-title-text`, `.pc-live-meta`, `.pc-live-btn`, `.pc-live-off`; site-wide `.pc-live-bar`, `.site-live`, `.pc-live-dock`. While a broadcast is live or upcoming, `<html data-live="live|upcoming">` is set, so a theme can style anything (for example `html[data-live='live'] .brand`).
|
|
64
|
+
|
|
65
|
+
Override these on `:root` in `theme.css`: `--live-color` (defaults to the theme token `live`, a tuned red), `--live-ink`, `--live-radius`, `--live-tag-radius` (keep it a rectangle), `--live-poster-ratio` and `--live-pulse`. The marker blink is disabled under `prefers-reduced-motion`.
|
|
66
|
+
|
|
67
|
+
All placements share the same status. Changing credentials or live mode takes effect independently of staged layout changes. Adding/reordering a placement still follows its existing save/publish workflow. Existing sites start with live display off.
|
|
68
|
+
|
|
69
|
+
Player mode loads the official platform iframe only after a visitor clicks. Autoplay is off. The external watch link stays available when playback is blocked. Twitch requires HTTPS, the embedding hostname in `parent`, and at least 400×300 pixels; narrow slots use a watch link. YouTube embeds preserve an origin referrer. The compact links card never embeds a player. Cross-domain embedding of an entire ACMS site may require additional Twitch parent domains and is not configured automatically. See [Twitch embeds](https://dev.twitch.tv/docs/embed/video-and-clips/) and [YouTube players](https://developers.google.com/youtube/iframe_api_reference).
|
|
70
|
+
|
|
71
|
+
## Manual, off, fallback and outages
|
|
72
|
+
|
|
73
|
+
**Manual** accepts an HTTPS watch URL and a bounded expiry of up to 12 hours (UI default: four hours). It overrides automatic detection until it expires; automatic detection then resumes. **End manual link now** resumes automatic immediately. **Off** hides every placement; turn off individual provider connections too if you want to stop their background checks.
|
|
74
|
+
|
|
75
|
+
An optional neutral “Watch our channel” URL appears when no fresh live/upcoming broadcast is available. Provider priority chooses the first currently live enabled source; a live source always takes precedence over an upcoming one. No replay is automatically labelled live, and replay libraries are not part of this release.
|
|
76
|
+
|
|
77
|
+
A successful live observation expires after two minutes. Failures never extend that expiry. A confirmed offline result clears the badge, revoked access clears live state and disables that connection, and expired state hides the badge. Visible browser tabs refresh the sanitized status every 30 seconds and on focus; an expiry timer removes stale badges even if requests fail. Without JavaScript, status refreshes on the next page load. Pages containing live cards and the public status endpoint are not cached.
|
|
78
|
+
|
|
79
|
+
## Storage, permissions and recovery
|
|
80
|
+
|
|
81
|
+
Migration 34 adds a `settings.live` display configuration, a separate `live_connections` vault, one-use `live_oauth_states`, and a live scheduler heartbeat. Credentials never enter generic Settings, theme props, public API responses, React hydration or ordinary content exports. Owner-only APIs use existing authenticated sessions plus same-origin writes. Public `/api/live/status` only returns the selected public snapshot and server time.
|
|
82
|
+
|
|
83
|
+
Each background check claims a durable lease. Settings revisions and lease tokens prevent a delayed check from overwriting a newer edit/disconnection; concurrent runners cannot refresh the same connection simultaneously. Requests have timeouts, response-size limits and redacted errors. Provider requests use fixed official endpoints; pasted URLs are never fetched by the server.
|
|
84
|
+
|
|
85
|
+
Saving a connection disables it until tested again. Blank secrets preserve the saved secret only for the same client ID; changing the client ID or supplying a new secret clears incompatible authorization. Disconnect removes local credentials and state immediately and attempts provider revocation. Other applications using the same owner developer project may share quota or authorization consequences.
|
|
86
|
+
|
|
87
|
+
Content backups include inert display preferences but no connection secrets or tokens. Restore forces live mode off and disables any destination connections until retested; it neither imports credentials nor resumes a copied live badge. On a new host, enter app credentials, update Google's callback, authorize and test again.
|
|
88
|
+
|
|
89
|
+
Facebook HTTPS watch links work in manual mode. A Facebook automatic Page connector, Twitch EventSub and the slower YouTube API-key mode remain separate follow-up releases. They are not represented as working connectors in the UI.
|
|
90
|
+
|
|
91
|
+
Validation uses simulated provider HTTP responses for token renewal, filtering, expiry, quotas and OAuth attacks, plus SQLite/D1 concurrency and real local HTTP rendering tests. A real owner-owned Twitch/Google app is still needed to validate its external consent and account permissions end to end.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# ACMS theme authoring contract (API 1)
|
|
2
|
+
|
|
3
|
+
Use the installed version as the authority: `acms inspect --json`, `acms schema theme --json`, and `acms docs theme --format markdown`. `astro sync`, dev or build writes a non-secret configuration inventory used by inspect. Never copy credentials or site content into a theme package.
|
|
4
|
+
|
|
5
|
+
## Start and validate
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
acms theme create studio --from business
|
|
9
|
+
acms theme dev studio
|
|
10
|
+
acms theme check studio --build --json
|
|
11
|
+
acms theme diff studio
|
|
12
|
+
acms theme pack studio
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The business starter includes a typed custom Statement section to adapt. Editorial is another starter: `--from editorial`. An installed project theme can also be used as a starter. Create never overwrites an existing folder. `agent init` writes `.agents/skills/acms-theme/SKILL.md` without modifying your existing instruction files.
|
|
16
|
+
|
|
17
|
+
Themes live in `src/themes/<slug>/` with `theme.json`, optional `theme.css`, `templates/`, `sections/`, and `assets/`. Metadata supports name, slug, semantic version, apiVersion (1), core compatibility range, description, author, license, token defaults, post defaults, templates, section definitions and asset inventory. Legacy manifests without version metadata receive defaults. Do not include package scripts or dependencies in theme archives.
|
|
18
|
+
|
|
19
|
+
The token schema is shared with the runtime, not a separate CLI copy. Color schemes, typography, spacing, width, shape and header/footer defaults are presentation. Site title, page copy, image choices and section order belong to the owner in ACMS. New theme files require build/deployment; database-only edits to installed sections and brand overrides do not.
|
|
20
|
+
|
|
21
|
+
## Public imports and template props
|
|
22
|
+
|
|
23
|
+
```astro
|
|
24
|
+
---
|
|
25
|
+
import type { HomeProps } from '@acms-subir/core/theme';
|
|
26
|
+
import { collectionUrl } from '@acms-subir/core/theme';
|
|
27
|
+
import PostCard from '@acms-subir/core/theme/components/PostCard.astro';
|
|
28
|
+
interface Props extends HomeProps {}
|
|
29
|
+
const { page, posts } = Astro.props;
|
|
30
|
+
---
|
|
31
|
+
{page?.bodyHtml && <div set:html={page.bodyHtml} />}
|
|
32
|
+
{posts.map(post => <PostCard href={collectionUrl(post.collection, post.slug)} title={post.title} excerpt={post.excerpt} />)}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Public types: HomeProps (settings, page, posts, tags), PageProps (settings, page, html), PostProps (settings, post, html, authors, related), EventProps (settings, event, occurrence, calendars, html, upcoming, origin, preview, rsvp), SectionProps (settings, page, variant, data). Public components: PostCard with card/row/feature variants, Pager with base/page/count, TagLabel with name/href/accent. Helpers include collectionUrl, postUrl, postsIndexUrl, tagUrl, formatDate, slugify, authorSlug, localeFromPath, localeHomeUrl and CONTENT. Use collectionUrl for non-default collections; postUrl is the built-in posts alias.
|
|
36
|
+
|
|
37
|
+
Template filenames: `home.astro`, `event.astro`, `post-<name>.astro`, `page-<name>.astro`. Post/page variants are selected in the content editor. Core owns routes, auth, SEO and published-content queries. Templates only replace their documented content area. Never query private data from a public section or insert untrusted strings with set:html. The core's rendered `html`/`bodyHtml` is the intended rich-content handoff.
|
|
38
|
+
|
|
39
|
+
## Editable sections
|
|
40
|
+
|
|
41
|
+
Every theme gets Hero, Features, Testimonials, FAQ, Posts, CTA and Existing page content. The last section explicitly adapts the original page renderer: published rich text and widgets can appear among your sections, with their editing lifecycle unchanged. In Design → Pages, select a page, enable Sections, add/reorder/duplicate/hide sections, edit fields and save the section draft. Review and publish through the existing Design workflow. Page content stays intact; switching back to Page content restores the existing renderer without deleting section data. Unknown sections and schema-version mismatches retain their data, show an editor warning and block publication until hidden, removed or migrated.
|
|
42
|
+
|
|
43
|
+
Custom definitions live in `theme.json`:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"name": "Studio", "slug": "studio", "version": "1.0.0", "apiVersion": 1,
|
|
48
|
+
"core": ">=0.2.0 <1.0.0",
|
|
49
|
+
"sections": {
|
|
50
|
+
"statement": {
|
|
51
|
+
"name": "Statement", "version": 1,
|
|
52
|
+
"component": "sections/Statement.astro", "variants": ["default"],
|
|
53
|
+
"fields": { "heading": { "label": "Heading", "type": "text", "default": "Welcome" } }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
The component receives SectionProps. Supported field kinds: text, textarea, image, url, select (options required), number. Instances have stable id/type/version/variant/hidden/data fields. A page layout has mode and sections, persisted by page ID in `settings.sectionPages`. Section values and order have a design draft lifecycle distinct from rich-text/widget page drafts; publishing design does not publish pending page text. Theme switches preserve the map. To migrate a custom schema, transform a pulled draft to the new version, review it, and push it with its revision; incompatible versions never auto-convert silently. Existing widget pages continue using their original renderer.
|
|
60
|
+
|
|
61
|
+
Images in template files should be imported using relative paths so Astro bundles them. Theme CSS can reference `url(assets/name.webp)`; ACMS resolves it through the bundled asset inventory. Brand images and editable section images use the media library. Packaged assets must be local files under assets; declare them in the manifest. Use `theme pack --optimize` to run current img-subir and rewrite references before packing. Original-image backup directories are excluded.
|
|
62
|
+
|
|
63
|
+
## Styling compatibility
|
|
64
|
+
|
|
65
|
+
The theme API version covers documented template/section contracts and public components. CSS variables emitted from the token schema are the supported customization surface. Arbitrary internal DOM selectors are not a stable API. Public theme v2 changed `.site-header-in`, `.site-tools`, and the position of share buttons in the byline. `header: minimal` is the closest old-header behavior. Legacy token JSON loading does not guarantee old CSS matches new markup. Recheck custom selectors after a core upgrade.
|
|
66
|
+
|
|
67
|
+
## Local previews and checks
|
|
68
|
+
|
|
69
|
+
`theme dev` starts a loopback Astro server in a new `.acms/previews/` workspace, with an isolated SQLite database, generated local secrets and linked theme sources for live reload. Interactive terminal sessions open `/acms-theme-preview` automatically; otherwise open the printed URL. Initialize synthetic content from its controls. It includes home/page/post/event, archive pagination, long titles, missing covers, calendar, search and 404; empty booking surfaces remain empty. Use the fixture device and scheme controls. No production config or credentials are copied. Theme code is application code and runs in your local process; only run themes you trust. Stop with Ctrl-C. Remove old preview folders when no longer needed.
|
|
70
|
+
|
|
71
|
+
`theme check` validates the manifest, compatibility range, declared files, supported file types, imports and asset references, and reports token contrast estimates. `--build` compiles an isolated consumer with both Node and Cloudflare adapters. Contrast results are not an accessibility certification; review actual custom templates at narrow/wide viewports, keyboard focus and light/dark palettes. A browser-only hover or interaction cannot be proven by a build.
|
|
72
|
+
|
|
73
|
+
`theme diff` compares against the creation snapshot; `--baseline <folder>` selects another version. Runtime settings/navigation differences are available in `design pull`.
|
|
74
|
+
|
|
75
|
+
## Sharing, versions and rollback
|
|
76
|
+
|
|
77
|
+
`theme pack` produces a deterministic theme-only tgz. `theme install` validates the archive before writing, rejects traversal/links/unexpected files/incompatible core versions, and never runs install scripts. Use `--force` for intentional replacement: the previous directory is retained in `.acms/theme-backups/`. `.acms/themes.lock.json` records the pinned version, digest and backup path. Copy a retained backup to a new theme identity or re-pack/install it to roll code back; rebuild and redeploy. No automatic download of theme dependencies occurs.
|
|
78
|
+
|
|
79
|
+
Deploy new code under a distinct theme identity before selecting it. Editing the active theme's deployed CSS/templates changes live presentation regardless of a settings draft. Design history stores the last 20 outgoing settings/navigation/section snapshots, not asset bytes or compiled code. Restoring a history version stages a draft; publish is separate. Old snapshots without section fields preserve current layouts.
|
|
80
|
+
|
|
81
|
+
## Remote operations
|
|
82
|
+
|
|
83
|
+
Owners create scoped credentials under Design → Access. A key has design:read, design:write-draft, design:publish, media:read and/or media:write. Publish is off by default. Legacy content-agent keys retain content access only and cannot access the design API. The read-only `/api/design/capabilities` probe accepts any explicit design/media scope, so a media-only profile can connect without gaining design read access.
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
# Set ACMS_TOKEN using your shell/secret manager; do not put it in command arguments.
|
|
87
|
+
acms site connect https://your-site.example --site production
|
|
88
|
+
acms design pull --site production --output draft.json
|
|
89
|
+
# Edit patch values in draft.json; keep its revision.
|
|
90
|
+
acms design push --site production --draft --file draft.json
|
|
91
|
+
acms design pull --site production
|
|
92
|
+
acms design publish --site production --revision 12
|
|
93
|
+
acms design history --site production
|
|
94
|
+
acms design rollback VERSION_ID --site production --revision 13
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Use the actual revision returned by your site, not the illustrative numbers above. All remote design writes compare revisions; conflict exits 4 instead of clobbering another editor. Restore and push only stage drafts. Share/revoke require draft-write scope and a revision; reviewer URLs grant no mutation permission and do not reveal unpublished content. Treat a share URL as a credential.
|
|
98
|
+
|
|
99
|
+
Profiles under `~/.config/acms/sites.json` store only URL and token environment-variable name, with restricted permissions. Set ACMS_CONFIG_DIR for an alternate location. Use `--token-env VARIABLE` to choose another token. Tokens are read at invocation and are never written into a project, logs or profile. HTTPS is required except loopback development sites.
|
|
100
|
+
|
|
101
|
+
`content capabilities` discovers the existing content-agent schema; content list/get/create/update/publish use its existing key and API contract with JSON arguments from --file or stdin. Connect a separate profile/token variable when content and design use separate credentials. `media list` and `media upload <image>` use explicit media scopes. Upload runs current img-subir before sending the resulting PNG/JPEG/WebP/GIF; the API caps images at 10 MB. No automatic retry of writes after ambiguous network failure.
|
|
102
|
+
|
|
103
|
+
Every command accepts --json and --help. JSON uses schema acms.cli/1 with ok/data or code/message/details/hints. stdout carries only the result; progress goes to stderr. --dry-run previews mutations. --cwd selects a project. --yes never expands permissions or bypasses compatibility. Exit codes: 0 success, 1 maintenance/check signal, 2 usage/validation, 3 auth, 4 conflict, 5 network/server failure.
|
package/email-runner.mjs
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Independent Node scheduler: reminders must not depend on page visitors.
|
|
3
|
+
import { setTimeout as sleep } from 'node:timers/promises';
|
|
4
|
+
import { writeFile } from 'node:fs/promises';
|
|
5
|
+
const base = process.env.EMAIL_RUNNER_URL || process.env.SITE_URL;
|
|
6
|
+
const secret = process.env.EMAIL_RUNNER_SECRET;
|
|
7
|
+
if (!base || !secret || secret.length < 32) {
|
|
8
|
+
console.error('Set EMAIL_RUNNER_URL (site origin) and a 32+ character EMAIL_RUNNER_SECRET.');
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
const url = new URL('/api/email/run', base);
|
|
12
|
+
if (url.protocol !== 'https:' && !['localhost','127.0.0.1','[::1]'].includes(url.hostname)) {
|
|
13
|
+
console.error('The runner requires HTTPS outside localhost.'); process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
let stopping = false;
|
|
17
|
+
process.on('SIGINT', () => { stopping = true; controller.abort(); });
|
|
18
|
+
process.on('SIGTERM', () => { stopping = true; controller.abort(); });
|
|
19
|
+
do {
|
|
20
|
+
const started = Date.now();
|
|
21
|
+
try {
|
|
22
|
+
const result = await fetch(url, { method: 'POST', headers: { authorization: `Bearer ${secret}`, 'Content-Type': 'application/json', Origin: url.origin }, signal: AbortSignal.any([AbortSignal.timeout(150000), controller.signal]), redirect: 'error' });
|
|
23
|
+
await result.body?.cancel();
|
|
24
|
+
if (!result.ok) throw new Error(`Runner endpoint returned HTTP ${result.status}`);
|
|
25
|
+
if (process.env.SCHEDULER_HEARTBEAT_FILE) await writeFile(process.env.SCHEDULER_HEARTBEAT_FILE, String(Date.now()));
|
|
26
|
+
} catch (error) {
|
|
27
|
+
console.error(new Date().toISOString(), error instanceof Error ? error.message : 'Email runner failed');
|
|
28
|
+
if (process.argv.includes('--once')) process.exitCode = 1;
|
|
29
|
+
}
|
|
30
|
+
if (stopping || process.argv.includes('--once')) break;
|
|
31
|
+
await sleep(Math.max(1000, 60000 - (Date.now() - started)), undefined, { signal: controller.signal }).catch(() => {});
|
|
32
|
+
} while (!stopping);
|
package/integration.mjs
CHANGED
|
@@ -25,6 +25,7 @@ export const workerEntrypoint = entry('./src/worker.ts');
|
|
|
25
25
|
const ROUTES = [
|
|
26
26
|
// public site (post + tag routes are built from config in acms() below)
|
|
27
27
|
['/', 'index.astro'],
|
|
28
|
+
['/404', '404.astro'],
|
|
28
29
|
['/[...slug]', '[...slug].astro'],
|
|
29
30
|
['/author/[slug]', 'author/[slug].astro'],
|
|
30
31
|
['/links', 'links.astro'],
|
|
@@ -53,11 +54,36 @@ const ROUTES = [
|
|
|
53
54
|
['/admin/posts', 'admin/posts/index.astro'],
|
|
54
55
|
['/admin/posts/[id]', 'admin/posts/[id].astro'],
|
|
55
56
|
['/admin/settings', 'admin/settings.astro'],
|
|
57
|
+
['/admin/integrations', 'admin/integrations.astro'],
|
|
58
|
+
['/admin/live', 'admin/live.astro'],
|
|
59
|
+
['/api/live/settings', 'api/live/settings.ts'],
|
|
60
|
+
['/api/live/status', 'api/live/status.ts'],
|
|
61
|
+
['/api/live/thumb', 'api/live/thumb.ts'],
|
|
62
|
+
['/api/live/oauth/youtube/callback', 'api/live/oauth/youtube/callback.ts'],
|
|
63
|
+
['/api/webhooks/docs', 'api/webhooks/docs.ts'],
|
|
64
|
+
['/admin/email', 'admin/email.astro'],
|
|
65
|
+
['/account/[action]', 'account/[action].astro'],
|
|
66
|
+
['/api/email/settings', 'api/email/settings.ts'],
|
|
67
|
+
['/api/email/webhooks/[provider]', 'api/email/webhooks/[provider].ts'],
|
|
68
|
+
['/api/email/run', 'api/email/run.ts'],
|
|
69
|
+
['/api/ops/status', 'api/ops/status.ts'],
|
|
70
|
+
['/admin/ai', 'admin/ai.astro'],
|
|
56
71
|
['/admin/tags', 'admin/tags.astro'],
|
|
57
72
|
['/admin/users', 'admin/users.astro'],
|
|
58
73
|
// API
|
|
59
74
|
['/api/backup', 'api/backup.ts'],
|
|
60
75
|
['/api/drafts', 'api/drafts.ts'],
|
|
76
|
+
['/api/ai/settings', 'api/ai/settings.ts'],
|
|
77
|
+
['/api/ai/test', 'api/ai/test.ts'],
|
|
78
|
+
['/api/ai/clean', 'api/ai/clean.ts'],
|
|
79
|
+
['/api/ai/draft', 'api/ai/draft.ts'],
|
|
80
|
+
['/api/design', 'api/design/index.ts'],
|
|
81
|
+
['/api/design/keys', 'api/design/keys.ts'],
|
|
82
|
+
['/api/design/capabilities', 'api/design/capabilities.ts'],
|
|
83
|
+
['/api/design/media', 'api/design/media.ts'],
|
|
84
|
+
['/api/agent', 'api/agent/index.ts'],
|
|
85
|
+
['/api/agent/docs', 'api/agent/docs.ts'],
|
|
86
|
+
['/api/mcp', 'api/mcp.ts'],
|
|
61
87
|
['/api/media/upload', 'api/media/upload.ts'],
|
|
62
88
|
['/api/restore', 'api/restore.ts'],
|
|
63
89
|
];
|
|
@@ -65,7 +91,7 @@ const ROUTES = [
|
|
|
65
91
|
// First segments of the fixed routes above — a configured base landing on one
|
|
66
92
|
// of these would shadow a real page, so it's a build-time error.
|
|
67
93
|
const RESERVED_BASES = new Set([
|
|
68
|
-
'/admin', '/api', '/author', '/embed', '/go', '/links', '/media', '/preview', '/search',
|
|
94
|
+
'/admin', '/api', '/author', '/embed', '/go', '/links', '/media', '/preview', '/search', '/privacy', '/terms',
|
|
69
95
|
]);
|
|
70
96
|
|
|
71
97
|
// Collection keys that would collide with admin sections or read ambiguously.
|
|
@@ -199,6 +225,97 @@ function resolveSearch(search) {
|
|
|
199
225
|
return { enabled };
|
|
200
226
|
}
|
|
201
227
|
|
|
228
|
+
// Defaults + validation for acms({ calendar }). The calendar module ships on:
|
|
229
|
+
// a main calendar page at `base`, event pages under `eventsBase`, ICS feeds,
|
|
230
|
+
// and the admin Calendar section. `calendar: false` (or enabled: false)
|
|
231
|
+
// removes every calendar route and hides the admin section; events stay in
|
|
232
|
+
// the database. Bases follow the same rules as collection bases.
|
|
233
|
+
const EVENT_FIELD_TYPES = ['text', 'textarea', 'number', 'url', 'select'];
|
|
234
|
+
function resolveCalendar(calendar, contentBases, locales) {
|
|
235
|
+
const defaults = {
|
|
236
|
+
enabled: true, base: '/calendar', eventsBase: '/events', bookings: true, bookBase: '/book',
|
|
237
|
+
resourceKinds: ['Room', 'Person', 'Vehicle', 'Equipment'], defaultView: 'month', eventFields: [],
|
|
238
|
+
};
|
|
239
|
+
if (calendar === false) return { ...defaults, enabled: false };
|
|
240
|
+
if (calendar === undefined || calendar === true) calendar = {};
|
|
241
|
+
if (typeof calendar !== 'object' || calendar === null || Array.isArray(calendar)) {
|
|
242
|
+
throw new Error('acms: calendar must be an object (or false to disable the calendar module)');
|
|
243
|
+
}
|
|
244
|
+
const known = ['enabled', 'base', 'eventsBase', 'bookings', 'bookBase', 'resourceKinds', 'defaultView', 'eventFields'];
|
|
245
|
+
const unknown = Object.keys(calendar).filter((k) => !known.includes(k));
|
|
246
|
+
if (unknown.length > 0) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`acms: unknown calendar key${unknown.length > 1 ? 's' : ''} ${unknown.map((k) => `"${k}"`).join(', ')} — ` +
|
|
249
|
+
`valid keys are ${known.map((k) => `calendar.${k}`).join(', ')}`
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
const enabled = calendar.enabled ?? true;
|
|
253
|
+
if (typeof enabled !== 'boolean') throw new Error('acms: calendar.enabled must be true or false');
|
|
254
|
+
const resolved = {
|
|
255
|
+
enabled,
|
|
256
|
+
base: calendar.base ?? defaults.base,
|
|
257
|
+
eventsBase: calendar.eventsBase ?? defaults.eventsBase,
|
|
258
|
+
bookings: calendar.bookings ?? true,
|
|
259
|
+
bookBase: calendar.bookBase ?? defaults.bookBase,
|
|
260
|
+
resourceKinds: calendar.resourceKinds ?? defaults.resourceKinds,
|
|
261
|
+
defaultView: calendar.defaultView ?? defaults.defaultView,
|
|
262
|
+
eventFields: [],
|
|
263
|
+
};
|
|
264
|
+
if (typeof resolved.bookings !== 'boolean') throw new Error('acms: calendar.bookings must be true or false');
|
|
265
|
+
if (!Array.isArray(resolved.resourceKinds) || !resolved.resourceKinds.length || resolved.resourceKinds.some((k) => typeof k !== 'string' || !k.trim())) {
|
|
266
|
+
throw new Error('acms: calendar.resourceKinds must be a non-empty array of labels, e.g. ["Room", "Person"]');
|
|
267
|
+
}
|
|
268
|
+
if (!['month', 'week', 'list'].includes(resolved.defaultView)) {
|
|
269
|
+
throw new Error(`acms: calendar.defaultView must be "month", "week", or "list" — got "${resolved.defaultView}"`);
|
|
270
|
+
}
|
|
271
|
+
const bases = [['calendar.base', resolved.base], ['calendar.eventsBase', resolved.eventsBase], ['calendar.bookBase', resolved.bookBase]];
|
|
272
|
+
for (const [label, base] of bases) {
|
|
273
|
+
if (typeof base !== 'string' || !/^\/[a-z0-9-]+(\/[a-z0-9-]+)*$/.test(base)) {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`acms: ${label} must start with "/" and use lowercase letters, digits, and hyphens (no trailing slash) — got "${base}"`
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if (RESERVED_BASES.has(base)) throw new Error(`acms: ${label} "${base}" collides with a built-in route — pick another path`);
|
|
279
|
+
if (enabled && contentBases.has(base)) {
|
|
280
|
+
throw new Error(`acms: ${label} "${base}" collides with ${contentBases.get(base)} — pick another path for one of them`);
|
|
281
|
+
}
|
|
282
|
+
if (enabled && locales.some((l) => `/${l}` === base)) {
|
|
283
|
+
throw new Error(`acms: ${label} "${base}" collides with the i18n locale prefix "${base}"`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (new Set(bases.map(([, b]) => b)).size !== bases.length) {
|
|
287
|
+
throw new Error('acms: calendar.base, calendar.eventsBase and calendar.bookBase must be different paths');
|
|
288
|
+
}
|
|
289
|
+
const fields = calendar.eventFields ?? [];
|
|
290
|
+
if (!Array.isArray(fields)) throw new Error('acms: calendar.eventFields must be an array of { key, label, type } objects');
|
|
291
|
+
const keys = new Set();
|
|
292
|
+
for (const [i, f] of fields.entries()) {
|
|
293
|
+
if (!f || typeof f !== 'object') throw new Error(`acms: calendar.eventFields[${i}] must be an object`);
|
|
294
|
+
const key = f.key;
|
|
295
|
+
if (typeof key !== 'string' || !/^[a-z][a-z0-9_]*$/.test(key)) {
|
|
296
|
+
throw new Error(`acms: calendar.eventFields[${i}].key must be lowercase letters, digits, and underscores, starting with a letter — got "${key}"`);
|
|
297
|
+
}
|
|
298
|
+
if (keys.has(key)) throw new Error(`acms: calendar.eventFields has two fields with the key "${key}"`);
|
|
299
|
+
keys.add(key);
|
|
300
|
+
const type = f.type ?? 'text';
|
|
301
|
+
if (!EVENT_FIELD_TYPES.includes(type)) {
|
|
302
|
+
throw new Error(`acms: calendar.eventFields "${key}" has type "${type}" — use one of ${EVENT_FIELD_TYPES.join(', ')}`);
|
|
303
|
+
}
|
|
304
|
+
const options = f.options ?? [];
|
|
305
|
+
if (!Array.isArray(options) || options.some((o) => typeof o !== 'string')) {
|
|
306
|
+
throw new Error(`acms: calendar.eventFields "${key}".options must be an array of strings`);
|
|
307
|
+
}
|
|
308
|
+
if (type === 'select' && options.length === 0) {
|
|
309
|
+
throw new Error(`acms: calendar.eventFields "${key}" is a select field — give it an options array`);
|
|
310
|
+
}
|
|
311
|
+
const label = f.label ?? key.charAt(0).toUpperCase() + key.slice(1).replace(/_/g, ' ');
|
|
312
|
+
if (typeof label !== 'string') throw new Error(`acms: calendar.eventFields "${key}".label must be a string`);
|
|
313
|
+
resolved.eventFields.push({ key, label, type, options });
|
|
314
|
+
}
|
|
315
|
+
if (resolved.eventFields.length > 30) throw new Error('acms: calendar.eventFields allows at most 30 fields');
|
|
316
|
+
return resolved;
|
|
317
|
+
}
|
|
318
|
+
|
|
202
319
|
const semverLt = (a, b) => {
|
|
203
320
|
const pa = a.split('.').map(Number), pb = b.split('.').map(Number);
|
|
204
321
|
for (let i = 0; i < 3; i++) { if ((pa[i] ?? 0) < (pb[i] ?? 0)) return true; if ((pa[i] ?? 0) > (pb[i] ?? 0)) return false; }
|
|
@@ -236,11 +353,14 @@ const THEMES_ID = 'virtual:acms-user-themes';
|
|
|
236
353
|
// they resolve against the consumer's src/ — the core's relative globs can't
|
|
237
354
|
// see outside the package. Same folder contract as built-in themes.
|
|
238
355
|
const USER_THEMES_CODE = `
|
|
356
|
+
export const assets = import.meta.glob(['/src/themes/*/assets/**/*', '!**/.img-subir-originals/**'], { eager: true, query: '?url', import: 'default' });
|
|
357
|
+
export const sectionTpls = import.meta.glob('/src/themes/*/sections/*.astro', { eager: true });
|
|
239
358
|
export const jsons = import.meta.glob('/src/themes/*/theme.json', { eager: true, import: 'default' });
|
|
240
359
|
export const csses = import.meta.glob('/src/themes/*/theme.css', { eager: true, query: '?raw', import: 'default' });
|
|
241
360
|
export const homes = import.meta.glob('/src/themes/*/templates/home.astro', { eager: true });
|
|
242
361
|
export const postTpls = import.meta.glob('/src/themes/*/templates/post-*.astro', { eager: true });
|
|
243
362
|
export const pageTpls = import.meta.glob('/src/themes/*/templates/page-*.astro', { eager: true });
|
|
363
|
+
export const eventTpls = import.meta.glob('/src/themes/*/templates/event.astro', { eager: true });
|
|
244
364
|
`;
|
|
245
365
|
|
|
246
366
|
function acmsVitePlugin(userConfig) {
|
|
@@ -284,6 +404,15 @@ function acmsVitePlugin(userConfig) {
|
|
|
284
404
|
* Public site search: the /search page, the /api/search JSON endpoint, and
|
|
285
405
|
* the header search modal. On by default — set `enabled: false` to remove
|
|
286
406
|
* all three.
|
|
407
|
+
* @param {false | {enabled?: boolean, base?: string, eventsBase?: string, defaultView?: 'month' | 'week' | 'list', eventFields?: Array<{key: string, label?: string, type?: 'text' | 'textarea' | 'number' | 'url' | 'select', options?: string[]}>}} [options.calendar]
|
|
408
|
+
* Calendar module: the main calendar page at `base` (default '/calendar')
|
|
409
|
+
* with subscribe feeds (`<base>.ics`, `<base>/<slug>.ics`), event pages at
|
|
410
|
+
* `eventsBase` (default '/events'), a public JSON API under /api/calendar,
|
|
411
|
+
* embeds, and the admin Calendar section. `eventFields` adds custom fields
|
|
412
|
+
* to every event (edited in the event sidebar, exposed to templates and the
|
|
413
|
+
* API). On by default — `calendar: false` removes all of it.
|
|
414
|
+
* @param {false | {enabled?: boolean, operatorName?: string, contactEmail?: string, updatedAt?: string}} [options.legal]
|
|
415
|
+
* Default privacy/terms routes and footer links; published CMS pages at those slugs override the text.
|
|
287
416
|
* @returns {import('astro').AstroIntegration}
|
|
288
417
|
*/
|
|
289
418
|
export default function acms(options = {}) {
|
|
@@ -294,6 +423,11 @@ export default function acms(options = {}) {
|
|
|
294
423
|
]);
|
|
295
424
|
const i18n = resolveI18n(options.i18n, contentBases);
|
|
296
425
|
const search = resolveSearch(options.search);
|
|
426
|
+
const calendar = resolveCalendar(options.calendar, contentBases, i18n.locales);
|
|
427
|
+
const legal = { enabled: true, operatorName: '', contactEmail: '', updatedAt: '', ...(options.legal === false ? { enabled: false } : options.legal ?? {}) };
|
|
428
|
+
if (typeof legal.enabled !== 'boolean' || ['operatorName','contactEmail','updatedAt'].some(k => typeof legal[k] !== 'string')) throw new Error('acms: invalid legal configuration');
|
|
429
|
+
if (legal.contactEmail && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(legal.contactEmail)) throw new Error('acms: legal.contactEmail must be an email address');
|
|
430
|
+
if (legal.updatedAt && (!/^\d{4}-\d{2}-\d{2}$/.test(legal.updatedAt) || !Number.isFinite(Date.parse(legal.updatedAt)) || new Date(legal.updatedAt).toISOString().slice(0,10) !== legal.updatedAt)) throw new Error('acms: legal.updatedAt must be a valid YYYY-MM-DD date');
|
|
297
431
|
const userConfig = {
|
|
298
432
|
coreVersion: CORE_VERSION,
|
|
299
433
|
brand: options.brand ?? {},
|
|
@@ -301,8 +435,14 @@ export default function acms(options = {}) {
|
|
|
301
435
|
content,
|
|
302
436
|
i18n,
|
|
303
437
|
search,
|
|
438
|
+
calendar,
|
|
439
|
+
legal,
|
|
304
440
|
};
|
|
305
|
-
const routes = [...ROUTES
|
|
441
|
+
const routes = [...ROUTES,
|
|
442
|
+
['/api/v1/content', 'api/v1/content/index.ts'],
|
|
443
|
+
['/api/v1/content/[id]', 'api/v1/content/[id].ts'],
|
|
444
|
+
];
|
|
445
|
+
if (legal.enabled) routes.push(['/privacy', 'legal/privacy.astro'], ['/terms', 'legal/terms.astro']);
|
|
306
446
|
for (const c of Object.values(content.collections)) {
|
|
307
447
|
routes.push([c.base, 'blog/index.astro'], [`${c.base}/[slug]`, 'blog/[slug].astro']);
|
|
308
448
|
}
|
|
@@ -320,12 +460,59 @@ export default function acms(options = {}) {
|
|
|
320
460
|
if (l !== i18n.defaultLocale) routes.push([`/${l}/search`, 'search.astro']);
|
|
321
461
|
}
|
|
322
462
|
}
|
|
463
|
+
if (calendar.enabled) {
|
|
464
|
+
routes.push(
|
|
465
|
+
[calendar.base, 'calendar/index.astro'],
|
|
466
|
+
[`${calendar.base}.ics`, 'calendar/feed.ics.ts'],
|
|
467
|
+
[`${calendar.base}/[slug].ics`, 'calendar/[slug].ics.ts'],
|
|
468
|
+
[`${calendar.eventsBase}/[slug]`, 'events/[slug].astro'],
|
|
469
|
+
[`${calendar.eventsBase}/[slug].ics`, 'events/[slug].ics.ts'],
|
|
470
|
+
['/embed/calendar/[slug]', 'embed/calendar/[slug].astro'],
|
|
471
|
+
['/api/v1/events', 'api/v1/events.ts'],
|
|
472
|
+
['/api/v1/calendars', 'api/v1/calendars.ts'],
|
|
473
|
+
['/api/calendar/events', 'api/calendar/events/index.ts'],
|
|
474
|
+
['/api/calendar/calendars', 'api/calendar/calendars.ts'],
|
|
475
|
+
['/admin/calendar', 'admin/calendar/index.ts'],
|
|
476
|
+
['/admin/calendar/events', 'admin/calendar/events/index.astro'],
|
|
477
|
+
['/admin/calendar/events/[id]', 'admin/calendar/events/[id].astro'],
|
|
478
|
+
['/admin/calendar/calendars', 'admin/calendar/calendars.astro'],
|
|
479
|
+
['/admin/calendar/integrations', 'admin/calendar/integrations.astro'],
|
|
480
|
+
['/api/calendar/docs', 'api/calendar/docs.ts'],
|
|
481
|
+
);
|
|
482
|
+
if (calendar.bookings) {
|
|
483
|
+
routes.push(
|
|
484
|
+
[calendar.bookBase, 'book/index.astro'],
|
|
485
|
+
[`${calendar.bookBase}/manage/[token]`, 'book/manage/[token].astro'],
|
|
486
|
+
[`${calendar.bookBase}/[resource].ics`, 'book/[resource].ics.ts'],
|
|
487
|
+
[`${calendar.bookBase}/[resource]`, 'book/[resource]/index.astro'],
|
|
488
|
+
[`${calendar.bookBase}/[resource]/[link]`, 'book/[resource]/[link].astro'],
|
|
489
|
+
['/embed/book/[resource]/[link]', 'embed/book/[resource]/[link].astro'],
|
|
490
|
+
['/api/v1/resources', 'api/v1/resources.ts'],
|
|
491
|
+
['/api/v1/availability', 'api/v1/availability.ts'],
|
|
492
|
+
['/api/v1/bookings', 'api/v1/bookings.ts'],
|
|
493
|
+
['/api/calendar/availability', 'api/calendar/availability.ts'],
|
|
494
|
+
['/api/calendar/bookings', 'api/calendar/bookings/index.ts'],
|
|
495
|
+
['/api/calendar/bookings/[id]', 'api/calendar/bookings/[id].ts'],
|
|
496
|
+
['/api/calendar/resources', 'api/calendar/resources.ts'],
|
|
497
|
+
['/admin/calendar/bookings', 'admin/calendar/bookings.astro'],
|
|
498
|
+
['/admin/calendar/resources', 'admin/calendar/resources/index.astro'],
|
|
499
|
+
['/admin/calendar/resources/[id]', 'admin/calendar/resources/[id].astro'],
|
|
500
|
+
['/admin/calendar/resources/[id]/links/[link]', 'admin/calendar/resources/link.astro'],
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
routes.push(['/api/calendar/events/[id]', 'api/calendar/events/[id].ts']);
|
|
504
|
+
}
|
|
323
505
|
let cacheDir = '';
|
|
324
506
|
return {
|
|
325
507
|
name: 'acms',
|
|
326
508
|
hooks: {
|
|
327
509
|
'astro:config:setup': ({ config, command, injectRoute, addMiddleware, updateConfig }) => {
|
|
328
510
|
if (config.cacheDir) cacheDir = fileURLToPath(config.cacheDir);
|
|
511
|
+
if (config.root) {
|
|
512
|
+
const discoveryDir = fileURLToPath(new URL('.astro/', config.root));
|
|
513
|
+
mkdirSync(discoveryDir, {recursive:true});
|
|
514
|
+
writeFileSync(join(discoveryDir,'acms-project.json'), JSON.stringify({ themeApiVersion:1, collections:userConfig.content.collections, calendar:userConfig.calendar }));
|
|
515
|
+
}
|
|
329
516
|
updateConfig({ vite: { plugins: [acmsVitePlugin(userConfig)] } });
|
|
330
517
|
|
|
331
518
|
// "Project-local routes win" is enforced here: Astro registers
|
package/ops-check.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Never accepts a credential in an argument or prints a response body.
|
|
3
|
+
const base = process.env.ACMS_SITE_URL;
|
|
4
|
+
const secret = process.env.EMAIL_RUNNER_SECRET;
|
|
5
|
+
if (!base || !secret || secret.length < 32) {
|
|
6
|
+
console.error('Set ACMS_SITE_URL and EMAIL_RUNNER_SECRET in your environment. Optional: --storage-check'); process.exit(1);
|
|
7
|
+
}
|
|
8
|
+
const origin = new URL(base);
|
|
9
|
+
if (origin.username || origin.password || (origin.protocol !== 'https:' && !(origin.protocol === 'http:' && ['localhost','127.0.0.1','[::1]'].includes(origin.hostname)))) {
|
|
10
|
+
console.error('Use HTTPS, or HTTP on localhost for local testing.'); process.exit(1);
|
|
11
|
+
}
|
|
12
|
+
let failed = false;
|
|
13
|
+
async function check(label, path, options = {}, predicate = r => r.ok) {
|
|
14
|
+
try {
|
|
15
|
+
const response = await fetch(new URL(path, origin), { ...options, redirect: 'error', signal: AbortSignal.timeout(15000) });
|
|
16
|
+
const ok = await predicate(response);
|
|
17
|
+
await response.body?.cancel().catch(() => {});
|
|
18
|
+
console.log(`${ok ? 'PASS' : 'FAIL'} ${label}`); if (!ok) failed = true;
|
|
19
|
+
} catch { console.log(`FAIL ${label}`); failed = true; }
|
|
20
|
+
}
|
|
21
|
+
await check('public content API', '/api/v1/content?limit=1', {}, async r => r.ok && (await r.json()).version === 1);
|
|
22
|
+
await check('privacy page', '/privacy');
|
|
23
|
+
await check('terms page', '/terms');
|
|
24
|
+
const headers = { authorization: `Bearer ${secret}`, 'Content-Type': 'application/json', Origin: origin.origin };
|
|
25
|
+
await check('database, HTTPS settings, secrets and scheduler heartbeat', '/api/ops/status', { headers }, async r => r.ok && (await r.json()).ok === true);
|
|
26
|
+
if (process.argv.includes('--storage-check')) await check('media write/read/delete probe', '/api/ops/status', { method: 'POST', headers }, async r => r.ok && (await r.json()).ok === true);
|
|
27
|
+
process.exitCode = failed ? 1 : 0;
|