@aglyn/aglyn 1.0.0-beta.288 → 1.0.0-beta.289
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 +11 -11
- package/src/lib/app-utils/docs-help-sections.generated.js +5 -0
- package/src/lib/app-utils/docs-help-sections.generated.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +7 -1
- package/src/lib/app-utils/docs-help.generated.js +24 -0
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +95 -11
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/element-motion.js +1 -0
- package/src/lib/app-utils/element-motion.js.map +1 -1
- package/src/lib/app-utils/host-status.d.ts +2 -1
- package/src/lib/app-utils/host-status.js +10 -0
- package/src/lib/app-utils/host-status.js.map +1 -1
- package/src/lib/app-utils/screen-slug.d.ts +5 -5
- package/src/lib/app-utils/screen-slug.js +5 -5
- package/src/lib/app-utils/screen-slug.js.map +1 -1
- package/src/lib/app-utils/search-indexing.d.ts +18 -0
- package/src/lib/app-utils/search-indexing.js +16 -2
- package/src/lib/app-utils/search-indexing.js.map +1 -1
- package/src/lib/app-utils/server.d.ts +1 -0
- package/src/lib/app-utils/server.js +2 -0
- package/src/lib/app-utils/server.js.map +1 -1
- package/src/lib/app-utils/site-preview.d.ts +153 -0
- package/src/lib/app-utils/site-preview.js +167 -0
- package/src/lib/app-utils/site-preview.js.map +1 -0
- package/src/lib/foundation/definitions/platform.types.d.ts +27 -0
- package/src/lib/foundation/definitions/platform.types.js.map +1 -1
- package/src/lib/plugin-manager/feature-plugins.d.ts +17 -1
- package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/element-motion.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 { CANVAS_ROOT_ELEMENT_ID } from '../foundation/constants/canvas'\nimport {\n ANIMATION_MAX_DELAY_MS,\n ANIMATION_MAX_DURATION_MS,\n ANIMATION_MAX_STAGGER_STEP_MS,\n NODE_ANIMATION_DELAY_PROP,\n NODE_ANIMATION_DURATION_PROP,\n NODE_ANIMATION_EASE_PROP,\n NODE_ANIMATION_PROPS,\n NODE_ANIMATION_PROP,\n NODE_ANIMATION_STAGGER_PROP,\n NODE_ANIMATION_STAGGER_STEP_PROP,\n NODE_ANIMATION_TRIGGER_PROP,\n type AnimationEase,\n type AnimationPreset,\n type AnimationTrigger,\n} from './element-animation'\n\n/**\n * Entrance motion for a whole page (AGL-3838): the pass that writes the\n * existing animation props (`element-animation.ts`) onto a page built by\n * code — a starter, a new site's home, a page the AI composed — so it moves\n * the way an author would have set it up by hand in the attributes panel.\n *\n * Nothing new is rendered or shipped: the pass only writes the reserved\n * `aglynAnimation*` props the renderer and the tenant's stylesheet already\n * read, with values from their own vocabulary. A page that moves pays the\n * stylesheet and the scroll observer it always paid; a page this pass leaves\n * alone pays nothing.\n *\n * ## The rules\n *\n * - The FIRST section's secondary content — its eyebrow, its lede, its\n * buttons, a small picture — plays on `load`, as a short ladder: each\n * block starts {@link ElementMotionCharacter.heroStepMs} after the one\n * before. Its main heading and its largest picture, the candidates for the\n * page's Largest Contentful Paint, never move: they are painted where\n * they stand from the first frame. Its band — a background color or a\n * cover photo — never moves either.\n * - Every later section's content slides in on `scroll`, each section in\n * turn taking the next of the character's {@link\n * ElementMotionCharacter.sectionPresets}, so a page does not arrive as\n * one repeated move.\n * - A split — a column of words beside a picture — comes in from its\n * sides when the character says {@link ElementMotionCharacter.sides}:\n * the left column from the left, the right from the right, the second a\n * stagger step after the first. A picture whose preset zooms keeps its\n * zoom. Without sides its columns still arrive one after the other, the\n * picture's in the media preset and the words' in the section's.\n * - A group of like items — a grid of cards, a list, the questions of an\n * FAQ, a dataset repeated over one card — STAGGERS its items, one after\n * another, rather than arriving as one block, in the character's {@link\n * ElementMotionCharacter.groupPreset} where it names one.\n * - A picture, and a group whose items each lead with one, takes the\n * character's media preset.\n * - Never touched: forms and their fields, navigation and the site's frame,\n * anything out of the flow (`position: fixed | sticky | absolute` — a\n * cover photo's backdrop is its band's), anything hidden, and\n * anything that already carries an animation prop — an author's choice,\n * `none` included, is never overwritten. A block that HOLDS one of those\n * is not animated as a whole either; its other children are animated one\n * by one instead, so a form never slides in with its section.\n * - Except a picture's FRAME: a box in the flow whose picture is laid over\n * it (`position: absolute`), as the AI's layout compiler draws every\n * picture slot. The frame is the picture, so it moves as one, picture and\n * all, and counts as a picture for the media preset and for a split.\n * - Never nested: once a block animates, nothing inside it does. Two\n * entrances on the same pixels multiply their fades and add their\n * offsets, which reads as a stutter, not as motion.\n *\n * Pure and idempotent: a page it already moved comes back as the same\n * object, which is what lets a caller compare by identity to skip a write.\n *\n * Leaf imports only, the same as `starter-template-nodes.ts`: the starter\n * kit reads this from the console's client gallery and its seed route alike.\n */\n\n/** A page's motion: the presets, curve and timing every entrance on it shares. */\nexport interface ElementMotionCharacter {\n /** Words and blocks: a section's content, a group of cards. */\n preset: AnimationPreset\n /** A picture, or a group whose every item leads with one. */\n mediaPreset: AnimationPreset\n ease: AnimationEase\n durationMs: number\n /** Gap between the items of a staggered group. */\n staggerStepMs: number\n /** Gap between the rungs of the first section's load ladder. */\n heroStepMs: number\n /**\n * The presets later sections take in turn, the second section the first of\n * them; {@link preset} alone when absent. A section's pictures still take\n * {@link mediaPreset}.\n */\n sectionPresets?: readonly AnimationPreset[]\n /** Whether a split's two columns come in from their own sides. */\n sides?: boolean\n /**\n * What a group of like items — a grid of cards, a list, an FAQ — arrives\n * with, whatever its section's turn; the section's own preset when absent.\n * A group whose every item leads with a picture still takes {@link mediaPreset}.\n */\n groupPreset?: AnimationPreset\n}\n\n/** The motion a page gets when nothing says otherwise: a soft rise, as the attributes panel defaults to. */\nexport const ELEMENT_MOTION_DEFAULT: ElementMotionCharacter = {\n preset: 'slide-up',\n mediaPreset: 'fade',\n ease: 'smooth',\n durationMs: 600,\n staggerStepMs: 90,\n heroStepMs: 120,\n sectionPresets: ['slide-up', 'fade'],\n sides: true,\n}\n\n/** The most rungs the first section's ladder climbs; later blocks share the last. */\nexport const ELEMENT_MOTION_HERO_RUNGS = 6\n\n/**\n * Elements that are never animated, nor animated as part of a block that\n * holds them: forms and every field of one, and the site's navigation and\n * frame. A field that slides in under a visitor's cursor is a field they\n * miss, and a header that moves is a site that looks broken.\n */\nexport const ELEMENT_MOTION_NEVER: ReadonlySet<string> = new Set([\n // Forms and their fields.\n 'form',\n 'formField',\n 'muiFormControl',\n 'muiFormLabel',\n 'muiFormControlLabel',\n 'muiFormHelperText',\n 'muiInput',\n 'muiSelect',\n 'muiSelectOption',\n 'muiRadioGroup',\n 'muiRadio',\n 'muiCheckbox',\n 'muiRating',\n 'muiDatePicker',\n 'muiTimePicker',\n 'newsletter-signup',\n 'member-signin',\n 'member-signup',\n 'member-recovery',\n 'booking',\n 'reservation-widget',\n 'customer-account',\n 'return-request',\n 'order-status',\n 'searchBox',\n 'collectionSearch',\n // Navigation and the site's frame.\n 'layoutSlot',\n 'muiAppBar',\n 'muiToolbar',\n 'muiDrawer',\n 'muiDrawerToggle',\n 'muiNavMenu',\n 'muiMegaMenu',\n 'muiBreadcrumbs',\n 'muiTabs',\n 'languageSwitcher',\n 'themeModeSwitcher',\n 'cart',\n])\n\n/** Pictures: what the media preset is for. */\nconst MEDIA: ReadonlySet<string> = new Set(['image', 'video', 'videoEmbed', 'gated-video'])\n\n/** Components that list like items whatever their children are. */\nconst LISTS: ReadonlySet<string> = new Set(['muiList', 'muiImageList', 'collectionEntries'])\n\n/**\n * What a group's items are made of when they are alike: cards, cells,\n * panels, pictures. Words and buttons never count, however alike — a row of\n * two buttons is a call to action, not a group of cards.\n */\nconst ITEMS: ReadonlySet<string> = new Set([\n 'muiCard',\n 'muiPaper',\n 'muiGrid',\n 'muiStack',\n 'muiBox',\n 'muiLinkBox',\n 'muiListItem',\n 'muiImageListItem',\n 'muiAccordion',\n 'reusableInstance',\n 'div',\n 'image',\n 'video',\n])\n\ninterface MotionNode {\n componentId?: string\n props?: Record<string, unknown>\n sx?: Record<string, unknown>\n nodes?: string[]\n hidden?: boolean\n}\n\nexport interface ElementMotionOptions {\n /** The page's root; the canvas root when absent. */\n rootId?: string\n /** The page's sections in order; the root's own children when absent. */\n sectionIds?: readonly string[]\n character?: ElementMotionCharacter\n}\n\nconst clamp = (value: number, max: number): number => Math.min(Math.max(Math.round(value), 0), max)\n\n/**\n * Whether a style value — plain or one per breakpoint — takes the element out\n * of the flow: pinned to the screen, or laid over its band, as a cover\n * photo's backdrop and its scrim are. Either is part of the band, not of what\n * the band holds.\n */\nfunction pins(value: unknown): boolean {\n if (value === 'fixed' || value === 'sticky' || value === 'absolute') return true\n return typeof value === 'object' && value !== null && Object.values(value).some(pins)\n}\n\n/**\n * Writes entrance motion onto a page's nodes by the rules above, and returns\n * the new node map — or `nodes` itself, unchanged, when there was nothing to\n * move.\n */\nexport function applyElementMotion<T extends Record<string, any>>(nodes: T, options: ElementMotionOptions = {}): T {\n if (!nodes) return nodes\n const character = options.character ?? ELEMENT_MOTION_DEFAULT\n const rootId = options.rootId ?? CANVAS_ROOT_ELEMENT_ID\n const node = (id: string): MotionNode | undefined => nodes[id] as MotionNode | undefined\n const children = (id: string): string[] => (node(id)?.nodes ?? []).filter((child) => typeof child === 'string' && !!node(child))\n const patches = new Map<string, Record<string, unknown>>()\n\n /** The first section's Largest Contentful Paint candidates, painted where they stand. */\n const still = new Set<string>()\n /** Left alone for what it is, wherever it stands. */\n const refused = (id: string): boolean => {\n const at = node(id)\n if (!at) return true\n if (at.hidden || still.has(id)) return true\n if (ELEMENT_MOTION_NEVER.has(String(at.componentId ?? ''))) return true\n return !!at.props && NODE_ANIMATION_PROPS.some((prop) => at.props?.[prop] !== undefined)\n }\n /** Left alone, and so is everything inside it. */\n const closed = (id: string): boolean => refused(id) || pins(node(id)?.sx?.['position'])\n /** A picture laid over its box, free to move with it. */\n const overlay = (id: string): boolean =>\n MEDIA.has(String(node(id)?.componentId ?? '')) && pins(node(id)?.sx?.['position']) && !refused(id)\n /**\n * A picture's frame: a box in the flow whose picture is laid over it\n * (`position: absolute`, filling it), beside at most the placeholder icon\n * an empty slot shows. The frame IS the picture, so it moves as one,\n * picture and all; its picture being out of the flow does not hold it still\n * the way a cover's backdrop holds its band, which is itself out of the flow.\n */\n const isFrame = (id: string): boolean => {\n const kids = children(id)\n return (\n !closed(id) &&\n kids.some(overlay) &&\n kids.every((child) => overlay(child) || (node(child)?.componentId === 'icon' && !refused(child) && !children(child).length))\n )\n }\n const holdsClosed = new Map<string, boolean>()\n /** The element, or one inside it, is left alone: it cannot move as one block. */\n const holds = (id: string): boolean => {\n const known = holdsClosed.get(id)\n if (known !== undefined) return known\n holdsClosed.set(id, false)\n const result = closed(id) || (!isFrame(id) && children(id).some(holds))\n holdsClosed.set(id, result)\n return result\n }\n const groupMemo = new Map<string, boolean>()\n const belowMemo = new Map<string, boolean>()\n /**\n * A group of like items, whose items stagger. A grid or a row of panels\n * that holds a group of its own is the LAYOUT around it — a heading beside\n * its cards — so it is opened up and the group inside staggers instead.\n */\n const isGroup = (id: string): boolean => {\n const known = groupMemo.get(id)\n if (known !== undefined) return known\n const at = node(id) as MotionNode\n const componentId = String(at.componentId ?? '')\n const kids = children(id)\n let result: boolean\n // A dataset repeated over one card renders one child per record.\n if ((at.props?.['repeatDataset'] || LISTS.has(componentId)) && kids.length) result = true\n else if (kids.length < 2) result = false\n else {\n const first = String(node(kids[0])?.componentId ?? '')\n const alike =\n (componentId === 'muiGrid' && !!at.props?.['container']) ||\n (ITEMS.has(first) && kids.every((child) => node(child)?.componentId === first))\n result = alike && !holdsGroup(id)\n }\n groupMemo.set(id, result)\n return result\n }\n /** A group sits somewhere inside the element. */\n const holdsGroup = (id: string): boolean => {\n const known = belowMemo.get(id)\n if (known !== undefined) return known\n const result = children(id).some((child) => isGroup(child) || holdsGroup(child))\n belowMemo.set(id, result)\n return result\n }\n /** Leads with a picture: its first leaf, down its first children, is one, or the frame of one. */\n const leadsWithMedia = (id: string): boolean => {\n let at = id\n for (let depth = 0; depth < 12; depth += 1) {\n if (MEDIA.has(String(node(at)?.componentId ?? '')) || isFrame(at)) return true\n const kids = children(at)\n if (!kids.length) return false\n at = kids[0]\n }\n return false\n }\n const presetOf = (id: string, group: boolean, words: AnimationPreset): AnimationPreset => {\n const media = group ? children(id).every(leadsWithMedia) : leadsWithMedia(id)\n return media ? character.mediaPreset : words\n }\n\n const animate = (\n id: string,\n trigger: AnimationTrigger,\n delayMs: number,\n words: AnimationPreset = character.preset,\n side?: AnimationPreset,\n ): void => {\n const group = isGroup(id)\n const preset = presetOf(id, group, group && character.groupPreset ? character.groupPreset : words)\n patches.set(id, {\n [NODE_ANIMATION_PROP]: side && !preset.startsWith('zoom') ? side : preset,\n [NODE_ANIMATION_TRIGGER_PROP]: trigger,\n [NODE_ANIMATION_DURATION_PROP]: clamp(character.durationMs, ANIMATION_MAX_DURATION_MS),\n ...(delayMs > 0 ? { [NODE_ANIMATION_DELAY_PROP]: clamp(delayMs, ANIMATION_MAX_DELAY_MS) } : {}),\n [NODE_ANIMATION_EASE_PROP]: character.ease,\n ...(group\n ? {\n [NODE_ANIMATION_STAGGER_PROP]: true,\n [NODE_ANIMATION_STAGGER_STEP_PROP]: clamp(character.staggerStepMs, ANIMATION_MAX_STAGGER_STEP_MS),\n }\n : {}),\n })\n }\n\n /** Down through wrappers of one child, to where a section's content starts. */\n const contentOf = (id: string): string => {\n let at = id\n while (!closed(at) && !isGroup(at)) {\n const kids = children(at)\n if (kids.length !== 1) break\n at = kids[0]\n }\n return at\n }\n\n /**\n * A column of words beside a picture: two blocks, one of which leads with a\n * picture and one of which does not, neither left alone. Two cards that\n * both lead with one are a group, and stagger.\n */\n const isSplit = (id: string): boolean => {\n const kids = children(id)\n return kids.length === 2 && leadsWithMedia(kids[0]) !== leadsWithMedia(kids[1]) && !kids.some(holds)\n }\n\n /** A later section's content: on scroll, groups staggered, splits from their sides, nothing nested. */\n const place = (id: string, words: AnimationPreset): void => {\n if (closed(id)) return\n if (isSplit(id)) {\n const [start, end] = children(id)\n animate(start, 'scroll', 0, words, character.sides ? 'slide-right' : undefined)\n animate(end, 'scroll', character.staggerStepMs, words, character.sides ? 'slide-left' : undefined)\n return\n }\n if (holds(id) || (!isGroup(id) && holdsGroup(id))) {\n for (const child of children(id)) place(child, words)\n return\n }\n animate(id, 'scroll', 0, words)\n }\n\n /** Down through wrappers of one child that are free to move. */\n const descend = (id: string): string => {\n let at = id\n for (let kids = children(at); kids.length === 1 && !closed(kids[0]); kids = children(at)) at = kids[0]\n return at\n }\n\n /**\n * The first section's content: on load, one rung per block, around the\n * blocks that stay still. A rung that holds one is opened up, down to the\n * parts beside it, so the eyebrow, the lede and the buttons still climb.\n */\n const ladder = (id: string): void => {\n if (closed(id)) return\n // A repeat's one child is the card every record is drawn with: the\n // repeat staggers its records rather than each record playing at once.\n if (isGroup(id) && children(id).length < 2) return animate(id, 'load', 0)\n const around = (at: string): string[] =>\n children(at).flatMap((child) => (closed(child) ? [] : holds(child) ? around(child) : [child]))\n const base = children(id).length ? around(id) : [id]\n // A column of words beside a picture is opened up, so its lines climb one\n // after another before the picture lands.\n const rungs: string[] = []\n base.forEach((rung, index) => {\n const inner = descend(rung)\n const kids = children(inner)\n const opens =\n kids.length >= 2 &&\n !isGroup(inner) &&\n !leadsWithMedia(inner) &&\n rungs.length + kids.length + (base.length - index - 1) <= ELEMENT_MOTION_HERO_RUNGS\n rungs.push(...(opens ? kids : [rung]))\n })\n // A rung left alone takes no step of the ladder: the first block that moves starts at once.\n rungs\n .filter((rung) => !closed(rung))\n .forEach((rung, index) => animate(rung, 'load', Math.min(index, ELEMENT_MOTION_HERO_RUNGS - 1) * character.heroStepMs))\n }\n\n /**\n * What the first section paints first, and so what Lighthouse times as its\n * Largest Contentful Paint (AGL-3581): its main heading — the h1, else its\n * first heading — and its largest picture. Neither ever starts at opacity 0,\n * which would hold the paint back by the length of the entrance.\n */\n const markStill = (sectionId: string): void => {\n const order: string[] = []\n const walk = (id: string): void => {\n order.push(id)\n for (const child of children(id)) walk(child)\n }\n walk(sectionId)\n const typeOf = (id: string) => {\n const at = node(id)\n return at?.componentId === 'muiTypography' ? String(at.props?.['variant'] ?? '') : null\n }\n const main = (id: string) => /^(h1|display)/i.test(typeOf(id) ?? '') || (typeOf(id) !== null && node(id)?.props?.['component'] === 'h1')\n const heading = order.find(main) ?? order.find((id) => /^(h[1-6]|display)/i.test(typeOf(id) ?? ''))\n if (heading) still.add(heading)\n const size = (id: string): number => {\n const at = node(id)\n const read = (value: unknown) => (typeof value === 'number' ? value : parseFloat(String(value ?? '')) || 0)\n return Math.max(read(at?.props?.['height']), read(at?.props?.['width']), read(at?.sx?.['height']), read(at?.sx?.['width']))\n }\n let picture: string | undefined\n for (const id of order) {\n if (MEDIA.has(String(node(id)?.componentId ?? '')) && (picture === undefined || size(id) > size(picture))) picture = id\n }\n if (picture) still.add(picture)\n }\n\n const sections = (options.sectionIds ?? children(rootId)).filter((id) => !!node(id))\n if (sections[0]) markStill(sections[0])\n const turns = character.sectionPresets?.length ? character.sectionPresets : [character.preset]\n sections.forEach((id, index) => {\n if (closed(id)) return\n const content = contentOf(id)\n // The band itself never moves — only what it holds — so a section that\n // is nothing but its band has nothing to move.\n if (content === id && !children(id).length) return\n const words = turns[(index - 1) % turns.length] ?? character.preset\n if (index === 0) ladder(content)\n else if (content === id && !isGroup(id) && !isSplit(id))\n for (const child of children(id)) place(child, words)\n else place(content, words)\n })\n\n if (!patches.size) return nodes\n const next: Record<string, any> = { ...nodes }\n for (const [id, props] of patches) {\n next[id] = { ...nodes[id], props: { ...(nodes[id]?.props ?? {}), ...props } }\n }\n return next as T\n}\n"],"names":["CANVAS_ROOT_ELEMENT_ID","ANIMATION_MAX_DELAY_MS","ANIMATION_MAX_DURATION_MS","ANIMATION_MAX_STAGGER_STEP_MS","NODE_ANIMATION_DELAY_PROP","NODE_ANIMATION_DURATION_PROP","NODE_ANIMATION_EASE_PROP","NODE_ANIMATION_PROPS","NODE_ANIMATION_PROP","NODE_ANIMATION_STAGGER_PROP","NODE_ANIMATION_STAGGER_STEP_PROP","NODE_ANIMATION_TRIGGER_PROP","ELEMENT_MOTION_DEFAULT","preset","mediaPreset","ease","durationMs","staggerStepMs","heroStepMs","sectionPresets","sides","ELEMENT_MOTION_HERO_RUNGS","ELEMENT_MOTION_NEVER","Set","MEDIA","LISTS","ITEMS","clamp","value","max","Math","min","round","pins","Object","values","some","applyElementMotion","nodes","options","character","rootId","node","id","children","filter","child","patches","Map","still","refused","at","hidden","has","String","componentId","props","prop","undefined","closed","sx","overlay","isFrame","kids","every","length","holdsClosed","holds","known","get","set","result","groupMemo","belowMemo","isGroup","first","alike","holdsGroup","leadsWithMedia","depth","presetOf","group","words","media","animate","trigger","delayMs","side","groupPreset","startsWith","contentOf","isSplit","place","start","end","descend","ladder","around","flatMap","base","rungs","forEach","rung","index","inner","opens","push","markStill","sectionId","order","walk","typeOf","main","test","heading","find","add","size","read","parseFloat","picture","sections","sectionIds","turns","content","next"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/element-motion.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 { CANVAS_ROOT_ELEMENT_ID } from '../foundation/constants/canvas'\nimport {\n ANIMATION_MAX_DELAY_MS,\n ANIMATION_MAX_DURATION_MS,\n ANIMATION_MAX_STAGGER_STEP_MS,\n NODE_ANIMATION_DELAY_PROP,\n NODE_ANIMATION_DURATION_PROP,\n NODE_ANIMATION_EASE_PROP,\n NODE_ANIMATION_PROPS,\n NODE_ANIMATION_PROP,\n NODE_ANIMATION_STAGGER_PROP,\n NODE_ANIMATION_STAGGER_STEP_PROP,\n NODE_ANIMATION_TRIGGER_PROP,\n type AnimationEase,\n type AnimationPreset,\n type AnimationTrigger,\n} from './element-animation'\n\n/**\n * Entrance motion for a whole page (AGL-3838): the pass that writes the\n * existing animation props (`element-animation.ts`) onto a page built by\n * code — a starter, a new site's home, a page the AI composed — so it moves\n * the way an author would have set it up by hand in the attributes panel.\n *\n * Nothing new is rendered or shipped: the pass only writes the reserved\n * `aglynAnimation*` props the renderer and the tenant's stylesheet already\n * read, with values from their own vocabulary. A page that moves pays the\n * stylesheet and the scroll observer it always paid; a page this pass leaves\n * alone pays nothing.\n *\n * ## The rules\n *\n * - The FIRST section's secondary content — its eyebrow, its lede, its\n * buttons, a small picture — plays on `load`, as a short ladder: each\n * block starts {@link ElementMotionCharacter.heroStepMs} after the one\n * before. Its main heading and its largest picture, the candidates for the\n * page's Largest Contentful Paint, never move: they are painted where\n * they stand from the first frame. Its band — a background color or a\n * cover photo — never moves either.\n * - Every later section's content slides in on `scroll`, each section in\n * turn taking the next of the character's {@link\n * ElementMotionCharacter.sectionPresets}, so a page does not arrive as\n * one repeated move.\n * - A split — a column of words beside a picture — comes in from its\n * sides when the character says {@link ElementMotionCharacter.sides}:\n * the left column from the left, the right from the right, the second a\n * stagger step after the first. A picture whose preset zooms keeps its\n * zoom. Without sides its columns still arrive one after the other, the\n * picture's in the media preset and the words' in the section's.\n * - A group of like items — a grid of cards, a list, the questions of an\n * FAQ, a dataset repeated over one card — STAGGERS its items, one after\n * another, rather than arriving as one block, in the character's {@link\n * ElementMotionCharacter.groupPreset} where it names one.\n * - A picture, and a group whose items each lead with one, takes the\n * character's media preset.\n * - Never touched: forms and their fields, navigation and the site's frame,\n * anything out of the flow (`position: fixed | sticky | absolute` — a\n * cover photo's backdrop is its band's), anything hidden, and\n * anything that already carries an animation prop — an author's choice,\n * `none` included, is never overwritten. A block that HOLDS one of those\n * is not animated as a whole either; its other children are animated one\n * by one instead, so a form never slides in with its section.\n * - Except a picture's FRAME: a box in the flow whose picture is laid over\n * it (`position: absolute`), as the AI's layout compiler draws every\n * picture slot. The frame is the picture, so it moves as one, picture and\n * all, and counts as a picture for the media preset and for a split.\n * - Never nested: once a block animates, nothing inside it does. Two\n * entrances on the same pixels multiply their fades and add their\n * offsets, which reads as a stutter, not as motion.\n *\n * Pure and idempotent: a page it already moved comes back as the same\n * object, which is what lets a caller compare by identity to skip a write.\n *\n * Leaf imports only, the same as `starter-template-nodes.ts`: the starter\n * kit reads this from the console's client gallery and its seed route alike.\n */\n\n/** A page's motion: the presets, curve and timing every entrance on it shares. */\nexport interface ElementMotionCharacter {\n /** Words and blocks: a section's content, a group of cards. */\n preset: AnimationPreset\n /** A picture, or a group whose every item leads with one. */\n mediaPreset: AnimationPreset\n ease: AnimationEase\n durationMs: number\n /** Gap between the items of a staggered group. */\n staggerStepMs: number\n /** Gap between the rungs of the first section's load ladder. */\n heroStepMs: number\n /**\n * The presets later sections take in turn, the second section the first of\n * them; {@link preset} alone when absent. A section's pictures still take\n * {@link mediaPreset}.\n */\n sectionPresets?: readonly AnimationPreset[]\n /** Whether a split's two columns come in from their own sides. */\n sides?: boolean\n /**\n * What a group of like items — a grid of cards, a list, an FAQ — arrives\n * with, whatever its section's turn; the section's own preset when absent.\n * A group whose every item leads with a picture still takes {@link mediaPreset}.\n */\n groupPreset?: AnimationPreset\n}\n\n/** The motion a page gets when nothing says otherwise: a soft rise, as the attributes panel defaults to. */\nexport const ELEMENT_MOTION_DEFAULT: ElementMotionCharacter = {\n preset: 'slide-up',\n mediaPreset: 'fade',\n ease: 'smooth',\n durationMs: 600,\n staggerStepMs: 90,\n heroStepMs: 120,\n sectionPresets: ['slide-up', 'fade'],\n sides: true,\n}\n\n/** The most rungs the first section's ladder climbs; later blocks share the last. */\nexport const ELEMENT_MOTION_HERO_RUNGS = 6\n\n/**\n * Elements that are never animated, nor animated as part of a block that\n * holds them: forms and every field of one, and the site's navigation and\n * frame. A field that slides in under a visitor's cursor is a field they\n * miss, and a header that moves is a site that looks broken.\n */\nexport const ELEMENT_MOTION_NEVER: ReadonlySet<string> = new Set([\n // Forms and their fields.\n 'form',\n 'formField',\n 'muiFormControl',\n 'muiFormLabel',\n 'muiFormControlLabel',\n 'muiFormHelperText',\n 'muiInput',\n 'muiSelect',\n 'muiSelectOption',\n 'muiRadioGroup',\n 'muiRadio',\n 'muiCheckbox',\n 'muiRating',\n 'muiDatePicker',\n 'muiTimePicker',\n 'newsletter-signup',\n 'member-signin',\n 'member-signup',\n 'member-recovery',\n 'booking',\n 'reservation-widget',\n 'customer-account',\n 'return-request',\n 'order-status',\n 'quote-page',\n 'searchBox',\n 'collectionSearch',\n // Navigation and the site's frame.\n 'layoutSlot',\n 'muiAppBar',\n 'muiToolbar',\n 'muiDrawer',\n 'muiDrawerToggle',\n 'muiNavMenu',\n 'muiMegaMenu',\n 'muiBreadcrumbs',\n 'muiTabs',\n 'languageSwitcher',\n 'themeModeSwitcher',\n 'cart',\n])\n\n/** Pictures: what the media preset is for. */\nconst MEDIA: ReadonlySet<string> = new Set(['image', 'video', 'videoEmbed', 'gated-video'])\n\n/** Components that list like items whatever their children are. */\nconst LISTS: ReadonlySet<string> = new Set(['muiList', 'muiImageList', 'collectionEntries'])\n\n/**\n * What a group's items are made of when they are alike: cards, cells,\n * panels, pictures. Words and buttons never count, however alike — a row of\n * two buttons is a call to action, not a group of cards.\n */\nconst ITEMS: ReadonlySet<string> = new Set([\n 'muiCard',\n 'muiPaper',\n 'muiGrid',\n 'muiStack',\n 'muiBox',\n 'muiLinkBox',\n 'muiListItem',\n 'muiImageListItem',\n 'muiAccordion',\n 'reusableInstance',\n 'div',\n 'image',\n 'video',\n])\n\ninterface MotionNode {\n componentId?: string\n props?: Record<string, unknown>\n sx?: Record<string, unknown>\n nodes?: string[]\n hidden?: boolean\n}\n\nexport interface ElementMotionOptions {\n /** The page's root; the canvas root when absent. */\n rootId?: string\n /** The page's sections in order; the root's own children when absent. */\n sectionIds?: readonly string[]\n character?: ElementMotionCharacter\n}\n\nconst clamp = (value: number, max: number): number => Math.min(Math.max(Math.round(value), 0), max)\n\n/**\n * Whether a style value — plain or one per breakpoint — takes the element out\n * of the flow: pinned to the screen, or laid over its band, as a cover\n * photo's backdrop and its scrim are. Either is part of the band, not of what\n * the band holds.\n */\nfunction pins(value: unknown): boolean {\n if (value === 'fixed' || value === 'sticky' || value === 'absolute') return true\n return typeof value === 'object' && value !== null && Object.values(value).some(pins)\n}\n\n/**\n * Writes entrance motion onto a page's nodes by the rules above, and returns\n * the new node map — or `nodes` itself, unchanged, when there was nothing to\n * move.\n */\nexport function applyElementMotion<T extends Record<string, any>>(nodes: T, options: ElementMotionOptions = {}): T {\n if (!nodes) return nodes\n const character = options.character ?? ELEMENT_MOTION_DEFAULT\n const rootId = options.rootId ?? CANVAS_ROOT_ELEMENT_ID\n const node = (id: string): MotionNode | undefined => nodes[id] as MotionNode | undefined\n const children = (id: string): string[] => (node(id)?.nodes ?? []).filter((child) => typeof child === 'string' && !!node(child))\n const patches = new Map<string, Record<string, unknown>>()\n\n /** The first section's Largest Contentful Paint candidates, painted where they stand. */\n const still = new Set<string>()\n /** Left alone for what it is, wherever it stands. */\n const refused = (id: string): boolean => {\n const at = node(id)\n if (!at) return true\n if (at.hidden || still.has(id)) return true\n if (ELEMENT_MOTION_NEVER.has(String(at.componentId ?? ''))) return true\n return !!at.props && NODE_ANIMATION_PROPS.some((prop) => at.props?.[prop] !== undefined)\n }\n /** Left alone, and so is everything inside it. */\n const closed = (id: string): boolean => refused(id) || pins(node(id)?.sx?.['position'])\n /** A picture laid over its box, free to move with it. */\n const overlay = (id: string): boolean =>\n MEDIA.has(String(node(id)?.componentId ?? '')) && pins(node(id)?.sx?.['position']) && !refused(id)\n /**\n * A picture's frame: a box in the flow whose picture is laid over it\n * (`position: absolute`, filling it), beside at most the placeholder icon\n * an empty slot shows. The frame IS the picture, so it moves as one,\n * picture and all; its picture being out of the flow does not hold it still\n * the way a cover's backdrop holds its band, which is itself out of the flow.\n */\n const isFrame = (id: string): boolean => {\n const kids = children(id)\n return (\n !closed(id) &&\n kids.some(overlay) &&\n kids.every((child) => overlay(child) || (node(child)?.componentId === 'icon' && !refused(child) && !children(child).length))\n )\n }\n const holdsClosed = new Map<string, boolean>()\n /** The element, or one inside it, is left alone: it cannot move as one block. */\n const holds = (id: string): boolean => {\n const known = holdsClosed.get(id)\n if (known !== undefined) return known\n holdsClosed.set(id, false)\n const result = closed(id) || (!isFrame(id) && children(id).some(holds))\n holdsClosed.set(id, result)\n return result\n }\n const groupMemo = new Map<string, boolean>()\n const belowMemo = new Map<string, boolean>()\n /**\n * A group of like items, whose items stagger. A grid or a row of panels\n * that holds a group of its own is the LAYOUT around it — a heading beside\n * its cards — so it is opened up and the group inside staggers instead.\n */\n const isGroup = (id: string): boolean => {\n const known = groupMemo.get(id)\n if (known !== undefined) return known\n const at = node(id) as MotionNode\n const componentId = String(at.componentId ?? '')\n const kids = children(id)\n let result: boolean\n // A dataset repeated over one card renders one child per record.\n if ((at.props?.['repeatDataset'] || LISTS.has(componentId)) && kids.length) result = true\n else if (kids.length < 2) result = false\n else {\n const first = String(node(kids[0])?.componentId ?? '')\n const alike =\n (componentId === 'muiGrid' && !!at.props?.['container']) ||\n (ITEMS.has(first) && kids.every((child) => node(child)?.componentId === first))\n result = alike && !holdsGroup(id)\n }\n groupMemo.set(id, result)\n return result\n }\n /** A group sits somewhere inside the element. */\n const holdsGroup = (id: string): boolean => {\n const known = belowMemo.get(id)\n if (known !== undefined) return known\n const result = children(id).some((child) => isGroup(child) || holdsGroup(child))\n belowMemo.set(id, result)\n return result\n }\n /** Leads with a picture: its first leaf, down its first children, is one, or the frame of one. */\n const leadsWithMedia = (id: string): boolean => {\n let at = id\n for (let depth = 0; depth < 12; depth += 1) {\n if (MEDIA.has(String(node(at)?.componentId ?? '')) || isFrame(at)) return true\n const kids = children(at)\n if (!kids.length) return false\n at = kids[0]\n }\n return false\n }\n const presetOf = (id: string, group: boolean, words: AnimationPreset): AnimationPreset => {\n const media = group ? children(id).every(leadsWithMedia) : leadsWithMedia(id)\n return media ? character.mediaPreset : words\n }\n\n const animate = (\n id: string,\n trigger: AnimationTrigger,\n delayMs: number,\n words: AnimationPreset = character.preset,\n side?: AnimationPreset,\n ): void => {\n const group = isGroup(id)\n const preset = presetOf(id, group, group && character.groupPreset ? character.groupPreset : words)\n patches.set(id, {\n [NODE_ANIMATION_PROP]: side && !preset.startsWith('zoom') ? side : preset,\n [NODE_ANIMATION_TRIGGER_PROP]: trigger,\n [NODE_ANIMATION_DURATION_PROP]: clamp(character.durationMs, ANIMATION_MAX_DURATION_MS),\n ...(delayMs > 0 ? { [NODE_ANIMATION_DELAY_PROP]: clamp(delayMs, ANIMATION_MAX_DELAY_MS) } : {}),\n [NODE_ANIMATION_EASE_PROP]: character.ease,\n ...(group\n ? {\n [NODE_ANIMATION_STAGGER_PROP]: true,\n [NODE_ANIMATION_STAGGER_STEP_PROP]: clamp(character.staggerStepMs, ANIMATION_MAX_STAGGER_STEP_MS),\n }\n : {}),\n })\n }\n\n /** Down through wrappers of one child, to where a section's content starts. */\n const contentOf = (id: string): string => {\n let at = id\n while (!closed(at) && !isGroup(at)) {\n const kids = children(at)\n if (kids.length !== 1) break\n at = kids[0]\n }\n return at\n }\n\n /**\n * A column of words beside a picture: two blocks, one of which leads with a\n * picture and one of which does not, neither left alone. Two cards that\n * both lead with one are a group, and stagger.\n */\n const isSplit = (id: string): boolean => {\n const kids = children(id)\n return kids.length === 2 && leadsWithMedia(kids[0]) !== leadsWithMedia(kids[1]) && !kids.some(holds)\n }\n\n /** A later section's content: on scroll, groups staggered, splits from their sides, nothing nested. */\n const place = (id: string, words: AnimationPreset): void => {\n if (closed(id)) return\n if (isSplit(id)) {\n const [start, end] = children(id)\n animate(start, 'scroll', 0, words, character.sides ? 'slide-right' : undefined)\n animate(end, 'scroll', character.staggerStepMs, words, character.sides ? 'slide-left' : undefined)\n return\n }\n if (holds(id) || (!isGroup(id) && holdsGroup(id))) {\n for (const child of children(id)) place(child, words)\n return\n }\n animate(id, 'scroll', 0, words)\n }\n\n /** Down through wrappers of one child that are free to move. */\n const descend = (id: string): string => {\n let at = id\n for (let kids = children(at); kids.length === 1 && !closed(kids[0]); kids = children(at)) at = kids[0]\n return at\n }\n\n /**\n * The first section's content: on load, one rung per block, around the\n * blocks that stay still. A rung that holds one is opened up, down to the\n * parts beside it, so the eyebrow, the lede and the buttons still climb.\n */\n const ladder = (id: string): void => {\n if (closed(id)) return\n // A repeat's one child is the card every record is drawn with: the\n // repeat staggers its records rather than each record playing at once.\n if (isGroup(id) && children(id).length < 2) return animate(id, 'load', 0)\n const around = (at: string): string[] =>\n children(at).flatMap((child) => (closed(child) ? [] : holds(child) ? around(child) : [child]))\n const base = children(id).length ? around(id) : [id]\n // A column of words beside a picture is opened up, so its lines climb one\n // after another before the picture lands.\n const rungs: string[] = []\n base.forEach((rung, index) => {\n const inner = descend(rung)\n const kids = children(inner)\n const opens =\n kids.length >= 2 &&\n !isGroup(inner) &&\n !leadsWithMedia(inner) &&\n rungs.length + kids.length + (base.length - index - 1) <= ELEMENT_MOTION_HERO_RUNGS\n rungs.push(...(opens ? kids : [rung]))\n })\n // A rung left alone takes no step of the ladder: the first block that moves starts at once.\n rungs\n .filter((rung) => !closed(rung))\n .forEach((rung, index) => animate(rung, 'load', Math.min(index, ELEMENT_MOTION_HERO_RUNGS - 1) * character.heroStepMs))\n }\n\n /**\n * What the first section paints first, and so what Lighthouse times as its\n * Largest Contentful Paint (AGL-3581): its main heading — the h1, else its\n * first heading — and its largest picture. Neither ever starts at opacity 0,\n * which would hold the paint back by the length of the entrance.\n */\n const markStill = (sectionId: string): void => {\n const order: string[] = []\n const walk = (id: string): void => {\n order.push(id)\n for (const child of children(id)) walk(child)\n }\n walk(sectionId)\n const typeOf = (id: string) => {\n const at = node(id)\n return at?.componentId === 'muiTypography' ? String(at.props?.['variant'] ?? '') : null\n }\n const main = (id: string) => /^(h1|display)/i.test(typeOf(id) ?? '') || (typeOf(id) !== null && node(id)?.props?.['component'] === 'h1')\n const heading = order.find(main) ?? order.find((id) => /^(h[1-6]|display)/i.test(typeOf(id) ?? ''))\n if (heading) still.add(heading)\n const size = (id: string): number => {\n const at = node(id)\n const read = (value: unknown) => (typeof value === 'number' ? value : parseFloat(String(value ?? '')) || 0)\n return Math.max(read(at?.props?.['height']), read(at?.props?.['width']), read(at?.sx?.['height']), read(at?.sx?.['width']))\n }\n let picture: string | undefined\n for (const id of order) {\n if (MEDIA.has(String(node(id)?.componentId ?? '')) && (picture === undefined || size(id) > size(picture))) picture = id\n }\n if (picture) still.add(picture)\n }\n\n const sections = (options.sectionIds ?? children(rootId)).filter((id) => !!node(id))\n if (sections[0]) markStill(sections[0])\n const turns = character.sectionPresets?.length ? character.sectionPresets : [character.preset]\n sections.forEach((id, index) => {\n if (closed(id)) return\n const content = contentOf(id)\n // The band itself never moves — only what it holds — so a section that\n // is nothing but its band has nothing to move.\n if (content === id && !children(id).length) return\n const words = turns[(index - 1) % turns.length] ?? character.preset\n if (index === 0) ladder(content)\n else if (content === id && !isGroup(id) && !isSplit(id))\n for (const child of children(id)) place(child, words)\n else place(content, words)\n })\n\n if (!patches.size) return nodes\n const next: Record<string, any> = { ...nodes }\n for (const [id, props] of patches) {\n next[id] = { ...nodes[id], props: { ...(nodes[id]?.props ?? {}), ...props } }\n }\n return next as T\n}\n"],"names":["CANVAS_ROOT_ELEMENT_ID","ANIMATION_MAX_DELAY_MS","ANIMATION_MAX_DURATION_MS","ANIMATION_MAX_STAGGER_STEP_MS","NODE_ANIMATION_DELAY_PROP","NODE_ANIMATION_DURATION_PROP","NODE_ANIMATION_EASE_PROP","NODE_ANIMATION_PROPS","NODE_ANIMATION_PROP","NODE_ANIMATION_STAGGER_PROP","NODE_ANIMATION_STAGGER_STEP_PROP","NODE_ANIMATION_TRIGGER_PROP","ELEMENT_MOTION_DEFAULT","preset","mediaPreset","ease","durationMs","staggerStepMs","heroStepMs","sectionPresets","sides","ELEMENT_MOTION_HERO_RUNGS","ELEMENT_MOTION_NEVER","Set","MEDIA","LISTS","ITEMS","clamp","value","max","Math","min","round","pins","Object","values","some","applyElementMotion","nodes","options","character","rootId","node","id","children","filter","child","patches","Map","still","refused","at","hidden","has","String","componentId","props","prop","undefined","closed","sx","overlay","isFrame","kids","every","length","holdsClosed","holds","known","get","set","result","groupMemo","belowMemo","isGroup","first","alike","holdsGroup","leadsWithMedia","depth","presetOf","group","words","media","animate","trigger","delayMs","side","groupPreset","startsWith","contentOf","isSplit","place","start","end","descend","ladder","around","flatMap","base","rungs","forEach","rung","index","inner","opens","push","markStill","sectionId","order","walk","typeOf","main","test","heading","find","add","size","read","parseFloat","picture","sections","sectionIds","turns","content","next"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,sBAAsB,QAAQ,oCAAgC;AACvE,SACEC,sBAAsB,EACtBC,yBAAyB,EACzBC,6BAA6B,EAC7BC,yBAAyB,EACzBC,4BAA4B,EAC5BC,wBAAwB,EACxBC,oBAAoB,EACpBC,mBAAmB,EACnBC,2BAA2B,EAC3BC,gCAAgC,EAChCC,2BAA2B,QAItB,yBAAqB;AAyF5B,0GAA0G,GAC1G,OAAO,MAAMC,yBAAiD;IAC5DC,QAAQ;IACRC,aAAa;IACbC,MAAM;IACNC,YAAY;IACZC,eAAe;IACfC,YAAY;IACZC,gBAAgB;QAAC;QAAY;KAAO;IACpCC,OAAO;AACT,EAAC;AAED,mFAAmF,GACnF,OAAO,MAAMC,4BAA4B,EAAC;AAE1C;;;;;CAKC,GACD,OAAO,MAAMC,uBAA4C,IAAIC,IAAI;IAC/D,0BAA0B;IAC1B;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA,mCAAmC;IACnC;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD,EAAC;AAEF,4CAA4C,GAC5C,MAAMC,QAA6B,IAAID,IAAI;IAAC;IAAS;IAAS;IAAc;CAAc;AAE1F,iEAAiE,GACjE,MAAME,QAA6B,IAAIF,IAAI;IAAC;IAAW;IAAgB;CAAoB;AAE3F;;;;CAIC,GACD,MAAMG,QAA6B,IAAIH,IAAI;IACzC;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AAkBD,MAAMI,QAAQ,CAACC,OAAeC,MAAwBC,KAAKC,GAAG,CAACD,KAAKD,GAAG,CAACC,KAAKE,KAAK,CAACJ,QAAQ,IAAIC;AAE/F;;;;;CAKC,GACD,SAASI,KAAKL,KAAc;IAC1B,IAAIA,UAAU,WAAWA,UAAU,YAAYA,UAAU,YAAY,OAAO;IAC5E,OAAO,OAAOA,UAAU,YAAYA,UAAU,QAAQM,OAAOC,MAAM,CAACP,OAAOQ,IAAI,CAACH;AAClF;AAEA;;;;CAIC,GACD,OAAO,SAASI,mBAAkDC,KAAQ,EAAEC,UAAgC,CAAC,CAAC;QAE1FA,oBACHA,iBAoOGA;QAEJC;IAxOd,IAAI,CAACF,OAAO,OAAOA;IACnB,MAAME,aAAYD,qBAAAA,QAAQC,SAAS,YAAjBD,qBAAqB3B;IACvC,MAAM6B,UAASF,kBAAAA,QAAQE,MAAM,YAAdF,kBAAkBvC;IACjC,MAAM0C,OAAO,CAACC,KAAuCL,KAAK,CAACK,GAAG;IAC9D,MAAMC,WAAW,CAACD;;YAA0BD;eAAD,UAACA,QAAAA,KAAKC,wBAALD,MAAUJ,KAAK,mBAAI,EAAE,EAAEO,MAAM,CAAC,CAACC,QAAU,OAAOA,UAAU,YAAY,CAAC,CAACJ,KAAKI;;IACzH,MAAMC,UAAU,IAAIC;IAEpB,uFAAuF,GACvF,MAAMC,QAAQ,IAAI1B;IAClB,mDAAmD,GACnD,MAAM2B,UAAU,CAACP;YAIqBQ;QAHpC,MAAMA,KAAKT,KAAKC;QAChB,IAAI,CAACQ,IAAI,OAAO;QAChB,IAAIA,GAAGC,MAAM,IAAIH,MAAMI,GAAG,CAACV,KAAK,OAAO;QACvC,IAAIrB,qBAAqB+B,GAAG,CAACC,QAAOH,kBAAAA,GAAGI,WAAW,YAAdJ,kBAAkB,MAAM,OAAO;QACnE,OAAO,CAAC,CAACA,GAAGK,KAAK,IAAIjD,qBAAqB6B,IAAI,CAAC,CAACqB;gBAASN;mBAAAA,EAAAA,YAAAA,GAAGK,KAAK,qBAARL,SAAU,CAACM,KAAK,MAAKC;;IAChF;IACA,gDAAgD,GAChD,MAAMC,SAAS,CAAChB;YAA4CD,UAAAA;eAApBQ,QAAQP,OAAOV,MAAKS,QAAAA,KAAKC,yBAALD,WAAAA,MAAUkB,EAAE,qBAAZlB,QAAc,CAAC,WAAW;;IACtF,uDAAuD,GACvD,MAAMmB,UAAU,CAAClB;;YACED,OAAsCA,UAAAA;eAAvDlB,MAAM6B,GAAG,CAACC,gBAAOZ,QAAAA,KAAKC,wBAALD,MAAUa,WAAW,mBAAI,QAAQtB,MAAKS,SAAAA,KAAKC,yBAALD,WAAAA,OAAUkB,EAAE,qBAAZlB,QAAc,CAAC,WAAW,KAAK,CAACQ,QAAQP;;IACjG;;;;;;GAMC,GACD,MAAMmB,UAAU,CAACnB;QACf,MAAMoB,OAAOnB,SAASD;QACtB,OACE,CAACgB,OAAOhB,OACRoB,KAAK3B,IAAI,CAACyB,YACVE,KAAKC,KAAK,CAAC,CAAClB;gBAA6BJ;mBAAnBmB,QAAQf,UAAWJ,EAAAA,QAAAA,KAAKI,2BAALJ,MAAaa,WAAW,MAAK,UAAU,CAACL,QAAQJ,UAAU,CAACF,SAASE,OAAOmB,MAAM;;IAE9H;IACA,MAAMC,cAAc,IAAIlB;IACxB,+EAA+E,GAC/E,MAAMmB,QAAQ,CAACxB;QACb,MAAMyB,QAAQF,YAAYG,GAAG,CAAC1B;QAC9B,IAAIyB,UAAUV,WAAW,OAAOU;QAChCF,YAAYI,GAAG,CAAC3B,IAAI;QACpB,MAAM4B,SAASZ,OAAOhB,OAAQ,CAACmB,QAAQnB,OAAOC,SAASD,IAAIP,IAAI,CAAC+B;QAChED,YAAYI,GAAG,CAAC3B,IAAI4B;QACpB,OAAOA;IACT;IACA,MAAMC,YAAY,IAAIxB;IACtB,MAAMyB,YAAY,IAAIzB;IACtB;;;;GAIC,GACD,MAAM0B,UAAU,CAAC/B;YAIYQ;YAItBA;QAPL,MAAMiB,QAAQI,UAAUH,GAAG,CAAC1B;QAC5B,IAAIyB,UAAUV,WAAW,OAAOU;QAChC,MAAMjB,KAAKT,KAAKC;QAChB,MAAMY,cAAcD,QAAOH,kBAAAA,GAAGI,WAAW,YAAdJ,kBAAkB;QAC7C,MAAMY,OAAOnB,SAASD;QACtB,IAAI4B;QACJ,iEAAiE;QACjE,IAAI,AAACpB,CAAAA,EAAAA,YAAAA,GAAGK,KAAK,qBAARL,SAAU,CAAC,gBAAgB,KAAI1B,MAAM4B,GAAG,CAACE,YAAW,KAAMQ,KAAKE,MAAM,EAAEM,SAAS;aAChF,IAAIR,KAAKE,MAAM,GAAG,GAAGM,SAAS;aAC9B;;gBACkB7B,OAEaS;YAFlC,MAAMwB,QAAQrB,gBAAOZ,QAAAA,KAAKqB,IAAI,CAAC,EAAE,sBAAZrB,MAAea,WAAW,mBAAI;YACnD,MAAMqB,QACJ,AAACrB,gBAAgB,aAAa,CAAC,GAACJ,aAAAA,GAAGK,KAAK,qBAARL,UAAU,CAAC,YAAY,KACtDzB,MAAM2B,GAAG,CAACsB,UAAUZ,KAAKC,KAAK,CAAC,CAAClB;oBAAUJ;uBAAAA,EAAAA,QAAAA,KAAKI,2BAALJ,MAAaa,WAAW,MAAKoB;;YAC1EJ,SAASK,SAAS,CAACC,WAAWlC;QAChC;QACA6B,UAAUF,GAAG,CAAC3B,IAAI4B;QAClB,OAAOA;IACT;IACA,+CAA+C,GAC/C,MAAMM,aAAa,CAAClC;QAClB,MAAMyB,QAAQK,UAAUJ,GAAG,CAAC1B;QAC5B,IAAIyB,UAAUV,WAAW,OAAOU;QAChC,MAAMG,SAAS3B,SAASD,IAAIP,IAAI,CAAC,CAACU,QAAU4B,QAAQ5B,UAAU+B,WAAW/B;QACzE2B,UAAUH,GAAG,CAAC3B,IAAI4B;QAClB,OAAOA;IACT;IACA,gGAAgG,GAChG,MAAMO,iBAAiB,CAACnC;QACtB,IAAIQ,KAAKR;QACT,IAAK,IAAIoC,QAAQ,GAAGA,QAAQ,IAAIA,SAAS,EAAG;;gBACrBrC;YAArB,IAAIlB,MAAM6B,GAAG,CAACC,gBAAOZ,QAAAA,KAAKS,wBAALT,MAAUa,WAAW,mBAAI,QAAQO,QAAQX,KAAK,OAAO;YAC1E,MAAMY,OAAOnB,SAASO;YACtB,IAAI,CAACY,KAAKE,MAAM,EAAE,OAAO;YACzBd,KAAKY,IAAI,CAAC,EAAE;QACd;QACA,OAAO;IACT;IACA,MAAMiB,WAAW,CAACrC,IAAYsC,OAAgBC;QAC5C,MAAMC,QAAQF,QAAQrC,SAASD,IAAIqB,KAAK,CAACc,kBAAkBA,eAAenC;QAC1E,OAAOwC,QAAQ3C,UAAU1B,WAAW,GAAGoE;IACzC;IAEA,MAAME,UAAU,CACdzC,IACA0C,SACAC,SACAJ,QAAyB1C,UAAU3B,MAAM,EACzC0E;QAEA,MAAMN,QAAQP,QAAQ/B;QACtB,MAAM9B,SAASmE,SAASrC,IAAIsC,OAAOA,SAASzC,UAAUgD,WAAW,GAAGhD,UAAUgD,WAAW,GAAGN;QAC5FnC,QAAQuB,GAAG,CAAC3B,IAAI;YACd,CAACnC,oBAAoB,EAAE+E,QAAQ,CAAC1E,OAAO4E,UAAU,CAAC,UAAUF,OAAO1E;YACnE,CAACF,4BAA4B,EAAE0E;YAC/B,CAAChF,6BAA6B,EAAEsB,MAAMa,UAAUxB,UAAU,EAAEd;WACxDoF,UAAU,IAAI;YAAE,CAAClF,0BAA0B,EAAEuB,MAAM2D,SAASrF;QAAwB,IAAI,CAAC;YAC7F,CAACK,yBAAyB,EAAEkC,UAAUzB,IAAI;WACtCkE,QACA;YACE,CAACxE,4BAA4B,EAAE;YAC/B,CAACC,iCAAiC,EAAEiB,MAAMa,UAAUvB,aAAa,EAAEd;QACrE,IACA,CAAC;IAET;IAEA,6EAA6E,GAC7E,MAAMuF,YAAY,CAAC/C;QACjB,IAAIQ,KAAKR;QACT,MAAO,CAACgB,OAAOR,OAAO,CAACuB,QAAQvB,IAAK;YAClC,MAAMY,OAAOnB,SAASO;YACtB,IAAIY,KAAKE,MAAM,KAAK,GAAG;YACvBd,KAAKY,IAAI,CAAC,EAAE;QACd;QACA,OAAOZ;IACT;IAEA;;;;GAIC,GACD,MAAMwC,UAAU,CAAChD;QACf,MAAMoB,OAAOnB,SAASD;QACtB,OAAOoB,KAAKE,MAAM,KAAK,KAAKa,eAAef,IAAI,CAAC,EAAE,MAAMe,eAAef,IAAI,CAAC,EAAE,KAAK,CAACA,KAAK3B,IAAI,CAAC+B;IAChG;IAEA,qGAAqG,GACrG,MAAMyB,QAAQ,CAACjD,IAAYuC;QACzB,IAAIvB,OAAOhB,KAAK;QAChB,IAAIgD,QAAQhD,KAAK;YACf,MAAM,CAACkD,OAAOC,IAAI,GAAGlD,SAASD;YAC9ByC,QAAQS,OAAO,UAAU,GAAGX,OAAO1C,UAAUpB,KAAK,GAAG,gBAAgBsC;YACrE0B,QAAQU,KAAK,UAAUtD,UAAUvB,aAAa,EAAEiE,OAAO1C,UAAUpB,KAAK,GAAG,eAAesC;YACxF;QACF;QACA,IAAIS,MAAMxB,OAAQ,CAAC+B,QAAQ/B,OAAOkC,WAAWlC,KAAM;YACjD,KAAK,MAAMG,SAASF,SAASD,IAAKiD,MAAM9C,OAAOoC;YAC/C;QACF;QACAE,QAAQzC,IAAI,UAAU,GAAGuC;IAC3B;IAEA,8DAA8D,GAC9D,MAAMa,UAAU,CAACpD;QACf,IAAIQ,KAAKR;QACT,IAAK,IAAIoB,OAAOnB,SAASO,KAAKY,KAAKE,MAAM,KAAK,KAAK,CAACN,OAAOI,IAAI,CAAC,EAAE,GAAGA,OAAOnB,SAASO,IAAKA,KAAKY,IAAI,CAAC,EAAE;QACtG,OAAOZ;IACT;IAEA;;;;GAIC,GACD,MAAM6C,SAAS,CAACrD;QACd,IAAIgB,OAAOhB,KAAK;QAChB,mEAAmE;QACnE,uEAAuE;QACvE,IAAI+B,QAAQ/B,OAAOC,SAASD,IAAIsB,MAAM,GAAG,GAAG,OAAOmB,QAAQzC,IAAI,QAAQ;QACvE,MAAMsD,SAAS,CAAC9C,KACdP,SAASO,IAAI+C,OAAO,CAAC,CAACpD,QAAWa,OAAOb,SAAS,EAAE,GAAGqB,MAAMrB,SAASmD,OAAOnD,SAAS;oBAACA;iBAAM;QAC9F,MAAMqD,OAAOvD,SAASD,IAAIsB,MAAM,GAAGgC,OAAOtD,MAAM;YAACA;SAAG;QACpD,0EAA0E;QAC1E,0CAA0C;QAC1C,MAAMyD,QAAkB,EAAE;QAC1BD,KAAKE,OAAO,CAAC,CAACC,MAAMC;YAClB,MAAMC,QAAQT,QAAQO;YACtB,MAAMvC,OAAOnB,SAAS4D;YACtB,MAAMC,QACJ1C,KAAKE,MAAM,IAAI,KACf,CAACS,QAAQ8B,UACT,CAAC1B,eAAe0B,UAChBJ,MAAMnC,MAAM,GAAGF,KAAKE,MAAM,GAAIkC,CAAAA,KAAKlC,MAAM,GAAGsC,QAAQ,CAAA,KAAMlF;YAC5D+E,MAAMM,IAAI,IAAKD,QAAQ1C,OAAO;gBAACuC;aAAK;QACtC;QACA,4FAA4F;QAC5FF,MACGvD,MAAM,CAAC,CAACyD,OAAS,CAAC3C,OAAO2C,OACzBD,OAAO,CAAC,CAACC,MAAMC,QAAUnB,QAAQkB,MAAM,QAAQxE,KAAKC,GAAG,CAACwE,OAAOlF,4BAA4B,KAAKmB,UAAUtB,UAAU;IACzH;IAEA;;;;;GAKC,GACD,MAAMyF,YAAY,CAACC;YAYDC;QAXhB,MAAMA,QAAkB,EAAE;QAC1B,MAAMC,OAAO,CAACnE;YACZkE,MAAMH,IAAI,CAAC/D;YACX,KAAK,MAAMG,SAASF,SAASD,IAAKmE,KAAKhE;QACzC;QACAgE,KAAKF;QACL,MAAMG,SAAS,CAACpE;;gBAEsCQ;YADpD,MAAMA,KAAKT,KAAKC;YAChB,OAAOQ,CAAAA,sBAAAA,GAAII,WAAW,MAAK,kBAAkBD,gBAAOH,YAAAA,GAAGK,KAAK,qBAARL,SAAU,CAAC,UAAU,mBAAI,MAAM;QACrF;QACA,MAAM6D,OAAO,CAACrE;gBAAqCoE;gBAA6CrE,aAAAA;mBAAnE,iBAAiBuE,IAAI,EAACF,UAAAA,OAAOpE,eAAPoE,UAAc,OAAQA,OAAOpE,QAAQ,QAAQD,EAAAA,QAAAA,KAAKC,yBAALD,cAAAA,MAAUc,KAAK,qBAAfd,WAAiB,CAAC,YAAY,MAAK;;QACnI,MAAMwE,WAAUL,cAAAA,MAAMM,IAAI,CAACH,iBAAXH,cAAoBA,MAAMM,IAAI,CAAC,CAACxE;gBAAiCoE;mBAA1B,qBAAqBE,IAAI,EAACF,UAAAA,OAAOpE,eAAPoE,UAAc;;QAC/F,IAAIG,SAASjE,MAAMmE,GAAG,CAACF;QACvB,MAAMG,OAAO,CAAC1E;gBAGSQ,WAA6BA,YAA4BA,QAA0BA;YAFxG,MAAMA,KAAKT,KAAKC;YAChB,MAAM2E,OAAO,CAAC1F,QAAoB,OAAOA,UAAU,WAAWA,QAAQ2F,WAAWjE,OAAO1B,gBAAAA,QAAS,QAAQ;YACzG,OAAOE,KAAKD,GAAG,CAACyF,KAAKnE,uBAAAA,YAAAA,GAAIK,KAAK,qBAATL,SAAW,CAAC,SAAS,GAAGmE,KAAKnE,uBAAAA,aAAAA,GAAIK,KAAK,qBAATL,UAAW,CAAC,QAAQ,GAAGmE,KAAKnE,uBAAAA,SAAAA,GAAIS,EAAE,qBAANT,MAAQ,CAAC,SAAS,GAAGmE,KAAKnE,uBAAAA,UAAAA,GAAIS,EAAE,qBAANT,OAAQ,CAAC,QAAQ;QAC3H;QACA,IAAIqE;QACJ,KAAK,MAAM7E,MAAMkE,MAAO;;gBACDnE;YAArB,IAAIlB,MAAM6B,GAAG,CAACC,gBAAOZ,QAAAA,KAAKC,wBAALD,MAAUa,WAAW,mBAAI,QAASiE,CAAAA,YAAY9D,aAAa2D,KAAK1E,MAAM0E,KAAKG,QAAO,GAAIA,UAAU7E;QACvH;QACA,IAAI6E,SAASvE,MAAMmE,GAAG,CAACI;IACzB;IAEA,MAAMC,WAAW,EAAClF,sBAAAA,QAAQmF,UAAU,YAAlBnF,sBAAsBK,SAASH,SAASI,MAAM,CAAC,CAACF,KAAO,CAAC,CAACD,KAAKC;IAChF,IAAI8E,QAAQ,CAAC,EAAE,EAAEd,UAAUc,QAAQ,CAAC,EAAE;IACtC,MAAME,QAAQnF,EAAAA,4BAAAA,UAAUrB,cAAc,qBAAxBqB,0BAA0ByB,MAAM,IAAGzB,UAAUrB,cAAc,GAAG;QAACqB,UAAU3B,MAAM;KAAC;IAC9F4G,SAASpB,OAAO,CAAC,CAAC1D,IAAI4D;YAMNoB;QALd,IAAIhE,OAAOhB,KAAK;QAChB,MAAMiF,UAAUlC,UAAU/C;QAC1B,uEAAuE;QACvE,+CAA+C;QAC/C,IAAIiF,YAAYjF,MAAM,CAACC,SAASD,IAAIsB,MAAM,EAAE;QAC5C,MAAMiB,SAAQyC,UAAAA,KAAK,CAAC,AAACpB,CAAAA,QAAQ,CAAA,IAAKoB,MAAM1D,MAAM,CAAC,YAAjC0D,UAAqCnF,UAAU3B,MAAM;QACnE,IAAI0F,UAAU,GAAGP,OAAO4B;aACnB,IAAIA,YAAYjF,MAAM,CAAC+B,QAAQ/B,OAAO,CAACgD,QAAQhD,KAClD,KAAK,MAAMG,SAASF,SAASD,IAAKiD,MAAM9C,OAAOoC;aAC5CU,MAAMgC,SAAS1C;IACtB;IAEA,IAAI,CAACnC,QAAQsE,IAAI,EAAE,OAAO/E;IAC1B,MAAMuF,OAA4B,aAAKvF;IACvC,KAAK,MAAM,CAACK,IAAIa,MAAM,IAAIT,QAAS;;YACOT;QAAxCuF,IAAI,CAAClF,GAAG,GAAG,aAAKL,KAAK,CAACK,GAAG;YAAEa,OAAO,sBAAMlB,YAAAA,KAAK,CAACK,GAAG,qBAATL,UAAWkB,KAAK,mBAAI,CAAC,GAAOA;;IACtE;IACA,OAAOqE;AACT"}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
17
|
export interface HostStatus {
|
|
18
|
-
/** `Live`, `Draft`, `Maintenance`, `Suspended`. */
|
|
18
|
+
/** `Live`, `Draft`, `In preview`, `Maintenance`, `Suspended`. */
|
|
19
19
|
label: string;
|
|
20
20
|
color: 'success' | 'default' | 'warning' | 'error';
|
|
21
21
|
/** Why, in one sentence — the pill's tooltip. */
|
|
@@ -65,6 +65,7 @@ export declare function publishedScreenCount(host: {
|
|
|
65
65
|
export declare function describeHostStatus(host: {
|
|
66
66
|
screens?: Record<string, unknown>;
|
|
67
67
|
maintenance?: boolean;
|
|
68
|
+
preview?: boolean;
|
|
68
69
|
suspendedAt?: number;
|
|
69
70
|
suspendedUntilMs?: number;
|
|
70
71
|
}): HostStatus;
|
|
@@ -73,6 +73,16 @@
|
|
|
73
73
|
detail: 'Every path serves the maintenance page.'
|
|
74
74
|
};
|
|
75
75
|
}
|
|
76
|
+
// A site in preview (AGL-4086) is not live to its visitors whatever it has
|
|
77
|
+
// published: they get the 503 page or "Coming soon", and only the team
|
|
78
|
+
// sees the pages.
|
|
79
|
+
if ((host == null ? void 0 : host.preview) === true) {
|
|
80
|
+
return {
|
|
81
|
+
label: 'In preview',
|
|
82
|
+
color: 'default',
|
|
83
|
+
detail: 'Only your team can see this site. Visitors see the 503 page or "Coming soon".'
|
|
84
|
+
};
|
|
85
|
+
}
|
|
76
86
|
const published = publishedScreenCount(host);
|
|
77
87
|
if (published > 0) {
|
|
78
88
|
return {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/host-status.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\nexport interface HostStatus {\n /** `Live`, `Draft`, `Maintenance`, `Suspended`. */\n label: string\n color: 'success' | 'default' | 'warning' | 'error'\n /** Why, in one sentence — the pill's tooltip. */\n detail: string\n}\n\n/**\n * How many pages this site publishes.\n *\n * Read off `host.screens` — the map publishing writes into the host document\n * (`hosts/{hostId}.screens[screenId] = slug`, see `template-screens.ts`) and\n * the tenant reads to route a request. So a site with an entry has at least\n * one page a visitor can reach, and one without has none. That makes this a\n * fact already sitting in a document its readers hold: **no extra read**, on\n * a page that can list a hundred sites.\n *\n * ⚠️ It counts PUBLISHED ROUTES, not authored work. A site with five\n * unpublished drafts counts 0 — the map is written by publishing and by\n * nothing else. Every caller is asking \"what can a visitor reach?\"; a caller\n * that means \"has anyone worked on this?\" is asking a different question and\n * this is the wrong number for it.\n *\n * The placeholder home page a new site is created with (AGL-3408) COUNTS\n * here: it is a page a visitor reaches, so a new site is Live. The first-run\n * offer asks a different question — has the OWNER published anything? — and\n * reads `hostIsBlankSite`, which discounts it.\n */\nexport function publishedScreenCount(\n host: { screens?: Record<string, unknown> } | null | undefined,\n): number {\n return Object.keys(host?.screens ?? {}).length\n}\n\n/**\n * The `Live` / `Draft` pill the console Sites mockup puts on every card\n * (AGL-2166).\n *\n * Derived from {@link publishedScreenCount}: the pill reports what a visitor\n * can reach, placeholder home page included.\n *\n * The staff admin host page has rendered a `published` / `draft` chip since\n * AGL-390 off `host.published`, which is not a field on `AglynHost` and is\n * written by nothing — so that chip has always said `draft`. Deriving from\n * `screens` is what it should have done.\n *\n * Order matters. A suspended site is not \"live\" whatever it has published,\n * and a site in maintenance is serving the 503 screen rather than its\n * pages — reporting either as `Live` would be the console agreeing with a\n * customer who thinks their site is up.\n */\nexport function describeHostStatus(host: {\n screens?: Record<string, unknown>\n maintenance?: boolean\n suspendedAt?: number\n suspendedUntilMs?: number\n}): HostStatus {\n if (host?.suspendedAt) {\n // A timed suspension that has elapsed is over, even though the fields\n // are still on the document — the tenant makes the same check.\n const until = host.suspendedUntilMs\n if (!until || until > Date.now()) {\n return {\n label: 'Suspended',\n color: 'error',\n detail: 'This site is serving a lockdown notice instead of content.',\n }\n }\n }\n if (host?.maintenance) {\n return {\n label: 'Maintenance',\n color: 'warning',\n detail: 'Every path serves the maintenance page.',\n }\n }\n const published = publishedScreenCount(host)\n if (published > 0) {\n return {\n label: 'Live',\n color: 'success',\n detail: `${published} published page${published === 1 ? '' : 's'}.`,\n }\n }\n return {\n label: 'Draft',\n color: 'default',\n detail: 'Nothing published yet — visitors see the placeholder.',\n }\n}\n\n/**\n * `6 of 10 sites · Business plan` — the meta line the Sites mockup puts\n * opposite the section heading.\n *\n * Returns `undefined` rather than a partial line while the org or its plan\n * is still resolving. A count against the FREE limit is what an unresolved\n * org produces, and telling a Business customer they are at \"1 of 1 sites\"\n * is how a correct page delivers a false upgrade prompt.\n */\nexport function describeSiteAllowance(options: {\n used: number\n limit: number | undefined\n planLabel: string | undefined\n ready: boolean\n}): string | undefined {\n const { used, limit, planLabel, ready } = options\n if (!ready || !planLabel) return undefined\n /*\n * `Number.isFinite`, not `limit < 0` (AGL-2223).\n *\n * `UNLIMITED` is `Number.POSITIVE_INFINITY`, and Enterprise's `hostLimit`\n * is exactly that — so the `< 0` test was never true for the one plan the\n * Unlimited branch existed for, and an Enterprise organization read\n * `4 of Infinity sites · Enterprise plan`. The negative case is kept: an\n * entitlement override can carry a negative sentinel, and reading that as a\n * cap of \"-1 sites\" would be the same class of nonsense.\n */\n const cap =\n limit === undefined || limit === null\n ? undefined\n : !Number.isFinite(limit) || limit < 0\n ? 'Unlimited'\n : String(limit)\n const sites =\n cap === undefined\n ? `${used} site${used === 1 ? '' : 's'}`\n : `${used} of ${cap} site${cap === '1' ? '' : 's'}`\n return `${sites} · ${planLabel} plan`\n}\n"],"names":["publishedScreenCount","host","Object","keys","screens","length","describeHostStatus","suspendedAt","until","suspendedUntilMs","Date","now","label","color","detail","maintenance","published","describeSiteAllowance","options","used","limit","planLabel","ready","undefined","cap","Number","isFinite","String","sites"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAUD;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,SAASA,qBACdC,IAA8D;;IAE9D,OAAOC,OAAOC,IAAI,SAACF,wBAAAA,KAAMG,OAAO,mBAAI,CAAC,GAAGC,MAAM;AAChD;AAEA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASC,mBAAmBL,
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/host-status.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\nexport interface HostStatus {\n /** `Live`, `Draft`, `In preview`, `Maintenance`, `Suspended`. */\n label: string\n color: 'success' | 'default' | 'warning' | 'error'\n /** Why, in one sentence — the pill's tooltip. */\n detail: string\n}\n\n/**\n * How many pages this site publishes.\n *\n * Read off `host.screens` — the map publishing writes into the host document\n * (`hosts/{hostId}.screens[screenId] = slug`, see `template-screens.ts`) and\n * the tenant reads to route a request. So a site with an entry has at least\n * one page a visitor can reach, and one without has none. That makes this a\n * fact already sitting in a document its readers hold: **no extra read**, on\n * a page that can list a hundred sites.\n *\n * ⚠️ It counts PUBLISHED ROUTES, not authored work. A site with five\n * unpublished drafts counts 0 — the map is written by publishing and by\n * nothing else. Every caller is asking \"what can a visitor reach?\"; a caller\n * that means \"has anyone worked on this?\" is asking a different question and\n * this is the wrong number for it.\n *\n * The placeholder home page a new site is created with (AGL-3408) COUNTS\n * here: it is a page a visitor reaches, so a new site is Live. The first-run\n * offer asks a different question — has the OWNER published anything? — and\n * reads `hostIsBlankSite`, which discounts it.\n */\nexport function publishedScreenCount(\n host: { screens?: Record<string, unknown> } | null | undefined,\n): number {\n return Object.keys(host?.screens ?? {}).length\n}\n\n/**\n * The `Live` / `Draft` pill the console Sites mockup puts on every card\n * (AGL-2166).\n *\n * Derived from {@link publishedScreenCount}: the pill reports what a visitor\n * can reach, placeholder home page included.\n *\n * The staff admin host page has rendered a `published` / `draft` chip since\n * AGL-390 off `host.published`, which is not a field on `AglynHost` and is\n * written by nothing — so that chip has always said `draft`. Deriving from\n * `screens` is what it should have done.\n *\n * Order matters. A suspended site is not \"live\" whatever it has published,\n * and a site in maintenance is serving the 503 screen rather than its\n * pages — reporting either as `Live` would be the console agreeing with a\n * customer who thinks their site is up.\n */\nexport function describeHostStatus(host: {\n screens?: Record<string, unknown>\n maintenance?: boolean\n preview?: boolean\n suspendedAt?: number\n suspendedUntilMs?: number\n}): HostStatus {\n if (host?.suspendedAt) {\n // A timed suspension that has elapsed is over, even though the fields\n // are still on the document — the tenant makes the same check.\n const until = host.suspendedUntilMs\n if (!until || until > Date.now()) {\n return {\n label: 'Suspended',\n color: 'error',\n detail: 'This site is serving a lockdown notice instead of content.',\n }\n }\n }\n if (host?.maintenance) {\n return {\n label: 'Maintenance',\n color: 'warning',\n detail: 'Every path serves the maintenance page.',\n }\n }\n // A site in preview (AGL-4086) is not live to its visitors whatever it has\n // published: they get the 503 page or \"Coming soon\", and only the team\n // sees the pages.\n if (host?.preview === true) {\n return {\n label: 'In preview',\n color: 'default',\n detail:\n 'Only your team can see this site. Visitors see the 503 page or \"Coming soon\".',\n }\n }\n const published = publishedScreenCount(host)\n if (published > 0) {\n return {\n label: 'Live',\n color: 'success',\n detail: `${published} published page${published === 1 ? '' : 's'}.`,\n }\n }\n return {\n label: 'Draft',\n color: 'default',\n detail: 'Nothing published yet — visitors see the placeholder.',\n }\n}\n\n/**\n * `6 of 10 sites · Business plan` — the meta line the Sites mockup puts\n * opposite the section heading.\n *\n * Returns `undefined` rather than a partial line while the org or its plan\n * is still resolving. A count against the FREE limit is what an unresolved\n * org produces, and telling a Business customer they are at \"1 of 1 sites\"\n * is how a correct page delivers a false upgrade prompt.\n */\nexport function describeSiteAllowance(options: {\n used: number\n limit: number | undefined\n planLabel: string | undefined\n ready: boolean\n}): string | undefined {\n const { used, limit, planLabel, ready } = options\n if (!ready || !planLabel) return undefined\n /*\n * `Number.isFinite`, not `limit < 0` (AGL-2223).\n *\n * `UNLIMITED` is `Number.POSITIVE_INFINITY`, and Enterprise's `hostLimit`\n * is exactly that — so the `< 0` test was never true for the one plan the\n * Unlimited branch existed for, and an Enterprise organization read\n * `4 of Infinity sites · Enterprise plan`. The negative case is kept: an\n * entitlement override can carry a negative sentinel, and reading that as a\n * cap of \"-1 sites\" would be the same class of nonsense.\n */\n const cap =\n limit === undefined || limit === null\n ? undefined\n : !Number.isFinite(limit) || limit < 0\n ? 'Unlimited'\n : String(limit)\n const sites =\n cap === undefined\n ? `${used} site${used === 1 ? '' : 's'}`\n : `${used} of ${cap} site${cap === '1' ? '' : 's'}`\n return `${sites} · ${planLabel} plan`\n}\n"],"names":["publishedScreenCount","host","Object","keys","screens","length","describeHostStatus","suspendedAt","until","suspendedUntilMs","Date","now","label","color","detail","maintenance","preview","published","describeSiteAllowance","options","used","limit","planLabel","ready","undefined","cap","Number","isFinite","String","sites"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAUD;;;;;;;;;;;;;;;;;;;;CAoBC,GACD,OAAO,SAASA,qBACdC,IAA8D;;IAE9D,OAAOC,OAAOC,IAAI,SAACF,wBAAAA,KAAMG,OAAO,mBAAI,CAAC,GAAGC,MAAM;AAChD;AAEA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASC,mBAAmBL,IAMlC;IACC,IAAIA,wBAAAA,KAAMM,WAAW,EAAE;QACrB,sEAAsE;QACtE,+DAA+D;QAC/D,MAAMC,QAAQP,KAAKQ,gBAAgB;QACnC,IAAI,CAACD,SAASA,QAAQE,KAAKC,GAAG,IAAI;YAChC,OAAO;gBACLC,OAAO;gBACPC,OAAO;gBACPC,QAAQ;YACV;QACF;IACF;IACA,IAAIb,wBAAAA,KAAMc,WAAW,EAAE;QACrB,OAAO;YACLH,OAAO;YACPC,OAAO;YACPC,QAAQ;QACV;IACF;IACA,2EAA2E;IAC3E,uEAAuE;IACvE,kBAAkB;IAClB,IAAIb,CAAAA,wBAAAA,KAAMe,OAAO,MAAK,MAAM;QAC1B,OAAO;YACLJ,OAAO;YACPC,OAAO;YACPC,QACE;QACJ;IACF;IACA,MAAMG,YAAYjB,qBAAqBC;IACvC,IAAIgB,YAAY,GAAG;QACjB,OAAO;YACLL,OAAO;YACPC,OAAO;YACPC,QAAQ,GAAGG,UAAU,eAAe,EAAEA,cAAc,IAAI,KAAK,IAAI,CAAC,CAAC;QACrE;IACF;IACA,OAAO;QACLL,OAAO;QACPC,OAAO;QACPC,QAAQ;IACV;AACF;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASI,sBAAsBC,OAKrC;IACC,MAAM,EAAEC,IAAI,EAAEC,KAAK,EAAEC,SAAS,EAAEC,KAAK,EAAE,GAAGJ;IAC1C,IAAI,CAACI,SAAS,CAACD,WAAW,OAAOE;IACjC;;;;;;;;;GASC,GACD,MAAMC,MACJJ,UAAUG,aAAaH,UAAU,OAC7BG,YACA,CAACE,OAAOC,QAAQ,CAACN,UAAUA,QAAQ,IACjC,cACAO,OAAOP;IACf,MAAMQ,QACJJ,QAAQD,YACJ,GAAGJ,KAAK,KAAK,EAAEA,SAAS,IAAI,KAAK,KAAK,GACtC,GAAGA,KAAK,IAAI,EAAEK,IAAI,KAAK,EAAEA,QAAQ,MAAM,KAAK,KAAK;IACvD,OAAO,GAAGI,MAAM,GAAG,EAAEP,UAAU,KAAK,CAAC;AACvC"}
|
|
@@ -62,14 +62,14 @@ export declare function normalizeScreenSlug(input: string | null | undefined): s
|
|
|
62
62
|
* the path is parsed as `[host]` with an empty slug.
|
|
63
63
|
* - `aglyn-preview` — the same mechanism as `search`, one route over
|
|
64
64
|
* (AGL-3205). The live-site preview of an unpublished collection entry is
|
|
65
|
-
* a rewrite to `app/[host]/[scheme]/aglyn-preview/[...slug]`, because it
|
|
65
|
+
* a rewrite to `app/[host]/[scheme]/aglyn-preview/[[...slug]]`, because it
|
|
66
66
|
* must be a DIFFERENT Next route from the ISR-cached catch-all; a static
|
|
67
67
|
* folder wins over the optional catch-all beside it, so `/aglyn-preview/x`
|
|
68
68
|
* would reach the preview page and 404 rather than render a screen. The
|
|
69
|
-
* bare `/aglyn-preview`
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
69
|
+
* bare `/aglyn-preview` reaches it too since the team's view of a site in
|
|
70
|
+
* preview (AGL-4086) made its catch-all optional, so a site's home page
|
|
71
|
+
* could be previewed; and a child's path is composed from its parent's
|
|
72
|
+
* slug, which this rule refuses.
|
|
73
73
|
* - `_aglyn` — the platform's own pages on every site, the leaving notice
|
|
74
74
|
* at `/_aglyn/leaving` first (AGL-3452). The middleware rewrites that path
|
|
75
75
|
* to an api route before the catch-all could see it.
|
|
@@ -73,14 +73,14 @@ import { PLATFORM_BRAND_NAME } from "./platform-brand.js";
|
|
|
73
73
|
* the path is parsed as `[host]` with an empty slug.
|
|
74
74
|
* - `aglyn-preview` — the same mechanism as `search`, one route over
|
|
75
75
|
* (AGL-3205). The live-site preview of an unpublished collection entry is
|
|
76
|
-
* a rewrite to `app/[host]/[scheme]/aglyn-preview/[...slug]`, because it
|
|
76
|
+
* a rewrite to `app/[host]/[scheme]/aglyn-preview/[[...slug]]`, because it
|
|
77
77
|
* must be a DIFFERENT Next route from the ISR-cached catch-all; a static
|
|
78
78
|
* folder wins over the optional catch-all beside it, so `/aglyn-preview/x`
|
|
79
79
|
* would reach the preview page and 404 rather than render a screen. The
|
|
80
|
-
* bare `/aglyn-preview`
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
80
|
+
* bare `/aglyn-preview` reaches it too since the team's view of a site in
|
|
81
|
+
* preview (AGL-4086) made its catch-all optional, so a site's home page
|
|
82
|
+
* could be previewed; and a child's path is composed from its parent's
|
|
83
|
+
* slug, which this rule refuses.
|
|
84
84
|
* - `_aglyn` — the platform's own pages on every site, the leaving notice
|
|
85
85
|
* at `/_aglyn/leaving` first (AGL-3452). The middleware rewrites that path
|
|
86
86
|
* to an api route before the catch-all could see it.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/screen-slug.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 { ENTRY_PREVIEW_ROUTE_SEGMENT } from './entry-preview-link'\nimport { LEAVING_NOTICE_ROUTE_SEGMENT } from './leaving-notice'\nimport { PLATFORM_BRAND_NAME } from './platform-brand'\n\n/*\n * The address rules of a page's slug, in a leaf of their own so the native apps\n * (which cannot import the routing map's reach into the whole foundation) read\n * the same answers: `screen-route.ts` re-exports every name below, so no\n * importer changes. The routing map, tree walks and links stay there.\n */\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's path is composed from\n * its parent's slug, and this rule refuses that slug.\n * - `_aglyn` — the platform's own pages on every site, the leaving notice\n * at `/_aglyn/leaving` first (AGL-3452). The middleware rewrites that path\n * to an api route before the catch-all could see it.\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 LEAVING_NOTICE_ROUTE_SEGMENT,\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 is composed from its\n * PARENT's slug, the parent would have to hold `404`, and this same rule\n * refuses that slug wherever one is typed. A second list to express a case\n * that cannot arise would 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 page under a ' +\n 'parent page instead'\n"],"names":["ENTRY_PREVIEW_ROUTE_SEGMENT","LEAVING_NOTICE_ROUTE_SEGMENT","PLATFORM_BRAND_NAME","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"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,2BAA2B,QAAQ,0BAAsB;AAClE,SAASC,4BAA4B,QAAQ,sBAAkB;AAC/D,SAASC,mBAAmB,QAAQ,sBAAkB;AAEtD;;;;;CAKC,GAED;;;;;;CAMC,GACD,OAAO,MAAMC,mBAAmB,IAAG;AAEnC;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASC,oBACdC,KAAgC;IAEhC,MAAMC,UAAU,CAACD,gBAAAA,QAAS,IAAIE,IAAI;IAClC,IAAI,CAACD,SAAS,OAAOE;IACrB,IAAIF,YAAYH,kBAAkB,OAAOA;IAEzC,MAAMM,UAAUH,QACbI,WAAW,GACXC,OAAO,CAAC,cAAc,IACtBA,OAAO,CAAC,QAAQ,KAChBA,OAAO,CAAC,gBAAgB,IACxBA,OAAO,CAAC,UAAU,KAClBA,OAAO,CAAC,YAAY;IAEvB,OAAOF,WAAWD;AACpB;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2CC,GACD,OAAO,MAAMI,iCAAoD;IAC/D;IACA;IACA;IACAZ;IACA;IACA;IACA;IACAC;CACD,CAAA;AAED;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASY,2BACdC,IAA+B;IAE/B,IAAI,CAACA,QAAQA,SAASX,kBAAkB,OAAOK;IAC/C,MAAM,CAACO,MAAM,GAAGD,KAAKH,OAAO,CAAC,QAAQ,IAAIK,KAAK,CAAC;IAC/C,OAAOJ,+BAA+BK,QAAQ,CAACF,SAASA,QAAQP;AAClE;AAEA;;;;;CAKC,GACD,OAAO,SAASU,2BAA2BT,OAAe;IACxD,OAAO,CAAC,EAAE,EAAEA,QAAQ,iCAAiC,EAAEP,oBAAoB,yBAAyB,CAAC;AACvG;AAEA;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,SAASiB,2BACdd,KAAgC;IAEhC,MAAMC,UAAU,CAACD,gBAAAA,QAAS,IAAIE,IAAI;IAClC,IAAI,CAACD,WAAWA,YAAYH,kBAAkB,OAAO;IACrD,OAAOG,QAAQK,OAAO,CAAC,cAAc,IAAIM,QAAQ,CAAC;AACpD;AAEA;;;;;;;;;CASC,GACD,OAAO,MAAMG,qCACX,2EACA,sBAAqB"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/screen-slug.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 { ENTRY_PREVIEW_ROUTE_SEGMENT } from './entry-preview-link'\nimport { LEAVING_NOTICE_ROUTE_SEGMENT } from './leaving-notice'\nimport { PLATFORM_BRAND_NAME } from './platform-brand'\n\n/*\n * The address rules of a page's slug, in a leaf of their own so the native apps\n * (which cannot import the routing map's reach into the whole foundation) read\n * the same answers: `screen-route.ts` re-exports every name below, so no\n * importer changes. The routing map, tree walks and links stay there.\n */\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` reaches it too since the team's view of a site in\n * preview (AGL-4086) made its catch-all optional, so a site's home page\n * could be previewed; and a child's path is composed from its parent's\n * slug, which this rule refuses.\n * - `_aglyn` — the platform's own pages on every site, the leaving notice\n * at `/_aglyn/leaving` first (AGL-3452). The middleware rewrites that path\n * to an api route before the catch-all could see it.\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 LEAVING_NOTICE_ROUTE_SEGMENT,\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 is composed from its\n * PARENT's slug, the parent would have to hold `404`, and this same rule\n * refuses that slug wherever one is typed. A second list to express a case\n * that cannot arise would 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 page under a ' +\n 'parent page instead'\n"],"names":["ENTRY_PREVIEW_ROUTE_SEGMENT","LEAVING_NOTICE_ROUTE_SEGMENT","PLATFORM_BRAND_NAME","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"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,2BAA2B,QAAQ,0BAAsB;AAClE,SAASC,4BAA4B,QAAQ,sBAAkB;AAC/D,SAASC,mBAAmB,QAAQ,sBAAkB;AAEtD;;;;;CAKC,GAED;;;;;;CAMC,GACD,OAAO,MAAMC,mBAAmB,IAAG;AAEnC;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASC,oBACdC,KAAgC;IAEhC,MAAMC,UAAU,CAACD,gBAAAA,QAAS,IAAIE,IAAI;IAClC,IAAI,CAACD,SAAS,OAAOE;IACrB,IAAIF,YAAYH,kBAAkB,OAAOA;IAEzC,MAAMM,UAAUH,QACbI,WAAW,GACXC,OAAO,CAAC,cAAc,IACtBA,OAAO,CAAC,QAAQ,KAChBA,OAAO,CAAC,gBAAgB,IACxBA,OAAO,CAAC,UAAU,KAClBA,OAAO,CAAC,YAAY;IAEvB,OAAOF,WAAWD;AACpB;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2CC,GACD,OAAO,MAAMI,iCAAoD;IAC/D;IACA;IACA;IACAZ;IACA;IACA;IACA;IACAC;CACD,CAAA;AAED;;;;;;;;;;;;;;;CAeC,GACD,OAAO,SAASY,2BACdC,IAA+B;IAE/B,IAAI,CAACA,QAAQA,SAASX,kBAAkB,OAAOK;IAC/C,MAAM,CAACO,MAAM,GAAGD,KAAKH,OAAO,CAAC,QAAQ,IAAIK,KAAK,CAAC;IAC/C,OAAOJ,+BAA+BK,QAAQ,CAACF,SAASA,QAAQP;AAClE;AAEA;;;;;CAKC,GACD,OAAO,SAASU,2BAA2BT,OAAe;IACxD,OAAO,CAAC,EAAE,EAAEA,QAAQ,iCAAiC,EAAEP,oBAAoB,yBAAyB,CAAC;AACvG;AAEA;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,SAASiB,2BACdd,KAAgC;IAEhC,MAAMC,UAAU,CAACD,gBAAAA,QAAS,IAAIE,IAAI;IAClC,IAAI,CAACD,WAAWA,YAAYH,kBAAkB,OAAO;IACrD,OAAOG,QAAQK,OAAO,CAAC,cAAc,IAAIM,QAAQ,CAAC;AACpD;AAEA;;;;;;;;;CASC,GACD,OAAO,MAAMG,qCACX,2EACA,sBAAqB"}
|
|
@@ -54,6 +54,13 @@ export interface SearchIndexingHost {
|
|
|
54
54
|
*/
|
|
55
55
|
discourageSearchEngines?: boolean;
|
|
56
56
|
} | null;
|
|
57
|
+
/**
|
|
58
|
+
* Preview (AGL-4086): only the site's team can see it, so nothing about it
|
|
59
|
+
* is offered to a crawler either. Read by {@link isSiteKeptFromSearch},
|
|
60
|
+
* never by {@link isSearchDiscouraged}, which answers for the switch an
|
|
61
|
+
* author flipped and nothing else.
|
|
62
|
+
*/
|
|
63
|
+
preview?: boolean | null;
|
|
57
64
|
}
|
|
58
65
|
/** The screen fields this module reads. */
|
|
59
66
|
export interface SearchIndexingScreen {
|
|
@@ -74,6 +81,17 @@ export interface SearchIndexingCollection {
|
|
|
74
81
|
* "hide this site", or a schema slip would quietly de-index every customer.
|
|
75
82
|
*/
|
|
76
83
|
export declare function isSearchDiscouraged(host: SearchIndexingHost | null | undefined): boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Whether crawlers are asked to stay away from the whole site: search is
|
|
86
|
+
* discouraged, or the site is in preview (AGL-4086).
|
|
87
|
+
*
|
|
88
|
+
* Separate from {@link isSearchDiscouraged} on purpose. That one is the state
|
|
89
|
+
* of a switch, and the console shows it as the switch; a site in preview has
|
|
90
|
+
* not had that switch flipped, and reading it as though it had would show the
|
|
91
|
+
* author a setting they never changed. Every surface that answers a CRAWLER —
|
|
92
|
+
* the head, `robots.txt`, the sitemap, the agent files — asks this one.
|
|
93
|
+
*/
|
|
94
|
+
export declare function isSiteKeptFromSearch(host: SearchIndexingHost | null | undefined): boolean;
|
|
77
95
|
/**
|
|
78
96
|
* Whether a screen may be indexed on its own merits, ignoring the site-level
|
|
79
97
|
* switch.
|
|
@@ -23,6 +23,18 @@ import { SITEMAP_INDEX_PATH } from "./sitemap.js";
|
|
|
23
23
|
var _host_seo;
|
|
24
24
|
return (host == null ? void 0 : (_host_seo = host.seo) == null ? void 0 : _host_seo.discourageSearchEngines) === true;
|
|
25
25
|
}
|
|
26
|
+
/**
|
|
27
|
+
* Whether crawlers are asked to stay away from the whole site: search is
|
|
28
|
+
* discouraged, or the site is in preview (AGL-4086).
|
|
29
|
+
*
|
|
30
|
+
* Separate from {@link isSearchDiscouraged} on purpose. That one is the state
|
|
31
|
+
* of a switch, and the console shows it as the switch; a site in preview has
|
|
32
|
+
* not had that switch flipped, and reading it as though it had would show the
|
|
33
|
+
* author a setting they never changed. Every surface that answers a CRAWLER —
|
|
34
|
+
* the head, `robots.txt`, the sitemap, the agent files — asks this one.
|
|
35
|
+
*/ export function isSiteKeptFromSearch(host) {
|
|
36
|
+
return isSearchDiscouraged(host) || (host == null ? void 0 : host.preview) === true;
|
|
37
|
+
}
|
|
26
38
|
/**
|
|
27
39
|
* Whether a screen may be indexed on its own merits, ignoring the site-level
|
|
28
40
|
* switch.
|
|
@@ -46,7 +58,7 @@ import { SITEMAP_INDEX_PATH } from "./sitemap.js";
|
|
|
46
58
|
* collection rather than to a screen, so they ask
|
|
47
59
|
* {@link areCollectionEntriesIndexable} alongside {@link isSearchDiscouraged}.
|
|
48
60
|
*/ export function isPageIndexable(options) {
|
|
49
|
-
if (
|
|
61
|
+
if (isSiteKeptFromSearch(options.host)) return false;
|
|
50
62
|
return isScreenIndexable(options.screen);
|
|
51
63
|
}
|
|
52
64
|
/**
|
|
@@ -80,7 +92,9 @@ import { SITEMAP_INDEX_PATH } from "./sitemap.js";
|
|
|
80
92
|
* crawler an index of the site you just asked it not to crawl is the
|
|
81
93
|
* contradiction the rest of this module exists to remove.
|
|
82
94
|
*/ export function buildRobotsTxt(options) {
|
|
83
|
-
|
|
95
|
+
// A site in preview (AGL-4086) closes the file the same way: nobody but
|
|
96
|
+
// its team may see the site, so no crawler is invited in.
|
|
97
|
+
if (isSiteKeptFromSearch(options.host)) {
|
|
84
98
|
/*
|
|
85
99
|
Every named agent group is dropped along with the wildcard. A group that
|
|
86
100
|
named an agent and allowed it would be a LOUDER permission than the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/search-indexing.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 { HostScreenVisibility } from '../foundation/definitions/platform.types'\nimport { SITEMAP_INDEX_PATH } from './sitemap'\n\n/**\n * Search-indexing policy (AGL-1263): the single answer to \"may a crawler\n * index this?\", shared by every surface that has to agree about it.\n *\n * Four surfaces answer that question and they must never disagree — the\n * server `generateMetadata`, its client `<Head>` twin, `robots.txt` and\n * `sitemap.xml`. Before this module three of them had their own copy of the\n * rule and the fourth had none: the sitemap listed whatever was in the host's\n * routing map, so an UNLISTED page was simultaneously told \"do not index me\"\n * in its own head and advertised as a canonical URL in the site's sitemap.\n * That is not a harmless duplication — a sitemap entry is an explicit\n * submission, and submitting a page you have marked noindex is the shape that\n * gets a site flagged for conflicting directives.\n *\n * There are three controls and they compose:\n *\n * - **Site-level** `host.seo.discourageSearchEngines` — the staged-launch\n * switch. Everything about the site goes dark to crawlers at once.\n * - **Per-screen** `screen.visibility` — the existing\n * {@link HostScreenVisibility} model, NOT a parallel `noindex` flag.\n * `UNLISTED` is literally `PUBLIC | (1 << 2)`: \"public, plus the not-listed\n * bit\". A second per-screen field would be a field that can disagree with\n * this one, and every render surface would then need a rule for which wins.\n * - **Per-collection** `collection.excludeEntriesFromSearch` (AGL-3247) — a\n * content collection keeps its listing and its category listings indexable\n * and withholds its ENTRIES. That is a different axis from the two above,\n * not a third copy of either: the subject is the collection, and what it\n * answers for is every URL underneath it.\n */\n\n/** The host fields this module reads; keeps callers free of the full doc. */\nexport interface SearchIndexingHost {\n seo?: {\n /**\n * Site-wide \"discourage search engines\" (AGL-1263). PERSISTED NAME — the\n * wording matches the switch an author sees, so support conversations and\n * the document agree.\n */\n discourageSearchEngines?: boolean\n } | null\n}\n\n/** The screen fields this module reads. */\nexport interface SearchIndexingScreen {\n visibility?: HostScreenVisibility | null\n}\n\n/** The content-collection fields this module reads. */\nexport interface SearchIndexingCollection {\n /**\n * \"List the collection, not its entries\" (AGL-3247). PERSISTED NAME — flat\n * on the collection document, where `schemaType` and `categories` already\n * live, rather than under a `seo` map the collection has never carried.\n */\n excludeEntriesFromSearch?: boolean | null\n}\n\n/**\n * Site-wide opt-out. Absent means \"index me\" — the default a site is created\n * with, and the only safe default: a missing field must never be read as\n * \"hide this site\", or a schema slip would quietly de-index every customer.\n */\nexport function isSearchDiscouraged(\n host: SearchIndexingHost | null | undefined,\n): boolean {\n return host?.seo?.discourageSearchEngines === true\n}\n\n/**\n * Whether a screen may be indexed on its own merits, ignoring the site-level\n * switch.\n *\n * Only `PUBLIC` — and an absent value, which every screen predating the\n * visibility model carries — is indexable. Everything else is excluded, and\n * that is wider than the old `=== UNLISTED` test on purpose: a\n * password-protected, members-only or private screen has nothing a crawler\n * can reach, so listing it in a sitemap published a URL that answers with a\n * gate. `UNLISTED` was the only one anyone remembered because it is the only\n * one whose *name* is about search.\n */\nexport function isScreenIndexable(\n screen: SearchIndexingScreen | null | undefined,\n): boolean {\n const visibility = screen?.visibility\n if (visibility == null) return true\n return visibility === HostScreenVisibility.PUBLIC\n}\n\n/**\n * The site and screen controls together — what a SCREEN render wants to know.\n *\n * Collection entries do not come through here; their URL belongs to the\n * collection rather than to a screen, so they ask\n * {@link areCollectionEntriesIndexable} alongside {@link isSearchDiscouraged}.\n */\nexport function isPageIndexable(options: {\n host?: SearchIndexingHost | null\n screen?: SearchIndexingScreen | null\n}): boolean {\n if (isSearchDiscouraged(options.host)) return false\n return isScreenIndexable(options.screen)\n}\n\n/**\n * Whether a content collection's ENTRIES may be indexed, ignoring the\n * site-level switch (AGL-3247).\n *\n * Absent means \"index them\" — the default every collection was created with,\n * and the only safe default, exactly as for {@link isSearchDiscouraged}: a\n * schema slip must never be readable as \"de-index this customer's blog\".\n *\n * This says nothing about the LISTING at `/{collection}` or the category\n * listings under it. Those stay indexable on purpose — they are the pages\n * that make the withheld entries reachable at all, and a collection whose\n * whole point is that it is too numerous to index is still a collection a\n * reader should be able to find.\n */\nexport function areCollectionEntriesIndexable(\n collection: SearchIndexingCollection | null | undefined,\n): boolean {\n return collection?.excludeEntriesFromSearch !== true\n}\n\n/**\n * The `robots.txt` body for a host.\n *\n * When search is discouraged the file disallows everything AND the pages\n * carry `noindex`, which reads like a contradiction and is not. `Disallow`\n * only asks a crawler not to FETCH; a URL linked from elsewhere can still be\n * indexed without ever being fetched, and a crawler that obeys the disallow\n * never sees the `noindex` that would have stopped it. Belt and braces is the\n * documented remedy, and it is what every other builder's equivalent switch\n * does.\n *\n * The `Sitemap:` line is dropped in that state rather than kept: handing a\n * crawler an index of the site you just asked it not to crawl is the\n * contradiction the rest of this module exists to remove.\n */\nexport function buildRobotsTxt(options: {\n host?: SearchIndexingHost | null\n /** Absolute origin (no trailing slash); omitted when unresolvable. */\n origin?: string\n}): string {\n if (isSearchDiscouraged(options.host)) {\n /*\n Every named agent group is dropped along with the wildcard. A group that\n named an agent and allowed it would be a LOUDER permission than the\n wildcard's refusal — `robots.txt` precedence gives the most specific\n matching group, so `User-agent: ClaudeBot` beats `User-agent: *` — and a\n site that asked to be left alone would be inviting exactly the readers it\n asked to leave.\n */\n return 'User-agent: *\\nDisallow: /\\n'\n }\n const sitemap = options.origin\n ? `Sitemap: ${options.origin}${SITEMAP_INDEX_PATH}\\n`\n : ''\n /*\n AI agents are named EXPLICITLY, and the group says exactly what the\n wildcard above already says.\n\n That looks redundant and is not. A wildcard `Allow: /` is the absence of a\n restriction; a named group is a STATEMENT, and several readers only look\n for the statement: an AI crawler checks for a group bearing its own token\n before it checks the wildcard, some readiness audits score a site on\n whether one exists, and an operator reading this file learns that admitting\n these agents is a decision somebody made rather than a default nobody\n revisited.\n\n It is also the file that will be edited when the decision changes. A site\n owner who wants to exclude one model's crawler edits its line here; before\n this list existed there was no line to edit, and the only lever was a\n wildcard `Disallow` that would have taken Google with it.\n\n ⚠️ `robots.txt` is a REQUEST, not a control. It stops a crawler that reads\n it and honors it, and nothing else — it is not an access control, and the\n WAF is where an agent is actually admitted or refused. The two have to\n agree: a site that names ClaudeBot here and challenges it at the edge has\n published an invitation it does not honor.\n */\n const agents = AI_AGENT_USER_AGENTS.map(\n (agent) => `User-agent: ${agent}\\nAllow: /\\n`,\n ).join('\\n')\n return `User-agent: *\\nAllow: /\\n\\n${agents}\\n${sitemap}`\n}\n\n/**\n * The AI agent user agents a site admits by name.\n *\n * Two kinds, kept in one list because `robots.txt` does not distinguish them\n * and a site owner reading the file should see everything that is admitted:\n *\n * - **Fetchers** — `ChatGPT-User`, `Claude-User`, `Perplexity-User`,\n * `Claude-SearchBot`, `ora-agent`. These act for a person who is asking\n * about the site RIGHT NOW. Refusing one does not protect anything; it\n * means the answer that person gets is written without the site's own\n * words in it.\n * - **Trainers and indexers** — `GPTBot`, `ClaudeBot`, `Google-Extended`,\n * `PerplexityBot`, `Applebot-Extended`, `CCBot`, `Bytespider`,\n * `Amazonbot`, `meta-externalagent`, `DeepSeekBot`, `cohere-ai`,\n * `Diffbot`, `Omgilibot`, `Timpibot`, `YouBot`. These read for a corpus.\n *\n * Admitting the second group is a judgment, and it is the platform DEFAULT\n * rather than a permanent answer: a tenant site is published to be read, and\n * a site that is absent from the corpora is absent from the answers. A site\n * owner who disagrees has `discourageSearchEngines`, which refuses everything\n * here in one switch.\n *\n * Spellings are the vendors' own tokens and are case-sensitive to some\n * readers. Do not \"tidy\" them.\n */\nexport const AI_AGENT_USER_AGENTS: readonly string[] = [\n 'GPTBot',\n 'ChatGPT-User',\n 'OAI-SearchBot',\n 'ClaudeBot',\n 'Claude-User',\n 'Claude-SearchBot',\n 'anthropic-ai',\n 'Google-Extended',\n 'PerplexityBot',\n 'Perplexity-User',\n 'Applebot-Extended',\n 'meta-externalagent',\n 'Amazonbot',\n 'Bytespider',\n 'CCBot',\n 'cohere-ai',\n 'DeepSeekBot',\n 'Diffbot',\n 'MistralAI-User',\n 'Omgilibot',\n 'Timpibot',\n 'YouBot',\n 'ora-agent',\n /*\n OUR OWN CLI (AGL-2717), and it belongs in this list rather than beside it.\n\n `@aglyn/cli` reads a site on a person's behalf — the same job\n `ChatGPT-User` and `Perplexity-User` do, and the same reason they are here.\n It names itself `aglyn-cli/<version> (+https://aglyn.com)` so a site\n operator has something to match on and somewhere to complain to.\n\n It is in THIS list, and not only in the WAF, because\n `check:agent-readiness` asserts the two are the same set: a client admitted\n at the edge but unnamed here is a client nobody reading `robots.txt` knows\n is admitted. The wildcard above already allows it, so the group changes\n nothing functionally — it is the statement that matters, exactly as for the\n twenty-three above.\n */\n 'aglyn-cli',\n]\n\n/**\n * Screen ids that are PUBLISHED but are HTTP STATUS pages, and must never be\n * handed to a crawler or an agent (AGL-2716).\n *\n * ⚠️ NOT `screen-route.ts`'s `nonPageScreenIds`, which is a BILLING predicate:\n * that one answers \"which live documents does the flat infrastructure cap\n * bound\" and counts email documents and entry templates. Two questions, two\n * answers, and merging them would make a pricing cap decide what a crawler may\n * read. The name here says `status` for that reason.\n *\n * Two kinds, and both were found the hard way:\n *\n * - **Bound error screens.** `aglyn.com` published `/401`, `/404` and `/503`\n * into its sitemap, which hands a new domain the worst possible first crawl:\n * the crawler fetches each one, gets the status code it exists to represent,\n * and logs crawl errors and soft-404s against a site it has never seen.\n * - **A bare status code as the whole path.** An error screen on this platform\n * is usually just a screen published at the status path with nothing binding\n * it, so filtering the bound ids alone changed nothing on the site that had\n * the problem. The pattern is anchored, so a real page at `/404-guide` or\n * `/products/503` keeps its place.\n *\n * Extracted from `/api/sitemap`, where it was written inline, so `/llms.txt`\n * and every later reader apply the same rule. The one thing this MUST NOT\n * become is a second copy of that filter: the two surfaces disagreeing is how\n * a site advertises to agents the URLs it withheld from crawlers.\n *\n * Template screens are NOT here. They are excluded too, but the source is a\n * Firestore read (`getTemplateScreenIds`) rather than the host document, so\n * they cannot be decided by a pure function.\n */\nexport function statusPageScreenIds(\n host:\n | {\n screens?: Record<string, string | undefined> | null\n errorScreens?: Record<string, string | undefined> | null\n notFoundScreenId?: string | null\n }\n | null\n | undefined,\n): Set<string> {\n const excluded = new Set<string>()\n for (const bound of [\n ...Object.values(host?.errorScreens ?? {}),\n host?.notFoundScreenId,\n ]) {\n if (typeof bound === 'string' && bound) excluded.add(bound)\n }\n for (const [screenId, path] of Object.entries(host?.screens ?? {})) {\n if (STATUS_CODE_PATH.test(String(path))) excluded.add(screenId)\n }\n return excluded\n}\n\n/** A bare HTTP status code as an entire route path — `/404`, `503`. */\nconst STATUS_CODE_PATH = /^\\/?[1-5][0-9][0-9]$/\n"],"names":["HostScreenVisibility","SITEMAP_INDEX_PATH","isSearchDiscouraged","host","seo","discourageSearchEngines","isScreenIndexable","screen","visibility","PUBLIC","isPageIndexable","options","areCollectionEntriesIndexable","collection","excludeEntriesFromSearch","buildRobotsTxt","sitemap","origin","agents","AI_AGENT_USER_AGENTS","map","agent","join","statusPageScreenIds","excluded","Set","bound","Object","values","errorScreens","notFoundScreenId","add","screenId","path","entries","screens","STATUS_CODE_PATH","test","String"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,oBAAoB,QAAQ,8CAA0C;AAC/E,SAASC,kBAAkB,QAAQ,eAAW;AA2D9C;;;;CAIC,GACD,OAAO,SAASC,oBACdC,IAA2C;QAEpCA;IAAP,OAAOA,CAAAA,yBAAAA,YAAAA,KAAMC,GAAG,qBAATD,UAAWE,uBAAuB,MAAK;AAChD;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,kBACdC,MAA+C;IAE/C,MAAMC,aAAaD,0BAAAA,OAAQC,UAAU;IACrC,IAAIA,cAAc,MAAM,OAAO;IAC/B,OAAOA,eAAeR,qBAAqBS,MAAM;AACnD;AAEA;;;;;;CAMC,GACD,OAAO,SAASC,gBAAgBC,OAG/B;IACC,IAAIT,oBAAoBS,QAAQR,IAAI,GAAG,OAAO;IAC9C,OAAOG,kBAAkBK,QAAQJ,MAAM;AACzC;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASK,8BACdC,UAAuD;IAEvD,OAAOA,CAAAA,8BAAAA,WAAYC,wBAAwB,MAAK;AAClD;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,eAAeJ,OAI9B;IACC,IAAIT,oBAAoBS,QAAQR,IAAI,GAAG;QACrC;;;;;;;IAOA,GACA,OAAO;IACT;IACA,MAAMa,UAAUL,QAAQM,MAAM,GAC1B,CAAC,SAAS,EAAEN,QAAQM,MAAM,GAAGhB,mBAAmB,EAAE,CAAC,GACnD;IACJ;;;;;;;;;;;;;;;;;;;;;;EAsBA,GACA,MAAMiB,SAASC,qBAAqBC,GAAG,CACrC,CAACC,QAAU,CAAC,YAAY,EAAEA,MAAM,YAAY,CAAC,EAC7CC,IAAI,CAAC;IACP,OAAO,CAAC,2BAA2B,EAAEJ,OAAO,EAAE,EAAEF,SAAS;AAC3D;AAEA;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GACD,OAAO,MAAMG,uBAA0C;IACrD;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;;;;;;;;;;;;;;EAcA,GACA;CACD,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GACD,OAAO,SAASI,oBACdpB,IAOa;;IAEb,MAAMqB,WAAW,IAAIC;IACrB,KAAK,MAAMC,SAAS;WACfC,OAAOC,MAAM,SAACzB,wBAAAA,KAAM0B,YAAY,mBAAI,CAAC;QACxC1B,wBAAAA,KAAM2B,gBAAgB;KACvB,CAAE;QACD,IAAI,OAAOJ,UAAU,YAAYA,OAAOF,SAASO,GAAG,CAACL;IACvD;IACA,KAAK,MAAM,CAACM,UAAUC,KAAK,IAAIN,OAAOO,OAAO,UAAC/B,wBAAAA,KAAMgC,OAAO,oBAAI,CAAC,GAAI;QAClE,IAAIC,iBAAiBC,IAAI,CAACC,OAAOL,QAAQT,SAASO,GAAG,CAACC;IACxD;IACA,OAAOR;AACT;AAEA,qEAAqE,GACrE,MAAMY,mBAAmB"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/search-indexing.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 { HostScreenVisibility } from '../foundation/definitions/platform.types'\nimport { SITEMAP_INDEX_PATH } from './sitemap'\n\n/**\n * Search-indexing policy (AGL-1263): the single answer to \"may a crawler\n * index this?\", shared by every surface that has to agree about it.\n *\n * Four surfaces answer that question and they must never disagree — the\n * server `generateMetadata`, its client `<Head>` twin, `robots.txt` and\n * `sitemap.xml`. Before this module three of them had their own copy of the\n * rule and the fourth had none: the sitemap listed whatever was in the host's\n * routing map, so an UNLISTED page was simultaneously told \"do not index me\"\n * in its own head and advertised as a canonical URL in the site's sitemap.\n * That is not a harmless duplication — a sitemap entry is an explicit\n * submission, and submitting a page you have marked noindex is the shape that\n * gets a site flagged for conflicting directives.\n *\n * There are three controls and they compose:\n *\n * - **Site-level** `host.seo.discourageSearchEngines` — the staged-launch\n * switch. Everything about the site goes dark to crawlers at once.\n * - **Per-screen** `screen.visibility` — the existing\n * {@link HostScreenVisibility} model, NOT a parallel `noindex` flag.\n * `UNLISTED` is literally `PUBLIC | (1 << 2)`: \"public, plus the not-listed\n * bit\". A second per-screen field would be a field that can disagree with\n * this one, and every render surface would then need a rule for which wins.\n * - **Per-collection** `collection.excludeEntriesFromSearch` (AGL-3247) — a\n * content collection keeps its listing and its category listings indexable\n * and withholds its ENTRIES. That is a different axis from the two above,\n * not a third copy of either: the subject is the collection, and what it\n * answers for is every URL underneath it.\n */\n\n/** The host fields this module reads; keeps callers free of the full doc. */\nexport interface SearchIndexingHost {\n seo?: {\n /**\n * Site-wide \"discourage search engines\" (AGL-1263). PERSISTED NAME — the\n * wording matches the switch an author sees, so support conversations and\n * the document agree.\n */\n discourageSearchEngines?: boolean\n } | null\n /**\n * Preview (AGL-4086): only the site's team can see it, so nothing about it\n * is offered to a crawler either. Read by {@link isSiteKeptFromSearch},\n * never by {@link isSearchDiscouraged}, which answers for the switch an\n * author flipped and nothing else.\n */\n preview?: boolean | null\n}\n\n/** The screen fields this module reads. */\nexport interface SearchIndexingScreen {\n visibility?: HostScreenVisibility | null\n}\n\n/** The content-collection fields this module reads. */\nexport interface SearchIndexingCollection {\n /**\n * \"List the collection, not its entries\" (AGL-3247). PERSISTED NAME — flat\n * on the collection document, where `schemaType` and `categories` already\n * live, rather than under a `seo` map the collection has never carried.\n */\n excludeEntriesFromSearch?: boolean | null\n}\n\n/**\n * Site-wide opt-out. Absent means \"index me\" — the default a site is created\n * with, and the only safe default: a missing field must never be read as\n * \"hide this site\", or a schema slip would quietly de-index every customer.\n */\nexport function isSearchDiscouraged(\n host: SearchIndexingHost | null | undefined,\n): boolean {\n return host?.seo?.discourageSearchEngines === true\n}\n\n/**\n * Whether crawlers are asked to stay away from the whole site: search is\n * discouraged, or the site is in preview (AGL-4086).\n *\n * Separate from {@link isSearchDiscouraged} on purpose. That one is the state\n * of a switch, and the console shows it as the switch; a site in preview has\n * not had that switch flipped, and reading it as though it had would show the\n * author a setting they never changed. Every surface that answers a CRAWLER —\n * the head, `robots.txt`, the sitemap, the agent files — asks this one.\n */\nexport function isSiteKeptFromSearch(\n host: SearchIndexingHost | null | undefined,\n): boolean {\n return isSearchDiscouraged(host) || host?.preview === true\n}\n\n/**\n * Whether a screen may be indexed on its own merits, ignoring the site-level\n * switch.\n *\n * Only `PUBLIC` — and an absent value, which every screen predating the\n * visibility model carries — is indexable. Everything else is excluded, and\n * that is wider than the old `=== UNLISTED` test on purpose: a\n * password-protected, members-only or private screen has nothing a crawler\n * can reach, so listing it in a sitemap published a URL that answers with a\n * gate. `UNLISTED` was the only one anyone remembered because it is the only\n * one whose *name* is about search.\n */\nexport function isScreenIndexable(\n screen: SearchIndexingScreen | null | undefined,\n): boolean {\n const visibility = screen?.visibility\n if (visibility == null) return true\n return visibility === HostScreenVisibility.PUBLIC\n}\n\n/**\n * The site and screen controls together — what a SCREEN render wants to know.\n *\n * Collection entries do not come through here; their URL belongs to the\n * collection rather than to a screen, so they ask\n * {@link areCollectionEntriesIndexable} alongside {@link isSearchDiscouraged}.\n */\nexport function isPageIndexable(options: {\n host?: SearchIndexingHost | null\n screen?: SearchIndexingScreen | null\n}): boolean {\n if (isSiteKeptFromSearch(options.host)) return false\n return isScreenIndexable(options.screen)\n}\n\n/**\n * Whether a content collection's ENTRIES may be indexed, ignoring the\n * site-level switch (AGL-3247).\n *\n * Absent means \"index them\" — the default every collection was created with,\n * and the only safe default, exactly as for {@link isSearchDiscouraged}: a\n * schema slip must never be readable as \"de-index this customer's blog\".\n *\n * This says nothing about the LISTING at `/{collection}` or the category\n * listings under it. Those stay indexable on purpose — they are the pages\n * that make the withheld entries reachable at all, and a collection whose\n * whole point is that it is too numerous to index is still a collection a\n * reader should be able to find.\n */\nexport function areCollectionEntriesIndexable(\n collection: SearchIndexingCollection | null | undefined,\n): boolean {\n return collection?.excludeEntriesFromSearch !== true\n}\n\n/**\n * The `robots.txt` body for a host.\n *\n * When search is discouraged the file disallows everything AND the pages\n * carry `noindex`, which reads like a contradiction and is not. `Disallow`\n * only asks a crawler not to FETCH; a URL linked from elsewhere can still be\n * indexed without ever being fetched, and a crawler that obeys the disallow\n * never sees the `noindex` that would have stopped it. Belt and braces is the\n * documented remedy, and it is what every other builder's equivalent switch\n * does.\n *\n * The `Sitemap:` line is dropped in that state rather than kept: handing a\n * crawler an index of the site you just asked it not to crawl is the\n * contradiction the rest of this module exists to remove.\n */\nexport function buildRobotsTxt(options: {\n host?: SearchIndexingHost | null\n /** Absolute origin (no trailing slash); omitted when unresolvable. */\n origin?: string\n}): string {\n // A site in preview (AGL-4086) closes the file the same way: nobody but\n // its team may see the site, so no crawler is invited in.\n if (isSiteKeptFromSearch(options.host)) {\n /*\n Every named agent group is dropped along with the wildcard. A group that\n named an agent and allowed it would be a LOUDER permission than the\n wildcard's refusal — `robots.txt` precedence gives the most specific\n matching group, so `User-agent: ClaudeBot` beats `User-agent: *` — and a\n site that asked to be left alone would be inviting exactly the readers it\n asked to leave.\n */\n return 'User-agent: *\\nDisallow: /\\n'\n }\n const sitemap = options.origin\n ? `Sitemap: ${options.origin}${SITEMAP_INDEX_PATH}\\n`\n : ''\n /*\n AI agents are named EXPLICITLY, and the group says exactly what the\n wildcard above already says.\n\n That looks redundant and is not. A wildcard `Allow: /` is the absence of a\n restriction; a named group is a STATEMENT, and several readers only look\n for the statement: an AI crawler checks for a group bearing its own token\n before it checks the wildcard, some readiness audits score a site on\n whether one exists, and an operator reading this file learns that admitting\n these agents is a decision somebody made rather than a default nobody\n revisited.\n\n It is also the file that will be edited when the decision changes. A site\n owner who wants to exclude one model's crawler edits its line here; before\n this list existed there was no line to edit, and the only lever was a\n wildcard `Disallow` that would have taken Google with it.\n\n ⚠️ `robots.txt` is a REQUEST, not a control. It stops a crawler that reads\n it and honors it, and nothing else — it is not an access control, and the\n WAF is where an agent is actually admitted or refused. The two have to\n agree: a site that names ClaudeBot here and challenges it at the edge has\n published an invitation it does not honor.\n */\n const agents = AI_AGENT_USER_AGENTS.map(\n (agent) => `User-agent: ${agent}\\nAllow: /\\n`,\n ).join('\\n')\n return `User-agent: *\\nAllow: /\\n\\n${agents}\\n${sitemap}`\n}\n\n/**\n * The AI agent user agents a site admits by name.\n *\n * Two kinds, kept in one list because `robots.txt` does not distinguish them\n * and a site owner reading the file should see everything that is admitted:\n *\n * - **Fetchers** — `ChatGPT-User`, `Claude-User`, `Perplexity-User`,\n * `Claude-SearchBot`, `ora-agent`. These act for a person who is asking\n * about the site RIGHT NOW. Refusing one does not protect anything; it\n * means the answer that person gets is written without the site's own\n * words in it.\n * - **Trainers and indexers** — `GPTBot`, `ClaudeBot`, `Google-Extended`,\n * `PerplexityBot`, `Applebot-Extended`, `CCBot`, `Bytespider`,\n * `Amazonbot`, `meta-externalagent`, `DeepSeekBot`, `cohere-ai`,\n * `Diffbot`, `Omgilibot`, `Timpibot`, `YouBot`. These read for a corpus.\n *\n * Admitting the second group is a judgment, and it is the platform DEFAULT\n * rather than a permanent answer: a tenant site is published to be read, and\n * a site that is absent from the corpora is absent from the answers. A site\n * owner who disagrees has `discourageSearchEngines`, which refuses everything\n * here in one switch.\n *\n * Spellings are the vendors' own tokens and are case-sensitive to some\n * readers. Do not \"tidy\" them.\n */\nexport const AI_AGENT_USER_AGENTS: readonly string[] = [\n 'GPTBot',\n 'ChatGPT-User',\n 'OAI-SearchBot',\n 'ClaudeBot',\n 'Claude-User',\n 'Claude-SearchBot',\n 'anthropic-ai',\n 'Google-Extended',\n 'PerplexityBot',\n 'Perplexity-User',\n 'Applebot-Extended',\n 'meta-externalagent',\n 'Amazonbot',\n 'Bytespider',\n 'CCBot',\n 'cohere-ai',\n 'DeepSeekBot',\n 'Diffbot',\n 'MistralAI-User',\n 'Omgilibot',\n 'Timpibot',\n 'YouBot',\n 'ora-agent',\n /*\n OUR OWN CLI (AGL-2717), and it belongs in this list rather than beside it.\n\n `@aglyn/cli` reads a site on a person's behalf — the same job\n `ChatGPT-User` and `Perplexity-User` do, and the same reason they are here.\n It names itself `aglyn-cli/<version> (+https://aglyn.com)` so a site\n operator has something to match on and somewhere to complain to.\n\n It is in THIS list, and not only in the WAF, because\n `check:agent-readiness` asserts the two are the same set: a client admitted\n at the edge but unnamed here is a client nobody reading `robots.txt` knows\n is admitted. The wildcard above already allows it, so the group changes\n nothing functionally — it is the statement that matters, exactly as for the\n twenty-three above.\n */\n 'aglyn-cli',\n]\n\n/**\n * Screen ids that are PUBLISHED but are HTTP STATUS pages, and must never be\n * handed to a crawler or an agent (AGL-2716).\n *\n * ⚠️ NOT `screen-route.ts`'s `nonPageScreenIds`, which is a BILLING predicate:\n * that one answers \"which live documents does the flat infrastructure cap\n * bound\" and counts email documents and entry templates. Two questions, two\n * answers, and merging them would make a pricing cap decide what a crawler may\n * read. The name here says `status` for that reason.\n *\n * Two kinds, and both were found the hard way:\n *\n * - **Bound error screens.** `aglyn.com` published `/401`, `/404` and `/503`\n * into its sitemap, which hands a new domain the worst possible first crawl:\n * the crawler fetches each one, gets the status code it exists to represent,\n * and logs crawl errors and soft-404s against a site it has never seen.\n * - **A bare status code as the whole path.** An error screen on this platform\n * is usually just a screen published at the status path with nothing binding\n * it, so filtering the bound ids alone changed nothing on the site that had\n * the problem. The pattern is anchored, so a real page at `/404-guide` or\n * `/products/503` keeps its place.\n *\n * Extracted from `/api/sitemap`, where it was written inline, so `/llms.txt`\n * and every later reader apply the same rule. The one thing this MUST NOT\n * become is a second copy of that filter: the two surfaces disagreeing is how\n * a site advertises to agents the URLs it withheld from crawlers.\n *\n * Template screens are NOT here. They are excluded too, but the source is a\n * Firestore read (`getTemplateScreenIds`) rather than the host document, so\n * they cannot be decided by a pure function.\n */\nexport function statusPageScreenIds(\n host:\n | {\n screens?: Record<string, string | undefined> | null\n errorScreens?: Record<string, string | undefined> | null\n notFoundScreenId?: string | null\n }\n | null\n | undefined,\n): Set<string> {\n const excluded = new Set<string>()\n for (const bound of [\n ...Object.values(host?.errorScreens ?? {}),\n host?.notFoundScreenId,\n ]) {\n if (typeof bound === 'string' && bound) excluded.add(bound)\n }\n for (const [screenId, path] of Object.entries(host?.screens ?? {})) {\n if (STATUS_CODE_PATH.test(String(path))) excluded.add(screenId)\n }\n return excluded\n}\n\n/** A bare HTTP status code as an entire route path — `/404`, `503`. */\nconst STATUS_CODE_PATH = /^\\/?[1-5][0-9][0-9]$/\n"],"names":["HostScreenVisibility","SITEMAP_INDEX_PATH","isSearchDiscouraged","host","seo","discourageSearchEngines","isSiteKeptFromSearch","preview","isScreenIndexable","screen","visibility","PUBLIC","isPageIndexable","options","areCollectionEntriesIndexable","collection","excludeEntriesFromSearch","buildRobotsTxt","sitemap","origin","agents","AI_AGENT_USER_AGENTS","map","agent","join","statusPageScreenIds","excluded","Set","bound","Object","values","errorScreens","notFoundScreenId","add","screenId","path","entries","screens","STATUS_CODE_PATH","test","String"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,oBAAoB,QAAQ,8CAA0C;AAC/E,SAASC,kBAAkB,QAAQ,eAAW;AAkE9C;;;;CAIC,GACD,OAAO,SAASC,oBACdC,IAA2C;QAEpCA;IAAP,OAAOA,CAAAA,yBAAAA,YAAAA,KAAMC,GAAG,qBAATD,UAAWE,uBAAuB,MAAK;AAChD;AAEA;;;;;;;;;CASC,GACD,OAAO,SAASC,qBACdH,IAA2C;IAE3C,OAAOD,oBAAoBC,SAASA,CAAAA,wBAAAA,KAAMI,OAAO,MAAK;AACxD;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,kBACdC,MAA+C;IAE/C,MAAMC,aAAaD,0BAAAA,OAAQC,UAAU;IACrC,IAAIA,cAAc,MAAM,OAAO;IAC/B,OAAOA,eAAeV,qBAAqBW,MAAM;AACnD;AAEA;;;;;;CAMC,GACD,OAAO,SAASC,gBAAgBC,OAG/B;IACC,IAAIP,qBAAqBO,QAAQV,IAAI,GAAG,OAAO;IAC/C,OAAOK,kBAAkBK,QAAQJ,MAAM;AACzC;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,SAASK,8BACdC,UAAuD;IAEvD,OAAOA,CAAAA,8BAAAA,WAAYC,wBAAwB,MAAK;AAClD;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASC,eAAeJ,OAI9B;IACC,wEAAwE;IACxE,0DAA0D;IAC1D,IAAIP,qBAAqBO,QAAQV,IAAI,GAAG;QACtC;;;;;;;IAOA,GACA,OAAO;IACT;IACA,MAAMe,UAAUL,QAAQM,MAAM,GAC1B,CAAC,SAAS,EAAEN,QAAQM,MAAM,GAAGlB,mBAAmB,EAAE,CAAC,GACnD;IACJ;;;;;;;;;;;;;;;;;;;;;;EAsBA,GACA,MAAMmB,SAASC,qBAAqBC,GAAG,CACrC,CAACC,QAAU,CAAC,YAAY,EAAEA,MAAM,YAAY,CAAC,EAC7CC,IAAI,CAAC;IACP,OAAO,CAAC,2BAA2B,EAAEJ,OAAO,EAAE,EAAEF,SAAS;AAC3D;AAEA;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GACD,OAAO,MAAMG,uBAA0C;IACrD;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;;;;;;;;;;;;;;EAcA,GACA;CACD,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GACD,OAAO,SAASI,oBACdtB,IAOa;;IAEb,MAAMuB,WAAW,IAAIC;IACrB,KAAK,MAAMC,SAAS;WACfC,OAAOC,MAAM,SAAC3B,wBAAAA,KAAM4B,YAAY,mBAAI,CAAC;QACxC5B,wBAAAA,KAAM6B,gBAAgB;KACvB,CAAE;QACD,IAAI,OAAOJ,UAAU,YAAYA,OAAOF,SAASO,GAAG,CAACL;IACvD;IACA,KAAK,MAAM,CAACM,UAAUC,KAAK,IAAIN,OAAOO,OAAO,UAACjC,wBAAAA,KAAMkC,OAAO,oBAAI,CAAC,GAAI;QAClE,IAAIC,iBAAiBC,IAAI,CAACC,OAAOL,QAAQT,SAASO,GAAG,CAACC;IACxD;IACA,OAAOR;AACT;AAEA,qEAAqE,GACrE,MAAMY,mBAAmB"}
|
|
@@ -169,6 +169,7 @@ export * from './page-markdown';
|
|
|
169
169
|
export * from './request-geo';
|
|
170
170
|
export * from './request-ip';
|
|
171
171
|
export * from './search-indexing';
|
|
172
|
+
export * from './site-preview';
|
|
172
173
|
export * from './seo-title';
|
|
173
174
|
export * from './sitemap';
|
|
174
175
|
export * from './visitor-consent';
|
|
@@ -317,6 +317,8 @@ export * from "./page-markdown.js";
|
|
|
317
317
|
export * from "./request-geo.js";
|
|
318
318
|
export * from "./request-ip.js";
|
|
319
319
|
export * from "./search-indexing.js";
|
|
320
|
+
// Preview: a site or page only its team can see (AGL-4086).
|
|
321
|
+
export * from "./site-preview.js";
|
|
320
322
|
export * from "./seo-title.js";
|
|
321
323
|
export * from "./sitemap.js";
|
|
322
324
|
export * from "./visitor-consent.js";
|