@aglyn/aglyn 1.0.0-beta.217 → 1.0.0-beta.218
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/package.json +14 -14
- package/src/lib/app-utils/admin-audit-index.js +2 -2
- package/src/lib/app-utils/admin-audit-index.js.map +1 -1
- package/src/lib/app-utils/analytics-events.d.ts +10 -1
- package/src/lib/app-utils/analytics-events.js +11 -2
- package/src/lib/app-utils/analytics-events.js.map +1 -1
- package/src/lib/app-utils/binding-tokens.js +1 -1
- package/src/lib/app-utils/binding-tokens.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +4 -4
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/lockdown.d.ts +33 -1
- package/src/lib/app-utils/lockdown.js +45 -4
- package/src/lib/app-utils/lockdown.js.map +1 -1
- package/src/lib/app-utils/marketplace-theme.js +3 -0
- package/src/lib/app-utils/marketplace-theme.js.map +1 -1
- package/src/lib/app-utils/screen-route.d.ts +15 -0
- package/src/lib/app-utils/screen-route.js +20 -0
- package/src/lib/app-utils/screen-route.js.map +1 -1
- package/src/lib/app-utils/starter-templates.d.ts +20 -0
- package/src/lib/app-utils/starter-templates.js +49 -0
- package/src/lib/app-utils/starter-templates.js.map +1 -1
- package/src/lib/canvas-manager/canvas-manager.js +3 -3
- package/src/lib/canvas-manager/canvas-manager.js.map +1 -1
- package/src/lib/foundation/definitions/org-billing.types.d.ts +9 -0
- package/src/lib/foundation/definitions/org-billing.types.js.map +1 -1
- package/src/lib/foundation/definitions/organization.types.d.ts +5 -0
- package/src/lib/foundation/definitions/organization.types.js.map +1 -1
- package/src/lib/foundation/definitions/platform.types.d.ts +16 -0
- package/src/lib/foundation/definitions/platform.types.js.map +1 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.js +1 -4
- package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/marketplace-theme.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {\n HostTheme,\n HostThemeFont,\n HostThemeScheme,\n HostThemeSchemeColors,\n} from '@aglyn/shared-data-types'\nimport {\n diffOverride,\n isEmptyOverride,\n overrideConflicts,\n overridePaths,\n readArtifactOverride,\n resolveOverride,\n type ArtifactOverride,\n} from './marketplace-overrides'\n\n/**\n * Publish-time validation for themes as a marketplace artifact (AGL-1020).\n *\n * A plugin bundle is validated because it is code and the risk is a\n * compromised site. A theme is data, so the risk is different in kind but not\n * in consequence: the failure mode is an unreadable or half-painted site, which\n * a publisher cannot see from their own machine because their fonts are\n * installed and their eyes already know what the text says.\n *\n * Both of the things that go wrong are mechanically checkable, so they are\n * checked here rather than left to review:\n *\n * * **Completeness.** Light and dark are not one palette with a filter. A theme\n * that defines only light is half a theme, and installing it leaves every\n * dark-mode visitor on MUI's stock blue — which reads as the site being\n * broken, not as the theme being partial.\n * * **Contrast.** Text on its background, and label on its button, are the two\n * pairs that make a site unusable when they are wrong. WCAG gives an exact\n * ratio, so \"is this readable\" has an answer rather than an opinion.\n *\n * Errors refuse the publish; warnings are shown and can be published past. The\n * split is deliberate — a 3.9:1 heading is a judgement call a designer may\n * legitimately win, but a theme with no dark scheme is not a complete artifact\n * whoever is asking.\n *\n * Pure and MUI-free so the publish route, the console's pre-flight and the\n * tests all reach the same verdict from the same code.\n */\n\nexport interface ThemeValidationIssue {\n /** Dotted path into the theme, so the editor can focus the offending field. */\n path: string\n message: string\n}\n\nexport interface ThemeValidation {\n /** No errors. Warnings do not block. */\n ok: boolean\n errors: ThemeValidationIssue[]\n warnings: ThemeValidationIssue[]\n}\n\n/**\n * WCAG AA for body text. The same 4.5 the accessibility audit uses, rather\n * than a house number, so a theme that passes here passes there.\n */\nexport const CONTRAST_AA = 4.5\n\n/**\n * WCAG AA for large text (18pt+/14pt bold) and UI component boundaries. Button\n * labels are judged against this rather than 4.5 because they are large, short\n * and centred, and holding them to body-text contrast rejects palettes that are\n * genuinely fine.\n */\nexport const CONTRAST_AA_LARGE = 3\n\nconst SCHEMES: HostThemeScheme[] = ['light', 'dark']\n\n/**\n * Font families that need no loading. Anything else is either declared in the\n * theme's own `fonts` array or will silently fall back to the browser's default\n * — which is the failure the issue calls out, so it is at least a warning.\n */\nconst SYSTEM_FAMILIES = new Set(\n [\n 'system-ui',\n 'sans-serif',\n 'serif',\n 'monospace',\n 'cursive',\n 'fantasy',\n 'ui-sans-serif',\n 'ui-serif',\n 'ui-monospace',\n 'ui-rounded',\n '-apple-system',\n 'blinkmacsystemfont',\n 'segoe ui',\n 'roboto',\n 'helvetica',\n 'helvetica neue',\n 'arial',\n 'georgia',\n 'times new roman',\n 'courier new',\n 'inherit',\n 'initial',\n ].map((family) => family.toLowerCase()),\n)\n\n/**\n * Parses a CSS colour into sRGB channels, or null when it is not something we\n * can reason about.\n *\n * `null` means \"not checked\", never \"failed\": a theme is free to use `color-mix`\n * or a CSS variable, and refusing to publish because this parser is narrower\n * than CSS would be a validator inventing a rule the platform does not have.\n */\nexport function parseColor(\n value: unknown,\n): { r: number; g: number; b: number } | null {\n if (typeof value !== 'string') return null\n const text = value.trim().toLowerCase()\n\n const hex = /^#([0-9a-f]{3,8})$/.exec(text)\n if (hex) {\n const digits = hex[1]\n if (digits.length === 3 || digits.length === 4) {\n return {\n r: parseInt(digits[0] + digits[0], 16),\n g: parseInt(digits[1] + digits[1], 16),\n b: parseInt(digits[2] + digits[2], 16),\n }\n }\n if (digits.length === 6 || digits.length === 8) {\n return {\n r: parseInt(digits.slice(0, 2), 16),\n g: parseInt(digits.slice(2, 4), 16),\n b: parseInt(digits.slice(4, 6), 16),\n }\n }\n return null\n }\n\n const rgb = /^rgba?\\(([^)]+)\\)$/.exec(text)\n if (rgb) {\n const parts = rgb[1].split(/[,/\\s]+/).filter(Boolean)\n if (parts.length < 3) return null\n const channels = parts.slice(0, 3).map((part) =>\n part.endsWith('%')\n ? (Number(part.slice(0, -1)) / 100) * 255\n : Number(part),\n )\n if (channels.some((channel) => !Number.isFinite(channel))) return null\n const [r, g, b] = channels\n return { r, g, b }\n }\n\n if (text === 'white') return { r: 255, g: 255, b: 255 }\n if (text === 'black') return { r: 0, g: 0, b: 0 }\n return null\n}\n\n/** WCAG relative luminance. */\nfunction luminance(channels: { r: number; g: number; b: number }): number {\n const channel = (raw: number) => {\n const value = Math.min(255, Math.max(0, raw)) / 255\n return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4\n }\n return (\n 0.2126 * channel(channels.r) +\n 0.7152 * channel(channels.g) +\n 0.0722 * channel(channels.b)\n )\n}\n\n/**\n * WCAG contrast ratio between two colours, or null when either is unparseable.\n * Ranges 1 (identical) to 21 (black on white).\n */\nexport function contrastRatio(a: unknown, b: unknown): number | null {\n const first = parseColor(a)\n const second = parseColor(b)\n if (!first || !second) return null\n const light = Math.max(luminance(first), luminance(second))\n const dark = Math.min(luminance(first), luminance(second))\n return (light + 0.05) / (dark + 0.05)\n}\n\nconst round = (ratio: number) => Math.round(ratio * 10) / 10\n\n/**\n * The colours a scheme must actually define to be usable on its own.\n *\n * Deliberately short. MUI derives an enormous amount from very little, and\n * demanding a full palette would reject good themes; but a scheme that names no\n * primary, no background and no text has not expressed an identity at all — it\n * is an empty object that happens to have a key.\n */\nfunction validateScheme(\n scheme: HostThemeScheme,\n colors: HostThemeSchemeColors | undefined,\n errors: ThemeValidationIssue[],\n warnings: ThemeValidationIssue[],\n): void {\n const at = `colorSchemes.${scheme}`\n if (!colors || !Object.keys(colors).length) {\n errors.push({\n path: at,\n message:\n `This theme defines no ${scheme} scheme. Light and dark are not one ` +\n 'palette with a filter — install it and every visitor in ' +\n `${scheme} mode sees the stock palette instead of your design.`,\n })\n return\n }\n if (!colors.primary?.main) {\n errors.push({\n path: `${at}.primary.main`,\n message: `The ${scheme} scheme has no primary color.`,\n })\n }\n\n const background = colors.background?.default\n const paper = colors.background?.paper\n const text = colors.text?.primary\n const secondaryText = colors.text?.secondary\n\n // Text on background is the pair that decides whether a site can be read at\n // all, so it is the one contrast failure that refuses the publish.\n const bodyContrast = contrastRatio(text, background)\n if (bodyContrast != null && bodyContrast < CONTRAST_AA) {\n errors.push({\n path: `${at}.text.primary`,\n message:\n `Body text on the ${scheme} background is ${round(bodyContrast)}:1, ` +\n `below the ${CONTRAST_AA}:1 minimum for readable text.`,\n })\n }\n\n const paperContrast = contrastRatio(text, paper)\n if (paperContrast != null && paperContrast < CONTRAST_AA) {\n errors.push({\n path: `${at}.background.paper`,\n message:\n `Body text on ${scheme} cards and menus is ${round(paperContrast)}:1, ` +\n `below the ${CONTRAST_AA}:1 minimum. Surfaces inherit this everywhere ` +\n 'a Paper is used, which is most of the site.',\n })\n }\n\n // Secondary text is legitimately dimmer by design, so it warns rather than\n // refuses — but silently shipping unreadable captions is worse than saying so.\n const captionContrast = contrastRatio(secondaryText, background)\n if (captionContrast != null && captionContrast < CONTRAST_AA) {\n warnings.push({\n path: `${at}.text.secondary`,\n message:\n `Secondary text on the ${scheme} background is ` +\n `${round(captionContrast)}:1. Captions and helper text will be hard ` +\n 'to read.',\n })\n }\n\n // A button whose label disappears into its own fill. Only checkable when the\n // theme states contrastText — otherwise MUI derives one, and derives it well.\n for (const key of ['primary', 'secondary', 'error'] as const) {\n const color = colors[key]\n const ratio = contrastRatio(color?.contrastText, color?.main)\n if (ratio != null && ratio < CONTRAST_AA_LARGE) {\n warnings.push({\n path: `${at}.${key}.contrastText`,\n message:\n `The ${scheme} ${key} button label is ${round(ratio)}:1 against its ` +\n `own fill, below ${CONTRAST_AA_LARGE}:1. Leave contrast text unset ` +\n 'to let the theme derive a readable one.',\n })\n }\n }\n}\n\n/**\n * Every family named in the typography, split out of its CSS stack.\n *\n * A stack is a fallback chain, so only the FIRST entry is the one the design\n * depends on; the rest exist precisely to be substituted and warning about them\n * would be noise.\n */\nfunction declaredFamilies(theme: HostTheme): string[] {\n const stacks: string[] = []\n if (theme.typography?.fontFamily) stacks.push(theme.typography.fontFamily)\n for (const variant of Object.values(theme.typography?.variants ?? {})) {\n if (variant?.fontFamily) stacks.push(variant.fontFamily)\n }\n const primary: string[] = []\n for (const stack of stacks) {\n const first = stack.split(',')[0]?.trim().replace(/^[\"']|[\"']$/g, '')\n if (first) primary.push(first)\n }\n return primary\n}\n\nfunction validateFonts(\n theme: HostTheme,\n warnings: ThemeValidationIssue[],\n): void {\n const loadable = new Set(\n (theme.fonts ?? [])\n .map((font: HostThemeFont) => font.family?.trim().toLowerCase())\n .filter(Boolean) as string[],\n )\n const seen = new Set<string>()\n for (const family of declaredFamilies(theme)) {\n const key = family.toLowerCase()\n if (seen.has(key) || loadable.has(key) || SYSTEM_FAMILIES.has(key)) continue\n seen.add(key)\n warnings.push({\n path: 'typography.fontFamily',\n message:\n `\"${family}\" is used but not listed in the theme's fonts, so it will ` +\n 'only appear on machines that happen to have it installed. Add it as ' +\n 'a Google font, or fall back to a system family.',\n })\n }\n}\n\n/**\n * Validates a theme for publishing.\n *\n * The same function backs the console's pre-flight and the publish route: a\n * publisher who is told \"this will be refused\" and then has it accepted (or the\n * reverse) stops trusting either, so there is one verdict from one place.\n */\nexport function validateThemeForPublish(\n theme: HostTheme | null | undefined,\n): ThemeValidation {\n const errors: ThemeValidationIssue[] = []\n const warnings: ThemeValidationIssue[] = []\n\n if (!theme || !Object.keys(theme).length) {\n return {\n ok: false,\n errors: [\n {\n path: '',\n message:\n 'This site has no theme customisations yet, so there is nothing ' +\n 'to publish.',\n },\n ],\n warnings,\n }\n }\n\n for (const scheme of SCHEMES) {\n validateScheme(scheme, theme.colorSchemes?.[scheme], errors, warnings)\n }\n validateFonts(theme, warnings)\n\n return { ok: !errors.length, errors, warnings }\n}\n\n/**\n * Every field of a `HostTheme` that is part of the published artifact.\n *\n * Typed as a total record over `keyof HostTheme` on purpose: adding a field to\n * `HostTheme` fails to compile here until someone decides whether it ships. The\n * alternative — a hand-written list of `if` statements — silently drops the new\n * field, and a theme that loses one is not obviously broken, it is subtly\n * wrong. `mixins` arrived exactly that way (AGL-1242, toolbar heights) while\n * this was being written, which is why the check is a type and not a comment.\n *\n * A `false` means \"host-local, never published\". `darkScheme` is the one such\n * field: whether visitors get a dark scheme is a decision about a SITE's\n * content (does it read in dark?), not about the design being published, so a\n * publisher's opt-out must not travel with the theme and an installer's must\n * not read as a divergence from it.\n */\nconst THEME_ARTIFACT_FIELDS: Record<keyof Required<HostTheme>, boolean> = {\n colorSchemes: true,\n typography: true,\n fonts: true,\n shape: true,\n spacing: true,\n mixins: true,\n components: true,\n darkScheme: false,\n}\n\n/**\n * The theme content as published — the artifact and nothing else.\n *\n * What goes in here is what \"reset to the publisher's version\" restores and\n * what the update diff compares against, so it must hold no per-install data:\n * a host document also carries `updatedAt`, `updatedBy` and whatever else the\n * editor writes alongside, and any of those in the content would read as a user\n * edit on the next diff.\n *\n * Empty branches are dropped so two themes that render identically also hash\n * identically — `{fonts: []}` and `{}` must not produce two base snapshots.\n */\nexport function themeArtifactContent(\n theme: HostTheme | null | undefined,\n): HostTheme {\n const content: Record<string, unknown> = {}\n if (!theme) return content\n for (const [key, ships] of Object.entries(THEME_ARTIFACT_FIELDS)) {\n if (!ships) continue\n const value = (theme as Record<string, unknown>)[key]\n if (value == null) continue\n if (Array.isArray(value) ? !value.length : typeof value === 'object'\n ? !Object.keys(value as object).length\n : false) {\n continue\n }\n content[key] = value\n }\n return content\n}\n\n/**\n * A short, human summary of what a theme actually contains, for the install\n * confirmation and the listing card.\n *\n * Installing a theme replaces a site's visual identity, so the confirmation has\n * to say what is about to change in terms someone recognises — \"both schemes,\n * 2 fonts, 4 component styles\" — rather than showing them a JSON blob and\n * asking them to agree to it.\n */\nexport function describeTheme(theme: HostTheme | null | undefined): string[] {\n if (!theme) return []\n const parts: string[] = []\n const schemes = SCHEMES.filter(\n (scheme) => Object.keys(theme.colorSchemes?.[scheme] ?? {}).length,\n )\n if (schemes.length === 2) parts.push('Light and dark schemes')\n else if (schemes.length === 1) parts.push(`${schemes[0]} scheme only`)\n\n const fonts = theme.fonts?.length ?? 0\n if (fonts) parts.push(`${fonts} font${fonts === 1 ? '' : 's'}`)\n if (theme.typography?.variants) {\n const count = Object.keys(theme.typography.variants).length\n if (count) parts.push(`${count} text style${count === 1 ? '' : 's'}`)\n }\n if (theme.shape?.borderRadius != null) {\n parts.push(`${theme.shape.borderRadius}px corners`)\n }\n if (typeof theme.spacing === 'number') parts.push(`${theme.spacing}px spacing`)\n const components = Object.keys(theme.components ?? {}).length\n if (components) {\n parts.push(`${components} component style${components === 1 ? '' : 's'}`)\n }\n return parts\n}\n\n/* ------------------------------------------------------------------------ *\n * The override layer, applied to themes (AGL-1021)\n * ------------------------------------------------------------------------ */\n\n/**\n * The field on a host document holding the site's theme override.\n *\n * A theme is a FIELD on the host, not a document of its own, so it cannot use\n * `ARTIFACT_OVERRIDE_FIELD` — a host may one day override more than one thing,\n * and a bare `overrides` would be the first to claim the name. The override\n * layer is general precisely so a namespaced field works with it unchanged.\n */\nexport const THEME_OVERRIDE_FIELD = 'themeOverride'\n\n/** A host document, as far as the theme layer is concerned. */\nexport interface ThemeHostDocument {\n theme?: HostTheme | null\n themeOverride?: unknown\n themeInstalledFrom?: { sha256?: string | null; listingId?: string } | null\n /** The library theme the site picked (AGL-3404) — see `theme-library`. */\n themeSelection?: unknown\n}\n\n/** Reads the stored theme override off a host document, tolerating junk. */\nexport function readThemeOverride(\n host: ThemeHostDocument | null | undefined,\n): ArtifactOverride | undefined {\n // The override layer's reader is keyed to its own field name, so hand it a\n // shim rather than duplicating its validation — which is where the \"a\n // top-level null blanks the artifact\" guard lives.\n return readArtifactOverride({ overrides: host?.themeOverride })\n}\n\n/**\n * The site's effective theme: the marketplace theme (or the site's own) with\n * this site's overrides resolved over it.\n *\n * This is the MIDDLE and TOP of the three layers the issue describes. The\n * bottom — the platform default — is applied further down by\n * `HostThemeProvider`, which layers whatever this returns onto the brand base\n * (AGL-1180) so a theme that omits a value still yields a complete theme\n * rather than an undefined-shaped hole. Doing it there rather than here is not\n * a detail: the default is a runtime MUI theme with FUNCTION style overrides\n * that JSON cannot represent, so a \"default\" merged at this layer would be a\n * lossy copy of the real one.\n *\n * Every surface that renders a site's appearance calls this — the tenant, all\n * four besigner editors, and the document preview — because a theme that\n * resolves differently in the editor than on the site is worse than one that\n * does not resolve at all.\n */\nexport function resolveSiteTheme(\n host: ThemeHostDocument | null | undefined,\n): HostTheme | undefined {\n const base = host?.theme ?? undefined\n const override = readThemeOverride(host)\n if (!override) return base\n return resolveOverride<HostTheme>(base ?? {}, override.patch)\n}\n\n/**\n * Where a site's theme came from (AGL-2938).\n *\n * - `installed` — a marketplace theme. The site's edits are its override\n * patch over the publisher's version, which stays untouched and can still\n * take an update.\n * - `preset` — a built-in theme a plugin contributes (AGL-3404).\n * - `custom` — one of the site's own themes.\n * - `default` — the platform default.\n *\n * Whichever it is, an edit is an override patch over the picked theme and the\n * theme itself is never rewritten (AGL-3404), so every source can be restored.\n *\n * One reading for every surface that needs it — the page that lays out the\n * Theme section, a widget proposing a change there, and a generator deciding\n * what its change will be stored as — so none of them can file a site under\n * a different source than the save path does.\n */\nexport type HostThemeSource = 'installed' | 'preset' | 'custom' | 'default'\n\nconst THEME_SOURCES: readonly HostThemeSource[] = [\n 'installed',\n 'preset',\n 'custom',\n 'default',\n]\n\nexport function hostThemeSource(\n host: ThemeHostDocument | null | undefined,\n): HostThemeSource {\n const picked = (host?.themeSelection as { kind?: unknown } | null | undefined)\n ?.kind\n if (THEME_SOURCES.includes(picked as HostThemeSource)) {\n return picked as HostThemeSource\n }\n if (host?.themeInstalledFrom?.listingId) return 'installed'\n return host?.theme && Object.keys(host.theme).length ? 'custom' : 'default'\n}\n\n/**\n * Was this override authored against the theme currently installed?\n *\n * False after a theme swap, which is the whole reason `baseSha256` is recorded.\n * Overrides deliberately SURVIVE a swap (the issue's call, and the right one —\n * a brand colour still expresses intent when the theme underneath it changes)\n * but surviving silently is what would surprise people, so every surface that\n * shows an override can ask this and say so.\n *\n * True when there is no override, and when either side has no hash to compare:\n * \"we cannot tell\" must not render as \"these are stale\".\n */\nexport function isOverrideForCurrentTheme(\n host: ThemeHostDocument | null | undefined,\n): boolean {\n const override = readThemeOverride(host)\n if (!override?.baseSha256) return true\n const installed = host?.themeInstalledFrom?.sha256\n if (!installed) return true\n return override.baseSha256 === installed\n}\n\n/** One row of the \"what have I changed?\" view. */\nexport interface ThemeOverrideEntry {\n /** Dotted path into the theme, e.g. `colorSchemes.dark.primary.main`. */\n path: string\n /** A human label for the path, for a UI that is not a JSON viewer. */\n label: string\n /** Which scheme this touches, when it touches one. */\n scheme?: HostThemeScheme\n /** The publisher's value, or undefined when the override adds the field. */\n themeValue: unknown\n /** What this site set it to, or undefined when the override removes it. */\n overrideValue: unknown\n}\n\nconst SEGMENT_LABELS: Record<string, string> = {\n darkScheme: 'Dark scheme',\n colorSchemes: 'Color',\n typography: 'Typography',\n components: 'Component',\n shape: 'Shape',\n spacing: 'Spacing',\n fonts: 'Fonts',\n mixins: 'Layout',\n variants: '',\n background: 'background',\n text: 'text',\n main: '',\n contrastText: 'label color',\n borderRadius: 'corner radius',\n fontFamily: 'font',\n}\n\n/**\n * A readable label for a theme path.\n *\n * \"Colour · dark · primary\" beats `colorSchemes.dark.primary.main`, and the\n * difference matters because this list is the answer to \"what have I changed?\"\n * — a question nobody asks wanting to read JSON paths.\n */\nexport function describeThemePath(path: string): string {\n const segments = path.split('.').filter(Boolean)\n const words: string[] = []\n for (const segment of segments) {\n const mapped = SEGMENT_LABELS[segment]\n if (mapped === '') continue\n words.push(mapped ?? segment)\n }\n return words.join(' · ') || 'the whole theme'\n}\n\n/**\n * \"What have I changed?\" — the patch, rendered.\n *\n * Literally a read of the stored override rather than a diff computed at call\n * time, which is the property the whole layer exists to provide: the answer is\n * the thing that was saved, so it cannot disagree with what will be applied.\n */\nexport function describeThemeOverride(\n host: ThemeHostDocument | null | undefined,\n): ThemeOverrideEntry[] {\n const override = readThemeOverride(host)\n if (!override) return []\n const base = host?.theme ?? {}\n const resolved = resolveOverride<HostTheme>(base, override.patch)\n return overridePaths(override.patch).map((path) => {\n const segments = path.split('.')\n const scheme =\n segments[0] === 'colorSchemes' &&\n (segments[1] === 'light' || segments[1] === 'dark')\n ? (segments[1] as HostThemeScheme)\n : undefined\n return {\n path,\n label: describeThemePath(path),\n scheme,\n themeValue: readThemePath(base, path),\n overrideValue: readThemePath(resolved, path),\n }\n })\n}\n\n/** Reads a dotted path out of a theme. */\nfunction readThemePath(theme: unknown, path: string): unknown {\n let cursor: unknown = theme\n for (const segment of path.split('.')) {\n if (cursor == null || typeof cursor !== 'object') return undefined\n cursor = (cursor as Record<string, unknown>)[segment]\n }\n return cursor\n}\n\n/**\n * The override this site should store after the theme editor produced `edited`.\n *\n * The editor edits the RESOLVED theme — that is what it renders and what the\n * user sees — so the patch is the difference between the publisher's base and\n * what came back. Diffing here rather than tracking per-field dirty state means\n * a field the user set and then set back to the theme's value stops being an\n * override, which is what \"reset\" means without needing a reset button to have\n * been pressed.\n */\nexport function themeOverridePatch(\n host: ThemeHostDocument | null | undefined,\n edited: HostTheme,\n): unknown {\n return diffOverride(host?.theme ?? {}, edited)\n}\n\n/**\n * Where this site's overrides sit on top of values a theme update also changes\n * — the only place a conflict can exist.\n *\n * Narrow by construction: an override touching four paths can conflict in at\n * most four places however much of the theme the publisher rewrote. That is the\n * difference between \"the publisher also changed your heading colour, whose\n * wins?\" and \"this update rewrites your theme, continue?\".\n */\nexport function themeUpdateConflicts(\n host: ThemeHostDocument | null | undefined,\n incoming: HostTheme,\n): ThemeOverrideEntry[] {\n const override = readThemeOverride(host)\n if (!override || isEmptyOverride(override.patch)) return []\n const base = host?.theme ?? {}\n const resolved = resolveOverride<HostTheme>(base, override.patch)\n return overrideConflicts(base, override.patch, incoming).map((path) => ({\n path,\n label: describeThemePath(path),\n scheme:\n path.startsWith('colorSchemes.light.')\n ? ('light' as const)\n : path.startsWith('colorSchemes.dark.')\n ? ('dark' as const)\n : undefined,\n themeValue: readThemePath(incoming, path),\n overrideValue: readThemePath(resolved, path),\n }))\n}\n"],"names":["diffOverride","isEmptyOverride","overrideConflicts","overridePaths","readArtifactOverride","resolveOverride","CONTRAST_AA","CONTRAST_AA_LARGE","SCHEMES","SYSTEM_FAMILIES","Set","map","family","toLowerCase","parseColor","value","text","trim","hex","exec","digits","length","r","parseInt","g","b","slice","rgb","parts","split","filter","Boolean","channels","part","endsWith","Number","some","channel","isFinite","luminance","raw","Math","min","max","contrastRatio","a","first","second","light","dark","round","ratio","validateScheme","scheme","colors","errors","warnings","at","Object","keys","push","path","message","primary","main","background","default","paper","secondaryText","secondary","bodyContrast","paperContrast","captionContrast","key","color","contrastText","declaredFamilies","theme","stacks","typography","fontFamily","variant","values","variants","stack","replace","validateFonts","loadable","fonts","font","seen","has","add","validateThemeForPublish","ok","colorSchemes","THEME_ARTIFACT_FIELDS","shape","spacing","mixins","components","darkScheme","themeArtifactContent","content","ships","entries","Array","isArray","describeTheme","schemes","count","borderRadius","THEME_OVERRIDE_FIELD","readThemeOverride","host","overrides","themeOverride","resolveSiteTheme","base","undefined","override","patch","THEME_SOURCES","hostThemeSource","picked","themeSelection","kind","includes","themeInstalledFrom","listingId","isOverrideForCurrentTheme","baseSha256","installed","sha256","SEGMENT_LABELS","describeThemePath","segments","words","segment","mapped","join","describeThemeOverride","resolved","label","themeValue","readThemePath","overrideValue","cursor","themeOverridePatch","edited","themeUpdateConflicts","incoming","startsWith"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/marketplace-theme.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {\n HostTheme,\n HostThemeFont,\n HostThemeScheme,\n HostThemeSchemeColors,\n} from '@aglyn/shared-data-types'\nimport {\n diffOverride,\n isEmptyOverride,\n overrideConflicts,\n overridePaths,\n readArtifactOverride,\n resolveOverride,\n type ArtifactOverride,\n} from './marketplace-overrides'\n\n/**\n * Publish-time validation for themes as a marketplace artifact (AGL-1020).\n *\n * A plugin bundle is validated because it is code and the risk is a\n * compromised site. A theme is data, so the risk is different in kind but not\n * in consequence: the failure mode is an unreadable or half-painted site, which\n * a publisher cannot see from their own machine because their fonts are\n * installed and their eyes already know what the text says.\n *\n * Both of the things that go wrong are mechanically checkable, so they are\n * checked here rather than left to review:\n *\n * * **Completeness.** Light and dark are not one palette with a filter. A theme\n * that defines only light is half a theme, and installing it leaves every\n * dark-mode visitor on MUI's stock blue — which reads as the site being\n * broken, not as the theme being partial.\n * * **Contrast.** Text on its background, and label on its button, are the two\n * pairs that make a site unusable when they are wrong. WCAG gives an exact\n * ratio, so \"is this readable\" has an answer rather than an opinion.\n *\n * Errors refuse the publish; warnings are shown and can be published past. The\n * split is deliberate — a 3.9:1 heading is a judgement call a designer may\n * legitimately win, but a theme with no dark scheme is not a complete artifact\n * whoever is asking.\n *\n * Pure and MUI-free so the publish route, the console's pre-flight and the\n * tests all reach the same verdict from the same code.\n */\n\nexport interface ThemeValidationIssue {\n /** Dotted path into the theme, so the editor can focus the offending field. */\n path: string\n message: string\n}\n\nexport interface ThemeValidation {\n /** No errors. Warnings do not block. */\n ok: boolean\n errors: ThemeValidationIssue[]\n warnings: ThemeValidationIssue[]\n}\n\n/**\n * WCAG AA for body text. The same 4.5 the accessibility audit uses, rather\n * than a house number, so a theme that passes here passes there.\n */\nexport const CONTRAST_AA = 4.5\n\n/**\n * WCAG AA for large text (18pt+/14pt bold) and UI component boundaries. Button\n * labels are judged against this rather than 4.5 because they are large, short\n * and centred, and holding them to body-text contrast rejects palettes that are\n * genuinely fine.\n */\nexport const CONTRAST_AA_LARGE = 3\n\nconst SCHEMES: HostThemeScheme[] = ['light', 'dark']\n\n/**\n * Font families that need no loading. Anything else is either declared in the\n * theme's own `fonts` array or will silently fall back to the browser's default\n * — which is the failure the issue calls out, so it is at least a warning.\n */\nconst SYSTEM_FAMILIES = new Set(\n [\n 'system-ui',\n 'sans-serif',\n 'serif',\n 'monospace',\n 'cursive',\n 'fantasy',\n 'ui-sans-serif',\n 'ui-serif',\n 'ui-monospace',\n 'ui-rounded',\n '-apple-system',\n 'blinkmacsystemfont',\n 'segoe ui',\n 'roboto',\n 'helvetica',\n 'helvetica neue',\n 'arial',\n 'georgia',\n 'times new roman',\n 'courier new',\n 'inherit',\n 'initial',\n ].map((family) => family.toLowerCase()),\n)\n\n/**\n * Parses a CSS colour into sRGB channels, or null when it is not something we\n * can reason about.\n *\n * `null` means \"not checked\", never \"failed\": a theme is free to use `color-mix`\n * or a CSS variable, and refusing to publish because this parser is narrower\n * than CSS would be a validator inventing a rule the platform does not have.\n */\nexport function parseColor(\n value: unknown,\n): { r: number; g: number; b: number } | null {\n if (typeof value !== 'string') return null\n const text = value.trim().toLowerCase()\n\n const hex = /^#([0-9a-f]{3,8})$/.exec(text)\n if (hex) {\n const digits = hex[1]\n if (digits.length === 3 || digits.length === 4) {\n return {\n r: parseInt(digits[0] + digits[0], 16),\n g: parseInt(digits[1] + digits[1], 16),\n b: parseInt(digits[2] + digits[2], 16),\n }\n }\n if (digits.length === 6 || digits.length === 8) {\n return {\n r: parseInt(digits.slice(0, 2), 16),\n g: parseInt(digits.slice(2, 4), 16),\n b: parseInt(digits.slice(4, 6), 16),\n }\n }\n return null\n }\n\n const rgb = /^rgba?\\(([^)]+)\\)$/.exec(text)\n if (rgb) {\n const parts = rgb[1].split(/[,/\\s]+/).filter(Boolean)\n if (parts.length < 3) return null\n const channels = parts.slice(0, 3).map((part) =>\n part.endsWith('%')\n ? (Number(part.slice(0, -1)) / 100) * 255\n : Number(part),\n )\n if (channels.some((channel) => !Number.isFinite(channel))) return null\n const [r, g, b] = channels\n return { r, g, b }\n }\n\n if (text === 'white') return { r: 255, g: 255, b: 255 }\n if (text === 'black') return { r: 0, g: 0, b: 0 }\n return null\n}\n\n/** WCAG relative luminance. */\nfunction luminance(channels: { r: number; g: number; b: number }): number {\n const channel = (raw: number) => {\n const value = Math.min(255, Math.max(0, raw)) / 255\n return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4\n }\n return (\n 0.2126 * channel(channels.r) +\n 0.7152 * channel(channels.g) +\n 0.0722 * channel(channels.b)\n )\n}\n\n/**\n * WCAG contrast ratio between two colours, or null when either is unparseable.\n * Ranges 1 (identical) to 21 (black on white).\n */\nexport function contrastRatio(a: unknown, b: unknown): number | null {\n const first = parseColor(a)\n const second = parseColor(b)\n if (!first || !second) return null\n const light = Math.max(luminance(first), luminance(second))\n const dark = Math.min(luminance(first), luminance(second))\n return (light + 0.05) / (dark + 0.05)\n}\n\nconst round = (ratio: number) => Math.round(ratio * 10) / 10\n\n/**\n * The colours a scheme must actually define to be usable on its own.\n *\n * Deliberately short. MUI derives an enormous amount from very little, and\n * demanding a full palette would reject good themes; but a scheme that names no\n * primary, no background and no text has not expressed an identity at all — it\n * is an empty object that happens to have a key.\n */\nfunction validateScheme(\n scheme: HostThemeScheme,\n colors: HostThemeSchemeColors | undefined,\n errors: ThemeValidationIssue[],\n warnings: ThemeValidationIssue[],\n): void {\n const at = `colorSchemes.${scheme}`\n if (!colors || !Object.keys(colors).length) {\n errors.push({\n path: at,\n message:\n `This theme defines no ${scheme} scheme. Light and dark are not one ` +\n 'palette with a filter — install it and every visitor in ' +\n `${scheme} mode sees the stock palette instead of your design.`,\n })\n return\n }\n if (!colors.primary?.main) {\n errors.push({\n path: `${at}.primary.main`,\n message: `The ${scheme} scheme has no primary color.`,\n })\n }\n\n const background = colors.background?.default\n const paper = colors.background?.paper\n const text = colors.text?.primary\n const secondaryText = colors.text?.secondary\n\n // Text on background is the pair that decides whether a site can be read at\n // all, so it is the one contrast failure that refuses the publish.\n const bodyContrast = contrastRatio(text, background)\n if (bodyContrast != null && bodyContrast < CONTRAST_AA) {\n errors.push({\n path: `${at}.text.primary`,\n message:\n `Body text on the ${scheme} background is ${round(bodyContrast)}:1, ` +\n `below the ${CONTRAST_AA}:1 minimum for readable text.`,\n })\n }\n\n const paperContrast = contrastRatio(text, paper)\n if (paperContrast != null && paperContrast < CONTRAST_AA) {\n errors.push({\n path: `${at}.background.paper`,\n message:\n `Body text on ${scheme} cards and menus is ${round(paperContrast)}:1, ` +\n `below the ${CONTRAST_AA}:1 minimum. Surfaces inherit this everywhere ` +\n 'a Paper is used, which is most of the site.',\n })\n }\n\n // Secondary text is legitimately dimmer by design, so it warns rather than\n // refuses — but silently shipping unreadable captions is worse than saying so.\n const captionContrast = contrastRatio(secondaryText, background)\n if (captionContrast != null && captionContrast < CONTRAST_AA) {\n warnings.push({\n path: `${at}.text.secondary`,\n message:\n `Secondary text on the ${scheme} background is ` +\n `${round(captionContrast)}:1. Captions and helper text will be hard ` +\n 'to read.',\n })\n }\n\n // A button whose label disappears into its own fill. Only checkable when the\n // theme states contrastText — otherwise MUI derives one, and derives it well.\n // Every accent a button can be filled with. Tertiary is one (AGL-3411):\n // `<Button color=\"tertiary\">` is offered wherever primary and secondary are.\n for (const key of ['primary', 'secondary', 'tertiary', 'error'] as const) {\n const color = colors[key]\n const ratio = contrastRatio(color?.contrastText, color?.main)\n if (ratio != null && ratio < CONTRAST_AA_LARGE) {\n warnings.push({\n path: `${at}.${key}.contrastText`,\n message:\n `The ${scheme} ${key} button label is ${round(ratio)}:1 against its ` +\n `own fill, below ${CONTRAST_AA_LARGE}:1. Leave contrast text unset ` +\n 'to let the theme derive a readable one.',\n })\n }\n }\n}\n\n/**\n * Every family named in the typography, split out of its CSS stack.\n *\n * A stack is a fallback chain, so only the FIRST entry is the one the design\n * depends on; the rest exist precisely to be substituted and warning about them\n * would be noise.\n */\nfunction declaredFamilies(theme: HostTheme): string[] {\n const stacks: string[] = []\n if (theme.typography?.fontFamily) stacks.push(theme.typography.fontFamily)\n for (const variant of Object.values(theme.typography?.variants ?? {})) {\n if (variant?.fontFamily) stacks.push(variant.fontFamily)\n }\n const primary: string[] = []\n for (const stack of stacks) {\n const first = stack.split(',')[0]?.trim().replace(/^[\"']|[\"']$/g, '')\n if (first) primary.push(first)\n }\n return primary\n}\n\nfunction validateFonts(\n theme: HostTheme,\n warnings: ThemeValidationIssue[],\n): void {\n const loadable = new Set(\n (theme.fonts ?? [])\n .map((font: HostThemeFont) => font.family?.trim().toLowerCase())\n .filter(Boolean) as string[],\n )\n const seen = new Set<string>()\n for (const family of declaredFamilies(theme)) {\n const key = family.toLowerCase()\n if (seen.has(key) || loadable.has(key) || SYSTEM_FAMILIES.has(key)) continue\n seen.add(key)\n warnings.push({\n path: 'typography.fontFamily',\n message:\n `\"${family}\" is used but not listed in the theme's fonts, so it will ` +\n 'only appear on machines that happen to have it installed. Add it as ' +\n 'a Google font, or fall back to a system family.',\n })\n }\n}\n\n/**\n * Validates a theme for publishing.\n *\n * The same function backs the console's pre-flight and the publish route: a\n * publisher who is told \"this will be refused\" and then has it accepted (or the\n * reverse) stops trusting either, so there is one verdict from one place.\n */\nexport function validateThemeForPublish(\n theme: HostTheme | null | undefined,\n): ThemeValidation {\n const errors: ThemeValidationIssue[] = []\n const warnings: ThemeValidationIssue[] = []\n\n if (!theme || !Object.keys(theme).length) {\n return {\n ok: false,\n errors: [\n {\n path: '',\n message:\n 'This site has no theme customisations yet, so there is nothing ' +\n 'to publish.',\n },\n ],\n warnings,\n }\n }\n\n for (const scheme of SCHEMES) {\n validateScheme(scheme, theme.colorSchemes?.[scheme], errors, warnings)\n }\n validateFonts(theme, warnings)\n\n return { ok: !errors.length, errors, warnings }\n}\n\n/**\n * Every field of a `HostTheme` that is part of the published artifact.\n *\n * Typed as a total record over `keyof HostTheme` on purpose: adding a field to\n * `HostTheme` fails to compile here until someone decides whether it ships. The\n * alternative — a hand-written list of `if` statements — silently drops the new\n * field, and a theme that loses one is not obviously broken, it is subtly\n * wrong. `mixins` arrived exactly that way (AGL-1242, toolbar heights) while\n * this was being written, which is why the check is a type and not a comment.\n *\n * A `false` means \"host-local, never published\". `darkScheme` is the one such\n * field: whether visitors get a dark scheme is a decision about a SITE's\n * content (does it read in dark?), not about the design being published, so a\n * publisher's opt-out must not travel with the theme and an installer's must\n * not read as a divergence from it.\n */\nconst THEME_ARTIFACT_FIELDS: Record<keyof Required<HostTheme>, boolean> = {\n colorSchemes: true,\n typography: true,\n fonts: true,\n shape: true,\n spacing: true,\n mixins: true,\n components: true,\n darkScheme: false,\n}\n\n/**\n * The theme content as published — the artifact and nothing else.\n *\n * What goes in here is what \"reset to the publisher's version\" restores and\n * what the update diff compares against, so it must hold no per-install data:\n * a host document also carries `updatedAt`, `updatedBy` and whatever else the\n * editor writes alongside, and any of those in the content would read as a user\n * edit on the next diff.\n *\n * Empty branches are dropped so two themes that render identically also hash\n * identically — `{fonts: []}` and `{}` must not produce two base snapshots.\n */\nexport function themeArtifactContent(\n theme: HostTheme | null | undefined,\n): HostTheme {\n const content: Record<string, unknown> = {}\n if (!theme) return content\n for (const [key, ships] of Object.entries(THEME_ARTIFACT_FIELDS)) {\n if (!ships) continue\n const value = (theme as Record<string, unknown>)[key]\n if (value == null) continue\n if (Array.isArray(value) ? !value.length : typeof value === 'object'\n ? !Object.keys(value as object).length\n : false) {\n continue\n }\n content[key] = value\n }\n return content\n}\n\n/**\n * A short, human summary of what a theme actually contains, for the install\n * confirmation and the listing card.\n *\n * Installing a theme replaces a site's visual identity, so the confirmation has\n * to say what is about to change in terms someone recognises — \"both schemes,\n * 2 fonts, 4 component styles\" — rather than showing them a JSON blob and\n * asking them to agree to it.\n */\nexport function describeTheme(theme: HostTheme | null | undefined): string[] {\n if (!theme) return []\n const parts: string[] = []\n const schemes = SCHEMES.filter(\n (scheme) => Object.keys(theme.colorSchemes?.[scheme] ?? {}).length,\n )\n if (schemes.length === 2) parts.push('Light and dark schemes')\n else if (schemes.length === 1) parts.push(`${schemes[0]} scheme only`)\n\n const fonts = theme.fonts?.length ?? 0\n if (fonts) parts.push(`${fonts} font${fonts === 1 ? '' : 's'}`)\n if (theme.typography?.variants) {\n const count = Object.keys(theme.typography.variants).length\n if (count) parts.push(`${count} text style${count === 1 ? '' : 's'}`)\n }\n if (theme.shape?.borderRadius != null) {\n parts.push(`${theme.shape.borderRadius}px corners`)\n }\n if (typeof theme.spacing === 'number') parts.push(`${theme.spacing}px spacing`)\n const components = Object.keys(theme.components ?? {}).length\n if (components) {\n parts.push(`${components} component style${components === 1 ? '' : 's'}`)\n }\n return parts\n}\n\n/* ------------------------------------------------------------------------ *\n * The override layer, applied to themes (AGL-1021)\n * ------------------------------------------------------------------------ */\n\n/**\n * The field on a host document holding the site's theme override.\n *\n * A theme is a FIELD on the host, not a document of its own, so it cannot use\n * `ARTIFACT_OVERRIDE_FIELD` — a host may one day override more than one thing,\n * and a bare `overrides` would be the first to claim the name. The override\n * layer is general precisely so a namespaced field works with it unchanged.\n */\nexport const THEME_OVERRIDE_FIELD = 'themeOverride'\n\n/** A host document, as far as the theme layer is concerned. */\nexport interface ThemeHostDocument {\n theme?: HostTheme | null\n themeOverride?: unknown\n themeInstalledFrom?: { sha256?: string | null; listingId?: string } | null\n /** The library theme the site picked (AGL-3404) — see `theme-library`. */\n themeSelection?: unknown\n}\n\n/** Reads the stored theme override off a host document, tolerating junk. */\nexport function readThemeOverride(\n host: ThemeHostDocument | null | undefined,\n): ArtifactOverride | undefined {\n // The override layer's reader is keyed to its own field name, so hand it a\n // shim rather than duplicating its validation — which is where the \"a\n // top-level null blanks the artifact\" guard lives.\n return readArtifactOverride({ overrides: host?.themeOverride })\n}\n\n/**\n * The site's effective theme: the marketplace theme (or the site's own) with\n * this site's overrides resolved over it.\n *\n * This is the MIDDLE and TOP of the three layers the issue describes. The\n * bottom — the platform default — is applied further down by\n * `HostThemeProvider`, which layers whatever this returns onto the brand base\n * (AGL-1180) so a theme that omits a value still yields a complete theme\n * rather than an undefined-shaped hole. Doing it there rather than here is not\n * a detail: the default is a runtime MUI theme with FUNCTION style overrides\n * that JSON cannot represent, so a \"default\" merged at this layer would be a\n * lossy copy of the real one.\n *\n * Every surface that renders a site's appearance calls this — the tenant, all\n * four besigner editors, and the document preview — because a theme that\n * resolves differently in the editor than on the site is worse than one that\n * does not resolve at all.\n */\nexport function resolveSiteTheme(\n host: ThemeHostDocument | null | undefined,\n): HostTheme | undefined {\n const base = host?.theme ?? undefined\n const override = readThemeOverride(host)\n if (!override) return base\n return resolveOverride<HostTheme>(base ?? {}, override.patch)\n}\n\n/**\n * Where a site's theme came from (AGL-2938).\n *\n * - `installed` — a marketplace theme. The site's edits are its override\n * patch over the publisher's version, which stays untouched and can still\n * take an update.\n * - `preset` — a built-in theme a plugin contributes (AGL-3404).\n * - `custom` — one of the site's own themes.\n * - `default` — the platform default.\n *\n * Whichever it is, an edit is an override patch over the picked theme and the\n * theme itself is never rewritten (AGL-3404), so every source can be restored.\n *\n * One reading for every surface that needs it — the page that lays out the\n * Theme section, a widget proposing a change there, and a generator deciding\n * what its change will be stored as — so none of them can file a site under\n * a different source than the save path does.\n */\nexport type HostThemeSource = 'installed' | 'preset' | 'custom' | 'default'\n\nconst THEME_SOURCES: readonly HostThemeSource[] = [\n 'installed',\n 'preset',\n 'custom',\n 'default',\n]\n\nexport function hostThemeSource(\n host: ThemeHostDocument | null | undefined,\n): HostThemeSource {\n const picked = (host?.themeSelection as { kind?: unknown } | null | undefined)\n ?.kind\n if (THEME_SOURCES.includes(picked as HostThemeSource)) {\n return picked as HostThemeSource\n }\n if (host?.themeInstalledFrom?.listingId) return 'installed'\n return host?.theme && Object.keys(host.theme).length ? 'custom' : 'default'\n}\n\n/**\n * Was this override authored against the theme currently installed?\n *\n * False after a theme swap, which is the whole reason `baseSha256` is recorded.\n * Overrides deliberately SURVIVE a swap (the issue's call, and the right one —\n * a brand colour still expresses intent when the theme underneath it changes)\n * but surviving silently is what would surprise people, so every surface that\n * shows an override can ask this and say so.\n *\n * True when there is no override, and when either side has no hash to compare:\n * \"we cannot tell\" must not render as \"these are stale\".\n */\nexport function isOverrideForCurrentTheme(\n host: ThemeHostDocument | null | undefined,\n): boolean {\n const override = readThemeOverride(host)\n if (!override?.baseSha256) return true\n const installed = host?.themeInstalledFrom?.sha256\n if (!installed) return true\n return override.baseSha256 === installed\n}\n\n/** One row of the \"what have I changed?\" view. */\nexport interface ThemeOverrideEntry {\n /** Dotted path into the theme, e.g. `colorSchemes.dark.primary.main`. */\n path: string\n /** A human label for the path, for a UI that is not a JSON viewer. */\n label: string\n /** Which scheme this touches, when it touches one. */\n scheme?: HostThemeScheme\n /** The publisher's value, or undefined when the override adds the field. */\n themeValue: unknown\n /** What this site set it to, or undefined when the override removes it. */\n overrideValue: unknown\n}\n\nconst SEGMENT_LABELS: Record<string, string> = {\n darkScheme: 'Dark scheme',\n colorSchemes: 'Color',\n typography: 'Typography',\n components: 'Component',\n shape: 'Shape',\n spacing: 'Spacing',\n fonts: 'Fonts',\n mixins: 'Layout',\n variants: '',\n background: 'background',\n text: 'text',\n main: '',\n contrastText: 'label color',\n borderRadius: 'corner radius',\n fontFamily: 'font',\n}\n\n/**\n * A readable label for a theme path.\n *\n * \"Colour · dark · primary\" beats `colorSchemes.dark.primary.main`, and the\n * difference matters because this list is the answer to \"what have I changed?\"\n * — a question nobody asks wanting to read JSON paths.\n */\nexport function describeThemePath(path: string): string {\n const segments = path.split('.').filter(Boolean)\n const words: string[] = []\n for (const segment of segments) {\n const mapped = SEGMENT_LABELS[segment]\n if (mapped === '') continue\n words.push(mapped ?? segment)\n }\n return words.join(' · ') || 'the whole theme'\n}\n\n/**\n * \"What have I changed?\" — the patch, rendered.\n *\n * Literally a read of the stored override rather than a diff computed at call\n * time, which is the property the whole layer exists to provide: the answer is\n * the thing that was saved, so it cannot disagree with what will be applied.\n */\nexport function describeThemeOverride(\n host: ThemeHostDocument | null | undefined,\n): ThemeOverrideEntry[] {\n const override = readThemeOverride(host)\n if (!override) return []\n const base = host?.theme ?? {}\n const resolved = resolveOverride<HostTheme>(base, override.patch)\n return overridePaths(override.patch).map((path) => {\n const segments = path.split('.')\n const scheme =\n segments[0] === 'colorSchemes' &&\n (segments[1] === 'light' || segments[1] === 'dark')\n ? (segments[1] as HostThemeScheme)\n : undefined\n return {\n path,\n label: describeThemePath(path),\n scheme,\n themeValue: readThemePath(base, path),\n overrideValue: readThemePath(resolved, path),\n }\n })\n}\n\n/** Reads a dotted path out of a theme. */\nfunction readThemePath(theme: unknown, path: string): unknown {\n let cursor: unknown = theme\n for (const segment of path.split('.')) {\n if (cursor == null || typeof cursor !== 'object') return undefined\n cursor = (cursor as Record<string, unknown>)[segment]\n }\n return cursor\n}\n\n/**\n * The override this site should store after the theme editor produced `edited`.\n *\n * The editor edits the RESOLVED theme — that is what it renders and what the\n * user sees — so the patch is the difference between the publisher's base and\n * what came back. Diffing here rather than tracking per-field dirty state means\n * a field the user set and then set back to the theme's value stops being an\n * override, which is what \"reset\" means without needing a reset button to have\n * been pressed.\n */\nexport function themeOverridePatch(\n host: ThemeHostDocument | null | undefined,\n edited: HostTheme,\n): unknown {\n return diffOverride(host?.theme ?? {}, edited)\n}\n\n/**\n * Where this site's overrides sit on top of values a theme update also changes\n * — the only place a conflict can exist.\n *\n * Narrow by construction: an override touching four paths can conflict in at\n * most four places however much of the theme the publisher rewrote. That is the\n * difference between \"the publisher also changed your heading colour, whose\n * wins?\" and \"this update rewrites your theme, continue?\".\n */\nexport function themeUpdateConflicts(\n host: ThemeHostDocument | null | undefined,\n incoming: HostTheme,\n): ThemeOverrideEntry[] {\n const override = readThemeOverride(host)\n if (!override || isEmptyOverride(override.patch)) return []\n const base = host?.theme ?? {}\n const resolved = resolveOverride<HostTheme>(base, override.patch)\n return overrideConflicts(base, override.patch, incoming).map((path) => ({\n path,\n label: describeThemePath(path),\n scheme:\n path.startsWith('colorSchemes.light.')\n ? ('light' as const)\n : path.startsWith('colorSchemes.dark.')\n ? ('dark' as const)\n : undefined,\n themeValue: readThemePath(incoming, path),\n overrideValue: readThemePath(resolved, path),\n }))\n}\n"],"names":["diffOverride","isEmptyOverride","overrideConflicts","overridePaths","readArtifactOverride","resolveOverride","CONTRAST_AA","CONTRAST_AA_LARGE","SCHEMES","SYSTEM_FAMILIES","Set","map","family","toLowerCase","parseColor","value","text","trim","hex","exec","digits","length","r","parseInt","g","b","slice","rgb","parts","split","filter","Boolean","channels","part","endsWith","Number","some","channel","isFinite","luminance","raw","Math","min","max","contrastRatio","a","first","second","light","dark","round","ratio","validateScheme","scheme","colors","errors","warnings","at","Object","keys","push","path","message","primary","main","background","default","paper","secondaryText","secondary","bodyContrast","paperContrast","captionContrast","key","color","contrastText","declaredFamilies","theme","stacks","typography","fontFamily","variant","values","variants","stack","replace","validateFonts","loadable","fonts","font","seen","has","add","validateThemeForPublish","ok","colorSchemes","THEME_ARTIFACT_FIELDS","shape","spacing","mixins","components","darkScheme","themeArtifactContent","content","ships","entries","Array","isArray","describeTheme","schemes","count","borderRadius","THEME_OVERRIDE_FIELD","readThemeOverride","host","overrides","themeOverride","resolveSiteTheme","base","undefined","override","patch","THEME_SOURCES","hostThemeSource","picked","themeSelection","kind","includes","themeInstalledFrom","listingId","isOverrideForCurrentTheme","baseSha256","installed","sha256","SEGMENT_LABELS","describeThemePath","segments","words","segment","mapped","join","describeThemeOverride","resolved","label","themeValue","readThemePath","overrideValue","cursor","themeOverridePatch","edited","themeUpdateConflicts","incoming","startsWith"],"mappings":"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"}
|
|
@@ -208,6 +208,21 @@ export declare function buildScreenRouteEntries(screenId: ScreenUid, screensById
|
|
|
208
208
|
* uniqueness checks before publishing.
|
|
209
209
|
*/
|
|
210
210
|
export declare function findScreenIdByRoutePath(screens: Record<ScreenUid, string> | null | undefined, path: string): ScreenUid | undefined;
|
|
211
|
+
/**
|
|
212
|
+
* The screen a publish at `path` would collide with, or `undefined` when the
|
|
213
|
+
* address is free to take (AGL-3408).
|
|
214
|
+
*
|
|
215
|
+
* {@link findScreenIdByRoutePath} with one exception: the placeholder home
|
|
216
|
+
* page a site is created with (`host.defaultHomeScreenId`) does not block the
|
|
217
|
+
* site root. It holds `/` only so a new site never answers its own address
|
|
218
|
+
* with a 404, and the first real page published there replaces it — the
|
|
219
|
+
* publish seam removes its entry in the same write. It still blocks any other
|
|
220
|
+
* path it has been moved to, and no other screen is ever passed over.
|
|
221
|
+
*
|
|
222
|
+
* Every publish surface's conflict check reads this rather than the raw owner,
|
|
223
|
+
* so "is this address taken?" has one answer across them.
|
|
224
|
+
*/
|
|
225
|
+
export declare function blockingRouteOwner(screens: Record<ScreenUid, string> | null | undefined, path: string, defaultHomeScreenId?: string | null): ScreenUid | undefined;
|
|
211
226
|
/** Human-facing URL for a routing-map path (`'/'` stays `/`, `about` → `/about`). */
|
|
212
227
|
export declare function screenRoutePathToUrl(path: string): string;
|
|
213
228
|
/**
|
|
@@ -266,6 +266,26 @@ import { formatCollectionLinkValue, formatEntryLinkValue, formatFeedLinkValue, p
|
|
|
266
266
|
const entry = Object.entries(screens).find(([, value])=>value === path);
|
|
267
267
|
return entry == null ? void 0 : entry[0];
|
|
268
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* The screen a publish at `path` would collide with, or `undefined` when the
|
|
271
|
+
* address is free to take (AGL-3408).
|
|
272
|
+
*
|
|
273
|
+
* {@link findScreenIdByRoutePath} with one exception: the placeholder home
|
|
274
|
+
* page a site is created with (`host.defaultHomeScreenId`) does not block the
|
|
275
|
+
* site root. It holds `/` only so a new site never answers its own address
|
|
276
|
+
* with a 404, and the first real page published there replaces it — the
|
|
277
|
+
* publish seam removes its entry in the same write. It still blocks any other
|
|
278
|
+
* path it has been moved to, and no other screen is ever passed over.
|
|
279
|
+
*
|
|
280
|
+
* Every publish surface's conflict check reads this rather than the raw owner,
|
|
281
|
+
* so "is this address taken?" has one answer across them.
|
|
282
|
+
*/ export function blockingRouteOwner(screens, path, defaultHomeScreenId) {
|
|
283
|
+
const owner = findScreenIdByRoutePath(screens, path);
|
|
284
|
+
if (owner && path === SCREEN_ROOT_PATH && owner === defaultHomeScreenId) {
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
return owner;
|
|
288
|
+
}
|
|
269
289
|
/** Human-facing URL for a routing-map path (`'/'` stays `/`, `about` → `/about`). */ export function screenRoutePathToUrl(path) {
|
|
270
290
|
return path === SCREEN_ROOT_PATH ? SCREEN_ROOT_PATH : `/${path}`;
|
|
271
291
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/screen-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { HOST_ERROR_SCREEN_SLOTS, type ScreenUid } from '../foundation'\nimport { collectionListUrl } from './collection-entries'\nimport { ENTRY_PREVIEW_ROUTE_SEGMENT } from './entry-preview-link'\nimport { PLATFORM_BRAND_NAME } from './platform-brand'\nimport {\n formatCollectionLinkValue,\n formatEntryLinkValue,\n formatFeedLinkValue,\n parseEntryLinkValue,\n} from './screen-link-value'\n\n/**\n * Route path of a host's root screen. The tenant matcher joins the catch-all\n * segments and collapses an empty one to this value (`load-page-data.ts`:\n * `slugSegments.length ? slugSegments.join('/') : SCREEN_ROOT_PATH`), so the\n * root is `'/'` and every other path is slash-joined segments WITHOUT a\n * leading slash (`about`, later `company/about`).\n */\nexport const SCREEN_ROOT_PATH = '/'\n\n/**\n * Normalizes user slug input into the routing-map path format described on\n * {@link SCREEN_ROOT_PATH}. `'/'` normalizes to the root path; anything else\n * becomes a single lowercase url-safe segment.\n *\n * Returns `undefined` for EMPTY input and for anything that sanitizes away\n * (`'###'`) — the two cases are deliberately the same answer, and the caller\n * decides what it means. In the Screens page's slug field an empty string\n * means \"no address typed\"; in a starter template it meant \"home\". Two\n * meanings for one value is what made AGL-1575 possible, so this function\n * refuses to pick one: a caller that wants the root must say `'/'`.\n *\n * This docstring used to claim empty input normalized to the root, which the\n * code has never done (see the spec, which asserts the `undefined`). A fix\n * written against the docstring instead of the behaviour would have shipped\n * broken; if you are here to make the code match the comment, do not.\n */\nexport function normalizeScreenSlug(\n input: string | null | undefined,\n): string | undefined {\n const trimmed = (input ?? '').trim()\n if (!trimmed) return undefined\n if (trimmed === SCREEN_ROOT_PATH) return SCREEN_ROOT_PATH\n\n const segment = trimmed\n .toLowerCase()\n .replace(/^\\/+|\\/+$/g, '')\n .replace(/\\s+/g, '-')\n .replace(/[^a-z0-9-_]/g, '')\n .replace(/-{2,}/g, '-')\n .replace(/^-+|-+$/g, '')\n\n return segment || undefined\n}\n\n/**\n * Route segments the published site CANNOT serve, whatever the routing map\n * says (AGL-2076).\n *\n * Every entry was measured against production on 2026-08-19 with a fresh\n * `x-vercel-cache: MISS`, because a stale negative always passes. Two\n * mechanisms, both of which resolve ahead of `[host]/[scheme]/[[...slug]]`:\n *\n * - `404`, `500` — Next emits `pages/404.html` and `pages/500.html` even for\n * an app-router-only build, and the deployed filesystem answers them as\n * static files (`content-disposition: inline; filename=\"404\"`,\n * `accept-ranges`, an `etag`, and no `x-nextjs-prerender`). The tenant\n * middleware's own CSP headers are on those responses, so the middleware\n * ran and its rewrite still lost. There is no rewrite this app can write\n * that beats them, which is why the honest fix is to refuse the slug at\n * authoring time rather than to keep promising an address that is dead.\n * - `search`, `api`, `_next`, `_static` — routes and exclusions the tenant\n * app owns. `/search` matches `app/[host]/[scheme]/search`; the other three are the\n * middleware matcher's exclusions, so no host rewrite happens at all and\n * the path is parsed as `[host]` with an empty slug.\n * - `aglyn-preview` — the same mechanism as `search`, one route over\n * (AGL-3205). The live-site preview of an unpublished collection entry is\n * a rewrite to `app/[host]/[scheme]/aglyn-preview/[...slug]`, because it\n * must be a DIFFERENT Next route from the ISR-cached catch-all; a static\n * folder wins over the optional catch-all beside it, so `/aglyn-preview/x`\n * would reach the preview page and 404 rather than render a screen. The\n * bare `/aglyn-preview` would in fact still serve — the preview route's\n * catch-all is required, not optional — and it is reserved anyway for the\n * reason given below about child paths: a child only exists when its\n * parent is published, and this rule refuses the parent.\n *\n * NOT reserved, and deliberately so, because measuring found them fine:\n * `401`, `403`, `503` (`/401` returns 200 through the catch-all — two of the\n * four designed error screens have always been viewable at their slugs, which\n * is what made the `404` case look like a Next.js defect), and `index`.\n *\n * `fonts` and `examples` USED to be dead for the third reason and are not\n * here: they were exclusions inherited from the Vercel platforms starter kit\n * for directories `apps/tenant/public` has never contained. They are given\n * back rather than reserved — see the matcher in `apps/tenant/middleware.ts`.\n */\nexport const RESERVED_SCREEN_ROUTE_SEGMENTS: readonly string[] = [\n '404',\n '500',\n 'search',\n ENTRY_PREVIEW_ROUTE_SEGMENT,\n 'api',\n '_next',\n '_static',\n]\n\n/**\n * The reserved segment a routing-map path would collide with, or `undefined`\n * when the path is servable. The segment rather than a boolean, so a refusal\n * can name what it is refusing over.\n *\n * Reads the FIRST segment only. For `api`/`_next`/`_static` that is exactly\n * right — the middleware excludes the whole subtree, so `api/docs` is as dead\n * as `api`. For `404`/`500`/`search` it over-refuses in principle (`/404/why`\n * would serve), and that is deliberate: a child path only exists when its\n * PARENT screen is published, the parent would sit at `404`, and this same\n * rule refuses that. A second list to express a case that cannot arise would\n * be a second list to keep correct.\n *\n * Takes a composed routing-map path (`about`, `company/about`, `'/'`), not a\n * raw slug field — `'/'` is the home screen and is never reserved.\n */\nexport function reservedScreenRouteSegment(\n path: string | null | undefined,\n): string | undefined {\n if (!path || path === SCREEN_ROOT_PATH) return undefined\n const [first] = path.replace(/^\\/+/, '').split('/')\n return RESERVED_SCREEN_ROUTE_SEGMENTS.includes(first) ? first : undefined\n}\n\n/**\n * The refusal an author reads when {@link reservedScreenRouteSegment} names a\n * segment. One sentence, in one place, because the Screens page, the version\n * view and the besigner all have to say the same thing — three surfaces\n * wording a platform constraint three ways is how AGL-2093's precheck drifted.\n */\nexport function reservedScreenRouteMessage(segment: string): string {\n return `\"/${segment}\" is a reserved address on every ${PLATFORM_BRAND_NAME} site — pick another slug`\n}\n\n/**\n * Does a raw slug value carry a path separator the one segment it describes\n * cannot hold (AGL-2572)?\n *\n * {@link normalizeScreenSlug} answers with a SINGLE segment, and it gets there\n * by deleting every character outside `[a-z0-9-_]` — an interior `/` included.\n * Whitespace becomes a `-`, but a `/` becomes nothing at all, so\n * `alternatives/webflow` normalizes to `alternativeswebflow` and the two words\n * are stored glued. Nothing about that is visible: the field accepts the\n * keystroke, the composed path reads plausibly, and the screen ends up\n * answering an address nobody typed. Two screens on `aglyn-marketing` carry a\n * slug of that shape.\n *\n * So the separator is a question a slug field asks BEFORE normalizing, and the\n * answer is a refusal rather than a silent repair — the caller cannot know\n * which segment the author meant, and guessing is how the glue happened.\n *\n * LEADING and TRAILING slashes are not separators here. `normalizeScreenSlug`\n * strips them deliberately (`/about/` → `about`), an author typing an address\n * writes them without meaning a hierarchy, and refusing them would reject a\n * value the normalizer has always handled correctly. `'/'` alone is the home\n * page and is never a separator.\n */\nexport function screenSlugHasPathSeparator(\n input: string | null | undefined,\n): boolean {\n const trimmed = (input ?? '').trim()\n if (!trimmed || trimmed === SCREEN_ROOT_PATH) return false\n return trimmed.replace(/^\\/+|\\/+$/g, '').includes('/')\n}\n\n/**\n * The refusal an author reads when {@link screenSlugHasPathSeparator} answers\n * true. One sentence, in one place, for the same reason\n * {@link reservedScreenRouteMessage} is: the Screens page, the version view\n * and the besigner all take a slug, and a constraint worded three ways drifts\n * into three different constraints.\n *\n * It names the alternative, because a `/` in this field almost always means\n * \"nest me\" and the hierarchy is what actually composes the longer path.\n */\nexport const SCREEN_SLUG_PATH_SEPARATOR_MESSAGE =\n 'A slug is one path segment — remove the \"/\" and nest the screen under a ' +\n 'parent screen instead'\n\n/** Minimal screen shape the hierarchy helpers need. */\nexport interface ScreenRouteNode {\n slug?: string\n parentId?: ScreenUid\n}\n\n/** Defensive cap: parent chains deeper than this are treated as invalid. */\nconst MAX_SCREEN_DEPTH = 32\n\n/**\n * Composes a screen's routing-map path from its own slug plus its ancestor\n * chain: parent `company` + own `about` → `company/about`. Root (`'/'`)\n * segments contribute nothing, so children of the home screen sit at the\n * top level. Returns `undefined` when the screen (or any ancestor) has no\n * slug, when the screen's own slug is `'/'` while it has a parent, or when\n * the chain has a cycle / exceeds {@link MAX_SCREEN_DEPTH}.\n */\nexport function composeScreenRoutePath(\n screenId: ScreenUid,\n screensById: Record<ScreenUid, ScreenRouteNode | undefined>,\n): string | undefined {\n const segments: string[] = []\n const visited = new Set<ScreenUid>()\n let currentId: ScreenUid | undefined = screenId\n\n while (currentId) {\n if (visited.has(currentId) || visited.size >= MAX_SCREEN_DEPTH) {\n return undefined\n }\n visited.add(currentId)\n const screen = screensById[currentId]\n if (!screen?.slug) return undefined\n if (screen.slug === SCREEN_ROOT_PATH) {\n // The home screen's segment is empty (children of home sit at the top\n // level), but a screen can't itself be the home page AND have a parent.\n if (currentId === screenId && screen.parentId) return undefined\n } else {\n segments.unshift(screen.slug)\n }\n currentId = screen.parentId\n }\n\n if (!segments.length) {\n return screensById[screenId]?.slug === SCREEN_ROOT_PATH\n ? SCREEN_ROOT_PATH\n : undefined\n }\n return segments.join('/')\n}\n\n/**\n * All screens whose parent chain passes through `screenId` (children,\n * grandchildren, …). Used to cascade routing-map rewrites when a screen's\n * slug or parent changes.\n */\nexport function collectScreenDescendantIds(\n screenId: ScreenUid,\n screensById: Record<ScreenUid, ScreenRouteNode | undefined>,\n): ScreenUid[] {\n const childrenByParent = new Map<ScreenUid, ScreenUid[]>()\n for (const [id, screen] of Object.entries(screensById)) {\n if (!screen?.parentId) continue\n const siblings = childrenByParent.get(screen.parentId) ?? []\n siblings.push(id)\n childrenByParent.set(screen.parentId, siblings)\n }\n const result: ScreenUid[] = []\n const queue = [...(childrenByParent.get(screenId) ?? [])]\n while (queue.length) {\n const id = queue.shift() as ScreenUid\n if (result.includes(id)) continue\n result.push(id)\n queue.push(...(childrenByParent.get(id) ?? []))\n }\n return result\n}\n\n/**\n * True when making `nextParentId` the parent of `screenId` would create a\n * loop — i.e. the candidate parent is the screen itself or one of its own\n * descendants.\n */\nexport function wouldCreateScreenCycle(\n screenId: ScreenUid,\n nextParentId: ScreenUid | undefined,\n screensById: Record<ScreenUid, ScreenRouteNode | undefined>,\n): boolean {\n if (!nextParentId) return false\n if (nextParentId === screenId) return true\n return collectScreenDescendantIds(screenId, screensById).includes(\n nextParentId,\n )\n}\n\n/** How {@link buildScreenRouteEntries} treats a screen with no entry today. */\nexport interface BuildScreenRouteEntriesOptions {\n /**\n * Is this call a PUBLISH, or only a move? (AGL-2571)\n *\n * An entry in the host's `screens` map is the whole of what makes a path\n * reachable, so writing one IS publishing — there is no second switch. This\n * function is used for both, and until now it could not tell them apart: it\n * wrote a path for every screen in the subtree whose slug chain happened to\n * resolve, whether or not anyone had asked for that screen to go live.\n *\n * What that produced on `aglyn-marketing`: assigning a parent to an\n * unpublished screen that merely CARRIED a slug registered it in the map,\n * and the besigner toolbar — which reads the map — then offered `Unpublish`\n * and an enabled `Live` link for a screen nobody had published, with no\n * `publishedAt` on the document and a 404 at the address.\n *\n * `false` therefore restricts the write to entries that ALREADY exist:\n * paths are rewritten, broken ones are removed, and nothing new goes live.\n * A move is not an activation.\n *\n * Defaults to `true`, which is what the publish buttons mean.\n */\n publish?: boolean\n}\n\n/**\n * Routing-map entries for a screen plus all its descendants under a\n * candidate screens map: a composed path sets the entry, `null` marks an\n * existing entry whose chain no longer resolves for removal. Callers apply\n * the result in one write so slug/parent changes cascade atomically.\n *\n * See {@link BuildScreenRouteEntriesOptions.publish} for the difference\n * between publishing a screen and merely moving one.\n */\nexport function buildScreenRouteEntries(\n screenId: ScreenUid,\n screensById: Record<ScreenUid, ScreenRouteNode | undefined>,\n routingMap: Record<ScreenUid, string> | null | undefined,\n options: BuildScreenRouteEntriesOptions = {},\n): Record<ScreenUid, string | null> {\n const { publish = true } = options\n const entries: Record<ScreenUid, string | null> = {}\n const ids = [screenId, ...collectScreenDescendantIds(screenId, screensById)]\n for (const id of ids) {\n const routed = routingMap?.[id] !== undefined\n const path = composeScreenRoutePath(id, screensById)\n if (path) {\n // A move rewrites what is live; it never puts something new there.\n if (routed || publish) entries[id] = path\n } else if (routed) entries[id] = null\n }\n return entries\n}\n\n/**\n * Looks up which screen currently owns a routing path, for sibling-slug\n * uniqueness checks before publishing.\n */\nexport function findScreenIdByRoutePath(\n screens: Record<ScreenUid, string> | null | undefined,\n path: string,\n): ScreenUid | undefined {\n if (!screens) return undefined\n const entry = Object.entries(screens).find(([, value]) => value === path)\n return entry?.[0]\n}\n\n/** Human-facing URL for a routing-map path (`'/'` stays `/`, `about` → `/about`). */\nexport function screenRoutePathToUrl(path: string): string {\n return path === SCREEN_ROOT_PATH ? SCREEN_ROOT_PATH : `/${path}`\n}\n\n/**\n * A screen's OWN slug segment, read back off its composed routing-map path\n * (`company/about` → `about`) — the inverse of the one step\n * {@link composeScreenRoutePath} adds for this screen (AGL-2572).\n *\n * The slug field holds one segment; the routing map holds the whole composed\n * path. A field that seeds itself from the map therefore has to take the last\n * segment, and the besigner's seeded the whole path instead. Its value then\n * went through {@link normalizeScreenSlug}, which DELETES an interior `/`\n * rather than separating on it, so `alternatives/webflow` came back as the\n * glued `alternativeswebflow` and was stored as the screen's own slug —\n * without anyone typing it. Two screens on `aglyn-marketing` carry a slug of\n * that shape.\n *\n * Not a general path parser: it answers only \"which segment is this screen's\n * own\", which is always the last one.\n */\nexport function ownScreenSlugFromRoutePath(\n path: string | null | undefined,\n): string | undefined {\n if (!path) return undefined\n if (path === SCREEN_ROOT_PATH) return SCREEN_ROOT_PATH\n const segments = path.split('/').filter(Boolean)\n return segments[segments.length - 1]\n}\n\n/** What a screen is ACTUALLY served at, where that is not its own slug. */\nexport interface LinkableScreenRouteSources {\n /**\n * screen id → the routing-map path the screen really answers on, for\n * screens routed by something other than their own slug. Today that is a\n * content collection's LIST template: `/{collectionSlug}` renders it\n * (`composeCollectionTemplatePage`), whatever slug it was published under.\n * Applied LAST, so it also un-drops a screen named in `unrouted`.\n */\n routedElsewhere?: Record<string, string> | null | undefined\n /**\n * Screen ids that serve NO address of their own — a collection ENTRY\n * template (AGL-1267), commerce's PDP/catalog templates (AGL-1270), a\n * screen that says `kind: 'template'` (AGL-1400). The tenant router drops\n * these before matching, so their routing-map path is a 404.\n */\n unrouted?: Iterable<string> | null | undefined\n /**\n * collection id → collection slug, for every CONTENT collection with a slug\n * (AGL-2799): the collections whose listing `/{collectionSlug}` serves. Each\n * becomes a link target of its own, keyed by `formatCollectionLinkValue`,\n * so a link can name a blog's listing whether or not a list template screen\n * renders it.\n */\n collectionListings?: Record<string, string> | null | undefined\n /**\n * entry key (`entry:<collectionId>/<entryId>`) → the entry's path, for the\n * entries a page references (AGL-3118). Unlike every other source, this one\n * is not the whole site: a renderer reads only the entries its own links\n * name, so an entry key missing here means \"not asked for\", and\n * `screenRoutesAnswerFor` judges entry links only against a map that has\n * some.\n */\n entryRoutes?: Record<string, string> | null | undefined\n}\n\n/**\n * Where a collection's RSS feed is served, in the routing map's own format\n * (no leading slash): `blog/rss.xml` for the collection at `/blog`.\n */\nexport function collectionFeedRoutePath(collectionSlug: string): string {\n const listing = collectionListUrl({ collectionSlug }).replace(/^\\/+|\\/+$/g, '')\n return `${listing}/rss.xml`\n}\n\n/**\n * Where a collection ENTRY is served, in the routing map's own format (no\n * leading slash): `blog/hello` for the entry slugged `hello` in the\n * collection at `/blog` — the `/{collection}/{entry}` shape\n * `parseCollectionRoute` reads back.\n */\nexport function collectionEntryRoutePath(\n collectionSlug: string,\n entrySlug: string,\n): string {\n const listing = collectionListUrl({ collectionSlug }).replace(/^\\/+|\\/+$/g, '')\n return `${listing}/${entrySlug.trim().replace(/^\\/+|\\/+$/g, '')}`\n}\n\n/**\n * The routing map as a LINK TARGET table (AGL-1998).\n *\n * The host's `screens` map is written by publishing, one entry per screen\n * under its own composed slug, and the tenant router then edits it at serve\n * time: template screens are dropped (see {@link LinkableScreenRouteSources})\n * and a collection's list template is reached at `/{collectionSlug}` instead.\n * Everything that RESOLVES a screen link — `resolveScreenHref` on the live\n * site, the besigner's screen picker, every `Link`-typed component prop — was\n * reading the raw map, so the two surfaces disagreed with the router in both\n * directions at once:\n *\n * - `/blog` was UNOFFERABLE. The blog's list template is published under\n * `blog-list-template` and serves `/blog`, so no screen link on aglyn.com\n * could point at the site's own blog index.\n * - Every template screen was offered at a path that 404s, and an author who\n * picked one got a dead anchor that looks exactly like a live one.\n *\n * So the map every linking surface reads is derived here, from the same two\n * facts the router uses, and the picker can no longer offer a path the router\n * would refuse.\n *\n * A content collection's LISTING is a target in its own right (AGL-2799),\n * keyed `collection:<collectionId>` beside the screen ids. Its path comes from\n * `collectionListUrl`, the builder the pager, the canonical link and the\n * sitemap already share, so a link cannot give a listing an address the rest\n * of the site does not; and because the key is the collection's id rather than\n * its slug, renaming the slug moves every link to it on the next render. A\n * collection that is gone, or that has lost its slug, has no entry — the same\n * absence `isScreenLinkBroken` reads as a dead target.\n *\n * A raw map with no source given comes back unchanged; an absent map with no\n * overrides and no listings stays absent, because `undefined` means \"nothing\n * resolves yet\" to {@link ScreenLinkContext} and `{}` would mean \"resolved:\n * nowhere\".\n */\nexport function linkableScreenRoutes(\n screens: Record<ScreenUid, string> | null | undefined,\n sources: LinkableScreenRouteSources = {},\n): Record<ScreenUid, string> | undefined {\n const { routedElsewhere, unrouted, collectionListings, entryRoutes } = sources\n const overrides = Object.entries(routedElsewhere ?? {})\n const listings = Object.entries(collectionListings ?? {})\n const entries = Object.entries(entryRoutes ?? {})\n if (!screens && !overrides.length && !listings.length && !entries.length) {\n return undefined\n }\n const next: Record<ScreenUid, string> = { ...(screens ?? {}) }\n for (const id of unrouted ?? []) delete next[id]\n for (const [collectionId, collectionSlug] of listings) {\n const slug =\n typeof collectionSlug === 'string'\n ? collectionSlug.trim().replace(/^\\/+|\\/+$/g, '')\n : ''\n if (!collectionId.trim() || !slug) continue\n // Built by the one listing-URL builder, then stored in the map's own\n // format: no leading slash, which `resolveScreenHref` puts back.\n next[formatCollectionLinkValue(collectionId)] = collectionListUrl({\n collectionSlug: slug,\n }).replace(/^\\/+/, '')\n // The feed hangs off the same listing (AGL-3118), so it follows the same\n // rename and disappears with the same collection.\n next[formatFeedLinkValue(collectionId)] = collectionFeedRoutePath(slug)\n }\n for (const [key, path] of entries) {\n // Only well-formed entry keys, stored under their canonical spelling: this\n // source must not be able to plant a screen id or a listing key the other\n // sources own.\n const entry = parseEntryLinkValue(key)\n if (!entry) continue\n const normalized =\n typeof path === 'string' ? path.trim().replace(/^\\/+|\\/+$/g, '') : ''\n if (!normalized) continue\n next[formatEntryLinkValue(entry.collectionId, entry.entryId)] = normalized\n }\n for (const [id, path] of overrides) {\n // A collection slug (`blog`) arrives in the map's own format already, but\n // the same fact is spelled `/blog` in half the places it is read from, and\n // a stored `/blog` would resolve to `//blog` — an absolute URL to the host\n // `blog`, i.e. off the site entirely.\n const normalized =\n typeof path === 'string' && path !== SCREEN_ROOT_PATH\n ? path.replace(/^\\/+|\\/+$/g, '')\n : path\n if (!normalized) continue\n next[id] = normalized\n }\n return next\n}\n\n/**\n * `kind` of a besigner email document (AGL-395): a screen authored on the\n * Emails page and sent by a campaign, never served at a URL.\n */\nexport const SCREEN_KIND_EMAIL = 'email'\n\n/**\n * `kind` of a collection ENTRY template (AGL-1400): a screen that composes\n * `/{collection}/{entry}` for every entry and has no address of its own.\n *\n * The value exists so that \"is this screen a page?\" stops being a JOIN.\n * Four issues (AGL-1173, AGL-1383, AGL-1387, AGL-1390) were the same sentence\n * at different depths because the answer was derived from a mutable field on a\n * DIFFERENT document — a collection's `entryScreenId` — and every new way to\n * edit that pointer was a new bypass of `screensPerHost`. Here the fact is a\n * property of the screen, server-stamped by /api/hosts/screens and frozen in\n * the rules exactly as `kind: 'email'` has been since AGL-1383.\n *\n * NOT a collection's LIST template. `/{collectionSlug}` renders that exact\n * screen with its entries, so it is a designed reachable page and AGL-1387\n * made it count; converting one would take the page off the site.\n */\nexport const SCREEN_KIND_TEMPLATE = 'template'\n\n/**\n * `kind` of a designed ERROR screen (AGL-2092): the screen a host assigns to\n * one of `errorScreens`' four status slots, rendered on paths that did not\n * match — and so, like an entry template, at no address of its own.\n *\n * the decision on 2026-08-18 was that these must not spend the plan's screen\n * allowance, for the reason AGL-1173 excluded an entry template: the governing\n * question is whether the screen occupies a URL of its own, and a 404 body does\n * not. (A collection's LIST template does — `/{collectionSlug}` renders that\n * exact screen — which is why AGL-1387 left it counting.)\n *\n * ## This value does NOT get `kind: 'template'`'s trust\n *\n * `billableScreenIds` short-circuits on `SCREEN_KIND_TEMPLATE` BEFORE it\n * consults the routing map, because a template is routed on purpose. An error\n * screen is not, so it deliberately falls through to the ordinary rule and\n * **the routing map outranks this field**: a screen still published at an\n * address of its own counts, whatever it says about itself. That is the whole\n * migration story for the error screens that already exist — see\n * {@link ERROR_SCREEN_MAX_PER_HOST}.\n *\n * Stamped only by /api/hosts/screens, as part of binding the screen to a slot,\n * and frozen against the client by the same rule that has frozen `kind` since\n * AGL-1383. Nothing about that is new for this value; what IS new is that the\n * stamp is BOUNDED, because unlike a template there is no natural limit on how\n * many screens somebody would like to declare as error pages.\n */\nexport const SCREEN_KIND_ERROR = 'error'\n\n/**\n * How many screens one host may hold with `kind: 'error'` (AGL-2092).\n *\n * An exclusion the metered party can ask for is a free-screen generator unless\n * something bounds it, and the four bindings in `HostErrorScreens` are the\n * bound: at most one exempt screen per error slot. It is `.length` of the slot\n * list rather than a literal `4` so the two can never disagree.\n *\n * Checked in TWO places, and the second is the one that makes the number true\n * (AGL-2093). At the moment of the STAMP, against the post-state — the\n * AGL-1390 shape, which produces the refusal worth reading because it can name\n * the four slots. And inside `billableScreenIds` itself, where a live error\n * screen past this bound is counted as an ordinary page: the stamp is not the\n * only writer, and a crafted import bundle carrying `kind: 'error'` on all 200\n * of its screens passed no bound at all until the rule owned one. An exclusion\n * enforced only at the write paths somebody remembered is the sentence this arc\n * has now repeated six times.\n *\n * The tempting alternative is to enforce \"bound to a slot\" as an\n * invariant, promoting a screen back to a page whenever its slot is cleared;\n * that is AGL-1390's refuse-the-clear bug wearing a different hat, because\n * promotion raises the count and a host at its cap could then never unassign an\n * error screen. So clearing a slot is always allowed and leaves the screen an\n * error screen — unbilled, unrouted, and one deliberate click from being a page\n * again — and what is bounded is how many of them can exist at once.\n */\nexport const ERROR_SCREEN_MAX_PER_HOST = HOST_ERROR_SCREEN_SLOTS.length\n\n/** The two self-describing fields {@link screenClaimsToBeAPage} reads. */\nexport interface ScreenPageClaim {\n kind?: string\n deletedAt?: unknown\n}\n\n/**\n * Whether a screen document claims to be a page of the site at all (AGL-1383).\n *\n * Four things say it is not: `deletedAt` (soft-deleted — delete stamps the\n * field rather than removing the doc), `kind: 'email'` (an Emails-page\n * document, which has no URL and is rendered only by the campaign sender,\n * straight off the doc), `kind: 'template'` (a collection entry template,\n * which composes `/{collection}/{entry}` and has no address of its own —\n * AGL-1400) and `kind: 'error'` (a screen assigned to one of the host's four\n * error slots, rendered on paths that matched nothing — AGL-2092). All four\n * are also the exclusions `countBillableScreens` subtracts before enforcing\n * `screensPerHost`, and since AGL-1400 they are the WHOLE of that subtraction:\n * the count reads no other collection.\n *\n * Adding the fourth needed no edit to `billableScreenIds` and no edit to\n * `nonPageScreenIds`, which is the property AGL-1439 wrote them for: the\n * routing-map override applies to a new non-page `kind` by default, and the\n * flat infrastructure cap is the complement, so there is no second list to\n * remember. Only `kind: 'template'` is named anywhere else, because only it\n * opts OUT of the routing-map override.\n *\n * That is exactly why this is ONE function with TWO callers rather than two\n * matching filters. Both fields are ordinary client-writable fields on\n * `hosts/{hostId}/screens/{screenId}`, and until AGL-1383 only the count read\n * them: an editor on a Free site could `updateDoc(screenRef, {kind: 'email'})`\n * and the screen stopped counting against the plan while the routing map still\n * pointed at it and the runtime still served it — a live page, for free. An\n * exclusion is only sound if an excluded screen genuinely is not a page, so the\n * serve path asks this question too, and flipping either field now costs the\n * page instead of the plan.\n *\n * A CLAIM, not the answer, for the two fields a client can still reach: the\n * routing map decides reachability, and `countBillableScreens` trusts\n * `deletedAt` / `kind: 'email'` only for screens the map does not route.\n * `kind: 'error'` is in the client-can-still-reach group for counting purposes\n * even though no client writes it either: an error screen that is ALSO\n * published at an address is a page somebody is using as a page, so the map\n * outranks the stamp and it counts. Serving is the other way round — see\n * `getScreen`, which returns error screens rather than 404ing them, because\n * refusing would break the customer's own designed error page without closing\n * anything (they are already paying for it if it is routed).\n *\n * `kind: 'template'` is the exception, and the reason it can be one is that no\n * client writes it — /api/hosts/screens stamps it, demotion lowers the count\n * and promotion is checked exactly like a create, so there is no toggle to\n * launder. A template is ROUTED on purpose (publishing is how the compose\n * pipeline picks it up), which is why the map cannot be the arbiter for it.\n */\nexport function screenClaimsToBeAPage(\n screen: ScreenPageClaim | null | undefined,\n): boolean {\n if (!screen) return false\n return (\n screen.deletedAt == null &&\n screen.kind !== SCREEN_KIND_EMAIL &&\n screen.kind !== SCREEN_KIND_TEMPLATE &&\n screen.kind !== SCREEN_KIND_ERROR\n )\n}\n\n/**\n * One screen, reduced to the two things {@link billableScreenIds} asks about.\n *\n * Rows rather than a query (AGL-1390) because the enforcement points ask about\n * a state that does not exist yet: a promotion is checked against the count it\n * WOULD leave behind. A function that does its own reads can only answer for\n * the present. `readScreenSources` in /api/hosts/resources is the reader.\n */\nexport interface BillableScreenSource {\n id: string\n kind?: unknown\n deletedAt?: unknown\n}\n\n/**\n * A host's `screens` routing map (screen id → route path) as\n * `publishScreenRoute` writes it. Only membership is read, never the path.\n */\nexport type ScreenRoutingMap = Record<string, unknown> | null | undefined\n\n/**\n * WHICH screens spend the plan's screen allowance (AGL-1173).\n *\n * The ids rather than the count, because an enforcement point that cannot name\n * the screen it is refusing over is a refusal the person reading it cannot act\n * on.\n *\n * A plain `screens.count()` charged for three things the subscriber never\n * chose to author, and the screens list — which filters them out — then\n * disagreed with the server about how much of the plan was used:\n *\n * - **Soft-deleted screens.** Delete stamps `deletedAt` rather than\n * removing the doc, so deleting a screen never freed a slot. On the free\n * plan (5 screens) that was a dead end with no way out from the UI.\n * - **Email screens** (`kind: 'email'`), which live on the Emails page and\n * were already excluded from the list count but not from enforcement.\n * - **A collection's ENTRY template** — one screen serving every entry at no\n * URL of its own. Adding a blog cost two of the free plan's five screens\n * before the first page existed.\n *\n * ## Why it lives HERE, next to the predicate (AGL-2093)\n *\n * It was in /api/hosts/resources, and the console's Screens page — which runs\n * the same rule as a precheck before it lets somebody add a page — could not\n * import it from there (that module reaches `@aglyn/aglyn/server`, which pulls\n * `node:stream` in through the API adapter). So the page restated it, and the\n * restatement drifted: it never learned the error-screen bound below, and on a\n * host holding five `kind: 'error'` screens the console offered room the API\n * then refused. A precheck that warns on a different number than the API\n * enforces is worse than no precheck at all, and a rule that has to be\n * restated to be reused will be restated wrongly eventually. One function, two\n * callers.\n *\n * ## ONE document answers it now (AGL-1400)\n *\n * The third exclusion used to be a JOIN: the count read the `collections`\n * collection and subtracted whatever screen ids the template pointers named.\n * That shape produced four issues in one arc, because the other side of the\n * join is editable and each new way to edit it was a new bypass — AGL-1173\n * created the exclusion, AGL-1383 froze the two fields the screen itself\n * carried, AGL-1387 found the list template was a page after all, and AGL-1390\n * found the pointer could be toggled to mint permanent slots and had to move\n * the write server-side and evaluate the cap against the post-state.\n *\n * Since AGL-1400 an entry template says so on its own document\n * (`kind: 'template'`), stamped by /api/hosts/screens and frozen in the rules\n * exactly as `kind: 'email'` is. So this reads the `screens` collection and\n * nothing else: the pointer is a pointer again, freely writable, and it excuses\n * nothing. `screenClaimsToBeAPage` is the whole rule, which is the property\n * that keeps the count and the serve path (`getScreen`) from ever disagreeing.\n *\n * What did NOT change is what anybody pays. A collection's LIST template stays\n * a page — `/{collectionSlug}` renders that exact screen (AGL-1387) — so it is\n * never stamped and still counts, and an entry template stays excluded\n * (AGL-1173's charge is not reinstated).\n *\n * ## The routing map outranks the document, and ONLY ADDS (AGL-1383, AGL-1445)\n *\n * `deletedAt` and `kind: 'email'` are ordinary client-writable fields on the\n * screen's OWN document, and the party they are subtracted on behalf of is the\n * party being metered. `updateDoc(screenRef, {kind: 'email'})` — one write, no\n * route change — used to take a live page off a Free plan's five and leave it\n * serving, because nothing else read either field. So for those two the\n * document's account of itself is consulted only for screens the routing map\n * does not route: **a routed screen counts, whatever it says about itself**.\n *\n * The converse is NOT true and must not become true, which is AGL-1445's\n * settlement. An unrouted screen with no slug — a draft, or an orphan left\n * behind by an unpublish — serves nothing, and it still counts:\n *\n * - `screensPerHost` is a CREATE-TIME gate. /api/hosts/resources counts and\n * refuses; `report-usage` re-measures monthly but RECORDS rather than\n * enforces. The create is the only door.\n * - A screen is born unrouted, and publishing is a separate later act — a\n * client `updateDoc` on the host's `screens` map (`publishScreenRoute`),\n * gated on `canPublishHostContent` and on nothing else.\n *\n * So subtracting unrouted screens would not make the count fairer, it would\n * make every create free — the new document is unrouted by construction — and\n * leave the publishes that follow ungated. A Free site would hold unlimited\n * pages. AGL-1445's option 2 (server-stamping a non-page `kind` on orphans by\n * a sweep) lands in the same place unless the promotion BACK passes a gate,\n * and for an orphan the promotion is the publish. `kind: 'template'` can be\n * trusted precisely because its promotion is `convertScreenKind`, checked\n * exactly like a create; there is no equivalent door in front of the routing\n * map. The price is one slot until somebody deletes the orphan, which is one\n * click and which the Screens page lists like any other row —\n * `unrouted-screen-counts.spec.ts` pins it at the enforcement point.\n *\n * `kind: 'template'` is deliberately outside the map's override, and it is the\n * one value that can be. A template is routed ON PURPOSE — publishing is how\n * the compose pipeline picks it up, and AGL-1267 drops it from routing when the\n * request arrives — so the map cannot be the arbiter for it. What makes\n * trusting it safe is that no client writes it: the demotion that sets it\n * always succeeds (it lowers the count) and the promotion that clears it is\n * checked exactly like a create (it raises it), so there is no toggle to run a\n * loop through.\n */\nexport function billableScreenIds(\n screens: ReadonlyArray<BillableScreenSource>,\n routingMap?: ScreenRoutingMap,\n): Set<string> {\n const routed = new Set(Object.keys(routingMap ?? {}))\n const exemptErrors = exemptErrorScreenIds(screens)\n const billable = new Set<string>()\n for (const screen of screens) {\n if (screen.kind === SCREEN_KIND_TEMPLATE) continue\n const claimsToBeAPage = screenClaimsToBeAPage({\n kind: screen.kind as string,\n deletedAt: screen.deletedAt,\n })\n // A `kind: 'error'` screen OVER the slot bound is a page again (AGL-2093).\n const exemptionSpent =\n screen.kind === SCREEN_KIND_ERROR &&\n screen.deletedAt == null &&\n !exemptErrors.has(screen.id)\n if (routed.has(screen.id) || claimsToBeAPage || exemptionSpent) {\n billable.add(screen.id)\n }\n }\n return billable\n}\n\n/**\n * The live `kind: 'error'` screens whose billing exemption actually HOLDS —\n * at most {@link ERROR_SCREEN_MAX_PER_HOST} of them (AGL-2093).\n *\n * ## Why the bound lives here and not at one endpoint\n *\n * AGL-2092 introduced the exemption and bounded it at four — one per\n * `HostErrorScreens` slot — by checking the post-state at the moment\n * /api/hosts/screens STAMPS the kind. That made the claim true of the assign\n * route and false end to end, because the stamp is not the only writer:\n * `SITE_EXPORT_FIELDS.screens` carries `kind`, so a hand-edited bundle could\n * declare `kind: 'error'` on all 200 of its screens and /api/hosts/import\n * excluded every one of them from `screensPerHost` — never passing the bound,\n * never binding a slot. N unbilled screens, one file.\n *\n * That is the arc's fifth repetition of one sentence (AGL-1173, AGL-1383,\n * AGL-1387, AGL-1390, AGL-1400): *an exclusion enforced at a write path is\n * enforced only at the write paths somebody remembered.* So the bound moves\n * into the rule the enforcement points SHARE. However a screen came to say\n * `kind: 'error'` — assign, import, or the next writer — a host gets four\n * unbilled ones and the rest spend the plan's allowance exactly as pages do.\n *\n * ## Which four\n *\n * The lowest ids, sorted. The choice is arbitrary and the DETERMINISM is not:\n * every enforcement point models a post-state by re-running this over a\n * different row set, and a rule that picked \"the first four encountered\" would\n * hand two callers different answers for the same host. The SIZE of the exempt\n * set never depends on the order — it is `min(live errors, 4)` — so only which\n * ids a refusal message names can move, never whether one is refused.\n *\n * Tombstones are excluded for `nonPageScreenIds`' reason: a cap counting\n * soft-deleted rows is AGL-1173's bug one cap over, where deleting an error\n * screen never frees its slot.\n *\n * The stamp-time bound in /api/hosts/screens is deliberately KEPT. It is the\n * better refusal — it names the four slots and arrives before the write — and\n * this is the backstop under it, so no writer can mint a fifth unbilled error\n * screen even by a path that never consults it.\n */\nexport function exemptErrorScreenIds(\n screens: ReadonlyArray<BillableScreenSource>,\n): Set<string> {\n const live: Array<string> = []\n for (const screen of screens) {\n if (screen.kind !== SCREEN_KIND_ERROR) continue\n if (screen.deletedAt != null) continue\n live.push(screen.id)\n }\n if (live.length <= ERROR_SCREEN_MAX_PER_HOST) return new Set(live)\n return new Set(live.sort().slice(0, ERROR_SCREEN_MAX_PER_HOST))\n}\n\n/**\n * The screens the flat platform cap bounds (AGL-1399, AGL-1439): LIVE documents\n * that are not billable pages — the exact complement of {@link billableScreenIds}.\n *\n * ## Why the complement, and not `kind === 'email' || kind === 'template'`\n *\n * Both issues are one sentence — *a `kind` value that excludes a document from\n * billing, declarable by the metered party* — and a cap that enumerated the two\n * values would be correct until the third arrived, which is how this arc has\n * already gone four times (AGL-1173, AGL-1383, AGL-1387, AGL-1390). Written as\n * the complement, a new non-page `kind` is bounded by the act of adding it to\n * `screenClaimsToBeAPage`: there is no second list to remember.\n *\n * Two properties fall out of taking the complement rather than negating the\n * predicate directly:\n *\n * - **A soft-deleted screen is in NEITHER set.** Delete stamps `deletedAt`\n * rather than removing the doc, and a cap that counted tombstones would be\n * the AGL-1173 bug one cap over — deleting an email document would never free\n * a slot, and a host at the cap could never author another.\n * - **A ROUTED email document counts once, against the plan.** The routing map\n * outranks the document (AGL-1383), so such a screen is a page somebody is\n * already paying for; charging it to the infrastructure cap as well would be\n * counting one document twice. The two sets partition the live screens.\n *\n * Taking the complement is also what makes AGL-2093's bound arrive here for\n * free: an error screen past `ERROR_SCREEN_MAX_PER_HOST` becomes billable\n * above, so it leaves this set in the same act. The partition holds — every\n * live screen is in exactly one of the two — and the fifth error screen is\n * bounded by the PLAN rather than by the flat 5,000, which is the stronger of\n * the two numbers on every finite tier.\n */\nexport function nonPageScreenIds(\n screens: ReadonlyArray<BillableScreenSource>,\n routingMap?: ScreenRoutingMap,\n): Set<string> {\n const billable = billableScreenIds(screens, routingMap)\n const nonPage = new Set<string>()\n for (const screen of screens) {\n if (screen.deletedAt != null) continue\n if (!billable.has(screen.id)) nonPage.add(screen.id)\n }\n return nonPage\n}\n\n/**\n * How many LIVE screen documents a host may hold that are NOT billable pages —\n * email documents, entry templates, and whatever non-page `kind` comes next\n * (AGL-1399, AGL-1439).\n *\n * ## Why a flat number and not a plan dimension\n *\n * Every exclusion `screenClaimsToBeAPage` names is declared by the party being\n * metered: `kind: 'email'` is on the create allow-list because the email\n * composer must send it, and AGL-1400 gave `kind: 'template'` the same\n * billing-excluding meaning. So `POST /api/hosts/resources` with\n * `kind: 'email'` created a document that no cap counted, without limit, on a\n * free plan — unbounded Firestore documents rather than a bypass of anything we\n * sell. AGL-1439 is the same sentence one value over, and a cap enumerating the\n * two values would leave the hole open for the third.\n *\n * The shape is `WEBHOOK_MAX_PER_HOST`'s (AGL-1360): no `OrgEntitlements` key, no\n * variation by plan, counted from a server read at the create path. An\n * `emailDocumentsPerHost` would have been a **pricing** decision — a number the\n * price list has to explain and support has to defend — for a limit whose only\n * job is to bound infrastructure. This is not the charge AGL-1173 removed and it\n * does not reinstate it: nothing here is billable, and `screensPerHost` counts\n * exactly what it counted yesterday.\n *\n * ## Why 5,000\n *\n * Sized to the heaviest library anybody could plausibly author, not to what\n * exists: production's busiest host holds FOUR non-page screens (one email,\n * three entry templates) across five hosts, so any bar sized to today's data\n * would be meaningless. The real quantity is campaigns, which accumulate and are\n * never deleted — a DAILY newsletter reaches 5,000 after thirteen years, and a\n * thrice-weekly one after thirty-two. Entry templates contribute one per\n * collection, so tens at the outside. Nothing a real customer does approaches\n * this, which is the property that matters: the failure mode of a too-low flat\n * cap is blocking legitimate work with an error the price list cannot explain.\n *\n * A cheaper bound is not worth much either. What the number defends against is a\n * script in a loop, and a loop stopped at 5,000 documents is stopped. It also\n * bounds the cost of the count itself — `countBillableScreens` scans the screens\n * collection on every create at one billed read per document (AGL-1440), and\n * before this cap that scan had no ceiling at all.\n */\nexport const NON_PAGE_SCREEN_MAX_PER_HOST = 5000\n"],"names":["HOST_ERROR_SCREEN_SLOTS","collectionListUrl","ENTRY_PREVIEW_ROUTE_SEGMENT","PLATFORM_BRAND_NAME","formatCollectionLinkValue","formatEntryLinkValue","formatFeedLinkValue","parseEntryLinkValue","SCREEN_ROOT_PATH","normalizeScreenSlug","input","trimmed","trim","undefined","segment","toLowerCase","replace","RESERVED_SCREEN_ROUTE_SEGMENTS","reservedScreenRouteSegment","path","first","split","includes","reservedScreenRouteMessage","screenSlugHasPathSeparator","SCREEN_SLUG_PATH_SEPARATOR_MESSAGE","MAX_SCREEN_DEPTH","composeScreenRoutePath","screenId","screensById","segments","visited","Set","currentId","has","size","add","screen","slug","parentId","unshift","length","join","collectScreenDescendantIds","childrenByParent","Map","id","Object","entries","siblings","get","push","set","result","queue","shift","wouldCreateScreenCycle","nextParentId","buildScreenRouteEntries","routingMap","options","publish","ids","routed","findScreenIdByRoutePath","screens","entry","find","value","screenRoutePathToUrl","ownScreenSlugFromRoutePath","filter","Boolean","collectionFeedRoutePath","collectionSlug","listing","collectionEntryRoutePath","entrySlug","linkableScreenRoutes","sources","routedElsewhere","unrouted","collectionListings","entryRoutes","overrides","listings","next","collectionId","key","normalized","entryId","SCREEN_KIND_EMAIL","SCREEN_KIND_TEMPLATE","SCREEN_KIND_ERROR","ERROR_SCREEN_MAX_PER_HOST","screenClaimsToBeAPage","deletedAt","kind","billableScreenIds","keys","exemptErrors","exemptErrorScreenIds","billable","claimsToBeAPage","exemptionSpent","live","sort","slice","nonPageScreenIds","nonPage","NON_PAGE_SCREEN_MAX_PER_HOST"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/screen-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { HOST_ERROR_SCREEN_SLOTS, type ScreenUid } from '../foundation'\nimport { collectionListUrl } from './collection-entries'\nimport { ENTRY_PREVIEW_ROUTE_SEGMENT } from './entry-preview-link'\nimport { PLATFORM_BRAND_NAME } from './platform-brand'\nimport {\n formatCollectionLinkValue,\n formatEntryLinkValue,\n formatFeedLinkValue,\n parseEntryLinkValue,\n} from './screen-link-value'\n\n/**\n * Route path of a host's root screen. The tenant matcher joins the catch-all\n * segments and collapses an empty one to this value (`load-page-data.ts`:\n * `slugSegments.length ? slugSegments.join('/') : SCREEN_ROOT_PATH`), so the\n * root is `'/'` and every other path is slash-joined segments WITHOUT a\n * leading slash (`about`, later `company/about`).\n */\nexport const SCREEN_ROOT_PATH = '/'\n\n/**\n * Normalizes user slug input into the routing-map path format described on\n * {@link SCREEN_ROOT_PATH}. `'/'` normalizes to the root path; anything else\n * becomes a single lowercase url-safe segment.\n *\n * Returns `undefined` for EMPTY input and for anything that sanitizes away\n * (`'###'`) — the two cases are deliberately the same answer, and the caller\n * decides what it means. In the Screens page's slug field an empty string\n * means \"no address typed\"; in a starter template it meant \"home\". Two\n * meanings for one value is what made AGL-1575 possible, so this function\n * refuses to pick one: a caller that wants the root must say `'/'`.\n *\n * This docstring used to claim empty input normalized to the root, which the\n * code has never done (see the spec, which asserts the `undefined`). A fix\n * written against the docstring instead of the behaviour would have shipped\n * broken; if you are here to make the code match the comment, do not.\n */\nexport function normalizeScreenSlug(\n input: string | null | undefined,\n): string | undefined {\n const trimmed = (input ?? '').trim()\n if (!trimmed) return undefined\n if (trimmed === SCREEN_ROOT_PATH) return SCREEN_ROOT_PATH\n\n const segment = trimmed\n .toLowerCase()\n .replace(/^\\/+|\\/+$/g, '')\n .replace(/\\s+/g, '-')\n .replace(/[^a-z0-9-_]/g, '')\n .replace(/-{2,}/g, '-')\n .replace(/^-+|-+$/g, '')\n\n return segment || undefined\n}\n\n/**\n * Route segments the published site CANNOT serve, whatever the routing map\n * says (AGL-2076).\n *\n * Every entry was measured against production on 2026-08-19 with a fresh\n * `x-vercel-cache: MISS`, because a stale negative always passes. Two\n * mechanisms, both of which resolve ahead of `[host]/[scheme]/[[...slug]]`:\n *\n * - `404`, `500` — Next emits `pages/404.html` and `pages/500.html` even for\n * an app-router-only build, and the deployed filesystem answers them as\n * static files (`content-disposition: inline; filename=\"404\"`,\n * `accept-ranges`, an `etag`, and no `x-nextjs-prerender`). The tenant\n * middleware's own CSP headers are on those responses, so the middleware\n * ran and its rewrite still lost. There is no rewrite this app can write\n * that beats them, which is why the honest fix is to refuse the slug at\n * authoring time rather than to keep promising an address that is dead.\n * - `search`, `api`, `_next`, `_static` — routes and exclusions the tenant\n * app owns. `/search` matches `app/[host]/[scheme]/search`; the other three are the\n * middleware matcher's exclusions, so no host rewrite happens at all and\n * the path is parsed as `[host]` with an empty slug.\n * - `aglyn-preview` — the same mechanism as `search`, one route over\n * (AGL-3205). The live-site preview of an unpublished collection entry is\n * a rewrite to `app/[host]/[scheme]/aglyn-preview/[...slug]`, because it\n * must be a DIFFERENT Next route from the ISR-cached catch-all; a static\n * folder wins over the optional catch-all beside it, so `/aglyn-preview/x`\n * would reach the preview page and 404 rather than render a screen. The\n * bare `/aglyn-preview` would in fact still serve — the preview route's\n * catch-all is required, not optional — and it is reserved anyway for the\n * reason given below about child paths: a child only exists when its\n * parent is published, and this rule refuses the parent.\n *\n * NOT reserved, and deliberately so, because measuring found them fine:\n * `401`, `403`, `503` (`/401` returns 200 through the catch-all — two of the\n * four designed error screens have always been viewable at their slugs, which\n * is what made the `404` case look like a Next.js defect), and `index`.\n *\n * `fonts` and `examples` USED to be dead for the third reason and are not\n * here: they were exclusions inherited from the Vercel platforms starter kit\n * for directories `apps/tenant/public` has never contained. They are given\n * back rather than reserved — see the matcher in `apps/tenant/middleware.ts`.\n */\nexport const RESERVED_SCREEN_ROUTE_SEGMENTS: readonly string[] = [\n '404',\n '500',\n 'search',\n ENTRY_PREVIEW_ROUTE_SEGMENT,\n 'api',\n '_next',\n '_static',\n]\n\n/**\n * The reserved segment a routing-map path would collide with, or `undefined`\n * when the path is servable. The segment rather than a boolean, so a refusal\n * can name what it is refusing over.\n *\n * Reads the FIRST segment only. For `api`/`_next`/`_static` that is exactly\n * right — the middleware excludes the whole subtree, so `api/docs` is as dead\n * as `api`. For `404`/`500`/`search` it over-refuses in principle (`/404/why`\n * would serve), and that is deliberate: a child path only exists when its\n * PARENT screen is published, the parent would sit at `404`, and this same\n * rule refuses that. A second list to express a case that cannot arise would\n * be a second list to keep correct.\n *\n * Takes a composed routing-map path (`about`, `company/about`, `'/'`), not a\n * raw slug field — `'/'` is the home screen and is never reserved.\n */\nexport function reservedScreenRouteSegment(\n path: string | null | undefined,\n): string | undefined {\n if (!path || path === SCREEN_ROOT_PATH) return undefined\n const [first] = path.replace(/^\\/+/, '').split('/')\n return RESERVED_SCREEN_ROUTE_SEGMENTS.includes(first) ? first : undefined\n}\n\n/**\n * The refusal an author reads when {@link reservedScreenRouteSegment} names a\n * segment. One sentence, in one place, because the Screens page, the version\n * view and the besigner all have to say the same thing — three surfaces\n * wording a platform constraint three ways is how AGL-2093's precheck drifted.\n */\nexport function reservedScreenRouteMessage(segment: string): string {\n return `\"/${segment}\" is a reserved address on every ${PLATFORM_BRAND_NAME} site — pick another slug`\n}\n\n/**\n * Does a raw slug value carry a path separator the one segment it describes\n * cannot hold (AGL-2572)?\n *\n * {@link normalizeScreenSlug} answers with a SINGLE segment, and it gets there\n * by deleting every character outside `[a-z0-9-_]` — an interior `/` included.\n * Whitespace becomes a `-`, but a `/` becomes nothing at all, so\n * `alternatives/webflow` normalizes to `alternativeswebflow` and the two words\n * are stored glued. Nothing about that is visible: the field accepts the\n * keystroke, the composed path reads plausibly, and the screen ends up\n * answering an address nobody typed. Two screens on `aglyn-marketing` carry a\n * slug of that shape.\n *\n * So the separator is a question a slug field asks BEFORE normalizing, and the\n * answer is a refusal rather than a silent repair — the caller cannot know\n * which segment the author meant, and guessing is how the glue happened.\n *\n * LEADING and TRAILING slashes are not separators here. `normalizeScreenSlug`\n * strips them deliberately (`/about/` → `about`), an author typing an address\n * writes them without meaning a hierarchy, and refusing them would reject a\n * value the normalizer has always handled correctly. `'/'` alone is the home\n * page and is never a separator.\n */\nexport function screenSlugHasPathSeparator(\n input: string | null | undefined,\n): boolean {\n const trimmed = (input ?? '').trim()\n if (!trimmed || trimmed === SCREEN_ROOT_PATH) return false\n return trimmed.replace(/^\\/+|\\/+$/g, '').includes('/')\n}\n\n/**\n * The refusal an author reads when {@link screenSlugHasPathSeparator} answers\n * true. One sentence, in one place, for the same reason\n * {@link reservedScreenRouteMessage} is: the Screens page, the version view\n * and the besigner all take a slug, and a constraint worded three ways drifts\n * into three different constraints.\n *\n * It names the alternative, because a `/` in this field almost always means\n * \"nest me\" and the hierarchy is what actually composes the longer path.\n */\nexport const SCREEN_SLUG_PATH_SEPARATOR_MESSAGE =\n 'A slug is one path segment — remove the \"/\" and nest the screen under a ' +\n 'parent screen instead'\n\n/** Minimal screen shape the hierarchy helpers need. */\nexport interface ScreenRouteNode {\n slug?: string\n parentId?: ScreenUid\n}\n\n/** Defensive cap: parent chains deeper than this are treated as invalid. */\nconst MAX_SCREEN_DEPTH = 32\n\n/**\n * Composes a screen's routing-map path from its own slug plus its ancestor\n * chain: parent `company` + own `about` → `company/about`. Root (`'/'`)\n * segments contribute nothing, so children of the home screen sit at the\n * top level. Returns `undefined` when the screen (or any ancestor) has no\n * slug, when the screen's own slug is `'/'` while it has a parent, or when\n * the chain has a cycle / exceeds {@link MAX_SCREEN_DEPTH}.\n */\nexport function composeScreenRoutePath(\n screenId: ScreenUid,\n screensById: Record<ScreenUid, ScreenRouteNode | undefined>,\n): string | undefined {\n const segments: string[] = []\n const visited = new Set<ScreenUid>()\n let currentId: ScreenUid | undefined = screenId\n\n while (currentId) {\n if (visited.has(currentId) || visited.size >= MAX_SCREEN_DEPTH) {\n return undefined\n }\n visited.add(currentId)\n const screen = screensById[currentId]\n if (!screen?.slug) return undefined\n if (screen.slug === SCREEN_ROOT_PATH) {\n // The home screen's segment is empty (children of home sit at the top\n // level), but a screen can't itself be the home page AND have a parent.\n if (currentId === screenId && screen.parentId) return undefined\n } else {\n segments.unshift(screen.slug)\n }\n currentId = screen.parentId\n }\n\n if (!segments.length) {\n return screensById[screenId]?.slug === SCREEN_ROOT_PATH\n ? SCREEN_ROOT_PATH\n : undefined\n }\n return segments.join('/')\n}\n\n/**\n * All screens whose parent chain passes through `screenId` (children,\n * grandchildren, …). Used to cascade routing-map rewrites when a screen's\n * slug or parent changes.\n */\nexport function collectScreenDescendantIds(\n screenId: ScreenUid,\n screensById: Record<ScreenUid, ScreenRouteNode | undefined>,\n): ScreenUid[] {\n const childrenByParent = new Map<ScreenUid, ScreenUid[]>()\n for (const [id, screen] of Object.entries(screensById)) {\n if (!screen?.parentId) continue\n const siblings = childrenByParent.get(screen.parentId) ?? []\n siblings.push(id)\n childrenByParent.set(screen.parentId, siblings)\n }\n const result: ScreenUid[] = []\n const queue = [...(childrenByParent.get(screenId) ?? [])]\n while (queue.length) {\n const id = queue.shift() as ScreenUid\n if (result.includes(id)) continue\n result.push(id)\n queue.push(...(childrenByParent.get(id) ?? []))\n }\n return result\n}\n\n/**\n * True when making `nextParentId` the parent of `screenId` would create a\n * loop — i.e. the candidate parent is the screen itself or one of its own\n * descendants.\n */\nexport function wouldCreateScreenCycle(\n screenId: ScreenUid,\n nextParentId: ScreenUid | undefined,\n screensById: Record<ScreenUid, ScreenRouteNode | undefined>,\n): boolean {\n if (!nextParentId) return false\n if (nextParentId === screenId) return true\n return collectScreenDescendantIds(screenId, screensById).includes(\n nextParentId,\n )\n}\n\n/** How {@link buildScreenRouteEntries} treats a screen with no entry today. */\nexport interface BuildScreenRouteEntriesOptions {\n /**\n * Is this call a PUBLISH, or only a move? (AGL-2571)\n *\n * An entry in the host's `screens` map is the whole of what makes a path\n * reachable, so writing one IS publishing — there is no second switch. This\n * function is used for both, and until now it could not tell them apart: it\n * wrote a path for every screen in the subtree whose slug chain happened to\n * resolve, whether or not anyone had asked for that screen to go live.\n *\n * What that produced on `aglyn-marketing`: assigning a parent to an\n * unpublished screen that merely CARRIED a slug registered it in the map,\n * and the besigner toolbar — which reads the map — then offered `Unpublish`\n * and an enabled `Live` link for a screen nobody had published, with no\n * `publishedAt` on the document and a 404 at the address.\n *\n * `false` therefore restricts the write to entries that ALREADY exist:\n * paths are rewritten, broken ones are removed, and nothing new goes live.\n * A move is not an activation.\n *\n * Defaults to `true`, which is what the publish buttons mean.\n */\n publish?: boolean\n}\n\n/**\n * Routing-map entries for a screen plus all its descendants under a\n * candidate screens map: a composed path sets the entry, `null` marks an\n * existing entry whose chain no longer resolves for removal. Callers apply\n * the result in one write so slug/parent changes cascade atomically.\n *\n * See {@link BuildScreenRouteEntriesOptions.publish} for the difference\n * between publishing a screen and merely moving one.\n */\nexport function buildScreenRouteEntries(\n screenId: ScreenUid,\n screensById: Record<ScreenUid, ScreenRouteNode | undefined>,\n routingMap: Record<ScreenUid, string> | null | undefined,\n options: BuildScreenRouteEntriesOptions = {},\n): Record<ScreenUid, string | null> {\n const { publish = true } = options\n const entries: Record<ScreenUid, string | null> = {}\n const ids = [screenId, ...collectScreenDescendantIds(screenId, screensById)]\n for (const id of ids) {\n const routed = routingMap?.[id] !== undefined\n const path = composeScreenRoutePath(id, screensById)\n if (path) {\n // A move rewrites what is live; it never puts something new there.\n if (routed || publish) entries[id] = path\n } else if (routed) entries[id] = null\n }\n return entries\n}\n\n/**\n * Looks up which screen currently owns a routing path, for sibling-slug\n * uniqueness checks before publishing.\n */\nexport function findScreenIdByRoutePath(\n screens: Record<ScreenUid, string> | null | undefined,\n path: string,\n): ScreenUid | undefined {\n if (!screens) return undefined\n const entry = Object.entries(screens).find(([, value]) => value === path)\n return entry?.[0]\n}\n\n/**\n * The screen a publish at `path` would collide with, or `undefined` when the\n * address is free to take (AGL-3408).\n *\n * {@link findScreenIdByRoutePath} with one exception: the placeholder home\n * page a site is created with (`host.defaultHomeScreenId`) does not block the\n * site root. It holds `/` only so a new site never answers its own address\n * with a 404, and the first real page published there replaces it — the\n * publish seam removes its entry in the same write. It still blocks any other\n * path it has been moved to, and no other screen is ever passed over.\n *\n * Every publish surface's conflict check reads this rather than the raw owner,\n * so \"is this address taken?\" has one answer across them.\n */\nexport function blockingRouteOwner(\n screens: Record<ScreenUid, string> | null | undefined,\n path: string,\n defaultHomeScreenId?: string | null,\n): ScreenUid | undefined {\n const owner = findScreenIdByRoutePath(screens, path)\n if (owner && path === SCREEN_ROOT_PATH && owner === defaultHomeScreenId) {\n return undefined\n }\n return owner\n}\n\n/** Human-facing URL for a routing-map path (`'/'` stays `/`, `about` → `/about`). */\nexport function screenRoutePathToUrl(path: string): string {\n return path === SCREEN_ROOT_PATH ? SCREEN_ROOT_PATH : `/${path}`\n}\n\n/**\n * A screen's OWN slug segment, read back off its composed routing-map path\n * (`company/about` → `about`) — the inverse of the one step\n * {@link composeScreenRoutePath} adds for this screen (AGL-2572).\n *\n * The slug field holds one segment; the routing map holds the whole composed\n * path. A field that seeds itself from the map therefore has to take the last\n * segment, and the besigner's seeded the whole path instead. Its value then\n * went through {@link normalizeScreenSlug}, which DELETES an interior `/`\n * rather than separating on it, so `alternatives/webflow` came back as the\n * glued `alternativeswebflow` and was stored as the screen's own slug —\n * without anyone typing it. Two screens on `aglyn-marketing` carry a slug of\n * that shape.\n *\n * Not a general path parser: it answers only \"which segment is this screen's\n * own\", which is always the last one.\n */\nexport function ownScreenSlugFromRoutePath(\n path: string | null | undefined,\n): string | undefined {\n if (!path) return undefined\n if (path === SCREEN_ROOT_PATH) return SCREEN_ROOT_PATH\n const segments = path.split('/').filter(Boolean)\n return segments[segments.length - 1]\n}\n\n/** What a screen is ACTUALLY served at, where that is not its own slug. */\nexport interface LinkableScreenRouteSources {\n /**\n * screen id → the routing-map path the screen really answers on, for\n * screens routed by something other than their own slug. Today that is a\n * content collection's LIST template: `/{collectionSlug}` renders it\n * (`composeCollectionTemplatePage`), whatever slug it was published under.\n * Applied LAST, so it also un-drops a screen named in `unrouted`.\n */\n routedElsewhere?: Record<string, string> | null | undefined\n /**\n * Screen ids that serve NO address of their own — a collection ENTRY\n * template (AGL-1267), commerce's PDP/catalog templates (AGL-1270), a\n * screen that says `kind: 'template'` (AGL-1400). The tenant router drops\n * these before matching, so their routing-map path is a 404.\n */\n unrouted?: Iterable<string> | null | undefined\n /**\n * collection id → collection slug, for every CONTENT collection with a slug\n * (AGL-2799): the collections whose listing `/{collectionSlug}` serves. Each\n * becomes a link target of its own, keyed by `formatCollectionLinkValue`,\n * so a link can name a blog's listing whether or not a list template screen\n * renders it.\n */\n collectionListings?: Record<string, string> | null | undefined\n /**\n * entry key (`entry:<collectionId>/<entryId>`) → the entry's path, for the\n * entries a page references (AGL-3118). Unlike every other source, this one\n * is not the whole site: a renderer reads only the entries its own links\n * name, so an entry key missing here means \"not asked for\", and\n * `screenRoutesAnswerFor` judges entry links only against a map that has\n * some.\n */\n entryRoutes?: Record<string, string> | null | undefined\n}\n\n/**\n * Where a collection's RSS feed is served, in the routing map's own format\n * (no leading slash): `blog/rss.xml` for the collection at `/blog`.\n */\nexport function collectionFeedRoutePath(collectionSlug: string): string {\n const listing = collectionListUrl({ collectionSlug }).replace(/^\\/+|\\/+$/g, '')\n return `${listing}/rss.xml`\n}\n\n/**\n * Where a collection ENTRY is served, in the routing map's own format (no\n * leading slash): `blog/hello` for the entry slugged `hello` in the\n * collection at `/blog` — the `/{collection}/{entry}` shape\n * `parseCollectionRoute` reads back.\n */\nexport function collectionEntryRoutePath(\n collectionSlug: string,\n entrySlug: string,\n): string {\n const listing = collectionListUrl({ collectionSlug }).replace(/^\\/+|\\/+$/g, '')\n return `${listing}/${entrySlug.trim().replace(/^\\/+|\\/+$/g, '')}`\n}\n\n/**\n * The routing map as a LINK TARGET table (AGL-1998).\n *\n * The host's `screens` map is written by publishing, one entry per screen\n * under its own composed slug, and the tenant router then edits it at serve\n * time: template screens are dropped (see {@link LinkableScreenRouteSources})\n * and a collection's list template is reached at `/{collectionSlug}` instead.\n * Everything that RESOLVES a screen link — `resolveScreenHref` on the live\n * site, the besigner's screen picker, every `Link`-typed component prop — was\n * reading the raw map, so the two surfaces disagreed with the router in both\n * directions at once:\n *\n * - `/blog` was UNOFFERABLE. The blog's list template is published under\n * `blog-list-template` and serves `/blog`, so no screen link on aglyn.com\n * could point at the site's own blog index.\n * - Every template screen was offered at a path that 404s, and an author who\n * picked one got a dead anchor that looks exactly like a live one.\n *\n * So the map every linking surface reads is derived here, from the same two\n * facts the router uses, and the picker can no longer offer a path the router\n * would refuse.\n *\n * A content collection's LISTING is a target in its own right (AGL-2799),\n * keyed `collection:<collectionId>` beside the screen ids. Its path comes from\n * `collectionListUrl`, the builder the pager, the canonical link and the\n * sitemap already share, so a link cannot give a listing an address the rest\n * of the site does not; and because the key is the collection's id rather than\n * its slug, renaming the slug moves every link to it on the next render. A\n * collection that is gone, or that has lost its slug, has no entry — the same\n * absence `isScreenLinkBroken` reads as a dead target.\n *\n * A raw map with no source given comes back unchanged; an absent map with no\n * overrides and no listings stays absent, because `undefined` means \"nothing\n * resolves yet\" to {@link ScreenLinkContext} and `{}` would mean \"resolved:\n * nowhere\".\n */\nexport function linkableScreenRoutes(\n screens: Record<ScreenUid, string> | null | undefined,\n sources: LinkableScreenRouteSources = {},\n): Record<ScreenUid, string> | undefined {\n const { routedElsewhere, unrouted, collectionListings, entryRoutes } = sources\n const overrides = Object.entries(routedElsewhere ?? {})\n const listings = Object.entries(collectionListings ?? {})\n const entries = Object.entries(entryRoutes ?? {})\n if (!screens && !overrides.length && !listings.length && !entries.length) {\n return undefined\n }\n const next: Record<ScreenUid, string> = { ...(screens ?? {}) }\n for (const id of unrouted ?? []) delete next[id]\n for (const [collectionId, collectionSlug] of listings) {\n const slug =\n typeof collectionSlug === 'string'\n ? collectionSlug.trim().replace(/^\\/+|\\/+$/g, '')\n : ''\n if (!collectionId.trim() || !slug) continue\n // Built by the one listing-URL builder, then stored in the map's own\n // format: no leading slash, which `resolveScreenHref` puts back.\n next[formatCollectionLinkValue(collectionId)] = collectionListUrl({\n collectionSlug: slug,\n }).replace(/^\\/+/, '')\n // The feed hangs off the same listing (AGL-3118), so it follows the same\n // rename and disappears with the same collection.\n next[formatFeedLinkValue(collectionId)] = collectionFeedRoutePath(slug)\n }\n for (const [key, path] of entries) {\n // Only well-formed entry keys, stored under their canonical spelling: this\n // source must not be able to plant a screen id or a listing key the other\n // sources own.\n const entry = parseEntryLinkValue(key)\n if (!entry) continue\n const normalized =\n typeof path === 'string' ? path.trim().replace(/^\\/+|\\/+$/g, '') : ''\n if (!normalized) continue\n next[formatEntryLinkValue(entry.collectionId, entry.entryId)] = normalized\n }\n for (const [id, path] of overrides) {\n // A collection slug (`blog`) arrives in the map's own format already, but\n // the same fact is spelled `/blog` in half the places it is read from, and\n // a stored `/blog` would resolve to `//blog` — an absolute URL to the host\n // `blog`, i.e. off the site entirely.\n const normalized =\n typeof path === 'string' && path !== SCREEN_ROOT_PATH\n ? path.replace(/^\\/+|\\/+$/g, '')\n : path\n if (!normalized) continue\n next[id] = normalized\n }\n return next\n}\n\n/**\n * `kind` of a besigner email document (AGL-395): a screen authored on the\n * Emails page and sent by a campaign, never served at a URL.\n */\nexport const SCREEN_KIND_EMAIL = 'email'\n\n/**\n * `kind` of a collection ENTRY template (AGL-1400): a screen that composes\n * `/{collection}/{entry}` for every entry and has no address of its own.\n *\n * The value exists so that \"is this screen a page?\" stops being a JOIN.\n * Four issues (AGL-1173, AGL-1383, AGL-1387, AGL-1390) were the same sentence\n * at different depths because the answer was derived from a mutable field on a\n * DIFFERENT document — a collection's `entryScreenId` — and every new way to\n * edit that pointer was a new bypass of `screensPerHost`. Here the fact is a\n * property of the screen, server-stamped by /api/hosts/screens and frozen in\n * the rules exactly as `kind: 'email'` has been since AGL-1383.\n *\n * NOT a collection's LIST template. `/{collectionSlug}` renders that exact\n * screen with its entries, so it is a designed reachable page and AGL-1387\n * made it count; converting one would take the page off the site.\n */\nexport const SCREEN_KIND_TEMPLATE = 'template'\n\n/**\n * `kind` of a designed ERROR screen (AGL-2092): the screen a host assigns to\n * one of `errorScreens`' four status slots, rendered on paths that did not\n * match — and so, like an entry template, at no address of its own.\n *\n * the decision on 2026-08-18 was that these must not spend the plan's screen\n * allowance, for the reason AGL-1173 excluded an entry template: the governing\n * question is whether the screen occupies a URL of its own, and a 404 body does\n * not. (A collection's LIST template does — `/{collectionSlug}` renders that\n * exact screen — which is why AGL-1387 left it counting.)\n *\n * ## This value does NOT get `kind: 'template'`'s trust\n *\n * `billableScreenIds` short-circuits on `SCREEN_KIND_TEMPLATE` BEFORE it\n * consults the routing map, because a template is routed on purpose. An error\n * screen is not, so it deliberately falls through to the ordinary rule and\n * **the routing map outranks this field**: a screen still published at an\n * address of its own counts, whatever it says about itself. That is the whole\n * migration story for the error screens that already exist — see\n * {@link ERROR_SCREEN_MAX_PER_HOST}.\n *\n * Stamped only by /api/hosts/screens, as part of binding the screen to a slot,\n * and frozen against the client by the same rule that has frozen `kind` since\n * AGL-1383. Nothing about that is new for this value; what IS new is that the\n * stamp is BOUNDED, because unlike a template there is no natural limit on how\n * many screens somebody would like to declare as error pages.\n */\nexport const SCREEN_KIND_ERROR = 'error'\n\n/**\n * How many screens one host may hold with `kind: 'error'` (AGL-2092).\n *\n * An exclusion the metered party can ask for is a free-screen generator unless\n * something bounds it, and the four bindings in `HostErrorScreens` are the\n * bound: at most one exempt screen per error slot. It is `.length` of the slot\n * list rather than a literal `4` so the two can never disagree.\n *\n * Checked in TWO places, and the second is the one that makes the number true\n * (AGL-2093). At the moment of the STAMP, against the post-state — the\n * AGL-1390 shape, which produces the refusal worth reading because it can name\n * the four slots. And inside `billableScreenIds` itself, where a live error\n * screen past this bound is counted as an ordinary page: the stamp is not the\n * only writer, and a crafted import bundle carrying `kind: 'error'` on all 200\n * of its screens passed no bound at all until the rule owned one. An exclusion\n * enforced only at the write paths somebody remembered is the sentence this arc\n * has now repeated six times.\n *\n * The tempting alternative is to enforce \"bound to a slot\" as an\n * invariant, promoting a screen back to a page whenever its slot is cleared;\n * that is AGL-1390's refuse-the-clear bug wearing a different hat, because\n * promotion raises the count and a host at its cap could then never unassign an\n * error screen. So clearing a slot is always allowed and leaves the screen an\n * error screen — unbilled, unrouted, and one deliberate click from being a page\n * again — and what is bounded is how many of them can exist at once.\n */\nexport const ERROR_SCREEN_MAX_PER_HOST = HOST_ERROR_SCREEN_SLOTS.length\n\n/** The two self-describing fields {@link screenClaimsToBeAPage} reads. */\nexport interface ScreenPageClaim {\n kind?: string\n deletedAt?: unknown\n}\n\n/**\n * Whether a screen document claims to be a page of the site at all (AGL-1383).\n *\n * Four things say it is not: `deletedAt` (soft-deleted — delete stamps the\n * field rather than removing the doc), `kind: 'email'` (an Emails-page\n * document, which has no URL and is rendered only by the campaign sender,\n * straight off the doc), `kind: 'template'` (a collection entry template,\n * which composes `/{collection}/{entry}` and has no address of its own —\n * AGL-1400) and `kind: 'error'` (a screen assigned to one of the host's four\n * error slots, rendered on paths that matched nothing — AGL-2092). All four\n * are also the exclusions `countBillableScreens` subtracts before enforcing\n * `screensPerHost`, and since AGL-1400 they are the WHOLE of that subtraction:\n * the count reads no other collection.\n *\n * Adding the fourth needed no edit to `billableScreenIds` and no edit to\n * `nonPageScreenIds`, which is the property AGL-1439 wrote them for: the\n * routing-map override applies to a new non-page `kind` by default, and the\n * flat infrastructure cap is the complement, so there is no second list to\n * remember. Only `kind: 'template'` is named anywhere else, because only it\n * opts OUT of the routing-map override.\n *\n * That is exactly why this is ONE function with TWO callers rather than two\n * matching filters. Both fields are ordinary client-writable fields on\n * `hosts/{hostId}/screens/{screenId}`, and until AGL-1383 only the count read\n * them: an editor on a Free site could `updateDoc(screenRef, {kind: 'email'})`\n * and the screen stopped counting against the plan while the routing map still\n * pointed at it and the runtime still served it — a live page, for free. An\n * exclusion is only sound if an excluded screen genuinely is not a page, so the\n * serve path asks this question too, and flipping either field now costs the\n * page instead of the plan.\n *\n * A CLAIM, not the answer, for the two fields a client can still reach: the\n * routing map decides reachability, and `countBillableScreens` trusts\n * `deletedAt` / `kind: 'email'` only for screens the map does not route.\n * `kind: 'error'` is in the client-can-still-reach group for counting purposes\n * even though no client writes it either: an error screen that is ALSO\n * published at an address is a page somebody is using as a page, so the map\n * outranks the stamp and it counts. Serving is the other way round — see\n * `getScreen`, which returns error screens rather than 404ing them, because\n * refusing would break the customer's own designed error page without closing\n * anything (they are already paying for it if it is routed).\n *\n * `kind: 'template'` is the exception, and the reason it can be one is that no\n * client writes it — /api/hosts/screens stamps it, demotion lowers the count\n * and promotion is checked exactly like a create, so there is no toggle to\n * launder. A template is ROUTED on purpose (publishing is how the compose\n * pipeline picks it up), which is why the map cannot be the arbiter for it.\n */\nexport function screenClaimsToBeAPage(\n screen: ScreenPageClaim | null | undefined,\n): boolean {\n if (!screen) return false\n return (\n screen.deletedAt == null &&\n screen.kind !== SCREEN_KIND_EMAIL &&\n screen.kind !== SCREEN_KIND_TEMPLATE &&\n screen.kind !== SCREEN_KIND_ERROR\n )\n}\n\n/**\n * One screen, reduced to the two things {@link billableScreenIds} asks about.\n *\n * Rows rather than a query (AGL-1390) because the enforcement points ask about\n * a state that does not exist yet: a promotion is checked against the count it\n * WOULD leave behind. A function that does its own reads can only answer for\n * the present. `readScreenSources` in /api/hosts/resources is the reader.\n */\nexport interface BillableScreenSource {\n id: string\n kind?: unknown\n deletedAt?: unknown\n}\n\n/**\n * A host's `screens` routing map (screen id → route path) as\n * `publishScreenRoute` writes it. Only membership is read, never the path.\n */\nexport type ScreenRoutingMap = Record<string, unknown> | null | undefined\n\n/**\n * WHICH screens spend the plan's screen allowance (AGL-1173).\n *\n * The ids rather than the count, because an enforcement point that cannot name\n * the screen it is refusing over is a refusal the person reading it cannot act\n * on.\n *\n * A plain `screens.count()` charged for three things the subscriber never\n * chose to author, and the screens list — which filters them out — then\n * disagreed with the server about how much of the plan was used:\n *\n * - **Soft-deleted screens.** Delete stamps `deletedAt` rather than\n * removing the doc, so deleting a screen never freed a slot. On the free\n * plan (5 screens) that was a dead end with no way out from the UI.\n * - **Email screens** (`kind: 'email'`), which live on the Emails page and\n * were already excluded from the list count but not from enforcement.\n * - **A collection's ENTRY template** — one screen serving every entry at no\n * URL of its own. Adding a blog cost two of the free plan's five screens\n * before the first page existed.\n *\n * ## Why it lives HERE, next to the predicate (AGL-2093)\n *\n * It was in /api/hosts/resources, and the console's Screens page — which runs\n * the same rule as a precheck before it lets somebody add a page — could not\n * import it from there (that module reaches `@aglyn/aglyn/server`, which pulls\n * `node:stream` in through the API adapter). So the page restated it, and the\n * restatement drifted: it never learned the error-screen bound below, and on a\n * host holding five `kind: 'error'` screens the console offered room the API\n * then refused. A precheck that warns on a different number than the API\n * enforces is worse than no precheck at all, and a rule that has to be\n * restated to be reused will be restated wrongly eventually. One function, two\n * callers.\n *\n * ## ONE document answers it now (AGL-1400)\n *\n * The third exclusion used to be a JOIN: the count read the `collections`\n * collection and subtracted whatever screen ids the template pointers named.\n * That shape produced four issues in one arc, because the other side of the\n * join is editable and each new way to edit it was a new bypass — AGL-1173\n * created the exclusion, AGL-1383 froze the two fields the screen itself\n * carried, AGL-1387 found the list template was a page after all, and AGL-1390\n * found the pointer could be toggled to mint permanent slots and had to move\n * the write server-side and evaluate the cap against the post-state.\n *\n * Since AGL-1400 an entry template says so on its own document\n * (`kind: 'template'`), stamped by /api/hosts/screens and frozen in the rules\n * exactly as `kind: 'email'` is. So this reads the `screens` collection and\n * nothing else: the pointer is a pointer again, freely writable, and it excuses\n * nothing. `screenClaimsToBeAPage` is the whole rule, which is the property\n * that keeps the count and the serve path (`getScreen`) from ever disagreeing.\n *\n * What did NOT change is what anybody pays. A collection's LIST template stays\n * a page — `/{collectionSlug}` renders that exact screen (AGL-1387) — so it is\n * never stamped and still counts, and an entry template stays excluded\n * (AGL-1173's charge is not reinstated).\n *\n * ## The routing map outranks the document, and ONLY ADDS (AGL-1383, AGL-1445)\n *\n * `deletedAt` and `kind: 'email'` are ordinary client-writable fields on the\n * screen's OWN document, and the party they are subtracted on behalf of is the\n * party being metered. `updateDoc(screenRef, {kind: 'email'})` — one write, no\n * route change — used to take a live page off a Free plan's five and leave it\n * serving, because nothing else read either field. So for those two the\n * document's account of itself is consulted only for screens the routing map\n * does not route: **a routed screen counts, whatever it says about itself**.\n *\n * The converse is NOT true and must not become true, which is AGL-1445's\n * settlement. An unrouted screen with no slug — a draft, or an orphan left\n * behind by an unpublish — serves nothing, and it still counts:\n *\n * - `screensPerHost` is a CREATE-TIME gate. /api/hosts/resources counts and\n * refuses; `report-usage` re-measures monthly but RECORDS rather than\n * enforces. The create is the only door.\n * - A screen is born unrouted, and publishing is a separate later act — a\n * client `updateDoc` on the host's `screens` map (`publishScreenRoute`),\n * gated on `canPublishHostContent` and on nothing else.\n *\n * So subtracting unrouted screens would not make the count fairer, it would\n * make every create free — the new document is unrouted by construction — and\n * leave the publishes that follow ungated. A Free site would hold unlimited\n * pages. AGL-1445's option 2 (server-stamping a non-page `kind` on orphans by\n * a sweep) lands in the same place unless the promotion BACK passes a gate,\n * and for an orphan the promotion is the publish. `kind: 'template'` can be\n * trusted precisely because its promotion is `convertScreenKind`, checked\n * exactly like a create; there is no equivalent door in front of the routing\n * map. The price is one slot until somebody deletes the orphan, which is one\n * click and which the Screens page lists like any other row —\n * `unrouted-screen-counts.spec.ts` pins it at the enforcement point.\n *\n * `kind: 'template'` is deliberately outside the map's override, and it is the\n * one value that can be. A template is routed ON PURPOSE — publishing is how\n * the compose pipeline picks it up, and AGL-1267 drops it from routing when the\n * request arrives — so the map cannot be the arbiter for it. What makes\n * trusting it safe is that no client writes it: the demotion that sets it\n * always succeeds (it lowers the count) and the promotion that clears it is\n * checked exactly like a create (it raises it), so there is no toggle to run a\n * loop through.\n */\nexport function billableScreenIds(\n screens: ReadonlyArray<BillableScreenSource>,\n routingMap?: ScreenRoutingMap,\n): Set<string> {\n const routed = new Set(Object.keys(routingMap ?? {}))\n const exemptErrors = exemptErrorScreenIds(screens)\n const billable = new Set<string>()\n for (const screen of screens) {\n if (screen.kind === SCREEN_KIND_TEMPLATE) continue\n const claimsToBeAPage = screenClaimsToBeAPage({\n kind: screen.kind as string,\n deletedAt: screen.deletedAt,\n })\n // A `kind: 'error'` screen OVER the slot bound is a page again (AGL-2093).\n const exemptionSpent =\n screen.kind === SCREEN_KIND_ERROR &&\n screen.deletedAt == null &&\n !exemptErrors.has(screen.id)\n if (routed.has(screen.id) || claimsToBeAPage || exemptionSpent) {\n billable.add(screen.id)\n }\n }\n return billable\n}\n\n/**\n * The live `kind: 'error'` screens whose billing exemption actually HOLDS —\n * at most {@link ERROR_SCREEN_MAX_PER_HOST} of them (AGL-2093).\n *\n * ## Why the bound lives here and not at one endpoint\n *\n * AGL-2092 introduced the exemption and bounded it at four — one per\n * `HostErrorScreens` slot — by checking the post-state at the moment\n * /api/hosts/screens STAMPS the kind. That made the claim true of the assign\n * route and false end to end, because the stamp is not the only writer:\n * `SITE_EXPORT_FIELDS.screens` carries `kind`, so a hand-edited bundle could\n * declare `kind: 'error'` on all 200 of its screens and /api/hosts/import\n * excluded every one of them from `screensPerHost` — never passing the bound,\n * never binding a slot. N unbilled screens, one file.\n *\n * That is the arc's fifth repetition of one sentence (AGL-1173, AGL-1383,\n * AGL-1387, AGL-1390, AGL-1400): *an exclusion enforced at a write path is\n * enforced only at the write paths somebody remembered.* So the bound moves\n * into the rule the enforcement points SHARE. However a screen came to say\n * `kind: 'error'` — assign, import, or the next writer — a host gets four\n * unbilled ones and the rest spend the plan's allowance exactly as pages do.\n *\n * ## Which four\n *\n * The lowest ids, sorted. The choice is arbitrary and the DETERMINISM is not:\n * every enforcement point models a post-state by re-running this over a\n * different row set, and a rule that picked \"the first four encountered\" would\n * hand two callers different answers for the same host. The SIZE of the exempt\n * set never depends on the order — it is `min(live errors, 4)` — so only which\n * ids a refusal message names can move, never whether one is refused.\n *\n * Tombstones are excluded for `nonPageScreenIds`' reason: a cap counting\n * soft-deleted rows is AGL-1173's bug one cap over, where deleting an error\n * screen never frees its slot.\n *\n * The stamp-time bound in /api/hosts/screens is deliberately KEPT. It is the\n * better refusal — it names the four slots and arrives before the write — and\n * this is the backstop under it, so no writer can mint a fifth unbilled error\n * screen even by a path that never consults it.\n */\nexport function exemptErrorScreenIds(\n screens: ReadonlyArray<BillableScreenSource>,\n): Set<string> {\n const live: Array<string> = []\n for (const screen of screens) {\n if (screen.kind !== SCREEN_KIND_ERROR) continue\n if (screen.deletedAt != null) continue\n live.push(screen.id)\n }\n if (live.length <= ERROR_SCREEN_MAX_PER_HOST) return new Set(live)\n return new Set(live.sort().slice(0, ERROR_SCREEN_MAX_PER_HOST))\n}\n\n/**\n * The screens the flat platform cap bounds (AGL-1399, AGL-1439): LIVE documents\n * that are not billable pages — the exact complement of {@link billableScreenIds}.\n *\n * ## Why the complement, and not `kind === 'email' || kind === 'template'`\n *\n * Both issues are one sentence — *a `kind` value that excludes a document from\n * billing, declarable by the metered party* — and a cap that enumerated the two\n * values would be correct until the third arrived, which is how this arc has\n * already gone four times (AGL-1173, AGL-1383, AGL-1387, AGL-1390). Written as\n * the complement, a new non-page `kind` is bounded by the act of adding it to\n * `screenClaimsToBeAPage`: there is no second list to remember.\n *\n * Two properties fall out of taking the complement rather than negating the\n * predicate directly:\n *\n * - **A soft-deleted screen is in NEITHER set.** Delete stamps `deletedAt`\n * rather than removing the doc, and a cap that counted tombstones would be\n * the AGL-1173 bug one cap over — deleting an email document would never free\n * a slot, and a host at the cap could never author another.\n * - **A ROUTED email document counts once, against the plan.** The routing map\n * outranks the document (AGL-1383), so such a screen is a page somebody is\n * already paying for; charging it to the infrastructure cap as well would be\n * counting one document twice. The two sets partition the live screens.\n *\n * Taking the complement is also what makes AGL-2093's bound arrive here for\n * free: an error screen past `ERROR_SCREEN_MAX_PER_HOST` becomes billable\n * above, so it leaves this set in the same act. The partition holds — every\n * live screen is in exactly one of the two — and the fifth error screen is\n * bounded by the PLAN rather than by the flat 5,000, which is the stronger of\n * the two numbers on every finite tier.\n */\nexport function nonPageScreenIds(\n screens: ReadonlyArray<BillableScreenSource>,\n routingMap?: ScreenRoutingMap,\n): Set<string> {\n const billable = billableScreenIds(screens, routingMap)\n const nonPage = new Set<string>()\n for (const screen of screens) {\n if (screen.deletedAt != null) continue\n if (!billable.has(screen.id)) nonPage.add(screen.id)\n }\n return nonPage\n}\n\n/**\n * How many LIVE screen documents a host may hold that are NOT billable pages —\n * email documents, entry templates, and whatever non-page `kind` comes next\n * (AGL-1399, AGL-1439).\n *\n * ## Why a flat number and not a plan dimension\n *\n * Every exclusion `screenClaimsToBeAPage` names is declared by the party being\n * metered: `kind: 'email'` is on the create allow-list because the email\n * composer must send it, and AGL-1400 gave `kind: 'template'` the same\n * billing-excluding meaning. So `POST /api/hosts/resources` with\n * `kind: 'email'` created a document that no cap counted, without limit, on a\n * free plan — unbounded Firestore documents rather than a bypass of anything we\n * sell. AGL-1439 is the same sentence one value over, and a cap enumerating the\n * two values would leave the hole open for the third.\n *\n * The shape is `WEBHOOK_MAX_PER_HOST`'s (AGL-1360): no `OrgEntitlements` key, no\n * variation by plan, counted from a server read at the create path. An\n * `emailDocumentsPerHost` would have been a **pricing** decision — a number the\n * price list has to explain and support has to defend — for a limit whose only\n * job is to bound infrastructure. This is not the charge AGL-1173 removed and it\n * does not reinstate it: nothing here is billable, and `screensPerHost` counts\n * exactly what it counted yesterday.\n *\n * ## Why 5,000\n *\n * Sized to the heaviest library anybody could plausibly author, not to what\n * exists: production's busiest host holds FOUR non-page screens (one email,\n * three entry templates) across five hosts, so any bar sized to today's data\n * would be meaningless. The real quantity is campaigns, which accumulate and are\n * never deleted — a DAILY newsletter reaches 5,000 after thirteen years, and a\n * thrice-weekly one after thirty-two. Entry templates contribute one per\n * collection, so tens at the outside. Nothing a real customer does approaches\n * this, which is the property that matters: the failure mode of a too-low flat\n * cap is blocking legitimate work with an error the price list cannot explain.\n *\n * A cheaper bound is not worth much either. What the number defends against is a\n * script in a loop, and a loop stopped at 5,000 documents is stopped. It also\n * bounds the cost of the count itself — `countBillableScreens` scans the screens\n * collection on every create at one billed read per document (AGL-1440), and\n * before this cap that scan had no ceiling at all.\n */\nexport const NON_PAGE_SCREEN_MAX_PER_HOST = 5000\n"],"names":["HOST_ERROR_SCREEN_SLOTS","collectionListUrl","ENTRY_PREVIEW_ROUTE_SEGMENT","PLATFORM_BRAND_NAME","formatCollectionLinkValue","formatEntryLinkValue","formatFeedLinkValue","parseEntryLinkValue","SCREEN_ROOT_PATH","normalizeScreenSlug","input","trimmed","trim","undefined","segment","toLowerCase","replace","RESERVED_SCREEN_ROUTE_SEGMENTS","reservedScreenRouteSegment","path","first","split","includes","reservedScreenRouteMessage","screenSlugHasPathSeparator","SCREEN_SLUG_PATH_SEPARATOR_MESSAGE","MAX_SCREEN_DEPTH","composeScreenRoutePath","screenId","screensById","segments","visited","Set","currentId","has","size","add","screen","slug","parentId","unshift","length","join","collectScreenDescendantIds","childrenByParent","Map","id","Object","entries","siblings","get","push","set","result","queue","shift","wouldCreateScreenCycle","nextParentId","buildScreenRouteEntries","routingMap","options","publish","ids","routed","findScreenIdByRoutePath","screens","entry","find","value","blockingRouteOwner","defaultHomeScreenId","owner","screenRoutePathToUrl","ownScreenSlugFromRoutePath","filter","Boolean","collectionFeedRoutePath","collectionSlug","listing","collectionEntryRoutePath","entrySlug","linkableScreenRoutes","sources","routedElsewhere","unrouted","collectionListings","entryRoutes","overrides","listings","next","collectionId","key","normalized","entryId","SCREEN_KIND_EMAIL","SCREEN_KIND_TEMPLATE","SCREEN_KIND_ERROR","ERROR_SCREEN_MAX_PER_HOST","screenClaimsToBeAPage","deletedAt","kind","billableScreenIds","keys","exemptErrors","exemptErrorScreenIds","billable","claimsToBeAPage","exemptionSpent","live","sort","slice","nonPageScreenIds","nonPage","NON_PAGE_SCREEN_MAX_PER_HOST"],"mappings":"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"}
|
|
@@ -96,4 +96,24 @@ export declare function starterTemplateDocId(starterId: string, screenKey: strin
|
|
|
96
96
|
export declare function buildStarterTemplateDocs(starter: StarterTemplate): StarterTemplateDoc[];
|
|
97
97
|
/** Every document a starter COULD materialize as, in bundle order. */
|
|
98
98
|
export declare function buildAllStarterTemplateDocs(): StarterTemplateDoc[];
|
|
99
|
+
/**
|
|
100
|
+
* The home page every new site is born with (AGL-3408).
|
|
101
|
+
*
|
|
102
|
+
* A site used to be created with an empty routing map, so the address the
|
|
103
|
+
* create dialog calls live answered with the tenant's 404 until somebody
|
|
104
|
+
* built and published a page. `claimHostForOrg` now writes this screen, its
|
|
105
|
+
* first version and its `/` route in the same transaction that creates the
|
|
106
|
+
* host, which makes "a site with no home page" a state no new site starts in.
|
|
107
|
+
*
|
|
108
|
+
* It is PUBLIC from the first request, so the copy speaks to a visitor, never
|
|
109
|
+
* to the owner — no "edit this in the besigner". The site's name is written in
|
|
110
|
+
* as text, not bound: there is no page-content token for it, and a heading
|
|
111
|
+
* that followed a rename is not worth one.
|
|
112
|
+
*
|
|
113
|
+
* Deliberately not a starter: it is not offered in the gallery, and applying a
|
|
114
|
+
* starter may take the root back from it (see `defaultHomeScreenId`).
|
|
115
|
+
*
|
|
116
|
+
* PERSISTED IDENTIFIERS: every node id below is stored on every new site.
|
|
117
|
+
*/
|
|
118
|
+
export declare function buildDefaultHomeScreen(siteName: string): StarterTemplateScreen;
|
|
99
119
|
export declare const STARTER_TEMPLATES: StarterTemplate[];
|