@murumets-ee/content 0.75.1 → 0.76.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.
Files changed (36) hide show
  1. package/dist/admin.mjs +1 -1
  2. package/dist/block-resolver.d.mts +5 -0
  3. package/dist/block-resolver.d.mts.map +1 -1
  4. package/dist/block-resolver.mjs +1 -1
  5. package/dist/block-resolver.mjs.map +1 -1
  6. package/dist/client.mjs +1 -1
  7. package/dist/enumerate-CBqu63Kp.mjs +2 -0
  8. package/dist/{enumerate-B5tWe9JC.mjs.map → enumerate-CBqu63Kp.mjs.map} +1 -1
  9. package/dist/plugin-BxOHoKPR.mjs +2 -0
  10. package/dist/{plugin-DEYaEeWC.mjs.map → plugin-BxOHoKPR.mjs.map} +1 -1
  11. package/dist/plugin.mjs +1 -1
  12. package/dist/resolve-context-MRZZ3rQl.mjs +2 -0
  13. package/dist/{resolve-context-rr-Eanwp.mjs.map → resolve-context-MRZZ3rQl.mjs.map} +1 -1
  14. package/dist/resolve.mjs +2 -2
  15. package/dist/resolve.mjs.map +1 -1
  16. package/dist/routes-C9I1iZw8.mjs +2 -0
  17. package/dist/{routes-DuJa-w4T.mjs.map → routes-C9I1iZw8.mjs.map} +1 -1
  18. package/dist/schema-0zayzpD_.mjs +2 -0
  19. package/dist/schema-0zayzpD_.mjs.map +1 -0
  20. package/dist/schema-D8sBQozw.mjs +2 -0
  21. package/dist/schema-D8sBQozw.mjs.map +1 -0
  22. package/dist/seed.d.mts +20 -1
  23. package/dist/seed.d.mts.map +1 -1
  24. package/dist/seed.mjs +1 -1
  25. package/dist/seed.mjs.map +1 -1
  26. package/dist/{versions-qld3n7B-.mjs → versions-BDxB6k_S.mjs} +2 -2
  27. package/dist/{versions-qld3n7B-.mjs.map → versions-BDxB6k_S.mjs.map} +1 -1
  28. package/package.json +9 -9
  29. package/dist/apply-path-s-YeCUXP.mjs +0 -2
  30. package/dist/apply-path-s-YeCUXP.mjs.map +0 -1
  31. package/dist/enumerate-B5tWe9JC.mjs +0 -2
  32. package/dist/plugin-DEYaEeWC.mjs +0 -2
  33. package/dist/resolve-context-rr-Eanwp.mjs +0 -2
  34. package/dist/routes-DuJa-w4T.mjs +0 -2
  35. package/dist/schema-BPDn8ajz.mjs +0 -2
  36. package/dist/schema-BPDn8ajz.mjs.map +0 -1
package/dist/plugin.mjs CHANGED
@@ -1 +1 @@
1
- import{a as e,i as t,n,o as r,r as i,t as a}from"./routing-strategy-DQGPPBlW.mjs";import{t as o}from"./content-config-store-8omQF5p5.mjs";import{t as s}from"./plugin-DEYaEeWC.mjs";export{t as Locale,i as Message,e as ROUTING_STRATEGIES,s as content,o as getContentConfig,r as i18nSettings,a as localePrefixForStrategy,n as prefixDefaultLocaleForStrategy};
1
+ import{a as e,i as t,n,o as r,r as i,t as a}from"./routing-strategy-DQGPPBlW.mjs";import{t as o}from"./content-config-store-8omQF5p5.mjs";import{t as s}from"./plugin-BxOHoKPR.mjs";export{t as Locale,i as Message,e as ROUTING_STRATEGIES,s as content,o as getContentConfig,r as i18nSettings,a as localePrefixForStrategy,n as prefixDefaultLocaleForStrategy};
@@ -0,0 +1,2 @@
1
+ import{t as e}from"./rolldown-runtime-DK3Fl9T5.mjs";import{t}from"./content-config-store-8omQF5p5.mjs";import{n}from"./schema-D8sBQozw.mjs";import{i as r,n as i}from"./schema-0zayzpD_.mjs";import{createSystemReachResolver as a}from"@murumets-ee/core";import{QueryClient as o}from"@murumets-ee/entity/query";import{inArray as s}from"drizzle-orm";import{z as c}from"zod";import{DEFAULT_LOCALE as l,GLOBAL_SCOPE_ID as u}from"@murumets-ee/settings";import{toolkitSettingsTable as d}from"@murumets-ee/settings/schema";const f=c.object({entity:c.string().min(1),id:c.string().uuid()});function p(e){let t=f.safeParse(e);return t.success?t.data:null}var m=e({buildCanonicalPathDeps:()=>g,buildCanonicalPathsBatchDeps:()=>b,buildEnumerateDeps:()=>y,buildPublishChecker:()=>_,buildRedirectsListDeps:()=>C,buildResolveDeps:()=>w,buildSitemapDeps:()=>S,createHomepageReader:()=>v});const h=a(`path-resolver`);function g(e){return{pathsClient:n.makeClient(e.db.readOnly)}}function _(e){return r({entityResolver:t=>e.entities.get(t),queryClientFactory:t=>new o({entity:t,db:e.db.readOnly,logger:e.logger.child({resolve:`publish-check`,entity:t.name}),contextResolver:h})})}function v(e){let t=d.makeClient(e);return async()=>{let e=await t.findOne({namespace:`site`,key:`homeDocument`,scope:`global`,scopeId:u,locale:l});return e?p(e.value):null}}function y(e){let t=e.db.readOnly;return{pathsClient:n.makeClient(t),filterPublished:async(n,r,i)=>{if(r.length===0)return new Set;let a=e.entities.get(n);if(a===void 0)return new Set;let c=new o({entity:a,db:t,logger:e.logger.child({resolve:`enumerate-publish`,entity:a.name}),contextResolver:h}),l=c.getTable().id,u=await c.findMany({where:s(l,[...r]),locale:i}),d=new Set;for(let e of u){let t=e.id;typeof t==`string`&&d.add(t)}return d}}}function b(e){return{pathsClient:n.makeClient(e.db.readOnly)}}function x(e){let t=e.db.readOnly;return async(n,r,i)=>{if(r.length===0)return new Set;let a=e.entities.get(n);if(a===void 0||!a.behaviors?.some(e=>e.name===`seoMeta`))return new Set;let c=new o({entity:a,db:t,logger:e.logger.child({resolve:`sitemap-noindex`,entity:a.name}),contextResolver:h}),l=c.getTable().id,u=await c.findMany({where:s(l,[...r]),locale:i}),d=new Set;for(let e of u){let t=e.id,n=e.noIndex;typeof t==`string`&&n===!0&&d.add(t)}return d}}function S(e){return{enumerate:y(e),canonicalBatch:b(e),filterNoIndex:x(e)}}function C(e){let t=y(e);return{redirectsClient:i.makeClient(e.db.readOnly),pathsClient:t.pathsClient,canonicalBatch:b(e),filterPublished:t.filterPublished}}function w(e){let r=e.db.readOnly;return{pathsClient:n.makeClient(r),redirectsClient:i.makeClient(r),publishChecker:_(e),homepageReader:v(r),locales:t().locales.map(e=>e.code),logger:e.logger}}export{C as a,v as c,p as d,_ as i,m as l,b as n,w as o,y as r,S as s,g as t,f as u};
2
+ //# sourceMappingURL=resolve-context-MRZZ3rQl.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"resolve-context-rr-Eanwp.mjs","names":[],"sources":["../src/paths/homepage.ts","../src/paths/resolve-context.ts"],"sourcesContent":["/**\n * Homepage pointer — the `site.homeDocument` setting that maps `/` to a document\n * (plan/routing PR06; phase `05-resolution-and-enumeration-api.md` §3, D004).\n *\n * The value is stored as JSONB in `toolkit_settings` (the `site` namespace,\n * declared per-app — see each app's `settings/site.ts`). Content can't statically\n * import an app's `SettingsDefinition`, so the resolution layer reads the raw row\n * and parses the value HERE, defensively (CLAUDE.md \"validate at the boundary\"):\n * an unset row OR a value that fails this schema means \"no homepage configured\"\n * (`/` resolves to `none`) — never a thrown error, since this runs on every `/`\n * request.\n *\n * This module is a LEAF (zod only) so the parse is unit-testable without a DB and\n * the resolution core stays free of settings/core imports; the actual\n * `toolkit_settings` read is wired in `./resolve-context.ts`.\n */\n\nimport { z } from 'zod'\n\n/** A resolved pointer at the document that serves `/`. */\nexport interface HomepagePointer {\n entity: string\n id: string\n}\n\n/**\n * Shape of the `site.homeDocument` JSONB value. `id` is a UUID — entity row ids\n * are UUIDs across this codebase (only USER ids are varchar; CLAUDE.md).\n */\nexport const homepagePointerSchema = z.object({\n entity: z.string().min(1),\n id: z.string().uuid(),\n})\n\n/**\n * Parse a raw `site.homeDocument` value into a {@link HomepagePointer}, or `null`\n * when it's unset / malformed. Never throws.\n */\nexport function parseHomepagePointer(value: unknown): HomepagePointer | null {\n const parsed = homepagePointerSchema.safeParse(value)\n return parsed.success ? parsed.data : null\n}\n","/**\n * App-binding for the path-resolution core (plan/routing PR06).\n *\n * `./resolve.ts` is a LEAF — it takes every dependency (path/redirect readers,\n * publish checker, homepage reader) as injected params so it imports no\n * `@murumets-ee/core` graph. THIS module is the outer seam that holds those\n * imports and builds concrete `deps` from a live `ToolkitApp`. Only the\n * request-time resolution / enumeration surfaces (future admin routes,\n * content-api, a frontend catch-all) reach this module — never the `lumi` CLI\n * command graph (F027 guardrail; keeps the `server-only`-adjacent imports out of\n * `paths/jobs.ts`).\n *\n * **Publish check runs under an ELEVATED system context** (mirrors\n * `../locale-registry.ts`'s `SYSTEM_CONTEXT`), NOT the ambient request context.\n * Rationale + security: URL resolution is a SYSTEM derivation that must work for\n * any caller — an anonymous public request, or a build-time enumeration with no\n * request context at all — and must not fail just because the caller lacks a\n * per-entity `view` grant. The elevated context only bypasses the\n * permission/scope check; the PUBLISH FILTER (`QueryClient.buildPublishFilter`)\n * and the trashed-row exclusion still apply, so an unpublished / trashed\n * document still resolves to `null` → not routable.\n *\n * Since D017 that publish filter is CONDITIONAL — it holds for any principal\n * WITHOUT `view_unpublished`, and is lifted for one that holds it. What makes\n * the paragraph above true is therefore not the filter being unconditional but\n * {@link SYSTEM_CONTEXT} WITHHOLDING that action (and every write action with\n * it). No content data is returned from the check (only a boolean), so\n * elevation leaks nothing — the downstream content fetch still runs under the\n * caller's real permissions.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport { createSystemReachResolver } from '@murumets-ee/core'\nimport type { Entity, SecurityContext } from '@murumets-ee/entity'\nimport { QueryClient } from '@murumets-ee/entity/query'\nimport { DEFAULT_LOCALE, GLOBAL_SCOPE_ID } from '@murumets-ee/settings'\nimport { toolkitSettingsTable } from '@murumets-ee/settings/schema'\nimport { inArray } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { getContentConfig } from '../content-config-store.js'\nimport { toolkitRedirectsTable } from '../redirects/schema.js'\nimport type { CanonicalPathsBatchDeps, EnumeratePathsDeps, PublishFilter } from './enumerate.js'\nimport { parseHomepagePointer } from './homepage.js'\nimport type { RedirectsListDeps } from './redirects-list.js'\nimport {\n type AlternatesDeps,\n type CanonicalPathDeps,\n createPublishChecker,\n type HomepageReader,\n type PublishChecker,\n type ResolvePathDeps,\n} from './resolve.js'\nimport { toolkitPathsTable } from './schema.js'\nimport type { SitemapDeps } from './sitemap.js'\n\n/**\n * Elevated **reach** resolver for the publish check — the roles/locale-registry\n * -read analog (see file header for why this is safe).\n *\n * It grants `view` and NOTHING else — in particular not `view_unpublished`,\n * which is what makes the file header's claim true. Until D017 the header's\n * \"the publish filter is applied by `QueryClient` regardless\" held for ANY\n * checker; now that publish-visibility IS a permission, a `checker: () => true`\n * here would answer \"yes\" to `view_unpublished` and every door this module backs\n * (`_resolve`, `_alternates`, `_paths`, sitemap, redirects) would start\n * resolving drafts to public URLs. {@link createSystemReachResolver} owns that\n * exception centrally so it can't be re-derived (or forgotten) per call site.\n *\n * The explicit id is the audit identity: it must be distinguishable from the\n * menu resolver's, or two elevated readers are one indistinguishable `'system'`\n * in the log.\n */\nconst SYSTEM_CONTEXT: () => SecurityContext = createSystemReachResolver('path-resolver')\n\n/**\n * Build the minimal deps for {@link import('./resolve.js').getCanonicalPath} —\n * just the `toolkit_paths` reader. Task 2's `resolveEntityHref` / search\n * url-builder re-points use this (no publish checker / settings machinery).\n */\nexport function buildCanonicalPathDeps(app: ToolkitApp): CanonicalPathDeps {\n return { pathsClient: toolkitPathsTable.makeClient(app.db.readOnly) }\n}\n\n/**\n * Build a live {@link PublishChecker} that reads through an elevated-context\n * `QueryClient` per entity (publish filter auto-applied). See file header.\n */\nexport function buildPublishChecker(app: ToolkitApp): PublishChecker {\n return createPublishChecker({\n entityResolver: (name) => app.entities.get(name),\n queryClientFactory: (entity: Entity) =>\n new QueryClient({\n entity,\n db: app.db.readOnly,\n logger: app.logger.child({ resolve: 'publish-check', entity: entity.name }),\n contextResolver: SYSTEM_CONTEXT,\n }),\n })\n}\n\n/**\n * Build the homepage reader — a single indexed `toolkit_settings` lookup for\n * `site.homeDocument` (global scope, default locale), zod-parsed defensively\n * (unset / malformed → `null`, never a throw). No cache in v1 (phase 05 §5).\n */\nexport function createHomepageReader(db: PostgresJsDatabase): HomepageReader {\n const settingsClient = toolkitSettingsTable.makeClient(db)\n return async () => {\n const row = await settingsClient.findOne({\n namespace: 'site',\n key: 'homeDocument',\n scope: 'global',\n scopeId: GLOBAL_SCOPE_ID,\n locale: DEFAULT_LOCALE,\n })\n if (!row) return null\n return parseHomepagePointer(row.value)\n }\n}\n\n/**\n * Build the deps for {@link import('./enumerate.js').enumeratePaths} — the\n * `toolkit_paths` reader plus a BATCHED publish filter. `filterPublished` runs\n * ONE elevated-context `QueryClient.findMany({ where: inArray(getTable().id,\n * ids), locale })` per entity-type group (not per row) — the trash filter\n * applies unconditionally, and the publish filter applies because the elevated\n * context WITHHOLDS `view_unpublished` (D017; see file header), so no\n * unpublished/trashed id is returned. An unknown/unregistered entity type yields the empty set (its\n * rows are treated as unpublished → dropped, fail-closed).\n */\nexport function buildEnumerateDeps(app: ToolkitApp): EnumeratePathsDeps {\n const db = app.db.readOnly\n return {\n pathsClient: toolkitPathsTable.makeClient(db),\n filterPublished: async (entityType, entityIds, locale) => {\n if (entityIds.length === 0) return new Set()\n const entity = app.entities.get(entityType)\n if (entity === undefined) return new Set()\n const client = new QueryClient({\n entity,\n db,\n logger: app.logger.child({ resolve: 'enumerate-publish', entity: entity.name }),\n contextResolver: SYSTEM_CONTEXT,\n })\n // Dynamic `id` column ref for the batched IN filter. `getTable()` is\n // `PgTableWithColumns<any>`; the structural cast narrows to the one column\n // we filter on (the same idiom paths/jobs.ts uses for dynamic column refs).\n const idColumn = (client.getTable() as unknown as { id: PgColumn }).id\n const rows = await client.findMany({ where: inArray(idColumn, [...entityIds]), locale })\n const published = new Set<string>()\n for (const row of rows) {\n const id = (row as { id?: unknown }).id\n if (typeof id === 'string') published.add(id)\n }\n return published\n },\n }\n}\n\n/**\n * Build the deps for {@link import('./enumerate.js').getCanonicalPaths} — just\n * the `toolkit_paths` reader (batched, NO publish filter, like\n * {@link buildCanonicalPathDeps}). Task 2's search url re-point uses this to\n * resolve a search page's result urls from the index in one query.\n */\nexport function buildCanonicalPathsBatchDeps(app: ToolkitApp): CanonicalPathsBatchDeps {\n return { pathsClient: toolkitPathsTable.makeClient(app.db.readOnly) }\n}\n\n/**\n * Build the `filterNoIndex` dep for {@link import('./sitemap.js').generateSitemap}\n * (PR 11's `seoMeta()`) — mirrors {@link buildEnumerateDeps}'s `filterPublished`\n * construction EXACTLY (elevated-context `QueryClient`, one `findMany` per\n * entity-type group), with two differences:\n *\n * 1. Short-circuits to the empty set when the entity does NOT declare\n * `seoMeta()` — most routable entities won't have this behavior, and\n * querying a `noIndex` column that doesn't exist would throw.\n * 2. Filters on the LOCALE-MERGED DTO rather than a raw SQL column, because\n * `noIndex` is `translatable: true` — its live per-locale value only\n * exists after `QueryClient.findMany`'s `mergeTranslations` step, not as a\n * column on the main table itself (unlike `filterPublished`'s `status`,\n * which is never translatable). The `id IN (...)` filter is still pushed\n * to SQL; only the `noIndex` check runs over the (batch-bounded) merged\n * rows in memory.\n */\nfunction buildNoIndexFilter(app: ToolkitApp): PublishFilter {\n const db = app.db.readOnly\n return async (entityType, entityIds, locale) => {\n if (entityIds.length === 0) return new Set()\n const entity = app.entities.get(entityType)\n if (entity === undefined) return new Set()\n if (!entity.behaviors?.some((b) => b.name === 'seoMeta')) return new Set()\n const client = new QueryClient({\n entity,\n db,\n logger: app.logger.child({ resolve: 'sitemap-noindex', entity: entity.name }),\n contextResolver: SYSTEM_CONTEXT,\n })\n // Dynamic `id` column ref for the batched IN filter — same idiom as\n // `buildEnumerateDeps.filterPublished`.\n const idColumn = (client.getTable() as unknown as { id: PgColumn }).id\n const rows = await client.findMany({ where: inArray(idColumn, [...entityIds]), locale })\n const noIndexed = new Set<string>()\n for (const row of rows) {\n const id = (row as { id?: unknown }).id\n const noIndex = (row as { noIndex?: unknown }).noIndex\n if (typeof id === 'string' && noIndex === true) noIndexed.add(id)\n }\n return noIndexed\n }\n}\n\n/**\n * Build the deps for {@link import('./sitemap.js').generateSitemap} — the\n * one-stop bundle of {@link buildEnumerateDeps} (per-locale enumeration + its\n * BATCHED publish filter, reused for the cross-locale alternate publish gate),\n * {@link buildCanonicalPathsBatchDeps} (the batched cross-locale forward index\n * read), and {@link buildNoIndexFilter} (PR 11's batched `noIndex` exclusion).\n * Mirrors `buildResolveDeps`'s one-stop-deps pattern.\n */\nexport function buildSitemapDeps(app: ToolkitApp): SitemapDeps {\n return {\n enumerate: buildEnumerateDeps(app),\n canonicalBatch: buildCanonicalPathsBatchDeps(app),\n filterNoIndex: buildNoIndexFilter(app),\n }\n}\n\n/**\n * Build the deps for {@link import('./redirects-list.js').getRedirects} — the\n * one-stop bundle for the build-time redirect list (PR08). Composes\n * {@link buildEnumerateDeps} (its `toolkit_paths` reader powers both the\n * historical keyset and the `path`-kind existence check; its BATCHED\n * `filterPublished` is the shared publish gate), {@link buildCanonicalPathsBatchDeps}\n * (the batched owning-document current-canonical lookup), and a\n * `toolkit_redirects` reader for the manual-redirect keyset. Mirrors\n * {@link buildSitemapDeps}'s one-stop-deps style.\n */\nexport function buildRedirectsListDeps(app: ToolkitApp): RedirectsListDeps {\n const enumerate = buildEnumerateDeps(app)\n return {\n redirectsClient: toolkitRedirectsTable.makeClient(app.db.readOnly),\n pathsClient: enumerate.pathsClient,\n canonicalBatch: buildCanonicalPathsBatchDeps(app),\n filterPublished: enumerate.filterPublished,\n }\n}\n\n/**\n * Build the full {@link ResolvePathDeps} (+ {@link AlternatesDeps}) from an app —\n * the one-stop deps for `resolvePath` / `resolveDraftPath` / `getAlternates`.\n * `locales` are the statically-configured content locales (v1 — matches the\n * redirect routes' own configured-locale source; DB-merged locale overrides are\n * a later refinement).\n */\nexport function buildResolveDeps(app: ToolkitApp): ResolvePathDeps & AlternatesDeps {\n const db = app.db.readOnly\n return {\n pathsClient: toolkitPathsTable.makeClient(db),\n redirectsClient: toolkitRedirectsTable.makeClient(db),\n publishChecker: buildPublishChecker(app),\n homepageReader: createHomepageReader(db),\n locales: getContentConfig().locales.map((l) => l.code),\n // Only consumer: the historical-redirect lookup's truncation warning.\n logger: app.logger,\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"resolve-context-MRZZ3rQl.mjs","names":[],"sources":["../src/paths/homepage.ts","../src/paths/resolve-context.ts"],"sourcesContent":["/**\n * Homepage pointer — the `site.homeDocument` setting that maps `/` to a document\n * (plan/routing PR06; phase `05-resolution-and-enumeration-api.md` §3, D004).\n *\n * The value is stored as JSONB in `toolkit_settings` (the `site` namespace,\n * declared per-app — see each app's `settings/site.ts`). Content can't statically\n * import an app's `SettingsDefinition`, so the resolution layer reads the raw row\n * and parses the value HERE, defensively (CLAUDE.md \"validate at the boundary\"):\n * an unset row OR a value that fails this schema means \"no homepage configured\"\n * (`/` resolves to `none`) — never a thrown error, since this runs on every `/`\n * request.\n *\n * This module is a LEAF (zod only) so the parse is unit-testable without a DB and\n * the resolution core stays free of settings/core imports; the actual\n * `toolkit_settings` read is wired in `./resolve-context.ts`.\n */\n\nimport { z } from 'zod'\n\n/** A resolved pointer at the document that serves `/`. */\nexport interface HomepagePointer {\n entity: string\n id: string\n}\n\n/**\n * Shape of the `site.homeDocument` JSONB value. `id` is a UUID — entity row ids\n * are UUIDs across this codebase (only USER ids are varchar; CLAUDE.md).\n */\nexport const homepagePointerSchema = z.object({\n entity: z.string().min(1),\n id: z.string().uuid(),\n})\n\n/**\n * Parse a raw `site.homeDocument` value into a {@link HomepagePointer}, or `null`\n * when it's unset / malformed. Never throws.\n */\nexport function parseHomepagePointer(value: unknown): HomepagePointer | null {\n const parsed = homepagePointerSchema.safeParse(value)\n return parsed.success ? parsed.data : null\n}\n","/**\n * App-binding for the path-resolution core (plan/routing PR06).\n *\n * `./resolve.ts` is a LEAF — it takes every dependency (path/redirect readers,\n * publish checker, homepage reader) as injected params so it imports no\n * `@murumets-ee/core` graph. THIS module is the outer seam that holds those\n * imports and builds concrete `deps` from a live `ToolkitApp`. Only the\n * request-time resolution / enumeration surfaces (future admin routes,\n * content-api, a frontend catch-all) reach this module — never the `lumi` CLI\n * command graph (F027 guardrail; keeps the `server-only`-adjacent imports out of\n * `paths/jobs.ts`).\n *\n * **Publish check runs under an ELEVATED system context** (mirrors\n * `../locale-registry.ts`'s `SYSTEM_CONTEXT`), NOT the ambient request context.\n * Rationale + security: URL resolution is a SYSTEM derivation that must work for\n * any caller — an anonymous public request, or a build-time enumeration with no\n * request context at all — and must not fail just because the caller lacks a\n * per-entity `view` grant. The elevated context only bypasses the\n * permission/scope check; the PUBLISH FILTER (`QueryClient.buildPublishFilter`)\n * and the trashed-row exclusion still apply, so an unpublished / trashed\n * document still resolves to `null` → not routable.\n *\n * Since D017 that publish filter is CONDITIONAL — it holds for any principal\n * WITHOUT `view_unpublished`, and is lifted for one that holds it. What makes\n * the paragraph above true is therefore not the filter being unconditional but\n * {@link SYSTEM_CONTEXT} WITHHOLDING that action (and every write action with\n * it). No content data is returned from the check (only a boolean), so\n * elevation leaks nothing — the downstream content fetch still runs under the\n * caller's real permissions.\n */\n\nimport type { ToolkitApp } from '@murumets-ee/core'\nimport { createSystemReachResolver } from '@murumets-ee/core'\nimport type { Entity, SecurityContext } from '@murumets-ee/entity'\nimport { QueryClient } from '@murumets-ee/entity/query'\nimport { DEFAULT_LOCALE, GLOBAL_SCOPE_ID } from '@murumets-ee/settings'\nimport { toolkitSettingsTable } from '@murumets-ee/settings/schema'\nimport { inArray } from 'drizzle-orm'\nimport type { PgColumn } from 'drizzle-orm/pg-core'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { getContentConfig } from '../content-config-store.js'\nimport { toolkitRedirectsTable } from '../redirects/schema.js'\nimport type { CanonicalPathsBatchDeps, EnumeratePathsDeps, PublishFilter } from './enumerate.js'\nimport { parseHomepagePointer } from './homepage.js'\nimport type { RedirectsListDeps } from './redirects-list.js'\nimport {\n type AlternatesDeps,\n type CanonicalPathDeps,\n createPublishChecker,\n type HomepageReader,\n type PublishChecker,\n type ResolvePathDeps,\n} from './resolve.js'\nimport { toolkitPathsTable } from './schema.js'\nimport type { SitemapDeps } from './sitemap.js'\n\n/**\n * Elevated **reach** resolver for the publish check — the roles/locale-registry\n * -read analog (see file header for why this is safe).\n *\n * It grants `view` and NOTHING else — in particular not `view_unpublished`,\n * which is what makes the file header's claim true. Until D017 the header's\n * \"the publish filter is applied by `QueryClient` regardless\" held for ANY\n * checker; now that publish-visibility IS a permission, a `checker: () => true`\n * here would answer \"yes\" to `view_unpublished` and every door this module backs\n * (`_resolve`, `_alternates`, `_paths`, sitemap, redirects) would start\n * resolving drafts to public URLs. {@link createSystemReachResolver} owns that\n * exception centrally so it can't be re-derived (or forgotten) per call site.\n *\n * The explicit id is the audit identity: it must be distinguishable from the\n * menu resolver's, or two elevated readers are one indistinguishable `'system'`\n * in the log.\n */\nconst SYSTEM_CONTEXT: () => SecurityContext = createSystemReachResolver('path-resolver')\n\n/**\n * Build the minimal deps for {@link import('./resolve.js').getCanonicalPath} —\n * just the `toolkit_paths` reader. Task 2's `resolveEntityHref` / search\n * url-builder re-points use this (no publish checker / settings machinery).\n */\nexport function buildCanonicalPathDeps(app: ToolkitApp): CanonicalPathDeps {\n return { pathsClient: toolkitPathsTable.makeClient(app.db.readOnly) }\n}\n\n/**\n * Build a live {@link PublishChecker} that reads through an elevated-context\n * `QueryClient` per entity (publish filter auto-applied). See file header.\n */\nexport function buildPublishChecker(app: ToolkitApp): PublishChecker {\n return createPublishChecker({\n entityResolver: (name) => app.entities.get(name),\n queryClientFactory: (entity: Entity) =>\n new QueryClient({\n entity,\n db: app.db.readOnly,\n logger: app.logger.child({ resolve: 'publish-check', entity: entity.name }),\n contextResolver: SYSTEM_CONTEXT,\n }),\n })\n}\n\n/**\n * Build the homepage reader — a single indexed `toolkit_settings` lookup for\n * `site.homeDocument` (global scope, default locale), zod-parsed defensively\n * (unset / malformed → `null`, never a throw). No cache in v1 (phase 05 §5).\n */\nexport function createHomepageReader(db: PostgresJsDatabase): HomepageReader {\n const settingsClient = toolkitSettingsTable.makeClient(db)\n return async () => {\n const row = await settingsClient.findOne({\n namespace: 'site',\n key: 'homeDocument',\n scope: 'global',\n scopeId: GLOBAL_SCOPE_ID,\n locale: DEFAULT_LOCALE,\n })\n if (!row) return null\n return parseHomepagePointer(row.value)\n }\n}\n\n/**\n * Build the deps for {@link import('./enumerate.js').enumeratePaths} — the\n * `toolkit_paths` reader plus a BATCHED publish filter. `filterPublished` runs\n * ONE elevated-context `QueryClient.findMany({ where: inArray(getTable().id,\n * ids), locale })` per entity-type group (not per row) — the trash filter\n * applies unconditionally, and the publish filter applies because the elevated\n * context WITHHOLDS `view_unpublished` (D017; see file header), so no\n * unpublished/trashed id is returned. An unknown/unregistered entity type yields the empty set (its\n * rows are treated as unpublished → dropped, fail-closed).\n */\nexport function buildEnumerateDeps(app: ToolkitApp): EnumeratePathsDeps {\n const db = app.db.readOnly\n return {\n pathsClient: toolkitPathsTable.makeClient(db),\n filterPublished: async (entityType, entityIds, locale) => {\n if (entityIds.length === 0) return new Set()\n const entity = app.entities.get(entityType)\n if (entity === undefined) return new Set()\n const client = new QueryClient({\n entity,\n db,\n logger: app.logger.child({ resolve: 'enumerate-publish', entity: entity.name }),\n contextResolver: SYSTEM_CONTEXT,\n })\n // Dynamic `id` column ref for the batched IN filter. `getTable()` is\n // `PgTableWithColumns<any>`; the structural cast narrows to the one column\n // we filter on (the same idiom paths/jobs.ts uses for dynamic column refs).\n const idColumn = (client.getTable() as unknown as { id: PgColumn }).id\n const rows = await client.findMany({ where: inArray(idColumn, [...entityIds]), locale })\n const published = new Set<string>()\n for (const row of rows) {\n const id = (row as { id?: unknown }).id\n if (typeof id === 'string') published.add(id)\n }\n return published\n },\n }\n}\n\n/**\n * Build the deps for {@link import('./enumerate.js').getCanonicalPaths} — just\n * the `toolkit_paths` reader (batched, NO publish filter, like\n * {@link buildCanonicalPathDeps}). Task 2's search url re-point uses this to\n * resolve a search page's result urls from the index in one query.\n */\nexport function buildCanonicalPathsBatchDeps(app: ToolkitApp): CanonicalPathsBatchDeps {\n return { pathsClient: toolkitPathsTable.makeClient(app.db.readOnly) }\n}\n\n/**\n * Build the `filterNoIndex` dep for {@link import('./sitemap.js').generateSitemap}\n * (PR 11's `seoMeta()`) — mirrors {@link buildEnumerateDeps}'s `filterPublished`\n * construction EXACTLY (elevated-context `QueryClient`, one `findMany` per\n * entity-type group), with two differences:\n *\n * 1. Short-circuits to the empty set when the entity does NOT declare\n * `seoMeta()` — most routable entities won't have this behavior, and\n * querying a `noIndex` column that doesn't exist would throw.\n * 2. Filters on the LOCALE-MERGED DTO rather than a raw SQL column, because\n * `noIndex` is `translatable: true` — its live per-locale value only\n * exists after `QueryClient.findMany`'s `mergeTranslations` step, not as a\n * column on the main table itself (unlike `filterPublished`'s `status`,\n * which is never translatable). The `id IN (...)` filter is still pushed\n * to SQL; only the `noIndex` check runs over the (batch-bounded) merged\n * rows in memory.\n */\nfunction buildNoIndexFilter(app: ToolkitApp): PublishFilter {\n const db = app.db.readOnly\n return async (entityType, entityIds, locale) => {\n if (entityIds.length === 0) return new Set()\n const entity = app.entities.get(entityType)\n if (entity === undefined) return new Set()\n if (!entity.behaviors?.some((b) => b.name === 'seoMeta')) return new Set()\n const client = new QueryClient({\n entity,\n db,\n logger: app.logger.child({ resolve: 'sitemap-noindex', entity: entity.name }),\n contextResolver: SYSTEM_CONTEXT,\n })\n // Dynamic `id` column ref for the batched IN filter — same idiom as\n // `buildEnumerateDeps.filterPublished`.\n const idColumn = (client.getTable() as unknown as { id: PgColumn }).id\n const rows = await client.findMany({ where: inArray(idColumn, [...entityIds]), locale })\n const noIndexed = new Set<string>()\n for (const row of rows) {\n const id = (row as { id?: unknown }).id\n const noIndex = (row as { noIndex?: unknown }).noIndex\n if (typeof id === 'string' && noIndex === true) noIndexed.add(id)\n }\n return noIndexed\n }\n}\n\n/**\n * Build the deps for {@link import('./sitemap.js').generateSitemap} — the\n * one-stop bundle of {@link buildEnumerateDeps} (per-locale enumeration + its\n * BATCHED publish filter, reused for the cross-locale alternate publish gate),\n * {@link buildCanonicalPathsBatchDeps} (the batched cross-locale forward index\n * read), and {@link buildNoIndexFilter} (PR 11's batched `noIndex` exclusion).\n * Mirrors `buildResolveDeps`'s one-stop-deps pattern.\n */\nexport function buildSitemapDeps(app: ToolkitApp): SitemapDeps {\n return {\n enumerate: buildEnumerateDeps(app),\n canonicalBatch: buildCanonicalPathsBatchDeps(app),\n filterNoIndex: buildNoIndexFilter(app),\n }\n}\n\n/**\n * Build the deps for {@link import('./redirects-list.js').getRedirects} — the\n * one-stop bundle for the build-time redirect list (PR08). Composes\n * {@link buildEnumerateDeps} (its `toolkit_paths` reader powers both the\n * historical keyset and the `path`-kind existence check; its BATCHED\n * `filterPublished` is the shared publish gate), {@link buildCanonicalPathsBatchDeps}\n * (the batched owning-document current-canonical lookup), and a\n * `toolkit_redirects` reader for the manual-redirect keyset. Mirrors\n * {@link buildSitemapDeps}'s one-stop-deps style.\n */\nexport function buildRedirectsListDeps(app: ToolkitApp): RedirectsListDeps {\n const enumerate = buildEnumerateDeps(app)\n return {\n redirectsClient: toolkitRedirectsTable.makeClient(app.db.readOnly),\n pathsClient: enumerate.pathsClient,\n canonicalBatch: buildCanonicalPathsBatchDeps(app),\n filterPublished: enumerate.filterPublished,\n }\n}\n\n/**\n * Build the full {@link ResolvePathDeps} (+ {@link AlternatesDeps}) from an app —\n * the one-stop deps for `resolvePath` / `resolveDraftPath` / `getAlternates`.\n * `locales` are the statically-configured content locales (v1 — matches the\n * redirect routes' own configured-locale source; DB-merged locale overrides are\n * a later refinement).\n */\nexport function buildResolveDeps(app: ToolkitApp): ResolvePathDeps & AlternatesDeps {\n const db = app.db.readOnly\n return {\n pathsClient: toolkitPathsTable.makeClient(db),\n redirectsClient: toolkitRedirectsTable.makeClient(db),\n publishChecker: buildPublishChecker(app),\n homepageReader: createHomepageReader(db),\n locales: getContentConfig().locales.map((l) => l.code),\n // Only consumer: the historical-redirect lookup's truncation warning.\n logger: app.logger,\n }\n}\n"],"mappings":"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"}
package/dist/resolve.mjs CHANGED
@@ -1,9 +1,9 @@
1
- import{r as e}from"./apply-path-s-YeCUXP.mjs";import{a as t,c as n,i as r,o as i,p as a,r as o,s}from"./schema-BPDn8ajz.mjs";import{a as c,c as l,d as u,i as d,n as f,o as p,r as m,s as h,t as g,u as _}from"./resolve-context-rr-Eanwp.mjs";import{r as v,t as y}from"./enumerate-B5tWe9JC.mjs";const b=4e4,x=`sitemap.xml`;async function*S(e,t,n={}){let r=N(e.maxUrlsPerFile,b),i=N(n.batchSize,200),a=A(e.baseUrl),o=[];for(let s of e.locales){let c=[],l=null,u=0,d=()=>{if(c.length===0)return null;let e=M(s,u),t=(l??new Date).toISOString(),n={kind:`urlset`,filename:e,xml:T(c)};return o.push({filename:e,lastmod:t}),c=[],l=null,u+=1,n},f=y({locale:s},t.enumerate,n.enumerate);for await(let n of P(f,i)){let i=await w(n,s,t);if(i.length===0)continue;let o=await C(i,s,e,t);for(let t of i){let n=o.get(t.id)??[{locale:s,path:t.path}];if(c.push(E(t,s,n,e,a)),(l===null||t.updatedAt>l)&&(l=t.updatedAt),c.length>=r){let e=d();e&&(yield e)}}}let p=d();p&&(yield p)}o.length>1&&(yield{kind:`index`,filename:x,xml:D(o,a)})}async function C(e,t,n,r){let i=new Map;for(let n of e)i.set(n.id,[{locale:t,path:n.path}]);let a=n.locales.filter(e=>e!==t);if(a.length===0)return i;let o=new Map;for(let t of e){let e=o.get(t.entityType);e?e.push(t.id):o.set(t.entityType,[t.id])}for(let e of a)for(let[t,n]of o){let a=await v(t,n,e,r.canonicalBatch);if(a.size===0)continue;let o=[...a.keys()],s=await r.enumerate.filterPublished(t,o,e),c=r.filterNoIndex===void 0?void 0:await r.filterNoIndex(t,o,e);for(let[t,n]of a){if(!s.has(t)||c?.has(t))continue;let r=i.get(t);r&&r.push({locale:e,path:n})}}return i}async function w(t,n,r){let i=r.filterNoIndex;if(i===void 0)return[...t];let a=new Map;for(let e of t){let t=a.get(e.entityType);t?t.push(e.id):a.set(e.entityType,[e.id])}let o=new Map;return await e([...a.entries()],5,async([e,t])=>{o.set(e,await i(e,t,n))}),t.filter(e=>!o.get(e.entityType)?.has(e.id))}function T(e){return`<?xml version="1.0" encoding="UTF-8"?>
1
+ import{a as e}from"./schema-D8sBQozw.mjs";import{a as t,c as n,d as r,i,o as a,r as o,s}from"./schema-0zayzpD_.mjs";import{a as c,c as l,d as u,i as d,n as f,o as p,r as m,s as h,t as g,u as _}from"./resolve-context-MRZZ3rQl.mjs";import{r as v,t as y}from"./enumerate-CBqu63Kp.mjs";const b=4e4,x=`sitemap.xml`;async function*S(e,t,n={}){let r=N(e.maxUrlsPerFile,b),i=N(n.batchSize,200),a=A(e.baseUrl),o=[];for(let s of e.locales){let c=[],l=null,u=0,d=()=>{if(c.length===0)return null;let e=M(s,u),t=(l??new Date).toISOString(),n={kind:`urlset`,filename:e,xml:T(c)};return o.push({filename:e,lastmod:t}),c=[],l=null,u+=1,n},f=y({locale:s},t.enumerate,n.enumerate);for await(let n of P(f,i)){let i=await w(n,s,t);if(i.length===0)continue;let o=await C(i,s,e,t);for(let t of i){let n=o.get(t.id)??[{locale:s,path:t.path}];if(c.push(E(t,s,n,e,a)),(l===null||t.updatedAt>l)&&(l=t.updatedAt),c.length>=r){let e=d();e&&(yield e)}}}let p=d();p&&(yield p)}o.length>1&&(yield{kind:`index`,filename:x,xml:D(o,a)})}async function C(e,t,n,r){let i=new Map;for(let n of e)i.set(n.id,[{locale:t,path:n.path}]);let a=n.locales.filter(e=>e!==t);if(a.length===0)return i;let o=new Map;for(let t of e){let e=o.get(t.entityType);e?e.push(t.id):o.set(t.entityType,[t.id])}for(let e of a)for(let[t,n]of o){let a=await v(t,n,e,r.canonicalBatch);if(a.size===0)continue;let o=[...a.keys()],s=await r.enumerate.filterPublished(t,o,e),c=r.filterNoIndex===void 0?void 0:await r.filterNoIndex(t,o,e);for(let[t,n]of a){if(!s.has(t)||c?.has(t))continue;let r=i.get(t);r&&r.push({locale:e,path:n})}}return i}async function w(t,n,r){let i=r.filterNoIndex;if(i===void 0)return[...t];let a=new Map;for(let e of t){let t=a.get(e.entityType);t?t.push(e.id):a.set(e.entityType,[e.id])}let o=new Map;return await e([...a.entries()],5,async([e,t])=>{o.set(e,await i(e,t,n))}),t.filter(e=>!o.get(e.entityType)?.has(e.id))}function T(e){return`<?xml version="1.0" encoding="UTF-8"?>
2
2
  <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">\n${e.join(`
3
3
  `)}\n</urlset>\n`}function E(e,t,n,r,i){let a=k(i,r.prefix,t,e.path),o=n.map(e=>` ${O(e.locale,k(i,r.prefix,e.locale,e.path))}`),s=n.find(e=>e.locale===r.defaultLocale);return s&&o.push(` ${O(`x-default`,k(i,r.prefix,s.locale,s.path))}`),` <url>
4
4
  <loc>${j(a)}</loc>\n <lastmod>${e.updatedAt.toISOString()}</lastmod>\n${o.join(`
5
5
  `)}\n </url>`}function D(e,t){return`<?xml version="1.0" encoding="UTF-8"?>
6
6
  <sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${e.map(e=>` <sitemap>
7
7
  <loc>${j(`${t}/${e.filename}`)}</loc>\n <lastmod>${e.lastmod}</lastmod>\n </sitemap>`).join(`
8
- `)}\n</sitemapindex>\n`}function O(e,t){return`<xhtml:link rel="alternate" hreflang="${j(e)}" href="${j(t)}"/>`}function k(e,t,n,r){return`${e}${t?t(n):``}${r}`}function A(e){return e.replace(/\/+$/,``)}function j(e){return e.replace(/&/g,`&amp;`).replace(/</g,`&lt;`).replace(/>/g,`&gt;`).replace(/"/g,`&quot;`).replace(/'/g,`&apos;`)}function M(e,t){return t===0?`sitemap-${e}.xml`:`sitemap-${e}-${t+1}.xml`}function N(e,t){if(e===void 0||!Number.isFinite(e))return t;let n=Math.floor(e);return n>=1?n:t}async function*P(e,t){let n=[];for await(let r of e)n.push(r),n.length>=t&&(yield n,n=[]);n.length>0&&(yield n)}async function*F(e,t,n={}){let{locale:r}=e,i=n.pageSize??200,a=new Set;yield*I(r,r,t,i,a),yield*I(r,null,t,i,a),yield*z(r,t,i,a)}async function*I(e,t,n,r,i){let o;for(;;){let s={locale:t};o!==void 0&&(s.id={gt:o});let c=await n.redirectsClient.findMany({where:s,orderBy:[{column:`id`,dir:`asc`}],limit:r});if(c.length===0)break;let l=[];for(let e of c)i.has(e.fromPath)||(i.add(e.fromPath),l.push(e));let u=await L(l,e,n);for(let t of l){let r=B(t.status),i=t.target;if(i.kind===`url`){yield{from:t.fromPath,to:i.url,status:r};continue}if(i.kind===`entity`){let e=u.get(t.id);e!==void 0&&e!==t.fromPath&&(yield{from:t.fromPath,to:e,status:r});continue}let o=a(i.path);o!==t.fromPath&&await R(n.pathsClient,e,o)&&(yield{from:t.fromPath,to:o,status:r})}let d=c[c.length-1];if(!d||(o=d.id,c.length<r))break}}async function L(e,t,n){let r=new Map,i=new Map;for(let t of e){if(t.target.kind!==`entity`)continue;let e={rowId:t.id,targetId:t.target.id},n=i.get(t.target.entity);n?n.push(e):i.set(t.target.entity,[e])}for(let[e,a]of i){let i=await v(e,a.map(e=>e.targetId),t,n.canonicalBatch),o=[...i.keys()];if(o.length===0)continue;let s=await n.filterPublished(e,o,t);for(let{rowId:e,targetId:t}of a){let n=i.get(t);n!==void 0&&s.has(t)&&r.set(e,n)}}return r}async function R(e,t,n){return(await e.findMany({where:{locale:t,path:n,isCanonical:!0},limit:1})).length>0}async function*z(e,t,n,r){let i=new Set,a;for(;;){let o={locale:e,isCanonical:!1};a!==void 0&&(o.id={gt:a});let s=await t.pathsClient.findMany({where:o,orderBy:[{column:`id`,dir:`asc`}],limit:n});if(s.length===0)break;let c=new Map;for(let e of s){let t=c.get(e.entityType);t?t.push(e):c.set(e.entityType,[e])}let l=new Map;for(let[n,r]of c){let i=await v(n,r.map(e=>e.entityId),e,t.canonicalBatch),a=[...i.keys()];if(a.length===0)continue;let o=await t.filterPublished(n,a,e);for(let[e,t]of i)o.has(e)&&l.set(e,t)}for(let e of s){if(r.has(e.path)||i.has(e.path))continue;let t=l.get(e.entityId);t!==void 0&&t!==e.path&&(i.add(e.path),yield{from:e.path,to:t,status:301})}let u=s[s.length-1];if(!u||(a=u.id,s.length<n))break}}function B(e){return e===302?302:301}export{b as DEFAULT_MAX_URLS_PER_FILE,o as MAX_REDIRECT_HOPS,x as SITEMAP_INDEX_FILENAME,g as buildCanonicalPathDeps,f as buildCanonicalPathsBatchDeps,m as buildEnumerateDeps,d as buildPublishChecker,c as buildRedirectsListDeps,p as buildResolveDeps,h as buildSitemapDeps,l as createHomepageReader,r as createPublishChecker,y as enumeratePaths,S as generateSitemap,t as getAlternates,i as getCanonicalPath,v as getCanonicalPaths,F as getRedirects,_ as homepagePointerSchema,u as parseHomepagePointer,s as resolveDraftPath,n as resolvePath};
8
+ `)}\n</sitemapindex>\n`}function O(e,t){return`<xhtml:link rel="alternate" hreflang="${j(e)}" href="${j(t)}"/>`}function k(e,t,n,r){return`${e}${t?t(n):``}${r}`}function A(e){return e.replace(/\/+$/,``)}function j(e){return e.replace(/&/g,`&amp;`).replace(/</g,`&lt;`).replace(/>/g,`&gt;`).replace(/"/g,`&quot;`).replace(/'/g,`&apos;`)}function M(e,t){return t===0?`sitemap-${e}.xml`:`sitemap-${e}-${t+1}.xml`}function N(e,t){if(e===void 0||!Number.isFinite(e))return t;let n=Math.floor(e);return n>=1?n:t}async function*P(e,t){let n=[];for await(let r of e)n.push(r),n.length>=t&&(yield n,n=[]);n.length>0&&(yield n)}async function*F(e,t,n={}){let{locale:r}=e,i=n.pageSize??200,a=new Set;yield*I(r,r,t,i,a),yield*I(r,null,t,i,a),yield*z(r,t,i,a)}async function*I(e,t,n,i,a){let o;for(;;){let s={locale:t};o!==void 0&&(s.id={gt:o});let c=await n.redirectsClient.findMany({where:s,orderBy:[{column:`id`,dir:`asc`}],limit:i});if(c.length===0)break;let l=[];for(let e of c)a.has(e.fromPath)||(a.add(e.fromPath),l.push(e));let u=await L(l,e,n);for(let t of l){let i=B(t.status),a=t.target;if(a.kind===`url`){yield{from:t.fromPath,to:a.url,status:i};continue}if(a.kind===`entity`){let e=u.get(t.id);e!==void 0&&e!==t.fromPath&&(yield{from:t.fromPath,to:e,status:i});continue}let o=r(a.path);o!==t.fromPath&&await R(n.pathsClient,e,o)&&(yield{from:t.fromPath,to:o,status:i})}let d=c[c.length-1];if(!d||(o=d.id,c.length<i))break}}async function L(e,t,n){let r=new Map,i=new Map;for(let t of e){if(t.target.kind!==`entity`)continue;let e={rowId:t.id,targetId:t.target.id},n=i.get(t.target.entity);n?n.push(e):i.set(t.target.entity,[e])}for(let[e,a]of i){let i=await v(e,a.map(e=>e.targetId),t,n.canonicalBatch),o=[...i.keys()];if(o.length===0)continue;let s=await n.filterPublished(e,o,t);for(let{rowId:e,targetId:t}of a){let n=i.get(t);n!==void 0&&s.has(t)&&r.set(e,n)}}return r}async function R(e,t,n){return(await e.findMany({where:{locale:t,path:n,isCanonical:!0},limit:1})).length>0}async function*z(e,t,n,r){let i=new Set,a;for(;;){let o={locale:e,isCanonical:!1};a!==void 0&&(o.id={gt:a});let s=await t.pathsClient.findMany({where:o,orderBy:[{column:`id`,dir:`asc`}],limit:n});if(s.length===0)break;let c=new Map;for(let e of s){let t=c.get(e.entityType);t?t.push(e):c.set(e.entityType,[e])}let l=new Map;for(let[n,r]of c){let i=await v(n,r.map(e=>e.entityId),e,t.canonicalBatch),a=[...i.keys()];if(a.length===0)continue;let o=await t.filterPublished(n,a,e);for(let[e,t]of i)o.has(e)&&l.set(e,t)}for(let e of s){if(r.has(e.path)||i.has(e.path))continue;let t=l.get(e.entityId);t!==void 0&&t!==e.path&&(i.add(e.path),yield{from:e.path,to:t,status:301})}let u=s[s.length-1];if(!u||(a=u.id,s.length<n))break}}function B(e){return e===302?302:301}export{b as DEFAULT_MAX_URLS_PER_FILE,o as MAX_REDIRECT_HOPS,x as SITEMAP_INDEX_FILENAME,g as buildCanonicalPathDeps,f as buildCanonicalPathsBatchDeps,m as buildEnumerateDeps,d as buildPublishChecker,c as buildRedirectsListDeps,p as buildResolveDeps,h as buildSitemapDeps,l as createHomepageReader,i as createPublishChecker,y as enumeratePaths,S as generateSitemap,t as getAlternates,a as getCanonicalPath,v as getCanonicalPaths,F as getRedirects,_ as homepagePointerSchema,u as parseHomepagePointer,s as resolveDraftPath,n as resolvePath};
9
9
  //# sourceMappingURL=resolve.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"resolve.mjs","names":[],"sources":["../src/paths/sitemap.ts","../src/paths/redirects-list.ts"],"sourcesContent":["/**\n * Sitemap generation for the routed content surface (plan/routing PR07; phase\n * `05-resolution-and-enumeration-api.md` §4).\n *\n * `generateSitemap` streams the standard-protocol sitemap document set for the\n * configured locales: one `<urlset>` per locale (split into numbered files at\n * {@link SitemapOptions.maxUrlsPerFile}), plus a `<sitemapindex>` when the run\n * produced more than one file. Each `<url>` carries a `<lastmod>` (from the\n * enumerated `updatedAt`) and the full `xhtml:link` hreflang-alternates set —\n * including an `x-default` pointing at the default-locale URL. It is a PURE\n * generator, NOT an HTTP endpoint: the frontend recipe (PR08) mounts it in a\n * route handler; this module only produces the documents.\n *\n * **Security (CLAUDE.md #1).** The output feeds a public build artifact, so it\n * must NEVER leak an unpublished / trashed document. Two independent publish\n * gates apply:\n * 1. The primary rows come from {@link enumeratePaths}, which publish-filters\n * each page against the entity (unpublished/trashed dropped) for the\n * sitemap's OWN locale.\n * 2. The cross-locale hreflang alternates come from {@link getCanonicalPaths},\n * which does NOT publish-filter (it is a display-only forward index read).\n * So this module ADDITIONALLY runs the same batched `filterPublished` over\n * every candidate other-locale alternate before linking it — an\n * unpublished-in-that-locale document is never emitted as an alternate.\n *\n * **No unbounded fan-out (CLAUDE.md \"no amplification\").** The cross-locale\n * alternate lookup is BATCHED exactly like `enumeratePaths`'s own publish check:\n * per {@link SITEMAP_ALTERNATES_BATCH_SIZE}-sized chunk of enumerated rows, the\n * work is ONE `getCanonicalPaths` + ONE `filterPublished` per (entity-type\n * group × other locale) — bounded by (batch size × distinct routable types ×\n * locale count), NEVER one query per document. `getAlternates` (the per-document\n * N-locale function) is deliberately NOT used here — calling it per enumerated\n * row would be the O(rows × locales) amplification this bound forbids.\n *\n * **`noIndex` exclusion (PR 11's `seoMeta()`).** `SitemapDeps.filterNoIndex` is\n * an OPTIONAL batched dep, same shape as `filterPublished` (a `PublishFilter`)\n * but semantics-inverted: it returns the subset of ids that ARE `noIndex` in\n * the given locale. It lives on `deps`, not `opts`, because — like\n * `filterPublished` — a real implementation needs a DB read, and `noIndex` is\n * `translatable: true`, so the SAME callback must be invoked once per\n * (locale, entity-type group, batch) to see the right per-locale value; a\n * per-entry (locale-blind) predicate could not express this correctly. Omitted\n * → nothing excluded (current default). See `excludeNoIndexed`.\n *\n * **Leaf module.** Every cross-package dependency is injected via `deps`\n * ({@link SitemapDeps}); this module imports no `@murumets-ee/core` and no\n * `server-only` graph. Concrete deps are built from an app in\n * `./resolve-context.ts` (`buildSitemapDeps`).\n */\n\nimport { mapBounded } from './apply-path.js'\nimport {\n type CanonicalPathsBatchDeps,\n enumeratePaths,\n type EnumeratePathsDeps,\n type EnumeratePathsTuning,\n getCanonicalPaths,\n type PathEntry,\n type PublishFilter,\n} from './enumerate.js'\n\n// ---------------------------------------------------------------------------\n// Public surface\n// ---------------------------------------------------------------------------\n\n/** Options for {@link generateSitemap}. */\nexport interface SitemapOptions {\n /** The locales to emit a `<urlset>` for (configured content locales). */\n locales: readonly string[]\n /**\n * The default locale — its URL is the `x-default` hreflang target. Explicit\n * (NOT inferred from `locales` order) so callers stay unambiguous.\n */\n defaultLocale: string\n /** Absolute site origin, e.g. `https://example.com` (trailing slash tolerated). */\n baseUrl: string\n /**\n * Per-locale URL prefix rule (e.g. `(l) => l === 'en' ? '' : '/' + l`). The\n * frontend owns the locale-prefix convention (D002); omitted → no prefix.\n */\n prefix?: (locale: string) => string\n /**\n * Max `<url>` entries per file before splitting (sitemap protocol caps at\n * 50 000). Defaults to {@link DEFAULT_MAX_URLS_PER_FILE}.\n */\n maxUrlsPerFile?: number\n}\n\n/** Injected dependencies for {@link generateSitemap}. */\nexport interface SitemapDeps {\n /**\n * Deps for {@link enumeratePaths} — the `toolkit_paths` reader plus the\n * batched `filterPublished`. `filterPublished` is REUSED for the cross-locale\n * alternate publish gate (same batched shape), so no second publish surface is\n * needed.\n */\n enumerate: EnumeratePathsDeps\n /**\n * Deps for {@link getCanonicalPaths} — the batched cross-locale forward index\n * read (NO publish filter; this module applies `enumerate.filterPublished` on\n * top).\n */\n canonicalBatch: CanonicalPathsBatchDeps\n /**\n * Optional: batched `noIndex` check, same shape as `filterPublished` but\n * semantics-inverted — returns the subset of `entityIds` that ARE `noIndex`\n * in `locale` (PR 11's `seoMeta()`). Called at most once per (entity-type\n * group, batch), never once per entry — see `excludeNoIndexed`. Omitted →\n * nothing excluded (current default).\n */\n filterNoIndex?: PublishFilter\n}\n\n/** Internal tuning seam (batch size / enumerate page size). Real callers omit it. */\nexport interface SitemapTuning {\n /**\n * Enumerated rows per cross-locale alternate batch. Defaults to\n * {@link SITEMAP_ALTERNATES_BATCH_SIZE}. Tests force a small value to span\n * multiple batches. Bounds the per-batch fan-out (see module header).\n */\n batchSize?: number\n /** Passed through to {@link enumeratePaths} for its own paging/publish tuning. */\n enumerate?: EnumeratePathsTuning\n}\n\n/** One emitted sitemap document. */\nexport interface SitemapFile {\n /** `'urlset'` for a per-locale URL set, `'index'` for the sitemap index. */\n kind: 'urlset' | 'index'\n /**\n * Suggested filename served at `${baseUrl}/${filename}`. Per-locale urlsets are\n * `sitemap-<locale>.xml` (first) / `sitemap-<locale>-<n>.xml` (splits, n≥2);\n * the index is {@link SITEMAP_INDEX_FILENAME}.\n */\n filename: string\n /** The complete, well-formed XML document text (trailing newline included). */\n xml: string\n}\n\n/** Default `<url>` cap per file — under the sitemap protocol's 50 000 hard limit. */\nexport const DEFAULT_MAX_URLS_PER_FILE = 40_000\n\n/** Filename of the emitted sitemap index (referenced by the mounting route). */\nexport const SITEMAP_INDEX_FILENAME = 'sitemap.xml'\n\n/**\n * Enumerated rows per cross-locale alternate batch. Aligned with\n * `enumeratePaths`'s own page size so a batch is ~one enumeration page; the\n * exact value only affects HOW MANY batched lookups run, never their per-batch\n * bound (see module header). Never per-row.\n */\nconst SITEMAP_ALTERNATES_BATCH_SIZE = 200\n\n// ---------------------------------------------------------------------------\n// Generator\n// ---------------------------------------------------------------------------\n\n/** One resolved hreflang alternate (a published, canonical per-locale URL). */\ninterface Alternate {\n locale: string\n path: string\n}\n\n/**\n * Stream the sitemap document set for `opts.locales`. See the module header for\n * the security + fan-out guarantees. Yields each `<urlset>` file as it fills,\n * then a `<sitemapindex>` LAST iff more than one file was produced.\n */\nexport async function* generateSitemap(\n opts: SitemapOptions,\n deps: SitemapDeps,\n tuning: SitemapTuning = {},\n): AsyncGenerator<SitemapFile> {\n const maxUrls = normalizePositiveInt(opts.maxUrlsPerFile, DEFAULT_MAX_URLS_PER_FILE)\n const batchSize = normalizePositiveInt(tuning.batchSize, SITEMAP_ALTERNATES_BATCH_SIZE)\n const base = stripTrailingSlash(opts.baseUrl)\n\n /** (filename, lastmod) of every urlset file produced — feeds the index. */\n const producedFiles: Array<{ filename: string; lastmod: string }> = []\n\n for (const locale of opts.locales) {\n let buffer: string[] = []\n let bufferMaxUpdatedAt: Date | null = null\n let fileIndex = 0\n\n /** Flush the current buffer as a numbered urlset file (or return null if empty). */\n const flush = (): SitemapFile | null => {\n if (buffer.length === 0) return null\n const filename = urlsetFilename(locale, fileIndex)\n const lastmod = (bufferMaxUpdatedAt ?? new Date()).toISOString()\n const file: SitemapFile = { kind: 'urlset', filename, xml: buildUrlset(buffer) }\n producedFiles.push({ filename, lastmod })\n buffer = []\n bufferMaxUpdatedAt = null\n fileIndex += 1\n return file\n }\n\n const source = enumeratePaths({ locale }, deps.enumerate, tuning.enumerate)\n for await (const batch of chunkAsync(source, batchSize)) {\n // Exclude noIndex rows FIRST so they're never fetched cross-locale.\n const kept = await excludeNoIndexed(batch, locale, deps)\n if (kept.length === 0) continue\n\n // ONE batched cross-locale lookup per (entity-type group × other locale).\n const alternatesByEntry = await resolveAlternates(kept, locale, opts, deps)\n\n for (const entry of kept) {\n const alternates = alternatesByEntry.get(entry.id) ?? [{ locale, path: entry.path }]\n buffer.push(buildUrlEntry(entry, locale, alternates, opts, base))\n if (bufferMaxUpdatedAt === null || entry.updatedAt > bufferMaxUpdatedAt) {\n bufferMaxUpdatedAt = entry.updatedAt\n }\n if (buffer.length >= maxUrls) {\n const full = flush()\n if (full) yield full\n }\n }\n }\n\n const tail = flush()\n if (tail) yield tail\n }\n\n // The index is only meaningful when it references more than one file.\n if (producedFiles.length > 1) {\n yield { kind: 'index', filename: SITEMAP_INDEX_FILENAME, xml: buildIndex(producedFiles, base) }\n }\n}\n\n/**\n * Resolve the full hreflang-alternate set for a batch of same-locale entries.\n * The current locale is always a self-alternate (`enumeratePaths` already proved\n * it published + canonical there). Every OTHER locale is resolved with ONE\n * batched `getCanonicalPaths` (forward index) + ONE batched `filterPublished`\n * (publish gate) per (entity-type group × locale) — never per row.\n */\nasync function resolveAlternates(\n entries: readonly PathEntry[],\n currentLocale: string,\n opts: SitemapOptions,\n deps: SitemapDeps,\n): Promise<Map<string, Alternate[]>> {\n const result = new Map<string, Alternate[]>()\n // Seed each entry with its self-alternate (its own published canonical URL).\n for (const entry of entries) result.set(entry.id, [{ locale: currentLocale, path: entry.path }])\n\n const otherLocales = opts.locales.filter((l) => l !== currentLocale)\n if (otherLocales.length === 0) return result\n\n // Group the batch's ids by entity type (bounded: routable types are few + config-fixed).\n const idsByType = new Map<string, string[]>()\n for (const entry of entries) {\n const bucket = idsByType.get(entry.entityType)\n if (bucket) bucket.push(entry.id)\n else idsByType.set(entry.entityType, [entry.id])\n }\n\n for (const otherLocale of otherLocales) {\n for (const [type, ids] of idsByType) {\n // (1) Forward index: which of these ids have a canonical row in otherLocale?\n const pathMap = await getCanonicalPaths(type, ids, otherLocale, deps.canonicalBatch)\n if (pathMap.size === 0) continue\n // (2) Publish gate: of THOSE, which are actually published in otherLocale?\n // getCanonicalPaths does NOT publish-filter — this is the required\n // no-unpublished-leak step, batched (never per-row).\n const candidateIds = [...pathMap.keys()]\n const publishedIds = await deps.enumerate.filterPublished(type, candidateIds, otherLocale)\n // (3) noIndex gate — same shape, other-locale value. Without this, a\n // document marked noIndex in `otherLocale` (excluded from ITS OWN\n // urlset by `excludeNoIndexed` below) still gets published as an\n // hreflang alternate on every OTHER locale's urlset, publishing the\n // exact URL the editor asked to hide.\n const noIndexIds =\n deps.filterNoIndex !== undefined\n ? await deps.filterNoIndex(type, candidateIds, otherLocale)\n : undefined\n for (const [id, path] of pathMap) {\n if (!publishedIds.has(id)) continue\n if (noIndexIds?.has(id)) continue\n const alternates = result.get(id)\n if (alternates) alternates.push({ locale: otherLocale, path })\n }\n }\n }\n\n return result\n}\n\n/**\n * Peak concurrent `noIndex`-check groups per batch — mirrors `enumerate.ts`'s\n * `PUBLISH_CHECK_CONCURRENCY` bound (CLAUDE.md \"no amplification\"): a batch\n * holds a handful of distinct entity types at most, so this is rarely reached\n * — it exists so the fan-out is EXPLICITLY capped, never unbounded.\n */\nconst NOINDEX_CHECK_CONCURRENCY = 5\n\n/**\n * Keep the entries `deps.filterNoIndex` does NOT report as `noIndex` in\n * `locale` (all of them when the dep is omitted — current default: nothing\n * excluded). Groups the batch by entity type (mirrors `enumeratePaths`'s own\n * `byType` grouping) and calls `deps.filterNoIndex` ONCE per (entity-type\n * group, batch) — never once per entry — bounded to\n * {@link NOINDEX_CHECK_CONCURRENCY} concurrent groups.\n *\n * FAIL-CLOSED, deliberately, unlike `loadMetaPanelData`'s fail-open contract\n * (`packages/admin-ui/src/server/meta-panel-data.ts`): `mapBounded` does not\n * catch per-item errors (see its own doc comment in `apply-path.ts`), so a\n * `deps.filterNoIndex` rejection aborts the whole `generateSitemap` run rather\n * than silently including a possibly-noIndexed row. A build-time sitemap\n * generator failing loudly is preferable to a build succeeding with a leaked\n * noIndex URL.\n */\nasync function excludeNoIndexed(\n entries: readonly PathEntry[],\n locale: string,\n deps: SitemapDeps,\n): Promise<PathEntry[]> {\n const filterNoIndex = deps.filterNoIndex\n if (filterNoIndex === undefined) return [...entries]\n\n // Group this batch's ids by entity type (bounded: routable types are few).\n const idsByType = new Map<string, string[]>()\n for (const entry of entries) {\n const bucket = idsByType.get(entry.entityType)\n if (bucket) bucket.push(entry.id)\n else idsByType.set(entry.entityType, [entry.id])\n }\n\n const noIndexedByType = new Map<string, ReadonlySet<string>>()\n await mapBounded([...idsByType.entries()], NOINDEX_CHECK_CONCURRENCY, async ([type, ids]) => {\n noIndexedByType.set(type, await filterNoIndex(type, ids, locale))\n })\n\n return entries.filter((entry) => !noIndexedByType.get(entry.entityType)?.has(entry.id))\n}\n\n// ---------------------------------------------------------------------------\n// XML builders\n// ---------------------------------------------------------------------------\n\nconst URLSET_HEADER =\n '<?xml version=\"1.0\" encoding=\"UTF-8\"?>\\n' +\n '<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" ' +\n 'xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">'\n\nconst INDEX_HEADER =\n '<?xml version=\"1.0\" encoding=\"UTF-8\"?>\\n' +\n '<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">'\n\n/** Wrap the buffered `<url>` blocks in a `<urlset>` envelope. */\nfunction buildUrlset(urlBlocks: readonly string[]): string {\n return `${URLSET_HEADER}\\n${urlBlocks.join('\\n')}\\n</urlset>\\n`\n}\n\n/** Build one `<url>` block: `<loc>`, `<lastmod>`, one `xhtml:link` per alternate + x-default. */\nfunction buildUrlEntry(\n entry: PathEntry,\n currentLocale: string,\n alternates: readonly Alternate[],\n opts: SitemapOptions,\n base: string,\n): string {\n const loc = absoluteUrl(base, opts.prefix, currentLocale, entry.path)\n const links = alternates.map(\n (alt) => ` ${hreflangLink(alt.locale, absoluteUrl(base, opts.prefix, alt.locale, alt.path))}`,\n )\n // x-default → the default-locale URL, when the document is published there.\n const defaultAlt = alternates.find((alt) => alt.locale === opts.defaultLocale)\n if (defaultAlt) {\n links.push(\n ` ${hreflangLink('x-default', absoluteUrl(base, opts.prefix, defaultAlt.locale, defaultAlt.path))}`,\n )\n }\n return (\n ' <url>\\n' +\n ` <loc>${escapeXml(loc)}</loc>\\n` +\n ` <lastmod>${entry.updatedAt.toISOString()}</lastmod>\\n` +\n `${links.join('\\n')}\\n` +\n ' </url>'\n )\n}\n\n/** Build the `<sitemapindex>` referencing every produced urlset file. */\nfunction buildIndex(files: ReadonlyArray<{ filename: string; lastmod: string }>, base: string): string {\n const entries = files.map(\n (f) =>\n ' <sitemap>\\n' +\n ` <loc>${escapeXml(`${base}/${f.filename}`)}</loc>\\n` +\n ` <lastmod>${f.lastmod}</lastmod>\\n` +\n ' </sitemap>',\n )\n return `${INDEX_HEADER}\\n${entries.join('\\n')}\\n</sitemapindex>\\n`\n}\n\n/** One `<xhtml:link rel=\"alternate\" hreflang=... href=.../>` tag (both values escaped). */\nfunction hreflangLink(hreflang: string, href: string): string {\n return `<xhtml:link rel=\"alternate\" hreflang=\"${escapeXml(hreflang)}\" href=\"${escapeXml(href)}\"/>`\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/** `baseUrl` + optional `prefix(locale)` + `path` — the absolute, un-escaped URL. */\nfunction absoluteUrl(\n base: string,\n prefix: SitemapOptions['prefix'],\n locale: string,\n path: string,\n): string {\n return `${base}${prefix ? prefix(locale) : ''}${path}`\n}\n\n/** Strip any trailing slash(es) so `base + '/' + file` / `base + path` never doubles up. */\nfunction stripTrailingSlash(url: string): string {\n return url.replace(/\\/+$/, '')\n}\n\n/**\n * Escape the five XML predefined entities. `&` MUST be replaced first so the\n * entity ampersands it introduces are not re-escaped.\n */\nfunction escapeXml(value: string): string {\n return value\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&apos;')\n}\n\n/** `sitemap-<locale>.xml` (index 0) / `sitemap-<locale>-<n>.xml` (splits, n≥2). */\nfunction urlsetFilename(locale: string, index: number): string {\n return index === 0 ? `sitemap-${locale}.xml` : `sitemap-${locale}-${index + 1}.xml`\n}\n\n/**\n * A finite, ≥1 integer from a config-derived value, else `fallback`. Guards a 0 /\n * negative / fractional / NaN `maxUrlsPerFile` or `batchSize` from producing a\n * zero-length split loop.\n */\nfunction normalizePositiveInt(value: number | undefined, fallback: number): number {\n if (value === undefined || !Number.isFinite(value)) return fallback\n const floored = Math.floor(value)\n return floored >= 1 ? floored : fallback\n}\n\n/**\n * Buffer an async source into fixed-size chunks — bounds the per-batch\n * cross-locale fan-out (see module header). The final chunk may be short.\n */\nasync function* chunkAsync<T>(source: AsyncIterable<T>, size: number): AsyncGenerator<T[]> {\n let buffer: T[] = []\n for await (const item of source) {\n buffer.push(item)\n if (buffer.length >= size) {\n yield buffer\n buffer = []\n }\n }\n if (buffer.length > 0) yield buffer\n}\n","/**\n * Build-time redirect list for SSG frontends (plan/routing PR08; phase\n * `06-frontend-adapters.md` §3, finding F037 point 3).\n *\n * `getRedirects` streams the full `(from, to, status)` redirect set for one\n * locale — the manual (`toolkit_redirects`) rows PLUS the automatic historical\n * (`toolkit_paths` non-canonical) rows — so a frontend can materialize a static\n * redirect map (`_redirects` / `vercel.json` / an nginx include) at build time.\n * It is a PURE generator, not an HTTP endpoint: the frontend recipe (PR08 Task\n * 2b) maps the yielded entries into its host's adapter format.\n *\n * **Security (CLAUDE.md #1).** The output feeds a public, static build artifact,\n * so a redirect must NEVER point at content the live site would 404 — a static\n * 301 to unpublished content has no request-time fallback to catch it (unlike\n * `resolvePath`, where the target's own canonical tier re-checks). So every\n * redirect whose target is a resolved DOCUMENT (entity-kind manual + all\n * historical) is publish-gated with the SAME batched `filterPublished` primitive\n * `enumeratePaths` / `generateSitemap` use, and an unpublished target is DROPPED\n * — mirroring `resolvePath`'s tier-4 `checkPublished` gate. `url`-kind targets\n * are admin-authored external URLs (never a public write); `path`-kind targets\n * are gated on the target path currently serving canonical content, matching\n * `resolveManualRedirect`'s own path-kind branch.\n *\n * **No unbounded fan-out (CLAUDE.md \"no amplification\").** Both halves are\n * keyset-paginated on the immutable `id` PK (one index query per page), and the\n * document resolution + publish check are BATCHED — one `getCanonicalPaths` +\n * one `filterPublished` per (page × distinct entity-type group), never one query\n * per row (mirrors `enumeratePaths`'s / the sitemap's own bound). Manual redirects\n * are admin-authored (bounded volume, not content-scaled) but are still paginated\n * on principle. The only inherently per-row read is the `path`-kind existence\n * check — one bounded `limit: 1` lookup per surviving `path`-kind manual row —\n * which can't be batched (it keys on the target PATH, not an id set) and rides on\n * the same bounded admin-authored volume.\n *\n * **Precedence, mirroring `resolvePath`.** `resolvePath` resolves\n * canonical → manual → historical, and within manual a locale-specific row wins\n * over the catch-all (`locale IS NULL`) row (`lookupManualRedirect`). This list\n * reproduces that ordering: locale-specific manual rows first (claiming their\n * `from`), then catch-all manual rows (skipped where a locale-specific row\n * already claimed the path), then historical rows (skipped where ANY manual\n * redirect claimed the path — matching resolvePath's tier-3-before-tier-4\n * short-circuit, which fires even when the manual row itself resolves to nothing).\n *\n * **Not reproduced (deliberate, deps stay minimal).** (1) The homepage-collision\n * collapse (`resolvePath` sends a redirect that targets the homepage document\n * straight to `/`): a manual/historical redirect targeting the homepage instead\n * emits its pattern URL, which the live `resolvePath` then 301s to `/` — one\n * extra hop for a rare case, not a broken link, and it avoids threading a\n * `homepageReader` dep. (2) `path`-kind live multi-hop walking: `path` targets are\n * collapsed to ≤1 hop at save time (`collapsePathTarget`, phase 04 §3), so a\n * single existence lookup suffices; a rebuild re-runs this generator whenever\n * redirects change. (3) The multi-document historical collision (two docs holding\n * a historical row at the same path — findRedirectTarget's documented\n * approximate/self-healing edge, F014): this list emits at most one rule per\n * `from` (first that resolves, in id order) rather than most-recent-updated wins.\n *\n * **Leaf module.** Every cross-package dependency is injected via `deps`; this\n * module imports no `@murumets-ee/core` and no `server-only` graph. Concrete deps\n * are built from an app in `./resolve-context.ts` (`buildRedirectsListDeps`).\n */\n\nimport {\n type CanonicalPathsBatchDeps,\n getCanonicalPaths,\n type PathEnumReader,\n type PathEnumRow,\n type PathEnumWhere,\n type PublishFilter,\n} from './enumerate.js'\nimport { normalizePath } from './generate-path.js'\nimport type { RedirectTarget } from '../redirects/target.js'\n\n// ---------------------------------------------------------------------------\n// Read surface — `type` aliases (not interfaces) so the real\n// `ToolkitRedirectsClient`, typed against `WhereClause`'s template-literal index\n// signature, stays assignable without a cast (same rationale as\n// `PathEnumReader` / `index-reader.ts`'s Deviation-1).\n// ---------------------------------------------------------------------------\n\n/** The projected `toolkit_redirects` row the manual-redirect enumeration reads. */\nexport type RedirectListRow = {\n id: string\n fromPath: string\n /** BCP47 locale, or `null` for the catch-all (all-locales) row. */\n locale: string | null\n target: RedirectTarget\n status: number\n}\n\n/**\n * The (operator-carrying) where shapes the manual enumeration issues: a keyset\n * `id > cursor` plus a `locale` equality (a concrete locale, or `null` for the\n * catch-all stratum). A subset of the real client's `WhereClause`, so the real\n * `ToolkitRedirectsClient` is assignable to {@link RedirectListReader}.\n */\nexport type RedirectListWhere = {\n id?: { gt: string }\n locale?: string | null\n}\n\n/**\n * The batched keyset read surface the manual half needs over `toolkit_redirects`.\n * Construct the real client directly (`toolkitRedirectsTable.makeClient(db)`) —\n * it satisfies this type.\n */\nexport type RedirectListReader = {\n findMany(options: {\n where?: RedirectListWhere\n orderBy?: Array<{ column: 'id'; dir?: 'asc' | 'desc' }>\n limit?: number\n }): Promise<RedirectListRow[]>\n}\n\n// ---------------------------------------------------------------------------\n// Public surface\n// ---------------------------------------------------------------------------\n\n/** One emitted redirect. `301` permanent, `302` temporary (phase 05 §2 shape). */\nexport interface RedirectEntry {\n from: string\n to: string\n status: 301 | 302\n}\n\n/** Options for {@link getRedirects}. */\nexport interface RedirectsListOptions {\n /** Emit this locale's redirects only (mirrors `enumeratePaths` / `generateSitemap`). */\n locale: string\n}\n\n/** Injected dependencies for {@link getRedirects}. */\nexport interface RedirectsListDeps {\n /** Keyset reader over `toolkit_redirects` (manual redirects). */\n redirectsClient: RedirectListReader\n /**\n * Keyset reader over `toolkit_paths` — the historical (non-canonical) rows AND\n * the `path`-kind existence check. Reuses `enumeratePaths`'s exact reader shape.\n */\n pathsClient: PathEnumReader\n /** Batched forward index read for owning-document current-canonical resolution. */\n canonicalBatch: CanonicalPathsBatchDeps\n /** The SAME batched publish filter `enumeratePaths` / `generateSitemap` use. */\n filterPublished: PublishFilter\n}\n\n/** Internal tuning seam (page size). Real callers omit it. */\nexport interface RedirectsListTuning {\n /** Rows per keyset page. Defaults to {@link REDIRECTS_PAGE_SIZE}. Tests force a small value. */\n pageSize?: number\n}\n\n/**\n * Redirect-list page size — same order of magnitude as `enumeratePaths`'s\n * `ENUMERATE_PAGE_SIZE` (200). Must stay ≤ the `defineTable` `MAX_LIMIT` (1000),\n * which THROWS rather than clamps.\n */\nconst REDIRECTS_PAGE_SIZE = 200\n\n/**\n * Stream the redirect list for `opts.locale`: manual redirects (locale-specific\n * then catch-all, deduped by precedence) followed by historical redirects\n * (skipping any `from` a manual redirect already claimed). See the module header\n * for the security + fan-out + precedence guarantees.\n */\nexport async function* getRedirects(\n opts: RedirectsListOptions,\n deps: RedirectsListDeps,\n tuning: RedirectsListTuning = {},\n): AsyncGenerator<RedirectEntry> {\n const { locale } = opts\n const pageSize = tuning.pageSize ?? REDIRECTS_PAGE_SIZE\n\n /**\n * Every `from` a manual redirect (either stratum) OWNS for this locale. Serves\n * two roles: (1) a locale-specific row shadows a catch-all row at the same\n * path; (2) ANY manual redirect at a path shadows the historical tier there —\n * even when the manual row itself resolves to nothing (resolvePath tier-3\n * short-circuit). Populated for EVERY manual row seen (dropped or emitted).\n */\n const manualFrom = new Set<string>()\n\n // ── Tier 3a: locale-specific manual redirects ──────────────────────────────\n yield* manualRedirects(locale, locale, deps, pageSize, manualFrom)\n // ── Tier 3b: catch-all (locale IS NULL) manual redirects ───────────────────\n // `manualFrom` now holds every locale-specific `from`, so catch-all rows at\n // those paths are suppressed inside `manualRedirects`.\n yield* manualRedirects(locale, null, deps, pageSize, manualFrom)\n\n // ── Tier 4: historical (automatic rename) redirects ────────────────────────\n yield* historicalRedirects(locale, deps, pageSize, manualFrom)\n}\n\n// ---------------------------------------------------------------------------\n// Manual redirects\n// ---------------------------------------------------------------------------\n\n/**\n * Stream one `locale` stratum of `toolkit_redirects` (a concrete locale, or\n * `null` for the catch-all rows), keyset-paginated. Records every surviving\n * `from` in `manualFrom` (for precedence + historical shadowing) and yields the\n * resolved, publish-gated entries in id-ascending order.\n */\nasync function* manualRedirects(\n locale: string,\n whereLocale: string | null,\n deps: RedirectsListDeps,\n pageSize: number,\n manualFrom: Set<string>,\n): AsyncGenerator<RedirectEntry> {\n let cursorId: string | undefined\n for (;;) {\n const where: RedirectListWhere = { locale: whereLocale }\n if (cursorId !== undefined) where.id = { gt: cursorId }\n\n const page = await deps.redirectsClient.findMany({\n where,\n orderBy: [{ column: 'id', dir: 'asc' }],\n limit: pageSize,\n })\n if (page.length === 0) break\n\n // Drop rows whose `from` a higher-precedence manual redirect already claimed\n // (a locale-specific row shadowing a catch-all one). Record every surviving\n // `from` — even one we go on to DROP — so precedence + historical shadowing\n // hold regardless of resolution outcome (matches resolvePath's short-circuit).\n const survivors: RedirectListRow[] = []\n for (const row of page) {\n if (manualFrom.has(row.fromPath)) continue\n manualFrom.add(row.fromPath)\n survivors.push(row)\n }\n\n // Batched entity-kind resolution: group by target entity type, then ONE\n // getCanonicalPaths + ONE filterPublished per type group (never per row).\n const resolvedEntityTo = await resolveEntityTargets(survivors, locale, deps)\n\n // Emit in survivor (id) order.\n for (const row of survivors) {\n const status = toRedirectStatus(row.status)\n const target = row.target\n\n if (target.kind === 'url') {\n // Terminal external URL — admin-authored, no publish gate, no self-loop.\n yield { from: row.fromPath, to: target.url, status }\n continue\n }\n\n if (target.kind === 'entity') {\n const to = resolvedEntityTo.get(row.id)\n // Dropped when unpublished / no canonical row (matches resolvePath's\n // tier-3 `{kind:'none'}`). Skip a degenerate self-redirect too.\n if (to !== undefined && to !== row.fromPath) {\n yield { from: row.fromPath, to, status }\n }\n continue\n }\n\n // target.kind === 'path' — collapsed to ≤1 hop at save time, so one bounded\n // existence lookup suffices. Emit only when the target path currently serves\n // canonical content (matches resolveManualRedirect's path-kind branch).\n const to = normalizePath(target.path)\n if (to === row.fromPath) continue\n if (await pathServesCanonical(deps.pathsClient, locale, to)) {\n yield { from: row.fromPath, to, status }\n }\n }\n\n const lastRow = page[page.length - 1]\n if (!lastRow) break\n cursorId = lastRow.id\n if (page.length < pageSize) break\n }\n}\n\n/**\n * Resolve the entity-kind targets in a page to their published canonical path,\n * BATCHED by entity type: one `getCanonicalPaths` (forward index) + one\n * `filterPublished` (publish gate) per distinct target type — never per row\n * (mirrors the sitemap's `resolveAlternates` bucketing). Returns a\n * `redirectRowId → path` map holding only the rows that resolve AND publish.\n */\nasync function resolveEntityTargets(\n rows: readonly RedirectListRow[],\n locale: string,\n deps: RedirectsListDeps,\n): Promise<Map<string, string>> {\n const result = new Map<string, string>()\n\n // Group entity-kind rows by target entity type (bounded: routable types are\n // few + config-fixed). Capture `(redirectRowId, targetId)` while the union arm\n // is still narrowed, so no discriminant re-cast is needed downstream.\n const targetsByType = new Map<string, Array<{ rowId: string; targetId: string }>>()\n for (const row of rows) {\n if (row.target.kind !== 'entity') continue\n const pair = { rowId: row.id, targetId: row.target.id }\n const bucket = targetsByType.get(row.target.entity)\n if (bucket) bucket.push(pair)\n else targetsByType.set(row.target.entity, [pair])\n }\n\n for (const [type, targets] of targetsByType) {\n const ids = targets.map((t) => t.targetId)\n // (1) Forward index: which target ids have a canonical row in `locale`?\n const pathMap = await getCanonicalPaths(type, ids, locale, deps.canonicalBatch)\n const candidateIds = [...pathMap.keys()]\n if (candidateIds.length === 0) continue\n // (2) Publish gate: of THOSE, which are actually published in `locale`?\n const published = await deps.filterPublished(type, candidateIds, locale)\n for (const { rowId, targetId } of targets) {\n const path = pathMap.get(targetId)\n if (path !== undefined && published.has(targetId)) result.set(rowId, path)\n }\n }\n\n return result\n}\n\n/** True iff `path` currently serves a canonical document in `locale` (one bounded lookup). */\nasync function pathServesCanonical(\n pathsClient: PathEnumReader,\n locale: string,\n path: string,\n): Promise<boolean> {\n const rows = await pathsClient.findMany({\n where: { locale, path, isCanonical: true },\n limit: 1,\n })\n return rows.length > 0\n}\n\n// ---------------------------------------------------------------------------\n// Historical redirects\n// ---------------------------------------------------------------------------\n\n/**\n * Stream the historical (automatic rename) redirects for `locale`: the demoted\n * (`is_canonical = false`) `toolkit_paths` rows, each resolved to its owning\n * document's CURRENT canonical path. Keyset-paginated; per page, ONE\n * `getCanonicalPaths` + ONE `filterPublished` per entity-type group (never per\n * row). Drops rows whose document has no live published canonical variant\n * anymore (matches `findRedirectTarget`'s `null`), and any `from` a manual\n * redirect already claimed (tier-3 wins) or an already-emitted historical `from`\n * (at most one rule per `from`).\n */\nasync function* historicalRedirects(\n locale: string,\n deps: RedirectsListDeps,\n pageSize: number,\n manualFrom: ReadonlySet<string>,\n): AsyncGenerator<RedirectEntry> {\n /** Historical `from`s already emitted — dedup the documented multi-doc collision. */\n const emittedFrom = new Set<string>()\n let cursorId: string | undefined\n for (;;) {\n const where: PathEnumWhere = { locale, isCanonical: false }\n if (cursorId !== undefined) where.id = { gt: cursorId }\n\n const page = await deps.pathsClient.findMany({\n where,\n orderBy: [{ column: 'id', dir: 'asc' }],\n limit: pageSize,\n })\n if (page.length === 0) break\n\n // Group this page's rows by entity type, then ONE batched canonical lookup +\n // ONE publish check per group. `entityId → current published canonical path`.\n const byType = new Map<string, PathEnumRow[]>()\n for (const row of page) {\n const bucket = byType.get(row.entityType)\n if (bucket) bucket.push(row)\n else byType.set(row.entityType, [row])\n }\n\n const currentPath = new Map<string, string>()\n for (const [type, rows] of byType) {\n const ids = rows.map((r) => r.entityId)\n const pathMap = await getCanonicalPaths(type, ids, locale, deps.canonicalBatch)\n const candidateIds = [...pathMap.keys()]\n if (candidateIds.length === 0) continue\n const published = await deps.filterPublished(type, candidateIds, locale)\n for (const [id, path] of pathMap) {\n if (published.has(id)) currentPath.set(id, path)\n }\n }\n\n for (const row of page) {\n // A manual redirect at this path wins (tier 3 before tier 4).\n if (manualFrom.has(row.path)) continue\n // Already emitted a historical rule at this `from` (multi-doc collision).\n if (emittedFrom.has(row.path)) continue\n const to = currentPath.get(row.entityId)\n if (to === undefined) continue // no live published canonical → dropped\n if (to === row.path) continue // self-redirect (from == to) → skip\n emittedFrom.add(row.path)\n yield { from: row.path, to, status: 301 }\n }\n\n const lastRow = page[page.length - 1]\n if (!lastRow) break\n cursorId = lastRow.id\n if (page.length < pageSize) break\n }\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Narrow the DB `status` integer (validated `301 | 302` at write time) to the\n * result union. Defaults to `301` for any unexpected value — never a raw number\n * leaking into the typed entry (mirrors `resolve.ts`'s `toRedirectStatus`).\n */\nfunction toRedirectStatus(status: number): 301 | 302 {\n return status === 302 ? 302 : 301\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"resolve.mjs","names":[],"sources":["../src/paths/sitemap.ts","../src/paths/redirects-list.ts"],"sourcesContent":["/**\n * Sitemap generation for the routed content surface (plan/routing PR07; phase\n * `05-resolution-and-enumeration-api.md` §4).\n *\n * `generateSitemap` streams the standard-protocol sitemap document set for the\n * configured locales: one `<urlset>` per locale (split into numbered files at\n * {@link SitemapOptions.maxUrlsPerFile}), plus a `<sitemapindex>` when the run\n * produced more than one file. Each `<url>` carries a `<lastmod>` (from the\n * enumerated `updatedAt`) and the full `xhtml:link` hreflang-alternates set —\n * including an `x-default` pointing at the default-locale URL. It is a PURE\n * generator, NOT an HTTP endpoint: the frontend recipe (PR08) mounts it in a\n * route handler; this module only produces the documents.\n *\n * **Security (CLAUDE.md #1).** The output feeds a public build artifact, so it\n * must NEVER leak an unpublished / trashed document. Two independent publish\n * gates apply:\n * 1. The primary rows come from {@link enumeratePaths}, which publish-filters\n * each page against the entity (unpublished/trashed dropped) for the\n * sitemap's OWN locale.\n * 2. The cross-locale hreflang alternates come from {@link getCanonicalPaths},\n * which does NOT publish-filter (it is a display-only forward index read).\n * So this module ADDITIONALLY runs the same batched `filterPublished` over\n * every candidate other-locale alternate before linking it — an\n * unpublished-in-that-locale document is never emitted as an alternate.\n *\n * **No unbounded fan-out (CLAUDE.md \"no amplification\").** The cross-locale\n * alternate lookup is BATCHED exactly like `enumeratePaths`'s own publish check:\n * per {@link SITEMAP_ALTERNATES_BATCH_SIZE}-sized chunk of enumerated rows, the\n * work is ONE `getCanonicalPaths` + ONE `filterPublished` per (entity-type\n * group × other locale) — bounded by (batch size × distinct routable types ×\n * locale count), NEVER one query per document. `getAlternates` (the per-document\n * N-locale function) is deliberately NOT used here — calling it per enumerated\n * row would be the O(rows × locales) amplification this bound forbids.\n *\n * **`noIndex` exclusion (PR 11's `seoMeta()`).** `SitemapDeps.filterNoIndex` is\n * an OPTIONAL batched dep, same shape as `filterPublished` (a `PublishFilter`)\n * but semantics-inverted: it returns the subset of ids that ARE `noIndex` in\n * the given locale. It lives on `deps`, not `opts`, because — like\n * `filterPublished` — a real implementation needs a DB read, and `noIndex` is\n * `translatable: true`, so the SAME callback must be invoked once per\n * (locale, entity-type group, batch) to see the right per-locale value; a\n * per-entry (locale-blind) predicate could not express this correctly. Omitted\n * → nothing excluded (current default). See `excludeNoIndexed`.\n *\n * **Leaf module.** Every cross-package dependency is injected via `deps`\n * ({@link SitemapDeps}); this module imports no `@murumets-ee/core` and no\n * `server-only` graph. Concrete deps are built from an app in\n * `./resolve-context.ts` (`buildSitemapDeps`).\n */\n\nimport { mapBounded } from './apply-path.js'\nimport {\n type CanonicalPathsBatchDeps,\n enumeratePaths,\n type EnumeratePathsDeps,\n type EnumeratePathsTuning,\n getCanonicalPaths,\n type PathEntry,\n type PublishFilter,\n} from './enumerate.js'\n\n// ---------------------------------------------------------------------------\n// Public surface\n// ---------------------------------------------------------------------------\n\n/** Options for {@link generateSitemap}. */\nexport interface SitemapOptions {\n /** The locales to emit a `<urlset>` for (configured content locales). */\n locales: readonly string[]\n /**\n * The default locale — its URL is the `x-default` hreflang target. Explicit\n * (NOT inferred from `locales` order) so callers stay unambiguous.\n */\n defaultLocale: string\n /** Absolute site origin, e.g. `https://example.com` (trailing slash tolerated). */\n baseUrl: string\n /**\n * Per-locale URL prefix rule (e.g. `(l) => l === 'en' ? '' : '/' + l`). The\n * frontend owns the locale-prefix convention (D002); omitted → no prefix.\n */\n prefix?: (locale: string) => string\n /**\n * Max `<url>` entries per file before splitting (sitemap protocol caps at\n * 50 000). Defaults to {@link DEFAULT_MAX_URLS_PER_FILE}.\n */\n maxUrlsPerFile?: number\n}\n\n/** Injected dependencies for {@link generateSitemap}. */\nexport interface SitemapDeps {\n /**\n * Deps for {@link enumeratePaths} — the `toolkit_paths` reader plus the\n * batched `filterPublished`. `filterPublished` is REUSED for the cross-locale\n * alternate publish gate (same batched shape), so no second publish surface is\n * needed.\n */\n enumerate: EnumeratePathsDeps\n /**\n * Deps for {@link getCanonicalPaths} — the batched cross-locale forward index\n * read (NO publish filter; this module applies `enumerate.filterPublished` on\n * top).\n */\n canonicalBatch: CanonicalPathsBatchDeps\n /**\n * Optional: batched `noIndex` check, same shape as `filterPublished` but\n * semantics-inverted — returns the subset of `entityIds` that ARE `noIndex`\n * in `locale` (PR 11's `seoMeta()`). Called at most once per (entity-type\n * group, batch), never once per entry — see `excludeNoIndexed`. Omitted →\n * nothing excluded (current default).\n */\n filterNoIndex?: PublishFilter\n}\n\n/** Internal tuning seam (batch size / enumerate page size). Real callers omit it. */\nexport interface SitemapTuning {\n /**\n * Enumerated rows per cross-locale alternate batch. Defaults to\n * {@link SITEMAP_ALTERNATES_BATCH_SIZE}. Tests force a small value to span\n * multiple batches. Bounds the per-batch fan-out (see module header).\n */\n batchSize?: number\n /** Passed through to {@link enumeratePaths} for its own paging/publish tuning. */\n enumerate?: EnumeratePathsTuning\n}\n\n/** One emitted sitemap document. */\nexport interface SitemapFile {\n /** `'urlset'` for a per-locale URL set, `'index'` for the sitemap index. */\n kind: 'urlset' | 'index'\n /**\n * Suggested filename served at `${baseUrl}/${filename}`. Per-locale urlsets are\n * `sitemap-<locale>.xml` (first) / `sitemap-<locale>-<n>.xml` (splits, n≥2);\n * the index is {@link SITEMAP_INDEX_FILENAME}.\n */\n filename: string\n /** The complete, well-formed XML document text (trailing newline included). */\n xml: string\n}\n\n/** Default `<url>` cap per file — under the sitemap protocol's 50 000 hard limit. */\nexport const DEFAULT_MAX_URLS_PER_FILE = 40_000\n\n/** Filename of the emitted sitemap index (referenced by the mounting route). */\nexport const SITEMAP_INDEX_FILENAME = 'sitemap.xml'\n\n/**\n * Enumerated rows per cross-locale alternate batch. Aligned with\n * `enumeratePaths`'s own page size so a batch is ~one enumeration page; the\n * exact value only affects HOW MANY batched lookups run, never their per-batch\n * bound (see module header). Never per-row.\n */\nconst SITEMAP_ALTERNATES_BATCH_SIZE = 200\n\n// ---------------------------------------------------------------------------\n// Generator\n// ---------------------------------------------------------------------------\n\n/** One resolved hreflang alternate (a published, canonical per-locale URL). */\ninterface Alternate {\n locale: string\n path: string\n}\n\n/**\n * Stream the sitemap document set for `opts.locales`. See the module header for\n * the security + fan-out guarantees. Yields each `<urlset>` file as it fills,\n * then a `<sitemapindex>` LAST iff more than one file was produced.\n */\nexport async function* generateSitemap(\n opts: SitemapOptions,\n deps: SitemapDeps,\n tuning: SitemapTuning = {},\n): AsyncGenerator<SitemapFile> {\n const maxUrls = normalizePositiveInt(opts.maxUrlsPerFile, DEFAULT_MAX_URLS_PER_FILE)\n const batchSize = normalizePositiveInt(tuning.batchSize, SITEMAP_ALTERNATES_BATCH_SIZE)\n const base = stripTrailingSlash(opts.baseUrl)\n\n /** (filename, lastmod) of every urlset file produced — feeds the index. */\n const producedFiles: Array<{ filename: string; lastmod: string }> = []\n\n for (const locale of opts.locales) {\n let buffer: string[] = []\n let bufferMaxUpdatedAt: Date | null = null\n let fileIndex = 0\n\n /** Flush the current buffer as a numbered urlset file (or return null if empty). */\n const flush = (): SitemapFile | null => {\n if (buffer.length === 0) return null\n const filename = urlsetFilename(locale, fileIndex)\n const lastmod = (bufferMaxUpdatedAt ?? new Date()).toISOString()\n const file: SitemapFile = { kind: 'urlset', filename, xml: buildUrlset(buffer) }\n producedFiles.push({ filename, lastmod })\n buffer = []\n bufferMaxUpdatedAt = null\n fileIndex += 1\n return file\n }\n\n const source = enumeratePaths({ locale }, deps.enumerate, tuning.enumerate)\n for await (const batch of chunkAsync(source, batchSize)) {\n // Exclude noIndex rows FIRST so they're never fetched cross-locale.\n const kept = await excludeNoIndexed(batch, locale, deps)\n if (kept.length === 0) continue\n\n // ONE batched cross-locale lookup per (entity-type group × other locale).\n const alternatesByEntry = await resolveAlternates(kept, locale, opts, deps)\n\n for (const entry of kept) {\n const alternates = alternatesByEntry.get(entry.id) ?? [{ locale, path: entry.path }]\n buffer.push(buildUrlEntry(entry, locale, alternates, opts, base))\n if (bufferMaxUpdatedAt === null || entry.updatedAt > bufferMaxUpdatedAt) {\n bufferMaxUpdatedAt = entry.updatedAt\n }\n if (buffer.length >= maxUrls) {\n const full = flush()\n if (full) yield full\n }\n }\n }\n\n const tail = flush()\n if (tail) yield tail\n }\n\n // The index is only meaningful when it references more than one file.\n if (producedFiles.length > 1) {\n yield { kind: 'index', filename: SITEMAP_INDEX_FILENAME, xml: buildIndex(producedFiles, base) }\n }\n}\n\n/**\n * Resolve the full hreflang-alternate set for a batch of same-locale entries.\n * The current locale is always a self-alternate (`enumeratePaths` already proved\n * it published + canonical there). Every OTHER locale is resolved with ONE\n * batched `getCanonicalPaths` (forward index) + ONE batched `filterPublished`\n * (publish gate) per (entity-type group × locale) — never per row.\n */\nasync function resolveAlternates(\n entries: readonly PathEntry[],\n currentLocale: string,\n opts: SitemapOptions,\n deps: SitemapDeps,\n): Promise<Map<string, Alternate[]>> {\n const result = new Map<string, Alternate[]>()\n // Seed each entry with its self-alternate (its own published canonical URL).\n for (const entry of entries) result.set(entry.id, [{ locale: currentLocale, path: entry.path }])\n\n const otherLocales = opts.locales.filter((l) => l !== currentLocale)\n if (otherLocales.length === 0) return result\n\n // Group the batch's ids by entity type (bounded: routable types are few + config-fixed).\n const idsByType = new Map<string, string[]>()\n for (const entry of entries) {\n const bucket = idsByType.get(entry.entityType)\n if (bucket) bucket.push(entry.id)\n else idsByType.set(entry.entityType, [entry.id])\n }\n\n for (const otherLocale of otherLocales) {\n for (const [type, ids] of idsByType) {\n // (1) Forward index: which of these ids have a canonical row in otherLocale?\n const pathMap = await getCanonicalPaths(type, ids, otherLocale, deps.canonicalBatch)\n if (pathMap.size === 0) continue\n // (2) Publish gate: of THOSE, which are actually published in otherLocale?\n // getCanonicalPaths does NOT publish-filter — this is the required\n // no-unpublished-leak step, batched (never per-row).\n const candidateIds = [...pathMap.keys()]\n const publishedIds = await deps.enumerate.filterPublished(type, candidateIds, otherLocale)\n // (3) noIndex gate — same shape, other-locale value. Without this, a\n // document marked noIndex in `otherLocale` (excluded from ITS OWN\n // urlset by `excludeNoIndexed` below) still gets published as an\n // hreflang alternate on every OTHER locale's urlset, publishing the\n // exact URL the editor asked to hide.\n const noIndexIds =\n deps.filterNoIndex !== undefined\n ? await deps.filterNoIndex(type, candidateIds, otherLocale)\n : undefined\n for (const [id, path] of pathMap) {\n if (!publishedIds.has(id)) continue\n if (noIndexIds?.has(id)) continue\n const alternates = result.get(id)\n if (alternates) alternates.push({ locale: otherLocale, path })\n }\n }\n }\n\n return result\n}\n\n/**\n * Peak concurrent `noIndex`-check groups per batch — mirrors `enumerate.ts`'s\n * `PUBLISH_CHECK_CONCURRENCY` bound (CLAUDE.md \"no amplification\"): a batch\n * holds a handful of distinct entity types at most, so this is rarely reached\n * — it exists so the fan-out is EXPLICITLY capped, never unbounded.\n */\nconst NOINDEX_CHECK_CONCURRENCY = 5\n\n/**\n * Keep the entries `deps.filterNoIndex` does NOT report as `noIndex` in\n * `locale` (all of them when the dep is omitted — current default: nothing\n * excluded). Groups the batch by entity type (mirrors `enumeratePaths`'s own\n * `byType` grouping) and calls `deps.filterNoIndex` ONCE per (entity-type\n * group, batch) — never once per entry — bounded to\n * {@link NOINDEX_CHECK_CONCURRENCY} concurrent groups.\n *\n * FAIL-CLOSED, deliberately, unlike `loadMetaPanelData`'s fail-open contract\n * (`packages/admin-ui/src/server/meta-panel-data.ts`): `mapBounded` does not\n * catch per-item errors (see its own doc comment in `apply-path.ts`), so a\n * `deps.filterNoIndex` rejection aborts the whole `generateSitemap` run rather\n * than silently including a possibly-noIndexed row. A build-time sitemap\n * generator failing loudly is preferable to a build succeeding with a leaked\n * noIndex URL.\n */\nasync function excludeNoIndexed(\n entries: readonly PathEntry[],\n locale: string,\n deps: SitemapDeps,\n): Promise<PathEntry[]> {\n const filterNoIndex = deps.filterNoIndex\n if (filterNoIndex === undefined) return [...entries]\n\n // Group this batch's ids by entity type (bounded: routable types are few).\n const idsByType = new Map<string, string[]>()\n for (const entry of entries) {\n const bucket = idsByType.get(entry.entityType)\n if (bucket) bucket.push(entry.id)\n else idsByType.set(entry.entityType, [entry.id])\n }\n\n const noIndexedByType = new Map<string, ReadonlySet<string>>()\n await mapBounded([...idsByType.entries()], NOINDEX_CHECK_CONCURRENCY, async ([type, ids]) => {\n noIndexedByType.set(type, await filterNoIndex(type, ids, locale))\n })\n\n return entries.filter((entry) => !noIndexedByType.get(entry.entityType)?.has(entry.id))\n}\n\n// ---------------------------------------------------------------------------\n// XML builders\n// ---------------------------------------------------------------------------\n\nconst URLSET_HEADER =\n '<?xml version=\"1.0\" encoding=\"UTF-8\"?>\\n' +\n '<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" ' +\n 'xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">'\n\nconst INDEX_HEADER =\n '<?xml version=\"1.0\" encoding=\"UTF-8\"?>\\n' +\n '<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">'\n\n/** Wrap the buffered `<url>` blocks in a `<urlset>` envelope. */\nfunction buildUrlset(urlBlocks: readonly string[]): string {\n return `${URLSET_HEADER}\\n${urlBlocks.join('\\n')}\\n</urlset>\\n`\n}\n\n/** Build one `<url>` block: `<loc>`, `<lastmod>`, one `xhtml:link` per alternate + x-default. */\nfunction buildUrlEntry(\n entry: PathEntry,\n currentLocale: string,\n alternates: readonly Alternate[],\n opts: SitemapOptions,\n base: string,\n): string {\n const loc = absoluteUrl(base, opts.prefix, currentLocale, entry.path)\n const links = alternates.map(\n (alt) => ` ${hreflangLink(alt.locale, absoluteUrl(base, opts.prefix, alt.locale, alt.path))}`,\n )\n // x-default → the default-locale URL, when the document is published there.\n const defaultAlt = alternates.find((alt) => alt.locale === opts.defaultLocale)\n if (defaultAlt) {\n links.push(\n ` ${hreflangLink('x-default', absoluteUrl(base, opts.prefix, defaultAlt.locale, defaultAlt.path))}`,\n )\n }\n return (\n ' <url>\\n' +\n ` <loc>${escapeXml(loc)}</loc>\\n` +\n ` <lastmod>${entry.updatedAt.toISOString()}</lastmod>\\n` +\n `${links.join('\\n')}\\n` +\n ' </url>'\n )\n}\n\n/** Build the `<sitemapindex>` referencing every produced urlset file. */\nfunction buildIndex(files: ReadonlyArray<{ filename: string; lastmod: string }>, base: string): string {\n const entries = files.map(\n (f) =>\n ' <sitemap>\\n' +\n ` <loc>${escapeXml(`${base}/${f.filename}`)}</loc>\\n` +\n ` <lastmod>${f.lastmod}</lastmod>\\n` +\n ' </sitemap>',\n )\n return `${INDEX_HEADER}\\n${entries.join('\\n')}\\n</sitemapindex>\\n`\n}\n\n/** One `<xhtml:link rel=\"alternate\" hreflang=... href=.../>` tag (both values escaped). */\nfunction hreflangLink(hreflang: string, href: string): string {\n return `<xhtml:link rel=\"alternate\" hreflang=\"${escapeXml(hreflang)}\" href=\"${escapeXml(href)}\"/>`\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/** `baseUrl` + optional `prefix(locale)` + `path` — the absolute, un-escaped URL. */\nfunction absoluteUrl(\n base: string,\n prefix: SitemapOptions['prefix'],\n locale: string,\n path: string,\n): string {\n return `${base}${prefix ? prefix(locale) : ''}${path}`\n}\n\n/** Strip any trailing slash(es) so `base + '/' + file` / `base + path` never doubles up. */\nfunction stripTrailingSlash(url: string): string {\n return url.replace(/\\/+$/, '')\n}\n\n/**\n * Escape the five XML predefined entities. `&` MUST be replaced first so the\n * entity ampersands it introduces are not re-escaped.\n */\nfunction escapeXml(value: string): string {\n return value\n .replace(/&/g, '&amp;')\n .replace(/</g, '&lt;')\n .replace(/>/g, '&gt;')\n .replace(/\"/g, '&quot;')\n .replace(/'/g, '&apos;')\n}\n\n/** `sitemap-<locale>.xml` (index 0) / `sitemap-<locale>-<n>.xml` (splits, n≥2). */\nfunction urlsetFilename(locale: string, index: number): string {\n return index === 0 ? `sitemap-${locale}.xml` : `sitemap-${locale}-${index + 1}.xml`\n}\n\n/**\n * A finite, ≥1 integer from a config-derived value, else `fallback`. Guards a 0 /\n * negative / fractional / NaN `maxUrlsPerFile` or `batchSize` from producing a\n * zero-length split loop.\n */\nfunction normalizePositiveInt(value: number | undefined, fallback: number): number {\n if (value === undefined || !Number.isFinite(value)) return fallback\n const floored = Math.floor(value)\n return floored >= 1 ? floored : fallback\n}\n\n/**\n * Buffer an async source into fixed-size chunks — bounds the per-batch\n * cross-locale fan-out (see module header). The final chunk may be short.\n */\nasync function* chunkAsync<T>(source: AsyncIterable<T>, size: number): AsyncGenerator<T[]> {\n let buffer: T[] = []\n for await (const item of source) {\n buffer.push(item)\n if (buffer.length >= size) {\n yield buffer\n buffer = []\n }\n }\n if (buffer.length > 0) yield buffer\n}\n","/**\n * Build-time redirect list for SSG frontends (plan/routing PR08; phase\n * `06-frontend-adapters.md` §3, finding F037 point 3).\n *\n * `getRedirects` streams the full `(from, to, status)` redirect set for one\n * locale — the manual (`toolkit_redirects`) rows PLUS the automatic historical\n * (`toolkit_paths` non-canonical) rows — so a frontend can materialize a static\n * redirect map (`_redirects` / `vercel.json` / an nginx include) at build time.\n * It is a PURE generator, not an HTTP endpoint: the frontend recipe (PR08 Task\n * 2b) maps the yielded entries into its host's adapter format.\n *\n * **Security (CLAUDE.md #1).** The output feeds a public, static build artifact,\n * so a redirect must NEVER point at content the live site would 404 — a static\n * 301 to unpublished content has no request-time fallback to catch it (unlike\n * `resolvePath`, where the target's own canonical tier re-checks). So every\n * redirect whose target is a resolved DOCUMENT (entity-kind manual + all\n * historical) is publish-gated with the SAME batched `filterPublished` primitive\n * `enumeratePaths` / `generateSitemap` use, and an unpublished target is DROPPED\n * — mirroring `resolvePath`'s tier-4 `checkPublished` gate. `url`-kind targets\n * are admin-authored external URLs (never a public write); `path`-kind targets\n * are gated on the target path currently serving canonical content, matching\n * `resolveManualRedirect`'s own path-kind branch.\n *\n * **No unbounded fan-out (CLAUDE.md \"no amplification\").** Both halves are\n * keyset-paginated on the immutable `id` PK (one index query per page), and the\n * document resolution + publish check are BATCHED — one `getCanonicalPaths` +\n * one `filterPublished` per (page × distinct entity-type group), never one query\n * per row (mirrors `enumeratePaths`'s / the sitemap's own bound). Manual redirects\n * are admin-authored (bounded volume, not content-scaled) but are still paginated\n * on principle. The only inherently per-row read is the `path`-kind existence\n * check — one bounded `limit: 1` lookup per surviving `path`-kind manual row —\n * which can't be batched (it keys on the target PATH, not an id set) and rides on\n * the same bounded admin-authored volume.\n *\n * **Precedence, mirroring `resolvePath`.** `resolvePath` resolves\n * canonical → manual → historical, and within manual a locale-specific row wins\n * over the catch-all (`locale IS NULL`) row (`lookupManualRedirect`). This list\n * reproduces that ordering: locale-specific manual rows first (claiming their\n * `from`), then catch-all manual rows (skipped where a locale-specific row\n * already claimed the path), then historical rows (skipped where ANY manual\n * redirect claimed the path — matching resolvePath's tier-3-before-tier-4\n * short-circuit, which fires even when the manual row itself resolves to nothing).\n *\n * **Not reproduced (deliberate, deps stay minimal).** (1) The homepage-collision\n * collapse (`resolvePath` sends a redirect that targets the homepage document\n * straight to `/`): a manual/historical redirect targeting the homepage instead\n * emits its pattern URL, which the live `resolvePath` then 301s to `/` — one\n * extra hop for a rare case, not a broken link, and it avoids threading a\n * `homepageReader` dep. (2) `path`-kind live multi-hop walking: `path` targets are\n * collapsed to ≤1 hop at save time (`collapsePathTarget`, phase 04 §3), so a\n * single existence lookup suffices; a rebuild re-runs this generator whenever\n * redirects change. (3) The multi-document historical collision (two docs holding\n * a historical row at the same path — findRedirectTarget's documented\n * approximate/self-healing edge, F014): this list emits at most one rule per\n * `from` (first that resolves, in id order) rather than most-recent-updated wins.\n *\n * **Leaf module.** Every cross-package dependency is injected via `deps`; this\n * module imports no `@murumets-ee/core` and no `server-only` graph. Concrete deps\n * are built from an app in `./resolve-context.ts` (`buildRedirectsListDeps`).\n */\n\nimport {\n type CanonicalPathsBatchDeps,\n getCanonicalPaths,\n type PathEnumReader,\n type PathEnumRow,\n type PathEnumWhere,\n type PublishFilter,\n} from './enumerate.js'\nimport { normalizePath } from './generate-path.js'\nimport type { RedirectTarget } from '../redirects/target.js'\n\n// ---------------------------------------------------------------------------\n// Read surface — `type` aliases (not interfaces) so the real\n// `ToolkitRedirectsClient`, typed against `WhereClause`'s template-literal index\n// signature, stays assignable without a cast (same rationale as\n// `PathEnumReader` / `index-reader.ts`'s Deviation-1).\n// ---------------------------------------------------------------------------\n\n/** The projected `toolkit_redirects` row the manual-redirect enumeration reads. */\nexport type RedirectListRow = {\n id: string\n fromPath: string\n /** BCP47 locale, or `null` for the catch-all (all-locales) row. */\n locale: string | null\n target: RedirectTarget\n status: number\n}\n\n/**\n * The (operator-carrying) where shapes the manual enumeration issues: a keyset\n * `id > cursor` plus a `locale` equality (a concrete locale, or `null` for the\n * catch-all stratum). A subset of the real client's `WhereClause`, so the real\n * `ToolkitRedirectsClient` is assignable to {@link RedirectListReader}.\n */\nexport type RedirectListWhere = {\n id?: { gt: string }\n locale?: string | null\n}\n\n/**\n * The batched keyset read surface the manual half needs over `toolkit_redirects`.\n * Construct the real client directly (`toolkitRedirectsTable.makeClient(db)`) —\n * it satisfies this type.\n */\nexport type RedirectListReader = {\n findMany(options: {\n where?: RedirectListWhere\n orderBy?: Array<{ column: 'id'; dir?: 'asc' | 'desc' }>\n limit?: number\n }): Promise<RedirectListRow[]>\n}\n\n// ---------------------------------------------------------------------------\n// Public surface\n// ---------------------------------------------------------------------------\n\n/** One emitted redirect. `301` permanent, `302` temporary (phase 05 §2 shape). */\nexport interface RedirectEntry {\n from: string\n to: string\n status: 301 | 302\n}\n\n/** Options for {@link getRedirects}. */\nexport interface RedirectsListOptions {\n /** Emit this locale's redirects only (mirrors `enumeratePaths` / `generateSitemap`). */\n locale: string\n}\n\n/** Injected dependencies for {@link getRedirects}. */\nexport interface RedirectsListDeps {\n /** Keyset reader over `toolkit_redirects` (manual redirects). */\n redirectsClient: RedirectListReader\n /**\n * Keyset reader over `toolkit_paths` — the historical (non-canonical) rows AND\n * the `path`-kind existence check. Reuses `enumeratePaths`'s exact reader shape.\n */\n pathsClient: PathEnumReader\n /** Batched forward index read for owning-document current-canonical resolution. */\n canonicalBatch: CanonicalPathsBatchDeps\n /** The SAME batched publish filter `enumeratePaths` / `generateSitemap` use. */\n filterPublished: PublishFilter\n}\n\n/** Internal tuning seam (page size). Real callers omit it. */\nexport interface RedirectsListTuning {\n /** Rows per keyset page. Defaults to {@link REDIRECTS_PAGE_SIZE}. Tests force a small value. */\n pageSize?: number\n}\n\n/**\n * Redirect-list page size — same order of magnitude as `enumeratePaths`'s\n * `ENUMERATE_PAGE_SIZE` (200). Must stay ≤ the `defineTable` `MAX_LIMIT` (1000),\n * which THROWS rather than clamps.\n */\nconst REDIRECTS_PAGE_SIZE = 200\n\n/**\n * Stream the redirect list for `opts.locale`: manual redirects (locale-specific\n * then catch-all, deduped by precedence) followed by historical redirects\n * (skipping any `from` a manual redirect already claimed). See the module header\n * for the security + fan-out + precedence guarantees.\n */\nexport async function* getRedirects(\n opts: RedirectsListOptions,\n deps: RedirectsListDeps,\n tuning: RedirectsListTuning = {},\n): AsyncGenerator<RedirectEntry> {\n const { locale } = opts\n const pageSize = tuning.pageSize ?? REDIRECTS_PAGE_SIZE\n\n /**\n * Every `from` a manual redirect (either stratum) OWNS for this locale. Serves\n * two roles: (1) a locale-specific row shadows a catch-all row at the same\n * path; (2) ANY manual redirect at a path shadows the historical tier there —\n * even when the manual row itself resolves to nothing (resolvePath tier-3\n * short-circuit). Populated for EVERY manual row seen (dropped or emitted).\n */\n const manualFrom = new Set<string>()\n\n // ── Tier 3a: locale-specific manual redirects ──────────────────────────────\n yield* manualRedirects(locale, locale, deps, pageSize, manualFrom)\n // ── Tier 3b: catch-all (locale IS NULL) manual redirects ───────────────────\n // `manualFrom` now holds every locale-specific `from`, so catch-all rows at\n // those paths are suppressed inside `manualRedirects`.\n yield* manualRedirects(locale, null, deps, pageSize, manualFrom)\n\n // ── Tier 4: historical (automatic rename) redirects ────────────────────────\n yield* historicalRedirects(locale, deps, pageSize, manualFrom)\n}\n\n// ---------------------------------------------------------------------------\n// Manual redirects\n// ---------------------------------------------------------------------------\n\n/**\n * Stream one `locale` stratum of `toolkit_redirects` (a concrete locale, or\n * `null` for the catch-all rows), keyset-paginated. Records every surviving\n * `from` in `manualFrom` (for precedence + historical shadowing) and yields the\n * resolved, publish-gated entries in id-ascending order.\n */\nasync function* manualRedirects(\n locale: string,\n whereLocale: string | null,\n deps: RedirectsListDeps,\n pageSize: number,\n manualFrom: Set<string>,\n): AsyncGenerator<RedirectEntry> {\n let cursorId: string | undefined\n for (;;) {\n const where: RedirectListWhere = { locale: whereLocale }\n if (cursorId !== undefined) where.id = { gt: cursorId }\n\n const page = await deps.redirectsClient.findMany({\n where,\n orderBy: [{ column: 'id', dir: 'asc' }],\n limit: pageSize,\n })\n if (page.length === 0) break\n\n // Drop rows whose `from` a higher-precedence manual redirect already claimed\n // (a locale-specific row shadowing a catch-all one). Record every surviving\n // `from` — even one we go on to DROP — so precedence + historical shadowing\n // hold regardless of resolution outcome (matches resolvePath's short-circuit).\n const survivors: RedirectListRow[] = []\n for (const row of page) {\n if (manualFrom.has(row.fromPath)) continue\n manualFrom.add(row.fromPath)\n survivors.push(row)\n }\n\n // Batched entity-kind resolution: group by target entity type, then ONE\n // getCanonicalPaths + ONE filterPublished per type group (never per row).\n const resolvedEntityTo = await resolveEntityTargets(survivors, locale, deps)\n\n // Emit in survivor (id) order.\n for (const row of survivors) {\n const status = toRedirectStatus(row.status)\n const target = row.target\n\n if (target.kind === 'url') {\n // Terminal external URL — admin-authored, no publish gate, no self-loop.\n yield { from: row.fromPath, to: target.url, status }\n continue\n }\n\n if (target.kind === 'entity') {\n const to = resolvedEntityTo.get(row.id)\n // Dropped when unpublished / no canonical row (matches resolvePath's\n // tier-3 `{kind:'none'}`). Skip a degenerate self-redirect too.\n if (to !== undefined && to !== row.fromPath) {\n yield { from: row.fromPath, to, status }\n }\n continue\n }\n\n // target.kind === 'path' — collapsed to ≤1 hop at save time, so one bounded\n // existence lookup suffices. Emit only when the target path currently serves\n // canonical content (matches resolveManualRedirect's path-kind branch).\n const to = normalizePath(target.path)\n if (to === row.fromPath) continue\n if (await pathServesCanonical(deps.pathsClient, locale, to)) {\n yield { from: row.fromPath, to, status }\n }\n }\n\n const lastRow = page[page.length - 1]\n if (!lastRow) break\n cursorId = lastRow.id\n if (page.length < pageSize) break\n }\n}\n\n/**\n * Resolve the entity-kind targets in a page to their published canonical path,\n * BATCHED by entity type: one `getCanonicalPaths` (forward index) + one\n * `filterPublished` (publish gate) per distinct target type — never per row\n * (mirrors the sitemap's `resolveAlternates` bucketing). Returns a\n * `redirectRowId → path` map holding only the rows that resolve AND publish.\n */\nasync function resolveEntityTargets(\n rows: readonly RedirectListRow[],\n locale: string,\n deps: RedirectsListDeps,\n): Promise<Map<string, string>> {\n const result = new Map<string, string>()\n\n // Group entity-kind rows by target entity type (bounded: routable types are\n // few + config-fixed). Capture `(redirectRowId, targetId)` while the union arm\n // is still narrowed, so no discriminant re-cast is needed downstream.\n const targetsByType = new Map<string, Array<{ rowId: string; targetId: string }>>()\n for (const row of rows) {\n if (row.target.kind !== 'entity') continue\n const pair = { rowId: row.id, targetId: row.target.id }\n const bucket = targetsByType.get(row.target.entity)\n if (bucket) bucket.push(pair)\n else targetsByType.set(row.target.entity, [pair])\n }\n\n for (const [type, targets] of targetsByType) {\n const ids = targets.map((t) => t.targetId)\n // (1) Forward index: which target ids have a canonical row in `locale`?\n const pathMap = await getCanonicalPaths(type, ids, locale, deps.canonicalBatch)\n const candidateIds = [...pathMap.keys()]\n if (candidateIds.length === 0) continue\n // (2) Publish gate: of THOSE, which are actually published in `locale`?\n const published = await deps.filterPublished(type, candidateIds, locale)\n for (const { rowId, targetId } of targets) {\n const path = pathMap.get(targetId)\n if (path !== undefined && published.has(targetId)) result.set(rowId, path)\n }\n }\n\n return result\n}\n\n/** True iff `path` currently serves a canonical document in `locale` (one bounded lookup). */\nasync function pathServesCanonical(\n pathsClient: PathEnumReader,\n locale: string,\n path: string,\n): Promise<boolean> {\n const rows = await pathsClient.findMany({\n where: { locale, path, isCanonical: true },\n limit: 1,\n })\n return rows.length > 0\n}\n\n// ---------------------------------------------------------------------------\n// Historical redirects\n// ---------------------------------------------------------------------------\n\n/**\n * Stream the historical (automatic rename) redirects for `locale`: the demoted\n * (`is_canonical = false`) `toolkit_paths` rows, each resolved to its owning\n * document's CURRENT canonical path. Keyset-paginated; per page, ONE\n * `getCanonicalPaths` + ONE `filterPublished` per entity-type group (never per\n * row). Drops rows whose document has no live published canonical variant\n * anymore (matches `findRedirectTarget`'s `null`), and any `from` a manual\n * redirect already claimed (tier-3 wins) or an already-emitted historical `from`\n * (at most one rule per `from`).\n */\nasync function* historicalRedirects(\n locale: string,\n deps: RedirectsListDeps,\n pageSize: number,\n manualFrom: ReadonlySet<string>,\n): AsyncGenerator<RedirectEntry> {\n /** Historical `from`s already emitted — dedup the documented multi-doc collision. */\n const emittedFrom = new Set<string>()\n let cursorId: string | undefined\n for (;;) {\n const where: PathEnumWhere = { locale, isCanonical: false }\n if (cursorId !== undefined) where.id = { gt: cursorId }\n\n const page = await deps.pathsClient.findMany({\n where,\n orderBy: [{ column: 'id', dir: 'asc' }],\n limit: pageSize,\n })\n if (page.length === 0) break\n\n // Group this page's rows by entity type, then ONE batched canonical lookup +\n // ONE publish check per group. `entityId → current published canonical path`.\n const byType = new Map<string, PathEnumRow[]>()\n for (const row of page) {\n const bucket = byType.get(row.entityType)\n if (bucket) bucket.push(row)\n else byType.set(row.entityType, [row])\n }\n\n const currentPath = new Map<string, string>()\n for (const [type, rows] of byType) {\n const ids = rows.map((r) => r.entityId)\n const pathMap = await getCanonicalPaths(type, ids, locale, deps.canonicalBatch)\n const candidateIds = [...pathMap.keys()]\n if (candidateIds.length === 0) continue\n const published = await deps.filterPublished(type, candidateIds, locale)\n for (const [id, path] of pathMap) {\n if (published.has(id)) currentPath.set(id, path)\n }\n }\n\n for (const row of page) {\n // A manual redirect at this path wins (tier 3 before tier 4).\n if (manualFrom.has(row.path)) continue\n // Already emitted a historical rule at this `from` (multi-doc collision).\n if (emittedFrom.has(row.path)) continue\n const to = currentPath.get(row.entityId)\n if (to === undefined) continue // no live published canonical → dropped\n if (to === row.path) continue // self-redirect (from == to) → skip\n emittedFrom.add(row.path)\n yield { from: row.path, to, status: 301 }\n }\n\n const lastRow = page[page.length - 1]\n if (!lastRow) break\n cursorId = lastRow.id\n if (page.length < pageSize) break\n }\n}\n\n// ---------------------------------------------------------------------------\n// Helpers\n// ---------------------------------------------------------------------------\n\n/**\n * Narrow the DB `status` integer (validated `301 | 302` at write time) to the\n * result union. Defaults to `301` for any unexpected value — never a raw number\n * leaking into the typed entry (mirrors `resolve.ts`'s `toRedirectStatus`).\n */\nfunction toRedirectStatus(status: number): 301 | 302 {\n return status === 302 ? 302 : 301\n}\n"],"mappings":"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"}
@@ -0,0 +1,2 @@
1
+ import{t as e}from"./content-config-store-8omQF5p5.mjs";import{i as t,n}from"./schema-D8sBQozw.mjs";import{d as r,n as i,s as a}from"./schema-0zayzpD_.mjs";import{matchReservedPrefix as o}from"@murumets-ee/entity";import{combineAdminRoutes as s,defineAdminRoute as c,getContext as l,safeAudit as u}from"@murumets-ee/core";import{and as d,eq as f}from"drizzle-orm";import{z as p}from"zod";import{entityRefs as m}from"@murumets-ee/entity/refs";const h=`path:resolve`,ee=[`admin`,`editor`];function g(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}const _={kind:`none`};function v(e){return e===null||e.length===0?{error:`query param 'path' is required`}:e.length>2048?{error:`query param 'path' is too long`}:e.startsWith(`/`)?e.startsWith(`//`)?{error:`query param 'path' must not be protocol-relative`}:e.includes(`?`)||e.includes(`#`)?{error:`query param 'path' must not include a query or fragment`}:{path:e}:{error:`query param 'path' must start with '/'`}}function y(t,n){let r=e(),i=t??n??r.defaultLocale;return(l()?.availableLocales??r.locales.map(e=>e.code)).includes(i)?{locale:i}:{error:`query param 'locale' is not a content locale`}}const b=async(e,t)=>{let n=new URL(e.url).searchParams,r=v(n.get(`path`));if(`error`in r)return g({error:r.error},400);let i=y(n.get(`locale`),t.locale??t.defaultLocale);if(`error`in i)return g({error:i.error},400);let{locale:o}=i,{buildResolveDeps:s}=await import(`./resolve-context-MRZZ3rQl.mjs`).then(e=>e.l),c=s(t.app),l=(e,n)=>t.checkPermission(e,`view`)?{kind:`document`,entityType:e,id:n}:_,u=await a(r.path,o,c);if(u.kind===`document`)return g(l(u.entityType,u.id));if(u.kind===`none`)return g(_);if(!(u.to.startsWith(`/`)&&!u.to.startsWith(`//`)))return g({kind:`redirect`,to:u.to});let d=await a(u.to,o,c);return g(d.kind===`document`?l(d.entityType,d.id):_)};function x(){return s([c({prefix:`paths`,path:`resolve`,method:`GET`,permission:h,defaultRoles:ee,description:`Resolve a site URL path to the CMS document that serves it (editor canvas link navigation).`,handler:b})])}var S=class extends Error{fromPath;prefix;constructor(e,t){super(`Redirect source "${e}" falls under the reserved prefix "${t}" and cannot be used — that path range is owned by the framework.`),this.name=`RedirectReservedPrefixError`,this.fromPath=e,this.prefix=t}},C=class extends Error{fromPath;locale;conflictingLocale;constructor(e,t,n){super(`Redirect source "${e}" already serves content in locale "${n}" (it is a live canonical URL). Choose a different source path, or unpublish/rename the content first.`),this.name=`RedirectServesContentError`,this.fromPath=e,this.locale=t,this.conflictingLocale=n}},w=class extends Error{entity;constructor(e){super(`Redirect target references entity type "${e}", which is not a registered entity. Check the entity name.`),this.name=`RedirectUnknownEntityError`,this.entity=e}},T=class extends Error{entity;id;constructor(e,t){super(`Redirect target ${e} "${t}" does not exist. Redirect to a document that exists, or use a URL/path target instead.`),this.name=`RedirectTargetNotFoundError`,this.entity=e,this.id=t}},E=class extends Error{fromPath;locale;constructor(e,t){super(`Redirect source "${e}"${t===null?``:` (locale "${t}")`} resolves back to itself — a redirect cannot point at its own source path. Choose a different destination.`),this.name=`RedirectSelfReferenceError`,this.fromPath=e,this.locale=t}};const D=`toolkit_redirects`;async function O(e,t,n){n.kind===`entity`&&await e.insert(m).values({sourceEntity:D,sourceId:t,sourceField:`target`,targetEntity:n.entity,targetId:n.id,relationKind:`link`}).onConflictDoNothing()}async function k(e,t){await e.delete(m).where(d(f(m.sourceEntity,D),f(m.sourceId,t)))}const A=p.discriminatedUnion(`kind`,[p.object({kind:p.literal(`entity`),entity:p.string().min(1),id:p.string().uuid()}),p.object({kind:p.literal(`url`),url:p.string().max(2048).url()}),p.object({kind:p.literal(`path`),path:p.string().min(1).max(2048).startsWith(`/`)})]);function j(e){return r(e)}function te(e){let t=j(e.fromPath),n=o(t,e.reservedPathPrefixes);if(n!==void 0)throw new S(t,n)}async function ne(e){let t=j(e.fromPath);if(e.locale!==null){let n=await e.pathsClient.findOne({path:t,isCanonical:!0,locale:e.locale});if(n)throw new C(t,e.locale,n.locale);return}let n=await e.pathsClient.findOne({path:t,isCanonical:!0,locale:{in:[...e.configuredLocales]}});if(n)throw new C(t,null,n.locale)}async function M(e){if(e.target.kind!==`path`)return e.target;let t=j(e.target.path),n=e.excludeId===void 0?{}:{id:{ne:e.excludeId}};if(e.locale!==null){let r=await e.redirectsClient.findOne({fromPath:t,locale:e.locale,...n});if(r)return r.target}let r=await e.redirectsClient.findOne({fromPath:t,locale:null,...n});return r?r.target:{kind:`path`,path:t}}async function N(e){if(e.target.kind!==`entity`)return;let{entity:t,id:n}=e.target,r=e.resolveClient(t);if(r===void 0)throw new w(t);if(await r.findById(n)==null)throw new T(t,n)}const P=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,F=/^[a-zA-Z0-9:_.-]{1,100}$/,I=`redirect:manage`,L=[`admin`];function R(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function z(e,t){return R({error:e},t)}async function B(e,t){let n;try{n=await e.json()}catch{return{response:z(`Invalid JSON body`,400)}}let r=t.safeParse(n);if(!r.success){let e=r.error.issues[0];return{response:z(e?`${e.path.join(`.`)||`body`}: ${e.message}`:`Invalid body`,400)}}return{data:r.data}}function V(e,t){if(e===null||!/^\d+$/.test(e))return t;let n=Number(e);return Number.isSafeInteger(n)?n:t}function H(e){return e.name===void 0?{userId:e.id}:{userId:e.id,userName:e.name}}async function U(){let{getApp:e}=await import(`@murumets-ee/core`);return e()}async function W(){return(await U()).db.readWrite}async function G(){let e=await U();return t=>{let n=e.entities.get(t);return n?e.getClient(n):void 0}}function K(e){return e}const q=p.union([p.literal(301),p.literal(302)]),J=p.string().min(1).max(2048).startsWith(`/`),Y=p.object({locale:p.string().min(1).nullable(),fromPath:J,target:A,status:q.default(301)}),X=p.object({locale:p.string().min(1).nullable().optional(),fromPath:J.optional(),target:A.optional(),status:q.optional()});function Z(t){return t===null||e().locales.map(e=>e.code).includes(t)?null:z(`Unknown locale "${t}" — not one of the configured content locales.`,400)}function Q(e){if(e instanceof S)return R({error:e.message,fromPath:e.fromPath,prefix:e.prefix},409);if(e instanceof C)return R({error:e.message,fromPath:e.fromPath,locale:e.locale,conflictingLocale:e.conflictingLocale},409);if(e instanceof E)return R({error:e.message,fromPath:e.fromPath,locale:e.locale},409);if(e instanceof w)return R({error:e.message,entity:e.entity},400);if(e instanceof T)return R({error:e.message,entity:e.entity,id:e.id},400);if(t(e))return R({error:`A redirect already exists for this locale + source path.`},409);throw e}async function $(t){let r=e(),a=r.reservedPathPrefixes??[],o=r.locales.map(e=>e.code),s=j(t.fromPath);return te({fromPath:s,reservedPathPrefixes:a}),await N({target:t.target,resolveClient:t.resolveClient}),(await W()).transaction(async e=>{let r=K(e),a=i.makeClient(r),c=n.makeClient(r);await ne({fromPath:s,locale:t.locale,configuredLocales:o,pathsClient:c});let l=await M({target:t.target,locale:t.locale,redirectsClient:a,...t.id===null?{}:{excludeId:t.id}});if(l.kind===`path`&&j(l.path)===s)throw new E(s,t.locale);if(t.id===null){let e=await a.insert({locale:t.locale,fromPath:s,target:l,status:t.status,createdBy:t.userId});return await O(r,e.id,l),e}let u=await a.update({id:t.id},{locale:t.locale,fromPath:s,target:l,status:t.status,updatedAt:new Date});return u?(await k(r,t.id),await O(r,t.id,l),u):null})}const re=async e=>{let t=new URL(e.url),n=t.searchParams.get(`locale`),r=Math.min(Math.max(V(t.searchParams.get(`limit`),50),1),200),a=Math.min(V(t.searchParams.get(`offset`),0),1e5),o;if(n!==null&&n!==``){let e=Z(n);if(e)return e;o=n}let s=i.makeClient(await W());return R({items:o===void 0?await s.findMany({orderBy:[{column:`createdAt`,dir:`desc`}],limit:r,offset:a}):await s.findMany({where:{locale:o},orderBy:[{column:`createdAt`,dir:`desc`}],limit:r,offset:a}),total:o===void 0?await s.count():await s.count({locale:o})})},ie=async(e,t)=>{let n=await B(e,Y);if(`response`in n)return n.response;let r=n.data,i=Z(r.locale);if(i)return i;try{let e=await G(),n=await $({id:null,locale:r.locale,fromPath:r.fromPath,target:r.target,status:r.status,userId:t.user.id,resolveClient:e});return n?(u(t,{action:`redirect.created`,entityType:`redirect`,entityId:n.id,...H(t.user),metadata:{locale:n.locale,fromPath:n.fromPath,targetKind:n.target.kind}}),R({item:n},201)):z(`Failed to create redirect`,500)}catch(e){return Q(e)}},ae=async(e,t)=>{let n=t.segments[0];if(!n||!P.test(n))return z(`Invalid id`,400);let r=await B(e,X);if(`response`in r)return r.response;let a=r.data,o=await i.makeClient(await W()).findOne({id:n});if(!o)return z(`Not found`,404);let s=a.locale===void 0?o.locale:a.locale,c=a.fromPath===void 0?o.fromPath:a.fromPath,l=a.target===void 0?o.target:a.target,d=a.status===void 0?o.status:a.status,f=Z(s);if(f)return f;try{let e=await G(),r=await $({id:n,locale:s,fromPath:c,target:l,status:d,userId:t.user.id,resolveClient:e});return r?(u(t,{action:`redirect.updated`,entityType:`redirect`,entityId:r.id,...H(t.user),metadata:{locale:r.locale,fromPath:r.fromPath,targetKind:r.target.kind}}),R({item:r})):z(`Not found`,404)}catch(e){return Q(e)}},oe=async(e,t)=>{let n=t.segments[0];if(!n||!P.test(n))return z(`Invalid id`,400);let r=await(await W()).transaction(async e=>{let t=K(e);return await k(t,n),i.makeClient(t).delete({id:n})});return r?(u(t,{action:`redirect.deleted`,entityType:`redirect`,entityId:n,...H(t.user),metadata:{locale:r.locale,fromPath:r.fromPath}}),R({ok:!0})):z(`Not found`,404)},se=async e=>{let t=new URL(e.url),r=t.searchParams.get(`entityType`),i=t.searchParams.get(`entityId`);if(!r||!F.test(r))return z(`entityType is required`,400);if(!i||!P.test(i))return z(`entityId is required and must be a UUID`,400);let a=t.searchParams.get(`locale`),o;if(a!==null&&a!==``){let e=Z(a);if(e)return e;o=a}return R({items:await n.makeClient(await W()).findMany({where:{entityType:r,entityId:i,isCanonical:!1,...o!==void 0&&{locale:o}},orderBy:[{column:`updatedAt`,dir:`desc`}],limit:100})})};function ce(){return s([c({prefix:`redirects`,path:``,method:`GET`,permission:I,defaultRoles:L,description:`List manual URL redirects (paginated, optional locale filter).`,handler:re}),c({prefix:`redirects`,path:`history`,method:`GET`,permission:I,defaultRoles:L,description:`Read-only automatic-redirect (rename) history for one document.`,handler:se}),c({prefix:`redirects`,path:``,method:`POST`,permission:I,defaultRoles:L,description:`Create a manual URL redirect.`,handler:ie}),c({prefix:`redirects`,path:``,method:`PATCH`,matchAnyPath:!0,permission:I,defaultRoles:L,description:`Update a manual URL redirect by id.`,handler:ae}),c({prefix:`redirects`,path:``,method:`DELETE`,matchAnyPath:!0,permission:I,defaultRoles:L,description:`Delete a manual URL redirect by id.`,handler:oe})])}export{h as n,x as r,ce as t};
2
+ //# sourceMappingURL=routes-C9I1iZw8.mjs.map