@aglyn/aglyn 1.0.0-beta.232 → 1.0.0-beta.233

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 (48) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/admin-audit-index.d.ts +3 -1
  3. package/src/lib/app-utils/admin-audit-index.js +9 -1
  4. package/src/lib/app-utils/admin-audit-index.js.map +1 -1
  5. package/src/lib/app-utils/analytics-summary.d.ts +95 -0
  6. package/src/lib/app-utils/analytics-summary.js +113 -0
  7. package/src/lib/app-utils/analytics-summary.js.map +1 -0
  8. package/src/lib/app-utils/artifact-list-keys.js +12 -0
  9. package/src/lib/app-utils/artifact-list-keys.js.map +1 -1
  10. package/src/lib/app-utils/artifact-list-queries.d.ts +16 -0
  11. package/src/lib/app-utils/artifact-list-queries.js +122 -4
  12. package/src/lib/app-utils/artifact-list-queries.js.map +1 -1
  13. package/src/lib/app-utils/crm.d.ts +30 -2
  14. package/src/lib/app-utils/crm.js +77 -15
  15. package/src/lib/app-utils/crm.js.map +1 -1
  16. package/src/lib/app-utils/docs-help.generated.d.ts +17 -5
  17. package/src/lib/app-utils/docs-help.generated.js +35 -1
  18. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  19. package/src/lib/app-utils/docs-index.generated.js +139 -6
  20. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  21. package/src/lib/app-utils/lockdown.js +1 -1
  22. package/src/lib/app-utils/lockdown.js.map +1 -1
  23. package/src/lib/app-utils/message-search.js +5 -2
  24. package/src/lib/app-utils/message-search.js.map +1 -1
  25. package/src/lib/app-utils/name-search.js +15 -3
  26. package/src/lib/app-utils/name-search.js.map +1 -1
  27. package/src/lib/app-utils/screen-analytics-aggregate.d.ts +70 -0
  28. package/src/lib/app-utils/screen-analytics-aggregate.js +82 -0
  29. package/src/lib/app-utils/screen-analytics-aggregate.js.map +1 -0
  30. package/src/lib/app-utils/screen-kind.d.ts +25 -0
  31. package/src/lib/app-utils/screen-kind.js +25 -0
  32. package/src/lib/app-utils/screen-kind.js.map +1 -0
  33. package/src/lib/app-utils/screen-route.d.ts +1 -5
  34. package/src/lib/app-utils/screen-route.js +2 -4
  35. package/src/lib/app-utils/screen-route.js.map +1 -1
  36. package/src/lib/app-utils/site-journey-steps.d.ts +38 -0
  37. package/src/lib/app-utils/site-journey-steps.js +54 -0
  38. package/src/lib/app-utils/site-journey-steps.js.map +1 -0
  39. package/src/lib/app-utils/site-journey.d.ts +2 -22
  40. package/src/lib/app-utils/site-journey.js +2 -32
  41. package/src/lib/app-utils/site-journey.js.map +1 -1
  42. package/src/lib/plugin-manager/first-party-plugins.generated.js +20 -2
  43. package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
  44. package/src/lib/plugin-manager/plugin-checkout-credits.d.ts +288 -0
  45. package/src/lib/plugin-manager/plugin-checkout-credits.js +196 -0
  46. package/src/lib/plugin-manager/plugin-checkout-credits.js.map +1 -0
  47. package/src/lib/plugin-manager/plugin-shipping-rates.d.ts +10 -0
  48. package/src/lib/plugin-manager/plugin-shipping-rates.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/name-search.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\n/**\n * Normalized search key for a human display name (AGL-835).\n *\n * Firestore has no case-insensitive query, so name search is done by storing\n * a normalized `nameLower` alongside the display name and running a prefix\n * range query against it: `orderBy(nameLower) startAt(q) endAt(q + '')`.\n * For that to match, the stored key and the typed query MUST be normalized the\n * same way — so both the write paths (screen/host creates and renames) and the\n * switcher's query builder run the raw text through this one function.\n *\n * Normalization is deliberately minimal and reversible-in-spirit: lower-case,\n * trim, and collapse internal whitespace. Diacritics are intentionally NOT\n * stripped — a user who types \"café\" should match the stored \"café\", and one\n * who types \"cafe\" is doing a different search; folding accents here would make\n * the prefix range silently disagree with the key. Returns '' for nullish or\n * blank input, which callers treat as \"no searchable name / skip the field\".\n */\nexport function nameSearchKey(name: string | null | undefined): string {\n return (name ?? '').trim().replace(/\\s+/g, ' ').toLowerCase()\n}\n\n/**\n * The longest word-prefix a token may be.\n *\n * Every prefix of every word is stored, so the token count grows with name\n * LENGTH rather than word count. Twelve characters is past the point where a\n * search box narrows anything — a reader who has typed twelve characters has\n * already found it — and it keeps a long name from spending a hundred index\n * entries on the tail of one word.\n */\nexport const NAME_TOKEN_MAX_PREFIX = 12\n\n/** Ceiling on tokens per document, so a pathological name cannot bloat the index. */\nexport const NAME_TOKEN_LIMIT = 120\n\n/**\n * Word-prefix tokens for `array-contains` search (AGL-2501).\n *\n * `nameSearchKey` supports a PREFIX range, which is anchored at the start of\n * the whole name: \"acme\" finds \"Acme Coffee\" and \"coffee\" does not. That is\n * the wrong shape for a search box, where the word a reader remembers is\n * rarely the first one.\n *\n * Firestore cannot answer `contains` on a string, but it can answer\n * `array-contains` on a field the write path prepared. Storing every prefix\n * of every WORD turns \"does this name contain a word starting with X\" into a\n * single indexed equality:\n *\n * \"Acme Coffee\" → a, ac, acm, acme, c, co, cof, coff, coffe, coffee\n *\n * So \"cof\" finds it, and so does \"acme\". What it still cannot do is match\n * mid-word — \"offee\" is not a prefix of any word — which is the honest edge\n * of doing this without a search service.\n *\n * Normalized through `nameSearchKey` so the stored tokens and the typed query\n * agree on case, trimming and internal whitespace; diacritics are kept, for\n * the reason given there.\n */\nexport function nameSearchTokens(name: string | null | undefined): string[] {\n const key = nameSearchKey(name)\n if (!key) return []\n const tokens = new Set<string>()\n for (const word of key.split(' ')) {\n if (!word) continue\n const capped = word.slice(0, NAME_TOKEN_MAX_PREFIX)\n for (let end = 1; end <= capped.length; end += 1) {\n tokens.add(capped.slice(0, end))\n if (tokens.size >= NAME_TOKEN_LIMIT) return [...tokens]\n }\n }\n return [...tokens]\n}\n\n/**\n * The one token a typed query becomes.\n *\n * `array-contains` takes a single value, and Firestore allows only one such\n * clause per query — so a multi-word query cannot be an AND on the server.\n * The FIRST word is used, which is what a reader is narrowing by when they\n * type \"acme cof\": they see the Acme results and read the rest themselves.\n * Capped to the same prefix length the tokens were written at, or a longer\n * query would match a token that was never stored.\n */\nexport function nameSearchToken(query: string | null | undefined): string {\n const key = nameSearchKey(query)\n if (!key) return ''\n return (key.split(' ')[0] ?? '').slice(0, NAME_TOKEN_MAX_PREFIX)\n}\n\n/**\n * The normalized name, reversed, so \"ends with\" becomes a prefix range.\n *\n * Firestore has one string operator that is not equality: the range. That\n * gives \"starts with\" directly — `>= q` and `<= q + ''` over\n * `nameLower` — and gives \"ends with\" nothing at all, because a range is\n * anchored at the front of the stored value.\n *\n * Reversing the stored key moves the end to the front. \"Acme Coffee\" is\n * stored as \"eeffoc emca\", and a search for names ending \"coffee\" becomes a\n * prefix range for \"eeffoc\" — the same query, on the same kind of index.\n *\n * Reversed by CODEPOINT (`[...key]`), not by UTF-16 unit: `split('')` cuts\n * surrogate pairs in half, so an emoji or a non-BMP character in a workspace\n * name would reverse into two lone surrogates and never match anything.\n */\nexport function nameSearchReversed(name: string | null | undefined): string {\n return [...nameSearchKey(name)].reverse().join('')\n}\n\n/**\n * The three search fields that travel with a `name`, as one object.\n *\n * Every collection whose list is SEARCHED on the server carries them, and\n * carrying only some is the failure mode worth designing against: `orderBy`\n * drops a document missing the field it sorts by, and `array-contains` drops\n * one missing the array. Either way the record still LISTS normally, so the\n * gap shows up only as a search that quietly cannot find one row.\n *\n * ⚠️ Spread this at every write that sets `name`, and only where `name` is\n * actually being written — a partial write that stamped an empty key over a\n * real one would make the document unfindable by the name it still displays.\n */\nexport function nameSearchFields(name: string): {\n name: string\n nameLower: string\n nameTokens: string[]\n nameReversed: string\n} {\n // `name` is INCLUDED so this is a drop-in replacement for writing `{ name }`.\n // A helper that returned only the derived keys would be spread beside the\n // name at four call sites, and the one that forgot would write search keys\n // for a name it never stored.\n return {\n name,\n nameLower: nameSearchKey(name),\n nameTokens: nameSearchTokens(name),\n nameReversed: nameSearchReversed(name),\n }\n}\n\n/**\n * The search fields a document NAMED BY `displayName` carries (AGL-3321):\n * the site artifacts (screens of every kind, layouts, reusable components,\n * templates) and the marketplace listings.\n *\n * `nameSearchFields` is for documents whose name IS `name`. These documents\n * name themselves `displayName`, and have since before search existed, so\n * the keys ride beside it under the same three names every list declares —\n * `lowerPath: 'nameLower'`, `tokensPath: 'nameTokens'`,\n * `reversedPath: 'nameReversed'` — and one list query grammar reads them all.\n *\n * `displayName` is NOT returned. A create that carries no name must not gain\n * an empty one: readers fall back with `displayName ?? id`, which an empty\n * string defeats. But the KEYS are always returned, empty for a missing name,\n * so a create stamps them unconditionally — an `orderBy('nameLower')` drops\n * any document that lacks the field, and a document created without a name\n * is still one a list must reach.\n *\n * ⚠️ Spread at every write that sets `displayName`, and at every create —\n * and nowhere else: a partial update that stamped keys for a name it did not\n * write would make the document unfindable by the name it still shows.\n */\nexport function displayNameSearchFields(displayName: unknown): {\n nameLower: string\n nameTokens: string[]\n nameReversed: string\n} {\n const name = typeof displayName === 'string' ? displayName : ''\n return {\n nameLower: nameSearchKey(name),\n nameTokens: nameSearchTokens(name),\n nameReversed: nameSearchReversed(name),\n }\n}\n\n/**\n * The normalizers a list query plan (`planListQuery` in\n * `@aglyn/shared-ui-jsx/const/list-query-plan`) turns typed values into keys\n * with: the same functions the writers stamp `nameLower`, `nameTokens` and\n * `nameReversed` with, so a query asks for what was stored (AGL-3321).\n */\nexport const nameSearchNormalizers = {\n key: (value: string) => nameSearchKey(value),\n token: (value: string) => nameSearchToken(value),\n reversed: (value: string) => nameSearchReversed(value),\n maxPrefix: NAME_TOKEN_MAX_PREFIX,\n} as const\n\n\n/**\n * What joins a scope token to a search prefix in a SCOPED search token\n * (AGL-3321): `host:abc~acm`.\n *\n * A list under a scope clause (`visibleTo array-contains-any [...]`) has\n * spent Firestore's one array clause on the scope, so its search cannot be a\n * second `array-contains`. The list query plan folds the typed word INTO the scope\n * clause instead (`ListQueryDeclaration.search.scoped`), asking for each\n * scope token joined to the word; the writers stamp every scope token\n * joined to every prefix, through {@link scopedSearchTokens}. A declaration\n * names this constant as its `join`, so the writer and the reader cannot\n * disagree about the character between the two halves.\n *\n * ⛔ Security rules cannot prove a folded query for a reader limited to some\n * sites. Rules like `canReadScoped` grant a list from\n * `visibleTo.hasAny(<the reader's scopes>)`, and a query that constrains only\n * the scoped-token field says nothing about `visibleTo`, so Firestore denies\n * it (pinned in cloud/rules-tests/firestore-rules.test.mjs). A folded search\n * is therefore for readers the rules admit WITHOUT a `visibleTo` term (an\n * org-wide member, whose rule short-circuits) or for an Admin-SDK route that\n * checks the reader's scope itself. A partial-reach collaborator's search\n * stays beside the `visibleTo` clause instead, as a prefix range on one\n * ordered key (media: `nameLower`; inbox leads: `email`).\n *\n * `~` appears in no scope token (`org`, `host:{id}`), so the split is never\n * ambiguous; a prefix that itself holds a `~` is matched whole on both sides\n * and needs no escaping.\n */\nexport const SCOPED_SEARCH_JOIN = '~'\n\n/**\n * The `search.scoped` half of a list declaration, over the array the\n * writers stamp with {@link scopedSearchTokens}.\n */\nexport function scopedSearch(tokensPath: string): {\n tokensPath: string\n join: typeof SCOPED_SEARCH_JOIN\n} {\n return { tokensPath, join: SCOPED_SEARCH_JOIN }\n}\n\n/**\n * Every scope a record is visible to, joined to every search prefix it\n * carries: `[org~a, org~ac, host:x~a, …]`, in scope order then token order,\n * with no duplicates (AGL-3321).\n *\n * `visibleTo` is read defensively — a record's stored array — and keeps\n * every non-empty string in it; anything else answers no scope, and so no\n * scoped tokens: a record visible to nobody is found by no scoped search.\n * `tokens` is whatever the record's plain search array holds, so a list that\n * searches several fields passes their merged prefixes rather than one\n * name's. Each is stamped beside the plain array on every write that sets\n * either the searched text or `visibleTo`.\n *\n * The count is the product of the two, so a record placed on thirty sites\n * with a long name carries thousands of entries. That stays far inside\n * Firestore's per-document index-entry ceiling, and it is the price of a\n * search a scoped reader can run at all.\n */\nexport function scopedSearchTokens(\n visibleTo: unknown,\n tokens: readonly string[],\n): string[] {\n if (!Array.isArray(visibleTo)) return []\n const scopes = [\n ...new Set(\n visibleTo.filter(\n (entry): entry is string => typeof entry === 'string' && entry.length > 0,\n ),\n ),\n ]\n const words = [...new Set(tokens.filter((token) => typeof token === 'string' && token))]\n const scoped: string[] = []\n for (const scope of scopes) {\n for (const word of words) scoped.push(`${scope}${SCOPED_SEARCH_JOIN}${word}`)\n }\n return scoped\n}\n"],"names":["nameSearchKey","name","trim","replace","toLowerCase","NAME_TOKEN_MAX_PREFIX","NAME_TOKEN_LIMIT","nameSearchTokens","key","tokens","Set","word","split","capped","slice","end","length","add","size","nameSearchToken","query","nameSearchReversed","reverse","join","nameSearchFields","nameLower","nameTokens","nameReversed","displayNameSearchFields","displayName","nameSearchNormalizers","value","token","reversed","maxPrefix","SCOPED_SEARCH_JOIN","scopedSearch","tokensPath","scopedSearchTokens","visibleTo","Array","isArray","scopes","filter","entry","words","scoped","scope","push"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/name-search.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\n/**\n * Normalized search key for a human display name (AGL-835).\n *\n * Firestore has no case-insensitive query, so name search is done by storing\n * a normalized `nameLower` alongside the display name and running a prefix\n * range query against it: `orderBy(nameLower) startAt(q) endAt(q + '')`.\n * For that to match, the stored key and the typed query MUST be normalized the\n * same way — so both the write paths (screen/host creates and renames) and the\n * switcher's query builder run the raw text through this one function.\n *\n * Normalization is deliberately minimal and reversible-in-spirit: lower-case,\n * trim, and collapse internal whitespace. Diacritics are intentionally NOT\n * stripped — a user who types \"café\" should match the stored \"café\", and one\n * who types \"cafe\" is doing a different search; folding accents here would make\n * the prefix range silently disagree with the key. Returns '' for nullish or\n * blank input, which callers treat as \"no searchable name / skip the field\".\n */\nexport function nameSearchKey(name: string | null | undefined): string {\n return (name ?? '').trim().replace(/\\s+/g, ' ').toLowerCase()\n}\n\n/**\n * The longest word-prefix a token may be.\n *\n * Every prefix of every word is stored, so the token count grows with name\n * LENGTH rather than word count. Twelve characters is past the point where a\n * search box narrows anything — a reader who has typed twelve characters has\n * already found it — and it keeps a long name from spending a hundred index\n * entries on the tail of one word.\n */\nexport const NAME_TOKEN_MAX_PREFIX = 12\n\n/** Ceiling on tokens per document, so a pathological name cannot bloat the index. */\nexport const NAME_TOKEN_LIMIT = 120\n\n/**\n * Word-prefix tokens for `array-contains` search (AGL-2501).\n *\n * `nameSearchKey` supports a PREFIX range, which is anchored at the start of\n * the whole name: \"acme\" finds \"Acme Coffee\" and \"coffee\" does not. That is\n * the wrong shape for a search box, where the word a reader remembers is\n * rarely the first one.\n *\n * Firestore cannot answer `contains` on a string, but it can answer\n * `array-contains` on a field the write path prepared. Storing every prefix\n * of every WORD turns \"does this name contain a word starting with X\" into a\n * single indexed equality:\n *\n * \"Acme Coffee\" → a, ac, acm, acme, c, co, cof, coff, coffe, coffee\n *\n * So \"cof\" finds it, and so does \"acme\". What it still cannot do is match\n * mid-word — \"offee\" is not a prefix of any word — which is the honest edge\n * of doing this without a search service.\n *\n * Normalized through `nameSearchKey` so the stored tokens and the typed query\n * agree on case, trimming and internal whitespace; diacritics are kept, for\n * the reason given there.\n */\nexport function nameSearchTokens(name: string | null | undefined): string[] {\n const key = nameSearchKey(name)\n if (!key) return []\n const tokens = new Set<string>()\n for (const word of key.split(' ')) {\n if (!word) continue\n // Walked by CODEPOINT, never by UTF-16 unit (AGL-3689, 2026-10-08). The\n // first unit of an emoji is a lone surrogate, which is not valid UTF-8,\n // and Firestore refuses the whole write with `3 INVALID_ARGUMENT` — a\n // site named \"Nova Library. 📚\" was created and then failed its member\n // projections, so its owner got a 500 for a site that existed. Identical\n // to the unit walk for every BMP name, so no stored token moves.\n const capped = [...word].slice(0, NAME_TOKEN_MAX_PREFIX)\n for (let end = 1; end <= capped.length; end += 1) {\n tokens.add(capped.slice(0, end).join(''))\n if (tokens.size >= NAME_TOKEN_LIMIT) return [...tokens]\n }\n }\n return [...tokens]\n}\n\n/**\n * The one token a typed query becomes.\n *\n * `array-contains` takes a single value, and Firestore allows only one such\n * clause per query — so a multi-word query cannot be an AND on the server.\n * The FIRST word is used, which is what a reader is narrowing by when they\n * type \"acme cof\": they see the Acme results and read the rest themselves.\n * Capped to the same prefix length the tokens were written at, or a longer\n * query would match a token that was never stored.\n */\nexport function nameSearchToken(query: string | null | undefined): string {\n const key = nameSearchKey(query)\n if (!key) return ''\n // By codepoint, as the tokens were written: a query is capped where a\n // stored token was, or it would ask for one that was never stored.\n return [...(key.split(' ')[0] ?? '')].slice(0, NAME_TOKEN_MAX_PREFIX).join('')\n}\n\n/**\n * The normalized name, reversed, so \"ends with\" becomes a prefix range.\n *\n * Firestore has one string operator that is not equality: the range. That\n * gives \"starts with\" directly — `>= q` and `<= q + ''` over\n * `nameLower` — and gives \"ends with\" nothing at all, because a range is\n * anchored at the front of the stored value.\n *\n * Reversing the stored key moves the end to the front. \"Acme Coffee\" is\n * stored as \"eeffoc emca\", and a search for names ending \"coffee\" becomes a\n * prefix range for \"eeffoc\" — the same query, on the same kind of index.\n *\n * Reversed by CODEPOINT (`[...key]`), not by UTF-16 unit: `split('')` cuts\n * surrogate pairs in half, so an emoji or a non-BMP character in a workspace\n * name would reverse into two lone surrogates and never match anything.\n */\nexport function nameSearchReversed(name: string | null | undefined): string {\n return [...nameSearchKey(name)].reverse().join('')\n}\n\n/**\n * The three search fields that travel with a `name`, as one object.\n *\n * Every collection whose list is SEARCHED on the server carries them, and\n * carrying only some is the failure mode worth designing against: `orderBy`\n * drops a document missing the field it sorts by, and `array-contains` drops\n * one missing the array. Either way the record still LISTS normally, so the\n * gap shows up only as a search that quietly cannot find one row.\n *\n * ⚠️ Spread this at every write that sets `name`, and only where `name` is\n * actually being written — a partial write that stamped an empty key over a\n * real one would make the document unfindable by the name it still displays.\n */\nexport function nameSearchFields(name: string): {\n name: string\n nameLower: string\n nameTokens: string[]\n nameReversed: string\n} {\n // `name` is INCLUDED so this is a drop-in replacement for writing `{ name }`.\n // A helper that returned only the derived keys would be spread beside the\n // name at four call sites, and the one that forgot would write search keys\n // for a name it never stored.\n return {\n name,\n nameLower: nameSearchKey(name),\n nameTokens: nameSearchTokens(name),\n nameReversed: nameSearchReversed(name),\n }\n}\n\n/**\n * The search fields a document NAMED BY `displayName` carries (AGL-3321):\n * the site artifacts (screens of every kind, layouts, reusable components,\n * templates) and the marketplace listings.\n *\n * `nameSearchFields` is for documents whose name IS `name`. These documents\n * name themselves `displayName`, and have since before search existed, so\n * the keys ride beside it under the same three names every list declares —\n * `lowerPath: 'nameLower'`, `tokensPath: 'nameTokens'`,\n * `reversedPath: 'nameReversed'` — and one list query grammar reads them all.\n *\n * `displayName` is NOT returned. A create that carries no name must not gain\n * an empty one: readers fall back with `displayName ?? id`, which an empty\n * string defeats. But the KEYS are always returned, empty for a missing name,\n * so a create stamps them unconditionally — an `orderBy('nameLower')` drops\n * any document that lacks the field, and a document created without a name\n * is still one a list must reach.\n *\n * ⚠️ Spread at every write that sets `displayName`, and at every create —\n * and nowhere else: a partial update that stamped keys for a name it did not\n * write would make the document unfindable by the name it still shows.\n */\nexport function displayNameSearchFields(displayName: unknown): {\n nameLower: string\n nameTokens: string[]\n nameReversed: string\n} {\n const name = typeof displayName === 'string' ? displayName : ''\n return {\n nameLower: nameSearchKey(name),\n nameTokens: nameSearchTokens(name),\n nameReversed: nameSearchReversed(name),\n }\n}\n\n/**\n * The normalizers a list query plan (`planListQuery` in\n * `@aglyn/shared-ui-jsx/const/list-query-plan`) turns typed values into keys\n * with: the same functions the writers stamp `nameLower`, `nameTokens` and\n * `nameReversed` with, so a query asks for what was stored (AGL-3321).\n */\nexport const nameSearchNormalizers = {\n key: (value: string) => nameSearchKey(value),\n token: (value: string) => nameSearchToken(value),\n reversed: (value: string) => nameSearchReversed(value),\n maxPrefix: NAME_TOKEN_MAX_PREFIX,\n} as const\n\n\n/**\n * What joins a scope token to a search prefix in a SCOPED search token\n * (AGL-3321): `host:abc~acm`.\n *\n * A list under a scope clause (`visibleTo array-contains-any [...]`) has\n * spent Firestore's one array clause on the scope, so its search cannot be a\n * second `array-contains`. The list query plan folds the typed word INTO the scope\n * clause instead (`ListQueryDeclaration.search.scoped`), asking for each\n * scope token joined to the word; the writers stamp every scope token\n * joined to every prefix, through {@link scopedSearchTokens}. A declaration\n * names this constant as its `join`, so the writer and the reader cannot\n * disagree about the character between the two halves.\n *\n * ⛔ Security rules cannot prove a folded query for a reader limited to some\n * sites. Rules like `canReadScoped` grant a list from\n * `visibleTo.hasAny(<the reader's scopes>)`, and a query that constrains only\n * the scoped-token field says nothing about `visibleTo`, so Firestore denies\n * it (pinned in cloud/rules-tests/firestore-rules.test.mjs). A folded search\n * is therefore for readers the rules admit WITHOUT a `visibleTo` term (an\n * org-wide member, whose rule short-circuits) or for an Admin-SDK route that\n * checks the reader's scope itself. A partial-reach collaborator's search\n * stays beside the `visibleTo` clause instead, as a prefix range on one\n * ordered key (media: `nameLower`; inbox leads: `email`).\n *\n * `~` appears in no scope token (`org`, `host:{id}`), so the split is never\n * ambiguous; a prefix that itself holds a `~` is matched whole on both sides\n * and needs no escaping.\n */\nexport const SCOPED_SEARCH_JOIN = '~'\n\n/**\n * The `search.scoped` half of a list declaration, over the array the\n * writers stamp with {@link scopedSearchTokens}.\n */\nexport function scopedSearch(tokensPath: string): {\n tokensPath: string\n join: typeof SCOPED_SEARCH_JOIN\n} {\n return { tokensPath, join: SCOPED_SEARCH_JOIN }\n}\n\n/**\n * Every scope a record is visible to, joined to every search prefix it\n * carries: `[org~a, org~ac, host:x~a, …]`, in scope order then token order,\n * with no duplicates (AGL-3321).\n *\n * `visibleTo` is read defensively — a record's stored array — and keeps\n * every non-empty string in it; anything else answers no scope, and so no\n * scoped tokens: a record visible to nobody is found by no scoped search.\n * `tokens` is whatever the record's plain search array holds, so a list that\n * searches several fields passes their merged prefixes rather than one\n * name's. Each is stamped beside the plain array on every write that sets\n * either the searched text or `visibleTo`.\n *\n * The count is the product of the two, so a record placed on thirty sites\n * with a long name carries thousands of entries. That stays far inside\n * Firestore's per-document index-entry ceiling, and it is the price of a\n * search a scoped reader can run at all.\n */\nexport function scopedSearchTokens(\n visibleTo: unknown,\n tokens: readonly string[],\n): string[] {\n if (!Array.isArray(visibleTo)) return []\n const scopes = [\n ...new Set(\n visibleTo.filter(\n (entry): entry is string => typeof entry === 'string' && entry.length > 0,\n ),\n ),\n ]\n const words = [...new Set(tokens.filter((token) => typeof token === 'string' && token))]\n const scoped: string[] = []\n for (const scope of scopes) {\n for (const word of words) scoped.push(`${scope}${SCOPED_SEARCH_JOIN}${word}`)\n }\n return scoped\n}\n"],"names":["nameSearchKey","name","trim","replace","toLowerCase","NAME_TOKEN_MAX_PREFIX","NAME_TOKEN_LIMIT","nameSearchTokens","key","tokens","Set","word","split","capped","slice","end","length","add","join","size","nameSearchToken","query","nameSearchReversed","reverse","nameSearchFields","nameLower","nameTokens","nameReversed","displayNameSearchFields","displayName","nameSearchNormalizers","value","token","reversed","maxPrefix","SCOPED_SEARCH_JOIN","scopedSearch","tokensPath","scopedSearchTokens","visibleTo","Array","isArray","scopes","filter","entry","words","scoped","scope","push"],"mappings":"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"}
@@ -0,0 +1,70 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * Aggregation for the per-screen traffic table (AGL-1844): folds the AGL-151
19
+ * screen day docs (`hosts/{id}/screenAnalytics/{screenId}:{day}`) a range
20
+ * query returns into one row per screen. Pure — the component owns the reads,
21
+ * this owns the arithmetic, and the spec owns this.
22
+ */
23
+ /** The fields this table reads off one screen day doc. */
24
+ export interface ScreenDayDocData {
25
+ screenId?: unknown;
26
+ total?: unknown;
27
+ devices?: Record<string, unknown> | null;
28
+ /**
29
+ * Referring host → visits (AGL-2341).
30
+ *
31
+ * /api/analytics/collect increments this on EVERY pageview, keyed per
32
+ * referrer host — the widest-fanout field on the document. It was written
33
+ * for every visitor on every plan and folded into nothing: the only reader
34
+ * was the per-screen drilldown, so the one question a site owner asks first
35
+ * — where is my traffic coming from — could be answered one screen at a
36
+ * time and never across the site.
37
+ *
38
+ * Folded here rather than left out because the sibling `devices` map is
39
+ * aggregated the same way and the silence read as an oversight. It costs
40
+ * nothing at the gate either: this table and the drilldown are behind the
41
+ * SAME `screenAnalytics` Pro+ entitlement, so nothing paid is being given
42
+ * away by summing a field the paid panel already shows.
43
+ */
44
+ referrers?: Record<string, unknown> | null;
45
+ }
46
+ export interface ScreenTrafficRow {
47
+ screenId: string;
48
+ total: number;
49
+ devices: Record<string, number>;
50
+ referrers: Record<string, number>;
51
+ }
52
+ /**
53
+ * One row per screen, pageviews and device splits summed across the range,
54
+ * sorted by views descending. Docs without a usable `screenId` or a positive
55
+ * `total` are dropped — a malformed doc must cost a row, never NaN in a
56
+ * column.
57
+ */
58
+ export declare function aggregateScreenDays(docs: readonly ScreenDayDocData[]): ScreenTrafficRow[];
59
+ /** The device with the highest count, or empty when none recorded. */
60
+ export declare function topDevice(row: ScreenTrafficRow): string;
61
+ /**
62
+ * The referring host that sent the most visits to this screen, or empty.
63
+ *
64
+ * Empty is the common answer and a true one: a direct visit records no
65
+ * referrer at all, so a screen people reach by typing the address has an
66
+ * empty map rather than a missing one. The column renders that as `--`, the
67
+ * same as `topDevice`, instead of inventing a "direct" bucket the collector
68
+ * never wrote.
69
+ */
70
+ export declare function topReferrer(row: ScreenTrafficRow): string;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * Aggregation for the per-screen traffic table (AGL-1844): folds the AGL-151
18
+ * screen day docs (`hosts/{id}/screenAnalytics/{screenId}:{day}`) a range
19
+ * query returns into one row per screen. Pure — the component owns the reads,
20
+ * this owns the arithmetic, and the spec owns this.
21
+ */ /** The fields this table reads off one screen day doc. */ /**
22
+ * One row per screen, pageviews and device splits summed across the range,
23
+ * sorted by views descending. Docs without a usable `screenId` or a positive
24
+ * `total` are dropped — a malformed doc must cost a row, never NaN in a
25
+ * column.
26
+ */ export function aggregateScreenDays(docs) {
27
+ const byScreen = new Map();
28
+ for (const data of docs){
29
+ var _data_total, _data_devices, _data_referrers;
30
+ const screenId = typeof data.screenId === 'string' ? data.screenId : '';
31
+ const total = Number((_data_total = data.total) != null ? _data_total : 0);
32
+ if (!screenId || !Number.isFinite(total) || total <= 0) continue;
33
+ let row = byScreen.get(screenId);
34
+ if (!row) {
35
+ row = {
36
+ screenId,
37
+ total: 0,
38
+ devices: {},
39
+ referrers: {}
40
+ };
41
+ byScreen.set(screenId, row);
42
+ }
43
+ row.total += total;
44
+ for (const [device, count] of Object.entries((_data_devices = data.devices) != null ? _data_devices : {})){
45
+ const n = Number(count != null ? count : 0);
46
+ if (Number.isFinite(n) && n > 0) {
47
+ var _row_devices_device;
48
+ row.devices[device] = ((_row_devices_device = row.devices[device]) != null ? _row_devices_device : 0) + n;
49
+ }
50
+ }
51
+ for (const [host, count] of Object.entries((_data_referrers = data.referrers) != null ? _data_referrers : {})){
52
+ const n = Number(count != null ? count : 0);
53
+ if (Number.isFinite(n) && n > 0) {
54
+ var _row_referrers_host;
55
+ row.referrers[host] = ((_row_referrers_host = row.referrers[host]) != null ? _row_referrers_host : 0) + n;
56
+ }
57
+ }
58
+ }
59
+ return [
60
+ ...byScreen.values()
61
+ ].sort((a, b)=>b.total - a.total);
62
+ }
63
+ /** The device with the highest count, or empty when none recorded. */ export function topDevice(row) {
64
+ var _ref;
65
+ const top = Object.entries(row.devices).sort(([, a], [, b])=>b - a)[0];
66
+ return (_ref = top == null ? void 0 : top[0]) != null ? _ref : '';
67
+ }
68
+ /**
69
+ * The referring host that sent the most visits to this screen, or empty.
70
+ *
71
+ * Empty is the common answer and a true one: a direct visit records no
72
+ * referrer at all, so a screen people reach by typing the address has an
73
+ * empty map rather than a missing one. The column renders that as `--`, the
74
+ * same as `topDevice`, instead of inventing a "direct" bucket the collector
75
+ * never wrote.
76
+ */ export function topReferrer(row) {
77
+ var _ref;
78
+ const top = Object.entries(row.referrers).sort(([, a], [, b])=>b - a)[0];
79
+ return (_ref = top == null ? void 0 : top[0]) != null ? _ref : '';
80
+ }
81
+
82
+ //# sourceMappingURL=screen-analytics-aggregate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/screen-analytics-aggregate.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\n/**\n * Aggregation for the per-screen traffic table (AGL-1844): folds the AGL-151\n * screen day docs (`hosts/{id}/screenAnalytics/{screenId}:{day}`) a range\n * query returns into one row per screen. Pure — the component owns the reads,\n * this owns the arithmetic, and the spec owns this.\n */\n\n/** The fields this table reads off one screen day doc. */\nexport interface ScreenDayDocData {\n screenId?: unknown\n total?: unknown\n devices?: Record<string, unknown> | null\n /**\n * Referring host → visits (AGL-2341).\n *\n * /api/analytics/collect increments this on EVERY pageview, keyed per\n * referrer host — the widest-fanout field on the document. It was written\n * for every visitor on every plan and folded into nothing: the only reader\n * was the per-screen drilldown, so the one question a site owner asks first\n * — where is my traffic coming from — could be answered one screen at a\n * time and never across the site.\n *\n * Folded here rather than left out because the sibling `devices` map is\n * aggregated the same way and the silence read as an oversight. It costs\n * nothing at the gate either: this table and the drilldown are behind the\n * SAME `screenAnalytics` Pro+ entitlement, so nothing paid is being given\n * away by summing a field the paid panel already shows.\n */\n referrers?: Record<string, unknown> | null\n}\n\nexport interface ScreenTrafficRow {\n screenId: string\n total: number\n devices: Record<string, number>\n referrers: Record<string, number>\n}\n\n/**\n * One row per screen, pageviews and device splits summed across the range,\n * sorted by views descending. Docs without a usable `screenId` or a positive\n * `total` are dropped — a malformed doc must cost a row, never NaN in a\n * column.\n */\nexport function aggregateScreenDays(\n docs: readonly ScreenDayDocData[],\n): ScreenTrafficRow[] {\n const byScreen = new Map<string, ScreenTrafficRow>()\n for (const data of docs) {\n const screenId = typeof data.screenId === 'string' ? data.screenId : ''\n const total = Number(data.total ?? 0)\n if (!screenId || !Number.isFinite(total) || total <= 0) continue\n let row = byScreen.get(screenId)\n if (!row) {\n row = { screenId, total: 0, devices: {}, referrers: {} }\n byScreen.set(screenId, row)\n }\n row.total += total\n for (const [device, count] of Object.entries(data.devices ?? {})) {\n const n = Number(count ?? 0)\n if (Number.isFinite(n) && n > 0) {\n row.devices[device] = (row.devices[device] ?? 0) + n\n }\n }\n for (const [host, count] of Object.entries(data.referrers ?? {})) {\n const n = Number(count ?? 0)\n if (Number.isFinite(n) && n > 0) {\n row.referrers[host] = (row.referrers[host] ?? 0) + n\n }\n }\n }\n return [...byScreen.values()].sort((a, b) => b.total - a.total)\n}\n\n/** The device with the highest count, or empty when none recorded. */\nexport function topDevice(row: ScreenTrafficRow): string {\n const top = Object.entries(row.devices).sort(([, a], [, b]) => b - a)[0]\n return top?.[0] ?? ''\n}\n\n/**\n * The referring host that sent the most visits to this screen, or empty.\n *\n * Empty is the common answer and a true one: a direct visit records no\n * referrer at all, so a screen people reach by typing the address has an\n * empty map rather than a missing one. The column renders that as `--`, the\n * same as `topDevice`, instead of inventing a \"direct\" bucket the collector\n * never wrote.\n */\nexport function topReferrer(row: ScreenTrafficRow): string {\n const top = Object.entries(row.referrers).sort(([, a], [, b]) => b - a)[0]\n return top?.[0] ?? ''\n}\n"],"names":["aggregateScreenDays","docs","byScreen","Map","data","screenId","total","Number","isFinite","row","get","devices","referrers","set","device","count","Object","entries","n","host","values","sort","a","b","topDevice","top","topReferrer"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;CAKC,GAED,wDAAwD,GA+BxD;;;;;CAKC,GACD,OAAO,SAASA,oBACdC,IAAiC;IAEjC,MAAMC,WAAW,IAAIC;IACrB,KAAK,MAAMC,QAAQH,KAAM;YAEFG,aAQwBA,eAMFA;QAf3C,MAAMC,WAAW,OAAOD,KAAKC,QAAQ,KAAK,WAAWD,KAAKC,QAAQ,GAAG;QACrE,MAAMC,QAAQC,QAAOH,cAAAA,KAAKE,KAAK,YAAVF,cAAc;QACnC,IAAI,CAACC,YAAY,CAACE,OAAOC,QAAQ,CAACF,UAAUA,SAAS,GAAG;QACxD,IAAIG,MAAMP,SAASQ,GAAG,CAACL;QACvB,IAAI,CAACI,KAAK;YACRA,MAAM;gBAAEJ;gBAAUC,OAAO;gBAAGK,SAAS,CAAC;gBAAGC,WAAW,CAAC;YAAE;YACvDV,SAASW,GAAG,CAACR,UAAUI;QACzB;QACAA,IAAIH,KAAK,IAAIA;QACb,KAAK,MAAM,CAACQ,QAAQC,MAAM,IAAIC,OAAOC,OAAO,EAACb,gBAAAA,KAAKO,OAAO,YAAZP,gBAAgB,CAAC,GAAI;YAChE,MAAMc,IAAIX,OAAOQ,gBAAAA,QAAS;YAC1B,IAAIR,OAAOC,QAAQ,CAACU,MAAMA,IAAI,GAAG;oBACRT;gBAAvBA,IAAIE,OAAO,CAACG,OAAO,GAAG,EAACL,sBAAAA,IAAIE,OAAO,CAACG,OAAO,YAAnBL,sBAAuB,KAAKS;YACrD;QACF;QACA,KAAK,MAAM,CAACC,MAAMJ,MAAM,IAAIC,OAAOC,OAAO,EAACb,kBAAAA,KAAKQ,SAAS,YAAdR,kBAAkB,CAAC,GAAI;YAChE,MAAMc,IAAIX,OAAOQ,gBAAAA,QAAS;YAC1B,IAAIR,OAAOC,QAAQ,CAACU,MAAMA,IAAI,GAAG;oBACRT;gBAAvBA,IAAIG,SAAS,CAACO,KAAK,GAAG,EAACV,sBAAAA,IAAIG,SAAS,CAACO,KAAK,YAAnBV,sBAAuB,KAAKS;YACrD;QACF;IACF;IACA,OAAO;WAAIhB,SAASkB,MAAM;KAAG,CAACC,IAAI,CAAC,CAACC,GAAGC,IAAMA,EAAEjB,KAAK,GAAGgB,EAAEhB,KAAK;AAChE;AAEA,oEAAoE,GACpE,OAAO,SAASkB,UAAUf,GAAqB;;IAC7C,MAAMgB,MAAMT,OAAOC,OAAO,CAACR,IAAIE,OAAO,EAAEU,IAAI,CAAC,CAAC,GAAGC,EAAE,EAAE,GAAGC,EAAE,GAAKA,IAAID,EAAE,CAAC,EAAE;IACxE,eAAOG,uBAAAA,GAAK,CAAC,EAAE,mBAAI;AACrB;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASC,YAAYjB,GAAqB;;IAC/C,MAAMgB,MAAMT,OAAOC,OAAO,CAACR,IAAIG,SAAS,EAAES,IAAI,CAAC,CAAC,GAAGC,EAAE,EAAE,GAAGC,EAAE,GAAKA,IAAID,EAAE,CAAC,EAAE;IAC1E,eAAOG,uBAAAA,GAAK,CAAC,EAAE,mBAAI;AACrB"}
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * `kind` of a besigner email document (AGL-395): a screen authored on the
19
+ * Emails page and sent by a campaign, never served at a URL.
20
+ *
21
+ * A leaf of its own so a pure model (the email design documents, which the
22
+ * native apps' contracts read) can name it without `screen-route`, which
23
+ * reaches the foundation barrel.
24
+ */
25
+ export declare const SCREEN_KIND_EMAIL = "email";
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * `kind` of a besigner email document (AGL-395): a screen authored on the
18
+ * Emails page and sent by a campaign, never served at a URL.
19
+ *
20
+ * A leaf of its own so a pure model (the email design documents, which the
21
+ * native apps' contracts read) can name it without `screen-route`, which
22
+ * reaches the foundation barrel.
23
+ */ export const SCREEN_KIND_EMAIL = 'email';
24
+
25
+ //# sourceMappingURL=screen-kind.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/screen-kind.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\n/**\n * `kind` of a besigner email document (AGL-395): a screen authored on the\n * Emails page and sent by a campaign, never served at a URL.\n *\n * A leaf of its own so a pure model (the email design documents, which the\n * native apps' contracts read) can name it without `screen-route`, which\n * reaches the foundation barrel.\n */\nexport const SCREEN_KIND_EMAIL = 'email'\n"],"names":["SCREEN_KIND_EMAIL"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;CAOC,GACD,OAAO,MAAMA,oBAAoB,QAAO"}
@@ -458,11 +458,7 @@ export declare function collectionEntryRoutePath(collectionSlug: string, entrySl
458
458
  * nowhere".
459
459
  */
460
460
  export declare function linkableScreenRoutes(screens: Record<ScreenUid, string> | null | undefined, sources?: LinkableScreenRouteSources): Record<ScreenUid, string> | undefined;
461
- /**
462
- * `kind` of a besigner email document (AGL-395): a screen authored on the
463
- * Emails page and sent by a campaign, never served at a URL.
464
- */
465
- export declare const SCREEN_KIND_EMAIL = "email";
461
+ export { SCREEN_KIND_EMAIL } from './screen-kind';
466
462
  /**
467
463
  * `kind` of a collection ENTRY template (AGL-1400): a screen that composes
468
464
  * `/{collection}/{entry}` for every entry and has no address of its own.
@@ -19,6 +19,7 @@ import { collectionListUrl } from "./collection-entries.js";
19
19
  import { ENTRY_PREVIEW_ROUTE_SEGMENT } from "./entry-preview-link.js";
20
20
  import { LEAVING_NOTICE_ROUTE_SEGMENT } from "./leaving-notice.js";
21
21
  import { PLATFORM_BRAND_NAME } from "./platform-brand.js";
22
+ import { SCREEN_KIND_EMAIL } from "./screen-kind.js";
22
23
  import { formatCollectionLinkValue, formatEntryLinkValue, formatFeedLinkValue, parseEntryLinkValue } from "./screen-link-value.js";
23
24
  /**
24
25
  * Route path of a host's root screen. The tenant matcher joins the catch-all
@@ -574,10 +575,7 @@ import { formatCollectionLinkValue, formatEntryLinkValue, formatFeedLinkValue, p
574
575
  }
575
576
  return next;
576
577
  }
577
- /**
578
- * `kind` of a besigner email document (AGL-395): a screen authored on the
579
- * Emails page and sent by a campaign, never served at a URL.
580
- */ export const SCREEN_KIND_EMAIL = 'email';
578
+ export { SCREEN_KIND_EMAIL } from "./screen-kind.js";
581
579
  /**
582
580
  * `kind` of a collection ENTRY template (AGL-1400): a screen that composes
583
581
  * `/{collection}/{entry}` for every entry and has no address of its own.