@acms-subir/core 0.3.27 → 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 CHANGED
@@ -1,5 +1,15 @@
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
+
3
13
  ## 0.3.27
4
14
 
5
15
  ### 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. 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
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.28",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -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
@@ -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">
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