@murumets-ee/content 0.99.0 → 0.102.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.
- package/dist/admin.mjs +1 -1
- package/dist/chrome-provider.d.mts +1 -1
- package/dist/chrome-provider.mjs +1 -1
- package/dist/client.d.mts +2 -2
- package/dist/client.mjs +1 -1
- package/dist/client.mjs.map +1 -1
- package/dist/context-DtHgyrgb.mjs +2 -0
- package/dist/{context-D7eEItCY.mjs.map → context-DtHgyrgb.mjs.map} +1 -1
- package/dist/document-site-B8tmEqLw.mjs +2 -0
- package/dist/document-site-B8tmEqLw.mjs.map +1 -0
- package/dist/entities/index.d.mts +2 -2
- package/dist/entities/index.mjs +1 -1
- package/dist/{index-C3VyZLjf.d.mts → index-Bni-PiIz.d.mts} +2 -2
- package/dist/{index-C3VyZLjf.d.mts.map → index-Bni-PiIz.d.mts.map} +1 -1
- package/dist/index.d.mts +4 -4
- package/dist/index.mjs +1 -1
- package/dist/locale-registry-COsJrVZl.mjs +2 -0
- package/dist/locale-registry-COsJrVZl.mjs.map +1 -0
- package/dist/lock-service.d.mts +1 -1
- package/dist/plugin-CvPWz399.mjs +2 -0
- package/dist/plugin-CvPWz399.mjs.map +1 -0
- package/dist/plugin.d.mts +2 -2
- package/dist/plugin.d.mts.map +1 -1
- package/dist/plugin.mjs +1 -1
- package/dist/resolve.mjs +1 -1
- package/dist/routes-D-NWP0hK.mjs +2 -0
- package/dist/routes-D-NWP0hK.mjs.map +1 -0
- package/dist/{routing-strategy-DV7KGarX.d.mts → routing-strategy-DVWB7qPp.d.mts} +2 -2
- package/dist/{routing-strategy-DV7KGarX.d.mts.map → routing-strategy-DVWB7qPp.d.mts.map} +1 -1
- package/dist/seed.d.mts.map +1 -1
- package/dist/seed.mjs +1 -1
- package/dist/seed.mjs.map +1 -1
- package/dist/site-chrome-BAtQaAqC.mjs +2 -0
- package/dist/{site-chrome-AffFxYrV.mjs.map → site-chrome-BAtQaAqC.mjs.map} +1 -1
- package/dist/{site-chrome-DJ8IJUi1.d.mts → site-chrome-BiV3zhw0.d.mts} +2 -2
- package/dist/{site-chrome-DJ8IJUi1.d.mts.map → site-chrome-BiV3zhw0.d.mts.map} +1 -1
- package/dist/translation-provenance--dHtcmfg.d.mts +253 -0
- package/dist/translation-provenance--dHtcmfg.d.mts.map +1 -0
- package/dist/translation-provenance-eqhsGAyi.mjs +2 -0
- package/dist/translation-provenance-eqhsGAyi.mjs.map +1 -0
- package/dist/translation.d.mts +2 -0
- package/dist/translation.mjs +1 -0
- package/dist/{types-B3K2p9t8.d.mts → types-wMTglM4j.d.mts} +13 -4
- package/dist/types-wMTglM4j.d.mts.map +1 -0
- package/dist/{versionable-ByZgfpp0.d.mts → versionable-D2JjselN.d.mts} +2 -2
- package/dist/{versionable-ByZgfpp0.d.mts.map → versionable-D2JjselN.d.mts.map} +1 -1
- package/dist/{versions-C5_IJpI8.mjs → versions-B0_9TtZ0.mjs} +2 -2
- package/dist/{versions-C5_IJpI8.mjs.map → versions-B0_9TtZ0.mjs.map} +1 -1
- package/package.json +14 -10
- package/dist/context-D7eEItCY.mjs +0 -2
- package/dist/locale-registry-Bn6e_nM7.mjs +0 -2
- package/dist/locale-registry-Bn6e_nM7.mjs.map +0 -1
- package/dist/plugin-DmyWYmuE.mjs +0 -2
- package/dist/plugin-DmyWYmuE.mjs.map +0 -1
- package/dist/registry-CaPbY5lm.mjs +0 -2
- package/dist/registry-CaPbY5lm.mjs.map +0 -1
- package/dist/routes-B6TJ5qiR.mjs +0 -2
- package/dist/routes-B6TJ5qiR.mjs.map +0 -1
- package/dist/site-chrome-AffFxYrV.mjs +0 -2
- package/dist/types-B3K2p9t8.d.mts.map +0 -1
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{t as e}from"./content-config-store-8omQF5p5.mjs";import{n as t,s as n}from"./schema-BYrYzsmO.mjs";import{d as r,n as i,s as a}from"./schema-LlK6EkOJ.mjs";import{matchReservedPrefix as o}from"@murumets-ee/entity";import{combineAdminRoutes as s,defineAdminRoute as c,getContext as l,requireRequestSite as u,safeAudit as d}from"@murumets-ee/core";import{and as f,eq as p}from"drizzle-orm";import{z as m}from"zod";import{entityRefs as h}from"@murumets-ee/entity/refs";const g=`path:resolve`,ee=[`admin`,`editor`];function _(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}const v={kind:`none`};function y(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 b(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 x=async(e,t)=>{let n=new URL(e.url).searchParams,r=y(n.get(`path`));if(`error`in r)return _({error:r.error},400);let i=b(n.get(`locale`),t.locale??t.defaultLocale);if(`error`in i)return _({error:i.error},400);let{locale:o}=i,{buildResolveDeps:s}=await import(`./resolve-context-DklN53BH.mjs`).then(e=>e.u),c=s(t.app),l=(e,n)=>t.checkPermission(e,`view`)?{kind:`document`,entityType:e,id:n}:v,u=await a(r.path,o,c);if(u.kind===`document`)return _(l(u.entityType,u.id));if(u.kind===`none`)return _(v);if(!(u.to.startsWith(`/`)&&!u.to.startsWith(`//`)))return _({kind:`redirect`,to:u.to});let d=await a(u.to,o,c);return _(d.kind===`document`?l(d.entityType,d.id):v)};function S(){return s([c({prefix:`paths`,path:`resolve`,method:`GET`,permission:g,defaultRoles:ee,description:`Resolve a site URL path to the CMS document that serves it (editor canvas link navigation).`,tokens:`accept`,handler:x})])}var C=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}},w=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}},T=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}},E=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}},D=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 O=`toolkit_redirects`;async function k(e,t,n){n.kind===`entity`&&await e.insert(h).values({sourceEntity:O,sourceId:t,sourceField:`target`,targetEntity:n.entity,targetId:n.id,relationKind:`link`}).onConflictDoNothing()}async function A(e,t){await e.delete(h).where(f(p(h.sourceEntity,O),p(h.sourceId,t)))}const j=m.discriminatedUnion(`kind`,[m.object({kind:m.literal(`entity`),entity:m.string().min(1),id:m.string().uuid()}),m.object({kind:m.literal(`url`),url:m.string().max(2048).url()}),m.object({kind:m.literal(`path`),path:m.string().min(1).max(2048).startsWith(`/`)})]);function M(e){return r(e)}function N(e){let t=M(e.fromPath),n=o(t,e.reservedPathPrefixes);if(n!==void 0)throw new C(t,n)}async function te(e){let t=M(e.fromPath);if(e.locale!==null){let n=await e.pathsClient.findOne({path:t,isCanonical:!0,locale:e.locale});if(n)throw new w(t,e.locale,n.locale);return}let n=await e.pathsClient.findOne({path:t,isCanonical:!0,locale:{in:[...e.configuredLocales]}});if(n)throw new w(t,null,n.locale)}async function ne(e){if(e.target.kind!==`path`)return e.target;let t=M(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 P(e){if(e.target.kind!==`entity`)return;let{entity:t,id:n}=e.target,r=e.resolveClient(t);if(r===void 0)throw new T(t);if(await r.findById(n)==null)throw new E(t,n)}function F(){return u(`toolkit_redirects`)}const I=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,L=/^[a-zA-Z0-9:_.-]{1,100}$/,R=`redirect:manage`,z=[`admin`];function B(e,t=200){return new Response(JSON.stringify(e),{status:t,headers:{"Content-Type":`application/json`}})}function V(e,t){return B({error:e},t)}async function H(e,t){let n;try{n=await e.json()}catch{return{response:V(`Invalid JSON body`,400)}}let r=t.safeParse(n);if(!r.success){let e=r.error.issues[0];return{response:V(e?`${e.path.join(`.`)||`body`}: ${e.message}`:`Invalid body`,400)}}return{data:r.data}}function U(e,t){if(e===null||!/^\d+$/.test(e))return t;let n=Number(e);return Number.isSafeInteger(n)?n:t}function W(e){return e.name===void 0?{userId:e.id}:{userId:e.id,userName:e.name}}async function G(){let{getApp:e}=await import(`@murumets-ee/core`);return e()}async function K(){return(await G()).db.readWrite}async function q(){let e=await G();return t=>{let n=e.entities.get(t);return n?e.getClient(n):void 0}}function J(e){return e}const Y=m.union([m.literal(301),m.literal(302)]),X=m.string().min(1).max(2048).startsWith(`/`),re=m.object({locale:m.string().min(1).nullable(),fromPath:X,target:j,status:Y.default(301)}),ie=m.object({locale:m.string().min(1).nullable().optional(),fromPath:X.optional(),target:j.optional(),status:Y.optional()});function Z(t){return t===null||e().locales.map(e=>e.code).includes(t)?null:V(`Unknown locale "${t}" — not one of the configured content locales.`,400)}function Q(e){if(e instanceof C)return B({error:e.message,fromPath:e.fromPath,prefix:e.prefix},409);if(e instanceof w)return B({error:e.message,fromPath:e.fromPath,locale:e.locale,conflictingLocale:e.conflictingLocale},409);if(e instanceof D)return B({error:e.message,fromPath:e.fromPath,locale:e.locale},409);if(e instanceof T)return B({error:e.message,entity:e.entity},400);if(e instanceof E)return B({error:e.message,entity:e.entity,id:e.id},400);if(n(e))return B({error:`A redirect already exists for this locale + source path.`},409);throw e}async function $(n){let r=e(),a=r.reservedPathPrefixes??[],o=r.locales.map(e=>e.code),s=M(n.fromPath);N({fromPath:s,reservedPathPrefixes:a}),await P({target:n.target,resolveClient:n.resolveClient});let c=await K(),l=await F();return c.transaction(async e=>{let r=J(e),a=i.makeClient(r,l),c=t.makeClient(r,l);await te({fromPath:s,locale:n.locale,configuredLocales:o,pathsClient:c});let u=await ne({target:n.target,locale:n.locale,redirectsClient:a,...n.id===null?{}:{excludeId:n.id}});if(u.kind===`path`&&M(u.path)===s)throw new D(s,n.locale);if(n.id===null){let e=await a.insert({locale:n.locale,fromPath:s,target:u,status:n.status,createdBy:n.userId});return await k(r,e.id,u),e}let d=await a.update({id:n.id},{locale:n.locale,fromPath:s,target:u,status:n.status,updatedAt:new Date});return d?(await A(r,n.id),await k(r,n.id,u),d):null})}const ae=async e=>{let t=new URL(e.url),n=t.searchParams.get(`locale`),r=Math.min(Math.max(U(t.searchParams.get(`limit`),50),1),200),a=Math.min(U(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 K(),await F());return B({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})})},oe=async(e,t)=>{let n=await H(e,re);if(`response`in n)return n.response;let r=n.data,i=Z(r.locale);if(i)return i;try{let e=await q(),n=await $({id:null,locale:r.locale,fromPath:r.fromPath,target:r.target,status:r.status,userId:t.user.id,resolveClient:e});return n?(d(t,{action:`redirect.created`,entityType:`redirect`,entityId:n.id,...W(t.user),metadata:{locale:n.locale,fromPath:n.fromPath,targetKind:n.target.kind}}),B({item:n},201)):V(`Failed to create redirect`,500)}catch(e){return Q(e)}},se=async(e,t)=>{let n=t.segments[0];if(!n||!I.test(n))return V(`Invalid id`,400);let r=await H(e,ie);if(`response`in r)return r.response;let a=r.data,o=await i.makeClient(await K(),await F()).findOne({id:n});if(!o)return V(`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,u=a.status===void 0?o.status:a.status,f=Z(s);if(f)return f;try{let e=await q(),r=await $({id:n,locale:s,fromPath:c,target:l,status:u,userId:t.user.id,resolveClient:e});return r?(d(t,{action:`redirect.updated`,entityType:`redirect`,entityId:r.id,...W(t.user),metadata:{locale:r.locale,fromPath:r.fromPath,targetKind:r.target.kind}}),B({item:r})):V(`Not found`,404)}catch(e){return Q(e)}},ce=async(e,t)=>{let n=t.segments[0];if(!n||!I.test(n))return V(`Invalid id`,400);let r=await K(),a=await F(),o=await r.transaction(async e=>{let t=J(e),r=await i.makeClient(t,a).delete({id:n});return r&&await A(t,n),r});return o?(d(t,{action:`redirect.deleted`,entityType:`redirect`,entityId:n,...W(t.user),metadata:{locale:o.locale,fromPath:o.fromPath}}),B({ok:!0})):V(`Not found`,404)},le=async e=>{let n=new URL(e.url),r=n.searchParams.get(`entityType`),i=n.searchParams.get(`entityId`);if(!r||!L.test(r))return V(`entityType is required`,400);if(!i||!I.test(i))return V(`entityId is required and must be a UUID`,400);let a=n.searchParams.get(`locale`),o;if(a!==null&&a!==``){let e=Z(a);if(e)return e;o=a}return B({items:await t.makeClient(await K(),await F()).findMany({where:{entityType:r,entityId:i,isCanonical:!1,...o!==void 0&&{locale:o}},orderBy:[{column:`updatedAt`,dir:`desc`}],limit:100})})};function ue(){return s([c({prefix:`redirects`,path:``,method:`GET`,permission:R,defaultRoles:z,description:`List manual URL redirects (paginated, optional locale filter).`,tokens:`accept`,handler:ae}),c({prefix:`redirects`,path:`history`,method:`GET`,permission:R,defaultRoles:z,description:`Read-only automatic-redirect (rename) history for one document.`,tokens:`accept`,handler:le}),c({prefix:`redirects`,path:``,method:`POST`,permission:R,defaultRoles:z,description:`Create a manual URL redirect.`,tokens:`accept`,handler:oe}),c({prefix:`redirects`,path:``,method:`PATCH`,matchAnyPath:!0,permission:R,defaultRoles:z,description:`Update a manual URL redirect by id.`,tokens:`accept`,handler:se}),c({prefix:`redirects`,path:``,method:`DELETE`,matchAnyPath:!0,permission:R,defaultRoles:z,description:`Delete a manual URL redirect by id.`,tokens:`accept`,confirm:!0,handler:ce})])}export{g as n,S as r,ue as t};
|
|
2
|
+
//# sourceMappingURL=routes-D-NWP0hK.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"routes-D-NWP0hK.mjs","names":["json"],"sources":["../src/paths/routes.ts","../src/redirects/errors.ts","../src/redirects/refs.ts","../src/redirects/target.ts","../src/redirects/validation.ts","../src/redirects/routes.ts"],"sourcesContent":["/**\n * Path-resolution admin route — \"which document owns this URL?\".\n *\n * Mounted under prefix `paths`. The URL surface:\n *\n * GET /api/admin/paths/resolve ?path=/foo/bar&locale=en\n *\n * **Why this exists.** The block editor's preview canvas is a `srcDoc` iframe,\n * so a rendered link's site-absolute `href` resolves against the ADMIN origin —\n * clicking one navigated the CANVAS and could render the whole admin editor\n * inside itself (#775). `@murumets-ee/blocks` now contains every in-canvas link\n * click and hands it to its host; the host needs exactly one server capability\n * to make that click useful: turn a site path into the document that serves it,\n * so it can open THAT document's editor.\n *\n * **Draft resolution, deliberately.** Uses `resolveDraftPath` (not\n * `resolvePath`): an editor following a link to an unpublished sibling page\n * must land on its editor, and the caller is an authenticated admin who can\n * already read the row through the entity API. This is exactly the\n * \"admin-only surface\" `resolveDraftPath`'s docblock reserves — the FIRST HTTP\n * route wired to it. It must never be reachable from `content-api` (public,\n * published-only).\n *\n * **Authorization — two gates.**\n * 1. Route gate (`defineAdminRoute`): `path:resolve`, `defaultRoles:\n * ['admin', 'editor']`. Viewers are excluded; the capability only exists\n * to navigate BETWEEN editors.\n * 2. Per-document gate: the resolved `entityType` must also pass\n * `ctx.checkPermission(entityType, 'view')`. A caller with the route\n * permission but no grant on that entity gets `{ kind: 'none' }` — the\n * SAME answer as a path that owns nothing, so the response never\n * discloses that a document exists there (no-leak, matching\n * `menus`' anchors route and content-api's 404-not-403 rule).\n *\n * **Bounded work.** One resolve is a handful of indexed single-row reads. A\n * `redirect`-kind result is followed exactly ONCE more (the index stores\n * collapsed targets, and `resolveInternal` already caps its own chain at\n * `MAX_REDIRECT_HOPS`) — so this handler's cost is constant, never a chain\n * walk driven by caller input.\n */\n\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n combineAdminRoutes,\n defineAdminRoute,\n getContext,\n} from '@murumets-ee/core'\nimport { getContentConfig } from '../content-config-store.js'\n// `./resolve.js` is a LEAF (every dependency injected, no `@murumets-ee/core`\n// graph) so it imports statically. `./resolve-context.js` is NOT — it holds the\n// app-binding imports, and its own header reserves it for request-time surfaces\n// and forbids the `lumi` CLI command graph (F027 guardrail). This module is\n// reached from `plugin.ts`'s STATIC graph, which the CLI loads through\n// `lumi.config.ts`, so the binding is imported at REQUEST time inside the\n// handler instead — same deferral `redirects/routes.ts` uses for `getApp`.\nimport { resolveDraftPath } from './resolve.js'\n\n/** Route permission + the roles that hold it by default. */\nexport const PATH_RESOLVE_PERMISSION = 'path:resolve'\nexport const PATH_RESOLVE_DEFAULT_ROLES = ['admin', 'editor'] as const\n\n/**\n * Cap on the `path` query param. `toolkit_paths.path` is `text` (no DB cap), so\n * the bound is ours: generously above any real URL, low enough that the param\n * can't be used to push megabytes through the query planner.\n */\nconst MAX_PATH_LENGTH = 2048\n\n/** The answer shape the caller (admin-ui's editor canvas) consumes. */\nexport type ResolvePathResponse =\n /** The path is served by this document. */\n | { readonly kind: 'document'; readonly entityType: string; readonly id: string }\n /** The path redirects OFF-SITE (a `url`-kind manual redirect). */\n | { readonly kind: 'redirect'; readonly to: string }\n /** Nothing the caller may see owns this path. */\n | { readonly kind: 'none' }\n\nfunction json(data: ResolvePathResponse | { error: string }, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n}\n\nconst NONE: ResolvePathResponse = { kind: 'none' }\n\n/**\n * Boundary validation for the `path` param. Rejects (rather than normalizes)\n * anything that isn't a bare locale-relative path: a protocol-relative\n * `//host` or a `?query` / `#fragment` tail means the CALLER built the request\n * wrong, and answering it would silently resolve something other than what it\n * asked about.\n */\nfunction validatePath(raw: string | null): { path: string } | { error: string } {\n if (raw === null || raw.length === 0) return { error: \"query param 'path' is required\" }\n if (raw.length > MAX_PATH_LENGTH) return { error: \"query param 'path' is too long\" }\n if (!raw.startsWith('/')) return { error: \"query param 'path' must start with '/'\" }\n if (raw.startsWith('//')) return { error: \"query param 'path' must not be protocol-relative\" }\n if (raw.includes('?') || raw.includes('#')) {\n return { error: \"query param 'path' must not include a query or fragment\" }\n }\n return { path: raw }\n}\n\n/**\n * Whitelist the locale (CLAUDE.md \"whitelist, don't blacklist\"), falling back\n * to the request's own content locale and then the configured default so the\n * common call can omit it.\n *\n * Checks the **request-scoped effective set** — `content()` shipped defaults ⊕\n * DB `locale` override rows, resolved once per request (SD006) — with the static\n * set as fallback outside a request context. Using only the static list was a\n * real bug, not just an inconsistency: the in-editor locale switcher is driven\n * from `effectiveLocales`, so a locale enabled at RUNTIME is a legal editor\n * locale, and it would have 400'd here — making EVERY canvas link click in that\n * locale report \"no CMS page serves …\".\n *\n * **Why this doesn't just call `locale-utils.validateLocale`**, which implements\n * exactly this predicate: that module imports `getContentConfig` from\n * `./plugin.js` rather than from the leaf store, deliberately and with a\n * documented reason (see `content-config-store.ts`'s header — its consumers'\n * unit tests mock `plugin.js`, and repointing it ripples through ~7 mocks).\n * Since `plugin.ts` imports THIS module, calling it would close a real import\n * cycle (`locale-utils -> plugin -> paths/routes -> locale-utils`), which\n * `verify:cycles` rejects. So the same two-line read is done here against the\n * same sources of truth, rather than growing this PR into that refactor.\n */\nfunction resolveRequestLocale(\n raw: string | null,\n fallback: string | undefined,\n): { locale: string } | { error: string } {\n const config = getContentConfig()\n const candidate = raw ?? fallback ?? config.defaultLocale\n const allowed = getContext()?.availableLocales ?? config.locales.map((l) => l.code)\n if (!allowed.includes(candidate)) {\n // Deliberately does NOT echo the allowed set — no reason to enumerate the\n // deployment's configured locales to the caller.\n return { error: \"query param 'locale' is not a content locale\" }\n }\n return { locale: candidate }\n}\n\n/**\n * `GET /api/admin/paths/resolve?path=&locale=` — answer \"which document serves\n * this URL?\".\n *\n * Validates both params at the boundary, resolves through the draft resolver,\n * and projects the result through the per-document `view` gate so an\n * unviewable document is indistinguishable from an unowned path. See the module\n * header for the authorization model and why the deps builder is imported\n * lazily.\n */\nconst handleResolve: AdminRouteHandler = async (req, ctx) => {\n const params = new URL(req.url).searchParams\n\n const pathResult = validatePath(params.get('path'))\n if ('error' in pathResult) return json({ error: pathResult.error }, 400)\n const localeResult = resolveRequestLocale(params.get('locale'), ctx.locale ?? ctx.defaultLocale)\n if ('error' in localeResult) return json({ error: localeResult.error }, 400)\n const { locale } = localeResult\n\n const { buildResolveDeps } = await import('./resolve-context.js')\n const deps = buildResolveDeps(ctx.app)\n\n // Gate 2 (see the module header) — a document the caller can't view answers\n // exactly like an unowned path.\n const project = (entityType: string, id: string): ResolvePathResponse =>\n ctx.checkPermission(entityType, 'view') ? { kind: 'document', entityType, id } : NONE\n\n const result = await resolveDraftPath(pathResult.path, locale, deps)\n if (result.kind === 'document') return json(project(result.entityType, result.id))\n if (result.kind === 'none') return json(NONE)\n\n // `redirect`: an off-site target is handed back for the host to open; a\n // same-site one is followed ONE hop so the caller lands on the document\n // instead of round-tripping. No loop — exactly one extra resolve. A\n // protocol-relative `//host` target counts as off-site, not as a path (target\n // validation shouldn't produce one, but resolving it AS a path would silently\n // answer about something else entirely).\n const sameSite = result.to.startsWith('/') && !result.to.startsWith('//')\n if (!sameSite) return json({ kind: 'redirect', to: result.to })\n const hop = await resolveDraftPath(result.to, locale, deps)\n return json(hop.kind === 'document' ? project(hop.entityType, hop.id) : NONE)\n}\n\n/**\n * Build the path-resolution admin routes. One entry: `GET /paths/resolve`.\n */\nexport function pathRoutes(): AdminRoute[] {\n return combineAdminRoutes([\n defineAdminRoute({\n prefix: 'paths',\n path: 'resolve',\n method: 'GET',\n permission: PATH_RESOLVE_PERMISSION,\n defaultRoles: PATH_RESOLVE_DEFAULT_ROLES,\n description:\n 'Resolve a site URL path to the CMS document that serves it (editor canvas link navigation).',\n // A read, and it answers only for a document the caller may `view`.\n tokens: 'accept',\n handler: handleResolve,\n }),\n ])\n}\n","/**\n * Domain errors thrown by the manual-redirect save-time validation\n * (`./validation.ts`, plan/routing PR05, phase `04-path-lifecycle-and-redirects.md`\n * §3). Distinct classes so Task 2's admin API handler can map each to a 4xx\n * (caller-actionable) rather than a generic 500, and so callers can branch on\n * `instanceof`.\n *\n * Style mirrors `../paths/errors.ts` exactly (`extends Error`, named readonly\n * fields, an actionable message, `this.name = 'ClassName'`). A redirect-specific\n * reserved-prefix error is defined here rather than reusing `paths`'\n * `PathReservedPrefixError`, whose constructor requires an `entityName` — a\n * redirect has no owning entity, so forcing that field would be a lie.\n */\n\n/**\n * A manual redirect's `from_path` falls under a reserved path prefix\n * (`reservedPathPrefixes`, e.g. `/admin` or `/api`). Allowing it would make the\n * redirect shadow a route the framework owns — the same hazard the path-index\n * guard rejects for entity canonical URLs — so the write is aborted before commit.\n */\nexport class RedirectReservedPrefixError extends Error {\n readonly fromPath: string\n readonly prefix: string\n\n constructor(fromPath: string, prefix: string) {\n super(\n `Redirect source \"${fromPath}\" falls under the reserved prefix \"${prefix}\" and cannot be ` +\n `used — that path range is owned by the framework.`,\n )\n this.name = 'RedirectReservedPrefixError'\n this.fromPath = fromPath\n this.prefix = prefix\n }\n}\n\n/**\n * A manual redirect's `from_path` already matches a live canonical path — that URL\n * serves content, so redirecting away from it would hide the content (phase 04 §3,\n * \"that URL serves content\"). The write is aborted before commit.\n *\n * `locale` is the redirect's own locale (`null` = a catch-all redirect, which was\n * checked against every configured locale); `conflictingLocale` names the specific\n * locale whose canonical path collided, so the message can be precise even for a\n * catch-all redirect.\n */\nexport class RedirectServesContentError extends Error {\n readonly fromPath: string\n readonly locale: string | null\n readonly conflictingLocale: string\n\n constructor(fromPath: string, locale: string | null, conflictingLocale: string) {\n super(\n `Redirect source \"${fromPath}\" already serves content in locale \"${conflictingLocale}\" ` +\n `(it is a live canonical URL). Choose a different source path, or unpublish/rename the ` +\n `content first.`,\n )\n this.name = 'RedirectServesContentError'\n this.fromPath = fromPath\n this.locale = locale\n this.conflictingLocale = conflictingLocale\n }\n}\n\n/**\n * An `entity`-kind redirect target names an entity type that isn't registered in\n * the app. A redirect to an unknown entity type can never resolve, so it's\n * rejected at save (CLAUDE.md \"validate at the boundary\").\n */\nexport class RedirectUnknownEntityError extends Error {\n readonly entity: string\n\n constructor(entity: string) {\n super(\n `Redirect target references entity type \"${entity}\", which is not a registered entity. ` +\n `Check the entity name.`,\n )\n this.name = 'RedirectUnknownEntityError'\n this.entity = entity\n }\n}\n\n/**\n * An `entity`-kind redirect target names a real entity type but a row id that\n * doesn't exist. A redirect to a nonexistent document can never resolve to a\n * useful URL, so it's rejected at save rather than discovered as a dead link at\n * request time (CLAUDE.md \"validate at the boundary\").\n */\nexport class RedirectTargetNotFoundError extends Error {\n readonly entity: string\n readonly id: string\n\n constructor(entity: string, id: string) {\n super(\n `Redirect target ${entity} \"${id}\" does not exist. Redirect to a document that exists, or ` +\n `use a URL/path target instead.`,\n )\n this.name = 'RedirectTargetNotFoundError'\n this.entity = entity\n this.id = id\n }\n}\n\n/**\n * A manual redirect's collapsed target points straight back at its OWN\n * `from_path` (in the same locale being saved) — a self-loop / no-op that could\n * never resolve to content. Rejected by Task 2's CRUD route BEFORE the row is\n * persisted, rather than stored as a degenerate row a request-time resolver\n * would have to special-case.\n *\n * Distinct from the collapse rule (`./validation.ts` `collapsePathTarget`, which\n * dedupes redirect CHAINS down to one hop): this guards the case where that\n * collapse resolves the destination back to the source path of the row itself.\n * `locale` is the redirect's own locale (`null` = catch-all).\n */\nexport class RedirectSelfReferenceError extends Error {\n readonly fromPath: string\n readonly locale: string | null\n\n constructor(fromPath: string, locale: string | null) {\n super(\n `Redirect source \"${fromPath}\"${locale === null ? '' : ` (locale \"${locale}\")`} resolves ` +\n `back to itself — a redirect cannot point at its own source path. Choose a different ` +\n `destination.`,\n )\n this.name = 'RedirectSelfReferenceError'\n this.fromPath = fromPath\n this.locale = locale\n }\n}\n","/**\n * `entity_refs` bookkeeping for manual redirects (plan/routing PR05, phase\n * `04-path-lifecycle-and-redirects.md` §3; decision F020 point 2).\n *\n * When a redirect targets an ENTITY, we register a row in the universal\n * `entity_refs` table so the broken-link dashboard can surface \"this redirect\n * points at a deleted document\". No public write helper exists for this:\n * `AdminClient.syncRefs` is private and entity-only (it needs a field map to\n * `extractRefs`), and `toolkit_redirects` is a `defineTable` infra row, not an\n * AdminClient entity. So we hand-roll insert/delete against the exported\n * `entityRefs` table, mirroring `syncRefs`'s shape (F020 point 2).\n *\n * The ref uses `relationKind: 'link'` — a SOFT reference. Only `'structured'`\n * refs block deleting the target entity (AdminClient's delete-protection filters\n * on that kind); a redirect must NEVER block deleting the document it points at\n * (phase 04 §3's Delete row: the manual redirect merely *surfaces in the\n * broken-link view* via `entity_refs`, it doesn't hard-depend). `'link'` is\n * exactly that: tracked, never blocking.\n *\n * `sourceEntity: 'toolkit_redirects'` is safe as a free-form marker —\n * `entity_refs.sourceEntity` / `targetEntity` are plain varchar with no FK to a\n * registered-entity list.\n *\n * Both functions take `exec: PostgresJsDatabase` (the same parameter type\n * `AdminClient.syncRefs` takes), so Task 2's route can call them inside the SAME\n * transaction as the `toolkit_redirects` write itself — keeping the row write and\n * its ref bookkeeping atomic.\n */\n\nimport { entityRefs, type RelationKind } from '@murumets-ee/entity/refs'\nimport { and, eq } from 'drizzle-orm'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport type { RedirectTarget } from './target.js'\n\n/** The `entity_refs.source_entity` marker for every manual-redirect ref row. */\nconst SOURCE_ENTITY = 'toolkit_redirects'\n/** The `entity_refs.source_field` marker — the `target` column of the redirect. */\nconst SOURCE_FIELD = 'target'\n/** Soft reference: tracked for the broken-link dashboard, never delete-blocking. */\nconst RELATION_KIND: RelationKind = 'link'\n\n/**\n * Register the `entity_refs` row for a redirect whose target is an entity.\n * No-op for `url` / `path` targets (nothing to track). Idempotent via\n * `.onConflictDoNothing()` (the `uq_entity_refs` unique covers\n * source+field+target), matching `AdminClient.syncRefs`'s own pattern — so a\n * re-run (e.g. an update that re-writes the same entity target) doesn't error.\n *\n * Call INSIDE the redirect-write transaction, AFTER {@link clearRedirectRefs} on\n * update (delete-then-insert; see that function's note).\n */\nexport async function writeRedirectRef(\n exec: PostgresJsDatabase,\n redirectId: string,\n target: RedirectTarget,\n): Promise<void> {\n if (target.kind !== 'entity') return\n await exec\n .insert(entityRefs)\n .values({\n sourceEntity: SOURCE_ENTITY,\n sourceId: redirectId,\n sourceField: SOURCE_FIELD,\n targetEntity: target.entity,\n targetId: target.id,\n relationKind: RELATION_KIND,\n })\n .onConflictDoNothing()\n}\n\n/**\n * Remove ALL `entity_refs` rows for a redirect. Called:\n * - before {@link writeRedirectRef} on UPDATE — delete-then-insert is simpler and\n * safer than diffing the old vs new target, and this is not a hot path; and\n * - on DELETE of the redirect row, so no dangling ref survives.\n *\n * Call INSIDE the redirect-write transaction so the ref cleanup commits (or rolls\n * back) atomically with the row change.\n */\nexport async function clearRedirectRefs(\n exec: PostgresJsDatabase,\n redirectId: string,\n): Promise<void> {\n await exec\n .delete(entityRefs)\n .where(and(eq(entityRefs.sourceEntity, SOURCE_ENTITY), eq(entityRefs.sourceId, redirectId)))\n}\n","/**\n * `RedirectTarget` — the discriminated union stored in `toolkit_redirects.target`\n * (plan/routing PR05, phase `04-path-lifecycle-and-redirects.md` §3; decision\n * F020 point 1).\n *\n * Phase 04 §3 sketches this as \"LinkValue-shaped\", but it is NOT literally blocks'\n * D008 `LinkValue` (`packages/blocks/src/core/types.ts`), which is\n * `entity | media | url | email` — it has NO `path` arm and carries `media`/`email`\n * arms a redirect never needs. `asLinkValue` / `resolveLinkHref` / the picker UI all\n * switch on that union exhaustively with no `default`, so bolting a `path` arm onto\n * the real type would ripple into blocks' richtext/picker surface for zero benefit\n * here. So this is a redirect-local union matching the phase doc's actual three\n * kinds (F020 point 1) — \"LinkValue-shaped\" read as \"same tagged-union spirit\",\n * not a literal type import.\n *\n * The three kinds:\n * - `entity` — points at a document. Resolves to that document's *current*\n * canonical path AT REQUEST TIME (never stale, per phase 04 §3 / PR06's\n * `resolvePath`), and registers an `entity_refs` row for broken-link dashboard\n * coverage (see `./refs.ts`).\n * - `url` — an absolute external URL. Admin-authored only (the table is never\n * writable from a public surface), so no open-redirect vector.\n * - `path` — a locale-relative internal path. Collapsed at save time if it\n * itself points at another manual redirect (see `./validation.ts`), so a stored\n * `path` target always points at content, never at another redirect row.\n */\n\nimport { z } from 'zod'\n\n/** The stored shape of `toolkit_redirects.target`. */\nexport type RedirectTarget =\n | { kind: 'entity'; entity: string; id: string }\n | { kind: 'url'; url: string }\n | { kind: 'path'; path: string }\n\n/**\n * API-boundary validation for a {@link RedirectTarget} (CLAUDE.md \"validate at the\n * boundary\"). Task 2's admin CRUD routes parse the request body's `target` with\n * this before any write. Discriminated on `kind` so a malformed arm reports the\n * precise field, and unknown `kind`s are rejected rather than silently stored.\n */\nexport const redirectTargetSchema = z.discriminatedUnion('kind', [\n z.object({\n kind: z.literal('entity'),\n /** Registered entity type name (existence checked in `./validation.ts`). */\n entity: z.string().min(1),\n /** Entity row id — always a UUID (entity ids are UUIDs; only user ids are varchar). */\n id: z.string().uuid(),\n }),\n z.object({\n kind: z.literal('url'),\n /** Absolute external URL. */\n url: z.string().max(2048).url(),\n }),\n z.object({\n kind: z.literal('path'),\n /** Locale-relative internal path — leading '/', same convention as `toolkit_paths.path`. */\n path: z.string().min(1).max(2048).startsWith('/'),\n }),\n])\n","/**\n * Save-time validation for manual redirects (plan/routing PR05, phase\n * `04-path-lifecycle-and-redirects.md` §3). Each rule is an exported function\n * Task 2's admin CRUD route calls BEFORE writing a `toolkit_redirects` row.\n *\n * The recommended route order (Task 2 owns the actual wiring):\n * 1. normalize `fromPath` + a `path`-kind target's `path` via {@link normalizeRedirectPath}\n * 2. {@link assertFromPathNotReserved} — reserved-prefix rejection (security)\n * 3. {@link assertFromPathNotServingContent} — \"that URL serves content\" rejection\n * 4. {@link assertEntityTargetValid} — entity target type + id existence\n * 5. {@link collapsePathTarget} — collapse a redirect-chain to one hop\n * 6. write the row (+ `entity_refs` via `./refs.ts`) inside one transaction\n *\n * ## Dependency injection (leaf-friendly, testable)\n *\n * The paths / redirects readers are injected as NARROW structural interfaces\n * ({@link PathsCanonicalReader}, {@link RedirectsCollapseReader}) rather than the\n * full `TableClient`. The real `ToolkitPathsClient` / `ToolkitRedirectsClient`\n * satisfy them (proven at compile time by the integration test, which passes the\n * real clients straight in), so Task 2 injects the real client while unit tests\n * inject a trivial stub — no `as unknown as` casts on either side. The\n * entity-existence check is injected the same way, as {@link\n * RedirectEntityClientResolver}, so this module never imports `@murumets-ee/core`.\n */\n\nimport { matchReservedPrefix } from '@murumets-ee/entity'\nimport { normalizePath } from '../paths/generate-path.js'\nimport {\n RedirectReservedPrefixError,\n RedirectServesContentError,\n RedirectTargetNotFoundError,\n RedirectUnknownEntityError,\n} from './errors.js'\nimport type { RedirectTarget } from './target.js'\n\n/**\n * Normalize a redirect path the SAME way the path index normalizes\n * `toolkit_paths.path` (collapse duplicate slashes + strip a trailing slash), so a\n * redirect's `from_path` and a `path`-kind target compare byte-identically against\n * the stored canonical/redirect paths. Task 2 should apply this to the stored\n * value too, so what is written matches what was validated. Re-exported thin\n * wrapper over the path module's `normalizePath` (DRY — do not reimplement).\n */\nexport function normalizeRedirectPath(path: string): string {\n return normalizePath(path)\n}\n\n/**\n * The read surface {@link assertFromPathNotServingContent} needs from the paths\n * index. The real `ToolkitPathsClient` satisfies this (its `findOne` accepts any\n * `WhereClause` and returns the full row).\n */\nexport interface PathsCanonicalReader {\n findOne(where: {\n path: string\n isCanonical: true\n /** A single locale, or an `IN (...)` set for the catch-all \"any configured locale\" check. */\n locale: string | { in: string[] }\n }): Promise<{ locale: string } | null>\n}\n\n/**\n * The read surface {@link collapsePathTarget} needs from the redirects table.\n * The real `ToolkitRedirectsClient` satisfies this.\n */\nexport interface RedirectsCollapseReader {\n findOne(where: {\n fromPath: string\n locale: string | null\n /** Excludes the row being updated so its own pre-write state can't mask a\n * self-referential collapse (M1 fix — see {@link collapsePathTarget}). */\n id?: { ne: string }\n }): Promise<{ target: RedirectTarget } | null>\n}\n\n/** Minimal client surface used to confirm an `entity`-kind target's id exists. */\nexport interface RedirectEntityClient {\n findById(id: string): Promise<unknown>\n}\n\n/**\n * Resolve an `AdminClient`-like reader for an entity TYPE NAME, or `undefined`\n * when that type isn't registered. Task 2 wires this to\n * `app.entities.get(name)` → `app.getClient(entity)`; injecting it keeps this\n * module a leaf (no `@murumets-ee/core` import).\n */\nexport type RedirectEntityClientResolver = (entity: string) => RedirectEntityClient | undefined\n\n/**\n * Rule (a) — reject a `from_path` under a reserved prefix (`/admin`, `/api`, …).\n * A manual redirect claiming a framework-owned path would be as broken as an\n * entity claiming it, and is the same security-relevant class as F013's gap, so\n * the same guard applies. Synchronous — pure string check. Throws\n * {@link RedirectReservedPrefixError}.\n */\nexport function assertFromPathNotReserved(args: {\n fromPath: string\n reservedPathPrefixes: readonly string[]\n}): void {\n const fromPath = normalizeRedirectPath(args.fromPath)\n const reserved = matchReservedPrefix(fromPath, args.reservedPathPrefixes)\n if (reserved !== undefined) {\n throw new RedirectReservedPrefixError(fromPath, reserved)\n }\n}\n\n/**\n * Rule (b) — reject a `from_path` that matches a live canonical path (\"that URL\n * serves content\"; phase 04 §3). Redirecting away from a URL that already serves\n * content would hide the content.\n *\n * - A LOCALE-SPECIFIC redirect (`locale` a string) is checked against that one\n * locale's canonical rows.\n * - A CATCH-ALL redirect (`locale === null`) is checked against EVERY configured\n * locale (a single `locale IN (...)` lookup — `toolkit_paths` has no null-locale\n * rows, so a null-locale lookup would never match). Restricting to configured\n * locales avoids over-blocking on orphaned rows left by a removed locale.\n *\n * Throws {@link RedirectServesContentError} naming the specific conflicting locale.\n */\nexport async function assertFromPathNotServingContent(args: {\n fromPath: string\n locale: string | null\n configuredLocales: readonly string[]\n pathsClient: PathsCanonicalReader\n}): Promise<void> {\n const fromPath = normalizeRedirectPath(args.fromPath)\n\n if (args.locale !== null) {\n const hit = await args.pathsClient.findOne({\n path: fromPath,\n isCanonical: true,\n locale: args.locale,\n })\n if (hit) throw new RedirectServesContentError(fromPath, args.locale, hit.locale)\n return\n }\n\n // Catch-all: does ANY configured locale serve this path? One IN-lookup covers\n // it (the query is bounded by the small, static configured-locale count).\n const hit = await args.pathsClient.findOne({\n path: fromPath,\n isCanonical: true,\n locale: { in: [...args.configuredLocales] },\n })\n if (hit) throw new RedirectServesContentError(fromPath, null, hit.locale)\n}\n\n/**\n * Rule (c) — collapse a `path`-kind target that itself points at another manual\n * redirect down to that redirect's (already-collapsed) target, so a stored `path`\n * target always points at CONTENT, never at another redirect row. \"Max one hop by\n * construction\" (phase 04 §3): every stored row's target is already fully\n * collapsed by this same rule, so a SINGLE lookup suffices — no loop, no\n * cycle-guard.\n *\n * Precedence, given the redirect being saved has its own `locale`:\n * - LOCALE-SPECIFIC save — prefer an exact-locale redirect at the target path,\n * else fall back to a catch-all (`locale = null`) redirect there.\n * - CATCH-ALL save (`locale === null`) — only a catch-all redirect at the target\n * path applies (a locale-specific row can't stand in for an all-locales\n * redirect).\n *\n * Non-`path` targets (entity / url) are returned unchanged without any lookup.\n * A `path` target that points at no redirect is returned unchanged (it points at\n * content, or a dead path — not this rule's concern).\n *\n * `excludeId` — pass the row's own id on UPDATE. Without it, a `PATCH` that\n * keeps `fromPath`/`locale` unchanged and sets a self-referential `path` target\n * would have this lookup match the row's own PRE-write state (the UPDATE hasn't\n * executed yet within the same transaction), silently returning its OLD target\n * instead of surfacing as a self-reference — the caller's write appears to\n * succeed (200) while the target silently doesn't change to what was requested.\n * Excluding the row's own id makes the lookup correctly report \"no redirect at\n * the target path\", which the caller's self-redirect guard then catches.\n */\nexport async function collapsePathTarget(args: {\n target: RedirectTarget\n locale: string | null\n redirectsClient: RedirectsCollapseReader\n excludeId?: string\n}): Promise<RedirectTarget> {\n if (args.target.kind !== 'path') return args.target\n const path = normalizeRedirectPath(args.target.path)\n const idFilter = args.excludeId === undefined ? {} : { id: { ne: args.excludeId } }\n\n // Prefer an exact-locale redirect (only meaningful for a locale-specific save).\n if (args.locale !== null) {\n const exact = await args.redirectsClient.findOne({\n fromPath: path,\n locale: args.locale,\n ...idFilter,\n })\n if (exact) return exact.target\n }\n // Fall back to (or, for a catch-all save, only consider) a catch-all redirect.\n const catchAll = await args.redirectsClient.findOne({ fromPath: path, locale: null, ...idFilter })\n if (catchAll) return catchAll.target\n\n // No redirect at the target path — leave the path target as-is.\n return { kind: 'path', path }\n}\n\n/**\n * Rule for `entity`-kind targets — confirm the referenced entity TYPE is\n * registered and the referenced ID exists. A redirect to an unknown type or a\n * nonexistent document can never resolve to a useful URL, so both are rejected at\n * save (CLAUDE.md \"validate at the boundary\") rather than surfacing as a dead link\n * at request time. No-op for `url` / `path` targets. Entity targets do NOT resolve\n * to a path here — that happens at REQUEST time in PR06 (phase 04 §3).\n *\n * Throws {@link RedirectUnknownEntityError} (unregistered type) or\n * {@link RedirectTargetNotFoundError} (type ok, id missing).\n */\nexport async function assertEntityTargetValid(args: {\n target: RedirectTarget\n resolveClient: RedirectEntityClientResolver\n}): Promise<void> {\n if (args.target.kind !== 'entity') return\n const { entity, id } = args.target\n\n const client = args.resolveClient(entity)\n if (client === undefined) throw new RedirectUnknownEntityError(entity)\n\n const found = await client.findById(id)\n if (found === null || found === undefined) throw new RedirectTargetNotFoundError(entity, id)\n}\n","/**\n * Manual-redirect admin CRUD routes — declarative `defineAdminRoute` entries\n * (plan/routing PR05, phase `04-path-lifecycle-and-redirects.md` §3-4; F022:\n * backend-only, no admin-ui page in this PR).\n *\n * Mounted under prefix `redirects`. The URL surface:\n *\n * GET /api/admin/redirects ?locale=&limit=&offset= — list (paginated)\n * POST /api/admin/redirects { locale, fromPath, target, status? }\n * PATCH /api/admin/redirects/:id { locale?, fromPath?, target?, status? }\n * DELETE /api/admin/redirects/:id\n * GET /api/admin/redirects/history ?entityType=&entityId=&locale= — automatic\n * (rename) redirect history for one document\n *\n * **Registration shape** (per `defineAdminRoute`'s single-segment-path\n * constraint): PATCH + DELETE embed the redirect id directly after the prefix\n * (`/redirects/<uuid>`), so they register `path: '', matchAnyPath: true` and read\n * the id from `ctx.segments[0]`. `combineAdminRoutes`' dispatcher routes any\n * unmatched sub-path under `(prefix, method)` through the single catch-all when\n * no exact `path` claims `segments[0]` (see `makeDispatcher`). GET registers two\n * EXACT entries — `''` (list) and `'history'` — which take precedence over any\n * catch-all and never collide. POST registers `''` (create) only.\n *\n * **Authorization model:** every route gates on the SINGLE `redirect:manage`\n * permission (phase 04 §4 — one permission for all CRUD, not a per-verb split).\n * `defaultRoles: ['admin']`. The wrapper (`guardedHandler`) emits the\n * `permission.denied` audit on every 403 uniformly.\n *\n * **DB access** is resolved at REQUEST time (never at factory/module scope —\n * `getApp()` throws before boot; the factory runs at config-load under jiti/tsx).\n * Create / update / delete each run inside ONE `db.transaction` so the\n * `toolkit_redirects` row write and its `entity_refs` bookkeeping (`./refs.ts`)\n * commit — or roll back — atomically.\n */\n\nimport {\n type AdminRoute,\n type AdminRouteHandler,\n type AuthUser,\n combineAdminRoutes,\n defineAdminRoute,\n requireRequestSite,\n safeAudit,\n} from '@murumets-ee/core'\nimport type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'\nimport { z } from 'zod'\nimport { getContentConfig } from '../content-config-store.js'\nimport { isUniqueViolation } from '../paths/apply-path.js'\nimport { toolkitPathsTable } from '../paths/schema.js'\nimport {\n RedirectReservedPrefixError,\n RedirectSelfReferenceError,\n RedirectServesContentError,\n RedirectTargetNotFoundError,\n RedirectUnknownEntityError,\n} from './errors.js'\nimport { clearRedirectRefs, writeRedirectRef } from './refs.js'\nimport { toolkitRedirectsTable } from './schema.js'\n\n/**\n * The site these routes act on (multisite phase 03): redirects and the path\n * history they read are kept per site, so every route reads and writes the\n * request's site only — never a default on an app serving several sites.\n */\nfunction requestSite(): Promise<string> {\n return requireRequestSite('toolkit_redirects')\n}\n\nimport { type RedirectTarget, redirectTargetSchema } from './target.js'\nimport {\n assertEntityTargetValid,\n assertFromPathNotReserved,\n assertFromPathNotServingContent,\n collapsePathTarget,\n normalizeRedirectPath,\n type RedirectEntityClientResolver,\n} from './validation.js'\n\nconst UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i\n/** `toolkit_paths.entity_type` is `varchar(100)`; same shape as queue's type filter. */\nconst ENTITY_TYPE_RE = /^[a-zA-Z0-9:_.-]{1,100}$/\n\n/** ONE permission gates every redirect CRUD verb (phase 04 §4). */\nconst REDIRECT_PERMISSION = 'redirect:manage'\nconst REDIRECT_DEFAULT_ROLES = ['admin'] as const\n\n// ---------------------------------------------------------------------------\n// HTTP helpers (same shape as queue/src/admin.ts)\n// ---------------------------------------------------------------------------\n\nfunction json(data: unknown, status = 200): Response {\n return new Response(JSON.stringify(data), {\n status,\n headers: { 'Content-Type': 'application/json' },\n })\n}\n\nfunction errorJson(message: string, status: number): Response {\n return json({ error: message }, status)\n}\n\nasync function parseJsonBody<S extends z.ZodTypeAny>(\n req: Request,\n schema: S,\n): Promise<{ data: z.infer<S> } | { response: Response }> {\n let raw: unknown\n try {\n raw = await req.json()\n } catch {\n return { response: errorJson('Invalid JSON body', 400) }\n }\n const result = schema.safeParse(raw)\n if (!result.success) {\n const issue = result.error.issues[0]\n const message = issue ? `${issue.path.join('.') || 'body'}: ${issue.message}` : 'Invalid body'\n return { response: errorJson(message, 400) }\n }\n return { data: result.data }\n}\n\n/**\n * Strict uint query-param parse — same regex + `Number.isSafeInteger` guard as\n * queue's `parseUintParam` (plain `Number()` silently coerces blanks / hex /\n * exponent / signed input; a long digit string overflows to `Infinity`).\n */\nfunction parseUintParam(raw: string | null, fallback: number): number {\n if (raw === null || !/^\\d+$/.test(raw)) return fallback\n const parsed = Number(raw)\n if (!Number.isSafeInteger(parsed)) return fallback\n return parsed\n}\n\n/** Pagination offset cap — a deep `?offset=` forces a scan-and-discard pass. */\nconst MAX_OFFSET = 100_000\n/** History is inherently bounded (PR04 caps rename history per doc/locale), but keep a hard ceiling. */\nconst HISTORY_LIMIT = 100\n\nfunction auditActor(user: AuthUser): { userId: string; userName?: string } {\n return user.name === undefined ? { userId: user.id } : { userId: user.id, userName: user.name }\n}\n\n// ---------------------------------------------------------------------------\n// App / DB access — deferred to request time (getApp throws before boot; the\n// factory runs at config-load). Mirrors queue/src/admin.ts's getDb().\n// ---------------------------------------------------------------------------\n\nasync function getAppInstance() {\n const { getApp } = await import('@murumets-ee/core')\n return getApp()\n}\n\nasync function getDb(): Promise<PostgresJsDatabase> {\n return (await getAppInstance()).db.readWrite\n}\n\n/**\n * Build the `entity`-target existence resolver from the live app registry:\n * `app.entities.get(name)` → `app.getClient(entity)`. Passed into\n * `assertEntityTargetValid`, keeping `./validation.ts` a leaf (no core import).\n */\nasync function buildEntityResolver(): Promise<RedirectEntityClientResolver> {\n const app = await getAppInstance()\n return (name) => {\n const entity = app.entities.get(name)\n return entity ? app.getClient(entity) : undefined\n }\n}\n\n/**\n * Drizzle's transaction handle is structurally a `PgDatabase` but its generic\n * parameters don't line up with the `PostgresJsDatabase` alias `makeClient` /\n * `writeRedirectRef` expect, so the tx handle is cast at this single boundary —\n * the exact pattern `@murumets-ee/db`'s own `TableClient.transaction` uses\n * (`innerDb as unknown as PostgresJsDatabase`). One cast per transaction.\n */\ntype DrizzleTx = Parameters<Parameters<PostgresJsDatabase['transaction']>[0]>[0]\nfunction txAsDb(tx: DrizzleTx): PostgresJsDatabase {\n return tx as unknown as PostgresJsDatabase\n}\n\n// ---------------------------------------------------------------------------\n// Request-body schemas — validated at the boundary (CLAUDE.md). `createdBy` is\n// NEVER accepted from the body; it's set server-side from ctx.user.id.\n// ---------------------------------------------------------------------------\n\n/** `301 | 302` only. */\nconst statusSchema = z.union([z.literal(301), z.literal(302)])\n\n/** Locale-relative internal path — leading '/', bounded, same convention as `target.ts`'s `path` arm. */\nconst fromPathSchema = z.string().min(1).max(2048).startsWith('/')\n\nconst createBodySchema = z.object({\n /** BCP47 locale, or `null` = catch-all (applies to all locales). Membership vs. configured locales is checked in the handler. */\n locale: z.string().min(1).nullable(),\n fromPath: fromPathSchema,\n target: redirectTargetSchema,\n status: statusSchema.default(301),\n})\n\nconst updateBodySchema = z.object({\n locale: z.string().min(1).nullable().optional(),\n fromPath: fromPathSchema.optional(),\n target: redirectTargetSchema.optional(),\n status: statusSchema.optional(),\n})\n\n/**\n * Whitelist a redirect's locale against the CONFIGURED content locales\n * (deny-by-default; CLAUDE.md). `null` (catch-all) is always allowed. Returns a\n * 400 Response on rejection, or `null` when the locale is acceptable.\n */\nfunction rejectUnconfiguredLocale(locale: string | null): Response | null {\n if (locale === null) return null\n const configured = getContentConfig().locales.map((l) => l.code)\n if (!configured.includes(locale)) {\n return errorJson(`Unknown locale \"${locale}\" — not one of the configured content locales.`, 400)\n }\n return null\n}\n\n// ---------------------------------------------------------------------------\n// Error → HTTP status mapping. Conflict-with-existing-state errors → 409\n// (matches the codebase convention: ReferencedEntityError / CrossLocaleWriteError\n// / RestoreConflictError all → 409). Bad-target-in-payload → 400 (matches\n// EntityCapabilityError / InvalidLocaleError → 400). A 23505 escaping the tx (the\n// DB-level partial-unique backstop firing on a race) → 409, never a raw 500.\n// Anything else is rethrown so the api-handler's central catch logs + 500s.\n// ---------------------------------------------------------------------------\n\nfunction mapRedirectError(err: unknown): Response {\n if (err instanceof RedirectReservedPrefixError) {\n return json({ error: err.message, fromPath: err.fromPath, prefix: err.prefix }, 409)\n }\n if (err instanceof RedirectServesContentError) {\n return json(\n {\n error: err.message,\n fromPath: err.fromPath,\n locale: err.locale,\n conflictingLocale: err.conflictingLocale,\n },\n 409,\n )\n }\n if (err instanceof RedirectSelfReferenceError) {\n return json({ error: err.message, fromPath: err.fromPath, locale: err.locale }, 409)\n }\n if (err instanceof RedirectUnknownEntityError) {\n return json({ error: err.message, entity: err.entity }, 400)\n }\n if (err instanceof RedirectTargetNotFoundError) {\n return json({ error: err.message, entity: err.entity, id: err.id }, 400)\n }\n if (isUniqueViolation(err)) {\n return json({ error: 'A redirect already exists for this locale + source path.' }, 409)\n }\n // Unknown — let the api-handler's central catch log it and return 500.\n throw err\n}\n\n// ---------------------------------------------------------------------------\n// Core write path — shared by create + update. Runs the full save-time\n// validation chain (normalize → reserved-prefix → entity-target → serves-content\n// → collapse → self-redirect guard) then writes the row + entity_refs inside ONE\n// transaction. Returns the written row, or `null` when an UPDATE matched no row.\n// ---------------------------------------------------------------------------\n\n/** The stored redirect row (typed via the DefinedTable's client). */\ntype RedirectRow = Awaited<\n ReturnType<ReturnType<typeof toolkitRedirectsTable.makeClient>['insert']>\n>\n\nasync function runRedirectWrite(args: {\n /** `null` = create; a UUID = update that id. */\n id: string | null\n locale: string | null\n fromPath: string\n target: RedirectTarget\n status: number\n userId: string\n resolveClient: RedirectEntityClientResolver\n}): Promise<RedirectRow | null> {\n const cfg = getContentConfig()\n const reservedPathPrefixes = cfg.reservedPathPrefixes ?? []\n const configuredLocales = cfg.locales.map((l) => l.code)\n const fromPath = normalizeRedirectPath(args.fromPath)\n\n // 1. Reserved-prefix rejection (pure string check; security).\n assertFromPathNotReserved({ fromPath, reservedPathPrefixes })\n\n // 2. Entity-target existence — a non-tx AdminClient read (soft pre-check; the\n // row it validates isn't part of this write's transaction).\n await assertEntityTargetValid({ target: args.target, resolveClient: args.resolveClient })\n\n const db = await getDb()\n // Resolved before the transaction: the single-site lookup reads the pool.\n const site = await requestSite()\n return db.transaction(async (txHandle) => {\n const tx = txAsDb(txHandle)\n const redirectsClient = toolkitRedirectsTable.makeClient(tx, site)\n const pathsClient = toolkitPathsTable.makeClient(tx, site)\n\n // 3. \"That URL serves content\" rejection — reads toolkit_paths in-tx.\n await assertFromPathNotServingContent({\n fromPath,\n locale: args.locale,\n configuredLocales,\n pathsClient,\n })\n\n // 4. Collapse a path-kind target that points at another redirect (one hop).\n // excludeId (update only) keeps the row's own pre-write state from\n // masking a self-referential collapse — see collapsePathTarget's doc.\n const collapsed = await collapsePathTarget({\n target: args.target,\n locale: args.locale,\n redirectsClient,\n ...(args.id === null ? {} : { excludeId: args.id }),\n })\n\n // 5. Self-redirect guard — a collapsed path target that points back at this\n // very from_path (same locale) is a no-op loop; reject rather than store.\n if (collapsed.kind === 'path' && normalizeRedirectPath(collapsed.path) === fromPath) {\n throw new RedirectSelfReferenceError(fromPath, args.locale)\n }\n\n // 6. Write the row + entity_refs bookkeeping, atomically.\n if (args.id === null) {\n const created = await redirectsClient.insert({\n locale: args.locale,\n fromPath,\n target: collapsed,\n status: args.status,\n createdBy: args.userId,\n })\n await writeRedirectRef(tx, created.id, collapsed)\n return created\n }\n\n const updated = await redirectsClient.update(\n { id: args.id },\n {\n locale: args.locale,\n fromPath,\n target: collapsed,\n status: args.status,\n updatedAt: new Date(),\n },\n )\n if (!updated) return null // row vanished between the pre-tx fetch and here.\n // Delete-then-insert refs (not diff) — matches ./refs.ts's documented usage.\n await clearRedirectRefs(tx, args.id)\n await writeRedirectRef(tx, args.id, collapsed)\n return updated\n })\n}\n\n// ---------------------------------------------------------------------------\n// Handlers\n// ---------------------------------------------------------------------------\n\nconst handleList: AdminRouteHandler = async (req) => {\n const url = new URL(req.url)\n const localeParam = url.searchParams.get('locale')\n const limit = Math.min(Math.max(parseUintParam(url.searchParams.get('limit'), 50), 1), 200)\n const offset = Math.min(parseUintParam(url.searchParams.get('offset'), 0), MAX_OFFSET)\n\n let localeFilter: string | undefined\n if (localeParam !== null && localeParam !== '') {\n const rejected = rejectUnconfiguredLocale(localeParam)\n if (rejected) return rejected\n localeFilter = localeParam\n }\n\n const client = toolkitRedirectsTable.makeClient(await getDb(), await requestSite())\n const items =\n localeFilter !== undefined\n ? await client.findMany({\n where: { locale: localeFilter },\n orderBy: [{ column: 'createdAt', dir: 'desc' }],\n limit,\n offset,\n })\n : await client.findMany({\n orderBy: [{ column: 'createdAt', dir: 'desc' }],\n limit,\n offset,\n })\n const total =\n localeFilter !== undefined ? await client.count({ locale: localeFilter }) : await client.count()\n\n return json({ items, total })\n}\n\nconst handleCreate: AdminRouteHandler = async (req, ctx) => {\n const parsed = await parseJsonBody(req, createBodySchema)\n if ('response' in parsed) return parsed.response\n const body = parsed.data\n\n const localeRejected = rejectUnconfiguredLocale(body.locale)\n if (localeRejected) return localeRejected\n\n try {\n const resolveClient = await buildEntityResolver()\n const row = await runRedirectWrite({\n id: null,\n locale: body.locale,\n fromPath: body.fromPath,\n target: body.target,\n status: body.status,\n userId: ctx.user.id,\n resolveClient,\n })\n // Create always returns a row (insert never matches \"nothing\").\n if (!row) return errorJson('Failed to create redirect', 500)\n safeAudit(ctx, {\n action: 'redirect.created',\n entityType: 'redirect',\n entityId: row.id,\n ...auditActor(ctx.user),\n metadata: { locale: row.locale, fromPath: row.fromPath, targetKind: row.target.kind },\n })\n return json({ item: row }, 201)\n } catch (err) {\n return mapRedirectError(err)\n }\n}\n\nconst handleUpdate: AdminRouteHandler = async (req, ctx) => {\n const id = ctx.segments[0]\n if (!id || !UUID_RE.test(id)) return errorJson('Invalid id', 400)\n\n const parsed = await parseJsonBody(req, updateBodySchema)\n if ('response' in parsed) return parsed.response\n const patch = parsed.data\n\n // Fetch the existing row so a partial PATCH merges onto its current state.\n const existing = await toolkitRedirectsTable\n .makeClient(await getDb(), await requestSite())\n .findOne({ id })\n if (!existing) return errorJson('Not found', 404)\n\n // Merge — only provided keys change. `locale` may be explicitly `null`\n // (switch to catch-all), so key-presence (!== undefined), not truthiness,\n // decides whether to keep the existing value.\n const locale = patch.locale !== undefined ? patch.locale : existing.locale\n const fromPath = patch.fromPath !== undefined ? patch.fromPath : existing.fromPath\n const target = patch.target !== undefined ? patch.target : existing.target\n const status = patch.status !== undefined ? patch.status : existing.status\n\n // Re-validate the RESULTING merged state (e.g. changing only `locale` can\n // newly conflict with content served in the new locale).\n const localeRejected = rejectUnconfiguredLocale(locale)\n if (localeRejected) return localeRejected\n\n try {\n const resolveClient = await buildEntityResolver()\n const row = await runRedirectWrite({\n id,\n locale,\n fromPath,\n target,\n status,\n userId: ctx.user.id,\n resolveClient,\n })\n if (!row) return errorJson('Not found', 404)\n safeAudit(ctx, {\n action: 'redirect.updated',\n entityType: 'redirect',\n entityId: row.id,\n ...auditActor(ctx.user),\n metadata: { locale: row.locale, fromPath: row.fromPath, targetKind: row.target.kind },\n })\n return json({ item: row })\n } catch (err) {\n return mapRedirectError(err)\n }\n}\n\nconst handleDelete: AdminRouteHandler = async (_req, ctx) => {\n const id = ctx.segments[0]\n if (!id || !UUID_RE.test(id)) return errorJson('Invalid id', 400)\n\n const db = await getDb()\n const site = await requestSite()\n const deleted = await db.transaction(async (txHandle) => {\n const tx = txAsDb(txHandle)\n // Delete (confined to the request's site) THEN clear its refs, same tx — no\n // dangling entity_refs row survives, and a redirect id of ANOTHER site\n // deletes nothing and clears nothing (multisite phase 03).\n const row = await toolkitRedirectsTable.makeClient(tx, site).delete({ id })\n if (row) await clearRedirectRefs(tx, id)\n return row\n })\n if (!deleted) return errorJson('Not found', 404)\n\n safeAudit(ctx, {\n action: 'redirect.deleted',\n entityType: 'redirect',\n entityId: id,\n ...auditActor(ctx.user),\n metadata: { locale: deleted.locale, fromPath: deleted.fromPath },\n })\n return json({ ok: true })\n}\n\n/**\n * Read-only automatic-redirect history for ONE document — the demoted\n * (`is_canonical = false`) `toolkit_paths` rows a rename leaves behind.\n * `entityType` + `entityId` are REQUIRED; `locale` optional. No write path.\n */\nconst handleHistory: AdminRouteHandler = async (req) => {\n const url = new URL(req.url)\n const entityType = url.searchParams.get('entityType')\n const entityId = url.searchParams.get('entityId')\n if (!entityType || !ENTITY_TYPE_RE.test(entityType)) {\n return errorJson('entityType is required', 400)\n }\n if (!entityId || !UUID_RE.test(entityId)) {\n return errorJson('entityId is required and must be a UUID', 400)\n }\n\n const localeParam = url.searchParams.get('locale')\n let localeFilter: string | undefined\n if (localeParam !== null && localeParam !== '') {\n const rejected = rejectUnconfiguredLocale(localeParam)\n if (rejected) return rejected\n localeFilter = localeParam\n }\n\n const client = toolkitPathsTable.makeClient(await getDb(), await requestSite())\n const items = await client.findMany({\n where: {\n entityType,\n entityId,\n isCanonical: false,\n ...(localeFilter !== undefined && { locale: localeFilter }),\n },\n orderBy: [{ column: 'updatedAt', dir: 'desc' }],\n limit: HISTORY_LIMIT,\n })\n return json({ items })\n}\n\n// ---------------------------------------------------------------------------\n// Route factory\n// ---------------------------------------------------------------------------\n\n/**\n * Build the manual-redirect admin API routes.\n *\n * Returns `AdminRoute[]` from `combineAdminRoutes` — one entry for prefix\n * `'redirects'` with GET/POST/PATCH/DELETE handlers. Every entry gates on\n * `redirect:manage` (defaultRoles `['admin']`); the permission is\n * auto-registered into the catalog by `defineAdminRoute`.\n */\nexport function redirectRoutes(): AdminRoute[] {\n return combineAdminRoutes([\n defineAdminRoute({\n prefix: 'redirects',\n path: '',\n method: 'GET',\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'List manual URL redirects (paginated, optional locale filter).',\n tokens: 'accept',\n handler: handleList,\n }),\n defineAdminRoute({\n prefix: 'redirects',\n path: 'history',\n method: 'GET',\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'Read-only automatic-redirect (rename) history for one document.',\n tokens: 'accept',\n handler: handleHistory,\n }),\n defineAdminRoute({\n prefix: 'redirects',\n path: '',\n method: 'POST',\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'Create a manual URL redirect.',\n tokens: 'accept',\n handler: handleCreate,\n }),\n defineAdminRoute({\n prefix: 'redirects',\n path: '',\n method: 'PATCH',\n matchAnyPath: true,\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'Update a manual URL redirect by id.',\n tokens: 'accept',\n handler: handleUpdate,\n }),\n defineAdminRoute({\n prefix: 'redirects',\n path: '',\n method: 'DELETE',\n matchAnyPath: true,\n permission: REDIRECT_PERMISSION,\n defaultRoles: REDIRECT_DEFAULT_ROLES,\n description: 'Delete a manual URL redirect by id.',\n tokens: 'accept',\n confirm: true,\n handler: handleDelete,\n }),\n ])\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { l as RoutingStrategy } from "./types-
|
|
1
|
+
import { l as RoutingStrategy } from "./types-wMTglM4j.mjs";
|
|
2
2
|
import { Behavior } from "@murumets-ee/entity";
|
|
3
3
|
|
|
4
4
|
//#region src/i18n-settings.d.ts
|
|
@@ -152,4 +152,4 @@ declare function localePrefixForStrategy(strategy: RoutingStrategy): 'always' |
|
|
|
152
152
|
declare function prefixDefaultLocaleForStrategy(strategy: RoutingStrategy): boolean;
|
|
153
153
|
//#endregion
|
|
154
154
|
export { ROUTING_STRATEGIES as a, Locale as i, prefixDefaultLocaleForStrategy as n, i18nSettings as o, Message as r, localePrefixForStrategy as t };
|
|
155
|
-
//# sourceMappingURL=routing-strategy-
|
|
155
|
+
//# sourceMappingURL=routing-strategy-DVWB7qPp.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"routing-strategy-
|
|
1
|
+
{"version":3,"file":"routing-strategy-DVWB7qPp.d.mts","names":[],"sources":["../src/i18n-settings.ts","../src/locale-entity.ts","../src/message-entity.ts","../src/routing-strategy.ts"],"mappings":";;;;;;;;AAiBA;;;;AAAwF;AAExF;;;;;cAFa,kBAAA;AAAA,cAEA,YAAA,yCAAY,kBAAA;;;;;;;;;;;ACQzB;;;;;;;;;;;;;ADVA;;;;AAAwF;AAExF;;;;;;;;;;;;;;;cCQa,MAAA,gCAAM,MAAA;;;EA0CjB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;cCTW,OAAA,gCAAO,MAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBClCJ,uBAAA,CACd,QAAyB,EAAf,eAAe;;AFA3B;;;;iBEiBgB,8BAAA,CAA+B,QAAyB,EAAf,eAAe"}
|
package/dist/seed.d.mts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"seed.d.mts","names":[],"sources":["../src/seed.ts"],"mappings":";;
|
|
1
|
+
{"version":3,"file":"seed.d.mts","names":[],"sources":["../src/seed.ts"],"mappings":";;AA+KA;;;;AAAyD;AAyFzD;;;;AAA8D;AAuE9D;;;;;;;;AAEiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAlKK,cAAA,CAAA,GAAkB,OAAO;EAAG,OAAO;AAAA;;;;;;;;;;;;;;iBAyFnC,mBAAA,CAAA,GAAuB,OAAO;EAAG,OAAO;AAAA;;;;;;;;;;;;;;;;;;;iBAuExC,iBAAA,CACpB,WAAA,sBACC,OAAO,CAAC,MAAA"}
|
package/dist/seed.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{t as e}from"./chrome-entity-names-zYJzP-_U.mjs";import{n as t,t as n}from"./detail-template-names-CD1Y9n1U.mjs";import{n as r,s as i}from"./schema-BYrYzsmO.mjs";import{n as a,t as o}from"./site-chrome-
|
|
1
|
+
import{t as e}from"./chrome-entity-names-zYJzP-_U.mjs";import{n as t,t as n}from"./detail-template-names-CD1Y9n1U.mjs";import{n as r,s as i}from"./schema-BYrYzsmO.mjs";import{n as a,t as o}from"./site-chrome-BAtQaAqC.mjs";import{getApp as s,requireRequestSite as c,runInEverySite as l}from"@murumets-ee/core";import{eq as u}from"drizzle-orm";import{createAdminClient as d}from"@murumets-ee/core/clients";import{randomUUID as f}from"node:crypto";import{defaultChromeBlocks as p}from"@murumets-ee/blocks/library";function m(e){return p(e,()=>f())}async function h(){let t=s(),n=t.entities.get(e);return n?{created:(await l(()=>g(n),{app:t,label:`content:site-chrome`})).reduce((e,t)=>e+t.created,0)}:{created:0}}async function g(t){let n=d(t),r=0,s=n.getTable().placement;if(!s)throw Error(`${e} has no "placement" column — the chrome entity registered by this app is not the one @murumets-ee/content ships.`);for(let e of o)if(!((await n.findMany({where:u(s,e),limit:1})).length>0))try{await n.create({placement:e,label:a[e],layout:m(e)}),r++}catch(e){if(!i(e))throw e}return{created:r}}async function _(){let e=s(),t=e.entities.get(n);return t?{created:(await l(()=>y(t),{app:e,label:`content:detail-templates`})).reduce((e,t)=>e+t.created,0)}:{created:0}}function v(e){let t=e.replace(/[_-]+/g,` `).trim();return`${t.charAt(0).toUpperCase()}${t.slice(1)} detail`}async function y(e){let r=e.allFields[t],a=r?.type===`select`?r.options:[];if(a.length===0)return{created:0};let o=d(e),s=o.getTable()[t];if(!s)throw Error(`${n} has no "${t}" column — the entity registered by this app is not the one @murumets-ee/content ships.`);let c=0;for(let e of a)if(!((await o.findMany({where:u(s,e),limit:1})).length>0))try{await o.create({[t]:e,label:v(e),layout:[{id:f(),type:`documentTitle`,props:{},traits:{}}]}),c++}catch(e){if(!i(e))throw e}return{created:c}}async function b(e){let t=r.makeClient(s().db.readWrite,await c(`toolkit_paths`)),n={};for(let r of e)n[r]=(await t.deleteMany({entityType:r})).length;return n}export{b as forgetEntityPaths,_ as seedDetailTemplates,h as seedSiteChrome};
|
|
2
2
|
//# sourceMappingURL=seed.mjs.map
|
package/dist/seed.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"seed.mjs","names":[],"sources":["../src/seed.ts"],"sourcesContent":["/**\n * The site-chrome seeder — creates the one `site_chrome` row per placement\n * that a site needs, POPULATED, before any of them can be edited.\n *\n * Contributed as `content:site-chrome` from the `content()` plugin factory and\n * reached through the `@murumets-ee/content/seed` subpath — its own build\n * entry so the lazy import keeps `@murumets-ee/core/clients`, which carries\n * `import 'server-only'`, off the config-load path.\n *\n * ## Why a seeder at all, and why nobody has to run it\n *\n * An unseeded chrome document is indistinguishable from a site that HAS no\n * header, and both the canvas and `/v1/_chrome` treat it that way — they\n * compose nothing and let the consumer render its own fallback. That is right,\n * and it means nothing ever CREATES the row, so without this an editor has no\n * header to open.\n *\n * Until PR30 that left the gap S5 names: nothing in the printed setup path ran\n * this, so a scaffolded project booted with an empty Site list and the only\n * bootstrap was a human pressing \"New\". The plugin now declares this seeder\n * `bootstrap: true`, and `lumi migrate` runs every bootstrap seeder after it\n * applies migrations — which is already step 4 of the printed next-steps. It\n * stays an ordinary named seeder too, runnable from `lumi seed` and the admin\n * Seed page; `bootstrap` adds a trigger rather than changing what it is.\n *\n * ## Why the entity is resolved by NAME\n *\n * This package ships the chrome entity as a FACTORY (`siteChromeEntity()`),\n * because an app must pass options it cannot get from here — `withOpLog()`\n * ships from `@murumets-ee/blocks/server`, and content must not depend on\n * blocks (blocks depends back on content). So there is no entity object in\n * this package to import; there is only the name the factory stamps on\n * whatever the app instantiated.\n *\n * That name is not an assumption: `site-chrome.ts` hardcodes\n * `name: 'site_chrome'`, so an app can configure the entity but cannot rename\n * it. This package's own chrome PROVIDER resolves it the same way\n * (`app.entities.get(name)`, `null` when absent), and `@murumets-ee/auth`'s\n * default permission maps and `@murumets-ee/frontend-cache`'s\n * `DEFAULT_CHROME_ENTITIES` name the same string.\n *\n * ## Why it is `available`-gated rather than merely tolerant\n *\n * An app is free to register no chrome at all. Seeding plan F009 deferred this\n * seeder on exactly that: contributed unconditionally it would put a button on\n * the Seed page whose entire outcome is `created: 0`, which a reader takes as\n * \"this seeder is broken\", not as \"this project has no chrome\". The plugin\n * declares `available` so the button is ABSENT instead — the same contract\n * `rent:cars-demo` has, where absence follows from the plugin's absence rather\n * than from a silent no-op.\n *\n * ## Idempotent by construction, not by a flag\n *\n * Every run finds before it creates, keyed on `placement`. That is what makes\n * a second run a no-op — necessary now in a way it was not before, because\n * `lumi migrate` runs this on EVERY migrate, not once. It is also why the\n * find is a `where` on `placement` rather than a bare `limit: 1`: the old\n * `kind: 'singleton'` made \"is there a row\" the whole question, and under one\n * entity holding two rows it is the wrong question — a bare existence check\n * would see the header and never create the footer.\n *\n * ## DRAFT, deliberately — and this reverses an earlier choice\n *\n * The rows are created unpublished, so `lumi migrate` changes nothing a\n * visitor sees. That is not a detail; it is what makes `bootstrap: true` safe,\n * because this now runs on EVERY migrate, in production, against a database\n * full of a customer's content (see `PluginSeeder.bootstrap`). A provisioning\n * step that silently alters the public site is not additive.\n *\n * This file used to create them PUBLISHED, reasoning that \"a draft-seeded site\n * is indistinguishable from an unseeded one to every public reader\" and that\n * \"the rows are EMPTY, so publishing them renders nothing\". The first half is\n * true and is the POINT: an empty chrome IS unseeded, and a public reader\n * should see exactly what they saw before. The second half was measured and is\n * false.\n *\n * Measured, on a running front-astro against a real CMS: an empty PUBLISHED\n * region arrives on the wire as `{ blocks: [] }`, which `placeRegion` reads as\n * `hidden` — \"the page hid this on purpose\" — and `rendersFallback` returns\n * false for `hidden`. So publishing an empty row does not render nothing; it\n * renders nothing AND SUPPRESSES THE TEMPLATE'S OWN FALLBACK. A freshly\n * scaffolded site lost its `<header>` and `<footer>` landmarks entirely, where\n * before it rendered the fallback header with its Home link and nav.\n *\n * `null` (no published row) reads as `fallback` instead, which is the answer\n * that leaves the delivery template in charge until somebody has actually\n * authored a header. The moment they do and press Publish, the CMS takes over\n * — ordinary publish semantics, the same as every other document.\n *\n * ## EMPTY is what starved the canvas — and only the SITE was ever considered\n *\n * The section above is right and stays. Its blind spot is that it reasons\n * entirely about what a VISITOR sees. The rows it creates were also `layout:\n * []`, and the comment on that line said an empty tree \"is a header with no\n * blocks yet, which is what the editor opens onto\" — which is true, and is the\n * defect.\n *\n * The live site has a fallback: no published chrome row reads as `fallback`\n * and the delivery template renders its own header. **The editing canvas has\n * no fallback.** Its only channel for chrome is these rows — it is React in the\n * admin app and cannot render an Astro template's markup — so an empty row\n * composes to zero blocks and `CanvasDocument` emits a bare `<div>` with\n * nothing in it. Every page, forever, on every project ever scaffolded. The\n * site looked right the whole time, which is why no gate and no person caught\n * it for months (`editor` D014).\n *\n * So the two properties are independent and both are required: the rows are\n * DRAFT (nothing a visitor sees changes) and NON-EMPTY (the canvas has a header\n * and a footer to paint). Draft is what makes non-empty safe — the tree is\n * real, and no visitor sees it until somebody publishes.\n *\n * ## What this canNOT fix, stated because it is the interesting half\n *\n * This seeder is ADDITIVE: `if (existing.length > 0) continue`. An install\n * whose rows already exist — every project bootstrapped before this change —\n * keeps its empty ones, and must, because overwriting an authored header is the\n * one thing a bootstrap seeder may never do. The same hole reopens for anyone\n * who deletes their header's last block.\n *\n * That half belongs to the canvas and is fixed there: an empty non-page\n * document now renders a NAMED region band rather than nothing\n * (`block-editor-canvas.tsx`'s `EndAddZone`). This seeder decides what a NEW\n * site starts with; the canvas decides that an empty region is never invisible.\n *\n * ## What it assumes about its caller\n *\n * An admin context — `runSeederAction`, `lumi seed` and `lumi migrate`'s\n * bootstrap pass all establish one. Auth, the admin-only role check and the\n * production gate live there.\n *\n * The entity NAME and the `available` predicate live one module over\n * (`./chrome-entity-names.ts`), which imports nothing — `plugin.ts` needs the\n * predicate statically and must not pull this file's `server-only` client\n * along with it.\n */\n\nimport { randomUUID } from 'node:crypto'\nimport { getApp, requireRequestSite, runInEverySite } from '@murumets-ee/core'\nimport { createAdminClient } from '@murumets-ee/core/clients'\nimport type { Entity } from '@murumets-ee/entity'\nimport { eq } from 'drizzle-orm'\nimport { CHROME_ENTITY_NAME } from './chrome-entity-names.js'\nimport {\n DETAIL_TEMPLATE_ENTITY_NAME,\n DETAIL_TEMPLATE_TARGET_FIELD,\n} from './detail-template-names.js'\nimport {\n CHROME_PLACEMENT_LABELS,\n CHROME_PLACEMENTS,\n type ChromePlacement,\n} from './entities/site-chrome.js'\nimport { isUniqueViolation } from './paths/apply-path.js'\nimport { toolkitPathsTable } from './paths/schema.js'\n\n/**\n * The `menu` block's registered type name, and the handle each placement's\n * default nav points at.\n *\n * ## Why a block instance can be written from HERE at all\n *\n * A block instance is plain JSON — `{ id, type, props, traits }` — so seeding\n * one needs no import. That matters: `@murumets-ee/content` must not depend on\n * `@murumets-ee/blocks` (blocks depends back on content, see this package's\n * chrome-entity docblock), so the type NAME is the whole contract and there is\n * no definition object to reach for.\n *\n * ## Why `menu`, and not something with words in it\n *\n * A header holds navigation. `menu` is the only block in the canonical\n * vocabulary that renders navigation, it is in `canonicalBlocks` — so it is in\n * the `page` environment's BASE registry and cannot be removed by an extension\n * (`environment-registry.ts` merges base ⊕ extensions and later contributors\n * only ever shadow by name) — and `themes/structural`'s `MenuRender` paints it\n * in the default theme. So the one block this seeder writes is the one block\n * every install is guaranteed to be able to paint.\n *\n * The alternative was a `text` block carrying a site name or a copyright line.\n * That is FABRICATED CONTENT: a string in a customer's header that they did\n * not write and must now find and delete. A `menu` block carries no words of\n * ours — it renders whatever menu the operator publishes at the handle, and\n * nothing at all before then.\n *\n * ## What it looks like before a menu exists\n *\n * A fresh scaffold has no `Menu` rows: `menus:main` is not a `bootstrap`\n * seeder, so `lumi migrate` does not run it. Until one exists, `getMenu`\n * answers `null` and the block renders its own documented inert state — an\n * empty `<nav>` on the live site, and inside the editor a labelled\n * `Menu: main` placeholder that is selectable and opens `@murumets-ee/menus-ui`'s\n * tree editor (the block declares `customEditorSurface`).\n *\n * That is the honest render, and it is the useful one: the canvas shows a\n * header, in the header's place, containing the thing a header contains, and\n * clicking it is the route to the tool that fills it.\n *\n * `'main'` and `'footer'` are the handles `MenuBlock`'s own docblock names, and\n * `'main'` is what `apps/front-astro`'s fallback header already fetches\n * (`PRIMARY_MENU_HANDLE`) and what `menus:main` creates.\n */\nconst CHROME_DEFAULT_MENU: Readonly<Record<ChromePlacement, { handle: string }>> = {\n header: { handle: 'main' },\n footer: { handle: 'footer' },\n}\n\n/**\n * The block tree a bootstrapped chrome row starts life with.\n *\n * Returns a fresh array with a fresh block id per call: block ids are the keys\n * a page's `_overrides` map is written under (`compose-chrome.ts`'s\n * `tagScopedRegion` uses `block.id` as `scopeBlockId`), so two rows sharing one\n * id would make a per-page override of the header also target the footer.\n *\n * `traits: {}` rather than omitted — `BlockInstance.traits` is required, and\n * the editor's own palette inserts carry it.\n *\n * Typed as the block-tree node shape rather than `Record<string, unknown>`. The\n * looser type only compiled because the widened column-value union used to\n * include `field.richtext()`'s old `Record<string, unknown>[]` — the very type\n * that let an array be written into a text column (F105).\n */\nfunction defaultChromeLayout(placement: ChromePlacement): Array<{\n id: string\n type: string\n props: Record<string, unknown>\n traits: Record<string, unknown>\n}> {\n return [\n {\n id: randomUUID(),\n type: 'menu',\n props: { handle: CHROME_DEFAULT_MENU[placement].handle, orientation: 'horizontal' },\n traits: {},\n },\n ]\n}\n\nexport async function seedSiteChrome(): Promise<{ created: number }> {\n const app = getApp()\n const entity = app.entities.get(CHROME_ENTITY_NAME)\n // Absent rather than thrown: `available` guarantees the app registered the\n // entity at the moment the list was built, and a config reloaded between\n // list and run should produce \"nothing to do\", not a stack trace.\n if (!entity) return { created: 0 }\n\n // Chrome is per site (multisite D002): every site gets its own header and\n // footer. In a run that names a site (`lumi seed --site`), that site; on a\n // single-site install, the one site; otherwise once per active site — so a\n // site added since the last run gets its chrome on the next `lumi migrate`.\n const perSite = await runInEverySite(() => seedSiteChromeInSite(entity), {\n app,\n label: 'content:site-chrome',\n })\n return { created: perSite.reduce((sum, r) => sum + r.created, 0) }\n}\n\nasync function seedSiteChromeInSite(entity: Entity): Promise<{ created: number }> {\n const client = createAdminClient(entity)\n let created = 0\n\n // Column refs through `getTable()` — the sanctioned accessor — never a bare\n // property on a dynamically-generated table, which would put an `any` into\n // the WHERE clause.\n const placementColumn = client.getTable().placement\n if (!placementColumn) {\n // Unreachable while the entity declares the field, and cheaper to state\n // than to debug: without it a missing column would make `eq()` build a\n // WHERE against `undefined` and the find would silently match nothing —\n // so every run would try to create, and every run after the first would\n // be absorbed by the unique-violation catch below as though it had\n // succeeded.\n throw new Error(\n `${CHROME_ENTITY_NAME} has no \"placement\" column — the chrome entity registered by this ` +\n `app is not the one @murumets-ee/content ships.`,\n )\n }\n\n for (const placement of CHROME_PLACEMENTS) {\n // Keyed on `placement`, which is also the unique column — so this find is\n // asking exactly the question the database constraint answers, and a race\n // between two callers ends in a unique violation rather than a duplicate.\n const existing = await client.findMany({ where: eq(placementColumn, placement), limit: 1 })\n if (existing.length > 0) continue\n try {\n // A POPULATED tree, not `[]` — see \"Empty is what starves the canvas\"\n // in this file's header. `defaultChromeLayout` is the one place that\n // decides what a bootstrapped region holds.\n // No `status` — `publishable()` defaults to `draft`, which is the whole\n // of the \"changes nothing a visitor sees\" property. See the header.\n await client.create({\n placement,\n label: CHROME_PLACEMENT_LABELS[placement],\n layout: defaultChromeLayout(placement),\n })\n created++\n } catch (err) {\n // Losing the find-then-create race is a SUCCESS, not a failure: someone\n // else created the row this run was about to create, which is the\n // outcome either way. `placement`'s unique constraint is what makes that\n // safe to assume — the violation can only be the row we just looked for.\n //\n // Worth the four lines rather than trusting the find, because `lumi\n // migrate`'s bootstrap pass runs this on every migrate and two deploys\n // can overlap. Payload's equivalent conditions its write on an existence\n // check that is simply WRONG on Postgres and survives only because the\n // adapter happens to re-check underneath (R036).\n if (!isUniqueViolation(err)) throw err\n }\n }\n\n return { created }\n}\n\n/**\n * Create the DRAFT detail template for every entity the app's\n * `detail_template` entity targets (block-library D009), so each has a document\n * an editor can open. Idempotent: an existing row for a target is left alone.\n *\n * The targets are read from the `target` select's own options — the list the\n * app's `detailTemplateEntity({ for })` validated at boot — so the seeder and\n * the entity cannot disagree about which templates exist.\n *\n * A draft, like chrome's: until someone publishes it, every row of the target\n * keeps drawing the plain fallback page, so a migrate changes nothing a visitor\n * sees.\n */\nexport async function seedDetailTemplates(): Promise<{ created: number }> {\n const app = getApp()\n const entity = app.entities.get(DETAIL_TEMPLATE_ENTITY_NAME)\n if (!entity) return { created: 0 }\n const perSite = await runInEverySite(() => seedDetailTemplatesInSite(entity), {\n app,\n label: 'content:detail-templates',\n })\n return { created: perSite.reduce((sum, r) => sum + r.created, 0) }\n}\n\n/** `yhikas_room_type` → `Yhikas room type`. English; an editor renames it. */\nfunction defaultTemplateLabel(target: string): string {\n const words = target.replace(/[_-]+/g, ' ').trim()\n return `${words.charAt(0).toUpperCase()}${words.slice(1)} detail`\n}\n\nasync function seedDetailTemplatesInSite(entity: Entity): Promise<{ created: number }> {\n const targetField = entity.allFields[DETAIL_TEMPLATE_TARGET_FIELD]\n const targets: readonly string[] = targetField?.type === 'select' ? targetField.options : []\n if (targets.length === 0) return { created: 0 }\n\n const client = createAdminClient(entity)\n const targetColumn = client.getTable()[DETAIL_TEMPLATE_TARGET_FIELD]\n if (!targetColumn) {\n throw new Error(\n `${DETAIL_TEMPLATE_ENTITY_NAME} has no \"${DETAIL_TEMPLATE_TARGET_FIELD}\" column — the ` +\n `entity registered by this app is not the one @murumets-ee/content ships.`,\n )\n }\n\n let created = 0\n for (const target of targets) {\n const existing = await client.findMany({ where: eq(targetColumn, target), limit: 1 })\n if (existing.length > 0) continue\n try {\n await client.create({\n [DETAIL_TEMPLATE_TARGET_FIELD]: target,\n label: defaultTemplateLabel(target),\n // One bound title, not `[]`: an empty canvas offers nothing to click,\n // and the title is the one value every detail declaration can feed.\n layout: [{ id: randomUUID(), type: 'documentTitle', props: {}, traits: {} }],\n })\n created++\n } catch (err) {\n // Losing the find-then-create race is the outcome wanted either way; the\n // unique `target` is what makes that safe to assume.\n if (!isUniqueViolation(err)) throw err\n }\n }\n return { created }\n}\n\n/**\n * Forget every `toolkit_paths` row — canonical and redirect history — for the\n * named entities. For a seeder that empties those entities' tables in bulk.\n *\n * `AdminClient.delete` drops a document's paths when the row is physically\n * removed (the path-index writer's `'delete'` action). A bulk reset that skips\n * the client — the scaffold's demo `content` seeder TRUNCATEs its tables so it\n * can re-run — leaves every old URL claimed by a document that no longer\n * exists. The re-seeded documents then collide with their own ghosts and come\n * back as `/about-2`, `/welcome-2`, and the old URLs keep answering as\n * redirects to nothing (lumi-site PR04).\n *\n * Seeder-only, like the reset it follows: it asks no permission of its own,\n * exactly as a TRUNCATE does not. The seeders that call it already run as an\n * admin — `lumi seed` under `runAsCli`, the Seed page behind its admin gate.\n *\n * Returns how many rows were removed, per entity, so a seeder can log it.\n */\nexport async function forgetEntityPaths(\n entityNames: readonly string[],\n): Promise<Record<string, number>> {\n // The seeding site's paths only (multisite phase 03) — a reset of one\n // site's pages must not strip another site's URLs.\n const paths = toolkitPathsTable.makeClient(\n getApp().db.readWrite,\n await requireRequestSite('toolkit_paths'),\n )\n const removed: Record<string, number> = {}\n for (const entityType of entityNames) {\n removed[entityType] = (await paths.deleteMany({ entityType })).length\n }\n return removed\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"seed.mjs","names":[],"sources":["../src/seed.ts"],"sourcesContent":["/**\n * The site-chrome seeder — creates the one `site_chrome` row per placement\n * that a site needs, POPULATED, before any of them can be edited.\n *\n * Contributed as `content:site-chrome` from the `content()` plugin factory and\n * reached through the `@murumets-ee/content/seed` subpath — its own build\n * entry so the lazy import keeps `@murumets-ee/core/clients`, which carries\n * `import 'server-only'`, off the config-load path.\n *\n * ## Why a seeder at all, and why nobody has to run it\n *\n * An unseeded chrome document is indistinguishable from a site that HAS no\n * header, and both the canvas and `/v1/_chrome` treat it that way — they\n * compose nothing and let the consumer render its own fallback. That is right,\n * and it means nothing ever CREATES the row, so without this an editor has no\n * header to open.\n *\n * Until PR30 that left the gap S5 names: nothing in the printed setup path ran\n * this, so a scaffolded project booted with an empty Site list and the only\n * bootstrap was a human pressing \"New\". The plugin now declares this seeder\n * `bootstrap: true`, and `lumi migrate` runs every bootstrap seeder after it\n * applies migrations — which is already step 4 of the printed next-steps. It\n * stays an ordinary named seeder too, runnable from `lumi seed` and the admin\n * Seed page; `bootstrap` adds a trigger rather than changing what it is.\n *\n * ## Why the entity is resolved by NAME\n *\n * This package ships the chrome entity as a FACTORY (`siteChromeEntity()`),\n * because an app must pass options it cannot get from here — `withOpLog()`\n * ships from `@murumets-ee/blocks/server`, and content must not depend on\n * blocks (blocks depends back on content). So there is no entity object in\n * this package to import; there is only the name the factory stamps on\n * whatever the app instantiated.\n *\n * That name is not an assumption: `site-chrome.ts` hardcodes\n * `name: 'site_chrome'`, so an app can configure the entity but cannot rename\n * it. This package's own chrome PROVIDER resolves it the same way\n * (`app.entities.get(name)`, `null` when absent), and `@murumets-ee/auth`'s\n * default permission maps and `@murumets-ee/frontend-cache`'s\n * `DEFAULT_CHROME_ENTITIES` name the same string.\n *\n * ## Why it is `available`-gated rather than merely tolerant\n *\n * An app is free to register no chrome at all. Seeding plan F009 deferred this\n * seeder on exactly that: contributed unconditionally it would put a button on\n * the Seed page whose entire outcome is `created: 0`, which a reader takes as\n * \"this seeder is broken\", not as \"this project has no chrome\". The plugin\n * declares `available` so the button is ABSENT instead — the same contract\n * `rent:cars-demo` has, where absence follows from the plugin's absence rather\n * than from a silent no-op.\n *\n * ## Idempotent by construction, not by a flag\n *\n * Every run finds before it creates, keyed on `placement`. That is what makes\n * a second run a no-op — necessary now in a way it was not before, because\n * `lumi migrate` runs this on EVERY migrate, not once. It is also why the\n * find is a `where` on `placement` rather than a bare `limit: 1`: the old\n * `kind: 'singleton'` made \"is there a row\" the whole question, and under one\n * entity holding two rows it is the wrong question — a bare existence check\n * would see the header and never create the footer.\n *\n * ## DRAFT, deliberately — and this reverses an earlier choice\n *\n * The rows are created unpublished, so `lumi migrate` changes nothing a\n * visitor sees. That is not a detail; it is what makes `bootstrap: true` safe,\n * because this now runs on EVERY migrate, in production, against a database\n * full of a customer's content (see `PluginSeeder.bootstrap`). A provisioning\n * step that silently alters the public site is not additive.\n *\n * This file used to create them PUBLISHED, reasoning that \"a draft-seeded site\n * is indistinguishable from an unseeded one to every public reader\" and that\n * \"the rows are EMPTY, so publishing them renders nothing\". The first half is\n * true and is the POINT: an empty chrome IS unseeded, and a public reader\n * should see exactly what they saw before. The second half was measured and is\n * false.\n *\n * Measured, on a running front-astro against a real CMS: an empty PUBLISHED\n * region arrives on the wire as `{ blocks: [] }`, which `placeRegion` reads as\n * `hidden` — \"the page hid this on purpose\" — and `rendersFallback` returns\n * false for `hidden`. So publishing an empty row does not render nothing; it\n * renders nothing AND SUPPRESSES THE TEMPLATE'S OWN FALLBACK. A freshly\n * scaffolded site lost its `<header>` and `<footer>` landmarks entirely, where\n * before it rendered the fallback header with its Home link and nav.\n *\n * `null` (no published row) reads as `fallback` instead, which is the answer\n * that leaves the delivery template in charge until somebody has actually\n * authored a header. The moment they do and press Publish, the CMS takes over\n * — ordinary publish semantics, the same as every other document.\n *\n * ## EMPTY is what starved the canvas — and only the SITE was ever considered\n *\n * The section above is right and stays. Its blind spot is that it reasons\n * entirely about what a VISITOR sees. The rows it creates were also `layout:\n * []`, and the comment on that line said an empty tree \"is a header with no\n * blocks yet, which is what the editor opens onto\" — which is true, and is the\n * defect.\n *\n * The live site has a fallback: no published chrome row reads as `fallback`\n * and the delivery template renders its own header. **The editing canvas has\n * no fallback.** Its only channel for chrome is these rows — it is React in the\n * admin app and cannot render an Astro template's markup — so an empty row\n * composes to zero blocks and `CanvasDocument` emits a bare `<div>` with\n * nothing in it. Every page, forever, on every project ever scaffolded. The\n * site looked right the whole time, which is why no gate and no person caught\n * it for months (`editor` D014).\n *\n * So the two properties are independent and both are required: the rows are\n * DRAFT (nothing a visitor sees changes) and NON-EMPTY (the canvas has a header\n * and a footer to paint). Draft is what makes non-empty safe — the tree is\n * real, and no visitor sees it until somebody publishes.\n *\n * ## What this canNOT fix, stated because it is the interesting half\n *\n * This seeder is ADDITIVE: `if (existing.length > 0) continue`. An install\n * whose rows already exist — every project bootstrapped before this change —\n * keeps its empty ones, and must, because overwriting an authored header is the\n * one thing a bootstrap seeder may never do. The same hole reopens for anyone\n * who deletes their header's last block.\n *\n * That half belongs to the canvas and is fixed there: an empty non-page\n * document now renders a NAMED region band rather than nothing\n * (`block-editor-canvas.tsx`'s `EndAddZone`). This seeder decides what a NEW\n * site starts with; the canvas decides that an empty region is never invisible.\n *\n * ## What it assumes about its caller\n *\n * An admin context — `runSeederAction`, `lumi seed` and `lumi migrate`'s\n * bootstrap pass all establish one. Auth, the admin-only role check and the\n * production gate live there.\n *\n * The entity NAME and the `available` predicate live one module over\n * (`./chrome-entity-names.ts`), which imports nothing — `plugin.ts` needs the\n * predicate statically and must not pull this file's `server-only` client\n * along with it.\n */\n\nimport { randomUUID } from 'node:crypto'\nimport { type DefaultChromeBlock, defaultChromeBlocks } from '@murumets-ee/blocks/library'\nimport { getApp, requireRequestSite, runInEverySite } from '@murumets-ee/core'\nimport { createAdminClient } from '@murumets-ee/core/clients'\nimport type { Entity } from '@murumets-ee/entity'\nimport { eq } from 'drizzle-orm'\nimport { CHROME_ENTITY_NAME } from './chrome-entity-names.js'\nimport {\n DETAIL_TEMPLATE_ENTITY_NAME,\n DETAIL_TEMPLATE_TARGET_FIELD,\n} from './detail-template-names.js'\nimport {\n CHROME_PLACEMENT_LABELS,\n CHROME_PLACEMENTS,\n type ChromePlacement,\n} from './entities/site-chrome.js'\nimport { isUniqueViolation } from './paths/apply-path.js'\nimport { toolkitPathsTable } from './paths/schema.js'\n\n/**\n * The block tree a bootstrapped chrome row starts life with is the library's\n * DEFAULT tree — `defaultChromeBlocks` in `@murumets-ee/blocks/library`, the\n * same function a frontend calls to draw a region the CMS has not published\n * (davatoidud-toolkit PR10). One definition, so a new site's canvas and its\n * visitors cannot be shown two different defaults.\n *\n * Minted with `randomUUID`: block ids are the keys a page's `_overrides` map\n * is written under (`compose-chrome.ts`'s `tagScopedRegion` uses `block.id` as\n * `scopeBlockId`), so two rows sharing one id would make a per-page override\n * of the header also target the footer.\n *\n * What the default holds (the library's Site Header and Site Footer, naming\n * the `main` and `footer` menus, with no copy of ours) and why: see that\n * module's docblock.\n */\nfunction defaultChromeLayout(placement: ChromePlacement): DefaultChromeBlock[] {\n return defaultChromeBlocks(placement, () => randomUUID())\n}\n\nexport async function seedSiteChrome(): Promise<{ created: number }> {\n const app = getApp()\n const entity = app.entities.get(CHROME_ENTITY_NAME)\n // Absent rather than thrown: `available` guarantees the app registered the\n // entity at the moment the list was built, and a config reloaded between\n // list and run should produce \"nothing to do\", not a stack trace.\n if (!entity) return { created: 0 }\n\n // Chrome is per site (multisite D002): every site gets its own header and\n // footer. In a run that names a site (`lumi seed --site`), that site; on a\n // single-site install, the one site; otherwise once per active site — so a\n // site added since the last run gets its chrome on the next `lumi migrate`.\n const perSite = await runInEverySite(() => seedSiteChromeInSite(entity), {\n app,\n label: 'content:site-chrome',\n })\n return { created: perSite.reduce((sum, r) => sum + r.created, 0) }\n}\n\nasync function seedSiteChromeInSite(entity: Entity): Promise<{ created: number }> {\n const client = createAdminClient(entity)\n let created = 0\n\n // Column refs through `getTable()` — the sanctioned accessor — never a bare\n // property on a dynamically-generated table, which would put an `any` into\n // the WHERE clause.\n const placementColumn = client.getTable().placement\n if (!placementColumn) {\n // Unreachable while the entity declares the field, and cheaper to state\n // than to debug: without it a missing column would make `eq()` build a\n // WHERE against `undefined` and the find would silently match nothing —\n // so every run would try to create, and every run after the first would\n // be absorbed by the unique-violation catch below as though it had\n // succeeded.\n throw new Error(\n `${CHROME_ENTITY_NAME} has no \"placement\" column — the chrome entity registered by this ` +\n `app is not the one @murumets-ee/content ships.`,\n )\n }\n\n for (const placement of CHROME_PLACEMENTS) {\n // Keyed on `placement`, which is also the unique column — so this find is\n // asking exactly the question the database constraint answers, and a race\n // between two callers ends in a unique violation rather than a duplicate.\n const existing = await client.findMany({ where: eq(placementColumn, placement), limit: 1 })\n if (existing.length > 0) continue\n try {\n // A POPULATED tree, not `[]` — see \"Empty is what starves the canvas\"\n // in this file's header. `defaultChromeLayout` is the one place that\n // decides what a bootstrapped region holds.\n // No `status` — `publishable()` defaults to `draft`, which is the whole\n // of the \"changes nothing a visitor sees\" property. See the header.\n await client.create({\n placement,\n label: CHROME_PLACEMENT_LABELS[placement],\n layout: defaultChromeLayout(placement),\n })\n created++\n } catch (err) {\n // Losing the find-then-create race is a SUCCESS, not a failure: someone\n // else created the row this run was about to create, which is the\n // outcome either way. `placement`'s unique constraint is what makes that\n // safe to assume — the violation can only be the row we just looked for.\n //\n // Worth the four lines rather than trusting the find, because `lumi\n // migrate`'s bootstrap pass runs this on every migrate and two deploys\n // can overlap. Payload's equivalent conditions its write on an existence\n // check that is simply WRONG on Postgres and survives only because the\n // adapter happens to re-check underneath (R036).\n if (!isUniqueViolation(err)) throw err\n }\n }\n\n return { created }\n}\n\n/**\n * Create the DRAFT detail template for every entity the app's\n * `detail_template` entity targets (block-library D009), so each has a document\n * an editor can open. Idempotent: an existing row for a target is left alone.\n *\n * The targets are read from the `target` select's own options — the list the\n * app's `detailTemplateEntity({ for })` validated at boot — so the seeder and\n * the entity cannot disagree about which templates exist.\n *\n * A draft, like chrome's: until someone publishes it, every row of the target\n * keeps drawing the plain fallback page, so a migrate changes nothing a visitor\n * sees.\n */\nexport async function seedDetailTemplates(): Promise<{ created: number }> {\n const app = getApp()\n const entity = app.entities.get(DETAIL_TEMPLATE_ENTITY_NAME)\n if (!entity) return { created: 0 }\n const perSite = await runInEverySite(() => seedDetailTemplatesInSite(entity), {\n app,\n label: 'content:detail-templates',\n })\n return { created: perSite.reduce((sum, r) => sum + r.created, 0) }\n}\n\n/** `yhikas_room_type` → `Yhikas room type`. English; an editor renames it. */\nfunction defaultTemplateLabel(target: string): string {\n const words = target.replace(/[_-]+/g, ' ').trim()\n return `${words.charAt(0).toUpperCase()}${words.slice(1)} detail`\n}\n\nasync function seedDetailTemplatesInSite(entity: Entity): Promise<{ created: number }> {\n const targetField = entity.allFields[DETAIL_TEMPLATE_TARGET_FIELD]\n const targets: readonly string[] = targetField?.type === 'select' ? targetField.options : []\n if (targets.length === 0) return { created: 0 }\n\n const client = createAdminClient(entity)\n const targetColumn = client.getTable()[DETAIL_TEMPLATE_TARGET_FIELD]\n if (!targetColumn) {\n throw new Error(\n `${DETAIL_TEMPLATE_ENTITY_NAME} has no \"${DETAIL_TEMPLATE_TARGET_FIELD}\" column — the ` +\n `entity registered by this app is not the one @murumets-ee/content ships.`,\n )\n }\n\n let created = 0\n for (const target of targets) {\n const existing = await client.findMany({ where: eq(targetColumn, target), limit: 1 })\n if (existing.length > 0) continue\n try {\n await client.create({\n [DETAIL_TEMPLATE_TARGET_FIELD]: target,\n label: defaultTemplateLabel(target),\n // One bound title, not `[]`: an empty canvas offers nothing to click,\n // and the title is the one value every detail declaration can feed.\n layout: [{ id: randomUUID(), type: 'documentTitle', props: {}, traits: {} }],\n })\n created++\n } catch (err) {\n // Losing the find-then-create race is the outcome wanted either way; the\n // unique `target` is what makes that safe to assume.\n if (!isUniqueViolation(err)) throw err\n }\n }\n return { created }\n}\n\n/**\n * Forget every `toolkit_paths` row — canonical and redirect history — for the\n * named entities. For a seeder that empties those entities' tables in bulk.\n *\n * `AdminClient.delete` drops a document's paths when the row is physically\n * removed (the path-index writer's `'delete'` action). A bulk reset that skips\n * the client — the scaffold's demo `content` seeder TRUNCATEs its tables so it\n * can re-run — leaves every old URL claimed by a document that no longer\n * exists. The re-seeded documents then collide with their own ghosts and come\n * back as `/about-2`, `/welcome-2`, and the old URLs keep answering as\n * redirects to nothing (lumi-site PR04).\n *\n * Seeder-only, like the reset it follows: it asks no permission of its own,\n * exactly as a TRUNCATE does not. The seeders that call it already run as an\n * admin — `lumi seed` under `runAsCli`, the Seed page behind its admin gate.\n *\n * Returns how many rows were removed, per entity, so a seeder can log it.\n */\nexport async function forgetEntityPaths(\n entityNames: readonly string[],\n): Promise<Record<string, number>> {\n // The seeding site's paths only (multisite phase 03) — a reset of one\n // site's pages must not strip another site's URLs.\n const paths = toolkitPathsTable.makeClient(\n getApp().db.readWrite,\n await requireRequestSite('toolkit_paths'),\n )\n const removed: Record<string, number> = {}\n for (const entityType of entityNames) {\n removed[entityType] = (await paths.deleteMany({ entityType })).length\n }\n return removed\n}\n"],"mappings":"+fA2KA,SAAS,EAAoB,EAAkD,CAC7E,OAAO,EAAoB,MAAiB,EAAW,CAAC,CAC1D,CAEA,eAAsB,GAA+C,CACnE,IAAM,EAAM,EAAO,EACb,EAAS,EAAI,SAAS,IAAI,CAAkB,EAclD,OAVK,EAUE,CAAE,SAAS,MAJI,MAAqB,EAAqB,CAAM,EAAG,CACvE,MACA,MAAO,qBACT,CAAC,EAAA,CACyB,QAAQ,EAAK,IAAM,EAAM,EAAE,QAAS,CAAC,CAAE,EAV7C,CAAE,QAAS,CAAE,CAWnC,CAEA,eAAe,EAAqB,EAA8C,CAChF,IAAM,EAAS,EAAkB,CAAM,EACnC,EAAU,EAKR,EAAkB,EAAO,SAAS,CAAC,CAAC,UAC1C,GAAI,CAAC,EAOH,MAAU,MACR,GAAG,EAAmB,iHAExB,EAGF,IAAK,IAAM,KAAa,EAKtB,MAAI,MADmB,EAAO,SAAS,CAAE,MAAO,EAAG,EAAiB,CAAS,EAAG,MAAO,CAAE,CAAC,EAAA,CAC7E,OAAS,GACtB,GAAI,CAMF,MAAM,EAAO,OAAO,CAClB,YACA,MAAO,EAAwB,GAC/B,OAAQ,EAAoB,CAAS,CACvC,CAAC,EACD,GACF,OAAS,EAAK,CAWZ,GAAI,CAAC,EAAkB,CAAG,EAAG,MAAM,CACrC,CAGF,MAAO,CAAE,SAAQ,CACnB,CAeA,eAAsB,GAAoD,CACxE,IAAM,EAAM,EAAO,EACb,EAAS,EAAI,SAAS,IAAI,CAA2B,EAM3D,OALK,EAKE,CAAE,SAAS,MAJI,MAAqB,EAA0B,CAAM,EAAG,CAC5E,MACA,MAAO,0BACT,CAAC,EAAA,CACyB,QAAQ,EAAK,IAAM,EAAM,EAAE,QAAS,CAAC,CAAE,EAL7C,CAAE,QAAS,CAAE,CAMnC,CAGA,SAAS,EAAqB,EAAwB,CACpD,IAAM,EAAQ,EAAO,QAAQ,SAAU,GAAG,CAAC,CAAC,KAAK,EACjD,MAAO,GAAG,EAAM,OAAO,CAAC,CAAC,CAAC,YAAY,IAAI,EAAM,MAAM,CAAC,EAAE,QAC3D,CAEA,eAAe,EAA0B,EAA8C,CACrF,IAAM,EAAc,EAAO,UAAU,GAC/B,EAA6B,GAAa,OAAS,SAAW,EAAY,QAAU,CAAC,EAC3F,GAAI,EAAQ,SAAW,EAAG,MAAO,CAAE,QAAS,CAAE,EAE9C,IAAM,EAAS,EAAkB,CAAM,EACjC,EAAe,EAAO,SAAS,CAAC,CAAC,GACvC,GAAI,CAAC,EACH,MAAU,MACR,GAAG,EAA4B,WAAW,EAA6B,wFAEzE,EAGF,IAAI,EAAU,EACd,IAAK,IAAM,KAAU,EAEnB,MAAI,MADmB,EAAO,SAAS,CAAE,MAAO,EAAG,EAAc,CAAM,EAAG,MAAO,CAAE,CAAC,EAAA,CACvE,OAAS,GACtB,GAAI,CACF,MAAM,EAAO,OAAO,EACjB,GAA+B,EAChC,MAAO,EAAqB,CAAM,EAGlC,OAAQ,CAAC,CAAE,GAAI,EAAW,EAAG,KAAM,gBAAiB,MAAO,CAAC,EAAG,OAAQ,CAAC,CAAE,CAAC,CAC7E,CAAC,EACD,GACF,OAAS,EAAK,CAGZ,GAAI,CAAC,EAAkB,CAAG,EAAG,MAAM,CACrC,CAEF,MAAO,CAAE,SAAQ,CACnB,CAoBA,eAAsB,EACpB,EACiC,CAGjC,IAAM,EAAQ,EAAkB,WAC9B,EAAO,CAAC,CAAC,GAAG,UACZ,MAAM,EAAmB,eAAe,CAC1C,EACM,EAAkC,CAAC,EACzC,IAAK,IAAM,KAAc,EACvB,EAAQ,IAAe,MAAM,EAAM,WAAW,CAAE,YAAW,CAAC,EAAA,CAAG,OAEjE,OAAO,CACT"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{n as e}from"./versionable-Br_G5VGR.mjs";import{auditable as t,defineEntity as n,field as r,publishable as i}from"@murumets-ee/entity";const a=[`header`,`footer`],o={header:`Site header`,footer:`Site footer`},s={label:{label:`Name`,description:`Shown in the admin list, and as the editor title when this document is opened by itself. Visitors never see it.`}};function c(e){let t={...s};for(let[n,r]of Object.entries(e??{}))t[n]={...t[n],...r};return t}function l(o){let s=o?.viewRole??`editor`,l=o?.updateRole??`editor`,u=o?.adminRole??`admin`,d=o?.maxVersions??30;return n({name:`site_chrome`,site:`bound`,kind:`collection`,fields:{placement:r.select({options:a,required:!0,unique:!0}),label:r.text({required:!0}),layout:r.blockTree({translatable:!0,areaFrom:`placement`,...o?.environment!==void 0&&{environment:o.environment}})},behaviors:[t(),i(),e({maxPerDoc:d}),...o?.behaviors??[]],scope:`global`,access:{view:s,create:u,update:l,delete:u},admin:{icon:`panel-top`,description:`Blocks rendered around every page that does not override them.`,rootFields:[`label`],fieldOverrides:c(o?.admin?.fieldOverrides),titleField:`label`,group:`Site`,disableCreate:!0}})}l().name;export{o as n,l as r,a as t};
|
|
2
|
+
//# sourceMappingURL=site-chrome-BAtQaAqC.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"site-chrome-AffFxYrV.mjs","names":[],"sources":["../src/entities/site-chrome.ts"],"sourcesContent":["/**\n * `site_chrome` — the block documents that hold the site chrome, one row per\n * PLACEMENT, so a header is edited on the same canvas as the page it sits\n * above instead of being typed into a JSON textarea.\n *\n * ## Placement, not type\n *\n * There is ONE entity. A chrome document says where it appears by carrying a\n * `placement` value, and `'header'` / `'footer'` are exactly the region keys\n * `GET /v1/_chrome` emits — so the provider maps placement to region by\n * identity rather than through a lookup table. That is the whole of the model.\n *\n * It replaces three entities (`site_header`, `site_footer`, and a single-row\n * site layout) and six hardcoded name lists, the most consequential of which\n * was not a list of names at all: `apps/admin/lib/chrome-documents.ts` carried\n * a module constant pairing each chrome entity with a\n * `position: 'before' | 'after'`. That was the placement flag, written in code,\n * in the app, where no operator could reach it and no plugin could extend it.\n *\n * ## What survives, and why it had to\n *\n * **A separate ROW per placement.** `publishable()` and `versionable()` are\n * per-row, so a header and a footer sharing one row could not be published or\n * rolled back independently. That constraint is real and this model honours\n * it — it is only the entity TYPE that collapses, never the row.\n *\n * **Peer-document editing.** The editor composes header, page and footer as\n * peers with per-document undo (`plan/site-chrome` D001 / D003). It never\n * keyed on the entity NAME to do that: `DocumentSlice.entity` is the only\n * discriminator and `pageDocumentId` is explicit state, precisely so that no\n * entity-name allowlist is needed. One name in place of two costs it nothing.\n *\n * ## Cardinality — why `kind: 'collection'` and a unique column\n *\n * `kind: 'singleton'` is enforced now (`plan/site-chrome` D011), and\n * `assertSingletonSlotFree` counts every live row of an entity — so an entity\n * holding a header row AND a footer row trips the gate on the second insert.\n * The kind and the collapse are mutually exclusive, which is why phase 09 lists\n * \"the singleton kind\" among the things that go.\n *\n * `placement`'s `unique: true` is what takes over the half that mattered: the\n * rule becomes one row per placement, enforced at the DATABASE for every\n * writer rather than in one client. On a `trashable()` entity — which this is,\n * by auto-enable, because it is publishable — the schema builder re-emits that\n * as a PARTIAL unique index (`WHERE \"deletedAt\" IS NULL`), so trashing a\n * header frees its placement for a replacement instead of wedging it.\n *\n * What is NOT carried over is the singleton DELETE rule (\"never leave zero\").\n * Deleting the only header is now possible, and the site falls back to the\n * delivery template's own markup — the same thing it does before the first\n * header is ever created. `trashable()` makes it recoverable.\n *\n * ## `admin.disableCreate` is correctness, not a workaround\n *\n * The block-editor create branch auto-POSTs `{ title: 'Untitled' }` and\n * nothing else, so any entity on that branch with a second required field\n * would reject on `/new`. `placement` is exactly such a field.\n *\n * Suppressing the affordance is also the honest answer: a third chrome row\n * would render NOWHERE, because the delivery template has exactly two chrome\n * slots. A new placement is a code change on both sides — a `placement` option\n * here and a slot in `BaseLayout.astro` — and that coupling is what keeps the\n * enum from accumulating values nothing renders.\n *\n * The two rows a site needs are created by the `content:site-chrome` seeder,\n * which `lumi migrate` runs automatically as a `bootstrap` seeder. Nobody has\n * to press anything.\n *\n * ## Wiring\n *\n * @example\n * ```ts\n * // apps/<app>/entities/site-chrome.ts\n * import { withOpLog } from '@murumets-ee/blocks/server'\n * import { siteChromeEntity } from '@murumets-ee/content/entities'\n *\n * export const SiteChrome = siteChromeEntity({ environment: 'page' })\n * .with(withOpLog()) // see below — the app layers this on\n * ```\n *\n * `withOpLog()` is deliberately NOT in the factory: it ships from\n * `@murumets-ee/blocks/server`, and `@murumets-ee/content` must not depend on\n * `@murumets-ee/blocks` (blocks depends back on content). The app layers it at\n * config time. **Without it the block editor 404s** — `editor-state` refuses a\n * document whose entity has no op-log behavior — so an app that registers this\n * and skips it gets a chrome document it cannot open.\n *\n * Behaviors the factory does apply:\n *\n * - `auditable()` — created/updated stamps\n * - `publishable()` — a header is drafted and published like any document,\n * which is the point of it being one\n * - `versionable({ maxPerDoc })` — per-locale history\n */\n\nimport type { Behavior, EntityAdminConfig } from '@murumets-ee/entity'\nimport { auditable, defineEntity, field, publishable } from '@murumets-ee/entity'\nimport { versionable } from '../behaviors/versionable.js'\n\n/**\n * Where a chrome document appears on every page that does not override it.\n *\n * These strings ARE the `/v1/_chrome` region keys and the delivery template's\n * slot names. Adding one means adding a slot to `BaseLayout.astro` in the same\n * change, or the value names a region nothing renders.\n */\nexport const CHROME_PLACEMENTS = ['header', 'footer'] as const\n\n/** One placement. Also a region name on the wire — same string, by design. */\nexport type ChromePlacement = (typeof CHROME_PLACEMENTS)[number]\n\n/** The default label a bootstrap row gets, per placement. */\nexport const CHROME_PLACEMENT_LABELS: Readonly<Record<ChromePlacement, string>> = {\n header: 'Site header',\n footer: 'Site footer',\n}\n\n/** Optional overrides apps can apply when calling the factory. */\nexport interface SiteChromeEntityOptions {\n /**\n * Roles allowed to read the document. Default `'editor'`.\n *\n * ADVISORY entity-level metadata that does NOT govern the public content API\n * — that path resolves a role and asks `buildPermissionChecker`.\n * `site_chrome:view` is seeded onto `public` by\n * `buildInitialRoleDefinitions()` so a fresh install serves its chrome;\n * changing this option does not change that.\n */\n viewRole?: string\n /** Roles allowed to edit a document's blocks. Default `'editor'`. */\n updateRole?: string\n /** Roles allowed to create/delete a chrome row. Default `'admin'`. */\n adminRole?: string\n /**\n * The block environment the editor offers in this document's canvas. Pass\n * the same value the site's page entity uses so a header can be built from\n * the same vocabulary. Omitted means \"no environment restriction\".\n */\n environment?: string\n /** Override the version-retention cap. Default 30 versions per locale. */\n maxVersions?: number\n /**\n * Extra behaviors layered after the factory's own.\n *\n * This is how an app supplies `withOpLog()`, which is REQUIRED for the block\n * editor to open the document and which cannot live in this package —\n * `withOpLog` ships from `@murumets-ee/blocks/server`, and\n * `@murumets-ee/content` must not depend on `@murumets-ee/blocks` because\n * blocks depends back on content.\n *\n * Typed as field-contributing-nothing on purpose: the entity's inferred field\n * map is part of its public type, and a behavior that added fields here would\n * widen it silently. `withOpLog()` adds none, which its own docblock states.\n */\n behaviors?: readonly Behavior<Record<never, never>, string>[]\n /**\n * How the Page-settings tab and forms NAME this entity's fields. Merged per\n * field over the factory's English defaults, so a project adds only what it\n * translates — typically `labelKey` / `descriptionKey` into its OWN catalogue\n * (e.g. `{ label: { labelKey: 'SiteChrome.fields.label', descriptionKey:\n * 'SiteChrome.fields.labelHelp' } }`). Resolution is key → English label →\n * humanized name, never the raw field name (editor-curation D003).\n */\n admin?: {\n fieldOverrides?: EntityAdminConfig['fieldOverrides']\n }\n}\n\n/**\n * The English defaults every project gets. `label` is the only root field the\n * Page-settings tab offers (`rootFields` below), so it is the one an operator\n * reads; without this it humanized to \"Label\", untranslatable.\n */\nconst DEFAULT_FIELD_OVERRIDES: NonNullable<EntityAdminConfig['fieldOverrides']> = {\n label: {\n label: 'Name',\n description: 'Shown in the admin list and as the editor title. Visitors never see it.',\n },\n}\n\n/** Per-field shallow merge — a project's key never drops the default English fallback. */\nfunction mergeFieldOverrides(\n project: EntityAdminConfig['fieldOverrides'],\n): NonNullable<EntityAdminConfig['fieldOverrides']> {\n const merged: NonNullable<EntityAdminConfig['fieldOverrides']> = { ...DEFAULT_FIELD_OVERRIDES }\n for (const [name, override] of Object.entries(project ?? {})) {\n merged[name] = { ...merged[name], ...override }\n }\n return merged\n}\n\n/**\n * Build the `site_chrome` entity — the block documents rendered around every\n * page that does not override them.\n *\n * The return type is intentionally INFERRED (no annotation) so `'site_chrome'`\n * stays a string literal in the caller's `Entity<…, 'site_chrome', …>` view;\n * annotating it as bare `Entity` widens the name to `string`, which propagates\n * into the resolved-permissions union as a `${string}:view` template. The\n * compile-time guard at the foot of this file is what keeps that true.\n */\nexport function siteChromeEntity(options?: SiteChromeEntityOptions) {\n const viewRole = options?.viewRole ?? 'editor'\n const updateRole = options?.updateRole ?? 'editor'\n const adminRole = options?.adminRole ?? 'admin'\n const maxVersions = options?.maxVersions ?? 30\n\n return defineEntity({\n name: 'site_chrome',\n // Each site has its own header and footer (multisite D002/D005): the\n // placement unique is per site, so every site holds one of each.\n site: 'bound',\n // NOT `'singleton'` — see the cardinality note in this file's header. One\n // row per placement, enforced by `placement`'s own unique constraint.\n kind: 'collection',\n fields: {\n // THE placement flag. Deliberately not translatable: where a document\n // appears is a property of the document, not of a translation of it.\n //\n // `unique: true` is load-bearing rather than decorative — it is the\n // whole cardinality rule. `indexed` is NOT set alongside it: the schema\n // builder skips indexing unique fields because the constraint already\n // emits its own btree.\n placement: field.select({\n options: CHROME_PLACEMENTS,\n required: true,\n unique: true,\n }),\n // The human name of the row, shown in the list and as the editor's\n // title. `titleField` below points at it explicitly so nothing has to\n // fall back to a heuristic — or to a `title` column this entity has\n // never had.\n label: field.text({ required: true }),\n // The ONE blockTree field, which is what makes this a canvas.\n //\n // `translatable: true`, load-bearing: without it the column lives on the\n // base row, so writing the English header overwrites the Estonian one\n // and every locale then serves English. The write succeeds; only a\n // per-locale READ reveals it.\n //\n // `translation: 'shared'` is not an option — the schema builder throws on\n // it for blockTree — so each locale's chrome is an independent tree. That\n // matches the editor, whose session id is `${id}:${locale}`.\n //\n // `areaFrom: 'placement'` is what makes this row a HEADER or a FOOTER to\n // everything that asks (davatoidud-toolkit D006): the canvas, op-commit,\n // the entity write path and the agent catalogue all call\n // `resolveDocumentArea(entity, row)`, which reads this declaration. A\n // header block is offered and accepted in the header row and nowhere else.\n layout: field.blockTree({\n translatable: true,\n areaFrom: 'placement',\n ...(options?.environment !== undefined && { environment: options.environment }),\n }),\n },\n behaviors: [\n auditable(),\n publishable(),\n versionable({ maxPerDoc: maxVersions }),\n ...(options?.behaviors ?? []),\n ],\n scope: 'global',\n access: {\n view: viewRole,\n // Restricts WHO may create; `placement`'s unique constraint restricts\n // HOW MANY. Both apply, and neither substitutes for the other.\n create: adminRole,\n update: updateRole,\n delete: adminRole,\n },\n admin: {\n icon: 'panel-top',\n description: 'Blocks rendered around every page that does not override them.',\n // `layout` is a blockTree and is excluded from forms by type, so this is\n // only about what the block editor's settings sheet offers. `placement`\n // is deliberately absent: moving a header to the footer slot by editing\n // a dropdown is not an operation anyone wants, and it would collide with\n // the row already holding that placement.\n rootFields: ['label'],\n fieldOverrides: mergeFieldOverrides(options?.admin?.fieldOverrides),\n titleField: 'label',\n group: 'Site',\n // See this file's header — the rows are bootstrapped, not hand-created.\n disableCreate: true,\n },\n })\n}\n\n/**\n * COMPILE-TIME guard for the inferred-return-type note above.\n *\n * It lives in `src/`, NOT in `tests/`, and that placement is the point: this\n * package's tsconfig includes `src` only, so a type-level assertion under\n * `tests/` is compiled by no gate at all — vitest transpiles without checking\n * types. A guard nobody compiles is worse than no guard, because it reads as\n * one.\n *\n * Negative-tested: annotating `siteChromeEntity`'s return as bare `Entity`\n * makes this line fail `tsc --noEmit` with TS2322.\n */\nconst _chromeNameStaysLiteral: 'site_chrome' = siteChromeEntity().name\nvoid _chromeNameStaysLiteral\n"],"mappings":"6IA0GA,MAAa,EAAoB,CAAC,SAAU,QAAQ,EAMvC,EAAqE,CAChF,OAAQ,cACR,OAAQ,aACV,EA0DM,EAA4E,CAChF,MAAO,CACL,MAAO,OACP,YAAa,yEACf,CACF,EAGA,SAAS,EACP,EACkD,CAClD,IAAM,EAA2D,CAAE,GAAG,CAAwB,EAC9F,IAAK,GAAM,CAAC,EAAM,KAAa,OAAO,QAAQ,GAAW,CAAC,CAAC,EACzD,EAAO,GAAQ,CAAE,GAAG,EAAO,GAAO,GAAG,CAAS,EAEhD,OAAO,CACT,CAYA,SAAgB,EAAiB,EAAmC,CAClE,IAAM,EAAW,GAAS,UAAY,SAChC,EAAa,GAAS,YAAc,SACpC,EAAY,GAAS,WAAa,QAClC,EAAc,GAAS,aAAe,GAE5C,OAAO,EAAa,CAClB,KAAM,cAGN,KAAM,QAGN,KAAM,aACN,OAAQ,CAQN,UAAW,EAAM,OAAO,CACtB,QAAS,EACT,SAAU,GACV,OAAQ,EACV,CAAC,EAKD,MAAO,EAAM,KAAK,CAAE,SAAU,EAAK,CAAC,EAiBpC,OAAQ,EAAM,UAAU,CACtB,aAAc,GACd,SAAU,YACV,GAAI,GAAS,cAAgB,IAAA,IAAa,CAAE,YAAa,EAAQ,WAAY,CAC/E,CAAC,CACH,EACA,UAAW,CACT,EAAU,EACV,EAAY,EACZ,EAAY,CAAE,UAAW,CAAY,CAAC,EACtC,GAAI,GAAS,WAAa,CAAC,CAC7B,EACA,MAAO,SACP,OAAQ,CACN,KAAM,EAGN,OAAQ,EACR,OAAQ,EACR,OAAQ,CACV,EACA,MAAO,CACL,KAAM,YACN,YAAa,iEAMb,WAAY,CAAC,OAAO,EACpB,eAAgB,EAAoB,GAAS,OAAO,cAAc,EAClE,WAAY,QACZ,MAAO,OAEP,cAAe,EACjB,CACF,CAAC,CACH,CAc+C,EAAiB,CAAC,CAAC"}
|
|
1
|
+
{"version":3,"file":"site-chrome-BAtQaAqC.mjs","names":[],"sources":["../src/entities/site-chrome.ts"],"sourcesContent":["/**\n * `site_chrome` — the block documents that hold the site chrome, one row per\n * PLACEMENT, so a header is edited on the same canvas as the page it sits\n * above instead of being typed into a JSON textarea.\n *\n * ## Placement, not type\n *\n * There is ONE entity. A chrome document says where it appears by carrying a\n * `placement` value, and `'header'` / `'footer'` are exactly the region keys\n * `GET /v1/_chrome` emits — so the provider maps placement to region by\n * identity rather than through a lookup table. That is the whole of the model.\n *\n * It replaces three entities (`site_header`, `site_footer`, and a single-row\n * site layout) and six hardcoded name lists, the most consequential of which\n * was not a list of names at all: `apps/admin/lib/chrome-documents.ts` carried\n * a module constant pairing each chrome entity with a\n * `position: 'before' | 'after'`. That was the placement flag, written in code,\n * in the app, where no operator could reach it and no plugin could extend it.\n *\n * ## What survives, and why it had to\n *\n * **A separate ROW per placement.** `publishable()` and `versionable()` are\n * per-row, so a header and a footer sharing one row could not be published or\n * rolled back independently. That constraint is real and this model honours\n * it — it is only the entity TYPE that collapses, never the row.\n *\n * **Peer-document editing.** The editor composes header, page and footer as\n * peers with per-document undo (`plan/site-chrome` D001 / D003). It never\n * keyed on the entity NAME to do that: `DocumentSlice.entity` is the only\n * discriminator and `pageDocumentId` is explicit state, precisely so that no\n * entity-name allowlist is needed. One name in place of two costs it nothing.\n *\n * ## Cardinality — why `kind: 'collection'` and a unique column\n *\n * `kind: 'singleton'` is enforced now (`plan/site-chrome` D011), and\n * `assertSingletonSlotFree` counts every live row of an entity — so an entity\n * holding a header row AND a footer row trips the gate on the second insert.\n * The kind and the collapse are mutually exclusive, which is why phase 09 lists\n * \"the singleton kind\" among the things that go.\n *\n * `placement`'s `unique: true` is what takes over the half that mattered: the\n * rule becomes one row per placement, enforced at the DATABASE for every\n * writer rather than in one client. On a `trashable()` entity — which this is,\n * by auto-enable, because it is publishable — the schema builder re-emits that\n * as a PARTIAL unique index (`WHERE \"deletedAt\" IS NULL`), so trashing a\n * header frees its placement for a replacement instead of wedging it.\n *\n * What is NOT carried over is the singleton DELETE rule (\"never leave zero\").\n * Deleting the only header is now possible, and the site falls back to the\n * delivery template's own markup — the same thing it does before the first\n * header is ever created. `trashable()` makes it recoverable.\n *\n * ## `admin.disableCreate` is correctness, not a workaround\n *\n * The block-editor create branch auto-POSTs `{ title: 'Untitled' }` and\n * nothing else, so any entity on that branch with a second required field\n * would reject on `/new`. `placement` is exactly such a field.\n *\n * Suppressing the affordance is also the honest answer: a third chrome row\n * would render NOWHERE, because the delivery template has exactly two chrome\n * slots. A new placement is a code change on both sides — a `placement` option\n * here and a slot in `BaseLayout.astro` — and that coupling is what keeps the\n * enum from accumulating values nothing renders.\n *\n * The two rows a site needs are created by the `content:site-chrome` seeder,\n * which `lumi migrate` runs automatically as a `bootstrap` seeder. Nobody has\n * to press anything.\n *\n * ## Wiring\n *\n * @example\n * ```ts\n * // apps/<app>/entities/site-chrome.ts\n * import { withOpLog } from '@murumets-ee/blocks/server'\n * import { siteChromeEntity } from '@murumets-ee/content/entities'\n *\n * export const SiteChrome = siteChromeEntity({ environment: 'page' })\n * .with(withOpLog()) // see below — the app layers this on\n * ```\n *\n * `withOpLog()` is deliberately NOT in the factory: it ships from\n * `@murumets-ee/blocks/server`, and `@murumets-ee/content` must not depend on\n * `@murumets-ee/blocks` (blocks depends back on content). The app layers it at\n * config time. **Without it the block editor 404s** — `editor-state` refuses a\n * document whose entity has no op-log behavior — so an app that registers this\n * and skips it gets a chrome document it cannot open.\n *\n * Behaviors the factory does apply:\n *\n * - `auditable()` — created/updated stamps\n * - `publishable()` — a header is drafted and published like any document,\n * which is the point of it being one\n * - `versionable({ maxPerDoc })` — per-locale history\n */\n\nimport type { Behavior, EntityAdminConfig } from '@murumets-ee/entity'\nimport { auditable, defineEntity, field, publishable } from '@murumets-ee/entity'\nimport { versionable } from '../behaviors/versionable.js'\n\n/**\n * Where a chrome document appears on every page that does not override it.\n *\n * These strings ARE the `/v1/_chrome` region keys and the delivery template's\n * slot names. Adding one means adding a slot to `BaseLayout.astro` in the same\n * change, or the value names a region nothing renders.\n */\nexport const CHROME_PLACEMENTS = ['header', 'footer'] as const\n\n/** One placement. Also a region name on the wire — same string, by design. */\nexport type ChromePlacement = (typeof CHROME_PLACEMENTS)[number]\n\n/** The default label a bootstrap row gets, per placement. */\nexport const CHROME_PLACEMENT_LABELS: Readonly<Record<ChromePlacement, string>> = {\n header: 'Site header',\n footer: 'Site footer',\n}\n\n/** Optional overrides apps can apply when calling the factory. */\nexport interface SiteChromeEntityOptions {\n /**\n * Roles allowed to read the document. Default `'editor'`.\n *\n * ADVISORY entity-level metadata that does NOT govern the public content API\n * — that path resolves a role and asks `buildPermissionChecker`.\n * `site_chrome:view` is seeded onto `public` by\n * `buildInitialRoleDefinitions()` so a fresh install serves its chrome;\n * changing this option does not change that.\n */\n viewRole?: string\n /** Roles allowed to edit a document's blocks. Default `'editor'`. */\n updateRole?: string\n /** Roles allowed to create/delete a chrome row. Default `'admin'`. */\n adminRole?: string\n /**\n * The block environment the editor offers in this document's canvas. Pass\n * the same value the site's page entity uses so a header can be built from\n * the same vocabulary. Omitted means \"no environment restriction\".\n */\n environment?: string\n /** Override the version-retention cap. Default 30 versions per locale. */\n maxVersions?: number\n /**\n * Extra behaviors layered after the factory's own.\n *\n * This is how an app supplies `withOpLog()`, which is REQUIRED for the block\n * editor to open the document and which cannot live in this package —\n * `withOpLog` ships from `@murumets-ee/blocks/server`, and\n * `@murumets-ee/content` must not depend on `@murumets-ee/blocks` because\n * blocks depends back on content.\n *\n * Typed as field-contributing-nothing on purpose: the entity's inferred field\n * map is part of its public type, and a behavior that added fields here would\n * widen it silently. `withOpLog()` adds none, which its own docblock states.\n */\n behaviors?: readonly Behavior<Record<never, never>, string>[]\n /**\n * How the Page-settings tab and forms NAME this entity's fields. Merged per\n * field over the factory's English defaults, so a project adds only what it\n * translates — typically `labelKey` / `descriptionKey` into its OWN catalogue\n * (e.g. `{ label: { labelKey: 'SiteChrome.fields.label', descriptionKey:\n * 'SiteChrome.fields.labelHelp' } }`). Resolution is key → English label →\n * humanized name, never the raw field name (editor-curation D003).\n */\n admin?: {\n fieldOverrides?: EntityAdminConfig['fieldOverrides']\n }\n}\n\n/**\n * The English defaults every project gets. `label` is the only root field the\n * Page-settings tab offers (`rootFields` below), so it is the one an operator\n * reads; without this it humanized to \"Label\", untranslatable.\n */\nconst DEFAULT_FIELD_OVERRIDES: NonNullable<EntityAdminConfig['fieldOverrides']> = {\n label: {\n label: 'Name',\n description:\n 'Shown in the admin list, and as the editor title when this document is opened by itself. Visitors never see it.',\n },\n}\n\n/** Per-field shallow merge — a project's key never drops the default English fallback. */\nfunction mergeFieldOverrides(\n project: EntityAdminConfig['fieldOverrides'],\n): NonNullable<EntityAdminConfig['fieldOverrides']> {\n const merged: NonNullable<EntityAdminConfig['fieldOverrides']> = { ...DEFAULT_FIELD_OVERRIDES }\n for (const [name, override] of Object.entries(project ?? {})) {\n merged[name] = { ...merged[name], ...override }\n }\n return merged\n}\n\n/**\n * Build the `site_chrome` entity — the block documents rendered around every\n * page that does not override them.\n *\n * The return type is intentionally INFERRED (no annotation) so `'site_chrome'`\n * stays a string literal in the caller's `Entity<…, 'site_chrome', …>` view;\n * annotating it as bare `Entity` widens the name to `string`, which propagates\n * into the resolved-permissions union as a `${string}:view` template. The\n * compile-time guard at the foot of this file is what keeps that true.\n */\nexport function siteChromeEntity(options?: SiteChromeEntityOptions) {\n const viewRole = options?.viewRole ?? 'editor'\n const updateRole = options?.updateRole ?? 'editor'\n const adminRole = options?.adminRole ?? 'admin'\n const maxVersions = options?.maxVersions ?? 30\n\n return defineEntity({\n name: 'site_chrome',\n // Each site has its own header and footer (multisite D002/D005): the\n // placement unique is per site, so every site holds one of each.\n site: 'bound',\n // NOT `'singleton'` — see the cardinality note in this file's header. One\n // row per placement, enforced by `placement`'s own unique constraint.\n kind: 'collection',\n fields: {\n // THE placement flag. Deliberately not translatable: where a document\n // appears is a property of the document, not of a translation of it.\n //\n // `unique: true` is load-bearing rather than decorative — it is the\n // whole cardinality rule. `indexed` is NOT set alongside it: the schema\n // builder skips indexing unique fields because the constraint already\n // emits its own btree.\n placement: field.select({\n options: CHROME_PLACEMENTS,\n required: true,\n unique: true,\n }),\n // The human name of the row, shown in the list and as the editor's\n // title. `titleField` below points at it explicitly so nothing has to\n // fall back to a heuristic — or to a `title` column this entity has\n // never had.\n label: field.text({ required: true }),\n // The ONE blockTree field, which is what makes this a canvas.\n //\n // `translatable: true`, load-bearing: without it the column lives on the\n // base row, so writing the English header overwrites the Estonian one\n // and every locale then serves English. The write succeeds; only a\n // per-locale READ reveals it.\n //\n // `translation: 'shared'` is not an option — the schema builder throws on\n // it for blockTree — so each locale's chrome is an independent tree. That\n // matches the editor, whose session id is `${id}:${locale}`.\n //\n // `areaFrom: 'placement'` is what makes this row a HEADER or a FOOTER to\n // everything that asks (davatoidud-toolkit D006): the canvas, op-commit,\n // the entity write path and the agent catalogue all call\n // `resolveDocumentArea(entity, row)`, which reads this declaration. A\n // header block is offered and accepted in the header row and nowhere else.\n layout: field.blockTree({\n translatable: true,\n areaFrom: 'placement',\n ...(options?.environment !== undefined && { environment: options.environment }),\n }),\n },\n behaviors: [\n auditable(),\n publishable(),\n versionable({ maxPerDoc: maxVersions }),\n ...(options?.behaviors ?? []),\n ],\n scope: 'global',\n access: {\n view: viewRole,\n // Restricts WHO may create; `placement`'s unique constraint restricts\n // HOW MANY. Both apply, and neither substitutes for the other.\n create: adminRole,\n update: updateRole,\n delete: adminRole,\n },\n admin: {\n icon: 'panel-top',\n description: 'Blocks rendered around every page that does not override them.',\n // `layout` is a blockTree and is excluded from forms by type, so this is\n // only about what the block editor's settings sheet offers. `placement`\n // is deliberately absent: moving a header to the footer slot by editing\n // a dropdown is not an operation anyone wants, and it would collide with\n // the row already holding that placement.\n rootFields: ['label'],\n fieldOverrides: mergeFieldOverrides(options?.admin?.fieldOverrides),\n titleField: 'label',\n group: 'Site',\n // See this file's header — the rows are bootstrapped, not hand-created.\n disableCreate: true,\n },\n })\n}\n\n/**\n * COMPILE-TIME guard for the inferred-return-type note above.\n *\n * It lives in `src/`, NOT in `tests/`, and that placement is the point: this\n * package's tsconfig includes `src` only, so a type-level assertion under\n * `tests/` is compiled by no gate at all — vitest transpiles without checking\n * types. A guard nobody compiles is worse than no guard, because it reads as\n * one.\n *\n * Negative-tested: annotating `siteChromeEntity`'s return as bare `Entity`\n * makes this line fail `tsc --noEmit` with TS2322.\n */\nconst _chromeNameStaysLiteral: 'site_chrome' = siteChromeEntity().name\nvoid _chromeNameStaysLiteral\n"],"mappings":"6IA0GA,MAAa,EAAoB,CAAC,SAAU,QAAQ,EAMvC,EAAqE,CAChF,OAAQ,cACR,OAAQ,aACV,EA0DM,EAA4E,CAChF,MAAO,CACL,MAAO,OACP,YACE,iHACJ,CACF,EAGA,SAAS,EACP,EACkD,CAClD,IAAM,EAA2D,CAAE,GAAG,CAAwB,EAC9F,IAAK,GAAM,CAAC,EAAM,KAAa,OAAO,QAAQ,GAAW,CAAC,CAAC,EACzD,EAAO,GAAQ,CAAE,GAAG,EAAO,GAAO,GAAG,CAAS,EAEhD,OAAO,CACT,CAYA,SAAgB,EAAiB,EAAmC,CAClE,IAAM,EAAW,GAAS,UAAY,SAChC,EAAa,GAAS,YAAc,SACpC,EAAY,GAAS,WAAa,QAClC,EAAc,GAAS,aAAe,GAE5C,OAAO,EAAa,CAClB,KAAM,cAGN,KAAM,QAGN,KAAM,aACN,OAAQ,CAQN,UAAW,EAAM,OAAO,CACtB,QAAS,EACT,SAAU,GACV,OAAQ,EACV,CAAC,EAKD,MAAO,EAAM,KAAK,CAAE,SAAU,EAAK,CAAC,EAiBpC,OAAQ,EAAM,UAAU,CACtB,aAAc,GACd,SAAU,YACV,GAAI,GAAS,cAAgB,IAAA,IAAa,CAAE,YAAa,EAAQ,WAAY,CAC/E,CAAC,CACH,EACA,UAAW,CACT,EAAU,EACV,EAAY,EACZ,EAAY,CAAE,UAAW,CAAY,CAAC,EACtC,GAAI,GAAS,WAAa,CAAC,CAC7B,EACA,MAAO,SACP,OAAQ,CACN,KAAM,EAGN,OAAQ,EACR,OAAQ,EACR,OAAQ,CACV,EACA,MAAO,CACL,KAAM,YACN,YAAa,iEAMb,WAAY,CAAC,OAAO,EACpB,eAAgB,EAAoB,GAAS,OAAO,cAAc,EAClE,WAAY,QACZ,MAAO,OAEP,cAAe,EACjB,CACF,CAAC,CACH,CAc+C,EAAiB,CAAC,CAAC"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as VersionableFields } from "./versionable-
|
|
1
|
+
import { t as VersionableFields } from "./versionable-D2JjselN.mjs";
|
|
2
2
|
import { Behavior, EntityAdminConfig } from "@murumets-ee/entity";
|
|
3
3
|
|
|
4
4
|
//#region src/entities/site-chrome.d.ts
|
|
@@ -95,4 +95,4 @@ declare function siteChromeEntity(options?: SiteChromeEntityOptions): import("@m
|
|
|
95
95
|
}, "site_chrome", [Behavior<import("@murumets-ee/entity").AuditableFields, string>, Behavior<import("@murumets-ee/entity").PublishableFields, "publishable">, Behavior<VersionableFields, string>, ...Behavior<Record<never, never>, string>[]]>;
|
|
96
96
|
//#endregion
|
|
97
97
|
export { siteChromeEntity as a, SiteChromeEntityOptions as i, CHROME_PLACEMENT_LABELS as n, ChromePlacement as r, CHROME_PLACEMENTS as t };
|
|
98
|
-
//# sourceMappingURL=site-chrome-
|
|
98
|
+
//# sourceMappingURL=site-chrome-BiV3zhw0.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"site-chrome-
|
|
1
|
+
{"version":3,"file":"site-chrome-BiV3zhw0.d.mts","names":[],"sources":["../src/entities/site-chrome.ts"],"mappings":";;;;;;;;;;;cA0Ga,iBAAA;;KAGD,eAAA,WAA0B,iBAAiB;;cAG1C,uBAAA,EAAyB,QAAA,CAAS,MAAA,CAAO,eAAA;;UAMrC,uBAAA;;;;;;;;;;EAUf,QAAA;;EAEA,UAAA;;EAEA,SAAA;;;;;;EAMA,WAAA;;EAEA,WAAA;;;;;;;;;;;;;;EAcA,SAAA,YAAqB,QAAA,CAAS,MAAA;;;;;;;;;EAS9B,KAAA;IACE,cAAA,GAAiB,iBAAA;EAAA;AAAA;;;;;;;;;;;iBAsCL,gBAAA,CAAiB,OAAA,GAAU,uBAAA,iCAAuB,MAAA;;sGAAA,iBAAA"}
|