@bygghammar/cms-platform 0.91.1 → 0.92.0

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.
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { auditAs } from "./audit-core";
10
+ import { normalizeRoutePath, servedPath, type RoutePathPolicy } from "../site/route-index";
10
11
 
11
12
  /** Structural subset of a Supabase-shaped client used by these cores. */
12
13
  export type RedirectClient = {
@@ -22,7 +23,7 @@ export type RedirectRow = {
22
23
  };
23
24
 
24
25
  export type PathResolution =
25
- | { kind: "page"; pageKey: string }
26
+ | { kind: "page"; pageKey: string; locale: string }
26
27
  | { kind: "redirect"; to: string; status: number }
27
28
  | { kind: "notfound" };
28
29
 
@@ -106,6 +107,23 @@ export type PageSlugRegistry = Record<string, { slug: Record<string, string | un
106
107
 
107
108
  /**
108
109
  * Resolve a URL path to a CMS page key, a redirect, or nothing.
110
+ *
111
+ * `locale` is the locale the host worked out from the domain; a locale prefix
112
+ * in the path overrides it, because the address the visitor typed is the
113
+ * address they asked for. The answer carries the locale it resolved under, so
114
+ * the caller renders the page in the language the URL named rather than the one
115
+ * the host guessed.
116
+ *
117
+ * Slugs are compared as addresses, not as strings: both sides go through
118
+ * `servedPath` and `normalizeRoutePath`, the same pair the site builds its own
119
+ * links with. Before this, every locale's slugs went into one flat map and the
120
+ * first one entered won, so a Swedish and an English page sharing a slug were
121
+ * one page and the loser was unreachable — and `/Om-Oss` reached nothing at all.
122
+ *
123
+ * Within the resolved locale a slug answers both prefixed and bare, because a
124
+ * locale served from its own domain has no prefix to strip. Across locales
125
+ * nothing is shared, which is the whole point.
126
+ *
109
127
  * Slug overrides from `page_seo` win over the static registry; builder-created
110
128
  * pages resolve by bare key through the `lookupPageByKey` callback.
111
129
  */
@@ -115,15 +133,31 @@ export async function resolvePathCore(
115
133
  client: RedirectClient;
116
134
  pages: PageSlugRegistry;
117
135
  locales: readonly string[];
136
+ locale: string;
137
+ defaultLocale: string;
118
138
  lookupPageByKey: (key: string) => Promise<string | null>;
139
+ policy?: RoutePathPolicy;
119
140
  },
120
141
  ): Promise<PathResolution> {
121
- const target = normalizePath(path);
142
+ const policy: RoutePathPolicy = { locales: deps.locales, ...(deps.policy ?? {}) };
143
+ const target = normalizeRoutePath(path, policy);
144
+
145
+ // The prefix in the URL beats the locale the host derived from the domain.
146
+ const first = target.split("/")[1] ?? "";
147
+ const prefixed = deps.locales.find((l) => l.toLowerCase() === first.toLowerCase());
148
+ const locale =
149
+ prefixed ?? (deps.locales.includes(deps.locale) ? deps.locale : deps.defaultLocale);
150
+
122
151
  const bySlug = new Map<string, string>();
123
152
  const put = (slug: string | undefined, key: string) => {
124
153
  if (slug === undefined || slug === null) return;
125
- const p = slug === "" ? "/" : `/${slug.replace(/^\/+|\/+$/g, "")}`;
126
- if (!bySlug.has(p)) bySlug.set(p, key);
154
+ for (const candidate of [
155
+ servedPath(slug, locale, deps.defaultLocale),
156
+ servedPath(slug, deps.defaultLocale, deps.defaultLocale),
157
+ ]) {
158
+ const p = normalizeRoutePath(candidate, policy);
159
+ if (!bySlug.has(p)) bySlug.set(p, key);
160
+ }
127
161
  };
128
162
 
129
163
  const { data: seoRows } = await deps.client.from("page_seo").select("page_key, slugs");
@@ -131,27 +165,35 @@ export async function resolvePathCore(
131
165
  for (const row of (seoRows ?? []) as { page_key: string; slugs: unknown }[]) {
132
166
  overrides.set(row.page_key, (row.slugs as Record<string, string>) ?? {});
133
167
  }
168
+ // Only this locale's slugs are consulted. A page with none falls back to its
169
+ // default-locale slug, exactly as path building does, so a page nobody has
170
+ // translated stays reachable under the prefix.
134
171
  for (const [key, slugs] of overrides) {
135
- for (const locale of deps.locales) put(slugs[locale], key);
172
+ put(slugs[locale] ?? slugs[deps.defaultLocale], key);
136
173
  }
137
174
  for (const key of Object.keys(deps.pages)) {
138
175
  if (overrides.has(key)) continue;
139
- for (const locale of deps.locales) put(deps.pages[key].slug[locale], key);
176
+ const slugs = deps.pages[key].slug;
177
+ put(slugs[locale] ?? slugs[deps.defaultLocale], key);
140
178
  }
141
179
 
142
180
  let pageKey = bySlug.get(target);
143
181
  if (!pageKey) {
144
- const candidate = target.replace(/^\//, "");
182
+ const rest = prefixed ? target.slice(`/${prefixed}`.length) || "/" : target;
183
+ const candidate = rest.replace(/^\//, "");
145
184
  if (/^[a-z0-9_-]+$/.test(candidate)) {
146
185
  pageKey = (await deps.lookupPageByKey(candidate)) ?? undefined;
147
186
  }
148
187
  }
149
- if (pageKey) return { kind: "page", pageKey };
188
+ if (pageKey) return { kind: "page", pageKey, locale };
150
189
 
190
+ // Redirect rows are literal strings an editor typed, matched as they were
191
+ // stored. Only page addresses go through the route identity; re-spelling a
192
+ // stored row here would look it up at an address the table does not hold.
151
193
  const { data: hit } = await deps.client
152
194
  .from("redirects")
153
195
  .select("to_path, status")
154
- .eq("from_path", target)
196
+ .eq("from_path", normalizePath(path))
155
197
  .maybeSingle();
156
198
  if (hit) return { kind: "redirect", to: hit.to_path, status: hit.status ?? 301 };
157
199
 
@@ -2,73 +2,29 @@
2
2
  * Canonical schema baseline shipped with this package version.
3
3
  *
4
4
  * `SCHEMA_MIGRATIONS` is the ordered list of migration files under
5
- * `@bygghammar/cms-platform/migrations`. A site compares it to the migrations
6
- * its database has actually applied so drift is visible in the backoffice
7
- * version screen instead of surfacing as a runtime error months later.
5
+ * `@bygghammar/cms-platform/migrations`, read from the generated manifest. A
6
+ * site compares it to the migrations its database has actually applied so
7
+ * drift is visible in the backoffice version screen instead of surfacing as a
8
+ * runtime error months later.
9
+ *
10
+ * The comparison is by name, because that is what migration tooling records.
11
+ * The manifest also carries each file's digest, and `platform/contract` exposes
12
+ * it as `migrationDigest`, for the separate question of whether a file by the
13
+ * right name holds the right bytes.
8
14
  */
9
15
 
10
- /** Every canonical migration filename, oldest first. */
11
- export const SCHEMA_MIGRATIONS: readonly string[] = [
12
- "20260728113741_f9744b19-078b-46e5-b00f-31c84aaf71ab.sql",
13
- "20260728113757_4156a886-21fb-4a1f-9c12-acb7bb145102.sql",
14
- "20260728113823_6e0237e1-99e3-4e1a-8a68-804b7bcdbbb8.sql",
15
- "20260728115305_506d92b4-129f-4d2b-961f-cf76e592677e.sql",
16
- "20260728115320_717d4447-f433-4a1d-89d2-fc8f028d4ac6.sql",
17
- "20260728115347_c77f5a56-19db-4117-bb60-9ad692b817ab.sql",
18
- "20260728132052_b692dcfe-a944-4a19-afd0-187f7600feb8.sql",
19
- "20260728183318_05b05a47-ba05-4a66-ab8f-69f2688fea84.sql",
20
- "20260728193035_84b68d27-c3f3-4516-a3a9-15fc6f476163.sql",
21
- "20260728193707_8438ee2d-d589-4048-86e2-464fd209af80.sql",
22
- "20260729055312_08862d7e-0279-4d4f-8709-a59b9f068c55.sql",
23
- "20260729055348_418b7e36-4877-4304-831f-290ca73e56f3.sql",
24
- "20260729055358_20b467e2-4efd-42c1-bd34-773ed6b77fc5.sql",
25
- "20260729061039_306859eb-a38c-44ef-af1a-6c02a8fd84db.sql",
26
- "20260730065600_54c25861-3355-4600-a72f-3d67b457ecb1.sql",
27
- "20260730071745_d4904314-15ff-4c58-ac66-85e7371e04b5.sql",
28
- "20260730071914_48163aa3-830e-4dcc-bdd4-c35be282ab87.sql",
29
- "20260730085949_bcfc3270-ea80-4617-86a3-2555ed34a280.sql",
30
- "20260730095256_c977c30e-b285-4041-8804-c0bd72b6d9b3.sql",
31
- "20260730100057_e341b887-eb0e-4a74-850e-0ee5ffb70d7a.sql",
32
- "20260730103703_d5c4e1b7-1e2c-4dd0-be4e-185c159ade69.sql",
33
- "20260730105635_3557b7da-919e-48db-8c92-8221d68d3cf9.sql",
34
- "20260730120308_12ff8cfb-43f9-4070-abde-61531ad3b75f.sql",
35
- "20260730125024_1baf8926-12a2-48f4-8edc-7309941309e9.sql",
36
- "20260730173510_feb974d1-510e-4f52-9e20-5fd0582d8952.sql",
37
- "20260730175409_9f60743f-f5e1-418c-a700-c91fb3704db8.sql",
38
- "20260730181224_1c52ff16-c7a2-4ef1-a0a8-e493c2c9106f.sql",
39
- "20260730183129_933adb75-f985-4d82-ad5d-213b69fd42e5.sql",
40
- "20260730183918_7bc7a827-2100-49f1-bc6e-24509249350b.sql",
41
- "20260730202628_7ceade5e-644c-4bc2-91b8-594133329f31.sql",
42
- "20260730211056_57f42049-00b4-473b-a2cd-65a13570b8d2.sql",
43
- "20260730214340_98af2e55-9031-4c11-9659-c47a37a8d6b7.sql",
44
- "20260731091630_8a6f28ce-5833-4901-ab17-27648cef32ec.sql",
45
- "20260731094716_4c68a1df-83ed-4ba8-8019-fa2fe2b9681e.sql",
46
- "20260731191655_fef97c3c-1397-47b4-8b56-afb020298e97.sql",
47
- "20260731192212_95772a2f-19a1-4013-b463-2eb161e96737.sql",
48
- "20260731192815_c490653e-1232-4ad9-a150-3c83d2b90f5e.sql",
49
- "20260731193135_7966e740-5e86-4b14-8e1d-5119069666ba.sql",
50
- "20260731193245_599790d0-ca5e-4b5d-a3e7-d69b2a79e1a3.sql",
51
- "20260731212959_a748cb0d-4dc7-45dc-be90-f6bacbfab26c.sql",
52
- "20260801003057_1195f4c1-fb54-42dc-9718-638a25e6a82e.sql",
53
- "20260801003300_fc591960-cb41-41cf-837d-2eef23b29ca9.sql",
54
- "20260802151445_a56b0023-eaf7-41e2-a7fb-d4beddfc587c.sql",
55
- "20260802202621_d661af8a-8513-4b54-b375-f963ae824c8f.sql",
56
- "20260802202702_c30cd837-744c-4641-85f1-edd3a7ebbcb6.sql",
57
- "20260802202720_4418d18d-c851-42dc-9345-78fc330128ae.sql",
58
- "20260803132514_78ba1f53-c85b-4457-96b1-7d49c5c8b494.sql",
59
- "20260803181201_28c8aa09-baa8-431c-8338-90b1af9c8172.sql",
60
- "20260803201518_6e8a6028-b70f-4a7d-8bc9-95c86304e7ae.sql",
61
- "20260803201535_32a9a213-cb92-4035-b8cd-8211ca34af68.sql",
62
- "20260811103505_29df753d-14fa-4a05-8fea-f4207785fba3.sql",
63
- "20260811130318_7cb95e95-9baa-40af-8f3f-de1d4e09cec4.sql",
64
- "20260818040722_1bdf1b8f-7fb0-4ae8-8ecf-73f1fadff3c2.sql",
65
- "20260818040808_69a5e832-57e8-4aa7-aff8-7fd7777faa7c.sql",
66
- "20260818040839_e6523163-bbf5-4a39-9233-91759059569a.sql",
67
- "20260902090000_dc072b25-8467-46ae-aef3-0ccdf40aa7eb.sql",
68
- "20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql",
69
- "20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql",
70
- "20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql",
71
- ];
16
+ import { MIGRATION_NAMES } from "../platform/migration-manifest";
17
+
18
+ /**
19
+ * Every canonical migration filename, oldest first.
20
+ *
21
+ * Derived from the generated catalog, which is written from the `migrations/`
22
+ * directory itself. This module and `platform/contract` used to hold separate
23
+ * copies of the same sixty names; a site that trusts one and displays the
24
+ * other must not be able to see two answers, and equality by construction is a
25
+ * stronger guarantee than a test noticing they had already diverged.
26
+ */
27
+ export const SCHEMA_MIGRATIONS: readonly string[] = MIGRATION_NAMES;
72
28
 
73
29
  /** Schema baseline version = the newest canonical migration. */
74
30
  export const SCHEMA_BASELINE = SCHEMA_MIGRATIONS[SCHEMA_MIGRATIONS.length - 1] ?? "";