@aglyn/aglyn 1.0.0-beta.231 → 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.
- package/package.json +11 -11
- package/src/lib/app-utils/account-acquisition.d.ts +15 -2
- package/src/lib/app-utils/account-acquisition.js +20 -1
- package/src/lib/app-utils/account-acquisition.js.map +1 -1
- package/src/lib/app-utils/admin-audit-index.d.ts +3 -1
- package/src/lib/app-utils/admin-audit-index.js +9 -1
- package/src/lib/app-utils/admin-audit-index.js.map +1 -1
- package/src/lib/app-utils/analytics-summary.d.ts +95 -0
- package/src/lib/app-utils/analytics-summary.js +113 -0
- package/src/lib/app-utils/analytics-summary.js.map +1 -0
- package/src/lib/app-utils/artifact-list-keys.js +12 -0
- package/src/lib/app-utils/artifact-list-keys.js.map +1 -1
- package/src/lib/app-utils/artifact-list-queries.d.ts +51 -0
- package/src/lib/app-utils/artifact-list-queries.js +321 -0
- package/src/lib/app-utils/artifact-list-queries.js.map +1 -0
- package/src/lib/app-utils/business-profile.d.ts +171 -0
- package/src/lib/app-utils/business-profile.js +277 -0
- package/src/lib/app-utils/business-profile.js.map +1 -0
- package/src/lib/app-utils/child-contract.d.ts +4 -1
- package/src/lib/app-utils/child-contract.js +5 -2
- package/src/lib/app-utils/child-contract.js.map +1 -1
- package/src/lib/app-utils/console-routes.d.ts +5 -0
- package/src/lib/app-utils/console-routes.js +1 -0
- package/src/lib/app-utils/console-routes.js.map +1 -1
- package/src/lib/app-utils/content-schema-type.d.ts +9 -0
- package/src/lib/app-utils/content-schema-type.js +4 -0
- package/src/lib/app-utils/content-schema-type.js.map +1 -1
- package/src/lib/app-utils/crm.d.ts +30 -2
- package/src/lib/app-utils/crm.js +77 -15
- package/src/lib/app-utils/crm.js.map +1 -1
- package/src/lib/app-utils/docs-help.generated.d.ts +58 -10
- package/src/lib/app-utils/docs-help.generated.js +118 -4
- package/src/lib/app-utils/docs-help.generated.js.map +1 -1
- package/src/lib/app-utils/docs-index.generated.js +320 -19
- package/src/lib/app-utils/docs-index.generated.js.map +1 -1
- package/src/lib/app-utils/entry-list-declaration.d.ts +33 -0
- package/src/lib/app-utils/entry-list-declaration.js +172 -0
- package/src/lib/app-utils/entry-list-declaration.js.map +1 -0
- package/src/lib/app-utils/first-version-seed.d.ts +19 -0
- package/src/lib/app-utils/first-version-seed.js +53 -0
- package/src/lib/app-utils/first-version-seed.js.map +1 -0
- package/src/lib/app-utils/local-business.d.ts +8 -0
- package/src/lib/app-utils/local-business.js +4 -0
- package/src/lib/app-utils/local-business.js.map +1 -1
- package/src/lib/app-utils/lockdown.js +1 -1
- package/src/lib/app-utils/lockdown.js.map +1 -1
- package/src/lib/app-utils/message-search.js +5 -2
- package/src/lib/app-utils/message-search.js.map +1 -1
- package/src/lib/app-utils/name-search.js +15 -3
- package/src/lib/app-utils/name-search.js.map +1 -1
- package/src/lib/app-utils/page-markdown.js +3 -0
- package/src/lib/app-utils/page-markdown.js.map +1 -1
- package/src/lib/app-utils/screen-analytics-aggregate.d.ts +70 -0
- package/src/lib/app-utils/screen-analytics-aggregate.js +82 -0
- package/src/lib/app-utils/screen-analytics-aggregate.js.map +1 -0
- package/src/lib/app-utils/screen-kind.d.ts +25 -0
- package/src/lib/app-utils/screen-kind.js +25 -0
- package/src/lib/app-utils/screen-kind.js.map +1 -0
- package/src/lib/app-utils/screen-route.d.ts +1 -5
- package/src/lib/app-utils/screen-route.js +2 -4
- package/src/lib/app-utils/screen-route.js.map +1 -1
- package/src/lib/app-utils/site-journey-steps.d.ts +38 -0
- package/src/lib/app-utils/site-journey-steps.js +54 -0
- package/src/lib/app-utils/site-journey-steps.js.map +1 -0
- package/src/lib/app-utils/site-journey.d.ts +2 -22
- package/src/lib/app-utils/site-journey.js +2 -32
- package/src/lib/app-utils/site-journey.js.map +1 -1
- package/src/lib/plugin-manager/feature-plugins.d.ts +19 -0
- package/src/lib/plugin-manager/feature-plugins.js +8 -0
- package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
- package/src/lib/plugin-manager/first-party-plugins.generated.js +124 -2
- package/src/lib/plugin-manager/first-party-plugins.generated.js.map +1 -1
- package/src/lib/plugin-manager/plugin-channel-orders.d.ts +262 -0
- package/src/lib/plugin-manager/plugin-channel-orders.js +32 -0
- package/src/lib/plugin-manager/plugin-channel-orders.js.map +1 -0
- package/src/lib/plugin-manager/plugin-checkout-credits.d.ts +288 -0
- package/src/lib/plugin-manager/plugin-checkout-credits.js +196 -0
- package/src/lib/plugin-manager/plugin-checkout-credits.js.map +1 -0
- package/src/lib/plugin-manager/plugin-product-catalog.d.ts +5 -0
- package/src/lib/plugin-manager/plugin-product-catalog.js.map +1 -1
- package/src/lib/plugin-manager/plugin-product-writer.d.ts +166 -0
- package/src/lib/plugin-manager/plugin-product-writer.js +31 -0
- package/src/lib/plugin-manager/plugin-product-writer.js.map +1 -0
- package/src/lib/plugin-manager/plugin-shipping-rates.d.ts +10 -0
- package/src/lib/plugin-manager/plugin-shipping-rates.js.map +1 -1
- package/src/lib/plugin-manager/plugin-theme-presets.d.ts +40 -0
- package/src/lib/plugin-manager/plugin-theme-presets.js +44 -0
- package/src/lib/plugin-manager/plugin-theme-presets.js.map +1 -0
|
@@ -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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"}
|
|
@@ -193,6 +193,9 @@ import { SAFE_HREF_PATTERN, resolveScreenHref, splitLinkValue } from "./screen-l
|
|
|
193
193
|
return;
|
|
194
194
|
}
|
|
195
195
|
push(level > 0 ? `${'#'.repeat(level)} ${text}` : text);
|
|
196
|
+
// A Typography renders the elements it holds after its text
|
|
197
|
+
// (AGL-3672), so they read after it here too.
|
|
198
|
+
recurse();
|
|
196
199
|
return;
|
|
197
200
|
}
|
|
198
201
|
case 'markdown':
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/page-markdown.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 * A published page as Markdown — the representation an agent gets when it\n * sends `Accept: text/markdown` (acceptmarkdown.com).\n *\n * ## Why serialize the NODE TREE and not the rendered HTML\n *\n * The obvious implementation is to render the page and run an HTML-to-Markdown\n * converter over it. Every site on this platform is a node document, and the\n * tree carries what the HTML has already thrown away: which text is a heading\n * because the author said so rather than because it is large, which container\n * is the page's content region, and which subtree is navigation. Converting\n * the HTML back would be reconstructing, by heuristic, information we are\n * holding.\n *\n * It is also cheaper by a wide margin — no React render, no plugin registry,\n * no MUI — which is what makes this affordable to serve from a cached route on\n * every site rather than only where somebody enabled it.\n *\n * ## The content region, not the document\n *\n * Serialization starts at the document's `main` landmark, which\n * `stampDocumentLandmark` has already placed on the layout slot (the page\n * content between the chrome) or on the screen root. That is what makes the\n * output nav-free and footer-free without this module knowing anything about\n * how a particular site builds its header — the same property that makes a\n * Markdown variant worth serving at all.\n *\n * A page with no landmark falls back to the whole tree. Some content is always\n * better than an empty document, and a page that shipped no landmark\n * deliberately (see `document-landmark.ts`) has no content region to prefer.\n *\n * ## Chrome that lives INSIDE the content region\n *\n * A handful of components are navigation wherever they sit — an app bar, a nav\n * menu, a language switcher, a pagination strip. They are skipped by id, and\n * the list is deliberately short: everything not named recurses, so a plugin\n * block nobody here has heard of still contributes its text instead of\n * vanishing. Silence is the one failure mode that would be invisible to the\n * author.\n */\n\nimport { NODE_ROOT_ID } from '../canvas-manager/canvas-manager'\nimport { authorHtmlToMarkdown, inlineAuthorHtmlToMarkdown } from './author-html-markdown'\nimport { resolveMarkdownSourceLinks } from './markdown-lite'\nimport { absoluteMediaSrc } from './media-ref'\nimport {\n SAFE_HREF_PATTERN,\n resolveScreenHref,\n splitLinkValue,\n} from './screen-link-value'\n\n/** The node shape this serializer reads; keeps callers free of the full type. */\nexport interface PageMarkdownNode {\n componentId?: string\n props?: Record<string, unknown> | null\n /** Normalized documents carry child IDS; this never reads a denormalized tree. */\n nodes?: unknown\n}\n\n/** A flat node map keyed by node id — exactly what the page route already holds. */\nexport type PageMarkdownNodes = Record<string, PageMarkdownNode>\n\n/** The element name a node was told to render as (`props.component`). */\nconst elementOf = (node: PageMarkdownNode | undefined): string =>\n String(node?.props?.['component'] ?? '').toLowerCase()\n\n/** Components that are navigation wherever they appear. See the module note. */\nconst CHROME_COMPONENTS: ReadonlySet<string> = new Set([\n 'muiAppBar',\n 'muiToolbar',\n 'muiDrawer',\n 'muiBreadcrumbs',\n 'navMenu',\n 'searchBox',\n 'languageSwitcher',\n 'themeModeSwitcher',\n 'muiPagination',\n // An icon is decoration with no accessible text of its own. Its LABEL, where\n // one exists, belongs to the control around it and is emitted there.\n 'icon',\n])\n\n/**\n * Whether a node is navigation rather than content: a chrome component, or\n * an element its author declared `nav`, `header` or `footer`. Exported so a\n * reader of a page's CONTENT — the SEO audit's heading and image scan\n * (AGL-2910) — skips exactly what this serializer skips.\n */\nexport function isPageChromeNode(node: PageMarkdownNode | undefined): boolean {\n if (!node) return false\n if (CHROME_COMPONENTS.has(String(node.componentId ?? ''))) return true\n const element = elementOf(node)\n return element === 'nav' || element === 'header' || element === 'footer'\n}\n\n/** Heading levels, as either `props.component` or `props.variant` spells them. */\nconst HEADING = /^h([1-6])$/\n\n/**\n * The heading level a text node publishes, or 0 for body copy.\n *\n * `component` wins over `variant` because they answer different questions and\n * the platform lets them disagree: `variant` is the type STYLE (what size it is\n * drawn at) and `component` is the ELEMENT (what it means). An author who set a\n * hero line to look like an h1 but render as a `p` said it is not a heading,\n * and the Markdown has to agree with the HTML or the two documents describe\n * different pages.\n */\nexport function headingLevelOf(props: Record<string, unknown> | null | undefined): number {\n const element = String(props?.['component'] ?? '').toLowerCase()\n if (element) {\n const asElement = HEADING.exec(element)\n return asElement ? Number(asElement[1]) : 0\n }\n const variant = String(props?.['variant'] ?? '').toLowerCase()\n const asVariant = HEADING.exec(variant)\n return asVariant ? Number(asVariant[1]) : 0\n}\n\n/**\n * Escape the Markdown control characters that would otherwise re-parse.\n *\n * Only the characters that START a construct at the position they can occur in:\n * a leading `#`, `>`, `-`, `+` or `1.` on its own line, and the emphasis,\n * backtick and bracket characters anywhere. Escaping more than that produces\n * output littered with backslashes, which is worse to read and worse to embed\n * than the rare false heading it prevents.\n */\nexport function escapeMarkdownText(value: string): string {\n return value\n .replace(/([\\\\`*_[\\]<>])/g, '\\\\$1')\n .replace(/^(\\s*)([#>])/gm, '$1\\\\$2')\n .replace(/^(\\s*)([-+])(\\s)/gm, '$1\\\\$2$3')\n .replace(/^(\\s*)(\\d+)\\.(\\s)/gm, '$1$2\\\\.$3')\n}\n\n/** Collapse runs of whitespace to single spaces, as inline text renders. */\nconst collapse = (value: string): string => value.replace(/\\s+/g, ' ').trim()\n\n/** Resolution context for values that are stored as references, not URLs. */\nexport interface PageMarkdownContext {\n /** Site origin, for absolutizing media and site-relative links. */\n origin?: string | null\n /** Host id, so a restricted org asset resolves for the site rendering it. */\n hostId?: string\n /**\n * The routing map screen links resolve against (`screenRoutes`), and the\n * link references in authored markdown with them (AGL-3118).\n */\n screenRoutes?: Record<string, string> | null\n}\n\n/**\n * A link target as Markdown should carry it: absolute where we can make it so.\n *\n * Site-relative is fine in a page — the browser has a document to resolve\n * against. A Markdown body has no such context by the time an agent has copied\n * it into a prompt, so a relative href there is a link to nowhere.\n */\nfunction absoluteHref(href: string, context?: PageMarkdownContext): string {\n const value = href.trim()\n if (!value) return ''\n if (/^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith('//')) return value\n const origin = context?.origin?.replace(/\\/+$/, '') ?? ''\n if (!origin) return value\n return value.startsWith('/') ? `${origin}${value}` : `${origin}/${value}`\n}\n\n/** The text a control shows, preferring its rich label over the plain one. */\nfunction labelOf(\n props: Record<string, unknown> | null | undefined,\n context?: PageMarkdownContext,\n): string {\n const html = props?.['html']\n if (typeof html === 'string' && html.trim()) {\n const rich = inlineAuthorHtmlToMarkdown(html, context)\n if (rich.trim()) return collapse(rich)\n }\n const children = props?.['children']\n return typeof children === 'string' ? collapse(escapeMarkdownText(children)) : ''\n}\n\n/** A block emitted by the walk, before the blocks are joined. */\ninterface Block {\n /** Markdown text of the block, already escaped. */\n text: string\n}\n\n/**\n * Serialize the subtree at `rootId` into Markdown blocks.\n *\n * A depth budget and a visited set, because a node map is data: a document\n * restored from a backup, or written through the REST API, can name a child\n * that names it back. Recursing that tree is a stack overflow inside a request\n * handler, i.e. a 500 on a page that renders fine — the renderer tolerates it\n * because React unmounts a cycle, and this has nothing to stop it but itself.\n */\nfunction walk(\n nodes: PageMarkdownNodes,\n rootId: string,\n context: PageMarkdownContext | undefined,\n blocks: Block[],\n seen: Set<string>,\n depth: number,\n): void {\n if (depth > 64 || seen.has(rootId)) return\n const node = nodes[rootId]\n if (!node) return\n seen.add(rootId)\n\n const componentId = String(node.componentId ?? '')\n // Chrome components, and `nav`, `header` and `footer` by their OWN\n // declaration — an author who set the element picker to one of them named\n // it navigation, and that is a stronger signal than any component-id list\n // can be.\n if (isPageChromeNode(node)) return\n\n const props = node.props ?? {}\n const childIds = Array.isArray(node.nodes)\n ? node.nodes.filter((id): id is string => typeof id === 'string')\n : []\n const recurse = (): void => {\n for (const childId of childIds) {\n walk(nodes, childId, context, blocks, seen, depth + 1)\n }\n }\n const push = (text: string): void => {\n if (text.trim()) blocks.push({ text: text.trim() })\n }\n\n switch (componentId) {\n case 'muiTypography':\n case 'muiInlineText':\n case 'muiListItemText': {\n const level = headingLevelOf(props)\n const text = labelOf(props, context)\n if (!text) {\n // A Typography with no text of its own but children beneath it is a\n // wrapper, which is how rich composites are built. Recurse rather than\n // dropping the subtree.\n recurse()\n return\n }\n push(level > 0 ? `${'#'.repeat(level)} ${text}` : text)\n return\n }\n case 'markdown': {\n /*\n Already Markdown. Emitted VERBATIM and never re-escaped: `content` is\n the markdown-lite source an author typed, so escaping it would turn\n their headings into literal `#` characters — the one transformation\n that can only make this output worse. Its link REFERENCES are the one\n exception (AGL-3118), resolved as the page resolves them.\n */\n const content = props['content']\n if (typeof content === 'string') push(authoredMarkdown(content, context))\n return\n }\n case 'custom-html': {\n const html = props['html'] ?? props['children']\n if (typeof html === 'string') push(authorHtmlToMarkdown(html, context))\n return\n }\n case 'image': {\n const src = absoluteMediaSrc(String(props['src'] ?? '').trim(), {\n hostId: context?.hostId,\n origin: context?.origin,\n })\n if (!src) return\n const alt = collapse(String(props['alt'] ?? ''))\n push(``)\n return\n }\n case 'video':\n case 'videoEmbed': {\n const src = String(props['src'] ?? props['url'] ?? '').trim()\n if (!src) return\n push(`[Video](${absoluteHref(src, context)})`)\n return\n }\n case 'muiButton':\n case 'muiScreenLink': {\n const label = labelOf(props, context) || 'Link'\n const href = screenLinkHref(props, context)\n push(href ? `[${label}](${href})` : label)\n return\n }\n case 'muiAccordionSummary': {\n /*\n An accordion's question (AGL-3575). The summary carries its text in\n its own props, not in a child node, so the default recursion below\n emitted NOTHING for it — every FAQ on every site reached an agent as\n a run of orphaned answers. It is a heading because that is what the\n HTML publishes: MUI renders the summary inside its `h3` heading slot,\n and the Markdown has to agree with the page or the two describe\n different documents. A linked header (AGL-1232) keeps its link, as\n the page does.\n */\n const text = labelOf(props, context)\n if (!text) {\n recurse()\n return\n }\n const href =\n typeof props['screenId'] === 'string' && props['screenId']\n ? screenLinkHref(props, context)\n : ''\n push(href ? `### [${text}](${href})` : `### ${text}`)\n return\n }\n case 'muiList': {\n /*\n The items are gathered into ONE block so they join as a list. Walking\n them as siblings would put a blank line between every bullet, which is\n a sequence of paragraphs beginning with a dash rather than a list.\n */\n const items: Block[] = []\n for (const childId of childIds) {\n walk(nodes, childId, context, items, seen, depth + 1)\n }\n const lines = items\n .map((item) => item.text.replace(/\\n/g, '\\n '))\n .filter(Boolean)\n .map((text) => `- ${text}`)\n push(lines.join('\\n'))\n return\n }\n default:\n recurse()\n }\n}\n\n/**\n * Where a Button or Screen Link points.\n *\n * A screen link stores a SCREEN ID, not a path, and the path it resolves to is\n * a per-request question — `screenRoutes` is the routing map the router\n * actually honors, which is not the map publishing wrote (a collection's list\n * template answers at the collection's slug, template screens answer nowhere).\n * Emitting `href` alone would publish, in Markdown, the dead links AGL-1998\n * removed from the HTML.\n *\n * `splitLinkValue` + `resolveScreenHref` rather than a local reading of the\n * two props, because WHICH SLOT a screen reference arrives in is not fixed\n * (AGL-1335): a `Link`-typed component prop is bound to whichever of\n * `screenId`/`href` the author reached for, and a value in the `href` slot\n * carries the stored `screen:<id>` spelling. Reading `screenId` alone published\n * that token verbatim as a link target on every page built from a prop-fed\n * CTA — ten of them on aglyn.com, unfollowable by the agents `/llms.txt` sends\n * here (AGL-2740). These are the same two functions `useLinkTarget` resolves\n * the HTML with, so the two representations of a page cannot drift again.\n *\n * An authored screen id that the map has lost resolves to NO link, exactly as\n * the HTML renders it: falling back to the stored `href` there would re-publish\n * the dead path AGL-1998 removed, which is the failure this function's own\n * contract exists to prevent.\n */\nfunction screenLinkHref(\n props: Record<string, unknown>,\n context?: PageMarkdownContext,\n): string {\n const target = splitLinkValue(\n typeof props['screenId'] === 'string' ? props['screenId'] : undefined,\n typeof props['href'] === 'string' ? props['href'] : undefined,\n )\n if (target.screenId) {\n const path = resolveScreenHref(context?.screenRoutes ?? undefined, target.screenId)\n return path ? absoluteHref(path, context) : ''\n }\n // The same protocol guard the linking elements apply. A `javascript:` href\n // is inert in a Markdown body, but it is still a target an agent may be\n // asked to follow, and a representation that offers one the page refuses to\n // render is describing a different document.\n return target.href && SAFE_HREF_PATTERN.test(target.href)\n ? absoluteHref(target.href, context)\n : ''\n}\n\n/**\n * Authored markdown-lite — a Markdown element's content, an entry's body —\n * as this document carries it: verbatim, except that each link REFERENCE\n * (AGL-3118) points where the page's own link does, through the same routing\n * map and the same `resolveScreenHref` as {@link screenLinkHref}, made\n * absolute for the reason {@link absoluteHref} gives. A reference the map\n * does not have becomes the link's text, as the page renders it: the stored\n * `entry:…` is not a URL any reader can follow.\n */\nfunction authoredMarkdown(\n source: string,\n context?: PageMarkdownContext,\n): string {\n return resolveMarkdownSourceLinks(source, (reference) => {\n const path = resolveScreenHref(context?.screenRoutes ?? undefined, reference)\n return path ? absoluteHref(path, context) : undefined\n })\n}\n\n/**\n * The node the document's content begins at.\n *\n * The `main` landmark first — `stampDocumentLandmark` puts it on the layout\n * slot or the screen root — then the layout slot by component id, then the\n * document root. Each fallback is wider than the last, so a tree that is\n * missing the marker still serializes rather than returning nothing.\n */\nexport function pageContentRootId(\n nodes: PageMarkdownNodes | null | undefined,\n rootId: string = NODE_ROOT_ID,\n): string | null {\n if (!nodes) return null\n for (const id in nodes) {\n if (elementOf(nodes[id]) === 'main') return id\n }\n for (const id in nodes) {\n if (nodes[id]?.componentId === 'layoutSlot') return id\n }\n return nodes[rootId] ? rootId : null\n}\n\n/** What a Markdown page carries above its body. */\nexport interface PageMarkdownFrontMatter {\n /** The `# ` heading. The page's own title, never the site's. */\n title?: string\n /** The meta description, emitted as the summary blockquote. */\n description?: string\n /** Canonical absolute URL, emitted as the source line. */\n canonicalUrl?: string\n /** ISO-8601 publication date, for a content entry. */\n publishedAt?: string\n /** ISO-8601 last-modified date, for a content entry. */\n updatedAt?: string\n /** Byline, for a content entry. */\n author?: string\n}\n\n/**\n * The whole Markdown representation of a page.\n *\n * @param body - already-Markdown body to use INSTEAD of walking nodes; the\n * content-entry case, where the stored body is markdown-lite source and\n * round-tripping it through the node tree could only lose fidelity.\n */\nexport function buildPageMarkdown(options: {\n nodes?: PageMarkdownNodes | null\n rootId?: string\n body?: string | null\n front?: PageMarkdownFrontMatter\n context?: PageMarkdownContext\n}): string {\n const front = options.front ?? {}\n const parts: string[] = []\n\n const title = collapse(String(front.title ?? ''))\n if (title) parts.push(`# ${escapeMarkdownText(title)}`)\n\n const description = collapse(String(front.description ?? ''))\n if (description) parts.push(`> ${escapeMarkdownText(description)}`)\n\n /*\n The dateline is one line rather than YAML front matter. Front matter is\n unparsed text to every Markdown reader that has not opted into it, so it\n arrives in an agent's context as a block of `key: value` noise above the\n content; a sentence is read correctly by all of them.\n */\n const meta: string[] = []\n if (front.author) meta.push(`By ${collapse(front.author)}`)\n if (front.publishedAt) meta.push(`Published ${front.publishedAt}`)\n if (front.updatedAt && front.updatedAt !== front.publishedAt) {\n meta.push(`Updated ${front.updatedAt}`)\n }\n if (meta.length) parts.push(`_${meta.join(' · ')}_`)\n\n if (typeof options.body === 'string' && options.body.trim()) {\n parts.push(authoredMarkdown(options.body.trim(), options.context))\n } else if (options.nodes) {\n const contentRoot = pageContentRootId(options.nodes, options.rootId)\n if (contentRoot) {\n const blocks: Block[] = []\n walk(options.nodes, contentRoot, options.context, blocks, new Set(), 0)\n for (const block of blocks) parts.push(block.text)\n }\n }\n\n if (front.canonicalUrl) {\n // A source line, and the reason it is worth the two lines it costs: an\n // agent that has copied this body into a prompt has no URL bar, so without\n // it the document cannot be cited, re-fetched, or linked to by whatever\n // reads it next.\n parts.push('---', `Source: ${front.canonicalUrl}`)\n }\n\n /*\n Collapse the runs of blank lines a nested walk leaves behind, and end with\n exactly one newline. A Markdown body is compared byte-for-byte by caches\n and by the conformance checkers agents run, so \"tidy\" here is a contract\n rather than an aesthetic.\n */\n return `${parts.join('\\n\\n').replace(/\\n{3,}/g, '\\n\\n').trim()}\\n`\n}\n"],"names":["NODE_ROOT_ID","authorHtmlToMarkdown","inlineAuthorHtmlToMarkdown","resolveMarkdownSourceLinks","absoluteMediaSrc","SAFE_HREF_PATTERN","resolveScreenHref","splitLinkValue","elementOf","node","String","props","toLowerCase","CHROME_COMPONENTS","Set","isPageChromeNode","has","componentId","element","HEADING","headingLevelOf","asElement","exec","Number","variant","asVariant","escapeMarkdownText","value","replace","collapse","trim","absoluteHref","href","context","test","startsWith","origin","labelOf","html","rich","children","walk","nodes","rootId","blocks","seen","depth","add","childIds","Array","isArray","filter","id","recurse","childId","push","text","level","repeat","content","authoredMarkdown","src","hostId","alt","label","screenLinkHref","items","lines","map","item","Boolean","join","target","undefined","screenId","path","screenRoutes","source","reference","pageContentRootId","buildPageMarkdown","options","front","parts","title","description","meta","author","publishedAt","updatedAt","length","body","contentRoot","block","canonicalUrl"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuCC,GAED,SAASA,YAAY,QAAQ,sCAAkC;AAC/D,SAASC,oBAAoB,EAAEC,0BAA0B,QAAQ,4BAAwB;AACzF,SAASC,0BAA0B,QAAQ,qBAAiB;AAC5D,SAASC,gBAAgB,QAAQ,iBAAa;AAC9C,SACEC,iBAAiB,EACjBC,iBAAiB,EACjBC,cAAc,QACT,yBAAqB;AAa5B,uEAAuE,GACvE,MAAMC,YAAY,CAACC;;QACVA;WAAPC,eAAOD,yBAAAA,cAAAA,KAAME,KAAK,qBAAXF,WAAa,CAAC,YAAY,mBAAI,IAAIG,WAAW;;AAEtD,8EAA8E,GAC9E,MAAMC,oBAAyC,IAAIC,IAAI;IACrD;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA,6EAA6E;IAC7E,qEAAqE;IACrE;CACD;AAED;;;;;CAKC,GACD,OAAO,SAASC,iBAAiBN,IAAkC;QAEhCA;IADjC,IAAI,CAACA,MAAM,OAAO;IAClB,IAAII,kBAAkBG,GAAG,CAACN,QAAOD,oBAAAA,KAAKQ,WAAW,YAAhBR,oBAAoB,MAAM,OAAO;IAClE,MAAMS,UAAUV,UAAUC;IAC1B,OAAOS,YAAY,SAASA,YAAY,YAAYA,YAAY;AAClE;AAEA,gFAAgF,GAChF,MAAMC,UAAU;AAEhB;;;;;;;;;CASC,GACD,OAAO,SAASC,eAAeT,KAAiD;;IAC9E,MAAMO,UAAUR,eAAOC,yBAAAA,KAAO,CAAC,YAAY,mBAAI,IAAIC,WAAW;IAC9D,IAAIM,SAAS;QACX,MAAMG,YAAYF,QAAQG,IAAI,CAACJ;QAC/B,OAAOG,YAAYE,OAAOF,SAAS,CAAC,EAAE,IAAI;IAC5C;IACA,MAAMG,UAAUd,gBAAOC,yBAAAA,KAAO,CAAC,UAAU,oBAAI,IAAIC,WAAW;IAC5D,MAAMa,YAAYN,QAAQG,IAAI,CAACE;IAC/B,OAAOC,YAAYF,OAAOE,SAAS,CAAC,EAAE,IAAI;AAC5C;AAEA;;;;;;;;CAQC,GACD,OAAO,SAASC,mBAAmBC,KAAa;IAC9C,OAAOA,MACJC,OAAO,CAAC,mBAAmB,QAC3BA,OAAO,CAAC,kBAAkB,UAC1BA,OAAO,CAAC,sBAAsB,YAC9BA,OAAO,CAAC,uBAAuB;AACpC;AAEA,0EAA0E,GAC1E,MAAMC,WAAW,CAACF,QAA0BA,MAAMC,OAAO,CAAC,QAAQ,KAAKE,IAAI;AAe3E;;;;;;CAMC,GACD,SAASC,aAAaC,IAAY,EAAEC,OAA6B;;QAIhDA;IAHf,MAAMN,QAAQK,KAAKF,IAAI;IACvB,IAAI,CAACH,OAAO,OAAO;IACnB,IAAI,uBAAuBO,IAAI,CAACP,UAAUA,MAAMQ,UAAU,CAAC,OAAO,OAAOR;IACzE,MAAMS,iBAASH,4BAAAA,kBAAAA,QAASG,MAAM,qBAAfH,gBAAiBL,OAAO,CAAC,QAAQ,sBAAO;IACvD,IAAI,CAACQ,QAAQ,OAAOT;IACpB,OAAOA,MAAMQ,UAAU,CAAC,OAAO,GAAGC,SAAST,OAAO,GAAG,GAAGS,OAAO,CAAC,EAAET,OAAO;AAC3E;AAEA,4EAA4E,GAC5E,SAASU,QACP1B,KAAiD,EACjDsB,OAA6B;IAE7B,MAAMK,OAAO3B,yBAAAA,KAAO,CAAC,OAAO;IAC5B,IAAI,OAAO2B,SAAS,YAAYA,KAAKR,IAAI,IAAI;QAC3C,MAAMS,OAAOrC,2BAA2BoC,MAAML;QAC9C,IAAIM,KAAKT,IAAI,IAAI,OAAOD,SAASU;IACnC;IACA,MAAMC,WAAW7B,yBAAAA,KAAO,CAAC,WAAW;IACpC,OAAO,OAAO6B,aAAa,WAAWX,SAASH,mBAAmBc,aAAa;AACjF;AAQA;;;;;;;;CAQC,GACD,SAASC,KACPC,KAAwB,EACxBC,MAAc,EACdV,OAAwC,EACxCW,MAAe,EACfC,IAAiB,EACjBC,KAAa;QAOcrC,mBAObA;IAZd,IAAIqC,QAAQ,MAAMD,KAAK7B,GAAG,CAAC2B,SAAS;IACpC,MAAMlC,OAAOiC,KAAK,CAACC,OAAO;IAC1B,IAAI,CAAClC,MAAM;IACXoC,KAAKE,GAAG,CAACJ;IAET,MAAM1B,cAAcP,QAAOD,oBAAAA,KAAKQ,WAAW,YAAhBR,oBAAoB;IAC/C,mEAAmE;IACnE,0EAA0E;IAC1E,0EAA0E;IAC1E,UAAU;IACV,IAAIM,iBAAiBN,OAAO;IAE5B,MAAME,SAAQF,cAAAA,KAAKE,KAAK,YAAVF,cAAc,CAAC;IAC7B,MAAMuC,WAAWC,MAAMC,OAAO,CAACzC,KAAKiC,KAAK,IACrCjC,KAAKiC,KAAK,CAACS,MAAM,CAAC,CAACC,KAAqB,OAAOA,OAAO,YACtD,EAAE;IACN,MAAMC,UAAU;QACd,KAAK,MAAMC,WAAWN,SAAU;YAC9BP,KAAKC,OAAOY,SAASrB,SAASW,QAAQC,MAAMC,QAAQ;QACtD;IACF;IACA,MAAMS,OAAO,CAACC;QACZ,IAAIA,KAAK1B,IAAI,IAAIc,OAAOW,IAAI,CAAC;YAAEC,MAAMA,KAAK1B,IAAI;QAAG;IACnD;IAEA,OAAQb;QACN,KAAK;QACL,KAAK;QACL,KAAK;YAAmB;gBACtB,MAAMwC,QAAQrC,eAAeT;gBAC7B,MAAM6C,OAAOnB,QAAQ1B,OAAOsB;gBAC5B,IAAI,CAACuB,MAAM;oBACT,oEAAoE;oBACpE,uEAAuE;oBACvE,wBAAwB;oBACxBH;oBACA;gBACF;gBACAE,KAAKE,QAAQ,IAAI,GAAG,IAAIC,MAAM,CAACD,OAAO,CAAC,EAAED,MAAM,GAAGA;gBAClD;YACF;QACA,KAAK;YAAY;gBACf;;;;;;MAMA,GACA,MAAMG,UAAUhD,KAAK,CAAC,UAAU;gBAChC,IAAI,OAAOgD,YAAY,UAAUJ,KAAKK,iBAAiBD,SAAS1B;gBAChE;YACF;QACA,KAAK;YAAe;oBACLtB;gBAAb,MAAM2B,QAAO3B,cAAAA,KAAK,CAAC,OAAO,YAAbA,cAAiBA,KAAK,CAAC,WAAW;gBAC/C,IAAI,OAAO2B,SAAS,UAAUiB,KAAKtD,qBAAqBqC,MAAML;gBAC9D;YACF;QACA,KAAK;YAAS;oBACwBtB,YAKRA;gBAL5B,MAAMkD,MAAMzD,iBAAiBM,QAAOC,aAAAA,KAAK,CAAC,MAAM,YAAZA,aAAgB,IAAImB,IAAI,IAAI;oBAC9DgC,MAAM,EAAE7B,2BAAAA,QAAS6B,MAAM;oBACvB1B,MAAM,EAAEH,2BAAAA,QAASG,MAAM;gBACzB;gBACA,IAAI,CAACyB,KAAK;gBACV,MAAME,MAAMlC,SAASnB,QAAOC,aAAAA,KAAK,CAAC,MAAM,YAAZA,aAAgB;gBAC5C4C,KAAK,CAAC,EAAE,EAAE7B,mBAAmBqC,KAAK,EAAE,EAAEF,IAAI,CAAC,CAAC;gBAC5C;YACF;QACA,KAAK;QACL,KAAK;YAAc;oBACElD,MAAAA;gBAAnB,MAAMkD,MAAMnD,QAAOC,QAAAA,cAAAA,KAAK,CAAC,MAAM,YAAZA,cAAgBA,KAAK,CAAC,MAAM,YAA5BA,OAAgC,IAAImB,IAAI;gBAC3D,IAAI,CAAC+B,KAAK;gBACVN,KAAK,CAAC,QAAQ,EAAExB,aAAa8B,KAAK5B,SAAS,CAAC,CAAC;gBAC7C;YACF;QACA,KAAK;QACL,KAAK;YAAiB;gBACpB,MAAM+B,QAAQ3B,QAAQ1B,OAAOsB,YAAY;gBACzC,MAAMD,OAAOiC,eAAetD,OAAOsB;gBACnCsB,KAAKvB,OAAO,CAAC,CAAC,EAAEgC,MAAM,EAAE,EAAEhC,KAAK,CAAC,CAAC,GAAGgC;gBACpC;YACF;QACA,KAAK;YAAuB;gBAC1B;;;;;;;;;MASA,GACA,MAAMR,OAAOnB,QAAQ1B,OAAOsB;gBAC5B,IAAI,CAACuB,MAAM;oBACTH;oBACA;gBACF;gBACA,MAAMrB,OACJ,OAAOrB,KAAK,CAAC,WAAW,KAAK,YAAYA,KAAK,CAAC,WAAW,GACtDsD,eAAetD,OAAOsB,WACtB;gBACNsB,KAAKvB,OAAO,CAAC,KAAK,EAAEwB,KAAK,EAAE,EAAExB,KAAK,CAAC,CAAC,GAAG,CAAC,IAAI,EAAEwB,MAAM;gBACpD;YACF;QACA,KAAK;YAAW;gBACd;;;;MAIA,GACA,MAAMU,QAAiB,EAAE;gBACzB,KAAK,MAAMZ,WAAWN,SAAU;oBAC9BP,KAAKC,OAAOY,SAASrB,SAASiC,OAAOrB,MAAMC,QAAQ;gBACrD;gBACA,MAAMqB,QAAQD,MACXE,GAAG,CAAC,CAACC,OAASA,KAAKb,IAAI,CAAC5B,OAAO,CAAC,OAAO,SACvCuB,MAAM,CAACmB,SACPF,GAAG,CAAC,CAACZ,OAAS,CAAC,EAAE,EAAEA,MAAM;gBAC5BD,KAAKY,MAAMI,IAAI,CAAC;gBAChB;YACF;QACA;YACElB;IACJ;AACF;AAEA;;;;;;;;;;;;;;;;;;;;;;;;CAwBC,GACD,SAASY,eACPtD,KAA8B,EAC9BsB,OAA6B;IAE7B,MAAMuC,SAASjE,eACb,OAAOI,KAAK,CAAC,WAAW,KAAK,WAAWA,KAAK,CAAC,WAAW,GAAG8D,WAC5D,OAAO9D,KAAK,CAAC,OAAO,KAAK,WAAWA,KAAK,CAAC,OAAO,GAAG8D;IAEtD,IAAID,OAAOE,QAAQ,EAAE;;QACnB,MAAMC,OAAOrE,0BAAkB2B,2BAAAA,QAAS2C,YAAY,mBAAIH,WAAWD,OAAOE,QAAQ;QAClF,OAAOC,OAAO5C,aAAa4C,MAAM1C,WAAW;IAC9C;IACA,2EAA2E;IAC3E,wEAAwE;IACxE,4EAA4E;IAC5E,6CAA6C;IAC7C,OAAOuC,OAAOxC,IAAI,IAAI3B,kBAAkB6B,IAAI,CAACsC,OAAOxC,IAAI,IACpDD,aAAayC,OAAOxC,IAAI,EAAEC,WAC1B;AACN;AAEA;;;;;;;;CAQC,GACD,SAAS2B,iBACPiB,MAAc,EACd5C,OAA6B;IAE7B,OAAO9B,2BAA2B0E,QAAQ,CAACC;;QACzC,MAAMH,OAAOrE,0BAAkB2B,2BAAAA,QAAS2C,YAAY,mBAAIH,WAAWK;QACnE,OAAOH,OAAO5C,aAAa4C,MAAM1C,WAAWwC;IAC9C;AACF;AAEA;;;;;;;CAOC,GACD,OAAO,SAASM,kBACdrC,KAA2C,EAC3CC,SAAiB3C,YAAY;IAE7B,IAAI,CAAC0C,OAAO,OAAO;IACnB,IAAK,MAAMU,MAAMV,MAAO;QACtB,IAAIlC,UAAUkC,KAAK,CAACU,GAAG,MAAM,QAAQ,OAAOA;IAC9C;IACA,IAAK,MAAMA,MAAMV,MAAO;YAClBA;QAAJ,IAAIA,EAAAA,YAAAA,KAAK,CAACU,GAAG,qBAATV,UAAWzB,WAAW,MAAK,cAAc,OAAOmC;IACtD;IACA,OAAOV,KAAK,CAACC,OAAO,GAAGA,SAAS;AAClC;AAkBA;;;;;;CAMC,GACD,OAAO,SAASqC,kBAAkBC,OAMjC;QACeA,gBAGgBC,cAGMA;IANpC,MAAMA,SAAQD,iBAAAA,QAAQC,KAAK,YAAbD,iBAAiB,CAAC;IAChC,MAAME,QAAkB,EAAE;IAE1B,MAAMC,QAAQvD,SAASnB,QAAOwE,eAAAA,MAAME,KAAK,YAAXF,eAAe;IAC7C,IAAIE,OAAOD,MAAM5B,IAAI,CAAC,CAAC,EAAE,EAAE7B,mBAAmB0D,QAAQ;IAEtD,MAAMC,cAAcxD,SAASnB,QAAOwE,qBAAAA,MAAMG,WAAW,YAAjBH,qBAAqB;IACzD,IAAIG,aAAaF,MAAM5B,IAAI,CAAC,CAAC,EAAE,EAAE7B,mBAAmB2D,cAAc;IAElE;;;;;EAKA,GACA,MAAMC,OAAiB,EAAE;IACzB,IAAIJ,MAAMK,MAAM,EAAED,KAAK/B,IAAI,CAAC,CAAC,GAAG,EAAE1B,SAASqD,MAAMK,MAAM,GAAG;IAC1D,IAAIL,MAAMM,WAAW,EAAEF,KAAK/B,IAAI,CAAC,CAAC,UAAU,EAAE2B,MAAMM,WAAW,EAAE;IACjE,IAAIN,MAAMO,SAAS,IAAIP,MAAMO,SAAS,KAAKP,MAAMM,WAAW,EAAE;QAC5DF,KAAK/B,IAAI,CAAC,CAAC,QAAQ,EAAE2B,MAAMO,SAAS,EAAE;IACxC;IACA,IAAIH,KAAKI,MAAM,EAAEP,MAAM5B,IAAI,CAAC,CAAC,CAAC,EAAE+B,KAAKf,IAAI,CAAC,OAAO,CAAC,CAAC;IAEnD,IAAI,OAAOU,QAAQU,IAAI,KAAK,YAAYV,QAAQU,IAAI,CAAC7D,IAAI,IAAI;QAC3DqD,MAAM5B,IAAI,CAACK,iBAAiBqB,QAAQU,IAAI,CAAC7D,IAAI,IAAImD,QAAQhD,OAAO;IAClE,OAAO,IAAIgD,QAAQvC,KAAK,EAAE;QACxB,MAAMkD,cAAcb,kBAAkBE,QAAQvC,KAAK,EAAEuC,QAAQtC,MAAM;QACnE,IAAIiD,aAAa;YACf,MAAMhD,SAAkB,EAAE;YAC1BH,KAAKwC,QAAQvC,KAAK,EAAEkD,aAAaX,QAAQhD,OAAO,EAAEW,QAAQ,IAAI9B,OAAO;YACrE,KAAK,MAAM+E,SAASjD,OAAQuC,MAAM5B,IAAI,CAACsC,MAAMrC,IAAI;QACnD;IACF;IAEA,IAAI0B,MAAMY,YAAY,EAAE;QACtB,uEAAuE;QACvE,2EAA2E;QAC3E,wEAAwE;QACxE,iBAAiB;QACjBX,MAAM5B,IAAI,CAAC,OAAO,CAAC,QAAQ,EAAE2B,MAAMY,YAAY,EAAE;IACnD;IAEA;;;;;EAKA,GACA,OAAO,GAAGX,MAAMZ,IAAI,CAAC,QAAQ3C,OAAO,CAAC,WAAW,QAAQE,IAAI,GAAG,EAAE,CAAC;AACpE"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/page-markdown.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 * A published page as Markdown — the representation an agent gets when it\n * sends `Accept: text/markdown` (acceptmarkdown.com).\n *\n * ## Why serialize the NODE TREE and not the rendered HTML\n *\n * The obvious implementation is to render the page and run an HTML-to-Markdown\n * converter over it. Every site on this platform is a node document, and the\n * tree carries what the HTML has already thrown away: which text is a heading\n * because the author said so rather than because it is large, which container\n * is the page's content region, and which subtree is navigation. Converting\n * the HTML back would be reconstructing, by heuristic, information we are\n * holding.\n *\n * It is also cheaper by a wide margin — no React render, no plugin registry,\n * no MUI — which is what makes this affordable to serve from a cached route on\n * every site rather than only where somebody enabled it.\n *\n * ## The content region, not the document\n *\n * Serialization starts at the document's `main` landmark, which\n * `stampDocumentLandmark` has already placed on the layout slot (the page\n * content between the chrome) or on the screen root. That is what makes the\n * output nav-free and footer-free without this module knowing anything about\n * how a particular site builds its header — the same property that makes a\n * Markdown variant worth serving at all.\n *\n * A page with no landmark falls back to the whole tree. Some content is always\n * better than an empty document, and a page that shipped no landmark\n * deliberately (see `document-landmark.ts`) has no content region to prefer.\n *\n * ## Chrome that lives INSIDE the content region\n *\n * A handful of components are navigation wherever they sit — an app bar, a nav\n * menu, a language switcher, a pagination strip. They are skipped by id, and\n * the list is deliberately short: everything not named recurses, so a plugin\n * block nobody here has heard of still contributes its text instead of\n * vanishing. Silence is the one failure mode that would be invisible to the\n * author.\n */\n\nimport { NODE_ROOT_ID } from '../canvas-manager/canvas-manager'\nimport { authorHtmlToMarkdown, inlineAuthorHtmlToMarkdown } from './author-html-markdown'\nimport { resolveMarkdownSourceLinks } from './markdown-lite'\nimport { absoluteMediaSrc } from './media-ref'\nimport {\n SAFE_HREF_PATTERN,\n resolveScreenHref,\n splitLinkValue,\n} from './screen-link-value'\n\n/** The node shape this serializer reads; keeps callers free of the full type. */\nexport interface PageMarkdownNode {\n componentId?: string\n props?: Record<string, unknown> | null\n /** Normalized documents carry child IDS; this never reads a denormalized tree. */\n nodes?: unknown\n}\n\n/** A flat node map keyed by node id — exactly what the page route already holds. */\nexport type PageMarkdownNodes = Record<string, PageMarkdownNode>\n\n/** The element name a node was told to render as (`props.component`). */\nconst elementOf = (node: PageMarkdownNode | undefined): string =>\n String(node?.props?.['component'] ?? '').toLowerCase()\n\n/** Components that are navigation wherever they appear. See the module note. */\nconst CHROME_COMPONENTS: ReadonlySet<string> = new Set([\n 'muiAppBar',\n 'muiToolbar',\n 'muiDrawer',\n 'muiBreadcrumbs',\n 'navMenu',\n 'searchBox',\n 'languageSwitcher',\n 'themeModeSwitcher',\n 'muiPagination',\n // An icon is decoration with no accessible text of its own. Its LABEL, where\n // one exists, belongs to the control around it and is emitted there.\n 'icon',\n])\n\n/**\n * Whether a node is navigation rather than content: a chrome component, or\n * an element its author declared `nav`, `header` or `footer`. Exported so a\n * reader of a page's CONTENT — the SEO audit's heading and image scan\n * (AGL-2910) — skips exactly what this serializer skips.\n */\nexport function isPageChromeNode(node: PageMarkdownNode | undefined): boolean {\n if (!node) return false\n if (CHROME_COMPONENTS.has(String(node.componentId ?? ''))) return true\n const element = elementOf(node)\n return element === 'nav' || element === 'header' || element === 'footer'\n}\n\n/** Heading levels, as either `props.component` or `props.variant` spells them. */\nconst HEADING = /^h([1-6])$/\n\n/**\n * The heading level a text node publishes, or 0 for body copy.\n *\n * `component` wins over `variant` because they answer different questions and\n * the platform lets them disagree: `variant` is the type STYLE (what size it is\n * drawn at) and `component` is the ELEMENT (what it means). An author who set a\n * hero line to look like an h1 but render as a `p` said it is not a heading,\n * and the Markdown has to agree with the HTML or the two documents describe\n * different pages.\n */\nexport function headingLevelOf(props: Record<string, unknown> | null | undefined): number {\n const element = String(props?.['component'] ?? '').toLowerCase()\n if (element) {\n const asElement = HEADING.exec(element)\n return asElement ? Number(asElement[1]) : 0\n }\n const variant = String(props?.['variant'] ?? '').toLowerCase()\n const asVariant = HEADING.exec(variant)\n return asVariant ? Number(asVariant[1]) : 0\n}\n\n/**\n * Escape the Markdown control characters that would otherwise re-parse.\n *\n * Only the characters that START a construct at the position they can occur in:\n * a leading `#`, `>`, `-`, `+` or `1.` on its own line, and the emphasis,\n * backtick and bracket characters anywhere. Escaping more than that produces\n * output littered with backslashes, which is worse to read and worse to embed\n * than the rare false heading it prevents.\n */\nexport function escapeMarkdownText(value: string): string {\n return value\n .replace(/([\\\\`*_[\\]<>])/g, '\\\\$1')\n .replace(/^(\\s*)([#>])/gm, '$1\\\\$2')\n .replace(/^(\\s*)([-+])(\\s)/gm, '$1\\\\$2$3')\n .replace(/^(\\s*)(\\d+)\\.(\\s)/gm, '$1$2\\\\.$3')\n}\n\n/** Collapse runs of whitespace to single spaces, as inline text renders. */\nconst collapse = (value: string): string => value.replace(/\\s+/g, ' ').trim()\n\n/** Resolution context for values that are stored as references, not URLs. */\nexport interface PageMarkdownContext {\n /** Site origin, for absolutizing media and site-relative links. */\n origin?: string | null\n /** Host id, so a restricted org asset resolves for the site rendering it. */\n hostId?: string\n /**\n * The routing map screen links resolve against (`screenRoutes`), and the\n * link references in authored markdown with them (AGL-3118).\n */\n screenRoutes?: Record<string, string> | null\n}\n\n/**\n * A link target as Markdown should carry it: absolute where we can make it so.\n *\n * Site-relative is fine in a page — the browser has a document to resolve\n * against. A Markdown body has no such context by the time an agent has copied\n * it into a prompt, so a relative href there is a link to nowhere.\n */\nfunction absoluteHref(href: string, context?: PageMarkdownContext): string {\n const value = href.trim()\n if (!value) return ''\n if (/^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith('//')) return value\n const origin = context?.origin?.replace(/\\/+$/, '') ?? ''\n if (!origin) return value\n return value.startsWith('/') ? `${origin}${value}` : `${origin}/${value}`\n}\n\n/** The text a control shows, preferring its rich label over the plain one. */\nfunction labelOf(\n props: Record<string, unknown> | null | undefined,\n context?: PageMarkdownContext,\n): string {\n const html = props?.['html']\n if (typeof html === 'string' && html.trim()) {\n const rich = inlineAuthorHtmlToMarkdown(html, context)\n if (rich.trim()) return collapse(rich)\n }\n const children = props?.['children']\n return typeof children === 'string' ? collapse(escapeMarkdownText(children)) : ''\n}\n\n/** A block emitted by the walk, before the blocks are joined. */\ninterface Block {\n /** Markdown text of the block, already escaped. */\n text: string\n}\n\n/**\n * Serialize the subtree at `rootId` into Markdown blocks.\n *\n * A depth budget and a visited set, because a node map is data: a document\n * restored from a backup, or written through the REST API, can name a child\n * that names it back. Recursing that tree is a stack overflow inside a request\n * handler, i.e. a 500 on a page that renders fine — the renderer tolerates it\n * because React unmounts a cycle, and this has nothing to stop it but itself.\n */\nfunction walk(\n nodes: PageMarkdownNodes,\n rootId: string,\n context: PageMarkdownContext | undefined,\n blocks: Block[],\n seen: Set<string>,\n depth: number,\n): void {\n if (depth > 64 || seen.has(rootId)) return\n const node = nodes[rootId]\n if (!node) return\n seen.add(rootId)\n\n const componentId = String(node.componentId ?? '')\n // Chrome components, and `nav`, `header` and `footer` by their OWN\n // declaration — an author who set the element picker to one of them named\n // it navigation, and that is a stronger signal than any component-id list\n // can be.\n if (isPageChromeNode(node)) return\n\n const props = node.props ?? {}\n const childIds = Array.isArray(node.nodes)\n ? node.nodes.filter((id): id is string => typeof id === 'string')\n : []\n const recurse = (): void => {\n for (const childId of childIds) {\n walk(nodes, childId, context, blocks, seen, depth + 1)\n }\n }\n const push = (text: string): void => {\n if (text.trim()) blocks.push({ text: text.trim() })\n }\n\n switch (componentId) {\n case 'muiTypography':\n case 'muiInlineText':\n case 'muiListItemText': {\n const level = headingLevelOf(props)\n const text = labelOf(props, context)\n if (!text) {\n // A Typography with no text of its own but children beneath it is a\n // wrapper, which is how rich composites are built. Recurse rather than\n // dropping the subtree.\n recurse()\n return\n }\n push(level > 0 ? `${'#'.repeat(level)} ${text}` : text)\n // A Typography renders the elements it holds after its text\n // (AGL-3672), so they read after it here too.\n recurse()\n return\n }\n case 'markdown': {\n /*\n Already Markdown. Emitted VERBATIM and never re-escaped: `content` is\n the markdown-lite source an author typed, so escaping it would turn\n their headings into literal `#` characters — the one transformation\n that can only make this output worse. Its link REFERENCES are the one\n exception (AGL-3118), resolved as the page resolves them.\n */\n const content = props['content']\n if (typeof content === 'string') push(authoredMarkdown(content, context))\n return\n }\n case 'custom-html': {\n const html = props['html'] ?? props['children']\n if (typeof html === 'string') push(authorHtmlToMarkdown(html, context))\n return\n }\n case 'image': {\n const src = absoluteMediaSrc(String(props['src'] ?? '').trim(), {\n hostId: context?.hostId,\n origin: context?.origin,\n })\n if (!src) return\n const alt = collapse(String(props['alt'] ?? ''))\n push(``)\n return\n }\n case 'video':\n case 'videoEmbed': {\n const src = String(props['src'] ?? props['url'] ?? '').trim()\n if (!src) return\n push(`[Video](${absoluteHref(src, context)})`)\n return\n }\n case 'muiButton':\n case 'muiScreenLink': {\n const label = labelOf(props, context) || 'Link'\n const href = screenLinkHref(props, context)\n push(href ? `[${label}](${href})` : label)\n return\n }\n case 'muiAccordionSummary': {\n /*\n An accordion's question (AGL-3575). The summary carries its text in\n its own props, not in a child node, so the default recursion below\n emitted NOTHING for it — every FAQ on every site reached an agent as\n a run of orphaned answers. It is a heading because that is what the\n HTML publishes: MUI renders the summary inside its `h3` heading slot,\n and the Markdown has to agree with the page or the two describe\n different documents. A linked header (AGL-1232) keeps its link, as\n the page does.\n */\n const text = labelOf(props, context)\n if (!text) {\n recurse()\n return\n }\n const href =\n typeof props['screenId'] === 'string' && props['screenId']\n ? screenLinkHref(props, context)\n : ''\n push(href ? `### [${text}](${href})` : `### ${text}`)\n return\n }\n case 'muiList': {\n /*\n The items are gathered into ONE block so they join as a list. Walking\n them as siblings would put a blank line between every bullet, which is\n a sequence of paragraphs beginning with a dash rather than a list.\n */\n const items: Block[] = []\n for (const childId of childIds) {\n walk(nodes, childId, context, items, seen, depth + 1)\n }\n const lines = items\n .map((item) => item.text.replace(/\\n/g, '\\n '))\n .filter(Boolean)\n .map((text) => `- ${text}`)\n push(lines.join('\\n'))\n return\n }\n default:\n recurse()\n }\n}\n\n/**\n * Where a Button or Screen Link points.\n *\n * A screen link stores a SCREEN ID, not a path, and the path it resolves to is\n * a per-request question — `screenRoutes` is the routing map the router\n * actually honors, which is not the map publishing wrote (a collection's list\n * template answers at the collection's slug, template screens answer nowhere).\n * Emitting `href` alone would publish, in Markdown, the dead links AGL-1998\n * removed from the HTML.\n *\n * `splitLinkValue` + `resolveScreenHref` rather than a local reading of the\n * two props, because WHICH SLOT a screen reference arrives in is not fixed\n * (AGL-1335): a `Link`-typed component prop is bound to whichever of\n * `screenId`/`href` the author reached for, and a value in the `href` slot\n * carries the stored `screen:<id>` spelling. Reading `screenId` alone published\n * that token verbatim as a link target on every page built from a prop-fed\n * CTA — ten of them on aglyn.com, unfollowable by the agents `/llms.txt` sends\n * here (AGL-2740). These are the same two functions `useLinkTarget` resolves\n * the HTML with, so the two representations of a page cannot drift again.\n *\n * An authored screen id that the map has lost resolves to NO link, exactly as\n * the HTML renders it: falling back to the stored `href` there would re-publish\n * the dead path AGL-1998 removed, which is the failure this function's own\n * contract exists to prevent.\n */\nfunction screenLinkHref(\n props: Record<string, unknown>,\n context?: PageMarkdownContext,\n): string {\n const target = splitLinkValue(\n typeof props['screenId'] === 'string' ? props['screenId'] : undefined,\n typeof props['href'] === 'string' ? props['href'] : undefined,\n )\n if (target.screenId) {\n const path = resolveScreenHref(context?.screenRoutes ?? undefined, target.screenId)\n return path ? absoluteHref(path, context) : ''\n }\n // The same protocol guard the linking elements apply. A `javascript:` href\n // is inert in a Markdown body, but it is still a target an agent may be\n // asked to follow, and a representation that offers one the page refuses to\n // render is describing a different document.\n return target.href && SAFE_HREF_PATTERN.test(target.href)\n ? absoluteHref(target.href, context)\n : ''\n}\n\n/**\n * Authored markdown-lite — a Markdown element's content, an entry's body —\n * as this document carries it: verbatim, except that each link REFERENCE\n * (AGL-3118) points where the page's own link does, through the same routing\n * map and the same `resolveScreenHref` as {@link screenLinkHref}, made\n * absolute for the reason {@link absoluteHref} gives. A reference the map\n * does not have becomes the link's text, as the page renders it: the stored\n * `entry:…` is not a URL any reader can follow.\n */\nfunction authoredMarkdown(\n source: string,\n context?: PageMarkdownContext,\n): string {\n return resolveMarkdownSourceLinks(source, (reference) => {\n const path = resolveScreenHref(context?.screenRoutes ?? undefined, reference)\n return path ? absoluteHref(path, context) : undefined\n })\n}\n\n/**\n * The node the document's content begins at.\n *\n * The `main` landmark first — `stampDocumentLandmark` puts it on the layout\n * slot or the screen root — then the layout slot by component id, then the\n * document root. Each fallback is wider than the last, so a tree that is\n * missing the marker still serializes rather than returning nothing.\n */\nexport function pageContentRootId(\n nodes: PageMarkdownNodes | null | undefined,\n rootId: string = NODE_ROOT_ID,\n): string | null {\n if (!nodes) return null\n for (const id in nodes) {\n if (elementOf(nodes[id]) === 'main') return id\n }\n for (const id in nodes) {\n if (nodes[id]?.componentId === 'layoutSlot') return id\n }\n return nodes[rootId] ? rootId : null\n}\n\n/** What a Markdown page carries above its body. */\nexport interface PageMarkdownFrontMatter {\n /** The `# ` heading. The page's own title, never the site's. */\n title?: string\n /** The meta description, emitted as the summary blockquote. */\n description?: string\n /** Canonical absolute URL, emitted as the source line. */\n canonicalUrl?: string\n /** ISO-8601 publication date, for a content entry. */\n publishedAt?: string\n /** ISO-8601 last-modified date, for a content entry. */\n updatedAt?: string\n /** Byline, for a content entry. */\n author?: string\n}\n\n/**\n * The whole Markdown representation of a page.\n *\n * @param body - already-Markdown body to use INSTEAD of walking nodes; the\n * content-entry case, where the stored body is markdown-lite source and\n * round-tripping it through the node tree could only lose fidelity.\n */\nexport function buildPageMarkdown(options: {\n nodes?: PageMarkdownNodes | null\n rootId?: string\n body?: string | null\n front?: PageMarkdownFrontMatter\n context?: PageMarkdownContext\n}): string {\n const front = options.front ?? {}\n const parts: string[] = []\n\n const title = collapse(String(front.title ?? ''))\n if (title) parts.push(`# ${escapeMarkdownText(title)}`)\n\n const description = collapse(String(front.description ?? ''))\n if (description) parts.push(`> ${escapeMarkdownText(description)}`)\n\n /*\n The dateline is one line rather than YAML front matter. Front matter is\n unparsed text to every Markdown reader that has not opted into it, so it\n arrives in an agent's context as a block of `key: value` noise above the\n content; a sentence is read correctly by all of them.\n */\n const meta: string[] = []\n if (front.author) meta.push(`By ${collapse(front.author)}`)\n if (front.publishedAt) meta.push(`Published ${front.publishedAt}`)\n if (front.updatedAt && front.updatedAt !== front.publishedAt) {\n meta.push(`Updated ${front.updatedAt}`)\n }\n if (meta.length) parts.push(`_${meta.join(' · ')}_`)\n\n if (typeof options.body === 'string' && options.body.trim()) {\n parts.push(authoredMarkdown(options.body.trim(), options.context))\n } else if (options.nodes) {\n const contentRoot = pageContentRootId(options.nodes, options.rootId)\n if (contentRoot) {\n const blocks: Block[] = []\n walk(options.nodes, contentRoot, options.context, blocks, new Set(), 0)\n for (const block of blocks) parts.push(block.text)\n }\n }\n\n if (front.canonicalUrl) {\n // A source line, and the reason it is worth the two lines it costs: an\n // agent that has copied this body into a prompt has no URL bar, so without\n // it the document cannot be cited, re-fetched, or linked to by whatever\n // reads it next.\n parts.push('---', `Source: ${front.canonicalUrl}`)\n }\n\n /*\n Collapse the runs of blank lines a nested walk leaves behind, and end with\n exactly one newline. A Markdown body is compared byte-for-byte by caches\n and by the conformance checkers agents run, so \"tidy\" here is a contract\n rather than an aesthetic.\n */\n return `${parts.join('\\n\\n').replace(/\\n{3,}/g, '\\n\\n').trim()}\\n`\n}\n"],"names":["NODE_ROOT_ID","authorHtmlToMarkdown","inlineAuthorHtmlToMarkdown","resolveMarkdownSourceLinks","absoluteMediaSrc","SAFE_HREF_PATTERN","resolveScreenHref","splitLinkValue","elementOf","node","String","props","toLowerCase","CHROME_COMPONENTS","Set","isPageChromeNode","has","componentId","element","HEADING","headingLevelOf","asElement","exec","Number","variant","asVariant","escapeMarkdownText","value","replace","collapse","trim","absoluteHref","href","context","test","startsWith","origin","labelOf","html","rich","children","walk","nodes","rootId","blocks","seen","depth","add","childIds","Array","isArray","filter","id","recurse","childId","push","text","level","repeat","content","authoredMarkdown","src","hostId","alt","label","screenLinkHref","items","lines","map","item","Boolean","join","target","undefined","screenId","path","screenRoutes","source","reference","pageContentRootId","buildPageMarkdown","options","front","parts","title","description","meta","author","publishedAt","updatedAt","length","body","contentRoot","block","canonicalUrl"],"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.
|