@acms-subir/core 0.3.26 → 0.3.28
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 +16 -0
- package/integration.mjs +12 -4
- package/package.json +1 -1
- package/src/lib/content.ts +15 -1
- package/src/middleware.ts +21 -5
- package/src/pages/admin/settings.astro +12 -2
- package/src/virtual.d.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @acms-subir/core
|
|
2
2
|
|
|
3
|
+
## 0.3.28
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 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`.
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
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.
|
|
12
|
+
|
|
13
|
+
## 0.3.27
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
3
19
|
## 0.3.26
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
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.
|
|
580
|
-
*
|
|
581
|
-
*
|
|
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
|
package/package.json
CHANGED
package/src/lib/content.ts
CHANGED
|
@@ -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}`;
|
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, 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';
|
|
@@ -44,6 +44,16 @@ export function legacyContentRedirect(pathname: string): string | null {
|
|
|
44
44
|
return null;
|
|
45
45
|
}
|
|
46
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, targets that already name a
|
|
50
|
+
// language, and addresses of a collection with localizeRedirects off (tag
|
|
51
|
+
// pages go with posts) stay as written.
|
|
52
|
+
export function localizedRedirect(to: string, locale: string | undefined): string {
|
|
53
|
+
if (!locale || !to.startsWith('/') || to.startsWith('//') || stripLocale(to) !== to || !localizesRedirectsTo(to)) return to;
|
|
54
|
+
return localePath(to, locale);
|
|
55
|
+
}
|
|
56
|
+
|
|
47
57
|
// An admin form sent after the minute-long Clerk sign-in cookie lapsed (a page
|
|
48
58
|
// whose script couldn't renew it in time): Clerk renews it only on a page GET,
|
|
49
59
|
// so the POST arrives signed out and what it carried is gone. Back to the same
|
|
@@ -204,16 +214,22 @@ async function handle(context: APIContext, next: MiddlewareNext): Promise<Respon
|
|
|
204
214
|
|
|
205
215
|
// Redirects fire only when the request would otherwise 404, so a rule can
|
|
206
216
|
// never shadow real content. The table is cached per isolate for a minute.
|
|
217
|
+
// On a /es/… request a rule for the full address wins as written; otherwise
|
|
218
|
+
// the bare path's rule or legacy move applies in the visitor's language.
|
|
207
219
|
if (response.status === 404 && !pathname.startsWith('/admin') && !pathname.startsWith('/api')) {
|
|
220
|
+
const { acmsLocale: locale, acmsPath } = context.locals as { acmsLocale?: string; acmsPath?: string };
|
|
208
221
|
try {
|
|
209
|
-
const
|
|
210
|
-
|
|
222
|
+
const redirects = await getRedirects(context.locals, now);
|
|
223
|
+
const exact = acmsPath ? redirects.get(acmsPath) : undefined;
|
|
224
|
+
if (exact) return context.redirect(exact.toPath + context.url.search, exact.code);
|
|
225
|
+
const hit = redirects.get(pathname);
|
|
226
|
+
if (hit) return context.redirect(localizedRedirect(hit.toPath, locale) + context.url.search, hit.code);
|
|
211
227
|
} catch {
|
|
212
228
|
// a broken redirect lookup must never break the 404 itself
|
|
213
229
|
}
|
|
214
230
|
// admin-authored rules win; config-derived legacy moves are the fallback
|
|
215
231
|
const legacy = legacyContentRedirect(pathname);
|
|
216
|
-
if (legacy) return context.redirect(legacy + context.url.search, 301);
|
|
232
|
+
if (legacy) return context.redirect(localizedRedirect(legacy, locale) + context.url.search, 301);
|
|
217
233
|
}
|
|
218
234
|
|
|
219
235
|
return response;
|
|
@@ -7,7 +7,8 @@ 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
|
+
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,15 @@ 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
|
|
205
|
+
// language, and names the bases whose targets keep no language (collections
|
|
206
|
+
// with localizeRedirects: false, and /tag with posts)
|
|
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
|
+
: '';
|
|
203
213
|
const backend = (Astro.locals as { runtime?: { env?: { DB?: unknown } } }).runtime?.env?.DB
|
|
204
214
|
? 'd1'
|
|
205
215
|
: 'sqlite';
|
|
@@ -441,7 +451,7 @@ const groups = [
|
|
|
441
451
|
<section class="scard" id="redirects">
|
|
442
452
|
<header class="scard-head">
|
|
443
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>
|
|
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
|
|
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>
|
|
445
455
|
</header>
|
|
446
456
|
{redirects.length > 0 && (
|
|
447
457
|
<div class="rd-list">
|
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
|