@aglyn/aglyn 1.0.0-beta.246 → 1.0.0-beta.249

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/package.json +11 -11
  2. package/src/lib/app-utils/contact-custom-fields.d.ts +23 -1
  3. package/src/lib/app-utils/contact-custom-fields.js +84 -2
  4. package/src/lib/app-utils/contact-custom-fields.js.map +1 -1
  5. package/src/lib/app-utils/crm.d.ts +7 -1
  6. package/src/lib/app-utils/crm.js.map +1 -1
  7. package/src/lib/app-utils/default-site.js +21 -33
  8. package/src/lib/app-utils/default-site.js.map +1 -1
  9. package/src/lib/app-utils/docs-help-sections.generated.js +8 -8
  10. package/src/lib/app-utils/docs-help-sections.generated.js.map +1 -1
  11. package/src/lib/app-utils/docs-help.generated.d.ts +8 -8
  12. package/src/lib/app-utils/docs-help.generated.js +9 -5
  13. package/src/lib/app-utils/docs-help.generated.js.map +1 -1
  14. package/src/lib/app-utils/docs-index.generated.js +129 -59
  15. package/src/lib/app-utils/docs-index.generated.js.map +1 -1
  16. package/src/lib/app-utils/element-pick-protocol.d.ts +54 -0
  17. package/src/lib/app-utils/element-pick-protocol.js +41 -0
  18. package/src/lib/app-utils/element-pick-protocol.js.map +1 -0
  19. package/src/lib/app-utils/element-pick-target.d.ts +32 -0
  20. package/src/lib/app-utils/element-pick-target.js +47 -0
  21. package/src/lib/app-utils/element-pick-target.js.map +1 -0
  22. package/src/lib/app-utils/error-beacon.d.ts +19 -0
  23. package/src/lib/app-utils/error-beacon.js +31 -5
  24. package/src/lib/app-utils/error-beacon.js.map +1 -1
  25. package/src/lib/app-utils/form-contract.js +13 -6
  26. package/src/lib/app-utils/form-contract.js.map +1 -1
  27. package/src/lib/app-utils/forms.d.ts +31 -0
  28. package/src/lib/app-utils/forms.js +213 -16
  29. package/src/lib/app-utils/forms.js.map +1 -1
  30. package/src/lib/app-utils/node-definition-sanitizer.js +12 -0
  31. package/src/lib/app-utils/node-definition-sanitizer.js.map +1 -1
  32. package/src/lib/app-utils/seo-locale.d.ts +14 -0
  33. package/src/lib/app-utils/seo-locale.js +15 -0
  34. package/src/lib/app-utils/seo-locale.js.map +1 -1
  35. package/src/lib/app-utils/seo-site-scan.js +18 -3
  36. package/src/lib/app-utils/seo-site-scan.js.map +1 -1
  37. package/src/lib/app-utils/site-live-url.d.ts +24 -0
  38. package/src/lib/app-utils/site-live-url.js +60 -0
  39. package/src/lib/app-utils/site-live-url.js.map +1 -0
  40. package/src/lib/app-utils/starter-template-nodes.d.ts +13 -0
  41. package/src/lib/app-utils/starter-template-nodes.js +40 -0
  42. package/src/lib/app-utils/starter-template-nodes.js.map +1 -1
  43. package/src/lib/app-utils/starter-templates.d.ts +1 -1
  44. package/src/lib/app-utils/starter-templates.js +19 -31
  45. package/src/lib/app-utils/starter-templates.js.map +1 -1
  46. package/src/lib/plugin-manager/feature-plugins.d.ts +74 -3
  47. package/src/lib/plugin-manager/feature-plugins.js +9 -0
  48. package/src/lib/plugin-manager/feature-plugins.js.map +1 -1
  49. package/src/lib/plugin-manager/plugin-ai-capabilities.d.ts +11 -0
  50. package/src/lib/plugin-manager/plugin-ai-capabilities.js.map +1 -1
  51. package/src/lib/plugin-manager/plugin-resource-drafts.d.ts +23 -0
  52. package/src/lib/plugin-manager/plugin-resource-drafts.js.map +1 -1
  53. package/src/lib/plugin-manager/stock-photo-provider.d.ts +67 -0
  54. package/src/lib/plugin-manager/stock-photo-provider.js +14 -1
  55. package/src/lib/plugin-manager/stock-photo-provider.js.map +1 -1
@@ -72,6 +72,20 @@ export declare const LOCALE_X_DEFAULT = "x-default";
72
72
  * BCP-47 casing so two spellings of one locale cannot read as two locales.
73
73
  */
74
74
  export declare function normalizeLocaleTag(value: string | null | undefined): string | undefined;
75
+ /**
76
+ * The pattern the console's Languages card holds one site language to: a
77
+ * two-letter language and at most one subtag, a region (`pt-BR`) or a script
78
+ * (`zh-Hant`). Narrower than `normalizeLocaleTag`, which also reads
79
+ * three-letter languages, numeric regions and script-plus-region tags.
80
+ */
81
+ export declare const SITE_LANGUAGE_CODE_PATTERN: RegExp;
82
+ /**
83
+ * A site language code as the Languages card would store it, or `null`:
84
+ * `normalizeLocaleTag`'s canonical casing (`_` read as `-`, surrounding space
85
+ * dropped), held to `SITE_LANGUAGE_CODE_PATTERN`. Anything that is not a
86
+ * string, or not a code the card would accept, is `null`.
87
+ */
88
+ export declare function siteLanguageCode(value: unknown): string | null;
75
89
  /** The screen and host fields the language of a page is decided from. */
76
90
  export interface PageLocaleSources {
77
91
  /** The screen being rendered — its `locale` is the most specific answer. */
@@ -81,6 +81,21 @@
81
81
  region ? region.toUpperCase() : undefined
82
82
  ].filter(Boolean).join('-');
83
83
  }
84
+ /**
85
+ * The pattern the console's Languages card holds one site language to: a
86
+ * two-letter language and at most one subtag, a region (`pt-BR`) or a script
87
+ * (`zh-Hant`). Narrower than `normalizeLocaleTag`, which also reads
88
+ * three-letter languages, numeric regions and script-plus-region tags.
89
+ */ export const SITE_LANGUAGE_CODE_PATTERN = /^[a-z]{2}(-[A-Za-z]{2,4})?$/;
90
+ /**
91
+ * A site language code as the Languages card would store it, or `null`:
92
+ * `normalizeLocaleTag`'s canonical casing (`_` read as `-`, surrounding space
93
+ * dropped), held to `SITE_LANGUAGE_CODE_PATTERN`. Anything that is not a
94
+ * string, or not a code the card would accept, is `null`.
95
+ */ export function siteLanguageCode(value) {
96
+ const tag = normalizeLocaleTag(typeof value === 'string' ? value : undefined);
97
+ return tag && SITE_LANGUAGE_CODE_PATTERN.test(tag) ? tag : null;
98
+ }
84
99
  /**
85
100
  * The language THIS page is written in.
86
101
  *
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/seo-locale.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 * WHAT LANGUAGE A PAGE IS IN, answered once (AGL-3148).\n *\n * Three surfaces need this answer and none of them had it: `og:locale` and\n * `og:locale:alternate` were absent from every page type on the platform,\n * and `inLanguage` was absent from every JSON-LD node. The fourth surface,\n * `<link rel=\"alternate\" hreflang>`, has had its own copy of the fallback\n * since AGL-164.\n *\n * They must agree. A page that declares `en` in its markup, `fr` in its Open\n * Graph and nothing in its structured data has told three readers three\n * things, and the one that matters — which language a searcher should be\n * served — is the one they disagree about. So the chain lives here and every\n * surface reads it.\n */\n\n/**\n * The language a page is in when nothing on the site says otherwise.\n *\n * NOT a guess: it is the language the document actually declares. The\n * `lang` attribute is no longer the literal `\"en\"` it was when this constant\n * was written — `apps/tenant/app/[host]/layout.tsx` resolves it through\n * `resolvePageLocale` and the two host-agnostic boundaries beside the root\n * layout emit THIS value (AGL-3153) — so the coupling now runs the honest\n * way round: the markup takes its answer from here rather than this constant\n * restating a literal somewhere else.\n *\n * That is what keeps `og:locale` safe to emit unconditionally. A page derived\n * from this chain can only ever repeat what the same chain put in the\n * document's own `lang`.\n *\n * ⚠️ Still PINNED to that attribute, in the direction that is now true: this\n * resolver is its SOURCE. A surface that needs a fallback language reads this\n * constant; it never writes a second default of its own, and changing this\n * value changes what every untranslated site declares.\n *\n * ⚠️ `<html lang>` is the SITE's language, not the screen's. A layout is the\n * deepest thing that can render `<html>`, and no layout has the slug the\n * screen is resolved from, so a locale variant's own language reaches the\n * metadata surfaces below — which call this resolver WITH a screen — and not\n * the document element. `apps/tenant/app/[host]/layout.tsx` records why\n * closing that gap would cost the catch-all's ISR window.\n */\nexport const PLATFORM_DEFAULT_LOCALE = 'en'\n\n/** The hreflang key for \"no locale of its own\", as `alternates.languages` spells it. */\nexport const LOCALE_X_DEFAULT = 'x-default'\n\n/**\n * A language tag, or nothing.\n *\n * Validated rather than trusted: these values reach a `<meta>` and a JSON-LD\n * document, and a locale field is free text in Firestore. An unparseable one\n * is dropped so the chain falls through to the next candidate — a page with a\n * corrupt locale emits the site's language, never the corruption.\n *\n * Accepts `language[-Script][-REGION]` — `en`, `en-GB`, `zh-Hant`,\n * `zh-Hant-TW`, `es-419` — in any casing, and returns it in the canonical\n * BCP-47 casing so two spellings of one locale cannot read as two locales.\n */\nexport function normalizeLocaleTag(\n value: string | null | undefined,\n): string | undefined {\n if (typeof value !== 'string') return undefined\n // `_` is the Open Graph separator and `-` the BCP-47 one; a stored value may\n // legitimately arrive in either, and both name the same locale.\n const raw = value.trim().replace(/_/g, '-')\n if (!raw) return undefined\n const match =\n /^([A-Za-z]{2,3})(?:-([A-Za-z]{4}))?(?:-([A-Za-z]{2}|[0-9]{3}))?$/.exec(raw)\n if (!match) return undefined\n const [, language, script, region] = match\n return [\n language.toLowerCase(),\n script ? script[0].toUpperCase() + script.slice(1).toLowerCase() : undefined,\n region ? region.toUpperCase() : undefined,\n ]\n .filter(Boolean)\n .join('-')\n}\n\n/** The screen and host fields the language of a page is decided from. */\nexport interface PageLocaleSources {\n /** The screen being rendered — its `locale` is the most specific answer. */\n screen?: { locale?: string } | null\n /** The host document — `defaultLocale`, then the first of `locales`. */\n host?: { defaultLocale?: string; locales?: string[] } | null\n}\n\n/**\n * The language THIS page is written in.\n *\n * Most specific first: the screen says what it was written in, the site says\n * what it defaults to, and the platform default answers for the sites — today\n * every one of them — that have configured neither.\n *\n * Never returns `undefined`. A page is always in some language, and a reader\n * that finds no answer assumes one anyway; saying which one out loud is\n * strictly more information than leaving it to be guessed.\n */\nexport function resolvePageLocale(sources: PageLocaleSources): string {\n return (\n normalizeLocaleTag(sources.screen?.locale) ??\n normalizeLocaleTag(sources.host?.defaultLocale) ??\n normalizeLocaleTag(sources.host?.locales?.[0]) ??\n PLATFORM_DEFAULT_LOCALE\n )\n}\n\n/**\n * A language tag in the form Open Graph spells it — `en_GB`, not `en-GB`.\n *\n * The one transformation, kept beside the normalizer so the two forms cannot\n * drift: `alternates.languages` keys stay BCP-47 because that is what\n * `hreflang` takes, and `og:locale` takes the underscore form.\n */\nexport function toOpenGraphLocale(\n value: string | null | undefined,\n): string | undefined {\n const tag = normalizeLocaleTag(value)\n return tag ? tag.replace(/-/g, '_') : undefined\n}\n\n/**\n * The OTHER languages this page exists in, as `og:locale:alternate` values.\n *\n * Derived from the very keys that drive `alternates.languages`, deliberately,\n * rather than from the site's configured `locales`: a language reaches that\n * map only once its translated screen resolves to a real routed path, so\n * every value returned here has a page behind it. A site that lists `fr`\n * among its locales but has translated nothing advertises no French card.\n *\n * `x-default` is dropped — it is a routing instruction, not a language — and\n * so is the page's own locale, which `og:locale` already carries.\n */\nexport function openGraphAlternateLocales(\n languageKeys: readonly string[] | null | undefined,\n pageLocale?: string,\n): string[] {\n const own = toOpenGraphLocale(pageLocale)\n const alternates = new Set<string>()\n for (const key of languageKeys ?? []) {\n if (key === LOCALE_X_DEFAULT) continue\n const tag = toOpenGraphLocale(key)\n if (tag && tag !== own) alternates.add(tag)\n }\n return [...alternates]\n}\n"],"names":["PLATFORM_DEFAULT_LOCALE","LOCALE_X_DEFAULT","normalizeLocaleTag","value","undefined","raw","trim","replace","match","exec","language","script","region","toLowerCase","toUpperCase","slice","filter","Boolean","join","resolvePageLocale","sources","screen","locale","host","defaultLocale","locales","toOpenGraphLocale","tag","openGraphAlternateLocales","languageKeys","pageLocale","own","alternates","Set","key","add"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;CAcC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,MAAMA,0BAA0B,KAAI;AAE3C,sFAAsF,GACtF,OAAO,MAAMC,mBAAmB,YAAW;AAE3C;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,mBACdC,KAAgC;IAEhC,IAAI,OAAOA,UAAU,UAAU,OAAOC;IACtC,6EAA6E;IAC7E,gEAAgE;IAChE,MAAMC,MAAMF,MAAMG,IAAI,GAAGC,OAAO,CAAC,MAAM;IACvC,IAAI,CAACF,KAAK,OAAOD;IACjB,MAAMI,QACJ,mEAAmEC,IAAI,CAACJ;IAC1E,IAAI,CAACG,OAAO,OAAOJ;IACnB,MAAM,GAAGM,UAAUC,QAAQC,OAAO,GAAGJ;IACrC,OAAO;QACLE,SAASG,WAAW;QACpBF,SAASA,MAAM,CAAC,EAAE,CAACG,WAAW,KAAKH,OAAOI,KAAK,CAAC,GAAGF,WAAW,KAAKT;QACnEQ,SAASA,OAAOE,WAAW,KAAKV;KACjC,CACEY,MAAM,CAACC,SACPC,IAAI,CAAC;AACV;AAUA;;;;;;;;;;CAUC,GACD,OAAO,SAASC,kBAAkBC,OAA0B;QAExDlB,MAAAA,OAAAA;QAAmBkB,iBACAA,eACAA,uBAAAA;IAHrB,QACElB,QAAAA,SAAAA,sBAAAA,oBAAmBkB,kBAAAA,QAAQC,MAAM,qBAAdD,gBAAgBE,MAAM,aAAzCpB,sBACAA,oBAAmBkB,gBAAAA,QAAQG,IAAI,qBAAZH,cAAcI,aAAa,aAD9CtB,QAEAA,oBAAmBkB,iBAAAA,QAAQG,IAAI,sBAAZH,wBAAAA,eAAcK,OAAO,qBAArBL,qBAAuB,CAAC,EAAE,aAF7ClB,OAGAF;AAEJ;AAEA;;;;;;CAMC,GACD,OAAO,SAAS0B,kBACdvB,KAAgC;IAEhC,MAAMwB,MAAMzB,mBAAmBC;IAC/B,OAAOwB,MAAMA,IAAIpB,OAAO,CAAC,MAAM,OAAOH;AACxC;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,SAASwB,0BACdC,YAAkD,EAClDC,UAAmB;IAEnB,MAAMC,MAAML,kBAAkBI;IAC9B,MAAME,aAAa,IAAIC;IACvB,KAAK,MAAMC,OAAOL,uBAAAA,eAAgB,EAAE,CAAE;QACpC,IAAIK,QAAQjC,kBAAkB;QAC9B,MAAM0B,MAAMD,kBAAkBQ;QAC9B,IAAIP,OAAOA,QAAQI,KAAKC,WAAWG,GAAG,CAACR;IACzC;IACA,OAAO;WAAIK;KAAW;AACxB"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/seo-locale.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 * WHAT LANGUAGE A PAGE IS IN, answered once (AGL-3148).\n *\n * Three surfaces need this answer and none of them had it: `og:locale` and\n * `og:locale:alternate` were absent from every page type on the platform,\n * and `inLanguage` was absent from every JSON-LD node. The fourth surface,\n * `<link rel=\"alternate\" hreflang>`, has had its own copy of the fallback\n * since AGL-164.\n *\n * They must agree. A page that declares `en` in its markup, `fr` in its Open\n * Graph and nothing in its structured data has told three readers three\n * things, and the one that matters — which language a searcher should be\n * served — is the one they disagree about. So the chain lives here and every\n * surface reads it.\n */\n\n/**\n * The language a page is in when nothing on the site says otherwise.\n *\n * NOT a guess: it is the language the document actually declares. The\n * `lang` attribute is no longer the literal `\"en\"` it was when this constant\n * was written — `apps/tenant/app/[host]/layout.tsx` resolves it through\n * `resolvePageLocale` and the two host-agnostic boundaries beside the root\n * layout emit THIS value (AGL-3153) — so the coupling now runs the honest\n * way round: the markup takes its answer from here rather than this constant\n * restating a literal somewhere else.\n *\n * That is what keeps `og:locale` safe to emit unconditionally. A page derived\n * from this chain can only ever repeat what the same chain put in the\n * document's own `lang`.\n *\n * ⚠️ Still PINNED to that attribute, in the direction that is now true: this\n * resolver is its SOURCE. A surface that needs a fallback language reads this\n * constant; it never writes a second default of its own, and changing this\n * value changes what every untranslated site declares.\n *\n * ⚠️ `<html lang>` is the SITE's language, not the screen's. A layout is the\n * deepest thing that can render `<html>`, and no layout has the slug the\n * screen is resolved from, so a locale variant's own language reaches the\n * metadata surfaces below — which call this resolver WITH a screen — and not\n * the document element. `apps/tenant/app/[host]/layout.tsx` records why\n * closing that gap would cost the catch-all's ISR window.\n */\nexport const PLATFORM_DEFAULT_LOCALE = 'en'\n\n/** The hreflang key for \"no locale of its own\", as `alternates.languages` spells it. */\nexport const LOCALE_X_DEFAULT = 'x-default'\n\n/**\n * A language tag, or nothing.\n *\n * Validated rather than trusted: these values reach a `<meta>` and a JSON-LD\n * document, and a locale field is free text in Firestore. An unparseable one\n * is dropped so the chain falls through to the next candidate — a page with a\n * corrupt locale emits the site's language, never the corruption.\n *\n * Accepts `language[-Script][-REGION]` — `en`, `en-GB`, `zh-Hant`,\n * `zh-Hant-TW`, `es-419` — in any casing, and returns it in the canonical\n * BCP-47 casing so two spellings of one locale cannot read as two locales.\n */\nexport function normalizeLocaleTag(\n value: string | null | undefined,\n): string | undefined {\n if (typeof value !== 'string') return undefined\n // `_` is the Open Graph separator and `-` the BCP-47 one; a stored value may\n // legitimately arrive in either, and both name the same locale.\n const raw = value.trim().replace(/_/g, '-')\n if (!raw) return undefined\n const match =\n /^([A-Za-z]{2,3})(?:-([A-Za-z]{4}))?(?:-([A-Za-z]{2}|[0-9]{3}))?$/.exec(raw)\n if (!match) return undefined\n const [, language, script, region] = match\n return [\n language.toLowerCase(),\n script ? script[0].toUpperCase() + script.slice(1).toLowerCase() : undefined,\n region ? region.toUpperCase() : undefined,\n ]\n .filter(Boolean)\n .join('-')\n}\n\n/**\n * The pattern the console's Languages card holds one site language to: a\n * two-letter language and at most one subtag, a region (`pt-BR`) or a script\n * (`zh-Hant`). Narrower than `normalizeLocaleTag`, which also reads\n * three-letter languages, numeric regions and script-plus-region tags.\n */\nexport const SITE_LANGUAGE_CODE_PATTERN = /^[a-z]{2}(-[A-Za-z]{2,4})?$/\n\n/**\n * A site language code as the Languages card would store it, or `null`:\n * `normalizeLocaleTag`'s canonical casing (`_` read as `-`, surrounding space\n * dropped), held to `SITE_LANGUAGE_CODE_PATTERN`. Anything that is not a\n * string, or not a code the card would accept, is `null`.\n */\nexport function siteLanguageCode(value: unknown): string | null {\n const tag = normalizeLocaleTag(typeof value === 'string' ? value : undefined)\n return tag && SITE_LANGUAGE_CODE_PATTERN.test(tag) ? tag : null\n}\n\n/** The screen and host fields the language of a page is decided from. */\nexport interface PageLocaleSources {\n /** The screen being rendered — its `locale` is the most specific answer. */\n screen?: { locale?: string } | null\n /** The host document — `defaultLocale`, then the first of `locales`. */\n host?: { defaultLocale?: string; locales?: string[] } | null\n}\n\n/**\n * The language THIS page is written in.\n *\n * Most specific first: the screen says what it was written in, the site says\n * what it defaults to, and the platform default answers for the sites — today\n * every one of them — that have configured neither.\n *\n * Never returns `undefined`. A page is always in some language, and a reader\n * that finds no answer assumes one anyway; saying which one out loud is\n * strictly more information than leaving it to be guessed.\n */\nexport function resolvePageLocale(sources: PageLocaleSources): string {\n return (\n normalizeLocaleTag(sources.screen?.locale) ??\n normalizeLocaleTag(sources.host?.defaultLocale) ??\n normalizeLocaleTag(sources.host?.locales?.[0]) ??\n PLATFORM_DEFAULT_LOCALE\n )\n}\n\n/**\n * A language tag in the form Open Graph spells it — `en_GB`, not `en-GB`.\n *\n * The one transformation, kept beside the normalizer so the two forms cannot\n * drift: `alternates.languages` keys stay BCP-47 because that is what\n * `hreflang` takes, and `og:locale` takes the underscore form.\n */\nexport function toOpenGraphLocale(\n value: string | null | undefined,\n): string | undefined {\n const tag = normalizeLocaleTag(value)\n return tag ? tag.replace(/-/g, '_') : undefined\n}\n\n/**\n * The OTHER languages this page exists in, as `og:locale:alternate` values.\n *\n * Derived from the very keys that drive `alternates.languages`, deliberately,\n * rather than from the site's configured `locales`: a language reaches that\n * map only once its translated screen resolves to a real routed path, so\n * every value returned here has a page behind it. A site that lists `fr`\n * among its locales but has translated nothing advertises no French card.\n *\n * `x-default` is dropped — it is a routing instruction, not a language — and\n * so is the page's own locale, which `og:locale` already carries.\n */\nexport function openGraphAlternateLocales(\n languageKeys: readonly string[] | null | undefined,\n pageLocale?: string,\n): string[] {\n const own = toOpenGraphLocale(pageLocale)\n const alternates = new Set<string>()\n for (const key of languageKeys ?? []) {\n if (key === LOCALE_X_DEFAULT) continue\n const tag = toOpenGraphLocale(key)\n if (tag && tag !== own) alternates.add(tag)\n }\n return [...alternates]\n}\n"],"names":["PLATFORM_DEFAULT_LOCALE","LOCALE_X_DEFAULT","normalizeLocaleTag","value","undefined","raw","trim","replace","match","exec","language","script","region","toLowerCase","toUpperCase","slice","filter","Boolean","join","SITE_LANGUAGE_CODE_PATTERN","siteLanguageCode","tag","test","resolvePageLocale","sources","screen","locale","host","defaultLocale","locales","toOpenGraphLocale","openGraphAlternateLocales","languageKeys","pageLocale","own","alternates","Set","key","add"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;CAcC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GACD,OAAO,MAAMA,0BAA0B,KAAI;AAE3C,sFAAsF,GACtF,OAAO,MAAMC,mBAAmB,YAAW;AAE3C;;;;;;;;;;;CAWC,GACD,OAAO,SAASC,mBACdC,KAAgC;IAEhC,IAAI,OAAOA,UAAU,UAAU,OAAOC;IACtC,6EAA6E;IAC7E,gEAAgE;IAChE,MAAMC,MAAMF,MAAMG,IAAI,GAAGC,OAAO,CAAC,MAAM;IACvC,IAAI,CAACF,KAAK,OAAOD;IACjB,MAAMI,QACJ,mEAAmEC,IAAI,CAACJ;IAC1E,IAAI,CAACG,OAAO,OAAOJ;IACnB,MAAM,GAAGM,UAAUC,QAAQC,OAAO,GAAGJ;IACrC,OAAO;QACLE,SAASG,WAAW;QACpBF,SAASA,MAAM,CAAC,EAAE,CAACG,WAAW,KAAKH,OAAOI,KAAK,CAAC,GAAGF,WAAW,KAAKT;QACnEQ,SAASA,OAAOE,WAAW,KAAKV;KACjC,CACEY,MAAM,CAACC,SACPC,IAAI,CAAC;AACV;AAEA;;;;;CAKC,GACD,OAAO,MAAMC,6BAA6B,8BAA6B;AAEvE;;;;;CAKC,GACD,OAAO,SAASC,iBAAiBjB,KAAc;IAC7C,MAAMkB,MAAMnB,mBAAmB,OAAOC,UAAU,WAAWA,QAAQC;IACnE,OAAOiB,OAAOF,2BAA2BG,IAAI,CAACD,OAAOA,MAAM;AAC7D;AAUA;;;;;;;;;;CAUC,GACD,OAAO,SAASE,kBAAkBC,OAA0B;QAExDtB,MAAAA,OAAAA;QAAmBsB,iBACAA,eACAA,uBAAAA;IAHrB,QACEtB,QAAAA,SAAAA,sBAAAA,oBAAmBsB,kBAAAA,QAAQC,MAAM,qBAAdD,gBAAgBE,MAAM,aAAzCxB,sBACAA,oBAAmBsB,gBAAAA,QAAQG,IAAI,qBAAZH,cAAcI,aAAa,aAD9C1B,QAEAA,oBAAmBsB,iBAAAA,QAAQG,IAAI,sBAAZH,wBAAAA,eAAcK,OAAO,qBAArBL,qBAAuB,CAAC,EAAE,aAF7CtB,OAGAF;AAEJ;AAEA;;;;;;CAMC,GACD,OAAO,SAAS8B,kBACd3B,KAAgC;IAEhC,MAAMkB,MAAMnB,mBAAmBC;IAC/B,OAAOkB,MAAMA,IAAId,OAAO,CAAC,MAAM,OAAOH;AACxC;AAEA;;;;;;;;;;;CAWC,GACD,OAAO,SAAS2B,0BACdC,YAAkD,EAClDC,UAAmB;IAEnB,MAAMC,MAAMJ,kBAAkBG;IAC9B,MAAME,aAAa,IAAIC;IACvB,KAAK,MAAMC,OAAOL,uBAAAA,eAAgB,EAAE,CAAE;QACpC,IAAIK,QAAQpC,kBAAkB;QAC9B,MAAMoB,MAAMS,kBAAkBO;QAC9B,IAAIhB,OAAOA,QAAQa,KAAKC,WAAWG,GAAG,CAACjB;IACzC;IACA,OAAO;WAAIc;KAAW;AACxB"}
@@ -141,6 +141,12 @@ async function inChunks(items, run) {
141
141
  await Promise.all(items.slice(start, start + READ_CHUNK).map(run));
142
142
  }
143
143
  }
144
+ /** How many shown by name before the rest are counted. */ const UNLISTED_NAMES_SHOWN = 5;
145
+ /** The note naming published pages a visibility setting keeps out of search. */ function unlistedNote(names) {
146
+ const shown = names.slice(0, UNLISTED_NAMES_SHOWN).join(', ');
147
+ const more = names.length > UNLISTED_NAMES_SHOWN ? ` and ${names.length - UNLISTED_NAMES_SHOWN} more` : '';
148
+ return names.length === 1 ? `1 published page is kept out of search and was not checked: ${shown}. Set its Visibility to Public to list it.` : `${names.length} published pages are kept out of search and were not checked: ${shown}${more}. Set a page’s Visibility to Public to list it.`;
149
+ }
144
150
  /**
145
151
  * Read `hostId`'s published pages for the check.
146
152
  *
@@ -169,10 +175,16 @@ async function inChunks(items, run) {
169
175
  ...statusPageScreenIds(host),
170
176
  ...(_options_templateScreenIds = options.templateScreenIds) != null ? _options_templateScreenIds : []
171
177
  ]);
172
- const published = Object.entries(routing).filter(([screenId])=>!excluded.has(screenId)).filter(([screenId])=>{
178
+ const routed = Object.entries(routing).filter(([screenId])=>{
173
179
  const screen = screens.get(screenId);
174
- return Boolean(screen) && !(screen == null ? void 0 : screen.deletedAt) && isScreenIndexable(screen);
175
- }).map(([screenId, route])=>({
180
+ return !excluded.has(screenId) && Boolean(screen) && !(screen == null ? void 0 : screen.deletedAt);
181
+ });
182
+ // Published but kept out of search: what a launch checks it did not leave on.
183
+ const unlisted = routed.filter(([screenId])=>!isScreenIndexable(screens.get(screenId))).map(([screenId, route])=>{
184
+ var _screens_get;
185
+ return str((_screens_get = screens.get(screenId)) == null ? void 0 : _screens_get.displayName) || screenRoutePathToUrl(String(route));
186
+ }).sort();
187
+ const published = routed.filter(([screenId])=>isScreenIndexable(screens.get(screenId))).map(([screenId, route])=>({
176
188
  screenId,
177
189
  path: screenRoutePathToUrl(String(route))
178
190
  })).sort((a, b)=>a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
@@ -215,6 +227,9 @@ async function inChunks(items, run) {
215
227
  ...Object.entries(keywordLines.unchecked).filter(([path])=>checkedPaths.has(path)).map(([path, unchecked])=>`Only the first ${SEO_MAX_KEYWORDS} keywords for ${path} were checked, so ${seoQuotedKeywords(unchecked)} ${unchecked.length === 1 ? 'was' : 'were'} not.`),
216
228
  ...rowsUnread ? [
217
229
  'Lists that repeat over your datasets were checked without their rows, which could not be read just now.'
230
+ ] : [],
231
+ ...unlisted.length ? [
232
+ unlistedNote(unlisted)
218
233
  ] : []
219
234
  ];
220
235
  const siteName = str((_host_seo = host.seo) == null ? void 0 : _host_seo.title) || str(host.displayName);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/seo-site-scan.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { screenRoutePathToUrl } from './screen-route'\nimport { nodesReferenceScreen } from './screen-link-value'\nimport {\n isScreenIndexable,\n isSearchDiscouraged,\n statusPageScreenIds,\n type SearchIndexingScreen,\n} from './search-indexing'\nimport {\n SEO_AUDIT_MAX_PAGES,\n readSeoKeywordLines,\n seoNormalizePath,\n type SeoAuditPage,\n type SeoAuditSite,\n} from './seo-audit'\nimport { SEO_MAX_KEYWORDS, seoQuotedKeywords } from './seo-keywords'\nimport { seoPageFacts } from './seo-page-facts'\nimport type { AglynNodeSchema } from '../foundation'\nimport { expandRepeatables, repeatKeys, type RepeatableDataset } from './expand-repeatables'\nimport {\n composeReferencedComponents,\n hostComponentReader,\n type ReadComponentDocument,\n type SkippedComponent,\n type StoredComponentDocument,\n} from './load-referenced-components'\nimport type { PageMarkdownNodes } from './page-markdown'\nimport { resolveSeoTitleVariables } from './seo-title-variables'\nimport { decodeStoredNodes } from './stored-nodes'\n\n/**\n * Read a site for the SEO check (`seo-audit.ts`) the way its sitemap lists\n * it: every screen the routing map publishes, less the template screens, the\n * status pages and every screen that is not public — through the same shared\n * predicates the tenant's `sitemap.xml` and `/llms.txt` apply, so the check\n * covers exactly the pages a crawler is handed. Then each page's published\n * version, and the shared layouts, for the links that decide which pages\n * nothing points to.\n *\n * It knows nothing about the Firestore SDK. A caller hands it a\n * {@link SeoScanStore} — the Admin SDK's `Firestore` satisfies it — and the\n * template screen ids, which the tenant runtime resolves; so this module stays\n * pure enough to import from anywhere, and a spec hands it a fake.\n *\n * Each page is checked as it publishes (AGL-3501): {@link composeSeoPages}\n * grafts the reusable components it places, with each placement's values,\n * and expands its repeats over their rows when the caller hands over the\n * repeat-rows reader — the composition the tenant runs before it renders.\n *\n * Reads: one projected query of the screens, one of the layouts, and one\n * version document per checked page and per layout — at most\n * {@link SEO_AUDIT_MAX_PAGES} + {@link SEO_SCAN_LAYOUT_LIMIT} + 2 — then one\n * document per distinct component those place, and one repeat-rows request\n * for every key they repeat over.\n */\n\n/** Shared layouts read for the links they carry. */\nexport const SEO_SCAN_LAYOUT_LIMIT = 20\n\n/** Version documents read at once. */\nconst READ_CHUNK = 10\n\n/** Screen documents the projected query reads. */\nconst SCREEN_SCAN_LIMIT = 1000\n\n/* ---- The store, structurally ---------------------------------------------- */\n\nexport interface SeoScanSnapshot {\n readonly exists: boolean\n get(field: string): unknown\n}\n\nexport interface SeoScanQueryDocument {\n readonly id: string\n data(): unknown\n get(field: string): unknown\n readonly ref: SeoScanDocumentRef\n}\n\nexport interface SeoScanQuery {\n limit(count: number): SeoScanQuery\n get(): Promise<{ readonly docs: readonly SeoScanQueryDocument[] }>\n}\n\nexport interface SeoScanCollection {\n select(...fields: string[]): SeoScanQuery\n doc(id: string): SeoScanDocumentRef\n}\n\nexport interface SeoScanDocumentRef {\n collection(name: string): SeoScanCollection\n get(): Promise<SeoScanSnapshot>\n}\n\n/** What the scan reads through; the Admin SDK's `Firestore` is one. */\nexport interface SeoScanStore {\n collection(name: string): SeoScanCollection\n}\n\n/* ---- What it reads --------------------------------------------------------- */\n\n/** The host document fields the scan and the site findings read. */\nexport interface SeoSiteHost {\n displayName?: string\n screens?: Record<string, string>\n errorScreens?: Record<string, string>\n notFoundScreenId?: string | null\n seo?: {\n title?: string\n separator?: string\n discourageSearchEngines?: boolean\n entity?: { name?: string; description?: string }\n agent?: { whenToUse?: string }\n }\n}\n\n/** The screen document fields the scan reads. */\ninterface SeoScreenDocument {\n displayName?: string\n description?: string\n versionId?: string\n visibility?: unknown\n deletedAt?: unknown\n seo?: {\n title?: string\n description?: string\n breadcrumb?: string\n image?: string\n imageAlt?: string\n }\n}\n\nexport interface SeoScannedPage extends SeoAuditPage {\n /** The listing as the screen document stores it: a title's variables unresolved. */\n storedSeo: NonNullable<SeoScreenDocument['seo']>\n /** The page's own node map, as its published version stores it — not composed. */\n nodes: Record<string, unknown> | null\n}\n\nexport interface SeoSiteScan {\n pages: SeoScannedPage[]\n /** Published pages past {@link SEO_AUDIT_MAX_PAGES}. */\n skipped: number\n /** Customer-safe lines: a keyword line naming a page the site does not publish. */\n notes: string[]\n}\n\nconst str = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')\n\n/* ---- The page as it publishes --------------------------------------------- */\n\n/**\n * The rows a site's repeats render, by key — the repeat-rows contract\n * (`plugin-manager/repeat-rows`), which is server-only and so handed in\n * rather than imported here.\n */\nexport type SeoRepeatRowsReader = (keys: readonly string[]) => Promise<Record<string, RepeatableDataset>>\n\nexport interface SeoCompositionOptions {\n /** Without it a repeat is read once, as written, its `{{item.*}}` tokens left out of the facts. */\n readRepeatRows?: SeoRepeatRowsReader\n}\n\nexport interface SeoComposedPages {\n /** Each node map as it publishes, in the order given; `null` stays `null`. */\n composed: (Record<string, unknown> | null)[]\n /** The repeats' rows were asked for and could not be read, so each repeat was read once, as written. */\n rowsUnread: boolean\n}\n\n/** A component reader that reads each document once however many pages place it. */\nfunction onceEach(read: ReadComponentDocument): ReadComponentDocument {\n const reads = new Map<string, Promise<StoredComponentDocument | null | undefined>>()\n return (componentId) => {\n let pending = reads.get(componentId)\n if (!pending) {\n pending = read(componentId)\n reads.set(componentId, pending)\n }\n return pending\n }\n}\n\n/**\n * Node maps of `hostId` as they publish (AGL-3501): each reusable component\n * placed grafted from its published definition with the placement's values\n * (`load-referenced-components.ts`, the graft the tenant runs), then each\n * repeat expanded over its rows when `readRepeatRows` is given — one request\n * for every key the maps repeat over.\n *\n * A component that does not load leaves its placement as authored, as on the\n * page; rows that cannot be read leave each repeat once, as written, and say\n * so through `rowsUnread` rather than failing the check.\n */\nexport async function composeSeoPages(\n store: SeoScanStore,\n hostId: string,\n maps: readonly (Record<string, unknown> | null | undefined)[],\n options: SeoCompositionOptions = {},\n): Promise<SeoComposedPages> {\n const read = onceEach(hostComponentReader(store, hostId))\n const skipped = new Map<string, SkippedComponent>()\n const grafted = await Promise.all(\n maps.map((nodes) =>\n nodes\n ? composeReferencedComponents(nodes, read, {\n onSkipped: (list) => list.forEach((entry) => skipped.set(entry.id, entry)),\n })\n : null,\n ),\n )\n if (skipped.size) {\n console.warn(JSON.stringify({ tag: 'AGL-3501:seo-components-skipped', hostId, skipped: [...skipped.values()] }))\n }\n\n const keys = [...new Set(grafted.flatMap((nodes) => (nodes ? repeatKeys(nodes) : [])))].sort()\n if (!keys.length || !options.readRepeatRows) return { composed: grafted, rowsUnread: false }\n let rows: Record<string, RepeatableDataset>\n try {\n rows = await options.readRepeatRows(keys)\n } catch (error) {\n console.warn(JSON.stringify({ tag: 'AGL-3501:seo-repeat-rows-unread', hostId, error: String(error) }))\n return { composed: grafted, rowsUnread: true }\n }\n return {\n composed: grafted.map((nodes) =>\n nodes ? expandRepeatables(nodes as Record<string, AglynNodeSchema>, rows) : null,\n ),\n rowsUnread: false,\n }\n}\n\n/** The site as the check reads it, from its host document. */\nexport function seoAuditSiteOf(host: SeoSiteHost): SeoAuditSite {\n return {\n discouraged: isSearchDiscouraged(host),\n entity: host.seo?.entity ?? {},\n agent: host.seo?.agent ?? {},\n }\n}\n\n/** A version's node map and root, or `null` when it cannot be read. */\nasync function readVersionNodes(\n parent: SeoScanDocumentRef,\n versionId: string,\n): Promise<{ nodes: Record<string, unknown>; rootId?: string } | null> {\n if (!versionId) return null\n const snapshot = await parent.collection('versions').doc(versionId).get()\n if (!snapshot.exists) return null\n const nodes = decodeStoredNodes<Record<string, unknown>>(snapshot.get('nodes'))\n if (!nodes) return null\n const rootId = snapshot.get('rootId')\n return { nodes, ...(typeof rootId === 'string' ? { rootId } : {}) }\n}\n\nasync function inChunks<T>(items: readonly T[], run: (item: T) => Promise<void>): Promise<void> {\n for (let start = 0; start < items.length; start += READ_CHUNK) {\n await Promise.all(items.slice(start, start + READ_CHUNK).map(run))\n }\n}\n\n/**\n * Read `hostId`'s published pages for the check.\n *\n * `keywords` are the target keyword lines a person typed (`/path: a, b`);\n * `templateScreenIds` are the screens a collection, the store or the host\n * renders other URLs through, which are not pages of their own;\n * `readRepeatRows` reads the rows the pages repeat over\n * ({@link composeSeoPages}).\n */\nexport async function scanSeoSite(\n store: SeoScanStore,\n hostId: string,\n host: SeoSiteHost,\n options: { keywords?: unknown; templateScreenIds?: Iterable<string> } & SeoCompositionOptions = {},\n): Promise<SeoSiteScan> {\n const hostRef = store.collection('hosts').doc(hostId)\n const routing = host.screens ?? {}\n const [screenDocs, layoutDocs] = await Promise.all([\n hostRef\n .collection('screens')\n .select('visibility', 'seo', 'description', 'displayName', 'versionId', 'deletedAt')\n .limit(SCREEN_SCAN_LIMIT)\n .get(),\n hostRef.collection('layouts').select('versionId').limit(SEO_SCAN_LAYOUT_LIMIT).get(),\n ])\n const screens = new Map(screenDocs.docs.map((doc) => [doc.id, (doc.data() ?? {}) as SeoScreenDocument]))\n const excluded = new Set<string>([...statusPageScreenIds(host), ...(options.templateScreenIds ?? [])])\n const published = Object.entries(routing)\n .filter(([screenId]) => !excluded.has(screenId))\n .filter(([screenId]) => {\n const screen = screens.get(screenId)\n return Boolean(screen) && !screen?.deletedAt && isScreenIndexable(screen as SearchIndexingScreen)\n })\n .map(([screenId, route]) => ({ screenId, path: screenRoutePathToUrl(String(route)) }))\n .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))\n const checked = published.slice(0, SEO_AUDIT_MAX_PAGES)\n\n const versions = new Map<string, { nodes: Record<string, unknown>; rootId?: string } | null>()\n await inChunks(checked, async ({ screenId }) => {\n versions.set(\n screenId,\n await readVersionNodes(hostRef.collection('screens').doc(screenId), str(screens.get(screenId)?.versionId)),\n )\n })\n const storedLayouts: Record<string, unknown>[] = []\n await inChunks(\n layoutDocs.docs.filter((doc) => str(doc.get('versionId'))),\n async (doc) => {\n const version = await readVersionNodes(doc.ref, str(doc.get('versionId')))\n if (version) storedLayouts.push(version.nodes)\n },\n )\n\n // Checked as they publish: a heading, an image or a link a component\n // renders is the page's, and so is a row a repeat shows.\n const { composed, rowsUnread } = await composeSeoPages(\n store,\n hostId,\n [...checked.map(({ screenId }) => versions.get(screenId)?.nodes ?? null), ...storedLayouts],\n options,\n )\n const composedPages = new Map(checked.map(({ screenId }, index) => [screenId, composed[index]]))\n const layoutNodes = composed.slice(checked.length)\n\n const keywordLines = readSeoKeywordLines(options.keywords)\n const keywordsByPath = keywordLines.keywords\n const checkedPaths = new Set(checked.map((page) => seoNormalizePath(page.path)))\n const notes = [\n ...[...new Set([...Object.keys(keywordsByPath), ...Object.keys(keywordLines.unchecked)])]\n .filter((path) => !checkedPaths.has(path))\n .map((path) => `Keywords for ${path} were not used: no checked page is published at that address.`),\n ...Object.entries(keywordLines.unchecked)\n .filter(([path]) => checkedPaths.has(path))\n .map(\n ([path, unchecked]) =>\n `Only the first ${SEO_MAX_KEYWORDS} keywords for ${path} were checked, so ${seoQuotedKeywords(unchecked)} ${unchecked.length === 1 ? 'was' : 'were'} not.`,\n ),\n ...(rowsUnread\n ? ['Lists that repeat over your datasets were checked without their rows, which could not be read just now.']\n : []),\n ]\n\n const siteName = str(host.seo?.title) || str(host.displayName)\n const pages: SeoScannedPage[] = checked.map(({ screenId, path }) => {\n const screen = screens.get(screenId) ?? {}\n const version = versions.get(screenId) ?? null\n const storedSeo = screen.seo ?? {}\n const linkedFrom =\n layoutNodes.some((nodes) => nodesReferenceScreen(nodes, screenId)) ||\n checked.some(\n (other) =>\n other.screenId !== screenId && nodesReferenceScreen(composedPages.get(other.screenId) ?? null, screenId),\n )\n return {\n screenId,\n path,\n name: str(screen.displayName) || path,\n versionId: str(screen.versionId) || null,\n // Judged as the head renders it: `{{page.name}} – {{site.name}}` on two\n // pages is two different titles, and its length is the rendered one.\n seo: {\n ...storedSeo,\n title: resolveSeoTitleVariables(storedSeo.title, {\n 'page.name': str(screen.displayName),\n 'site.name': siteName,\n 'site.separator': str(host.seo?.separator),\n }),\n },\n storedSeo,\n description: str(screen.description),\n facts: seoPageFacts(composedPages.get(screenId) as PageMarkdownNodes | null | undefined, {\n rootId: version?.rootId,\n pageNodes: version?.nodes as PageMarkdownNodes | undefined,\n }),\n linkedFrom,\n keywords: keywordsByPath[seoNormalizePath(path)] ?? [],\n nodes: version?.nodes ?? null,\n }\n })\n return { pages, skipped: Math.max(0, published.length - checked.length), notes }\n}\n"],"names":["screenRoutePathToUrl","nodesReferenceScreen","isScreenIndexable","isSearchDiscouraged","statusPageScreenIds","SEO_AUDIT_MAX_PAGES","readSeoKeywordLines","seoNormalizePath","SEO_MAX_KEYWORDS","seoQuotedKeywords","seoPageFacts","expandRepeatables","repeatKeys","composeReferencedComponents","hostComponentReader","resolveSeoTitleVariables","decodeStoredNodes","SEO_SCAN_LAYOUT_LIMIT","READ_CHUNK","SCREEN_SCAN_LIMIT","str","value","trim","onceEach","read","reads","Map","componentId","pending","get","set","composeSeoPages","store","hostId","maps","options","skipped","grafted","Promise","all","map","nodes","onSkipped","list","forEach","entry","id","size","console","warn","JSON","stringify","tag","values","keys","Set","flatMap","sort","length","readRepeatRows","composed","rowsUnread","rows","error","String","seoAuditSiteOf","host","discouraged","entity","seo","agent","readVersionNodes","parent","versionId","snapshot","collection","doc","exists","rootId","inChunks","items","run","start","slice","scanSeoSite","hostRef","routing","screens","screenDocs","layoutDocs","select","limit","docs","data","excluded","templateScreenIds","published","Object","entries","filter","screenId","has","screen","Boolean","deletedAt","route","path","a","b","checked","versions","storedLayouts","version","ref","push","composedPages","index","layoutNodes","keywordLines","keywords","keywordsByPath","checkedPaths","page","notes","unchecked","siteName","title","displayName","pages","storedSeo","linkedFrom","some","other","name","separator","description","facts","pageNodes","Math","max"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/seo-site-scan.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { screenRoutePathToUrl } from './screen-route'\nimport { nodesReferenceScreen } from './screen-link-value'\nimport {\n isScreenIndexable,\n isSearchDiscouraged,\n statusPageScreenIds,\n type SearchIndexingScreen,\n} from './search-indexing'\nimport {\n SEO_AUDIT_MAX_PAGES,\n readSeoKeywordLines,\n seoNormalizePath,\n type SeoAuditPage,\n type SeoAuditSite,\n} from './seo-audit'\nimport { SEO_MAX_KEYWORDS, seoQuotedKeywords } from './seo-keywords'\nimport { seoPageFacts } from './seo-page-facts'\nimport type { AglynNodeSchema } from '../foundation'\nimport { expandRepeatables, repeatKeys, type RepeatableDataset } from './expand-repeatables'\nimport {\n composeReferencedComponents,\n hostComponentReader,\n type ReadComponentDocument,\n type SkippedComponent,\n type StoredComponentDocument,\n} from './load-referenced-components'\nimport type { PageMarkdownNodes } from './page-markdown'\nimport { resolveSeoTitleVariables } from './seo-title-variables'\nimport { decodeStoredNodes } from './stored-nodes'\n\n/**\n * Read a site for the SEO check (`seo-audit.ts`) the way its sitemap lists\n * it: every screen the routing map publishes, less the template screens, the\n * status pages and every screen that is not public — through the same shared\n * predicates the tenant's `sitemap.xml` and `/llms.txt` apply, so the check\n * covers exactly the pages a crawler is handed. Then each page's published\n * version, and the shared layouts, for the links that decide which pages\n * nothing points to.\n *\n * It knows nothing about the Firestore SDK. A caller hands it a\n * {@link SeoScanStore} — the Admin SDK's `Firestore` satisfies it — and the\n * template screen ids, which the tenant runtime resolves; so this module stays\n * pure enough to import from anywhere, and a spec hands it a fake.\n *\n * Each page is checked as it publishes (AGL-3501): {@link composeSeoPages}\n * grafts the reusable components it places, with each placement's values,\n * and expands its repeats over their rows when the caller hands over the\n * repeat-rows reader — the composition the tenant runs before it renders.\n *\n * Reads: one projected query of the screens, one of the layouts, and one\n * version document per checked page and per layout — at most\n * {@link SEO_AUDIT_MAX_PAGES} + {@link SEO_SCAN_LAYOUT_LIMIT} + 2 — then one\n * document per distinct component those place, and one repeat-rows request\n * for every key they repeat over.\n */\n\n/** Shared layouts read for the links they carry. */\nexport const SEO_SCAN_LAYOUT_LIMIT = 20\n\n/** Version documents read at once. */\nconst READ_CHUNK = 10\n\n/** Screen documents the projected query reads. */\nconst SCREEN_SCAN_LIMIT = 1000\n\n/* ---- The store, structurally ---------------------------------------------- */\n\nexport interface SeoScanSnapshot {\n readonly exists: boolean\n get(field: string): unknown\n}\n\nexport interface SeoScanQueryDocument {\n readonly id: string\n data(): unknown\n get(field: string): unknown\n readonly ref: SeoScanDocumentRef\n}\n\nexport interface SeoScanQuery {\n limit(count: number): SeoScanQuery\n get(): Promise<{ readonly docs: readonly SeoScanQueryDocument[] }>\n}\n\nexport interface SeoScanCollection {\n select(...fields: string[]): SeoScanQuery\n doc(id: string): SeoScanDocumentRef\n}\n\nexport interface SeoScanDocumentRef {\n collection(name: string): SeoScanCollection\n get(): Promise<SeoScanSnapshot>\n}\n\n/** What the scan reads through; the Admin SDK's `Firestore` is one. */\nexport interface SeoScanStore {\n collection(name: string): SeoScanCollection\n}\n\n/* ---- What it reads --------------------------------------------------------- */\n\n/** The host document fields the scan and the site findings read. */\nexport interface SeoSiteHost {\n displayName?: string\n screens?: Record<string, string>\n errorScreens?: Record<string, string>\n notFoundScreenId?: string | null\n seo?: {\n title?: string\n separator?: string\n discourageSearchEngines?: boolean\n entity?: { name?: string; description?: string }\n agent?: { whenToUse?: string }\n }\n}\n\n/** The screen document fields the scan reads. */\ninterface SeoScreenDocument {\n displayName?: string\n description?: string\n versionId?: string\n visibility?: unknown\n deletedAt?: unknown\n seo?: {\n title?: string\n description?: string\n breadcrumb?: string\n image?: string\n imageAlt?: string\n }\n}\n\nexport interface SeoScannedPage extends SeoAuditPage {\n /** The listing as the screen document stores it: a title's variables unresolved. */\n storedSeo: NonNullable<SeoScreenDocument['seo']>\n /** The page's own node map, as its published version stores it — not composed. */\n nodes: Record<string, unknown> | null\n}\n\nexport interface SeoSiteScan {\n pages: SeoScannedPage[]\n /** Published pages past {@link SEO_AUDIT_MAX_PAGES}. */\n skipped: number\n /** Customer-safe lines: a keyword line naming a page the site does not publish. */\n notes: string[]\n}\n\nconst str = (value: unknown): string => (typeof value === 'string' ? value.trim() : '')\n\n/* ---- The page as it publishes --------------------------------------------- */\n\n/**\n * The rows a site's repeats render, by key — the repeat-rows contract\n * (`plugin-manager/repeat-rows`), which is server-only and so handed in\n * rather than imported here.\n */\nexport type SeoRepeatRowsReader = (keys: readonly string[]) => Promise<Record<string, RepeatableDataset>>\n\nexport interface SeoCompositionOptions {\n /** Without it a repeat is read once, as written, its `{{item.*}}` tokens left out of the facts. */\n readRepeatRows?: SeoRepeatRowsReader\n}\n\nexport interface SeoComposedPages {\n /** Each node map as it publishes, in the order given; `null` stays `null`. */\n composed: (Record<string, unknown> | null)[]\n /** The repeats' rows were asked for and could not be read, so each repeat was read once, as written. */\n rowsUnread: boolean\n}\n\n/** A component reader that reads each document once however many pages place it. */\nfunction onceEach(read: ReadComponentDocument): ReadComponentDocument {\n const reads = new Map<string, Promise<StoredComponentDocument | null | undefined>>()\n return (componentId) => {\n let pending = reads.get(componentId)\n if (!pending) {\n pending = read(componentId)\n reads.set(componentId, pending)\n }\n return pending\n }\n}\n\n/**\n * Node maps of `hostId` as they publish (AGL-3501): each reusable component\n * placed grafted from its published definition with the placement's values\n * (`load-referenced-components.ts`, the graft the tenant runs), then each\n * repeat expanded over its rows when `readRepeatRows` is given — one request\n * for every key the maps repeat over.\n *\n * A component that does not load leaves its placement as authored, as on the\n * page; rows that cannot be read leave each repeat once, as written, and say\n * so through `rowsUnread` rather than failing the check.\n */\nexport async function composeSeoPages(\n store: SeoScanStore,\n hostId: string,\n maps: readonly (Record<string, unknown> | null | undefined)[],\n options: SeoCompositionOptions = {},\n): Promise<SeoComposedPages> {\n const read = onceEach(hostComponentReader(store, hostId))\n const skipped = new Map<string, SkippedComponent>()\n const grafted = await Promise.all(\n maps.map((nodes) =>\n nodes\n ? composeReferencedComponents(nodes, read, {\n onSkipped: (list) => list.forEach((entry) => skipped.set(entry.id, entry)),\n })\n : null,\n ),\n )\n if (skipped.size) {\n console.warn(JSON.stringify({ tag: 'AGL-3501:seo-components-skipped', hostId, skipped: [...skipped.values()] }))\n }\n\n const keys = [...new Set(grafted.flatMap((nodes) => (nodes ? repeatKeys(nodes) : [])))].sort()\n if (!keys.length || !options.readRepeatRows) return { composed: grafted, rowsUnread: false }\n let rows: Record<string, RepeatableDataset>\n try {\n rows = await options.readRepeatRows(keys)\n } catch (error) {\n console.warn(JSON.stringify({ tag: 'AGL-3501:seo-repeat-rows-unread', hostId, error: String(error) }))\n return { composed: grafted, rowsUnread: true }\n }\n return {\n composed: grafted.map((nodes) =>\n nodes ? expandRepeatables(nodes as Record<string, AglynNodeSchema>, rows) : null,\n ),\n rowsUnread: false,\n }\n}\n\n/** The site as the check reads it, from its host document. */\nexport function seoAuditSiteOf(host: SeoSiteHost): SeoAuditSite {\n return {\n discouraged: isSearchDiscouraged(host),\n entity: host.seo?.entity ?? {},\n agent: host.seo?.agent ?? {},\n }\n}\n\n/** A version's node map and root, or `null` when it cannot be read. */\nasync function readVersionNodes(\n parent: SeoScanDocumentRef,\n versionId: string,\n): Promise<{ nodes: Record<string, unknown>; rootId?: string } | null> {\n if (!versionId) return null\n const snapshot = await parent.collection('versions').doc(versionId).get()\n if (!snapshot.exists) return null\n const nodes = decodeStoredNodes<Record<string, unknown>>(snapshot.get('nodes'))\n if (!nodes) return null\n const rootId = snapshot.get('rootId')\n return { nodes, ...(typeof rootId === 'string' ? { rootId } : {}) }\n}\n\nasync function inChunks<T>(items: readonly T[], run: (item: T) => Promise<void>): Promise<void> {\n for (let start = 0; start < items.length; start += READ_CHUNK) {\n await Promise.all(items.slice(start, start + READ_CHUNK).map(run))\n }\n}\n\n/** How many shown by name before the rest are counted. */\nconst UNLISTED_NAMES_SHOWN = 5\n\n/** The note naming published pages a visibility setting keeps out of search. */\nfunction unlistedNote(names: readonly string[]): string {\n const shown = names.slice(0, UNLISTED_NAMES_SHOWN).join(', ')\n const more = names.length > UNLISTED_NAMES_SHOWN ? ` and ${names.length - UNLISTED_NAMES_SHOWN} more` : ''\n return names.length === 1\n ? `1 published page is kept out of search and was not checked: ${shown}. Set its Visibility to Public to list it.`\n : `${names.length} published pages are kept out of search and were not checked: ${shown}${more}. Set a page’s Visibility to Public to list it.`\n}\n\n/**\n * Read `hostId`'s published pages for the check.\n *\n * `keywords` are the target keyword lines a person typed (`/path: a, b`);\n * `templateScreenIds` are the screens a collection, the store or the host\n * renders other URLs through, which are not pages of their own;\n * `readRepeatRows` reads the rows the pages repeat over\n * ({@link composeSeoPages}).\n */\nexport async function scanSeoSite(\n store: SeoScanStore,\n hostId: string,\n host: SeoSiteHost,\n options: { keywords?: unknown; templateScreenIds?: Iterable<string> } & SeoCompositionOptions = {},\n): Promise<SeoSiteScan> {\n const hostRef = store.collection('hosts').doc(hostId)\n const routing = host.screens ?? {}\n const [screenDocs, layoutDocs] = await Promise.all([\n hostRef\n .collection('screens')\n .select('visibility', 'seo', 'description', 'displayName', 'versionId', 'deletedAt')\n .limit(SCREEN_SCAN_LIMIT)\n .get(),\n hostRef.collection('layouts').select('versionId').limit(SEO_SCAN_LAYOUT_LIMIT).get(),\n ])\n const screens = new Map(screenDocs.docs.map((doc) => [doc.id, (doc.data() ?? {}) as SeoScreenDocument]))\n const excluded = new Set<string>([...statusPageScreenIds(host), ...(options.templateScreenIds ?? [])])\n const routed = Object.entries(routing).filter(([screenId]) => {\n const screen = screens.get(screenId)\n return !excluded.has(screenId) && Boolean(screen) && !screen?.deletedAt\n })\n // Published but kept out of search: what a launch checks it did not leave on.\n const unlisted = routed\n .filter(([screenId]) => !isScreenIndexable(screens.get(screenId) as SearchIndexingScreen))\n .map(([screenId, route]) => str(screens.get(screenId)?.displayName) || screenRoutePathToUrl(String(route)))\n .sort()\n const published = routed\n .filter(([screenId]) => isScreenIndexable(screens.get(screenId) as SearchIndexingScreen))\n .map(([screenId, route]) => ({ screenId, path: screenRoutePathToUrl(String(route)) }))\n .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))\n const checked = published.slice(0, SEO_AUDIT_MAX_PAGES)\n\n const versions = new Map<string, { nodes: Record<string, unknown>; rootId?: string } | null>()\n await inChunks(checked, async ({ screenId }) => {\n versions.set(\n screenId,\n await readVersionNodes(hostRef.collection('screens').doc(screenId), str(screens.get(screenId)?.versionId)),\n )\n })\n const storedLayouts: Record<string, unknown>[] = []\n await inChunks(\n layoutDocs.docs.filter((doc) => str(doc.get('versionId'))),\n async (doc) => {\n const version = await readVersionNodes(doc.ref, str(doc.get('versionId')))\n if (version) storedLayouts.push(version.nodes)\n },\n )\n\n // Checked as they publish: a heading, an image or a link a component\n // renders is the page's, and so is a row a repeat shows.\n const { composed, rowsUnread } = await composeSeoPages(\n store,\n hostId,\n [...checked.map(({ screenId }) => versions.get(screenId)?.nodes ?? null), ...storedLayouts],\n options,\n )\n const composedPages = new Map(checked.map(({ screenId }, index) => [screenId, composed[index]]))\n const layoutNodes = composed.slice(checked.length)\n\n const keywordLines = readSeoKeywordLines(options.keywords)\n const keywordsByPath = keywordLines.keywords\n const checkedPaths = new Set(checked.map((page) => seoNormalizePath(page.path)))\n const notes = [\n ...[...new Set([...Object.keys(keywordsByPath), ...Object.keys(keywordLines.unchecked)])]\n .filter((path) => !checkedPaths.has(path))\n .map((path) => `Keywords for ${path} were not used: no checked page is published at that address.`),\n ...Object.entries(keywordLines.unchecked)\n .filter(([path]) => checkedPaths.has(path))\n .map(\n ([path, unchecked]) =>\n `Only the first ${SEO_MAX_KEYWORDS} keywords for ${path} were checked, so ${seoQuotedKeywords(unchecked)} ${unchecked.length === 1 ? 'was' : 'were'} not.`,\n ),\n ...(rowsUnread\n ? ['Lists that repeat over your datasets were checked without their rows, which could not be read just now.']\n : []), ...(unlisted.length ? [unlistedNote(unlisted)] : []),\n ]\n\n const siteName = str(host.seo?.title) || str(host.displayName)\n const pages: SeoScannedPage[] = checked.map(({ screenId, path }) => {\n const screen = screens.get(screenId) ?? {}\n const version = versions.get(screenId) ?? null\n const storedSeo = screen.seo ?? {}\n const linkedFrom =\n layoutNodes.some((nodes) => nodesReferenceScreen(nodes, screenId)) ||\n checked.some(\n (other) =>\n other.screenId !== screenId && nodesReferenceScreen(composedPages.get(other.screenId) ?? null, screenId),\n )\n return {\n screenId,\n path,\n name: str(screen.displayName) || path,\n versionId: str(screen.versionId) || null,\n // Judged as the head renders it: `{{page.name}} – {{site.name}}` on two\n // pages is two different titles, and its length is the rendered one.\n seo: {\n ...storedSeo,\n title: resolveSeoTitleVariables(storedSeo.title, {\n 'page.name': str(screen.displayName),\n 'site.name': siteName,\n 'site.separator': str(host.seo?.separator),\n }),\n },\n storedSeo,\n description: str(screen.description),\n facts: seoPageFacts(composedPages.get(screenId) as PageMarkdownNodes | null | undefined, {\n rootId: version?.rootId,\n pageNodes: version?.nodes as PageMarkdownNodes | undefined,\n }),\n linkedFrom,\n keywords: keywordsByPath[seoNormalizePath(path)] ?? [],\n nodes: version?.nodes ?? null,\n }\n })\n return { pages, skipped: Math.max(0, published.length - checked.length), notes }\n}\n"],"names":["screenRoutePathToUrl","nodesReferenceScreen","isScreenIndexable","isSearchDiscouraged","statusPageScreenIds","SEO_AUDIT_MAX_PAGES","readSeoKeywordLines","seoNormalizePath","SEO_MAX_KEYWORDS","seoQuotedKeywords","seoPageFacts","expandRepeatables","repeatKeys","composeReferencedComponents","hostComponentReader","resolveSeoTitleVariables","decodeStoredNodes","SEO_SCAN_LAYOUT_LIMIT","READ_CHUNK","SCREEN_SCAN_LIMIT","str","value","trim","onceEach","read","reads","Map","componentId","pending","get","set","composeSeoPages","store","hostId","maps","options","skipped","grafted","Promise","all","map","nodes","onSkipped","list","forEach","entry","id","size","console","warn","JSON","stringify","tag","values","keys","Set","flatMap","sort","length","readRepeatRows","composed","rowsUnread","rows","error","String","seoAuditSiteOf","host","discouraged","entity","seo","agent","readVersionNodes","parent","versionId","snapshot","collection","doc","exists","rootId","inChunks","items","run","start","slice","UNLISTED_NAMES_SHOWN","unlistedNote","names","shown","join","more","scanSeoSite","hostRef","routing","screens","screenDocs","layoutDocs","select","limit","docs","data","excluded","templateScreenIds","routed","Object","entries","filter","screenId","screen","has","Boolean","deletedAt","unlisted","route","displayName","published","path","a","b","checked","versions","storedLayouts","version","ref","push","composedPages","index","layoutNodes","keywordLines","keywords","keywordsByPath","checkedPaths","page","notes","unchecked","siteName","title","pages","storedSeo","linkedFrom","some","other","name","separator","description","facts","pageNodes","Math","max"],"mappings":"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"}
@@ -0,0 +1,24 @@
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
+ /** True for `localhost` and `*.localhost`, which have a local tenant to use. */
18
+ export declare function isLocalConsole(hostname: string): boolean;
19
+ export declare function isPreviewConsole(hostname: string): boolean;
20
+ /** The live URL for a site-relative slug on this host's real origin. */
21
+ export declare function siteLiveUrlForSlug(host: {
22
+ subdomain?: string | null;
23
+ cname?: string | null;
24
+ } | undefined, slug: string, consoleHostname?: string | undefined): string | undefined;
@@ -0,0 +1,60 @@
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
+ */ import { TENANT_APEX } from "./tenant-apex.js";
17
+ /**
18
+ * Where a site's pages are live, as the CONSOLE reaches them (AGL-3758):
19
+ * the custom domain, else the platform subdomain, else — on a preview or
20
+ * local console — the tenant preview origin with `?tenantHost=`. Moved out of
21
+ * the console app so a plugin's console card (the Actions element picker)
22
+ * opens the same page every "View live" link does.
23
+ */ // Preview consoles link to the tenant preview deployment, which carries
24
+ // no tenant subdomain and resolves the host via the ?tenantHost= override.
25
+ const TENANT_PREVIEW_HOST = process.env.NEXT_PUBLIC_AGLYN_TENANT_PREVIEW_HOST || 'aglyn-tenant-git-main-aglyn.vercel.app';
26
+ /**
27
+ * A console served from localhost links to the LOCAL tenant dev server
28
+ * (AGL-1203), not the remote preview deployment.
29
+ *
30
+ * Sending a local console to `aglyn-tenant-git-main-*.vercel.app` meant every
31
+ * Live link 404'd for anything that had not been deployed yet — which is the
32
+ * normal state of the work you are doing on localhost. `nx serve tenant`
33
+ * listens on 4500; override for a different port.
34
+ */ const TENANT_LOCAL_ORIGIN = process.env.NEXT_PUBLIC_AGLYN_TENANT_LOCAL_ORIGIN || 'http://localhost:4500';
35
+ /** True for `localhost` and `*.localhost`, which have a local tenant to use. */ export function isLocalConsole(hostname) {
36
+ return hostname === 'localhost' || hostname.endsWith('.localhost');
37
+ }
38
+ export function isPreviewConsole(hostname) {
39
+ return hostname.endsWith('.vercel.app') || hostname === 'localhost' || hostname.endsWith('.localhost');
40
+ }
41
+ /** The live URL for a site-relative slug on this host's real origin. */ export function siteLiveUrlForSlug(host, slug, consoleHostname = typeof window !== 'undefined' ? window.location.hostname : undefined) {
42
+ if (!host) return undefined;
43
+ // Published slugs are stored with a leading slash ("/product/besigner");
44
+ // the origin already ends without one, so strip it rather than emitting the
45
+ // "//" that every one of these URLs used to carry.
46
+ const path = slug === '/' ? '' : String(slug).replace(/^\/+/, '');
47
+ if (consoleHostname && isPreviewConsole(consoleHostname)) {
48
+ if (!host.subdomain) return undefined;
49
+ const tenantHost = encodeURIComponent(host.subdomain);
50
+ // Local console → local tenant. Both resolve the site through the same
51
+ // `?tenantHost=` override; only the origin differs.
52
+ const origin = isLocalConsole(consoleHostname) ? TENANT_LOCAL_ORIGIN : `https://${TENANT_PREVIEW_HOST}`;
53
+ return `${origin}/${path}?tenantHost=${tenantHost}`;
54
+ }
55
+ const domain = host.cname || (host.subdomain ? `${host.subdomain}.${TENANT_APEX}` : undefined);
56
+ if (!domain) return undefined;
57
+ return `https://${domain}/${path}`;
58
+ }
59
+
60
+ //# sourceMappingURL=site-live-url.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/site-live-url.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { TENANT_APEX } from './tenant-apex'\n\n/**\n * Where a site's pages are live, as the CONSOLE reaches them (AGL-3758):\n * the custom domain, else the platform subdomain, else — on a preview or\n * local console — the tenant preview origin with `?tenantHost=`. Moved out of\n * the console app so a plugin's console card (the Actions element picker)\n * opens the same page every \"View live\" link does.\n */\n\n// Preview consoles link to the tenant preview deployment, which carries\n// no tenant subdomain and resolves the host via the ?tenantHost= override.\nconst TENANT_PREVIEW_HOST =\n process.env.NEXT_PUBLIC_AGLYN_TENANT_PREVIEW_HOST ||\n 'aglyn-tenant-git-main-aglyn.vercel.app'\n\n/**\n * A console served from localhost links to the LOCAL tenant dev server\n * (AGL-1203), not the remote preview deployment.\n *\n * Sending a local console to `aglyn-tenant-git-main-*.vercel.app` meant every\n * Live link 404'd for anything that had not been deployed yet — which is the\n * normal state of the work you are doing on localhost. `nx serve tenant`\n * listens on 4500; override for a different port.\n */\nconst TENANT_LOCAL_ORIGIN =\n process.env.NEXT_PUBLIC_AGLYN_TENANT_LOCAL_ORIGIN || 'http://localhost:4500'\n\n/** True for `localhost` and `*.localhost`, which have a local tenant to use. */\nexport function isLocalConsole(hostname: string): boolean {\n return hostname === 'localhost' || hostname.endsWith('.localhost')\n}\n\nexport function isPreviewConsole(hostname: string): boolean {\n return (\n hostname.endsWith('.vercel.app') ||\n hostname === 'localhost' ||\n hostname.endsWith('.localhost')\n )\n}\n\n/** The live URL for a site-relative slug on this host's real origin. */\nexport function siteLiveUrlForSlug(\n host: { subdomain?: string | null; cname?: string | null } | undefined,\n slug: string,\n consoleHostname: string | undefined = typeof window !== 'undefined'\n ? window.location.hostname\n : undefined,\n): string | undefined {\n if (!host) return undefined\n // Published slugs are stored with a leading slash (\"/product/besigner\");\n // the origin already ends without one, so strip it rather than emitting the\n // \"//\" that every one of these URLs used to carry.\n const path = slug === '/' ? '' : String(slug).replace(/^\\/+/, '')\n\n if (consoleHostname && isPreviewConsole(consoleHostname)) {\n if (!host.subdomain) return undefined\n const tenantHost = encodeURIComponent(host.subdomain)\n // Local console → local tenant. Both resolve the site through the same\n // `?tenantHost=` override; only the origin differs.\n const origin = isLocalConsole(consoleHostname)\n ? TENANT_LOCAL_ORIGIN\n : `https://${TENANT_PREVIEW_HOST}`\n return `${origin}/${path}?tenantHost=${tenantHost}`\n }\n\n const domain =\n host.cname || (host.subdomain ? `${host.subdomain}.${TENANT_APEX}` : undefined)\n if (!domain) return undefined\n return `https://${domain}/${path}`\n}\n"],"names":["TENANT_APEX","TENANT_PREVIEW_HOST","process","env","NEXT_PUBLIC_AGLYN_TENANT_PREVIEW_HOST","TENANT_LOCAL_ORIGIN","NEXT_PUBLIC_AGLYN_TENANT_LOCAL_ORIGIN","isLocalConsole","hostname","endsWith","isPreviewConsole","siteLiveUrlForSlug","host","slug","consoleHostname","window","location","undefined","path","String","replace","subdomain","tenantHost","encodeURIComponent","origin","domain","cname"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,WAAW,QAAQ,mBAAe;AAE3C;;;;;;CAMC,GAED,wEAAwE;AACxE,2EAA2E;AAC3E,MAAMC,sBACJC,QAAQC,GAAG,CAACC,qCAAqC,IACjD;AAEF;;;;;;;;CAQC,GACD,MAAMC,sBACJH,QAAQC,GAAG,CAACG,qCAAqC,IAAI;AAEvD,8EAA8E,GAC9E,OAAO,SAASC,eAAeC,QAAgB;IAC7C,OAAOA,aAAa,eAAeA,SAASC,QAAQ,CAAC;AACvD;AAEA,OAAO,SAASC,iBAAiBF,QAAgB;IAC/C,OACEA,SAASC,QAAQ,CAAC,kBAClBD,aAAa,eACbA,SAASC,QAAQ,CAAC;AAEtB;AAEA,sEAAsE,GACtE,OAAO,SAASE,mBACdC,IAAsE,EACtEC,IAAY,EACZC,kBAAsC,OAAOC,WAAW,cACpDA,OAAOC,QAAQ,CAACR,QAAQ,GACxBS,SAAS;IAEb,IAAI,CAACL,MAAM,OAAOK;IAClB,yEAAyE;IACzE,4EAA4E;IAC5E,mDAAmD;IACnD,MAAMC,OAAOL,SAAS,MAAM,KAAKM,OAAON,MAAMO,OAAO,CAAC,QAAQ;IAE9D,IAAIN,mBAAmBJ,iBAAiBI,kBAAkB;QACxD,IAAI,CAACF,KAAKS,SAAS,EAAE,OAAOJ;QAC5B,MAAMK,aAAaC,mBAAmBX,KAAKS,SAAS;QACpD,uEAAuE;QACvE,oDAAoD;QACpD,MAAMG,SAASjB,eAAeO,mBAC1BT,sBACA,CAAC,QAAQ,EAAEJ,qBAAqB;QACpC,OAAO,GAAGuB,OAAO,CAAC,EAAEN,KAAK,YAAY,EAAEI,YAAY;IACrD;IAEA,MAAMG,SACJb,KAAKc,KAAK,IAAKd,CAAAA,KAAKS,SAAS,GAAG,GAAGT,KAAKS,SAAS,CAAC,CAAC,EAAErB,aAAa,GAAGiB,SAAQ;IAC/E,IAAI,CAACQ,QAAQ,OAAOR;IACpB,OAAO,CAAC,QAAQ,EAAEQ,OAAO,CAAC,EAAEP,MAAM;AACpC"}
@@ -97,6 +97,19 @@ export type StarterNodeSpec = {
97
97
  export declare function buildStarterNodes(children: StarterNodeSpec[]): Record<string, any>;
98
98
  /** One line of a starter's copy: a `muiTypography` of the given variant. */
99
99
  export declare const starterText: (id: string, variant: string, children: string, extra?: Record<string, unknown>) => StarterNodeSpec;
100
+ /**
101
+ * One labelled text field, as MUI's separate form elements: a Form Control
102
+ * holding a Form Label and an Input — exactly the tree the forms plugin's
103
+ * `formFieldToElements` rebuilds an all-in-one Form Field into, so a starter's fields and a migrated
104
+ * site's look and submit alike. Ids are `id`, `id-label` and `id-input`.
105
+ */
106
+ export declare const starterTextField: (id: string, field: {
107
+ name: string;
108
+ label: string;
109
+ type?: "text" | "email";
110
+ multiline?: boolean;
111
+ required?: boolean;
112
+ }) => StarterNodeSpec;
100
113
  /**
101
114
  * The stock widths a starter band may be (AGL-1932, AGL-1298).
102
115
  *
@@ -52,6 +52,46 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
52
52
  children
53
53
  }, extra)
54
54
  });
55
+ /**
56
+ * One labelled text field, as MUI's separate form elements: a Form Control
57
+ * holding a Form Label and an Input — exactly the tree the forms plugin's
58
+ * `formFieldToElements` rebuilds an all-in-one Form Field into, so a starter's fields and a migrated
59
+ * site's look and submit alike. Ids are `id`, `id-label` and `id-input`.
60
+ */ export const starterTextField = (id, field)=>{
61
+ var _field_type;
62
+ const required = field.required ? {
63
+ required: true
64
+ } : {};
65
+ return {
66
+ id,
67
+ componentId: 'muiFormControl',
68
+ props: _extends({
69
+ fullWidth: true
70
+ }, required),
71
+ children: [
72
+ {
73
+ id: `${id}-label`,
74
+ componentId: 'muiFormLabel',
75
+ props: {
76
+ children: field.label
77
+ }
78
+ },
79
+ {
80
+ id: `${id}-input`,
81
+ componentId: 'muiInput',
82
+ props: _extends({
83
+ name: field.name,
84
+ type: (_field_type = field.type) != null ? _field_type : 'text'
85
+ }, field.multiline ? {
86
+ multiline: true,
87
+ minRows: 3
88
+ } : {}, required, {
89
+ fullWidth: true
90
+ })
91
+ }
92
+ ]
93
+ };
94
+ };
55
95
  /**
56
96
  * One page band: a `Container` at a stock width, carrying the band's own
57
97
  * vertical rhythm (AGL-1932).
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/starter-template-nodes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { CANVAS_ROOT_ELEMENT_ID } from '../foundation/constants/canvas'\nimport type { NodeInteraction } from './node-interactions'\n\n/**\n * What a starter site is, and the kit its pages are built with (AGL-687,\n * AGL-3080).\n *\n * A starter is a bundle of page templates the template gallery offers and\n * the seed route copies into a site's library on first use. The platform's\n * own starters live in `starter-templates.ts`; a plugin whose elements a\n * starter is built around declares its own, built with this same kit, and\n * the manifest generator compiles them into\n * `plugin-starter-templates.generated.ts`.\n *\n * Leaf imports only, for the reason `starter-templates.ts` states: this is\n * read from the console's client gallery, its seed route and a plugin's\n * server alike, so it imports a canvas constant and nothing that drags in\n * either barrel.\n *\n * PERSISTED IDENTIFIERS: `StarterTemplate.id`, `StarterTemplateScreen.key`\n * and every node id a starter builds appear in stored documents (seeded\n * template doc ids are derived from the first two). They must never be\n * renamed.\n */\nexport interface StarterTemplateScreen {\n /**\n * Stable, starter-local key. Part of the seeded document id, so it is a\n * persisted identifier — never rename one.\n */\n key: string\n displayName: string\n description?: string\n /**\n * Routing-map slug. `SCREEN_ROOT_PATH` (`'/'`) asks for the site root — the\n * home page — and anything else is a single path segment.\n *\n * It used to be `''` that meant home, which no normalizer agreed with: both\n * the apply path and the seed below read an empty string as \"no address\n * authored\" and derived one from the display name, so the shop starters'\n * home page was published at `/home` and the site 404'd at its own URL\n * (AGL-1575).\n */\n slug: string\n seo?: { title?: string; description?: string }\n /** Flat node map including the canvas root. */\n nodes: Record<string, any>\n}\n\nexport interface StarterTemplate {\n id: string\n displayName: string\n description: string\n category: string\n screens: StarterTemplateScreen[]\n}\n\n/** A materialized starter template document, keyed by its deterministic id. */\n\nexport type StarterNodeSpec = {\n id: string\n componentId: string\n /** Bundle owning the component; defaults to 'mui' (AGL-300). */\n pluginId?: string\n props?: Record<string, unknown>\n /**\n * Node-level styles — a SIBLING of props, never `props.sx` (AGL-1346).\n *\n * Both records render (`Leaf` composes `(sx, props.sx, node.sx)`, later\n * wins), but the Styles panel edits `node.sx`. A starter that seeded its\n * styling into `props.sx` handed the author a document full of values\n * the panel could show but no click could change or clear.\n */\n sx?: Record<string, unknown>\n /**\n * What the element does when a visitor acts on it, stored on the node as\n * the interactions editor stores it (AGL-2867) — a button that scrolls to\n * a section of its own page, for one.\n */\n interactions?: NodeInteraction[]\n children?: StarterNodeSpec[]\n}\n\n/** Builds the flat, persisted node map from a nested spec. */\nexport function buildStarterNodes(children: StarterNodeSpec[]): Record<string, any> {\n const map: Record<string, any> = {\n [CANVAS_ROOT_ELEMENT_ID]: {\n $id: CANVAS_ROOT_ELEMENT_ID,\n componentId: 'div',\n nodes: children.map((child) => child.id),\n },\n }\n const walk = (spec: StarterNodeSpec, parentId: string) => {\n map[spec.id] = {\n $id: spec.id,\n componentId: spec.componentId,\n pluginId: spec.pluginId ?? 'mui',\n parentId,\n props: spec.props ?? {},\n ...(spec.sx ? { sx: spec.sx } : {}),\n ...(spec.interactions?.length ? { interactions: spec.interactions } : {}),\n nodes: (spec.children ?? []).map((child) => child.id),\n }\n for (const child of spec.children ?? []) walk(child, spec.id)\n }\n for (const child of children) walk(child, CANVAS_ROOT_ELEMENT_ID)\n return map\n}\n\n/** One line of a starter's copy: a `muiTypography` of the given variant. */\nexport const starterText = (\n id: string,\n variant: string,\n children: string,\n extra?: Record<string, unknown>,\n): StarterNodeSpec => ({\n id,\n componentId: 'muiTypography',\n props: { variant, children, ...extra },\n})\n\n/**\n * The stock widths a starter band may be (AGL-1932, AGL-1298).\n *\n * Three cases, and only three, because the standard has three. There is no\n * pixel case: `1328` is a CONTENT width, not a breakpoint, and the ban on\n * bespoke numbers is the half of AGL-1298 these templates used to violate\n * four times over.\n */\nexport type StarterSectionWidth = 'md' | 'lg' | 'xl'\n\n/**\n * One page band: a `Container` at a stock width, carrying the band's own\n * vertical rhythm (AGL-1932).\n *\n * The layout standard (AGL-1298): every section is a Container. Full-bleed is\n * the Container's own full-width attribute, never the absence of a Container,\n * and anything else is one of three stock widths. A template that seeds no\n * Container hands every customer a site that starts outside the standard.\n *\n * How the three widths are chosen here, stated so the next audit does not\n * have to guess:\n *\n * - `xl` — a marketing band: hero, feature row, product grid, gallery. The\n * default width, and what an unremarkable page should be.\n * - `lg` — the deliberate middle case: wide but text-led and interactive.\n * Cart and account are the honest instances, not decoration.\n * - `md` — long-form prose and narrow form columns, on READING grounds: at\n * `xl` a paragraph runs 110–120 characters a line. This is the case the\n * `Prose Container` preset exists for.\n *\n * Horizontal padding is the Container's own gutters, so bands no longer carry\n * horizontal padding at all. Only the vertical rhythm rides here, which is\n * rhythm rather than width.\n *\n * Written as the two longhands rather than MUI's `py` (AGL-2207/2208): the\n * Styles panel's Padding control is named for the four sides, so a band\n * seeded with `py` handed the customer a document whose padding the panel\n * showed as empty and no click could clear.\n */\nexport const starterSection = (\n id: string,\n maxWidth: StarterSectionWidth,\n verticalPadding: number,\n children: StarterNodeSpec[],\n): StarterNodeSpec => ({\n id,\n componentId: 'muiContainer',\n props: { maxWidth },\n sx: { paddingTop: verticalPadding, paddingBottom: verticalPadding },\n children,\n})\n\n/**\n * The hero band a starter page opens with: a headline, a tagline and a call\n * to action, centered in an `xl` band. Its node ids are `prefix` plus fixed\n * names, so a starter's prefix keeps them its own.\n */\nexport const starterHeroSection = (\n prefix: string,\n headline: string,\n tagline: string,\n): StarterNodeSpec =>\n // The Container is a NEW node (`…heroSection`); `…hero` keeps its id and\n // stays the Stack. Node ids here are persisted identifiers, so bands are\n // wrapped rather than re-pointed.\n starterSection(`${prefix}heroSection`, 'xl', 10, [\n {\n id: `${prefix}hero`,\n componentId: 'muiStack',\n props: { spacing: 2 },\n sx: { alignItems: 'center' },\n children: [\n starterText(`${prefix}heroTitle`, 'h2', headline, { align: 'center' }),\n starterText(`${prefix}heroSub`, 'h6', tagline, { align: 'center' }),\n {\n id: `${prefix}heroCta`,\n componentId: 'muiButton',\n props: {\n variant: 'contained',\n size: 'large',\n children: 'Get in touch',\n },\n },\n ],\n },\n ])\n"],"names":["CANVAS_ROOT_ELEMENT_ID","buildStarterNodes","children","map","$id","componentId","nodes","child","id","walk","spec","parentId","pluginId","props","sx","interactions","length","starterText","variant","extra","starterSection","maxWidth","verticalPadding","paddingTop","paddingBottom","starterHeroSection","prefix","headline","tagline","spacing","alignItems","align","size"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,sBAAsB,QAAQ,oCAAgC;AAkFvE,4DAA4D,GAC5D,OAAO,SAASC,kBAAkBC,QAA2B;IAC3D,MAAMC,MAA2B;QAC/B,CAACH,uBAAuB,EAAE;YACxBI,KAAKJ;YACLK,aAAa;YACbC,OAAOJ,SAASC,GAAG,CAAC,CAACI,QAAUA,MAAMC,EAAE;QACzC;IACF;IACA,MAAMC,OAAO,CAACC,MAAuBC;YAIvBD,gBAEHA,aAGCA,gBAEUA;YAHdA;QAPNP,GAAG,CAACO,KAAKF,EAAE,CAAC,GAAG;YACbJ,KAAKM,KAAKF,EAAE;YACZH,aAAaK,KAAKL,WAAW;YAC7BO,QAAQ,GAAEF,iBAAAA,KAAKE,QAAQ,YAAbF,iBAAiB;YAC3BC;YACAE,KAAK,GAAEH,cAAAA,KAAKG,KAAK,YAAVH,cAAc,CAAC;WAClBA,KAAKI,EAAE,GAAG;YAAEA,IAAIJ,KAAKI,EAAE;QAAC,IAAI,CAAC,GAC7BJ,EAAAA,qBAAAA,KAAKK,YAAY,qBAAjBL,mBAAmBM,MAAM,IAAG;YAAED,cAAcL,KAAKK,YAAY;QAAC,IAAI,CAAC;YACvET,OAAO,EAACI,iBAAAA,KAAKR,QAAQ,YAAbQ,iBAAiB,EAAE,EAAEP,GAAG,CAAC,CAACI,QAAUA,MAAMC,EAAE;;QAEtD,KAAK,MAAMD,UAASG,kBAAAA,KAAKR,QAAQ,YAAbQ,kBAAiB,EAAE,CAAED,KAAKF,OAAOG,KAAKF,EAAE;IAC9D;IACA,KAAK,MAAMD,SAASL,SAAUO,KAAKF,OAAOP;IAC1C,OAAOG;AACT;AAEA,0EAA0E,GAC1E,OAAO,MAAMc,cAAc,CACzBT,IACAU,SACAhB,UACAiB,QACqB,CAAA;QACrBX;QACAH,aAAa;QACbQ,OAAO;YAAEK;YAAShB;WAAaiB;IACjC,CAAA,EAAE;AAYF;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,MAAMC,iBAAiB,CAC5BZ,IACAa,UACAC,iBACApB,WACqB,CAAA;QACrBM;QACAH,aAAa;QACbQ,OAAO;YAAEQ;QAAS;QAClBP,IAAI;YAAES,YAAYD;YAAiBE,eAAeF;QAAgB;QAClEpB;IACF,CAAA,EAAE;AAEF;;;;CAIC,GACD,OAAO,MAAMuB,qBAAqB,CAChCC,QACAC,UACAC,UAEA,yEAAyE;IACzE,yEAAyE;IACzE,kCAAkC;IAClCR,eAAe,GAAGM,OAAO,WAAW,CAAC,EAAE,MAAM,IAAI;QAC/C;YACElB,IAAI,GAAGkB,OAAO,IAAI,CAAC;YACnBrB,aAAa;YACbQ,OAAO;gBAAEgB,SAAS;YAAE;YACpBf,IAAI;gBAAEgB,YAAY;YAAS;YAC3B5B,UAAU;gBACRe,YAAY,GAAGS,OAAO,SAAS,CAAC,EAAE,MAAMC,UAAU;oBAAEI,OAAO;gBAAS;gBACpEd,YAAY,GAAGS,OAAO,OAAO,CAAC,EAAE,MAAME,SAAS;oBAAEG,OAAO;gBAAS;gBACjE;oBACEvB,IAAI,GAAGkB,OAAO,OAAO,CAAC;oBACtBrB,aAAa;oBACbQ,OAAO;wBACLK,SAAS;wBACTc,MAAM;wBACN9B,UAAU;oBACZ;gBACF;aACD;QACH;KACD,EAAC"}
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/starter-template-nodes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { CANVAS_ROOT_ELEMENT_ID } from '../foundation/constants/canvas'\nimport type { NodeInteraction } from './node-interactions'\n\n/**\n * What a starter site is, and the kit its pages are built with (AGL-687,\n * AGL-3080).\n *\n * A starter is a bundle of page templates the template gallery offers and\n * the seed route copies into a site's library on first use. The platform's\n * own starters live in `starter-templates.ts`; a plugin whose elements a\n * starter is built around declares its own, built with this same kit, and\n * the manifest generator compiles them into\n * `plugin-starter-templates.generated.ts`.\n *\n * Leaf imports only, for the reason `starter-templates.ts` states: this is\n * read from the console's client gallery, its seed route and a plugin's\n * server alike, so it imports a canvas constant and nothing that drags in\n * either barrel.\n *\n * PERSISTED IDENTIFIERS: `StarterTemplate.id`, `StarterTemplateScreen.key`\n * and every node id a starter builds appear in stored documents (seeded\n * template doc ids are derived from the first two). They must never be\n * renamed.\n */\nexport interface StarterTemplateScreen {\n /**\n * Stable, starter-local key. Part of the seeded document id, so it is a\n * persisted identifier — never rename one.\n */\n key: string\n displayName: string\n description?: string\n /**\n * Routing-map slug. `SCREEN_ROOT_PATH` (`'/'`) asks for the site root — the\n * home page — and anything else is a single path segment.\n *\n * It used to be `''` that meant home, which no normalizer agreed with: both\n * the apply path and the seed below read an empty string as \"no address\n * authored\" and derived one from the display name, so the shop starters'\n * home page was published at `/home` and the site 404'd at its own URL\n * (AGL-1575).\n */\n slug: string\n seo?: { title?: string; description?: string }\n /** Flat node map including the canvas root. */\n nodes: Record<string, any>\n}\n\nexport interface StarterTemplate {\n id: string\n displayName: string\n description: string\n category: string\n screens: StarterTemplateScreen[]\n}\n\n/** A materialized starter template document, keyed by its deterministic id. */\n\nexport type StarterNodeSpec = {\n id: string\n componentId: string\n /** Bundle owning the component; defaults to 'mui' (AGL-300). */\n pluginId?: string\n props?: Record<string, unknown>\n /**\n * Node-level styles — a SIBLING of props, never `props.sx` (AGL-1346).\n *\n * Both records render (`Leaf` composes `(sx, props.sx, node.sx)`, later\n * wins), but the Styles panel edits `node.sx`. A starter that seeded its\n * styling into `props.sx` handed the author a document full of values\n * the panel could show but no click could change or clear.\n */\n sx?: Record<string, unknown>\n /**\n * What the element does when a visitor acts on it, stored on the node as\n * the interactions editor stores it (AGL-2867) — a button that scrolls to\n * a section of its own page, for one.\n */\n interactions?: NodeInteraction[]\n children?: StarterNodeSpec[]\n}\n\n/** Builds the flat, persisted node map from a nested spec. */\nexport function buildStarterNodes(children: StarterNodeSpec[]): Record<string, any> {\n const map: Record<string, any> = {\n [CANVAS_ROOT_ELEMENT_ID]: {\n $id: CANVAS_ROOT_ELEMENT_ID,\n componentId: 'div',\n nodes: children.map((child) => child.id),\n },\n }\n const walk = (spec: StarterNodeSpec, parentId: string) => {\n map[spec.id] = {\n $id: spec.id,\n componentId: spec.componentId,\n pluginId: spec.pluginId ?? 'mui',\n parentId,\n props: spec.props ?? {},\n ...(spec.sx ? { sx: spec.sx } : {}),\n ...(spec.interactions?.length ? { interactions: spec.interactions } : {}),\n nodes: (spec.children ?? []).map((child) => child.id),\n }\n for (const child of spec.children ?? []) walk(child, spec.id)\n }\n for (const child of children) walk(child, CANVAS_ROOT_ELEMENT_ID)\n return map\n}\n\n/** One line of a starter's copy: a `muiTypography` of the given variant. */\nexport const starterText = (\n id: string,\n variant: string,\n children: string,\n extra?: Record<string, unknown>,\n): StarterNodeSpec => ({\n id,\n componentId: 'muiTypography',\n props: { variant, children, ...extra },\n})\n\n/**\n * One labelled text field, as MUI's separate form elements: a Form Control\n * holding a Form Label and an Input — exactly the tree the forms plugin's\n * `formFieldToElements` rebuilds an all-in-one Form Field into, so a starter's fields and a migrated\n * site's look and submit alike. Ids are `id`, `id-label` and `id-input`.\n */\nexport const starterTextField = (\n id: string,\n field: {\n name: string\n label: string\n type?: 'text' | 'email'\n multiline?: boolean\n required?: boolean\n },\n): StarterNodeSpec => {\n const required = field.required ? { required: true } : {}\n return {\n id,\n componentId: 'muiFormControl',\n props: { fullWidth: true, ...required },\n children: [\n { id: `${id}-label`, componentId: 'muiFormLabel', props: { children: field.label } },\n {\n id: `${id}-input`,\n componentId: 'muiInput',\n props: {\n name: field.name,\n type: field.type ?? 'text',\n ...(field.multiline ? { multiline: true, minRows: 3 } : {}),\n ...required,\n fullWidth: true,\n },\n },\n ],\n }\n}\n\n/**\n * The stock widths a starter band may be (AGL-1932, AGL-1298).\n *\n * Three cases, and only three, because the standard has three. There is no\n * pixel case: `1328` is a CONTENT width, not a breakpoint, and the ban on\n * bespoke numbers is the half of AGL-1298 these templates used to violate\n * four times over.\n */\nexport type StarterSectionWidth = 'md' | 'lg' | 'xl'\n\n/**\n * One page band: a `Container` at a stock width, carrying the band's own\n * vertical rhythm (AGL-1932).\n *\n * The layout standard (AGL-1298): every section is a Container. Full-bleed is\n * the Container's own full-width attribute, never the absence of a Container,\n * and anything else is one of three stock widths. A template that seeds no\n * Container hands every customer a site that starts outside the standard.\n *\n * How the three widths are chosen here, stated so the next audit does not\n * have to guess:\n *\n * - `xl` — a marketing band: hero, feature row, product grid, gallery. The\n * default width, and what an unremarkable page should be.\n * - `lg` — the deliberate middle case: wide but text-led and interactive.\n * Cart and account are the honest instances, not decoration.\n * - `md` — long-form prose and narrow form columns, on READING grounds: at\n * `xl` a paragraph runs 110–120 characters a line. This is the case the\n * `Prose Container` preset exists for.\n *\n * Horizontal padding is the Container's own gutters, so bands no longer carry\n * horizontal padding at all. Only the vertical rhythm rides here, which is\n * rhythm rather than width.\n *\n * Written as the two longhands rather than MUI's `py` (AGL-2207/2208): the\n * Styles panel's Padding control is named for the four sides, so a band\n * seeded with `py` handed the customer a document whose padding the panel\n * showed as empty and no click could clear.\n */\nexport const starterSection = (\n id: string,\n maxWidth: StarterSectionWidth,\n verticalPadding: number,\n children: StarterNodeSpec[],\n): StarterNodeSpec => ({\n id,\n componentId: 'muiContainer',\n props: { maxWidth },\n sx: { paddingTop: verticalPadding, paddingBottom: verticalPadding },\n children,\n})\n\n/**\n * The hero band a starter page opens with: a headline, a tagline and a call\n * to action, centered in an `xl` band. Its node ids are `prefix` plus fixed\n * names, so a starter's prefix keeps them its own.\n */\nexport const starterHeroSection = (\n prefix: string,\n headline: string,\n tagline: string,\n): StarterNodeSpec =>\n // The Container is a NEW node (`…heroSection`); `…hero` keeps its id and\n // stays the Stack. Node ids here are persisted identifiers, so bands are\n // wrapped rather than re-pointed.\n starterSection(`${prefix}heroSection`, 'xl', 10, [\n {\n id: `${prefix}hero`,\n componentId: 'muiStack',\n props: { spacing: 2 },\n sx: { alignItems: 'center' },\n children: [\n starterText(`${prefix}heroTitle`, 'h2', headline, { align: 'center' }),\n starterText(`${prefix}heroSub`, 'h6', tagline, { align: 'center' }),\n {\n id: `${prefix}heroCta`,\n componentId: 'muiButton',\n props: {\n variant: 'contained',\n size: 'large',\n children: 'Get in touch',\n },\n },\n ],\n },\n ])\n"],"names":["CANVAS_ROOT_ELEMENT_ID","buildStarterNodes","children","map","$id","componentId","nodes","child","id","walk","spec","parentId","pluginId","props","sx","interactions","length","starterText","variant","extra","starterTextField","field","required","fullWidth","label","name","type","multiline","minRows","starterSection","maxWidth","verticalPadding","paddingTop","paddingBottom","starterHeroSection","prefix","headline","tagline","spacing","alignItems","align","size"],"mappings":"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"}
@@ -16,7 +16,7 @@
16
16
  */
17
17
  import { type StarterTemplate } from './starter-template-nodes';
18
18
  export type { StarterNodeSpec, StarterSectionWidth, StarterTemplate, StarterTemplateScreen, } from './starter-template-nodes';
19
- export { buildStarterNodes, starterHeroSection, starterSection, starterText, } from './starter-template-nodes';
19
+ export { buildStarterNodes, starterHeroSection, starterSection, starterText, starterTextField, } from './starter-template-nodes';
20
20
  /**
21
21
  * First-party starter definitions (AGL-78/79), SEED INPUT (AGL-687).
22
22
  *
@@ -25,8 +25,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
25
25
  // imports only a TYPE from foundation — so all three are safe on either side.
26
26
  import { PLUGIN_STARTER_TEMPLATES } from "./plugin-starter-templates.generated.js";
27
27
  import { normalizeScreenSlug } from "./screen-route.js";
28
- import { buildStarterNodes as buildNodes, starterHeroSection as heroSection, starterSection as section, starterText as text } from "./starter-template-nodes.js";
29
- export { buildStarterNodes, starterHeroSection, starterSection, starterText } from "./starter-template-nodes.js";
28
+ import { buildStarterNodes as buildNodes, starterHeroSection as heroSection, starterSection as section, starterText as text, starterTextField } from "./starter-template-nodes.js";
29
+ export { buildStarterNodes, starterHeroSection, starterSection, starterText, starterTextField } from "./starter-template-nodes.js";
30
30
  /**
31
31
  * Deterministic document id for a seeded starter screen (AGL-687).
32
32
  *
@@ -111,35 +111,23 @@ const contactForm = (prefix)=>({
111
111
  successMessage: 'Thanks — we will get back to you soon.'
112
112
  },
113
113
  children: [
114
- {
115
- id: `${prefix}fName`,
116
- componentId: 'formField',
117
- props: {
118
- fieldName: 'name',
119
- label: 'Name',
120
- required: true
121
- }
122
- },
123
- {
124
- id: `${prefix}fEmail`,
125
- componentId: 'formField',
126
- props: {
127
- fieldName: 'email',
128
- label: 'Email',
129
- fieldType: 'email',
130
- required: true
131
- }
132
- },
133
- {
134
- id: `${prefix}fMessage`,
135
- componentId: 'formField',
136
- props: {
137
- fieldName: 'message',
138
- label: 'Message',
139
- fieldType: 'textarea',
140
- required: true
141
- }
142
- }
114
+ starterTextField(`${prefix}fName`, {
115
+ name: 'name',
116
+ label: 'Name',
117
+ required: true
118
+ }),
119
+ starterTextField(`${prefix}fEmail`, {
120
+ name: 'email',
121
+ label: 'Email',
122
+ type: 'email',
123
+ required: true
124
+ }),
125
+ starterTextField(`${prefix}fMessage`, {
126
+ name: 'message',
127
+ label: 'Message',
128
+ multiline: true,
129
+ required: true
130
+ })
143
131
  ]
144
132
  });
145
133
  /**