@aglyn/aglyn 1.0.0-beta.264 → 1.0.0-beta.268

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/package.json +12 -12
  2. package/src/lib/app-utils/account-acquisition.d.ts +13 -0
  3. package/src/lib/app-utils/account-acquisition.js +84 -0
  4. package/src/lib/app-utils/account-acquisition.js.map +1 -1
  5. package/src/lib/app-utils/analytics-events.d.ts +70 -0
  6. package/src/lib/app-utils/analytics-events.js +8 -0
  7. package/src/lib/app-utils/analytics-events.js.map +1 -1
  8. package/src/lib/app-utils/docs-help-sections.generated.js +4 -1
  9. package/src/lib/app-utils/docs-help-sections.generated.js.map +1 -1
  10. package/src/lib/app-utils/docs-help.generated.d.ts +2 -2
  11. package/src/lib/app-utils/docs-help.generated.js +8 -1
  12. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  13. package/src/lib/app-utils/docs-index.generated.js +31 -10
  14. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  15. package/src/lib/app-utils/expand-repeatables.js +14 -7
  16. package/src/lib/app-utils/expand-repeatables.js.map +1 -1
  17. package/src/lib/app-utils/health-report.d.ts +120 -8
  18. package/src/lib/app-utils/health-report.js +138 -3
  19. package/src/lib/app-utils/health-report.js.map +1 -1
  20. package/src/lib/app-utils/live-document-count.client.d.ts +28 -0
  21. package/src/lib/app-utils/live-document-count.client.js +35 -0
  22. package/src/lib/app-utils/live-document-count.client.js.map +1 -0
  23. package/src/lib/app-utils/live-document-count.d.ts +91 -0
  24. package/src/lib/app-utils/live-document-count.js +81 -0
  25. package/src/lib/app-utils/live-document-count.js.map +1 -0
  26. package/src/lib/app-utils/notification-sounds.d.ts +49 -0
  27. package/src/lib/app-utils/notification-sounds.js +54 -0
  28. package/src/lib/app-utils/notification-sounds.js.map +1 -0
  29. package/src/lib/app-utils/notifications.d.ts +2 -0
  30. package/src/lib/app-utils/notifications.js +38 -10
  31. package/src/lib/app-utils/notifications.js.map +1 -1
  32. package/src/lib/app-utils/screen-analytics-aggregate.d.ts +10 -0
  33. package/src/lib/app-utils/screen-analytics-aggregate.js +11 -0
  34. package/src/lib/app-utils/screen-analytics-aggregate.js.map +1 -1
  35. package/src/lib/app-utils/upload-inspection.d.ts +9 -0
  36. package/src/lib/app-utils/upload-inspection.js +27 -0
  37. package/src/lib/app-utils/upload-inspection.js.map +1 -1
  38. package/src/lib/plugin-manager/first-party-plugins.generated.js +6 -0
  39. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  40. package/src/lib/plugin-manager/plugin-activity-actions.d.ts +14 -0
  41. package/src/lib/plugin-manager/plugin-activity-actions.js +30 -3
  42. package/src/lib/plugin-manager/plugin-activity-actions.js.map +1 -1
  43. package/src/lib/plugin-manager/plugin-site-erase.d.ts +81 -0
  44. package/src/lib/plugin-manager/plugin-site-erase.js +56 -0
  45. package/src/lib/plugin-manager/plugin-site-erase.js.map +1 -0
@@ -67,14 +67,21 @@ function substituteValue(value, context) {
67
67
  /** A text that is nothing but `{{item.*}}` tokens: a binding, not words. */ const WHOLE_BINDING = new RegExp(`^\\s*(?:${ITEM_TOKEN_PATTERN.source}\\s*)+$`);
68
68
  /**
69
69
  * Whether a copy's element is a binding its record left empty (AGL-3616):
70
- * its text, `children`, was only `{{item.*}}` tokens and they printed
71
- * nothing. The copy leaves the element out rather than drawing an empty line
72
- * (an eyebrow with no venue), and a record that fills the field draws it.
70
+ * its text, `children`, or its picture, `src`, was only `{{item.*}}` tokens
71
+ * and they printed nothing. The copy leaves the element out rather than
72
+ * drawing an empty line (an eyebrow with no venue) or an empty frame (a card
73
+ * whose record has no photo, AGL-3912), and a record that fills the field
74
+ * draws it.
73
75
  */ function isEmptyBinding(template, substituted) {
74
- const text = template == null ? void 0 : template['children'];
75
- if (typeof text !== 'string' || !WHOLE_BINDING.test(text)) return false;
76
- const printed = substituted['children'];
77
- return typeof printed === 'string' && printed.trim() === '';
76
+ return [
77
+ 'children',
78
+ 'src'
79
+ ].some((prop)=>{
80
+ const text = template == null ? void 0 : template[prop];
81
+ if (typeof text !== 'string' || !WHOLE_BINDING.test(text)) return false;
82
+ const printed = substituted[prop];
83
+ return typeof printed === 'string' && printed.trim() === '';
84
+ });
78
85
  }
79
86
  /**
80
87
  * The key a node repeats over, trimmed, or `''` when it repeats over nothing.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/expand-repeatables.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { AglynNodeSchema, NodeId } from '../foundation'\nimport {\n applyRepeatQuery,\n parseRepeatFilter,\n parseRepeatSort,\n} from './repeat-query'\n\n/** Namespaces cloned template ids per container/record. */\nexport const REPEAT_NODE_ID_PREFIX = 'rep__'\n\n/**\n * `{{item.field}}` token, plus an optional single reference hop\n * (`{{item.author.name}}`, AGL-180). One hop only — the pattern itself is\n * the depth guard, so cycles can't recurse.\n */\nconst ITEM_TOKEN_PATTERN =\n /\\{\\{\\s*item\\.([A-Za-z][A-Za-z0-9_]*)(?:\\.([A-Za-z][A-Za-z0-9_]*))?\\s*\\}\\}/g\n\n/** Hard bound on rows a single repeatable renders, before `repeatLimit`. */\nexport const REPEAT_MAX_RECORDS = 100\n\n/**\n * What the expansion reads of the rows' field model (AGL-177, AGL-180): which\n * fields are references, each with the key its target's rows are answered\n * under (`datasetsByKey`). The model itself is the plugin's that keeps the\n * rows, and it states this much of it when it hands the rows over\n * (`plugin-manager/repeat-rows`, `repeat-sources`); a field it does not name\n * here hops nowhere, and its token is left as written.\n */\nexport interface RepeatRowsModel {\n /** Reference field id → the key of the rows it points into. */\n references?: Readonly<Record<string, string>>\n}\n\nexport interface RepeatableDataset {\n /**\n * Row value maps, in display order. Rows carry `$id` (needed to resolve\n * incoming references); values may be typed (AGL-177), stringified at\n * substitution.\n */\n records: Array<Record<string, unknown>>\n /** The rows' references; required for reference-hop bindings. */\n model?: RepeatRowsModel\n}\n\ninterface SubstituteContext {\n record: Record<string, unknown>\n /** The repeated rows' references (AGL-180). */\n model?: RepeatRowsModel\n /** All host datasets keyed by id (and name) for hop resolution. */\n datasetsByKey?: Record<string, RepeatableDataset | undefined>\n /**\n * What a token whose field the record leaves empty becomes: `'token'` keeps\n * it as written (the besigner's canvas, where an author edits the template),\n * `'empty'` prints nothing (a rendered page, AGL-3616). A published page\n * never shows a visitor `{{item.venue}}` because one record has no venue.\n */\n missing?: 'token' | 'empty'\n}\n\nconst displayValue = (value: unknown): string =>\n Array.isArray(value) ? value.join(', ') : String(value)\n\n/** Resolves `{{item.ref.field}}`: FKey(s) → target document field value. */\nfunction resolveReferenceHop(\n context: SubstituteContext,\n fieldId: string,\n targetFieldId: string,\n): string | null {\n const targetKey = context.model?.references?.[fieldId]\n if (!targetKey) return null\n const target = context.datasetsByKey?.[targetKey]\n if (!target) return null\n const keys = context.record[fieldId]\n const ids = Array.isArray(keys) ? keys : keys != null ? [keys] : []\n const resolved = ids\n .map((id) => {\n const match = target.records.find((row) => row['$id'] === id)\n const value = match?.[targetFieldId]\n return value != null ? displayValue(value) : null\n })\n .filter((value): value is string => value != null)\n return resolved.length ? resolved.join(', ') : null\n}\n\nfunction substituteValue(\n value: unknown,\n context: SubstituteContext,\n): unknown {\n const { record } = context\n if (typeof value === 'string') {\n const unresolved = (token: string) =>\n context.missing === 'empty' ? '' : token\n return value.replace(\n ITEM_TOKEN_PATTERN,\n (token, field: string, hop?: string) => {\n if (hop) {\n const resolved = resolveReferenceHop(context, field, hop)\n return resolved != null ? resolved : unresolved(token)\n }\n return record[field] != null\n ? displayValue(record[field])\n : unresolved(token)\n },\n )\n }\n if (Array.isArray(value)) {\n return value.map((entry) => substituteValue(entry, context))\n }\n if (value && typeof value === 'object') {\n const next: Record<string, unknown> = {}\n for (const [key, entry] of Object.entries(value)) {\n next[key] = substituteValue(entry, context)\n }\n return next\n }\n return value\n}\n\n/** A text that is nothing but `{{item.*}}` tokens: a binding, not words. */\nconst WHOLE_BINDING = new RegExp(\n `^\\\\s*(?:${ITEM_TOKEN_PATTERN.source}\\\\s*)+$`,\n)\n\n/**\n * Whether a copy's element is a binding its record left empty (AGL-3616):\n * its text, `children`, was only `{{item.*}}` tokens and they printed\n * nothing. The copy leaves the element out rather than drawing an empty line\n * (an eyebrow with no venue), and a record that fills the field draws it.\n */\nfunction isEmptyBinding(\n template: Record<string, unknown> | undefined,\n substituted: Record<string, unknown>,\n): boolean {\n const text = template?.['children']\n if (typeof text !== 'string' || !WHOLE_BINDING.test(text)) return false\n const printed = substituted['children']\n return typeof printed === 'string' && printed.trim() === ''\n}\n\n/**\n * The key a node repeats over, trimmed, or `''` when it repeats over nothing.\n *\n * The one predicate every repeat question is asked through: the expansion, the\n * datasets-read gate below, the reusable-component graft that moves a\n * placement's repeat onto the element it becomes, and the besigner's preview\n * and badge. Two copies of it are how a `.trim()` on one side and not the\n * other becomes an empty list on a customer's page.\n */\nexport const repeatKey = (node: unknown): string => {\n const key = (node as { props?: { repeatDataset?: unknown } })?.props\n ?.repeatDataset\n return typeof key === 'string' ? key.trim() : ''\n}\n\n/**\n * Makes an element that holds children repeat ITSELF, once per record, instead\n * of what is inside it (AGL-3111): a card whose contents are the design, rather\n * than a list whose contents are the item.\n *\n * Persisted in screen, layout and component documents — never rename. `'true'`\n * counts too, because a switch round-tripped through a text-shaped store comes\n * back as the string.\n */\nexport const REPEAT_SELF_PROP = 'repeatSelf'\n\n/**\n * Every prop that directs a repeat. They are instructions to the composition,\n * never attributes of the element: the expansion consumes them from every node\n * it expands and every copy it makes, so a published element never carries\n * one. Persisted — never rename.\n */\nexport const REPEAT_DIRECTIVE_PROPS: readonly string[] = [\n 'repeatDataset',\n 'repeatLimit',\n 'repeatFilter',\n 'repeatSort',\n REPEAT_SELF_PROP,\n]\n\n/**\n * What a repeating node copies once per record (AGL-3111).\n *\n * - `children` — what is inside it. The node stays one element around the\n * copies: the list, grid or gallery they fill. This is how a Stack has\n * always repeated (AGL-103), and a stored Stack names neither scope.\n * - `self` — the node itself, with everything inside it. What a leaf and a\n * component instance repeat, and what any element carrying\n * {@link REPEAT_SELF_PROP} repeats.\n */\nexport type RepeatScope = 'self' | 'children'\n\n/**\n * Which scope a repeating node has, read off the node alone so the published\n * page's composition and the besigner decide it identically.\n *\n * {@link REPEAT_SELF_PROP} decides it whenever the node carries one, which is\n * every repeat the Attributes panel writes. Without one the node's own shape\n * decides, and it decides CONSERVATIVELY: anything that holds a child list —\n * a Stack, empty or not — keeps repeating its children, and only a node with\n * no child list at all repeats itself. A stored repeat predates the prop, so\n * this branch is the one that has to render it unchanged, and an author who\n * named a dataset before filling the container in would otherwise come back to\n * a hundred empty copies of it.\n *\n * A component instance never reaches that branch: the graft stamps the prop on\n * the element it becomes, whose children are the component's own.\n */\nexport function repeatScope(node: unknown): RepeatScope {\n const { props, nodes } = (node ?? {}) as {\n props?: Record<string, unknown>\n nodes?: unknown\n }\n // `'true'`/`'false'` too: a switch round-tripped through a text-shaped\n // store comes back as the string it was written as.\n const self = props?.[REPEAT_SELF_PROP]\n if (self === true || self === 'true') return 'self'\n if (self === false || self === 'false') return 'children'\n return Array.isArray(nodes) ? 'children' : 'self'\n}\n\n/**\n * One record's values put into `{{item.field}}` tokens, anywhere in a props\n * map — the substitution {@link expandRepeatables} makes on every copy it\n * builds, for a caller that already knows which record it is rendering.\n *\n * The besigner's canvas is that caller (AGL-3111): the element an author\n * edits IS the first copy, so it draws with the first record's values while\n * the node it saves keeps the tokens. Exported rather than reimplemented\n * because a canvas that resolved a token differently from the page would be\n * showing the author a page that does not exist.\n */\nexport function substituteRecordTokens<T>(\n props: T,\n context: {\n record: Record<string, unknown>\n /** The repeated rows' references (AGL-180). */\n model?: RepeatRowsModel\n /** Rows by key, for a one-hop reference (AGL-180). */\n datasetsByKey?: Record<string, RepeatableDataset | undefined>\n },\n): T {\n return substituteValue(props, context) as T\n}\n\n/**\n * The one record a whole page is rendered for (AGL-3475): a record template's\n * routed record, in the shape a repeat hands each of its copies.\n */\nexport interface PageRecordScope {\n record: Record<string, unknown>\n /** The record's references (AGL-180). */\n model?: RepeatRowsModel\n /** Rows by key, for a one-hop reference (AGL-180). */\n datasetsByKey?: Record<string, RepeatableDataset | undefined>\n}\n\n/**\n * Every node's props with the page's record put into its `{{item.field}}`\n * tokens — the substitution a repeat makes on each copy, made once over a\n * page that renders one record (AGL-3475).\n *\n * Run AFTER {@link expandRepeatables}: a repeat inside the page has already\n * spent its own `{{item.*}}` tokens on its own rows by then, so what is left\n * is the page's. A token naming a field the record leaves empty prints\n * nothing, exactly as in a repeat (AGL-3616). Returns the input when there is\n * no record.\n */\nexport function substituteNodesRecordTokens<N>(\n nodes: Record<NodeId, N>,\n scope: PageRecordScope | null | undefined,\n): Record<NodeId, N> {\n if (!scope) return nodes\n const next: Record<NodeId, N> = {}\n for (const [id, node] of Object.entries(nodes) as Array<[NodeId, N]>) {\n const props = (node as { props?: unknown })?.props\n next[id] = props\n ? ({\n ...node,\n props: substituteValue(props, { ...scope, missing: 'empty' }),\n } as N)\n : node\n }\n return next\n}\n\n/**\n * The page's record put into each repeat's FILTER, before the repeats expand\n * (AGL-3475): on a record template, `category == {{item.category}}` lists the\n * other records in the routed record's category. Only `repeatFilter` is\n * touched — everything else inside a repeat is its template, whose\n * `{{item.*}}` belongs to its own rows. Returns the input when there is no\n * record or no filter names one.\n */\nexport function substituteRepeatFilterRecordTokens<N>(\n nodes: Record<NodeId, N>,\n scope: PageRecordScope | null | undefined,\n): Record<NodeId, N> {\n if (!scope) return nodes\n let next: Record<NodeId, N> | null = null\n for (const [id, node] of Object.entries(nodes) as Array<[NodeId, N]>) {\n const props = (node as { props?: Record<string, unknown> })?.props\n const filter = props?.['repeatFilter']\n if (typeof filter !== 'string' || !filter.includes('{{')) continue\n next ??= { ...nodes }\n next[id] = {\n ...node,\n props: { ...props, repeatFilter: substituteValue(filter, scope) },\n } as N\n }\n return next ?? nodes\n}\n\n/** The node minus its repeat directives, or the node itself when it has none. */\nexport function withoutRepeatDirective<N>(node: N): N {\n const props = (node as { props?: Record<string, unknown> })?.props\n if (!props || !REPEAT_DIRECTIVE_PROPS.some((key) => key in props)) {\n return node\n }\n const kept = { ...props }\n for (const key of REPEAT_DIRECTIVE_PROPS) delete kept[key]\n return { ...node, props: kept }\n}\n\n/**\n * The records a repeating node renders, in order (AGL-181): its filter and sort\n * evaluated over the rows, then its limit, never past\n * {@link REPEAT_MAX_RECORDS}. An unparseable filter or sort fails open.\n *\n * Shared by the expansion and the besigner's canvas preview and badge, so the\n * canvas can never show a copy or a count the published page does not.\n */\nexport function repeatedRecords(\n node: unknown,\n dataset: RepeatableDataset | undefined,\n): Array<Record<string, unknown>> {\n const props = ((node as { props?: unknown })?.props ?? {}) as Record<\n string,\n unknown\n >\n const where = parseRepeatFilter(String(props['repeatFilter'] ?? ''))\n const orderBy = parseRepeatSort(String(props['repeatSort'] ?? ''))\n const limit = Number(props['repeatLimit'])\n return applyRepeatQuery(dataset?.records ?? [], {\n ...(where ? { where: [where] } : {}),\n ...(orderBy ? { orderBy } : {}),\n }).slice(\n 0,\n Number.isFinite(limit) && limit > 0\n ? Math.min(limit, REPEAT_MAX_RECORDS)\n : REPEAT_MAX_RECORDS,\n )\n}\n\n/**\n * Does this tree contain anything {@link expandRepeatables} would expand —\n * or, with no rows, empty?\n *\n * The rows read is the largest on the render path — up to two pages of\n * {@link REPEAT_MAX_RECORDS} records for every key a page repeats over — and a\n * page that repeats over nothing should pay none of it (AGL-1440).\n *\n * Any gate on that read has to ask EXACTLY the question the expansion asks,\n * which is why this and {@link repeatKeys} share the one predicate\n * `expandRepeatables` looks keys up with, {@link repeatKey}. A gate that is\n * even slightly stricter than the expansion is not a saving: it is a published\n * page whose repeat never reaches the expansion at all, and so goes out as\n * its raw template — `{{item.*}}` tokens and all — where the author put a list.\n */\nexport function hasRepeatableNodes(\n nodes: Record<NodeId, unknown> | null | undefined,\n): boolean {\n if (!nodes) return false\n return Object.values(nodes).some((node) => repeatKey(node) !== '')\n}\n\n/**\n * Every key this tree repeats over, exactly as {@link expandRepeatables} looks\n * it up — sorted and without repeats.\n *\n * What the tenant compose asks the repeat-rows contract for\n * (`plugin-manager/repeat-rows.ts`), so a page loads the rows it renders and\n * not everything its site may see. Built on the same predicate as\n * {@link hasRepeatableNodes} for the reason given there.\n */\nexport function repeatKeys(\n nodes: Record<NodeId, unknown> | null | undefined,\n): string[] {\n const keys = new Set<string>()\n for (const node of Object.values(nodes ?? {})) {\n const key = repeatKey(node)\n if (key) keys.add(key)\n }\n return [...keys].sort()\n}\n\n/**\n * Repeats (AGL-103, AGL-3111): any node carrying `props.repeatDataset` (a\n * dataset id or display name) renders once per record of that dataset, with\n * `{{item.field}}` tokens in the copied string props replaced by the record's\n * values. A field the record leaves empty prints nothing — never the raw\n * token — and an element whose whole text is such a binding is left out of\n * that copy (AGL-3616: a tour date with no venue drew `{{ITEM.VENUE}}` as its\n * eyebrow on a published site). The besigner's canvas keeps the tokens of\n * the template an author edits ({@link substituteRecordTokens}).\n *\n * What is copied is the node's {@link repeatScope}:\n *\n * - `children` — its children are the item template. The node stays, and its\n * child list becomes the copies, record by record. Its own props are not the\n * template, so no token in them is substituted.\n * - `self` — the node and everything inside it are the template. The copies\n * take the node's place in its parent's child list, and every prop in the\n * subtree is substituted, the node's own included.\n *\n * - Copy ids are namespaced `rep__{repeatId}__{index}__…` — the repeat's own\n * id, then the template node's — so repeats never collide, including inside\n * grafted reusable components. A self-scoped node's first copy is\n * `rep__{id}__0__{id}`. Run this AFTER `composeReusableComponentNodes` and\n * BEFORE binding resolution.\n * - Rows are the node's {@link repeatedRecords}.\n * - The repeat directives ({@link REPEAT_DIRECTIVE_PROPS}) are consumed: every\n * node this expands, and every copy it makes, comes out without them.\n * - Repeats do not nest. A repeat inside another repeat's template is copied\n * with its directives consumed, so it renders once in each copy.\n * - No rows renders ZERO copies (AGL-3496): a `children` repeat keeps its\n * element with an empty child list, a `self` repeat leaves its parent's\n * list. That covers a filter matching nothing, an empty dataset, and a key\n * the rows answer nothing under — a deleted or unshared dataset, or no rows\n * map at all — because the template drawn without a record is its literal\n * `{{item.*}}` tokens, printed to every visitor. Never a throw: a dataset\n * going away empties a list, it does not take the page down.\n * - An empty template, or a self-scoped node its parent does not list (the\n * document root, say), has nothing to copy or nowhere to put copies, and is\n * left as written.\n * - Inputs are never mutated; templates stay in the map unreferenced.\n *\n * This is the PUBLISHED page's composition. The besigner's canvas keeps the\n * template on screen whatever the rows say — it is what an author edits — and\n * flags a repeat with no rows on its badge instead ({@link repeatedRecords}\n * is the count both read).\n */\nexport function expandRepeatables<N extends AglynNodeSchema = AglynNodeSchema>(\n nodes: Record<NodeId, N>,\n datasetsByKey: Record<string, RepeatableDataset | undefined> | undefined,\n): Record<NodeId, N> {\n const rowsByKey = datasetsByKey ?? {}\n const repeatIds = Object.entries(nodes).filter(\n ([, node]) => repeatKey(node) !== '',\n )\n if (!repeatIds.length) return nodes\n\n const next: Record<NodeId, N> = { ...nodes }\n for (const [repeatId, repeated] of repeatIds) {\n const dataset = rowsByKey[repeatKey(repeated)]\n const records = repeatedRecords(repeated, dataset)\n const self = repeatScope(repeated) === 'self'\n // The copies' parent: the node itself, or — for a self-scoped node — the\n // parent whose child list the copies take its place in, read from `next`\n // so two self-scoped siblings both land in the list the other has left.\n const parentId = (self ? repeated.parentId : repeatId) as NodeId\n const siblings = next[parentId]?.nodes\n const slot = Array.isArray(siblings)\n ? (siblings as NodeId[]).indexOf(repeatId)\n : -1\n const templateIds = self\n ? [repeatId]\n : Array.isArray(repeated.nodes)\n ? (repeated.nodes as NodeId[])\n : []\n next[repeatId] = withoutRepeatDirective(next[repeatId])\n if (!templateIds.length || (self && slot < 0)) continue\n if (!records.length) {\n // Zero copies (AGL-3496). The template is a design, not content: drawn\n // without a record it prints `{{item.name}}` to every visitor.\n if (self) {\n const listed = [...(next[parentId].nodes as NodeId[])]\n listed.splice(slot, 1)\n next[parentId] = { ...next[parentId], nodes: listed }\n } else {\n next[repeatId] = { ...next[repeatId], nodes: [] }\n }\n continue\n }\n\n const copyIds: NodeId[] = []\n records.forEach((record, index) => {\n const prefix = `${REPEAT_NODE_ID_PREFIX}${repeatId}__${index}__`\n const prefixId = (id: NodeId) => `${prefix}${id}`\n // Whether the copy drew the node: an element whose whole text is a\n // binding this record leaves empty is left out of it (AGL-3616).\n const cloneSubtree = (id: NodeId, clonedParentId: NodeId): boolean => {\n const node = nodes[id]\n if (!node) return false\n const props = substituteValue(node.props ?? {}, {\n record,\n model: dataset?.model,\n datasetsByKey: rowsByKey,\n missing: 'empty',\n }) as Record<string, unknown>\n if (isEmptyBinding(node.props as Record<string, unknown>, props)) {\n return false\n }\n const clonedChildren = Array.isArray(node.nodes)\n ? (node.nodes as NodeId[])\n : undefined\n const drawn = clonedChildren?.filter((childId) =>\n cloneSubtree(childId, prefixId(id)),\n )\n next[prefixId(id)] = withoutRepeatDirective({\n ...node,\n $id: prefixId(id),\n parentId: clonedParentId,\n props: props as any,\n ...(drawn && {\n nodes: drawn.map((childId) => prefixId(childId)),\n }),\n })\n return true\n }\n for (const templateId of templateIds) {\n if (cloneSubtree(templateId, parentId)) {\n copyIds.push(prefixId(templateId))\n }\n }\n })\n if (self) {\n const listed = [...(next[parentId].nodes as NodeId[])]\n listed.splice(slot, 1, ...copyIds)\n next[parentId] = { ...next[parentId], nodes: listed }\n } else {\n next[repeatId] = { ...next[repeatId], nodes: copyIds }\n }\n }\n return next\n}\n\nexport default expandRepeatables\n"],"names":["applyRepeatQuery","parseRepeatFilter","parseRepeatSort","REPEAT_NODE_ID_PREFIX","ITEM_TOKEN_PATTERN","REPEAT_MAX_RECORDS","displayValue","value","Array","isArray","join","String","resolveReferenceHop","context","fieldId","targetFieldId","targetKey","model","references","target","datasetsByKey","keys","record","ids","resolved","map","id","match","records","find","row","filter","length","substituteValue","unresolved","token","missing","replace","field","hop","entry","next","key","Object","entries","WHOLE_BINDING","RegExp","source","isEmptyBinding","template","substituted","text","test","printed","trim","repeatKey","node","props","repeatDataset","REPEAT_SELF_PROP","REPEAT_DIRECTIVE_PROPS","repeatScope","nodes","self","substituteRecordTokens","substituteNodesRecordTokens","scope","substituteRepeatFilterRecordTokens","includes","repeatFilter","withoutRepeatDirective","some","kept","repeatedRecords","dataset","where","orderBy","limit","Number","slice","isFinite","Math","min","hasRepeatableNodes","values","repeatKeys","Set","add","sort","expandRepeatables","rowsByKey","repeatIds","repeatId","repeated","parentId","siblings","slot","indexOf","templateIds","listed","splice","copyIds","forEach","index","prefix","prefixId","cloneSubtree","clonedParentId","clonedChildren","undefined","drawn","childId","$id","templateId","push"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/expand-repeatables.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { AglynNodeSchema, NodeId } from '../foundation'\nimport {\n applyRepeatQuery,\n parseRepeatFilter,\n parseRepeatSort,\n} from './repeat-query'\n\n/** Namespaces cloned template ids per container/record. */\nexport const REPEAT_NODE_ID_PREFIX = 'rep__'\n\n/**\n * `{{item.field}}` token, plus an optional single reference hop\n * (`{{item.author.name}}`, AGL-180). One hop only — the pattern itself is\n * the depth guard, so cycles can't recurse.\n */\nconst ITEM_TOKEN_PATTERN =\n /\\{\\{\\s*item\\.([A-Za-z][A-Za-z0-9_]*)(?:\\.([A-Za-z][A-Za-z0-9_]*))?\\s*\\}\\}/g\n\n/** Hard bound on rows a single repeatable renders, before `repeatLimit`. */\nexport const REPEAT_MAX_RECORDS = 100\n\n/**\n * What the expansion reads of the rows' field model (AGL-177, AGL-180): which\n * fields are references, each with the key its target's rows are answered\n * under (`datasetsByKey`). The model itself is the plugin's that keeps the\n * rows, and it states this much of it when it hands the rows over\n * (`plugin-manager/repeat-rows`, `repeat-sources`); a field it does not name\n * here hops nowhere, and its token is left as written.\n */\nexport interface RepeatRowsModel {\n /** Reference field id → the key of the rows it points into. */\n references?: Readonly<Record<string, string>>\n}\n\nexport interface RepeatableDataset {\n /**\n * Row value maps, in display order. Rows carry `$id` (needed to resolve\n * incoming references); values may be typed (AGL-177), stringified at\n * substitution.\n */\n records: Array<Record<string, unknown>>\n /** The rows' references; required for reference-hop bindings. */\n model?: RepeatRowsModel\n}\n\ninterface SubstituteContext {\n record: Record<string, unknown>\n /** The repeated rows' references (AGL-180). */\n model?: RepeatRowsModel\n /** All host datasets keyed by id (and name) for hop resolution. */\n datasetsByKey?: Record<string, RepeatableDataset | undefined>\n /**\n * What a token whose field the record leaves empty becomes: `'token'` keeps\n * it as written (the besigner's canvas, where an author edits the template),\n * `'empty'` prints nothing (a rendered page, AGL-3616). A published page\n * never shows a visitor `{{item.venue}}` because one record has no venue.\n */\n missing?: 'token' | 'empty'\n}\n\nconst displayValue = (value: unknown): string =>\n Array.isArray(value) ? value.join(', ') : String(value)\n\n/** Resolves `{{item.ref.field}}`: FKey(s) → target document field value. */\nfunction resolveReferenceHop(\n context: SubstituteContext,\n fieldId: string,\n targetFieldId: string,\n): string | null {\n const targetKey = context.model?.references?.[fieldId]\n if (!targetKey) return null\n const target = context.datasetsByKey?.[targetKey]\n if (!target) return null\n const keys = context.record[fieldId]\n const ids = Array.isArray(keys) ? keys : keys != null ? [keys] : []\n const resolved = ids\n .map((id) => {\n const match = target.records.find((row) => row['$id'] === id)\n const value = match?.[targetFieldId]\n return value != null ? displayValue(value) : null\n })\n .filter((value): value is string => value != null)\n return resolved.length ? resolved.join(', ') : null\n}\n\nfunction substituteValue(\n value: unknown,\n context: SubstituteContext,\n): unknown {\n const { record } = context\n if (typeof value === 'string') {\n const unresolved = (token: string) =>\n context.missing === 'empty' ? '' : token\n return value.replace(\n ITEM_TOKEN_PATTERN,\n (token, field: string, hop?: string) => {\n if (hop) {\n const resolved = resolveReferenceHop(context, field, hop)\n return resolved != null ? resolved : unresolved(token)\n }\n return record[field] != null\n ? displayValue(record[field])\n : unresolved(token)\n },\n )\n }\n if (Array.isArray(value)) {\n return value.map((entry) => substituteValue(entry, context))\n }\n if (value && typeof value === 'object') {\n const next: Record<string, unknown> = {}\n for (const [key, entry] of Object.entries(value)) {\n next[key] = substituteValue(entry, context)\n }\n return next\n }\n return value\n}\n\n/** A text that is nothing but `{{item.*}}` tokens: a binding, not words. */\nconst WHOLE_BINDING = new RegExp(\n `^\\\\s*(?:${ITEM_TOKEN_PATTERN.source}\\\\s*)+$`,\n)\n\n/**\n * Whether a copy's element is a binding its record left empty (AGL-3616):\n * its text, `children`, or its picture, `src`, was only `{{item.*}}` tokens\n * and they printed nothing. The copy leaves the element out rather than\n * drawing an empty line (an eyebrow with no venue) or an empty frame (a card\n * whose record has no photo, AGL-3912), and a record that fills the field\n * draws it.\n */\nfunction isEmptyBinding(\n template: Record<string, unknown> | undefined,\n substituted: Record<string, unknown>,\n): boolean {\n return (['children', 'src'] as const).some((prop) => {\n const text = template?.[prop]\n if (typeof text !== 'string' || !WHOLE_BINDING.test(text)) return false\n const printed = substituted[prop]\n return typeof printed === 'string' && printed.trim() === ''\n })\n}\n\n/**\n * The key a node repeats over, trimmed, or `''` when it repeats over nothing.\n *\n * The one predicate every repeat question is asked through: the expansion, the\n * datasets-read gate below, the reusable-component graft that moves a\n * placement's repeat onto the element it becomes, and the besigner's preview\n * and badge. Two copies of it are how a `.trim()` on one side and not the\n * other becomes an empty list on a customer's page.\n */\nexport const repeatKey = (node: unknown): string => {\n const key = (node as { props?: { repeatDataset?: unknown } })?.props\n ?.repeatDataset\n return typeof key === 'string' ? key.trim() : ''\n}\n\n/**\n * Makes an element that holds children repeat ITSELF, once per record, instead\n * of what is inside it (AGL-3111): a card whose contents are the design, rather\n * than a list whose contents are the item.\n *\n * Persisted in screen, layout and component documents — never rename. `'true'`\n * counts too, because a switch round-tripped through a text-shaped store comes\n * back as the string.\n */\nexport const REPEAT_SELF_PROP = 'repeatSelf'\n\n/**\n * Every prop that directs a repeat. They are instructions to the composition,\n * never attributes of the element: the expansion consumes them from every node\n * it expands and every copy it makes, so a published element never carries\n * one. Persisted — never rename.\n */\nexport const REPEAT_DIRECTIVE_PROPS: readonly string[] = [\n 'repeatDataset',\n 'repeatLimit',\n 'repeatFilter',\n 'repeatSort',\n REPEAT_SELF_PROP,\n]\n\n/**\n * What a repeating node copies once per record (AGL-3111).\n *\n * - `children` — what is inside it. The node stays one element around the\n * copies: the list, grid or gallery they fill. This is how a Stack has\n * always repeated (AGL-103), and a stored Stack names neither scope.\n * - `self` — the node itself, with everything inside it. What a leaf and a\n * component instance repeat, and what any element carrying\n * {@link REPEAT_SELF_PROP} repeats.\n */\nexport type RepeatScope = 'self' | 'children'\n\n/**\n * Which scope a repeating node has, read off the node alone so the published\n * page's composition and the besigner decide it identically.\n *\n * {@link REPEAT_SELF_PROP} decides it whenever the node carries one, which is\n * every repeat the Attributes panel writes. Without one the node's own shape\n * decides, and it decides CONSERVATIVELY: anything that holds a child list —\n * a Stack, empty or not — keeps repeating its children, and only a node with\n * no child list at all repeats itself. A stored repeat predates the prop, so\n * this branch is the one that has to render it unchanged, and an author who\n * named a dataset before filling the container in would otherwise come back to\n * a hundred empty copies of it.\n *\n * A component instance never reaches that branch: the graft stamps the prop on\n * the element it becomes, whose children are the component's own.\n */\nexport function repeatScope(node: unknown): RepeatScope {\n const { props, nodes } = (node ?? {}) as {\n props?: Record<string, unknown>\n nodes?: unknown\n }\n // `'true'`/`'false'` too: a switch round-tripped through a text-shaped\n // store comes back as the string it was written as.\n const self = props?.[REPEAT_SELF_PROP]\n if (self === true || self === 'true') return 'self'\n if (self === false || self === 'false') return 'children'\n return Array.isArray(nodes) ? 'children' : 'self'\n}\n\n/**\n * One record's values put into `{{item.field}}` tokens, anywhere in a props\n * map — the substitution {@link expandRepeatables} makes on every copy it\n * builds, for a caller that already knows which record it is rendering.\n *\n * The besigner's canvas is that caller (AGL-3111): the element an author\n * edits IS the first copy, so it draws with the first record's values while\n * the node it saves keeps the tokens. Exported rather than reimplemented\n * because a canvas that resolved a token differently from the page would be\n * showing the author a page that does not exist.\n */\nexport function substituteRecordTokens<T>(\n props: T,\n context: {\n record: Record<string, unknown>\n /** The repeated rows' references (AGL-180). */\n model?: RepeatRowsModel\n /** Rows by key, for a one-hop reference (AGL-180). */\n datasetsByKey?: Record<string, RepeatableDataset | undefined>\n },\n): T {\n return substituteValue(props, context) as T\n}\n\n/**\n * The one record a whole page is rendered for (AGL-3475): a record template's\n * routed record, in the shape a repeat hands each of its copies.\n */\nexport interface PageRecordScope {\n record: Record<string, unknown>\n /** The record's references (AGL-180). */\n model?: RepeatRowsModel\n /** Rows by key, for a one-hop reference (AGL-180). */\n datasetsByKey?: Record<string, RepeatableDataset | undefined>\n}\n\n/**\n * Every node's props with the page's record put into its `{{item.field}}`\n * tokens — the substitution a repeat makes on each copy, made once over a\n * page that renders one record (AGL-3475).\n *\n * Run AFTER {@link expandRepeatables}: a repeat inside the page has already\n * spent its own `{{item.*}}` tokens on its own rows by then, so what is left\n * is the page's. A token naming a field the record leaves empty prints\n * nothing, exactly as in a repeat (AGL-3616). Returns the input when there is\n * no record.\n */\nexport function substituteNodesRecordTokens<N>(\n nodes: Record<NodeId, N>,\n scope: PageRecordScope | null | undefined,\n): Record<NodeId, N> {\n if (!scope) return nodes\n const next: Record<NodeId, N> = {}\n for (const [id, node] of Object.entries(nodes) as Array<[NodeId, N]>) {\n const props = (node as { props?: unknown })?.props\n next[id] = props\n ? ({\n ...node,\n props: substituteValue(props, { ...scope, missing: 'empty' }),\n } as N)\n : node\n }\n return next\n}\n\n/**\n * The page's record put into each repeat's FILTER, before the repeats expand\n * (AGL-3475): on a record template, `category == {{item.category}}` lists the\n * other records in the routed record's category. Only `repeatFilter` is\n * touched — everything else inside a repeat is its template, whose\n * `{{item.*}}` belongs to its own rows. Returns the input when there is no\n * record or no filter names one.\n */\nexport function substituteRepeatFilterRecordTokens<N>(\n nodes: Record<NodeId, N>,\n scope: PageRecordScope | null | undefined,\n): Record<NodeId, N> {\n if (!scope) return nodes\n let next: Record<NodeId, N> | null = null\n for (const [id, node] of Object.entries(nodes) as Array<[NodeId, N]>) {\n const props = (node as { props?: Record<string, unknown> })?.props\n const filter = props?.['repeatFilter']\n if (typeof filter !== 'string' || !filter.includes('{{')) continue\n next ??= { ...nodes }\n next[id] = {\n ...node,\n props: { ...props, repeatFilter: substituteValue(filter, scope) },\n } as N\n }\n return next ?? nodes\n}\n\n/** The node minus its repeat directives, or the node itself when it has none. */\nexport function withoutRepeatDirective<N>(node: N): N {\n const props = (node as { props?: Record<string, unknown> })?.props\n if (!props || !REPEAT_DIRECTIVE_PROPS.some((key) => key in props)) {\n return node\n }\n const kept = { ...props }\n for (const key of REPEAT_DIRECTIVE_PROPS) delete kept[key]\n return { ...node, props: kept }\n}\n\n/**\n * The records a repeating node renders, in order (AGL-181): its filter and sort\n * evaluated over the rows, then its limit, never past\n * {@link REPEAT_MAX_RECORDS}. An unparseable filter or sort fails open.\n *\n * Shared by the expansion and the besigner's canvas preview and badge, so the\n * canvas can never show a copy or a count the published page does not.\n */\nexport function repeatedRecords(\n node: unknown,\n dataset: RepeatableDataset | undefined,\n): Array<Record<string, unknown>> {\n const props = ((node as { props?: unknown })?.props ?? {}) as Record<\n string,\n unknown\n >\n const where = parseRepeatFilter(String(props['repeatFilter'] ?? ''))\n const orderBy = parseRepeatSort(String(props['repeatSort'] ?? ''))\n const limit = Number(props['repeatLimit'])\n return applyRepeatQuery(dataset?.records ?? [], {\n ...(where ? { where: [where] } : {}),\n ...(orderBy ? { orderBy } : {}),\n }).slice(\n 0,\n Number.isFinite(limit) && limit > 0\n ? Math.min(limit, REPEAT_MAX_RECORDS)\n : REPEAT_MAX_RECORDS,\n )\n}\n\n/**\n * Does this tree contain anything {@link expandRepeatables} would expand —\n * or, with no rows, empty?\n *\n * The rows read is the largest on the render path — up to two pages of\n * {@link REPEAT_MAX_RECORDS} records for every key a page repeats over — and a\n * page that repeats over nothing should pay none of it (AGL-1440).\n *\n * Any gate on that read has to ask EXACTLY the question the expansion asks,\n * which is why this and {@link repeatKeys} share the one predicate\n * `expandRepeatables` looks keys up with, {@link repeatKey}. A gate that is\n * even slightly stricter than the expansion is not a saving: it is a published\n * page whose repeat never reaches the expansion at all, and so goes out as\n * its raw template — `{{item.*}}` tokens and all — where the author put a list.\n */\nexport function hasRepeatableNodes(\n nodes: Record<NodeId, unknown> | null | undefined,\n): boolean {\n if (!nodes) return false\n return Object.values(nodes).some((node) => repeatKey(node) !== '')\n}\n\n/**\n * Every key this tree repeats over, exactly as {@link expandRepeatables} looks\n * it up — sorted and without repeats.\n *\n * What the tenant compose asks the repeat-rows contract for\n * (`plugin-manager/repeat-rows.ts`), so a page loads the rows it renders and\n * not everything its site may see. Built on the same predicate as\n * {@link hasRepeatableNodes} for the reason given there.\n */\nexport function repeatKeys(\n nodes: Record<NodeId, unknown> | null | undefined,\n): string[] {\n const keys = new Set<string>()\n for (const node of Object.values(nodes ?? {})) {\n const key = repeatKey(node)\n if (key) keys.add(key)\n }\n return [...keys].sort()\n}\n\n/**\n * Repeats (AGL-103, AGL-3111): any node carrying `props.repeatDataset` (a\n * dataset id or display name) renders once per record of that dataset, with\n * `{{item.field}}` tokens in the copied string props replaced by the record's\n * values. A field the record leaves empty prints nothing — never the raw\n * token — and an element whose whole text is such a binding is left out of\n * that copy (AGL-3616: a tour date with no venue drew `{{ITEM.VENUE}}` as its\n * eyebrow on a published site). The besigner's canvas keeps the tokens of\n * the template an author edits ({@link substituteRecordTokens}).\n *\n * What is copied is the node's {@link repeatScope}:\n *\n * - `children` — its children are the item template. The node stays, and its\n * child list becomes the copies, record by record. Its own props are not the\n * template, so no token in them is substituted.\n * - `self` — the node and everything inside it are the template. The copies\n * take the node's place in its parent's child list, and every prop in the\n * subtree is substituted, the node's own included.\n *\n * - Copy ids are namespaced `rep__{repeatId}__{index}__…` — the repeat's own\n * id, then the template node's — so repeats never collide, including inside\n * grafted reusable components. A self-scoped node's first copy is\n * `rep__{id}__0__{id}`. Run this AFTER `composeReusableComponentNodes` and\n * BEFORE binding resolution.\n * - Rows are the node's {@link repeatedRecords}.\n * - The repeat directives ({@link REPEAT_DIRECTIVE_PROPS}) are consumed: every\n * node this expands, and every copy it makes, comes out without them.\n * - Repeats do not nest. A repeat inside another repeat's template is copied\n * with its directives consumed, so it renders once in each copy.\n * - No rows renders ZERO copies (AGL-3496): a `children` repeat keeps its\n * element with an empty child list, a `self` repeat leaves its parent's\n * list. That covers a filter matching nothing, an empty dataset, and a key\n * the rows answer nothing under — a deleted or unshared dataset, or no rows\n * map at all — because the template drawn without a record is its literal\n * `{{item.*}}` tokens, printed to every visitor. Never a throw: a dataset\n * going away empties a list, it does not take the page down.\n * - An empty template, or a self-scoped node its parent does not list (the\n * document root, say), has nothing to copy or nowhere to put copies, and is\n * left as written.\n * - Inputs are never mutated; templates stay in the map unreferenced.\n *\n * This is the PUBLISHED page's composition. The besigner's canvas keeps the\n * template on screen whatever the rows say — it is what an author edits — and\n * flags a repeat with no rows on its badge instead ({@link repeatedRecords}\n * is the count both read).\n */\nexport function expandRepeatables<N extends AglynNodeSchema = AglynNodeSchema>(\n nodes: Record<NodeId, N>,\n datasetsByKey: Record<string, RepeatableDataset | undefined> | undefined,\n): Record<NodeId, N> {\n const rowsByKey = datasetsByKey ?? {}\n const repeatIds = Object.entries(nodes).filter(\n ([, node]) => repeatKey(node) !== '',\n )\n if (!repeatIds.length) return nodes\n\n const next: Record<NodeId, N> = { ...nodes }\n for (const [repeatId, repeated] of repeatIds) {\n const dataset = rowsByKey[repeatKey(repeated)]\n const records = repeatedRecords(repeated, dataset)\n const self = repeatScope(repeated) === 'self'\n // The copies' parent: the node itself, or — for a self-scoped node — the\n // parent whose child list the copies take its place in, read from `next`\n // so two self-scoped siblings both land in the list the other has left.\n const parentId = (self ? repeated.parentId : repeatId) as NodeId\n const siblings = next[parentId]?.nodes\n const slot = Array.isArray(siblings)\n ? (siblings as NodeId[]).indexOf(repeatId)\n : -1\n const templateIds = self\n ? [repeatId]\n : Array.isArray(repeated.nodes)\n ? (repeated.nodes as NodeId[])\n : []\n next[repeatId] = withoutRepeatDirective(next[repeatId])\n if (!templateIds.length || (self && slot < 0)) continue\n if (!records.length) {\n // Zero copies (AGL-3496). The template is a design, not content: drawn\n // without a record it prints `{{item.name}}` to every visitor.\n if (self) {\n const listed = [...(next[parentId].nodes as NodeId[])]\n listed.splice(slot, 1)\n next[parentId] = { ...next[parentId], nodes: listed }\n } else {\n next[repeatId] = { ...next[repeatId], nodes: [] }\n }\n continue\n }\n\n const copyIds: NodeId[] = []\n records.forEach((record, index) => {\n const prefix = `${REPEAT_NODE_ID_PREFIX}${repeatId}__${index}__`\n const prefixId = (id: NodeId) => `${prefix}${id}`\n // Whether the copy drew the node: an element whose whole text is a\n // binding this record leaves empty is left out of it (AGL-3616).\n const cloneSubtree = (id: NodeId, clonedParentId: NodeId): boolean => {\n const node = nodes[id]\n if (!node) return false\n const props = substituteValue(node.props ?? {}, {\n record,\n model: dataset?.model,\n datasetsByKey: rowsByKey,\n missing: 'empty',\n }) as Record<string, unknown>\n if (isEmptyBinding(node.props as Record<string, unknown>, props)) {\n return false\n }\n const clonedChildren = Array.isArray(node.nodes)\n ? (node.nodes as NodeId[])\n : undefined\n const drawn = clonedChildren?.filter((childId) =>\n cloneSubtree(childId, prefixId(id)),\n )\n next[prefixId(id)] = withoutRepeatDirective({\n ...node,\n $id: prefixId(id),\n parentId: clonedParentId,\n props: props as any,\n ...(drawn && {\n nodes: drawn.map((childId) => prefixId(childId)),\n }),\n })\n return true\n }\n for (const templateId of templateIds) {\n if (cloneSubtree(templateId, parentId)) {\n copyIds.push(prefixId(templateId))\n }\n }\n })\n if (self) {\n const listed = [...(next[parentId].nodes as NodeId[])]\n listed.splice(slot, 1, ...copyIds)\n next[parentId] = { ...next[parentId], nodes: listed }\n } else {\n next[repeatId] = { ...next[repeatId], nodes: copyIds }\n }\n }\n return next\n}\n\nexport default expandRepeatables\n"],"names":["applyRepeatQuery","parseRepeatFilter","parseRepeatSort","REPEAT_NODE_ID_PREFIX","ITEM_TOKEN_PATTERN","REPEAT_MAX_RECORDS","displayValue","value","Array","isArray","join","String","resolveReferenceHop","context","fieldId","targetFieldId","targetKey","model","references","target","datasetsByKey","keys","record","ids","resolved","map","id","match","records","find","row","filter","length","substituteValue","unresolved","token","missing","replace","field","hop","entry","next","key","Object","entries","WHOLE_BINDING","RegExp","source","isEmptyBinding","template","substituted","some","prop","text","test","printed","trim","repeatKey","node","props","repeatDataset","REPEAT_SELF_PROP","REPEAT_DIRECTIVE_PROPS","repeatScope","nodes","self","substituteRecordTokens","substituteNodesRecordTokens","scope","substituteRepeatFilterRecordTokens","includes","repeatFilter","withoutRepeatDirective","kept","repeatedRecords","dataset","where","orderBy","limit","Number","slice","isFinite","Math","min","hasRepeatableNodes","values","repeatKeys","Set","add","sort","expandRepeatables","rowsByKey","repeatIds","repeatId","repeated","parentId","siblings","slot","indexOf","templateIds","listed","splice","copyIds","forEach","index","prefix","prefixId","cloneSubtree","clonedParentId","clonedChildren","undefined","drawn","childId","$id","templateId","push"],"mappings":"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"}
@@ -505,22 +505,84 @@ export declare function signupDroughtHealth(markers: SignupServedMarker[] | null
505
505
  * month, 10/11 04:00Z–12:00Z, which is AGL-3724, and it would have paged
506
506
  * at about 04:20Z, two hours before the third person was lost.
507
507
  *
508
- * One stranded org is never enough on its own. People do sign up and leave,
509
- * and at a handful of sign-ups a day a single one carries no signal.
508
+ * ## It reads a streak of PEOPLE, not a count of orgs (2026-10-17)
509
+ *
510
+ * The first rule was "two orgs with no site in twelve hours". On 2026-10-17
511
+ * it was red for 85 minutes and then for 350, while sign-ups worked, which
512
+ * UptimeRobot reported as Signups down for six hours. Two things were wrong
513
+ * with it.
514
+ *
515
+ * - It counted orgs. One phone visitor made a workspace with a password and
516
+ * then two more with two Google accounts, all under one name, in sixteen
517
+ * minutes. Two got sites, and the third read as a stranded sign-up. So
518
+ * orgs are grouped by `visitor` (the browser's first-touch stamp, or the
519
+ * owner's uid when there is none), and a person with a site in any of
520
+ * their orgs has landed.
521
+ * - It counted. At nine sign-ups a day, two unrelated people leaving inside
522
+ * twelve hours is an ordinary Saturday, and no threshold on a count tells
523
+ * that apart from a broken landing. A broken landing strands EVERY new
524
+ * sign-up, so the newest people are all stranded. When people only leave,
525
+ * the next person who makes a site clears it. So the verdict is red when
526
+ * the newest {@link MAX_STRANDED_SIGNUPS} people in the window, in order of
527
+ * their latest org, all have no site.
528
+ *
529
+ * Backtest, 2026-09-07 → 2026-10-17, 105 orgs, five-minute steps: the count
530
+ * of orgs was red for 1,125 minutes, and 435 of them were false (both of
531
+ * 10/17's). The streak of two was red only on 10/11, AGL-3724, and nowhere
532
+ * else. A streak of three missed AGL-3724 entirely.
533
+ *
534
+ * ## A site is not the question; reaching the console is (2026-10-18)
535
+ *
536
+ * The streak rule went red on 2026-10-18 at 15:37Z for the same reason the
537
+ * count did: the two newest people had no site, and both had LEFT. One left
538
+ * nine seconds after the workspace appeared. The other reached the site list,
539
+ * walked four steps of the guided start, and stopped. Sign-ups on either side
540
+ * of them made sites on the same build. "No site" is an outcome that leaving
541
+ * produces as surely as a broken landing does, so no rule over sites alone
542
+ * can tell the two apart.
543
+ *
544
+ * What AGL-3724 broke was the landing itself: the person never REACHED the
545
+ * console after the workspace was made. That is recorded now. Every org is
546
+ * born with `landedAtMs: null` ({@link ORG_LANDED_AT_PATH}), and the org
547
+ * shell's activity beat, which fires the moment the workspace opens, sets it
548
+ * the first time (`stampOrgLastActivity`). So an org counts as stranded only
549
+ * when it has no site AND its owner never reached the console. A person who
550
+ * arrived and then left is a drop-off, which is not this check's business.
551
+ *
552
+ * An org without the field predates the marker, and the check has no
553
+ * evidence about it either way, so it ABSTAINS on it (`unmarkedOrgs`). It is
554
+ * never counted as stranded, the way a check that cannot see its subject
555
+ * says so instead of guessing.
510
556
  *
511
557
  * Pure, like its siblings: the route reads, this decides. The body carries
512
- * counts only, never an org id or slug, because the endpoint is public.
558
+ * counts only, never an org id, slug or visitor key, because the endpoint is
559
+ * public.
513
560
  */
514
561
  export interface SignupLandingCheck extends HealthCheck {
515
- /** Orgs in the window that have no site. Null when the read failed. */
562
+ /**
563
+ * Orgs in the window with no site whose owner never reached the console.
564
+ * Null when the read failed.
565
+ */
516
566
  strandedOrgs: number | null;
517
- /** Every org in the window, the context for the count above. */
567
+ /** People in the window none of whose orgs has a site or a landing. */
568
+ strandedPeople: number | null;
569
+ /**
570
+ * How many of the newest people in the window, in a row, are stranded:
571
+ * what the verdict reads. Null when the read failed.
572
+ */
573
+ strandedStreak: number | null;
574
+ /**
575
+ * Orgs with no site that predate the landing marker, which the check
576
+ * abstains on. Null when the read failed.
577
+ */
578
+ unmarkedOrgs: number | null;
579
+ /** Every org in the window, the context for the counts above. */
518
580
  orgsInWindow: number | null;
519
581
  /** How far back the window reaches. */
520
582
  windowHours: number;
521
583
  /** How old an org must be before having no site counts against it. */
522
584
  graceMinutes: number;
523
- /** Stranded orgs at which this goes red. */
585
+ /** The stranded streak at which this goes red. */
524
586
  maxStranded: number;
525
587
  }
526
588
  /** See {@link SignupLandingCheck}: twelve hours of orgs. */
@@ -532,11 +594,29 @@ export declare const SIGNUP_LANDING_WINDOW_HOURS = 12;
532
594
  * typing a site name.
533
595
  */
534
596
  export declare const SIGNUP_LANDING_GRACE_MINUTES = 30;
535
- /** Red at two stranded orgs in the window. See {@link SignupLandingCheck}. */
597
+ /**
598
+ * Red when the two newest people in the window are both stranded. See
599
+ * {@link SignupLandingCheck}.
600
+ */
536
601
  export declare const MAX_STRANDED_SIGNUPS = 2;
537
- /** One org as the landing check reads it: only whether it has any site. */
602
+ /**
603
+ * Where an org records that its owner reached the console (see
604
+ * {@link SignupLandingCheck}). `null` from birth, epoch millis once the
605
+ * workspace has been opened, absent on an org made before it existed.
606
+ */
607
+ export declare const ORG_LANDED_AT_PATH = "landedAtMs";
608
+ /**
609
+ * One org as the landing check reads it: whether it has any site, whether
610
+ * its owner reached the console, when it was made, and who made it.
611
+ * `landed` is `undefined` for an org that predates the marker. `visitor` is
612
+ * an opaque key that never leaves the check; an org without one is its own
613
+ * person.
614
+ */
538
615
  export interface SignupLandingOrg {
539
616
  hostCount: number;
617
+ landed?: boolean;
618
+ createdAtMs?: number | null;
619
+ visitor?: string | null;
540
620
  }
541
621
  export declare function signupLandingHealth(orgs: SignupLandingOrg[] | null, ms: number, maxStranded?: number, windowHours?: number, graceMinutes?: number): SignupLandingCheck;
542
622
  /**
@@ -1457,6 +1537,10 @@ export interface CronBeatStore {
1457
1537
  collection(name: string): {
1458
1538
  doc(id: string): {
1459
1539
  set(data: Record<string, unknown>, options?: unknown): Promise<unknown>;
1540
+ /** Present on a real Firestore handle; {@link writeCronBeats} reads the summary through it. */
1541
+ get?(): Promise<{
1542
+ get(field: string): unknown;
1543
+ }>;
1460
1544
  };
1461
1545
  };
1462
1546
  }
@@ -1474,6 +1558,34 @@ export declare const CRON_BEAT_EVERY_MINUTE_MIN_INTERVAL_MS: number;
1474
1558
  /** Test seam. */
1475
1559
  export declare function resetCronBeatThrottleForTests(): void;
1476
1560
  export declare function writeCronBeat(store: CronBeatStore, jobId: string, now?: number): Promise<boolean>;
1561
+ /**
1562
+ * How long a job's own document may lag its summary entry when the jobs are
1563
+ * marked together (AGL-3932).
1564
+ *
1565
+ * The reader judges from the summary and opens a job's own document only to
1566
+ * confirm an entry the summary calls late, so a job marked every fifteen
1567
+ * minutes does not need its own document rewritten every fifteen minutes:
1568
+ * half an hour, under every plugin job's forty-five-minute grace, keeps the
1569
+ * confirming mark fresh enough to agree with a summary that has stopped.
1570
+ */
1571
+ export declare const CRON_BEAT_OWN_MARK_REFRESH_MS: number;
1572
+ /**
1573
+ * The marks one run leaves for several jobs at once — the plugin-crons route
1574
+ * stamps twenty jobs a run, and {@link writeCronBeat} per job was forty
1575
+ * writes every fifteen minutes, half of everything the platform wrote
1576
+ * (AGL-3932).
1577
+ *
1578
+ * One summary write carries every job's entry (merged, like the single mark's).
1579
+ * A job's own document is rewritten only when the summary's current entry
1580
+ * for it is missing or older than {@link CRON_BEAT_OWN_MARK_REFRESH_MS},
1581
+ * which one read of the summary tells; a store without `get`, or a summary
1582
+ * that cannot be read or written, falls back to the single-mark shape for
1583
+ * every job, so no run can leave fewer marks than before.
1584
+ *
1585
+ * NEVER THROWS, as {@link writeCronBeat} never throws, and for the same
1586
+ * reason. Returns false only when nothing landed for some job.
1587
+ */
1588
+ export declare function writeCronBeats(store: CronBeatStore, jobIds: readonly string[], now?: number): Promise<boolean>;
1477
1589
  /**
1478
1590
  * The beats a summary document holds, ignoring anything malformed.
1479
1591
  *
@@ -544,7 +544,15 @@ export function signupDroughtHealth(markers, attemptMarkers, orgCreations, ms, m
544
544
  * median under a minute, slowest 8.2), so thirty never counts a person still
545
545
  * typing a site name.
546
546
  */ export const SIGNUP_LANDING_GRACE_MINUTES = 30;
547
- /** Red at two stranded orgs in the window. See {@link SignupLandingCheck}. */ export const MAX_STRANDED_SIGNUPS = 2;
547
+ /**
548
+ * Red when the two newest people in the window are both stranded. See
549
+ * {@link SignupLandingCheck}.
550
+ */ export const MAX_STRANDED_SIGNUPS = 2;
551
+ /**
552
+ * Where an org records that its owner reached the console (see
553
+ * {@link SignupLandingCheck}). `null` from birth, epoch millis once the
554
+ * workspace has been opened, absent on an org made before it existed.
555
+ */ export const ORG_LANDED_AT_PATH = 'landedAtMs';
548
556
  export function signupLandingHealth(orgs, ms, maxStranded = MAX_STRANDED_SIGNUPS, windowHours = SIGNUP_LANDING_WINDOW_HOURS, graceMinutes = SIGNUP_LANDING_GRACE_MINUTES) {
549
557
  const shape = {
550
558
  windowHours,
@@ -559,13 +567,46 @@ export function signupLandingHealth(orgs, ms, maxStranded = MAX_STRANDED_SIGNUPS
559
567
  ms,
560
568
  code: 'orgs-unavailable',
561
569
  strandedOrgs: null,
570
+ strandedPeople: null,
571
+ strandedStreak: null,
572
+ unmarkedOrgs: null,
562
573
  orgsInWindow: null
563
574
  }, shape);
564
575
  }
565
576
  // `!(n > 0)` rather than `n === 0`, so a corrupt count (NaN, a string)
566
577
  // reads as "no site" and cannot hide a stranded org.
567
- const strandedOrgs = orgs.filter((landing)=>!(Number(landing.hostCount) > 0)).length;
568
- const stranded = strandedOrgs >= maxStranded;
578
+ const hasSite = (landing)=>Number(landing.hostCount) > 0;
579
+ // Each org is one of: arrived (a site, or its owner reached the console),
580
+ // stranded (neither, and the marker says so), or unknown (neither, and it
581
+ // predates the marker).
582
+ const arrived = (landing)=>hasSite(landing) || landing.landed === true;
583
+ const isStranded = (landing)=>!arrived(landing) && landing.landed === false;
584
+ const strandedOrgs = orgs.filter(isStranded).length;
585
+ const unmarkedOrgs = orgs.filter((landing)=>!arrived(landing) && landing.landed === undefined).length;
586
+ const people = new Map();
587
+ orgs.forEach((landing, index)=>{
588
+ var _ref, _ref1, _ref2;
589
+ const key = landing.visitor ? `visitor:${landing.visitor}` : `org:${index}`;
590
+ const createdAtMs = Number(landing.createdAtMs);
591
+ const latestMs = Number.isFinite(createdAtMs) ? createdAtMs : 0;
592
+ const person = people.get(key);
593
+ people.set(key, {
594
+ latestMs: Math.max((_ref = person == null ? void 0 : person.latestMs) != null ? _ref : 0, latestMs),
595
+ arrived: ((_ref1 = person == null ? void 0 : person.arrived) != null ? _ref1 : false) || arrived(landing),
596
+ stranded: ((_ref2 = person == null ? void 0 : person.stranded) != null ? _ref2 : false) || isStranded(landing)
597
+ });
598
+ });
599
+ // A person with nothing but unmarked orgs is not graded at all: neither
600
+ // arrived nor stranded, so they neither start nor break a streak.
601
+ const newestFirst = [
602
+ ...people.values()
603
+ ].filter((person)=>person.arrived || person.stranded).sort((a, b)=>b.latestMs - a.latestMs);
604
+ const strandedPeople = newestFirst.filter((person)=>!person.arrived).length;
605
+ let strandedStreak = 0;
606
+ while(strandedStreak < newestFirst.length && !newestFirst[strandedStreak].arrived){
607
+ strandedStreak += 1;
608
+ }
609
+ const stranded = strandedStreak >= maxStranded;
569
610
  return _extends({
570
611
  ok: !stranded,
571
612
  ms
@@ -573,6 +614,9 @@ export function signupLandingHealth(orgs, ms, maxStranded = MAX_STRANDED_SIGNUPS
573
614
  code: 'signups-stranded'
574
615
  } : {}, {
575
616
  strandedOrgs,
617
+ strandedPeople,
618
+ strandedStreak,
619
+ unmarkedOrgs,
576
620
  orgsInWindow: orgs.length
577
621
  }, shape);
578
622
  }
@@ -1919,6 +1963,97 @@ export async function writeCronBeat(store, jobId, now = Date.now()) {
1919
1963
  return false;
1920
1964
  }
1921
1965
  }
1966
+ /**
1967
+ * How long a job's own document may lag its summary entry when the jobs are
1968
+ * marked together (AGL-3932).
1969
+ *
1970
+ * The reader judges from the summary and opens a job's own document only to
1971
+ * confirm an entry the summary calls late, so a job marked every fifteen
1972
+ * minutes does not need its own document rewritten every fifteen minutes:
1973
+ * half an hour, under every plugin job's forty-five-minute grace, keeps the
1974
+ * confirming mark fresh enough to agree with a summary that has stopped.
1975
+ */ export const CRON_BEAT_OWN_MARK_REFRESH_MS = 30 * 60000;
1976
+ /**
1977
+ * The marks one run leaves for several jobs at once — the plugin-crons route
1978
+ * stamps twenty jobs a run, and {@link writeCronBeat} per job was forty
1979
+ * writes every fifteen minutes, half of everything the platform wrote
1980
+ * (AGL-3932).
1981
+ *
1982
+ * One summary write carries every job's entry (merged, like the single mark's).
1983
+ * A job's own document is rewritten only when the summary's current entry
1984
+ * for it is missing or older than {@link CRON_BEAT_OWN_MARK_REFRESH_MS},
1985
+ * which one read of the summary tells; a store without `get`, or a summary
1986
+ * that cannot be read or written, falls back to the single-mark shape for
1987
+ * every job, so no run can leave fewer marks than before.
1988
+ *
1989
+ * NEVER THROWS, as {@link writeCronBeat} never throws, and for the same
1990
+ * reason. Returns false only when nothing landed for some job.
1991
+ */ export async function writeCronBeats(store, jobIds, now = Date.now()) {
1992
+ const ids = [
1993
+ ...new Set(jobIds)
1994
+ ].filter((id)=>!CRON_BEAT_RESERVED_DOCS.has(id));
1995
+ if (!ids.length) return true;
1996
+ const beats = store.collection(CRON_BEAT_COLLECTION);
1997
+ const summary = beats.doc(CRON_BEAT_SUMMARY_DOC);
1998
+ let current = null;
1999
+ try {
2000
+ if (summary.get) {
2001
+ const snapshot = await summary.get();
2002
+ current = new Map(cronBeatsFromSummary(snapshot.get('beats')).map((beat)=>[
2003
+ beat.jobId,
2004
+ beat.atMs
2005
+ ]));
2006
+ }
2007
+ } catch (unused) {
2008
+ // Unreadable: every job gets its own mark below, as before.
2009
+ }
2010
+ const refresh = current === null ? ids : ids.filter((id)=>{
2011
+ const last = current.get(id);
2012
+ return last === undefined || now - last >= CRON_BEAT_OWN_MARK_REFRESH_MS || now < last;
2013
+ });
2014
+ let landed = true;
2015
+ for (const id of refresh){
2016
+ try {
2017
+ await beats.doc(id).set({
2018
+ jobId: id,
2019
+ atMs: now,
2020
+ at: new Date(now).toISOString()
2021
+ }, {
2022
+ merge: true
2023
+ });
2024
+ } catch (unused) {
2025
+ landed = false;
2026
+ }
2027
+ }
2028
+ try {
2029
+ await summary.set({
2030
+ beats: Object.fromEntries(ids.map((id)=>[
2031
+ id,
2032
+ now
2033
+ ]))
2034
+ }, {
2035
+ merge: true
2036
+ });
2037
+ } catch (unused) {
2038
+ // The summary is what the reader reads first; without it, give every job
2039
+ // the mark the reader confirms against, as the single mark would have.
2040
+ for (const id of ids){
2041
+ if (refresh.includes(id)) continue;
2042
+ try {
2043
+ await beats.doc(id).set({
2044
+ jobId: id,
2045
+ atMs: now,
2046
+ at: new Date(now).toISOString()
2047
+ }, {
2048
+ merge: true
2049
+ });
2050
+ } catch (unused) {
2051
+ landed = false;
2052
+ }
2053
+ }
2054
+ }
2055
+ return landed;
2056
+ }
1922
2057
  /**
1923
2058
  * The beats a summary document holds, ignoring anything malformed.
1924
2059
  *