@acms-subir/core 0.3.27 → 0.3.29

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 CHANGED
@@ -1,5 +1,25 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.29
4
+
5
+ ### Patch Changes
6
+
7
+ - da0382f: Apps can show live video. `GET /api/v1/live` returns the snapshot the website's live placements use: `snapshot` (or `null`), `serverNow`, and the owner's "How it looks" choices as `display` (`style`, `bar`, `header`, `dock`), so an app can follow the website's live bar and header switches. The thumbnail URL is absolute. The route is `no-store` with no ETag, so a shared cache never serves a stale "live", and it has the same CORS and read limit as the other v1 routes. While live video is off it answers 404, and `/api/v1/site` reports `features.live: false`. `/api/v1/theme` adds `live` to both palettes: the website's own `--live` color for that scheme, readable on its background. The OpenAPI document describes `/live` with a `LiveStatus` schema.
8
+
9
+ "We're live" push notifications, with push on. **Mobile app → Automatic notifications** has a new rule, **We go live**, off by default. When live video detects a broadcast, phones on the new `live` topic get "We're live" in their language (English, Spanish, French, German or Portuguese) with the broadcast title, and the tap opens the stream. Each broadcast is announced once, keyed `live:<provider>:<id>` in the push ledger, so a stream that drops and comes back, or a server restart, never sends it twice. A broadcast first seen more than 20 minutes after it started isn't announced, and neither is a second one within 3 hours (a stream restarted as a new broadcast). On **Integrations → Live video**, a manual link gets a **Notify app users** checkbox. It sends once per link, and a YouTube video that was already announced isn't announced again.
10
+
11
+ `/api/v1/push/topics` lists `live` ("Live video") while live video isn't off, and devices may keep it while it's off. The topic only reaches devices that choose it. Turning the rule on, or upgrading, never subscribes devices that registered before, as App Store guidelines 4.5.3 and 4.5.4 and Mexico's data protection law expect. Apps should offer it with a one-time prompt. `acms mobile doctor` counts a Live video section as native with `@acms-subir/native` 0.1.5 or later.
12
+
13
+ ## 0.3.28
14
+
15
+ ### Patch Changes
16
+
17
+ - 9964864: Collections get a `localizeRedirects` option, so a site whose posts are each written in one language can turn off 0.3.27's "keep the visitor's language" for them. It sits next to `base` and `redirectFrom`: `acms({ content: { posts: { base: '/sermon', redirectFrom: ['/sermons'], localizeRedirects: false } } })`, or under `content.collections.<key>` for any other collection. It defaults to `true`, the 0.3.27 behaviour, because ACMS posts can carry translations (`/es/sermon/<slug>` shows a post's Spanish words), so other sites don't change: `/es/blog` → `/es/sermon`.
18
+
19
+ With `localizeRedirects: false`, a redirect whose target is one of that collection's addresses (its base or an entry under it) goes to the target without adding the language: `/es/blog` → `/sermon`, `/es/blog/x` → `/sermon/x`, `/es/sermons` (a `redirectFrom` base) → `/sermon`, as in 0.3.26. Admin → Redirects rules are treated the same way: on a `/es/…` request, a rule `/old` → `/sermon/x` goes to `/sermon/x`. Tag addresses have no setting of their own. Tag archives are shared by every collection, and they replaced the built-in collection's old `/blog/tag/…`, so they follow `posts`: with it off, `/es/blog/tag/x` → `/tag/x`, and so do rules pointing at `/tag/…`. Redirects to pages keep the language either way, and a rule written for the full `/es/old` address is still used exactly as written. On a site with more than one language, the Redirects card names the addresses that stay as written. The value must be `true` or `false`; anything else fails the build. The app API (`/api/v1/site`) doesn't list it: apps don't follow the website's language redirects.
20
+
21
+ The option only covers redirects. Whether `/es/<collection>` pages render, their hreflang tags, the sitemap, and links from Spanish pages to `/es/sermon/…` are unchanged.
22
+
3
23
  ## 0.3.27
4
24
 
5
25
  ### Patch Changes
@@ -298,7 +298,10 @@ async function doctorApp(app, flags, ctx) {
298
298
  const all = [...texts.values()].join('\n');
299
299
  const fallbackOff = !deps['react-native-webview'] || /webSectionFallback=\{\s*false\s*\}/.test(all);
300
300
  const partlyOff = /webFallback=\{\s*false\s*\}/.test(all);
301
- const web = [...used.keys()].filter((t) => !registry.has(t) && !library[t]).sort();
301
+ // built-in sections the native package renders itself (not in the section library), by the version that added them
302
+ const nativeAt = (min) => { const v = versions.native?.match(/^(\d+)\.(\d+)\.(\d+)/)?.slice(1).map(Number); const m = min.split('.').map(Number); return !!v && (v[0] - m[0] || v[1] - m[1] || v[2] - m[2]) >= 0; };
303
+ const builtIn = new Set(nativeAt('0.1.5') ? ['live'] : []);
304
+ const web = [...used.keys()].filter((t) => !registry.has(t) && !library[t] && !builtIn.has(t)).sort();
302
305
  const where = (t) => `${t} (${[...new Set(used.get(t))].join(', ')})`;
303
306
  const checked = pages.filter((p) => p.r.status === 200).map((p) => p.slug);
304
307
  if (!web.length) c.pass('sections', 'Section coverage', `All ${used.size} section types on ${checked.join(', ')} render natively.`);
@@ -68,6 +68,16 @@ All placements share the same status. Changing credentials or live mode takes ef
68
68
 
69
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
70
 
71
+ ## In the app
72
+
73
+ Apps built with `@acms-subir/native` show the same status (see [mobile](mobile.md) and [the API](../../../docs/mobile-api-v1.md#live-video)):
74
+
75
+ - **Status.** `GET /api/v1/live` returns the public snapshot with an absolute thumbnail URL, the server's clock and the owner's **How it looks** choices (`style`, `bar`, `header`, `dock`). It is never cached and answers 404 while live video is **Off**; `site.features.live` tells apps whether to ask. App requests read the stored snapshot, so more phones don't use more YouTube or Twitch quota.
76
+ - **Hook and card.** `useLive()` checks every 30 seconds while the app is in the foreground and again when it comes back. It drops an expired "live" on time even without a connection, using the same rule as the website. `<LiveCard>` renders the poster, split or row layout (the owner's card style unless the app picks one) with the theme's live color (`/api/v1/theme` → `colors.light.live` / `colors.dark.live`). Taps open the broadcast in YouTube, Twitch or the browser. A **Live video** section on a page renders natively; there is no in-app player.
77
+ - **Signals.** The website's **live bar**, **header marker** and **floating card** switches arrive as `display.bar`, `display.header` and `display.dock`, so an app's own bar or tab badge can follow them.
78
+ - **"We're live" notifications** (with push on, `acms({ mobile: { push: true } })`). In **Mobile app → Automatic notifications**, turn on **We go live**. When live video detects a broadcast, phones that turned on the **Live video** topic get "We're live" (in their language) with the broadcast title. Tapping it opens the stream. It is sent once per broadcast, never again when the stream flickers or the server restarts. It is skipped if the broadcast was first seen more than 20 minutes after it started (for example, a connection turned on mid-service), and if another live notice went out in the last 3 hours (a stream restarted as a new broadcast). A **manual** link notifies only when you tick **Notify app users** while saving it, once per link. A YouTube video already announced isn't announced again.
79
+ - **Consent.** The `live` topic reaches only phones that chose it. Turning the feature on never subscribes people who installed the app earlier. The app should ask them once, which App Store rules (4.5.3, 4.5.4) and Mexico's data protection law expect.
80
+
71
81
  ## Manual, off, fallback and outages
72
82
 
73
83
  **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.
package/docs/mobile.md CHANGED
@@ -113,7 +113,7 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
113
113
 
114
114
  | Hook | For |
115
115
  | --- | --- |
116
- | `useSite()` | name, menu, languages, features (`calendar`, `bookings`, `blog`, `search`, `bookingChanges`, `guestSignIn`, `forms`, `links`) |
116
+ | `useSite()` | name, menu, languages, features (`calendar`, `bookings`, `blog`, `search`, `bookingChanges`, `guestSignIn`, `forms`, `links`, `live`) |
117
117
  | `usePage(slug)` | a published page: `mode` (`sections`/`content`), `sections`, `body.html`; 404 when missing |
118
118
  | `usePosts({ collection, limit, q })` / `usePost(slug)` | posts (infinite: `items`, `fetchNextPage`); `q` gives ranked search matches |
119
119
  | `useSearch(text, { types, collection, limit })` | site-wide search: `data.posts`, `data.pages`, `data.events` (each with `snippet.text` + `snippet.highlights`), `data.more`; debounced, blank text fetches nothing |
@@ -122,13 +122,14 @@ Read the app's `AGENTS.md` too: it holds the same rules, next to the code.
122
122
  | `useManagedBooking(token)` / `useCancelBooking` / `useRescheduleBooking` / `useRescheduleAvailability` | the booker's own booking (from `book()`'s `manageToken`): `can.cancel`, `can.reschedule`, `can.closed` (change cutoff passed: show "Contact us") |
123
123
  | `useGuest()` / `useMyBookings({ when })` | guest sign-in with an emailed code, then every booking made with that email; check `features.guestSignIn` |
124
124
  | `useLinks()` / `useForm(id)` / `useSubmitForm(id)` | the links page; published forms and sending answers; check `features.links` / `features.forms` |
125
+ | `useLive()` | live video: `snapshot`, `status` (`live`/`upcoming`/`offline`), `isLive`, localized `view` text, the owner's `display` choices (`style`, `bar`, `header`, `dock`). Polls every 30 s only in the foreground, rechecks on resume, drops an expired "live" even offline; idle while `features.live` is false. Render `<LiveCard>` / `<LiveTag>` |
125
126
  | `useResources()` / `useAvailability({ resource, link })` / `useBooking()` | bookings |
126
127
  | `useLocale()`, `useTheme()`, `useLayout()`, `useRefresh()`, `useOpenLink()` | language, tokens, layout, refresh, links |
127
128
  | `usePushRegistration({ topics })` / `usePushTopics()` / `useNotificationTaps()` | push notifications (see Push notifications) |
128
129
  | `useAcms()` | the typed client (e.g. `calendars()`) for your own `useQuery` |
129
130
 
130
131
  Components: `<Sections>`, `<RichText html>`, `<EventDate start end allDay timezone>`,
131
- `<Button>`, `<ErrorView error retry>`, `<GenericSection>`. Full reference:
132
+ `<Button>`, `<ErrorView error retry>`, `<GenericSection>`, `<LiveCard layout>`, `<LiveTag>`. Full reference:
132
133
  `@acms-subir/native` README; API contract: the site's `docs/mobile-api-v1.md`
133
134
  and `<site>/api/v1/openapi.json`.
134
135
 
@@ -269,8 +270,8 @@ keyed by `locale`.
269
270
  ## Push notifications
270
271
 
271
272
  Optional; the owner sends them from **Admin → Mobile app** (manual messages
272
- plus automatic ones: new post, new event, "starting soon"). The app only
273
- registers and chooses topics; it never sends.
273
+ plus automatic ones: new post, new event, "starting soon", "we're live"). The
274
+ app only registers and chooses topics; it never sends.
274
275
 
275
276
  1. Site: `acms({ mobile: { push: true } })` in `astro.config.mjs` (off by
276
277
  default; `/api/v1/devices` and `/api/v1/push/topics` 404 until then).
@@ -291,6 +292,26 @@ reader's language when a translation exists. Test on a real device (simulators
291
292
  get no push); doctor checks the plugin, the project id and that the site has
292
293
  push on.
293
294
 
295
+ **Consent (App Store, US and Mexico).** Apple's guidelines (4.5.3, 4.5.4,
296
+ 5.1.2(i)) don't allow unsolicited push messages or requiring notifications, and
297
+ they need in-app consent and an in-app way to opt out of anything promotional.
298
+ Mexico's data law (LFPDPPP) also asks for free, specific, informed consent, so
299
+ a pre-ticked choice doesn't count. So:
300
+
301
+ - A new topic (for example `live`) never reaches existing devices by itself.
302
+ To offer it to people who installed the app before it existed, show a
303
+ one-time in-app prompt ("Get notified when we're live?") and call
304
+ `setTopics([...topics, 'live'])` only on yes. Remember that you asked
305
+ (AsyncStorage), and don't ask again.
306
+ - Starting topics for new installs (`usePushRegistration({ topics })`) are
307
+ fine once the person has allowed notifications from your explained prompt.
308
+ List the topics they'll get on that screen.
309
+ - Keep a Notifications screen with a switch per topic (`usePushTopics()`) and
310
+ an "Off" (`unregister()`). The app must work fully with notifications off.
311
+ - Describe push notifications in the app's privacy policy (aviso de privacidad
312
+ in Mexico) and App Privacy answers. The site stores only the Expo token,
313
+ platform, language and topics, with no name or email.
314
+
294
315
  ## Editor apps (staff sign-in)
295
316
 
296
317
  A second, separate Expo app for the people who run the site: owners and
package/integration.mjs CHANGED
@@ -161,6 +161,9 @@ function resolveContent(content = {}) {
161
161
  if (key !== 'posts' && RESERVED_COLLECTION_KEYS.has(key)) {
162
162
  throw new Error(`acms: collection key "${key}" is reserved (it collides with an admin section) — pick another key`);
163
163
  }
164
+ if (def.localizeRedirects !== undefined && typeof def.localizeRedirects !== 'boolean') {
165
+ throw new Error(`acms: content.collections.${key}.localizeRedirects must be true or false`);
166
+ }
164
167
  const fallbackPlural = key === 'posts' ? 'Posts' : key.charAt(0).toUpperCase() + key.slice(1);
165
168
  const plural = def.plural ?? fallbackPlural;
166
169
  collections[key] = {
@@ -168,6 +171,7 @@ function resolveContent(content = {}) {
168
171
  plural,
169
172
  base: def.base ?? (key === 'posts' ? '/blog' : `/${key}`),
170
173
  redirectFrom: def.redirectFrom ?? [],
174
+ localizeRedirects: def.localizeRedirects ?? true,
171
175
  };
172
176
  }
173
177
  const resolved = { collections, tags: { base: (content.tags ?? {}).base ?? '/tag' } };
@@ -571,14 +575,18 @@ function acmsVitePlugin(userConfig) {
571
575
  * User-visible identity in the admin chrome (sidebar, titles, breadcrumb).
572
576
  * @param {{siteTitle?: string, tagline?: string, heroHeading?: string, heroSub?: string}} [options.seed]
573
577
  * First-boot database seed content. Only read when the database is empty.
574
- * @param {{posts?: {singular?: string, plural?: string, base?: string, redirectFrom?: string[]}, collections?: Record<string, {singular?: string, plural?: string, base?: string, redirectFrom?: string[]}>, tags?: {base?: string}}} [options.content]
578
+ * @param {{posts?: {singular?: string, plural?: string, base?: string, redirectFrom?: string[], localizeRedirects?: boolean}, collections?: Record<string, {singular?: string, plural?: string, base?: string, redirectFrom?: string[], localizeRedirects?: boolean}>, tags?: {base?: string}}} [options.content]
575
579
  * Content collections. `collections` is a keyed map — each key gets its own
576
580
  * public routes at `base`, admin section at /admin/c/<key>, and labels from
577
581
  * `singular`/`plural`. The `posts` key is the built-in collection (default
578
582
  * base '/blog'); `content.posts` is shorthand for `collections.posts`.
579
- * `redirectFrom` 301s extra legacy bases. Tags are standalone at `tags.base`
580
- * (default '/tag'), shared across collections. Collection keys are permanent
581
- * internal identifiers (DB rows, backups, admin URLs) — never renamed.
583
+ * `redirectFrom` 301s extra legacy bases. On a site with `i18n`, a redirect
584
+ * from /es/… to the collection keeps the language (/es/blog → /es/sermon);
585
+ * `localizeRedirects: false` drops it (→ /sermon) for entries each written
586
+ * in one language (tag addresses follow `posts`). Tags are standalone at
587
+ * `tags.base` (default '/tag'), shared across collections. Collection keys
588
+ * are permanent internal identifiers (DB rows, backups, admin URLs) — never
589
+ * renamed.
582
590
  * @param {{locales?: string[], defaultLocale?: string}} [options.i18n]
583
591
  * Site languages. The default locale (first entry unless `defaultLocale`
584
592
  * says otherwise) is served at `/`; every other locale gets its own home
@@ -706,6 +714,8 @@ export default function acms(options = {}) {
706
714
  ['/api/v1/content', 'api/v1/content/index.ts'],
707
715
  ['/api/v1/content/[id]', 'api/v1/content/[id].ts'],
708
716
  ['/api/v1/links', 'api/v1/links.ts'],
717
+ // live video status (404 while Integrations → Live video is off)
718
+ ['/api/v1/live', 'api/v1/live.ts'],
709
719
  ['/api/v1/forms/[id]', 'api/v1/forms/[id]/index.ts'],
710
720
  ['/api/v1/forms/[id]/responses', 'api/v1/forms/[id]/responses.ts'],
711
721
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.27",
3
+ "version": "0.3.29",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -18,6 +18,8 @@ interface ConnForm { clientId: string; clientSecret: string; channelInput: strin
18
18
  interface ConfigForm {
19
19
  mode: Mode; primary: Provider; fallbackUrl: string; showUpcoming: boolean;
20
20
  watchUrl: string; title: string; hours: string; display: Display; dirty: boolean;
21
+ /** "Notify app users" for this save only; never remembered */
22
+ notify: boolean;
21
23
  }
22
24
  type Display = LiveConfig['display'];
23
25
 
@@ -79,6 +81,7 @@ const toConfigForm = (c: LiveConfig, now: number): ConfigForm => ({
79
81
  hours: c.mode === 'manual' && c.manual.until > now ? 'keep' : '4',
80
82
  display: c.display,
81
83
  dirty: false,
84
+ notify: false,
82
85
  });
83
86
 
84
87
  function rel(ts: number, now: number) {
@@ -256,13 +259,15 @@ export default function LiveManager() {
256
259
  }
257
260
  await run('config', async () => {
258
261
  try {
259
- const s = await api<LiveAdminState>({ action: 'saveConfig', config: configBody(f) });
262
+ const notify = f.mode === 'manual' && f.notify && state.push;
263
+ const s = await api<LiveAdminState & { notified?: { recipients: number } | null; notifyError?: string }>({ action: 'saveConfig', config: configBody(f), notify });
260
264
  adopt(s, { config: true });
265
+ const told = !notify ? '' : s.notifyError ? '' : s.notified ? ` Notified ${s.notified.recipients.toLocaleString()} app user${s.notified.recipients === 1 ? '' : 's'}.` : ' App users were already notified about this link.';
261
266
  setCfgMsg({
262
- ok: s.config.mode === 'manual' ? `Saved. Your link shows until ${fullTime(s.config.manual.until)}.`
267
+ ok: s.config.mode === 'manual' ? `Saved. Your link shows until ${fullTime(s.config.manual.until)}.${told}`
263
268
  : s.config.mode === 'automatic' ? 'Saved. Your site will show broadcasts from your connected channels.'
264
269
  : 'Saved. Live video is hidden on your site.',
265
- err: '',
270
+ err: s.notifyError ?? '',
266
271
  });
267
272
  } catch (err) {
268
273
  setCfgMsg({ ok: '', err: errText(err) });
@@ -815,6 +820,12 @@ export default function LiveManager() {
815
820
  </select>
816
821
  </label>
817
822
  </div>
823
+ {state.push && (
824
+ <label className="check-row">
825
+ <input type="checkbox" checked={cfg.notify} onChange={(e) => setCfgField('notify', e.target.checked)} />
826
+ <span>Notify app users <span className="hint">(sends “We’re live” once to phones that turned on Live video notifications in your app)</span></span>
827
+ </label>
828
+ )}
818
829
  </fieldset>
819
830
  )}
820
831
 
@@ -35,7 +35,7 @@ function ago(ms: number, now: number) {
35
35
  : d.toLocaleDateString([], { month: 'short', day: 'numeric', ...(d.getFullYear() !== new Date(now).getFullYear() ? { year: 'numeric' } : {}) });
36
36
  }
37
37
 
38
- const SOURCE: Record<PushNotification['source'], string> = { manual: 'Sent by', post: 'New post', event: 'New event', soon: 'Starting soon' };
38
+ const SOURCE: Record<PushNotification['source'], string> = { manual: 'Sent by', post: 'New post', event: 'New event', soon: 'Starting soon', live: 'We’re live' };
39
39
 
40
40
  export default function MobileApp() {
41
41
  const [state, setState] = useState<AdminPushState | null>(null);
@@ -279,7 +279,7 @@ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: A
279
279
  <section className="card card-pad" aria-labelledby="ma-auto-h">
280
280
  <header className="ma-head">
281
281
  <h2 id="ma-auto-h">Automatic notifications</h2>
282
- <p>Sent once, when something goes live for the first time. Edits never notify again.</p>
282
+ <p>Sent once, when something is published for the first time or a broadcast starts. Edits never notify again.</p>
283
283
  </header>
284
284
  <div className="ma-rules">
285
285
  <div className="ma-rule">
@@ -309,6 +309,15 @@ function Automatic({ state, setState }: { state: AdminPushState; setState: (s: A
309
309
  </div>
310
310
  </>
311
311
  )}
312
+ <div className="ma-rule">
313
+ <div className="ma-rule-head"><b>We go live</b><small>“We’re live” and the broadcast title, opens the stream. Once per broadcast, not when it was found more than 20 minutes after it started, and at most once every 3 hours.</small></div>
314
+ <div className="ma-choices">
315
+ <label className="ma-check"><input type="checkbox" checked={auto.live.enabled} onChange={() => setAuto({ ...auto, live: { enabled: !auto.live.enabled } })} /><span>When live video detects a broadcast</span></label>
316
+ </div>
317
+ <p className="hint">{state.liveMode === 'off'
318
+ ? <>Live video is off. Turn it on in <a href="/admin/live">Integrations → Live video</a> first.</>
319
+ : 'Only phones that turned on Live video notifications in the app get it. Manual links notify only when you tick “Notify app users” on the link.'}</p>
320
+ </div>
312
321
  </div>
313
322
  {msg.err && <p className="flash error" role="alert">{msg.err}</p>}
314
323
  <div className="form-actions">
@@ -14,9 +14,12 @@ export interface CollectionConfig {
14
14
  plural: string;
15
15
  base: string;
16
16
  redirectFrom: string[];
17
+ // false: a redirect from a /<locale>/… address to this collection drops the
18
+ // language, for entries each written in one language (localizesRedirectsTo)
19
+ localizeRedirects: boolean;
17
20
  }
18
21
 
19
- const FALLBACK_POSTS: CollectionConfig = { singular: 'Post', plural: 'Posts', base: '/blog', redirectFrom: [] };
22
+ const FALLBACK_POSTS: CollectionConfig = { singular: 'Post', plural: 'Posts', base: '/blog', redirectFrom: [], localizeRedirects: true };
20
23
 
21
24
  const collections: Record<string, CollectionConfig> =
22
25
  userConfig.content?.collections ?? { posts: FALLBACK_POSTS };
@@ -53,6 +56,17 @@ export function collectionFromPath(pathname: string): (CollectionConfig & { key:
53
56
  return best;
54
57
  }
55
58
 
59
+ // Whether a redirect from a /<locale>/… address to this path on the site
60
+ // keeps the visitor's language (middleware.ts localizedRedirect): not for a
61
+ // collection whose localizeRedirects is off. Tag archives are shared by every
62
+ // collection and follow the built-in one, whose old /blog/tag/… they replace.
63
+ export function localizesRedirectsTo(path: string): boolean {
64
+ const pathname = path.split(/[?#]/)[0];
65
+ const { base } = CONTENT.tags;
66
+ const c = pathname === base || pathname.startsWith(`${base}/`) ? CONTENT.posts : collectionFromPath(pathname);
67
+ return c?.localizeRedirects !== false;
68
+ }
69
+
56
70
  // Aliases for the built-in collection — the bulk of call sites.
57
71
  export const postsIndexUrl = () => CONTENT.posts.base;
58
72
  export const postUrl = (slug: string) => `${CONTENT.posts.base}/${slug}`;
@@ -2,6 +2,7 @@ import { ApiError } from '../api';
2
2
  import type { ContentRepository } from '../repo/types';
3
3
  import { encrypt, decrypt, encryptionReady, hash, randomToken } from './crypto';
4
4
  import { channels, detect, exchangeGoogleCode, ProviderError, revoke, twitchLogin } from './providers';
5
+ import { PUSH } from '../push/config';
5
6
  import { LiveConfig, type Connection, type Credentials, type Provider, type LiveAdminState, type LiveSnapshot, type PublicLiveSnapshot } from './types';
6
7
 
7
8
  export function canonicalOrigin(siteUrl: string, requestUrl: URL) {
@@ -67,7 +68,7 @@ export async function adminState(repo:ContentRepository,url:URL,locals?:unknown)
67
68
  let authorized=false;try{const s=decrypt<Credentials>(c.encrypted,c.provider,locals);authorized=c.provider==='twitch'?!!s.clientSecret:!!s.refreshToken;}catch{/* needs reconnect */}
68
69
  const {encrypted,lease,leaseUntil,snapshot,upcoming,...visible}=c;
69
70
  return {...visible,hasSecret:!!encrypted,authorized,broadcastStatus:snapshot&&snapshot.expiresAt>Date.now()?snapshot.status:'unknown'};
70
- }),callbackUrl:`${origin}/api/live/oauth/youtube/callback`,encryptionReady:encryptionReady(locals),scheduler:{lastAt,healthy:lastAt>Date.now()-180_000},snapshot:effectiveSnapshot(settings.live,connections)};
71
+ }),callbackUrl:`${origin}/api/live/oauth/youtube/callback`,encryptionReady:encryptionReady(locals),scheduler:{lastAt,healthy:lastAt>Date.now()-180_000},snapshot:effectiveSnapshot(settings.live,connections),push:PUSH.enabled};
71
72
  }
72
73
  const providerError=(error:unknown)=>error instanceof ProviderError?error:error instanceof ApiError?new ProviderError('auth',error.message):new ProviderError('unavailable','The provider returned an unexpected response. Try the connection test again.');
73
74
  export async function syncConnection(repo:ContentRepository,provider:Provider,locals?:unknown,force=false):Promise<boolean> {
@@ -48,5 +48,7 @@ export interface LiveAdminState {
48
48
  config: LiveConfig; connections: PublicConnection[]; callbackUrl: string;
49
49
  encryptionReady: boolean; scheduler: { lastAt: number; healthy: boolean };
50
50
  snapshot: LiveSnapshot | null;
51
+ /** push notifications are on (acms({ mobile: { push } })): manual links can notify app users */
52
+ push: boolean;
51
53
  }
52
54
  export const emptySnapshot = (provider: Provider, now: number): LiveSnapshot => ({ status: 'offline', source: 'automatic', provider, id: '', title: '', watchUrl: '', embedId: '', startedAt: 0, checkedAt: now, expiresAt: now + 120_000 });
@@ -0,0 +1,25 @@
1
+ import { z } from 'zod';
2
+ import { ApiError } from '../api';
3
+ import type { ContentRepository } from '../repo/types';
4
+ import { siteOrigin } from '../seo';
5
+ import { effectiveSnapshot, publicSnapshot } from '../live/service';
6
+ import { queryParams } from './http';
7
+
8
+ // Live video for apps: the website's public snapshot (/api/live/status) with
9
+ // an absolute thumbnail URL, plus the owner's "How it looks" choices so apps
10
+ // can follow them. Served no-store: a cached "live" would outlive the stream.
11
+
12
+ export async function getLive(repo: ContentRepository, url: URL) {
13
+ z.object({}).strict().parse(queryParams(url));
14
+ const [settings, connections] = await Promise.all([repo.getSettings(), repo.live.list()]);
15
+ const config = settings.live;
16
+ if (config.mode === 'off') throw new ApiError(404, 'This feature is disabled.');
17
+ const origin = siteOrigin(settings, url);
18
+ const s = publicSnapshot(effectiveSnapshot(config, connections), config);
19
+ const { style, bar, header, dock } = config.display;
20
+ return {
21
+ snapshot: s && { ...s, thumbUrl: s.thumbUrl && new URL(s.thumbUrl, origin).href },
22
+ serverNow: Date.now(),
23
+ display: { style, bar, header, dock },
24
+ };
25
+ }
@@ -50,6 +50,7 @@ export async function getOpenApi(_repo: ContentRepository, url: URL) {
50
50
  ['/availability', 'Current availability; requires bookings', availabilityQuery, ref('Availability'), false],
51
51
  ['/push/topics', 'Topics an app can offer as notification toggles; requires push', empty, ref('PushTopics')],
52
52
  ['/links', 'The links page: profile, socials and visible blocks in order (owner switch: links)', empty, ref('Links')],
53
+ ['/live', 'Live video status: the current broadcast (or null), server time and the owner’s display choices; never cached; 404 while live video is off', empty, ref('LiveStatus'), false],
53
54
  ['/forms/{id}', 'A published form’s questions; 410 when it stopped accepting responses (owner switch: forms)', empty, ref('Form')],
54
55
  ['/openapi.json', 'OpenAPI 3.1 document (no v1 envelope)', empty, { type: 'object', required: ['openapi', 'info', 'paths', 'components'] }],
55
56
  ];
@@ -4,11 +4,11 @@ import { CalEvent, Booking, Calendar, BookingQuestion, LinksAvatarShape, LinksAv
4
4
  const str = z.string(), bool = z.boolean(), strings = z.array(str);
5
5
  const date = str.datetime(), locale = str.nullable();
6
6
  export const Pagination = z.object({ page: z.number().int(), limit: z.number().int(), nextPage: z.number().int().nullable() });
7
- const Palette = z.object({ bg: str, ink: str, muted: str, line: str, accent: str, panel: str, soft: str });
7
+ const Palette = z.object({ bg: str, ink: str, muted: str, line: str, accent: str, panel: str, soft: str, live: str });
8
8
  export const Site = z.object({
9
9
  name: str, description: str, logo: str, logoDark: str, icon: str, menu: z.array(z.object({ label: str, href: str })),
10
10
  languages: z.object({ default: locale, locales: z.array(z.object({ code: str, name: str })) }),
11
- features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool }), apiVersion: z.literal(1),
11
+ features: z.object({ blog: z.array(z.object({ key: str, name: str, url: str, redirectFrom: strings })), calendar: bool, bookings: bool, search: bool, bookingChanges: bool, guestSignIn: bool, forms: bool, links: bool, live: bool }), apiVersion: z.literal(1),
12
12
  });
13
13
  export const Theme = z.object({
14
14
  id: str, name: str, scheme: z.enum(['auto', 'light', 'dark']), colors: z.object({ light: Palette, dark: Palette }),
@@ -105,6 +105,17 @@ export const Device = z.object({
105
105
  export const DeviceRegistration = z.object({ created: bool, device: Device });
106
106
  export const DeviceRemoved = z.object({ token: str, deleted: z.literal(true) });
107
107
  export const PushTopics = z.object({ topics: z.array(z.object({ id: str, label: str, kind: z.enum(['general', 'collection', 'calendar']) })) });
108
+ // times are Unix milliseconds (as on the website); compare them with serverNow, not the device clock
109
+ const ms = z.number().int();
110
+ export const LiveSnapshot = z.object({
111
+ status: z.enum(['live', 'upcoming', 'offline', 'unknown']), source: z.enum(['automatic', 'manual']), provider: z.enum(['youtube', 'twitch', 'facebook', 'other']),
112
+ id: str, title: str, watchUrl: str, embedId: str, startedAt: ms, checkedAt: ms, expiresAt: ms,
113
+ channel: str.optional(), viewers: z.number().int().optional(), thumbUrl: str,
114
+ });
115
+ export const LiveStatus = z.object({
116
+ snapshot: LiveSnapshot.nullable(), serverNow: ms,
117
+ display: z.object({ style: z.enum(['poster', 'split', 'row']), bar: bool, header: bool, dock: bool }),
118
+ });
108
119
  export const Error = z.object({ version: z.literal(1), error: z.object({
109
120
  code: z.enum(['invalid_request', 'unauthorized', 'forbidden', 'not_found', 'method_not_allowed', 'conflict', 'gone', 'payload_too_large', 'unsupported_media_type', 'rate_limited', 'upstream_error', 'internal_error']),
110
121
  message: str, issues: z.array(z.object({ path: z.array(z.union([str, z.number()])), message: str })).optional(),
@@ -6,7 +6,7 @@ import { CALENDAR, bookingsEnabled } from '../calendar/config';
6
6
  import { SEARCH } from '../search-config';
7
7
  import { siteOrigin } from '../seo';
8
8
  import { resolveDesign } from '../theme';
9
- import { darkPalette } from '../themeTokens';
9
+ import { darkPalette, liveColor } from '../themeTokens';
10
10
  import { safeUrl } from './content';
11
11
  import { queryParams } from './http';
12
12
  import { guestSignInReady } from './guest';
@@ -35,6 +35,8 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
35
35
  // Admin → Mobile app → App access
36
36
  bookingChanges: CALENDAR.enabled && settings.mobileAccess.bookingChanges, guestSignIn: await guestSignInReady(repo, locals),
37
37
  forms: settings.mobileAccess.forms, links: settings.mobileAccess.links,
38
+ // Integrations → Live video; GET /api/v1/live answers 404 while it's off
39
+ live: settings.live.mode !== 'off',
38
40
  },
39
41
  apiVersion: 1,
40
42
  };
@@ -42,9 +44,11 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
42
44
  export async function getThemeTokens(repo: ContentRepository, url: URL) {
43
45
  z.object({}).strict().parse(queryParams(url));
44
46
  const { theme, tokens: t } = resolveDesign(await repo.getSettings());
47
+ const light = { bg: t.bg, ink: t.ink, muted: t.muted, line: t.line, accent: t.accent, panel: t.panel, soft: t.soft }, dark = darkPalette(t);
45
48
  return {
46
49
  id: theme.slug, name: theme.name, scheme: t.scheme,
47
- colors: { light: { bg: t.bg, ink: t.ink, muted: t.muted, line: t.line, accent: t.accent, panel: t.panel, soft: t.soft }, dark: darkPalette(t) },
50
+ // live: the live-video marker color the website uses (--live)
51
+ colors: { light: { ...light, live: liveColor(t, light, false) }, dark: { ...dark, live: liveColor(t, dark, true) } },
48
52
  fonts: { heading: t.headingFont, body: t.bodyFont, mono: t.monoFont }, radius: t.radius,
49
53
  };
50
54
  }
@@ -108,6 +108,8 @@ export async function adminPushState(repo: ContentRepository, url: URL, locals?:
108
108
  stats, topics, auto: settings.push.auto, links, log,
109
109
  collections: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, label: c.plural })),
110
110
  calendarEnabled: CALENDAR.enabled,
111
+ // "we're live" needs live video on (Integrations → Live video)
112
+ liveMode: settings.live.mode,
111
113
  calendars: calendars.filter((c) => c.visibility === 'public').map((c) => ({ id: c.id, slug: c.slug, name: c.name, color: c.color })),
112
114
  expo: { accessToken: Boolean(expo.accessToken), customEndpoint: expo.base !== EXPO_PUSH_BASE },
113
115
  };
@@ -150,6 +152,7 @@ export async function adminPushAction(repo: ContentRepository, input: z.infer<ty
150
152
  newPost: { collections: [...new Set(input.auto.newPost.collections)].filter((k) => collections.has(k)) },
151
153
  newEvent: { calendars: CALENDAR.enabled ? cals(input.auto.newEvent.calendars) : [] },
152
154
  startingSoon: { calendars: CALENDAR.enabled ? cals(input.auto.startingSoon.calendars) : [], minutes: input.auto.startingSoon.minutes },
155
+ live: { enabled: input.auto.live?.enabled === true },
153
156
  };
154
157
  await repo.updateSettings({ push: { auto } });
155
158
  return { auto };
@@ -1,5 +1,5 @@
1
1
  // Push notifications: topics, audiences, the Expo sender pipeline, automatic
2
- // notices (first publish of a post/event, "starting soon") and receipts.
2
+ // notices (first publish of a post/event, "starting soon", "we're live") and receipts.
3
3
  import type { ContentRepository } from '../repo/types';
4
4
  import type { CalEvent, Calendar, Post, Settings } from '../schema';
5
5
  import { CONTENT, collectionUrl } from '../content';
@@ -8,6 +8,8 @@ import { eventIsListed, eventTimeZone, expandEvents } from '../calendar/events';
8
8
  import { I18N } from '../i18n';
9
9
  import { localizeDoc } from '../translations';
10
10
  import { localizedUrl } from '../mobile/locale';
11
+ import { effectiveSnapshot } from '../live/service';
12
+ import type { LiveSnapshot } from '../live/types';
11
13
  import { PUSH } from './config';
12
14
  import { expoOptions, fetchReceipts, isGone, sendMessages, type ExpoMessage, type ExpoOptions } from './expo';
13
15
  import type { PushNotification, PushSource, PushTopic } from './types';
@@ -22,8 +24,11 @@ export const RECEIPT_DELAY = 15 * MINUTE;
22
24
 
23
25
  /**
24
26
  * Topics an app can offer as toggles. `news` = every new post, `events` = every
25
- * new event. Per-collection and per-calendar topics appear once there is more
26
- * than one of them (a single one would just repeat news/events).
27
+ * new event, `live` = "we're live" (while live video isn't off). Per-collection
28
+ * and per-calendar topics appear once there is more than one of them (a single
29
+ * one would just repeat news/events). Devices only get the topics they (or the
30
+ * app, at their first registration) choose: a new topic never reaches existing
31
+ * devices on its own.
27
32
  */
28
33
  export async function pushTopics(repo: ContentRepository): Promise<PushTopic[]> {
29
34
  const collections = Object.entries(CONTENT.collections);
@@ -34,12 +39,13 @@ export async function pushTopics(repo: ContentRepository): Promise<PushTopic[]>
34
39
  const calendars = (await repo.listCalendars()).filter((c) => c.visibility === 'public');
35
40
  if (calendars.length > 1) out.push(...calendars.map((c): PushTopic => ({ id: `calendar:${c.slug}`, label: c.name, kind: 'calendar' })));
36
41
  }
42
+ if ((await repo.getSettings()).live.mode !== 'off') out.push({ id: 'live', label: 'Live video', kind: 'general' });
37
43
  return out;
38
44
  }
39
45
 
40
- /** Every topic a device may subscribe to (listed or not). */
46
+ /** Every topic a device may subscribe to (listed or not). `live` stays valid while live video is off, so a saved choice survives. */
41
47
  export async function knownTopics(repo: ContentRepository): Promise<Set<string>> {
42
- const set = new Set(['news', ...Object.keys(CONTENT.collections).map((k) => `collection:${k}`)]);
48
+ const set = new Set(['news', 'live', ...Object.keys(CONTENT.collections).map((k) => `collection:${k}`)]);
43
49
  if (CALENDAR.enabled) {
44
50
  set.add('events');
45
51
  for (const c of await repo.listCalendars()) if (c.visibility === 'public') set.add(`calendar:${c.slug}`);
@@ -57,12 +63,12 @@ export function deviceLocale(raw: string): string | null {
57
63
  return match && match !== I18N.defaultLocale ? match : null;
58
64
  }
59
65
 
60
- const STRINGS: Record<string, { startsIn: (m: number) => string; now: string }> = {
61
- en: { startsIn: (m) => `Starts in ${m} min`, now: 'Starting now' },
62
- es: { startsIn: (m) => `Empieza en ${m} min`, now: 'Empieza ahora' },
63
- fr: { startsIn: (m) => `Commence dans ${m} min`, now: 'Commence maintenant' },
64
- de: { startsIn: (m) => `Beginnt in ${m} Min.`, now: 'Beginnt jetzt' },
65
- pt: { startsIn: (m) => `Começa em ${m} min`, now: 'Começa agora' },
66
+ const STRINGS: Record<string, { startsIn: (m: number) => string; now: string; live: string }> = {
67
+ en: { startsIn: (m) => `Starts in ${m} min`, now: 'Starting now', live: 'We’re live' },
68
+ es: { startsIn: (m) => `Empieza en ${m} min`, now: 'Empieza ahora', live: 'Estamos en vivo' },
69
+ fr: { startsIn: (m) => `Commence dans ${m} min`, now: 'Commence maintenant', live: 'Nous sommes en direct' },
70
+ de: { startsIn: (m) => `Beginnt in ${m} Min.`, now: 'Beginnt jetzt', live: 'Wir sind live' },
71
+ pt: { startsIn: (m) => `Começa em ${m} min`, now: 'Começa agora', live: 'Estamos ao vivo' },
66
72
  };
67
73
  export const pushStrings = (locale: string) => STRINGS[locale.split('-')[0]] ?? STRINGS.en;
68
74
 
@@ -289,13 +295,96 @@ export async function checkPushReceipts(repo: ContentRepository, opts: PushRunOp
289
295
  return done.length;
290
296
  }
291
297
 
292
- /** Scheduler entry: queue, starting soon, receipts and retention. Disabled → queued notices are skipped. */
298
+ // ---- live video ------------------------------------------------------------------
299
+
300
+ /** A broadcast first seen this long after it started (connection turned on mid-service, scheduler down) is not announced. */
301
+ export const LIVE_LATE_AFTER = 20 * MINUTE;
302
+ /** At most one automatic "we're live" notice in this window: a stream that drops and restarts as a new broadcast never notifies twice. */
303
+ export const LIVE_COOLDOWN = 3 * HOUR;
304
+
305
+ /**
306
+ * The ledger key: one notice per broadcast. YouTube video ids are the same
307
+ * whether detected or pasted as a manual link, so the two never both notify;
308
+ * other manual links count once per link expiry.
309
+ */
310
+ export function liveKey(s: Pick<LiveSnapshot, 'source' | 'provider' | 'id' | 'expiresAt'>): string {
311
+ if (s.source === 'manual' && s.provider !== 'youtube') return `live:manual:${s.expiresAt}`;
312
+ return `live:${s.provider}:${s.id}`;
313
+ }
314
+
315
+ /** "We're live": localized title, the broadcast title as the body, and the watch link (apps open it outside the app). */
316
+ export function liveNotice(s: LiveSnapshot, settings: Settings): SendInput {
317
+ // a manual link keeps its default title "We’re live": don't say it twice
318
+ const body = clip(s.title && s.title !== STRINGS.en.live ? s.title : settings.siteTitle, 240);
319
+ const words = (locale: string): Composed => ({ title: pushStrings(locale).live, body, url: s.watchUrl });
320
+ return { ...words(displayLocale(settings)), audience: ['live'], source: 'live', sourceKey: liveKey(s),
321
+ data: { kind: 'live', provider: s.provider, id: s.id }, localize: (l) => words(l) };
322
+ }
323
+
324
+ async function announceLive(repo: ContentRepository, s: LiveSnapshot, settings: Settings, key: string, run: PushRunOptions & { now: number }) {
325
+ try {
326
+ const n = await sendNotification(repo, liveNotice(s, settings), run);
327
+ await repo.push.markQueue(key, 'sent', n.id, run.now);
328
+ return n;
329
+ } catch (e) {
330
+ await repo.push.markQueue(key, 'failed', null, run.now).catch(() => {});
331
+ throw e;
332
+ }
333
+ }
334
+
335
+ /**
336
+ * Automatic "we're live" (Mobile app → automatic notifications): runs after
337
+ * the scheduler's live check. Each detected broadcast is claimed once in the
338
+ * push ledger, so a stream that flickers offline and back, or a restart, can't
339
+ * send it twice. Late detections and restarts inside the cooldown are skipped.
340
+ */
341
+ export async function sendLiveNotice(repo: ContentRepository, opts: PushRunOptions = {}): Promise<number> {
342
+ if (!PUSH.enabled) return 0;
343
+ const now = opts.now ?? Date.now();
344
+ const [settings, connections] = await Promise.all([repo.getSettings(), repo.live.list()]);
345
+ if (!settings.push.auto.live.enabled) return 0;
346
+ const s = effectiveSnapshot(settings.live, connections, now);
347
+ if (s?.status !== 'live' || s.source !== 'automatic' || !s.id) return 0;
348
+ const key = liveKey(s);
349
+ if (!(await repo.push.claimKey(key, 'live', s.id, s.startedAt || null, now))) return 0;
350
+ const late = s.startedAt > 0 && now - s.startedAt > LIVE_LATE_AFTER;
351
+ if (late || (await repo.push.lastSent('live')) > now - LIVE_COOLDOWN) {
352
+ await repo.push.markQueue(key, 'skipped', null, now);
353
+ return 0;
354
+ }
355
+ try {
356
+ await announceLive(repo, s, settings, key, { ...opts, now, origin: opts.origin ?? siteOriginOf(settings, opts.fallbackOrigin) });
357
+ return 1;
358
+ } catch (e) {
359
+ console.error('acms push: live notification failed', e);
360
+ return 0;
361
+ }
362
+ }
363
+
364
+ /**
365
+ * "Notify app users" on a manual link (Integrations → Live video): one notice
366
+ * for the active manual link, sent right away. Saving the same link again
367
+ * (or a YouTube video already announced) sends nothing. Null when nothing was sent.
368
+ */
369
+ export async function sendManualLiveNotice(repo: ContentRepository, opts: PushRunOptions = {}): Promise<PushNotification | null> {
370
+ if (!PUSH.enabled) return null;
371
+ const now = opts.now ?? Date.now();
372
+ const settings = await repo.getSettings();
373
+ const s = effectiveSnapshot(settings.live, [], now);
374
+ if (s?.source !== 'manual' || s.status !== 'live') return null;
375
+ const key = liveKey(s);
376
+ if (!(await repo.push.claimKey(key, 'live', s.id, null, now))) return null;
377
+ return announceLive(repo, s, settings, key, { ...opts, now, origin: opts.origin ?? siteOriginOf(settings, opts.fallbackOrigin) });
378
+ }
379
+
380
+ /** Scheduler entry: queue, starting soon, we're live, receipts and retention. Disabled → queued notices are skipped. */
293
381
  export async function runPushJobs(repo: ContentRepository, opts: PushRunOptions = {}): Promise<number> {
294
382
  const now = opts.now ?? Date.now();
295
383
  if (!PUSH.enabled) { await repo.push.skipPending(now); return 0; }
296
384
  const run = { ...opts, now };
297
385
  let count = await processPushQueue(repo, run);
298
386
  count += await sendStartingSoon(repo, run);
387
+ count += await sendLiveNotice(repo, run);
299
388
  count += await checkPushReceipts(repo, run);
300
389
  await repo.push.purge(now, now - PUSH.retentionDays * DAY);
301
390
  return count;
@@ -128,16 +128,20 @@ export class PushStore {
128
128
  await this.run('UPDATE push_queue SET status=?, notification_id=?, updated_at=? WHERE key=?', [status, notificationId, now, key]);
129
129
  }
130
130
  async skipPending(now: number) { await this.run(`UPDATE push_queue SET status='skipped', updated_at=? WHERE status='pending'`, [now]); }
131
+ /** When a notice of this kind last went out (0 = never). */
132
+ async lastSent(kind: PushQueueRow['kind']): Promise<number> {
133
+ return Number((await this.all(`SELECT MAX(updated_at) AS at FROM push_queue WHERE kind=? AND status='sent'`, [kind]))[0]?.at ?? 0);
134
+ }
131
135
  async queueStatus(key: string) { return (await this.all('SELECT status FROM push_queue WHERE key=?', [key]))[0]?.status as string | undefined; }
132
136
 
133
137
  // ---- retention ----
134
- /** Devices not seen since `devicesBefore`, log rows older than a year, expired tickets and old occurrence keys. */
138
+ /** Devices not seen since `devicesBefore`, log rows older than a year, expired tickets, and old occurrence and broadcast keys. */
135
139
  async purge(now: number, devicesBefore: number): Promise<number> {
136
140
  const gone = await this.all('DELETE FROM push_devices WHERE last_seen_at<? RETURNING id', [devicesBefore]);
137
141
  await (await this.driver()).batch([
138
142
  { sql: 'DELETE FROM push_notifications WHERE created_at<?', args: [now - 365 * 86400000] },
139
143
  { sql: 'DELETE FROM push_tickets WHERE created_at<?', args: [now - 86400000] },
140
- { sql: `DELETE FROM push_queue WHERE kind='soon' AND updated_at<?`, args: [now - 30 * 86400000] },
144
+ { sql: `DELETE FROM push_queue WHERE kind IN ('soon','live') AND updated_at<?`, args: [now - 30 * 86400000] },
141
145
  ]);
142
146
  return gone.length;
143
147
  }
@@ -9,6 +9,8 @@ export const PushAuto = z.object({
9
9
  newEvent: z.object({ calendars: keys.default([]) }).default({}),
10
10
  /** "starting soon" reminders, sent `minutes` before each occurrence */
11
11
  startingSoon: z.object({ calendars: keys.default([]), minutes: z.number().int().min(5).max(1440).default(30) }).default({}),
12
+ /** "We're live": once per broadcast that live video detects, to devices on the `live` topic */
13
+ live: z.object({ enabled: z.boolean().default(false) }).default({}),
12
14
  });
13
15
  export type PushAuto = z.infer<typeof PushAuto>;
14
16
  export const PushSettings = z.object({ auto: PushAuto.default({}) });
@@ -19,7 +21,7 @@ export interface PushDevice {
19
21
  id: string; token: string; platform: PushPlatform; locale: string; topics: string[];
20
22
  createdAt: number; updatedAt: number; lastSeenAt: number;
21
23
  }
22
- export type PushSource = 'manual' | 'post' | 'event' | 'soon';
24
+ export type PushSource = 'manual' | 'post' | 'event' | 'soon' | 'live';
23
25
  export interface PushNotification {
24
26
  id: string; title: string; body: string; url: string;
25
27
  /** ['*'] = everyone */
@@ -31,7 +33,7 @@ export interface PushNotification {
31
33
  removed: number;
32
34
  error: string; author: string; createdAt: number; finishedAt: number | null;
33
35
  }
34
- export interface PushQueueRow { key: string; kind: 'post' | 'event' | 'soon'; docId: string; occurrence: number | null; status: string; createdAt: number }
36
+ export interface PushQueueRow { key: string; kind: 'post' | 'event' | 'soon' | 'live'; docId: string; occurrence: number | null; status: string; createdAt: number }
35
37
  export interface PushTopic { id: string; label: string; kind: 'general' | 'collection' | 'calendar' }
36
38
  export interface PushStats {
37
39
  devices: number;
@@ -138,16 +138,20 @@ export function paletteVars(p: Palette, t: ThemeTokens, dark: boolean): string[]
138
138
  `--btn:${t.button === 'accent' ? 'var(--accent)' : 'var(--ink)'}`, `--btn-ink:${btnInk}`,
139
139
  `--code-bg:${dark ? '#0d0e11' : '#1b1c21'}`,
140
140
  `--success:${dark ? '#5cc98e' : '#127442'}`, `--danger:${dark ? '#f07b7b' : '#b42f2f'}`, `--warning:${dark ? '#e3b456' : '#8a5f0a'}`,
141
- ...liveVars(dark ? t.dark?.live || (t.live ? ensureContrast(t.live, p.bg, 3, '#ffffff') : '#ff5a4e') : t.live || '#d42f25', p.bg),
141
+ ...liveVars(liveColor(t, p, dark)),
142
142
  `--sh-1:${s1}`, `--sh-2:${s2}`, `--sh-3:${s3}`,
143
143
  `color-scheme:${dark ? 'dark' : 'light'}`,
144
144
  ];
145
145
  }
146
146
 
147
147
  // The live signal color stays visible on the canvas (3:1, it's a marker, not
148
- // body text) and gets whichever text color reads on it.
149
- function liveVars(live: string, bg: string): string[] {
150
- const c = ensureContrast(live, bg, 3);
148
+ // body text). The app API sends the same value as colors.<scheme>.live.
149
+ export function liveColor(t: ThemeTokens, p: Palette, dark: boolean): string {
150
+ const raw = dark ? t.dark?.live || (t.live ? ensureContrast(t.live, p.bg, 3, '#ffffff') : '#ff5a4e') : t.live || '#d42f25';
151
+ return ensureContrast(raw, p.bg, 3);
152
+ }
153
+ // …and gets whichever text color reads on it.
154
+ function liveVars(c: string): string[] {
151
155
  const on = contrast('#ffffff', c) >= 4.5 || contrast('#ffffff', c) >= contrast('#111214', c) ? '#ffffff' : '#111214';
152
156
  return [`--live:${c}`, `--live-on:${on}`];
153
157
  }
package/src/middleware.ts CHANGED
@@ -5,7 +5,7 @@ import { bindAuthEnvironment, getRequestAuth, requestPerson, shouldResolveClerk,
5
5
  import { roleHome } from './lib/roles';
6
6
  import { AUTH } from './lib/auth/config';
7
7
  import { getRepo } from './lib/repo';
8
- import { CONTENT, legacyBases } from './lib/content';
8
+ import { CONTENT, legacyBases, localizesRedirectsTo } from './lib/content';
9
9
  import { processDueDeliveries, waitUntilFrom } from './lib/webhooks';
10
10
  import { dispatchEmail } from './lib/email/service';
11
11
  import { decideOverdue, sendDueReminders } from './lib/booking/service';
@@ -46,10 +46,11 @@ export function legacyContentRedirect(pathname: string): string | null {
46
46
 
47
47
  // A /es/… request reaches the redirect lookup rewritten to its bare path (see
48
48
  // handle), so a target on this site gets the visitor's language back: /blog →
49
- // /sermon serves /es/blog → /es/sermon. Full URLs and targets that already
50
- // name a language stay as written.
49
+ // /sermon serves /es/blog → /es/sermon. Full URLs, targets that already name a
50
+ // language, and addresses of a collection with localizeRedirects off (tag
51
+ // pages go with posts) stay as written.
51
52
  export function localizedRedirect(to: string, locale: string | undefined): string {
52
- if (!locale || !to.startsWith('/') || to.startsWith('//') || stripLocale(to) !== to) return to;
53
+ if (!locale || !to.startsWith('/') || to.startsWith('//') || stripLocale(to) !== to || !localizesRedirectsTo(to)) return to;
53
54
  return localePath(to, locale);
54
55
  }
55
56
 
@@ -7,7 +7,7 @@ import { accessFor } from '../../lib/permissions/access';
7
7
  import type { Permission } from '../../lib/permissions/catalog';
8
8
  import { AUTH } from '../../lib/auth/config';
9
9
  import { invalidateRedirectCache } from '../../middleware';
10
- import { CONTENT } from '../../lib/content';
10
+ import { CONTENT, localizesRedirectsTo } from '../../lib/content';
11
11
  import { extraLocales } from '../../lib/i18n';
12
12
  import { UMAMI_ID_RE, META_PIXEL_ID_RE, TIKTOK_PIXEL_ID_RE, normalizeScriptUrl } from '../../lib/analytics';
13
13
 
@@ -201,8 +201,15 @@ if (Astro.request.method === 'POST') {
201
201
 
202
202
  const settings = await repo.getSettings();
203
203
  const redirects = await repo.listRedirects();
204
- // the redirect card explains /<locale>/… rules with the site's first extra language
204
+ // the redirect card explains /<locale>/… rules with the site's first extra
205
+ // language, and names the bases whose targets keep no language (collections
206
+ // with localizeRedirects: false, and /tag with posts)
205
207
  const extraLang = extraLocales()[0] ?? '';
208
+ const asWritten = [...Object.values(CONTENT.collections).map((c) => c.base), CONTENT.tags.base].filter((b) => !localizesRedirectsTo(b));
209
+ const asWrittenList = asWritten.length > 1 ? `${asWritten.slice(0, -1).join(', ')} or ${asWritten.at(-1)}` : asWritten[0];
210
+ const redirectsLangNote = extraLang
211
+ ? ` A rule for /old-path also sends /${extraLang}/old-path to /${extraLang}/new-path${asWrittenList ? `, or to the new path as written when it's under ${asWrittenList}` : ''}; rules that start with /${extraLang}/ are used exactly as written.`
212
+ : '';
206
213
  const backend = (Astro.locals as { runtime?: { env?: { DB?: unknown } } }).runtime?.env?.DB
207
214
  ? 'd1'
208
215
  : 'sqlite';
@@ -444,7 +451,7 @@ const groups = [
444
451
  <section class="scard" id="redirects">
445
452
  <header class="scard-head">
446
453
  <a class="scard-back" href="#" aria-label="All settings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 6l-6 6 6 6" /></svg></a>
447
- <div class="grow"><h2>Redirects</h2><p>Forward old URLs to new ones. They only fire when a URL would otherwise 404, and renaming a published post adds one automatically.{extraLang && ` A rule for /old-path also sends /${extraLang}/old-path to /${extraLang}/new-path; rules that start with /${extraLang}/ are used exactly as written.`}</p></div>
454
+ <div class="grow"><h2>Redirects</h2><p>Forward old URLs to new ones. They only fire when a URL would otherwise 404, and renaming a published post adds one automatically.{redirectsLangNote}</p></div>
448
455
  </header>
449
456
  {redirects.length > 0 && (
450
457
  <div class="rd-list">
@@ -6,6 +6,7 @@ import { requestSessionBinding } from '../../../lib/auth/request';
6
6
  import { Provider } from '../../../lib/live/types';
7
7
  import { adminState, beginOAuth, disconnect, saveConnection, syncConnection, validateConfig } from '../../../lib/live/service';
8
8
  import { requirePermission } from '../../../lib/permissions/access';
9
+ import { sendManualLiveNotice } from '../../../lib/push/service';
9
10
  const Input=z.discriminatedUnion('action',[
10
11
  z.object({action:z.literal('saveConnection'),provider:Provider,clientId:z.string().trim().min(1).max(200),clientSecret:z.string().trim().max(8192).default(''),channelInput:z.string().trim().max(2048).default(''),googleStatus:z.enum(['testing','production']).default('testing')}).strict(),
11
12
  z.object({action:z.literal('test'),provider:Provider}).strict(),
@@ -13,7 +14,8 @@ const Input=z.discriminatedUnion('action',[
13
14
  z.object({action:z.literal('enable'),provider:Provider,enabled:z.boolean()}).strict(),
14
15
  z.object({action:z.literal('authorize'),provider:z.literal('youtube')}).strict(),
15
16
  z.object({action:z.literal('disconnect'),provider:Provider}).strict(),
16
- z.object({action:z.literal('saveConfig'),config:z.unknown()}).strict(),
17
+ // notify: "Notify app users" on a manual link (sends once per link; needs push)
18
+ z.object({action:z.literal('saveConfig'),config:z.unknown(),notify:z.boolean().default(false)}).strict(),
17
19
  ]);
18
20
  export const GET:APIRoute=async({request,cookies,locals,url})=>{
19
21
  try{const repo=await getRepo(locals);await requirePermission(request, cookies, repo, 'services.manage');return json(await adminState(repo,url,locals));}catch(e){return apiError(e);}
@@ -29,7 +31,14 @@ export const POST:APIRoute=async({request,cookies,locals,url})=>{
29
31
  case 'test':await syncConnection(repo,input.provider,locals,true);break;
30
32
  case 'authorize':return json({authorizationUrl:await beginOAuth(repo,await requestSessionBinding(request,cookies,locals),url,locals)});
31
33
  case 'disconnect':await disconnect(repo,input.provider,locals);break;
32
- case 'saveConfig':await repo.updateSettings({live:validateConfig(input.config)});break;
34
+ case 'saveConfig':{
35
+ await repo.updateSettings({live:validateConfig(input.config)});
36
+ if(!input.notify)break;
37
+ // the link is saved either way; report whether the notice went out
38
+ let notified:{recipients:number}|null=null,notifyError='';
39
+ try{const n=await sendManualLiveNotice(repo,{locals,fallbackOrigin:url.origin});notified=n&&{recipients:n.recipients};}catch{notifyError='Saved, but the notification could not be sent. Try again from Mobile app.';}
40
+ return json({...await adminState(repo,url,locals),notified,notifyError});
41
+ }
33
42
  case 'selectChannel':{
34
43
  const conn=await repo.live.get(input.provider);const selected=conn?.channels.find(c=>c.id===input.channelId);
35
44
  if(!conn||!selected)throw new ApiError(400,'Choose a channel from the authorized account.');
@@ -0,0 +1,8 @@
1
+ import { getLive } from '../../../lib/mobile/live';
2
+ import { readRoute } from '../../../lib/mobile/route';
3
+ import { methodNotAllowed } from '../../../lib/mobile/http';
4
+ export { preflight as OPTIONS } from '../../../lib/mobile/http';
5
+ // no-store: the status changes by the minute and must never be served stale
6
+ export const GET = readRoute(getLive, false);
7
+ export const HEAD = GET;
8
+ export const ALL = () => methodNotAllowed();
@@ -74,6 +74,7 @@
74
74
  .ma-rule:first-child { border-top: 0; padding-top: 0; }
75
75
  .ma-rule-head b { display: block; font-size: 13.5px; }
76
76
  .ma-rule-head small { display: block; color: var(--muted); font-size: 12px; margin-top: 1px; }
77
+ .ma-rule .hint { margin: 8px 0 0; }
77
78
  .ma-choices { display: flex; flex-wrap: wrap; gap: 4px 18px; margin-top: 8px; }
78
79
  .ma-check { display: inline-flex; align-items: center; gap: 8px; min-height: 30px; font-size: 13px; color: var(--text-2); cursor: pointer; }
79
80
  .ma-check input { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; }
package/src/virtual.d.ts CHANGED
@@ -9,7 +9,7 @@ declare module 'virtual:acms-config' {
9
9
  // resolved (defaults applied + validated) by integration.mjs; `posts`
10
10
  // always exists in collections, insertion order = display order
11
11
  content: {
12
- collections: Record<string, { singular: string; plural: string; base: string; redirectFrom: string[] }>;
12
+ collections: Record<string, { singular: string; plural: string; base: string; redirectFrom: string[]; localizeRedirects: boolean }>;
13
13
  tags: { base: string };
14
14
  };
15
15
  // resolved by integration.mjs; empty locales = i18n not configured