@acms-subir/core 0.3.25 → 0.3.27

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,17 @@
1
1
  # @acms-subir/core
2
2
 
3
+ ## 0.3.27
4
+
5
+ ### Patch Changes
6
+
7
+ - 2972283: Redirects from another language now keep it. On a bilingual site, `/es/blog` went to `/sermon` (English) instead of `/es/sermon`, and the same happened to `/es/blog/<slug>`, a collection's `redirectFrom` bases, old `/blog/tag/…` addresses, and Admin → Redirects rules: a rule `/old` → `/new` sent `/es/old` to `/new`. A redirect on a `/es/…` address now adds the language back to its target, so these go to `/es/sermon`, `/es/tag/…` and `/es/new`. Full URLs (`https://…`) and targets that already start with a language are left as written. An admin rule written for the full `/es/old` address, which could never match before, now wins over the `/old` rule and is used exactly as written. The Redirects card says so on sites with more than one language.
8
+
9
+ ## 0.3.26
10
+
11
+ ### Patch Changes
12
+
13
+ - 89ecf3c: Apps can now follow a collection's old addresses. Each `features.blog` entry in the app API's `/api/v1/site` (and the client's `Site` type) gains `redirectFrom`: the absolute addresses the website 301s to that collection's `url`. It lists the collection's `redirectFrom` bases, plus `/blog` for `posts` once its base moved away from that default, so on a site whose posts live at `/sermon`, an app can open a `/blog` link as the sermons list instead of looking for a page called "blog". The list comes from the same place as the website's redirects, so the two can't drift apart. An address another collection lives at is left out, so a site with sermons at `/sermon` and its own blog at `/blog` keeps `/blog` for the blog.
14
+
3
15
  ## 0.3.25
4
16
 
5
17
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.25",
3
+ "version": "0.3.27",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -33,6 +33,12 @@ export const collectionUrl = (key: string, slug?: string) => {
33
33
  return slug ? `${base}/${slug}` : base;
34
34
  };
35
35
 
36
+ // Old bases a collection 301s from (middleware.ts), also listed in the app
37
+ // API's site features so apps can follow them: its redirectFrom, plus '/blog'
38
+ // for the built-in collection (its historic default) once it moved away.
39
+ export const legacyBases = (key: string, c: CollectionConfig) =>
40
+ [...new Set([...(key === 'posts' ? ['/blog'] : []), ...c.redirectFrom])].filter((from) => from !== c.base);
41
+
36
42
  // Which collection a public URL belongs to — injected routes share one page
37
43
  // file per shape (blog/index.astro, blog/[slug].astro), and Astro's
38
44
  // injectRoute can't attach props, so pages resolve their collection from the
@@ -8,7 +8,7 @@ const Palette = z.object({ bg: str, ink: str, muted: str, line: str, accent: 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 })), 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 }), 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 }),
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import type { ContentRepository } from '../repo/types';
3
- import { CONTENT } from '../content';
3
+ import { CONTENT, legacyBases } from '../content';
4
4
  import { I18N, localeName } from '../i18n';
5
5
  import { CALENDAR, bookingsEnabled } from '../calendar/config';
6
6
  import { SEARCH } from '../search-config';
@@ -15,6 +15,7 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
15
15
  z.object({}).strict().parse(queryParams(url));
16
16
  const [settings, nav, pages] = await Promise.all([repo.getSettings(), repo.listNavItems(), repo.listPages()]);
17
17
  const origin = siteOrigin(settings, url);
18
+ const bases = new Set(Object.values(CONTENT.collections).map(c => c.base));
18
19
  const header = nav.filter(n => n.area === 'header').sort((a, b) => a.order - b.order);
19
20
  const menu = header.length ? header : [
20
21
  { label: 'Home', url: '/' },
@@ -28,7 +29,9 @@ export async function getSite(repo: ContentRepository, url: URL, _id = '', local
28
29
  menu: menu.map(n => ({ label: n.label, href: safeUrl(n.url, origin, true) })),
29
30
  languages: { default: I18N.defaultLocale || null, locales: I18N.locales.map(code => ({ code, name: localeName(code, true) })) },
30
31
  features: {
31
- blog: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, name: c.plural, url: safeUrl(c.base, origin) })), calendar: CALENDAR.enabled, bookings: bookingsEnabled(), search: SEARCH.enabled,
32
+ // redirectFrom: old addresses the website 301s to url, so apps can follow them too,
33
+ // minus any another collection now lives at (the website serves that one first)
34
+ blog: Object.entries(CONTENT.collections).map(([key, c]) => ({ key, name: c.plural, url: safeUrl(c.base, origin), redirectFrom: legacyBases(key, c).filter(from => !bases.has(from)).map(from => safeUrl(from, origin)) })), calendar: CALENDAR.enabled, bookings: bookingsEnabled(), search: SEARCH.enabled,
32
35
  // Admin → Mobile app → App access
33
36
  bookingChanges: CALENDAR.enabled && settings.mobileAccess.bookingChanges, guestSignIn: await guestSignInReady(repo, locals),
34
37
  forms: settings.mobileAccess.forms, links: settings.mobileAccess.links,
package/src/middleware.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { defineMiddleware } from 'astro:middleware';
2
2
  import type { APIContext, MiddlewareNext } from 'astro';
3
- import { I18N } from './lib/i18n';
3
+ import { I18N, localePath, stripLocale } from './lib/i18n';
4
4
  import { bindAuthEnvironment, getRequestAuth, requestPerson, shouldResolveClerk, validateRequestSession, withClerkHeaders, type RequestAuth } from './lib/auth/request';
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 } from './lib/content';
8
+ import { CONTENT, legacyBases } 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';
@@ -28,10 +28,7 @@ const TAG_SOURCES = [
28
28
  ]),
29
29
  ];
30
30
  const COLLECTION_SOURCES: Array<{ from: string; to: string }> = Object.entries(CONTENT.collections).flatMap(
31
- ([key, c]) =>
32
- [...new Set([...(key === 'posts' ? ['/blog'] : []), ...c.redirectFrom])]
33
- .filter((from) => from !== c.base)
34
- .map((from) => ({ from, to: c.base }))
31
+ ([key, c]) => legacyBases(key, c).map((from) => ({ from, to: c.base }))
35
32
  );
36
33
 
37
34
  export function legacyContentRedirect(pathname: string): string | null {
@@ -47,6 +44,15 @@ export function legacyContentRedirect(pathname: string): string | null {
47
44
  return null;
48
45
  }
49
46
 
47
+ // A /es/… request reaches the redirect lookup rewritten to its bare path (see
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.
51
+ export function localizedRedirect(to: string, locale: string | undefined): string {
52
+ if (!locale || !to.startsWith('/') || to.startsWith('//') || stripLocale(to) !== to) return to;
53
+ return localePath(to, locale);
54
+ }
55
+
50
56
  // An admin form sent after the minute-long Clerk sign-in cookie lapsed (a page
51
57
  // whose script couldn't renew it in time): Clerk renews it only on a page GET,
52
58
  // so the POST arrives signed out and what it carried is gone. Back to the same
@@ -207,16 +213,22 @@ async function handle(context: APIContext, next: MiddlewareNext): Promise<Respon
207
213
 
208
214
  // Redirects fire only when the request would otherwise 404, so a rule can
209
215
  // never shadow real content. The table is cached per isolate for a minute.
216
+ // On a /es/… request a rule for the full address wins as written; otherwise
217
+ // the bare path's rule or legacy move applies in the visitor's language.
210
218
  if (response.status === 404 && !pathname.startsWith('/admin') && !pathname.startsWith('/api')) {
219
+ const { acmsLocale: locale, acmsPath } = context.locals as { acmsLocale?: string; acmsPath?: string };
211
220
  try {
212
- const hit = (await getRedirects(context.locals, now)).get(pathname);
213
- if (hit) return context.redirect(hit.toPath + context.url.search, hit.code);
221
+ const redirects = await getRedirects(context.locals, now);
222
+ const exact = acmsPath ? redirects.get(acmsPath) : undefined;
223
+ if (exact) return context.redirect(exact.toPath + context.url.search, exact.code);
224
+ const hit = redirects.get(pathname);
225
+ if (hit) return context.redirect(localizedRedirect(hit.toPath, locale) + context.url.search, hit.code);
214
226
  } catch {
215
227
  // a broken redirect lookup must never break the 404 itself
216
228
  }
217
229
  // admin-authored rules win; config-derived legacy moves are the fallback
218
230
  const legacy = legacyContentRedirect(pathname);
219
- if (legacy) return context.redirect(legacy + context.url.search, 301);
231
+ if (legacy) return context.redirect(localizedRedirect(legacy, locale) + context.url.search, 301);
220
232
  }
221
233
 
222
234
  return response;
@@ -8,6 +8,7 @@ import type { Permission } from '../../lib/permissions/catalog';
8
8
  import { AUTH } from '../../lib/auth/config';
9
9
  import { invalidateRedirectCache } from '../../middleware';
10
10
  import { CONTENT } from '../../lib/content';
11
+ import { extraLocales } from '../../lib/i18n';
11
12
  import { UMAMI_ID_RE, META_PIXEL_ID_RE, TIKTOK_PIXEL_ID_RE, normalizeScriptUrl } from '../../lib/analytics';
12
13
 
13
14
  const repo = await getRepo(Astro.locals);
@@ -200,6 +201,8 @@ if (Astro.request.method === 'POST') {
200
201
 
201
202
  const settings = await repo.getSettings();
202
203
  const redirects = await repo.listRedirects();
204
+ // the redirect card explains /<locale>/… rules with the site's first extra language
205
+ const extraLang = extraLocales()[0] ?? '';
203
206
  const backend = (Astro.locals as { runtime?: { env?: { DB?: unknown } } }).runtime?.env?.DB
204
207
  ? 'd1'
205
208
  : 'sqlite';
@@ -441,7 +444,7 @@ const groups = [
441
444
  <section class="scard" id="redirects">
442
445
  <header class="scard-head">
443
446
  <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>
444
- <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.</p></div>
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>
445
448
  </header>
446
449
  {redirects.length > 0 && (
447
450
  <div class="rd-list">