@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 +12 -0
- package/package.json +1 -1
- package/src/lib/content.ts +6 -0
- package/src/lib/mobile/schemas.ts +1 -1
- package/src/lib/mobile/site.ts +5 -2
- package/src/middleware.ts +21 -9
- package/src/pages/admin/settings.astro +4 -1
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
package/src/lib/content.ts
CHANGED
|
@@ -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 }),
|
package/src/lib/mobile/site.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
213
|
-
|
|
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
|
|
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">
|