@aglyn/aglyn 1.0.0-beta.263 → 1.0.0-beta.266
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/account-acquisition.d.ts +13 -0
- package/src/lib/app-utils/account-acquisition.js +84 -0
- package/src/lib/app-utils/account-acquisition.js.map +1 -1
- package/src/lib/app-utils/analytics-events.d.ts +70 -0
- package/src/lib/app-utils/analytics-events.js +8 -0
- package/src/lib/app-utils/analytics-events.js.map +1 -1
- package/src/lib/app-utils/docs-help-sections.generated.js +4 -1
- package/src/lib/app-utils/docs-help-sections.generated.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +2 -2
- package/src/lib/app-utils/docs-help.generated.js +8 -1
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +31 -10
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/expand-repeatables.js +14 -7
- package/src/lib/app-utils/expand-repeatables.js.map +1 -1
- package/src/lib/app-utils/health-report.d.ts +47 -7
- package/src/lib/app-utils/health-report.js +31 -3
- package/src/lib/app-utils/health-report.js.map +1 -1
- package/src/lib/app-utils/live-document-count.client.d.ts +28 -0
- package/src/lib/app-utils/live-document-count.client.js +35 -0
- package/src/lib/app-utils/live-document-count.client.js.map +1 -0
- package/src/lib/app-utils/live-document-count.d.ts +91 -0
- package/src/lib/app-utils/live-document-count.js +81 -0
- package/src/lib/app-utils/live-document-count.js.map +1 -0
- package/src/lib/app-utils/native-app-web-view.d.ts +63 -0
- package/src/lib/app-utils/native-app-web-view.js +83 -0
- package/src/lib/app-utils/native-app-web-view.js.map +1 -0
- package/src/lib/app-utils/notification-sounds.d.ts +49 -0
- package/src/lib/app-utils/notification-sounds.js +54 -0
- package/src/lib/app-utils/notification-sounds.js.map +1 -0
- package/src/lib/app-utils/notifications.d.ts +2 -0
- package/src/lib/app-utils/notifications.js +32 -0
- package/src/lib/app-utils/notifications.js.map +1 -1
- package/src/lib/app-utils/screen-analytics-aggregate.d.ts +10 -0
- package/src/lib/app-utils/screen-analytics-aggregate.js +11 -0
- package/src/lib/app-utils/screen-analytics-aggregate.js.map +1 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.js +6 -0
- package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
- package/src/lib/plugin-manager/plugin-activity-actions.d.ts +14 -0
- package/src/lib/plugin-manager/plugin-activity-actions.js +30 -3
- package/src/lib/plugin-manager/plugin-activity-actions.js.map +1 -1
|
@@ -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
|
|
71
|
-
* nothing. The copy leaves the element out rather than
|
|
72
|
-
* (an eyebrow with no venue)
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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,53 @@ 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
|
-
*
|
|
509
|
-
*
|
|
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.
|
|
510
533
|
*
|
|
511
534
|
* Pure, like its siblings: the route reads, this decides. The body carries
|
|
512
|
-
* counts only, never an org id or
|
|
535
|
+
* counts only, never an org id, slug or visitor key, because the endpoint is
|
|
536
|
+
* public.
|
|
513
537
|
*/
|
|
514
538
|
export interface SignupLandingCheck extends HealthCheck {
|
|
515
539
|
/** Orgs in the window that have no site. Null when the read failed. */
|
|
516
540
|
strandedOrgs: number | null;
|
|
517
|
-
/**
|
|
541
|
+
/** People in the window with no site in any of their orgs. */
|
|
542
|
+
strandedPeople: number | null;
|
|
543
|
+
/**
|
|
544
|
+
* How many of the newest people in the window, in a row, have no site:
|
|
545
|
+
* what the verdict reads. Null when the read failed.
|
|
546
|
+
*/
|
|
547
|
+
strandedStreak: number | null;
|
|
548
|
+
/** Every org in the window, the context for the counts above. */
|
|
518
549
|
orgsInWindow: number | null;
|
|
519
550
|
/** How far back the window reaches. */
|
|
520
551
|
windowHours: number;
|
|
521
552
|
/** How old an org must be before having no site counts against it. */
|
|
522
553
|
graceMinutes: number;
|
|
523
|
-
/**
|
|
554
|
+
/** The stranded streak at which this goes red. */
|
|
524
555
|
maxStranded: number;
|
|
525
556
|
}
|
|
526
557
|
/** See {@link SignupLandingCheck}: twelve hours of orgs. */
|
|
@@ -532,11 +563,20 @@ export declare const SIGNUP_LANDING_WINDOW_HOURS = 12;
|
|
|
532
563
|
* typing a site name.
|
|
533
564
|
*/
|
|
534
565
|
export declare const SIGNUP_LANDING_GRACE_MINUTES = 30;
|
|
535
|
-
/**
|
|
566
|
+
/**
|
|
567
|
+
* Red when the two newest people in the window are both stranded. See
|
|
568
|
+
* {@link SignupLandingCheck}.
|
|
569
|
+
*/
|
|
536
570
|
export declare const MAX_STRANDED_SIGNUPS = 2;
|
|
537
|
-
/**
|
|
571
|
+
/**
|
|
572
|
+
* One org as the landing check reads it: whether it has any site, when it
|
|
573
|
+
* was made, and who made it. `visitor` is an opaque key that never leaves
|
|
574
|
+
* the check; an org without one is its own person.
|
|
575
|
+
*/
|
|
538
576
|
export interface SignupLandingOrg {
|
|
539
577
|
hostCount: number;
|
|
578
|
+
createdAtMs?: number | null;
|
|
579
|
+
visitor?: string | null;
|
|
540
580
|
}
|
|
541
581
|
export declare function signupLandingHealth(orgs: SignupLandingOrg[] | null, ms: number, maxStranded?: number, windowHours?: number, graceMinutes?: number): SignupLandingCheck;
|
|
542
582
|
/**
|
|
@@ -544,7 +544,10 @@ 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
|
-
/**
|
|
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;
|
|
548
551
|
export function signupLandingHealth(orgs, ms, maxStranded = MAX_STRANDED_SIGNUPS, windowHours = SIGNUP_LANDING_WINDOW_HOURS, graceMinutes = SIGNUP_LANDING_GRACE_MINUTES) {
|
|
549
552
|
const shape = {
|
|
550
553
|
windowHours,
|
|
@@ -559,13 +562,36 @@ export function signupLandingHealth(orgs, ms, maxStranded = MAX_STRANDED_SIGNUPS
|
|
|
559
562
|
ms,
|
|
560
563
|
code: 'orgs-unavailable',
|
|
561
564
|
strandedOrgs: null,
|
|
565
|
+
strandedPeople: null,
|
|
566
|
+
strandedStreak: null,
|
|
562
567
|
orgsInWindow: null
|
|
563
568
|
}, shape);
|
|
564
569
|
}
|
|
565
570
|
// `!(n > 0)` rather than `n === 0`, so a corrupt count (NaN, a string)
|
|
566
571
|
// reads as "no site" and cannot hide a stranded org.
|
|
567
|
-
const
|
|
568
|
-
const
|
|
572
|
+
const hasSite = (landing)=>Number(landing.hostCount) > 0;
|
|
573
|
+
const strandedOrgs = orgs.filter((landing)=>!hasSite(landing)).length;
|
|
574
|
+
const people = new Map();
|
|
575
|
+
orgs.forEach((landing, index)=>{
|
|
576
|
+
var _ref, _ref1;
|
|
577
|
+
const key = landing.visitor ? `visitor:${landing.visitor}` : `org:${index}`;
|
|
578
|
+
const createdAtMs = Number(landing.createdAtMs);
|
|
579
|
+
const latestMs = Number.isFinite(createdAtMs) ? createdAtMs : 0;
|
|
580
|
+
const person = people.get(key);
|
|
581
|
+
people.set(key, {
|
|
582
|
+
latestMs: Math.max((_ref = person == null ? void 0 : person.latestMs) != null ? _ref : 0, latestMs),
|
|
583
|
+
landed: ((_ref1 = person == null ? void 0 : person.landed) != null ? _ref1 : false) || hasSite(landing)
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
const newestFirst = [
|
|
587
|
+
...people.values()
|
|
588
|
+
].sort((a, b)=>b.latestMs - a.latestMs);
|
|
589
|
+
const strandedPeople = newestFirst.filter((person)=>!person.landed).length;
|
|
590
|
+
let strandedStreak = 0;
|
|
591
|
+
while(strandedStreak < newestFirst.length && !newestFirst[strandedStreak].landed){
|
|
592
|
+
strandedStreak += 1;
|
|
593
|
+
}
|
|
594
|
+
const stranded = strandedStreak >= maxStranded;
|
|
569
595
|
return _extends({
|
|
570
596
|
ok: !stranded,
|
|
571
597
|
ms
|
|
@@ -573,6 +599,8 @@ export function signupLandingHealth(orgs, ms, maxStranded = MAX_STRANDED_SIGNUPS
|
|
|
573
599
|
code: 'signups-stranded'
|
|
574
600
|
} : {}, {
|
|
575
601
|
strandedOrgs,
|
|
602
|
+
strandedPeople,
|
|
603
|
+
strandedStreak,
|
|
576
604
|
orgsInWindow: orgs.length
|
|
577
605
|
}, shape);
|
|
578
606
|
}
|