@aglyn/tenant-runtime 1.0.0-beta.143

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 (120) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +26 -0
  3. package/package.json +39 -0
  4. package/src/index.d.ts +21 -0
  5. package/src/index.js +22 -0
  6. package/src/index.js.map +1 -0
  7. package/src/lib/apply-publish-schedule.d.ts +34 -0
  8. package/src/lib/apply-publish-schedule.js +285 -0
  9. package/src/lib/apply-publish-schedule.js.map +1 -0
  10. package/src/lib/assign-contact-owner.d.ts +162 -0
  11. package/src/lib/assign-contact-owner.js +322 -0
  12. package/src/lib/assign-contact-owner.js.map +1 -0
  13. package/src/lib/associate-company-by-domain.d.ts +70 -0
  14. package/src/lib/associate-company-by-domain.js +150 -0
  15. package/src/lib/associate-company-by-domain.js.map +1 -0
  16. package/src/lib/author-page-nodes.d.ts +36 -0
  17. package/src/lib/author-page-nodes.js +296 -0
  18. package/src/lib/author-page-nodes.js.map +1 -0
  19. package/src/lib/built-in-page-layout.d.ts +48 -0
  20. package/src/lib/built-in-page-layout.js +107 -0
  21. package/src/lib/built-in-page-layout.js.map +1 -0
  22. package/src/lib/capture-host-contact.d.ts +95 -0
  23. package/src/lib/capture-host-contact.js +137 -0
  24. package/src/lib/capture-host-contact.js.map +1 -0
  25. package/src/lib/collection-fallback-nodes.d.ts +90 -0
  26. package/src/lib/collection-fallback-nodes.js +602 -0
  27. package/src/lib/collection-fallback-nodes.js.map +1 -0
  28. package/src/lib/compose-author-page.d.ts +109 -0
  29. package/src/lib/compose-author-page.js +225 -0
  30. package/src/lib/compose-author-page.js.map +1 -0
  31. package/src/lib/compose-collection-page.d.ts +94 -0
  32. package/src/lib/compose-collection-page.js +256 -0
  33. package/src/lib/compose-collection-page.js.map +1 -0
  34. package/src/lib/compose-screen-nodes.d.ts +156 -0
  35. package/src/lib/compose-screen-nodes.js +547 -0
  36. package/src/lib/compose-screen-nodes.js.map +1 -0
  37. package/src/lib/compose-search-page.d.ts +39 -0
  38. package/src/lib/compose-search-page.js +57 -0
  39. package/src/lib/compose-search-page.js.map +1 -0
  40. package/src/lib/convert-host-lead.d.ts +189 -0
  41. package/src/lib/convert-host-lead.js +443 -0
  42. package/src/lib/convert-host-lead.js.map +1 -0
  43. package/src/lib/defer-lazy-panels.d.ts +58 -0
  44. package/src/lib/defer-lazy-panels.js +171 -0
  45. package/src/lib/defer-lazy-panels.js.map +1 -0
  46. package/src/lib/element-animation-assets.d.ts +62 -0
  47. package/src/lib/element-animation-assets.js +290 -0
  48. package/src/lib/element-animation-assets.js.map +1 -0
  49. package/src/lib/emit-host-event.d.ts +29 -0
  50. package/src/lib/emit-host-event.js +30 -0
  51. package/src/lib/emit-host-event.js.map +1 -0
  52. package/src/lib/enrich-gated-page.d.ts +67 -0
  53. package/src/lib/enrich-gated-page.js +134 -0
  54. package/src/lib/enrich-gated-page.js.map +1 -0
  55. package/src/lib/entry-link-routes.d.ts +61 -0
  56. package/src/lib/entry-link-routes.js +169 -0
  57. package/src/lib/entry-link-routes.js.map +1 -0
  58. package/src/lib/get-author-content.d.ts +119 -0
  59. package/src/lib/get-author-content.js +288 -0
  60. package/src/lib/get-author-content.js.map +1 -0
  61. package/src/lib/get-collection-content.d.ts +290 -0
  62. package/src/lib/get-collection-content.js +565 -0
  63. package/src/lib/get-collection-content.js.map +1 -0
  64. package/src/lib/get-components.d.ts +24 -0
  65. package/src/lib/get-components.js +102 -0
  66. package/src/lib/get-components.js.map +1 -0
  67. package/src/lib/get-datasets.d.ts +45 -0
  68. package/src/lib/get-datasets.js +169 -0
  69. package/src/lib/get-datasets.js.map +1 -0
  70. package/src/lib/get-forms.d.ts +24 -0
  71. package/src/lib/get-forms.js +116 -0
  72. package/src/lib/get-forms.js.map +1 -0
  73. package/src/lib/get-layout-version.d.ts +26 -0
  74. package/src/lib/get-layout-version.js +99 -0
  75. package/src/lib/get-layout-version.js.map +1 -0
  76. package/src/lib/get-media-asset-facts.d.ts +74 -0
  77. package/src/lib/get-media-asset-facts.js +116 -0
  78. package/src/lib/get-media-asset-facts.js.map +1 -0
  79. package/src/lib/get-plugin-installs.d.ts +29 -0
  80. package/src/lib/get-plugin-installs.js +94 -0
  81. package/src/lib/get-plugin-installs.js.map +1 -0
  82. package/src/lib/get-screen-version.d.ts +27 -0
  83. package/src/lib/get-screen-version.js +66 -0
  84. package/src/lib/get-screen-version.js.map +1 -0
  85. package/src/lib/get-screen.d.ts +39 -0
  86. package/src/lib/get-screen.js +126 -0
  87. package/src/lib/get-screen.js.map +1 -0
  88. package/src/lib/get-variables.d.ts +41 -0
  89. package/src/lib/get-variables.js +128 -0
  90. package/src/lib/get-variables.js.map +1 -0
  91. package/src/lib/get-video-asset-facts.d.ts +22 -0
  92. package/src/lib/get-video-asset-facts.js +22 -0
  93. package/src/lib/get-video-asset-facts.js.map +1 -0
  94. package/src/lib/host-event-listeners.d.ts +83 -0
  95. package/src/lib/host-event-listeners.js +91 -0
  96. package/src/lib/host-event-listeners.js.map +1 -0
  97. package/src/lib/org-permissions.d.ts +63 -0
  98. package/src/lib/org-permissions.js +187 -0
  99. package/src/lib/org-permissions.js.map +1 -0
  100. package/src/lib/repeat-record-pages.d.ts +48 -0
  101. package/src/lib/repeat-record-pages.js +64 -0
  102. package/src/lib/repeat-record-pages.js.map +1 -0
  103. package/src/lib/required-site-plugins.d.ts +114 -0
  104. package/src/lib/required-site-plugins.js +121 -0
  105. package/src/lib/required-site-plugins.js.map +1 -0
  106. package/src/lib/resolve-dataset.d.ts +40 -0
  107. package/src/lib/resolve-dataset.js +64 -0
  108. package/src/lib/resolve-dataset.js.map +1 -0
  109. package/src/lib/search-results-nodes.d.ts +48 -0
  110. package/src/lib/search-results-nodes.js +302 -0
  111. package/src/lib/search-results-nodes.js.map +1 -0
  112. package/src/lib/social-image-facts.d.ts +107 -0
  113. package/src/lib/social-image-facts.js +133 -0
  114. package/src/lib/social-image-facts.js.map +1 -0
  115. package/src/lib/stamp-form-dataset-bindings.d.ts +32 -0
  116. package/src/lib/stamp-form-dataset-bindings.js +59 -0
  117. package/src/lib/stamp-form-dataset-bindings.js.map +1 -0
  118. package/src/lib/template-screens.d.ts +227 -0
  119. package/src/lib/template-screens.js +364 -0
  120. package/src/lib/template-screens.js.map +1 -0
@@ -0,0 +1,121 @@
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
+ * Which site plugins a page must have registered *before it can render*
18
+ * (AGL-1289).
19
+ *
20
+ * The tenant client suspends on `sitePluginLoader.ensure(enabledPlugins)`, and
21
+ * `enabledPlugins` is the ORG's list — nothing narrows it to the page. On
22
+ * aglyn.com that is twelve enabled plugins, of which the `/pricing` document
23
+ * uses exactly one: 2,816 of its 2,818 nodes are `mui` and the other two carry
24
+ * no `pluginId` at all. The bundles with a site surface that it does not use —
25
+ * bookings, commerce, email, events-calendar — were downloaded and evaluated
26
+ * in front of first render, in a second request wave measured starting ~1.7 s
27
+ * after the first and finishing at ~4.0 s.
28
+ *
29
+ * A plugin belongs in front of the render for exactly two reasons:
30
+ *
31
+ * 1. **Its components are on the page.** Without it registered the canvas has
32
+ * no component to render for those nodes — the blank-site failure
33
+ * (AGL-52). Read straight off each node's `pluginId`.
34
+ * 2. **It contributed something to this page.** A site runtime only mounts
35
+ * once its plugin registers, so a late one shows its announcement bar a
36
+ * beat after paint and — the case that actually costs something — an
37
+ * experiment runner can paint the control variant before it swaps.
38
+ *
39
+ * The second is why this takes `contributors` rather than inspecting props: it
40
+ * must distinguish "the marketing plugin returned `announcementBar: null`"
41
+ * from "the marketing plugin returned a live bar". Every marketing enricher
42
+ * returns its whole key set on every page, so presence proves nothing;
43
+ * `runSitePageEnrichers` reports only the plugins whose output had content.
44
+ *
45
+ * `/pricing` is the case that shows why the distinction matters. Its
46
+ * `announcementBar`, `popup`, `experiments` and `automationOverlays` are all
47
+ * empty — but its `clientAutomations` is NOT, so `marketing` genuinely has to
48
+ * register early and only the other four move.
49
+ *
50
+ * Everything here fails toward "no narrowing": an unattributable contribution,
51
+ * a missing enabled list, no nodes to read. The cost of refusing is a slower
52
+ * page; the cost of wrongly dropping a plugin is a site that does not render.
53
+ */ import { isPluginUsedOnPage, pagePresence } from "@aglyn/aglyn/plugin-manager/plugin-contributions";
54
+ /** Registered regardless of the org's list; the canvas cannot render without it. */ export const ALWAYS_ON_SITE_PLUGINS = [
55
+ 'mui'
56
+ ];
57
+ /**
58
+ * The plugins that must register before first render, or `null` when the page
59
+ * has to keep waiting for the whole enabled set.
60
+ *
61
+ * Always a subset of `enabledPlugins`, in that list's order, so this can never
62
+ * turn on a plugin the org has not enabled.
63
+ */ export function requiredSitePlugins({ nodes, contributors, unattributed, enabledPlugins }) {
64
+ if (!(enabledPlugins == null ? void 0 : enabledPlugins.length) || !nodes || unattributed) return null;
65
+ const needed = new Set(ALWAYS_ON_SITE_PLUGINS);
66
+ for (const node of Object.values(nodes)){
67
+ if (node == null ? void 0 : node.pluginId) needed.add(node.pluginId);
68
+ }
69
+ for (const id of contributors != null ? contributors : [])needed.add(id);
70
+ const required = enabledPlugins.filter((id)=>needed.has(id));
71
+ // Nothing gained, and an empty list would be a strictly worse thing to hand
72
+ // the loader than the list it already had.
73
+ if (!required.length || required.length === enabledPlugins.length) return null;
74
+ return required;
75
+ }
76
+ /**
77
+ * The canvas component ids a published page places (AGL-3141).
78
+ *
79
+ * The loader carries this to each site plugin's register function, and a
80
+ * plugin that can register a part of itself — the MUI bundle, which owns
81
+ * sixty elements where a page renders about nineteen — registers only what
82
+ * is here.
83
+ *
84
+ * Read from the FULL composed document, like `requiredSitePlugins`: an
85
+ * element inside a withheld lazy panel is still this page's element, and the
86
+ * visitor who opens that panel would otherwise find nothing in it.
87
+ *
88
+ * `null` for a page with no document to read, because a narrowing that cannot
89
+ * see the page must not be made: an element whose component never registered
90
+ * renders NOTHING, silently (AGL-52). Sorted so the loader's cache key for a
91
+ * page is the same whatever order the document stored its nodes in.
92
+ */ export function placedComponentIds(nodes) {
93
+ if (!nodes) return null;
94
+ return [
95
+ ...pagePresence(nodes).componentIds
96
+ ].sort();
97
+ }
98
+ /**
99
+ * The trusted-realm installs a published page uses (AGL-3116), and only
100
+ * those: the page loads the realm-plugin host — which hands a bundle the
101
+ * whole core namespace — and each bundle only when something on the page
102
+ * uses it.
103
+ *
104
+ * Installation is not use. A workspace that installed a plugin for its
105
+ * console widget used to load the host and the bundle on every page of every
106
+ * site it owns, and the bundle's `register()` then added a widget no
107
+ * published page renders. An install is kept when its version declares a
108
+ * component this page places or a site feature, or when a node is stamped
109
+ * with its id; one that declares nothing is kept only for a stamped node, the
110
+ * default `plugin-contributions.ts` documents.
111
+ *
112
+ * Read from the FULL composed document, like `requiredSitePlugins`: an
113
+ * element inside a withheld lazy panel is still this page's element. A page
114
+ * with no nodes places nothing, so only a site feature can be in use on it.
115
+ */ export function realmPluginsInUse(installs, nodes) {
116
+ if (!installs.length) return [];
117
+ const page = pagePresence(nodes);
118
+ return installs.filter((install)=>isPluginUsedOnPage(install, page));
119
+ }
120
+
121
+ //# sourceMappingURL=required-site-plugins.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/required-site-plugins.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 * Which site plugins a page must have registered *before it can render*\n * (AGL-1289).\n *\n * The tenant client suspends on `sitePluginLoader.ensure(enabledPlugins)`, and\n * `enabledPlugins` is the ORG's list — nothing narrows it to the page. On\n * aglyn.com that is twelve enabled plugins, of which the `/pricing` document\n * uses exactly one: 2,816 of its 2,818 nodes are `mui` and the other two carry\n * no `pluginId` at all. The bundles with a site surface that it does not use —\n * bookings, commerce, email, events-calendar — were downloaded and evaluated\n * in front of first render, in a second request wave measured starting ~1.7 s\n * after the first and finishing at ~4.0 s.\n *\n * A plugin belongs in front of the render for exactly two reasons:\n *\n * 1. **Its components are on the page.** Without it registered the canvas has\n * no component to render for those nodes — the blank-site failure\n * (AGL-52). Read straight off each node's `pluginId`.\n * 2. **It contributed something to this page.** A site runtime only mounts\n * once its plugin registers, so a late one shows its announcement bar a\n * beat after paint and — the case that actually costs something — an\n * experiment runner can paint the control variant before it swaps.\n *\n * The second is why this takes `contributors` rather than inspecting props: it\n * must distinguish \"the marketing plugin returned `announcementBar: null`\"\n * from \"the marketing plugin returned a live bar\". Every marketing enricher\n * returns its whole key set on every page, so presence proves nothing;\n * `runSitePageEnrichers` reports only the plugins whose output had content.\n *\n * `/pricing` is the case that shows why the distinction matters. Its\n * `announcementBar`, `popup`, `experiments` and `automationOverlays` are all\n * empty — but its `clientAutomations` is NOT, so `marketing` genuinely has to\n * register early and only the other four move.\n *\n * Everything here fails toward \"no narrowing\": an unattributable contribution,\n * a missing enabled list, no nodes to read. The cost of refusing is a slower\n * page; the cost of wrongly dropping a plugin is a site that does not render.\n */\n\nimport {\n isPluginUsedOnPage,\n pagePresence,\n type PresenceNode,\n} from '@aglyn/aglyn/plugin-manager/plugin-contributions'\nimport type { RealmPluginInstall } from '@aglyn/aglyn/plugin-manager/realm-plugins'\n\n/** Registered regardless of the org's list; the canvas cannot render without it. */\nexport const ALWAYS_ON_SITE_PLUGINS: readonly string[] = ['mui']\n\nexport interface RequiredSitePluginsInput {\n /** The FULL composed document — not one already pruned for deferral. */\n nodes?: Record<string, { pluginId?: string }> | null\n /** Plugins whose enrichers produced non-empty output for this page. */\n contributors?: readonly string[]\n /** True when some contribution could not be traced to a plugin. */\n unattributed?: boolean\n enabledPlugins?: readonly string[] | null\n}\n\n/**\n * The plugins that must register before first render, or `null` when the page\n * has to keep waiting for the whole enabled set.\n *\n * Always a subset of `enabledPlugins`, in that list's order, so this can never\n * turn on a plugin the org has not enabled.\n */\nexport function requiredSitePlugins({\n nodes,\n contributors,\n unattributed,\n enabledPlugins,\n}: RequiredSitePluginsInput): string[] | null {\n if (!enabledPlugins?.length || !nodes || unattributed) return null\n\n const needed = new Set<string>(ALWAYS_ON_SITE_PLUGINS)\n for (const node of Object.values(nodes)) {\n if (node?.pluginId) needed.add(node.pluginId)\n }\n for (const id of contributors ?? []) needed.add(id)\n\n const required = enabledPlugins.filter((id) => needed.has(id))\n // Nothing gained, and an empty list would be a strictly worse thing to hand\n // the loader than the list it already had.\n if (!required.length || required.length === enabledPlugins.length) return null\n return required\n}\n\n/**\n * The canvas component ids a published page places (AGL-3141).\n *\n * The loader carries this to each site plugin's register function, and a\n * plugin that can register a part of itself — the MUI bundle, which owns\n * sixty elements where a page renders about nineteen — registers only what\n * is here.\n *\n * Read from the FULL composed document, like `requiredSitePlugins`: an\n * element inside a withheld lazy panel is still this page's element, and the\n * visitor who opens that panel would otherwise find nothing in it.\n *\n * `null` for a page with no document to read, because a narrowing that cannot\n * see the page must not be made: an element whose component never registered\n * renders NOTHING, silently (AGL-52). Sorted so the loader's cache key for a\n * page is the same whatever order the document stored its nodes in.\n */\nexport function placedComponentIds(\n nodes: Record<string, PresenceNode | null | undefined> | null | undefined,\n): string[] | null {\n if (!nodes) return null\n return [...pagePresence(nodes).componentIds].sort()\n}\n\n/**\n * The trusted-realm installs a published page uses (AGL-3116), and only\n * those: the page loads the realm-plugin host — which hands a bundle the\n * whole core namespace — and each bundle only when something on the page\n * uses it.\n *\n * Installation is not use. A workspace that installed a plugin for its\n * console widget used to load the host and the bundle on every page of every\n * site it owns, and the bundle's `register()` then added a widget no\n * published page renders. An install is kept when its version declares a\n * component this page places or a site feature, or when a node is stamped\n * with its id; one that declares nothing is kept only for a stamped node, the\n * default `plugin-contributions.ts` documents.\n *\n * Read from the FULL composed document, like `requiredSitePlugins`: an\n * element inside a withheld lazy panel is still this page's element. A page\n * with no nodes places nothing, so only a site feature can be in use on it.\n */\nexport function realmPluginsInUse(\n installs: readonly RealmPluginInstall[],\n nodes: Record<string, PresenceNode | null | undefined> | null | undefined,\n): RealmPluginInstall[] {\n if (!installs.length) return []\n const page = pagePresence(nodes)\n return installs.filter((install) => isPluginUsedOnPage(install, page))\n}\n"],"names":["isPluginUsedOnPage","pagePresence","ALWAYS_ON_SITE_PLUGINS","requiredSitePlugins","nodes","contributors","unattributed","enabledPlugins","length","needed","Set","node","Object","values","pluginId","add","id","required","filter","has","placedComponentIds","componentIds","sort","realmPluginsInUse","installs","page","install"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAqCC,GAED,SACEA,kBAAkB,EAClBC,YAAY,QAEP,mDAAkD;AAGzD,kFAAkF,GAClF,OAAO,MAAMC,yBAA4C;IAAC;CAAM,CAAA;AAYhE;;;;;;CAMC,GACD,OAAO,SAASC,oBAAoB,EAClCC,KAAK,EACLC,YAAY,EACZC,YAAY,EACZC,cAAc,EACW;IACzB,IAAI,EAACA,kCAAAA,eAAgBC,MAAM,KAAI,CAACJ,SAASE,cAAc,OAAO;IAE9D,MAAMG,SAAS,IAAIC,IAAYR;IAC/B,KAAK,MAAMS,QAAQC,OAAOC,MAAM,CAACT,OAAQ;QACvC,IAAIO,wBAAAA,KAAMG,QAAQ,EAAEL,OAAOM,GAAG,CAACJ,KAAKG,QAAQ;IAC9C;IACA,KAAK,MAAME,MAAMX,uBAAAA,eAAgB,EAAE,CAAEI,OAAOM,GAAG,CAACC;IAEhD,MAAMC,WAAWV,eAAeW,MAAM,CAAC,CAACF,KAAOP,OAAOU,GAAG,CAACH;IAC1D,4EAA4E;IAC5E,2CAA2C;IAC3C,IAAI,CAACC,SAAST,MAAM,IAAIS,SAAST,MAAM,KAAKD,eAAeC,MAAM,EAAE,OAAO;IAC1E,OAAOS;AACT;AAEA;;;;;;;;;;;;;;;;CAgBC,GACD,OAAO,SAASG,mBACdhB,KAAyE;IAEzE,IAAI,CAACA,OAAO,OAAO;IACnB,OAAO;WAAIH,aAAaG,OAAOiB,YAAY;KAAC,CAACC,IAAI;AACnD;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASC,kBACdC,QAAuC,EACvCpB,KAAyE;IAEzE,IAAI,CAACoB,SAAShB,MAAM,EAAE,OAAO,EAAE;IAC/B,MAAMiB,OAAOxB,aAAaG;IAC1B,OAAOoB,SAASN,MAAM,CAAC,CAACQ,UAAY1B,mBAAmB0B,SAASD;AAClE"}
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /**
18
+ * Resolves a dataset by its human name: console-created docs store it as
19
+ * `displayName` (AGL-536); the `name` fallback covers pre-migration docs.
20
+ *
21
+ * `scope` narrows the candidates to what one host may see (AGL-1039). It
22
+ * is applied INSIDE the query rather than to the result, which is what
23
+ * makes the collision case come out right: when a host-scoped dataset and
24
+ * an org-wide one share a display name, an unscoped `limit(1)` would pick
25
+ * one arbitrarily and a post-filter would then reject it — reading as
26
+ * "no such dataset" when a perfectly visible one exists.
27
+ */
28
+ export declare function findDatasetByName(datasetsRef: FirebaseFirestore.CollectionReference, datasetName: string, scope?: (query: FirebaseFirestore.Query) => FirebaseFirestore.Query): Promise<FirebaseFirestore.QueryDocumentSnapshot | undefined>;
29
+ /**
30
+ * Id-first dataset resolution (AGL-556): a `datasetId` binding is a
31
+ * direct doc get — display names never enter into it, so renamed
32
+ * datasets keep receiving records. When the id is absent or doesn't
33
+ * resolve, the human-name query (AGL-536 semantics) is the legacy
34
+ * fallback. Returns undefined when neither resolves; callers still own
35
+ * the `deletedAt` check.
36
+ */
37
+ export declare function resolveDatasetDoc(datasetsRef: FirebaseFirestore.CollectionReference, binding: {
38
+ datasetId?: string | null;
39
+ datasetName?: string | null;
40
+ }, hostId: string): Promise<FirebaseFirestore.DocumentSnapshot | undefined>;
@@ -0,0 +1,64 @@
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 * as Aglyn from "@aglyn/aglyn/server";
17
+ /**
18
+ * Resolves a dataset by its human name: console-created docs store it as
19
+ * `displayName` (AGL-536); the `name` fallback covers pre-migration docs.
20
+ *
21
+ * `scope` narrows the candidates to what one host may see (AGL-1039). It
22
+ * is applied INSIDE the query rather than to the result, which is what
23
+ * makes the collision case come out right: when a host-scoped dataset and
24
+ * an org-wide one share a display name, an unscoped `limit(1)` would pick
25
+ * one arbitrarily and a post-filter would then reject it — reading as
26
+ * "no such dataset" when a perfectly visible one exists.
27
+ */ export async function findDatasetByName(datasetsRef, datasetName, scope) {
28
+ const narrow = scope != null ? scope : (query)=>query;
29
+ const byDisplayName = await narrow(datasetsRef.where('displayName', '==', datasetName)).limit(1).get();
30
+ if (!byDisplayName.empty) return byDisplayName.docs[0];
31
+ return (await narrow(datasetsRef.where('name', '==', datasetName)).limit(1).get()).docs[0];
32
+ }
33
+ /**
34
+ * Id-first dataset resolution (AGL-556): a `datasetId` binding is a
35
+ * direct doc get — display names never enter into it, so renamed
36
+ * datasets keep receiving records. When the id is absent or doesn't
37
+ * resolve, the human-name query (AGL-536 semantics) is the legacy
38
+ * fallback. Returns undefined when neither resolves; callers still own
39
+ * the `deletedAt` check.
40
+ */ export async function resolveDatasetDoc(datasetsRef, binding, hostId) {
41
+ var _datasetsRef_parent_parent, _datasetsRef_parent, _binding_datasetId, _binding_datasetName;
42
+ // `hostId` is required rather than optional on purpose (AGL-1039): every
43
+ // caller here writes records into whatever comes back, and an optional
44
+ // scope is one a future caller forgets to pass. The Admin SDK ignores
45
+ // rules, so this is the only thing standing between a client site and
46
+ // another client's dataset.
47
+ const orgScoped = ((_datasetsRef_parent = datasetsRef.parent) == null ? void 0 : (_datasetsRef_parent_parent = _datasetsRef_parent.parent) == null ? void 0 : _datasetsRef_parent_parent.id) === 'orgs';
48
+ const datasetId = (_binding_datasetId = binding.datasetId) == null ? void 0 : _binding_datasetId.trim();
49
+ if (datasetId) {
50
+ const datasetDoc = await datasetsRef.doc(datasetId).get();
51
+ if (datasetDoc.exists && (!orgScoped || Aglyn.visibleToHost(datasetDoc.get('visibleTo'), hostId))) {
52
+ return datasetDoc;
53
+ }
54
+ // A hit this host cannot see is NOT a reason to fall through to the
55
+ // name lookup — that would answer "which dataset is called X" for a
56
+ // binding that already named a specific one.
57
+ if (datasetDoc.exists) return undefined;
58
+ }
59
+ const datasetName = (_binding_datasetName = binding.datasetName) == null ? void 0 : _binding_datasetName.trim();
60
+ if (!datasetName) return undefined;
61
+ return findDatasetByName(datasetsRef, datasetName, orgScoped ? (query)=>query.where('visibleTo', 'array-contains-any', Aglyn.scopeTokensForHost(hostId)) : undefined);
62
+ }
63
+
64
+ //# sourceMappingURL=resolve-dataset.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/resolve-dataset.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 * as Aglyn from '@aglyn/aglyn/server'\n\n/**\n * Resolves a dataset by its human name: console-created docs store it as\n * `displayName` (AGL-536); the `name` fallback covers pre-migration docs.\n *\n * `scope` narrows the candidates to what one host may see (AGL-1039). It\n * is applied INSIDE the query rather than to the result, which is what\n * makes the collision case come out right: when a host-scoped dataset and\n * an org-wide one share a display name, an unscoped `limit(1)` would pick\n * one arbitrarily and a post-filter would then reject it — reading as\n * \"no such dataset\" when a perfectly visible one exists.\n */\nexport async function findDatasetByName(\n datasetsRef: FirebaseFirestore.CollectionReference,\n datasetName: string,\n scope?: (query: FirebaseFirestore.Query) => FirebaseFirestore.Query,\n): Promise<FirebaseFirestore.QueryDocumentSnapshot | undefined> {\n const narrow = scope ?? ((query: FirebaseFirestore.Query) => query)\n const byDisplayName = await narrow(\n datasetsRef.where('displayName', '==', datasetName),\n )\n .limit(1)\n .get()\n if (!byDisplayName.empty) return byDisplayName.docs[0]\n return (\n await narrow(datasetsRef.where('name', '==', datasetName)).limit(1).get()\n ).docs[0]\n}\n\n/**\n * Id-first dataset resolution (AGL-556): a `datasetId` binding is a\n * direct doc get — display names never enter into it, so renamed\n * datasets keep receiving records. When the id is absent or doesn't\n * resolve, the human-name query (AGL-536 semantics) is the legacy\n * fallback. Returns undefined when neither resolves; callers still own\n * the `deletedAt` check.\n */\nexport async function resolveDatasetDoc(\n datasetsRef: FirebaseFirestore.CollectionReference,\n binding: { datasetId?: string | null; datasetName?: string | null },\n hostId: string,\n): Promise<FirebaseFirestore.DocumentSnapshot | undefined> {\n // `hostId` is required rather than optional on purpose (AGL-1039): every\n // caller here writes records into whatever comes back, and an optional\n // scope is one a future caller forgets to pass. The Admin SDK ignores\n // rules, so this is the only thing standing between a client site and\n // another client's dataset.\n const orgScoped = datasetsRef.parent?.parent?.id === 'orgs'\n const datasetId = binding.datasetId?.trim()\n if (datasetId) {\n const datasetDoc = await datasetsRef.doc(datasetId).get()\n if (\n datasetDoc.exists &&\n (!orgScoped ||\n Aglyn.visibleToHost(datasetDoc.get('visibleTo'), hostId))\n ) {\n return datasetDoc\n }\n // A hit this host cannot see is NOT a reason to fall through to the\n // name lookup — that would answer \"which dataset is called X\" for a\n // binding that already named a specific one.\n if (datasetDoc.exists) return undefined\n }\n const datasetName = binding.datasetName?.trim()\n if (!datasetName) return undefined\n return findDatasetByName(\n datasetsRef,\n datasetName,\n orgScoped\n ? (query) =>\n query.where(\n 'visibleTo',\n 'array-contains-any',\n Aglyn.scopeTokensForHost(hostId),\n )\n : undefined,\n )\n}\n"],"names":["Aglyn","findDatasetByName","datasetsRef","datasetName","scope","narrow","query","byDisplayName","where","limit","get","empty","docs","resolveDatasetDoc","binding","hostId","orgScoped","parent","id","datasetId","trim","datasetDoc","doc","exists","visibleToHost","undefined","scopeTokensForHost"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,sBAAqB;AAE5C;;;;;;;;;;CAUC,GACD,OAAO,eAAeC,kBACpBC,WAAkD,EAClDC,WAAmB,EACnBC,KAAmE;IAEnE,MAAMC,SAASD,gBAAAA,QAAU,CAACE,QAAmCA;IAC7D,MAAMC,gBAAgB,MAAMF,OAC1BH,YAAYM,KAAK,CAAC,eAAe,MAAML,cAEtCM,KAAK,CAAC,GACNC,GAAG;IACN,IAAI,CAACH,cAAcI,KAAK,EAAE,OAAOJ,cAAcK,IAAI,CAAC,EAAE;IACtD,OAAO,AACL,CAAA,MAAMP,OAAOH,YAAYM,KAAK,CAAC,QAAQ,MAAML,cAAcM,KAAK,CAAC,GAAGC,GAAG,EAAC,EACxEE,IAAI,CAAC,EAAE;AACX;AAEA;;;;;;;CAOC,GACD,OAAO,eAAeC,kBACpBX,WAAkD,EAClDY,OAAmE,EACnEC,MAAc;QAOIb,4BAAAA,qBACAY,oBAeEA;IArBpB,yEAAyE;IACzE,uEAAuE;IACvE,sEAAsE;IACtE,sEAAsE;IACtE,4BAA4B;IAC5B,MAAME,YAAYd,EAAAA,sBAAAA,YAAYe,MAAM,sBAAlBf,6BAAAA,oBAAoBe,MAAM,qBAA1Bf,2BAA4BgB,EAAE,MAAK;IACrD,MAAMC,aAAYL,qBAAAA,QAAQK,SAAS,qBAAjBL,mBAAmBM,IAAI;IACzC,IAAID,WAAW;QACb,MAAME,aAAa,MAAMnB,YAAYoB,GAAG,CAACH,WAAWT,GAAG;QACvD,IACEW,WAAWE,MAAM,IAChB,CAAA,CAACP,aACAhB,MAAMwB,aAAa,CAACH,WAAWX,GAAG,CAAC,cAAcK,OAAM,GACzD;YACA,OAAOM;QACT;QACA,oEAAoE;QACpE,oEAAoE;QACpE,6CAA6C;QAC7C,IAAIA,WAAWE,MAAM,EAAE,OAAOE;IAChC;IACA,MAAMtB,eAAcW,uBAAAA,QAAQX,WAAW,qBAAnBW,qBAAqBM,IAAI;IAC7C,IAAI,CAACjB,aAAa,OAAOsB;IACzB,OAAOxB,kBACLC,aACAC,aACAa,YACI,CAACV,QACCA,MAAME,KAAK,CACT,aACA,sBACAR,MAAM0B,kBAAkB,CAACX,WAE7BU;AAER"}
@@ -0,0 +1,48 @@
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
+ import * as Aglyn from '@aglyn/aglyn/server';
18
+ type NodesMap = Record<string, Aglyn.AglynNodeSchema>;
19
+ export interface SearchResultsNode {
20
+ title: string;
21
+ url: string;
22
+ snippet: string;
23
+ collection?: {
24
+ slug: string;
25
+ title: string;
26
+ };
27
+ date?: string;
28
+ }
29
+ export interface SearchFacetNode {
30
+ key: string;
31
+ label: string;
32
+ count: number;
33
+ }
34
+ export interface SearchResultsNodesOptions {
35
+ query: string;
36
+ results: readonly SearchResultsNode[];
37
+ facets: readonly SearchFacetNode[];
38
+ /** The `?in=` facet in force; `all` (or absent) is the unfiltered view. */
39
+ activeFacet: string;
40
+ /** `all` — passed in so the tenant's route table stays the one source. */
41
+ allFacetKey: string;
42
+ }
43
+ /**
44
+ * The results page's node tree, root first. Grafted into a layout's slot by
45
+ * `composeNodesWithChrome`, exactly like a screen's own nodes.
46
+ */
47
+ export declare function buildSearchResultsNodes(options: SearchResultsNodesOptions): NodesMap;
48
+ export default buildSearchResultsNodes;
@@ -0,0 +1,302 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ /**
3
+ * @license
4
+ * Copyright 2026 Aglyn LLC
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this file except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ */ import * as Aglyn from "@aglyn/aglyn/server";
18
+ /**
19
+ * The site-search results page as canvas nodes (AGL-2513).
20
+ *
21
+ * ## Why nodes and not the React component it replaces
22
+ *
23
+ * `/search` rendered a bare MUI `Container` — no header, no nav, no footer —
24
+ * because a React body has nowhere to sit inside a designed layout: the
25
+ * layout's slot is filled at COMPOSE time, with nodes. Every other built-in
26
+ * page that wanted the site's chrome hit the same wall and answered it the
27
+ * same way; `collection-fallback-nodes.ts` (AGL-551) is the precedent this
28
+ * file follows, down to the synthetic id prefix.
29
+ *
30
+ * Building the body as nodes puts search on the one render path everything
31
+ * else uses — the site's theme, its shared layout, its nav and footer, its
32
+ * plugin runtimes — instead of a second, chrome-less one that had to be kept
33
+ * in step by hand.
34
+ *
35
+ * Ids are synthetic and NEVER persisted; the `srx__` prefix keeps them out of
36
+ * every id space an author's document uses.
37
+ */ const ID_PREFIX = 'srx__';
38
+ const id = (suffix)=>`${ID_PREFIX}${suffix}`;
39
+ const facetHref = (query, key, allKey)=>key === allKey ? `/search?q=${encodeURIComponent(query)}` : `/search?q=${encodeURIComponent(query)}&in=${encodeURIComponent(key)}`;
40
+ /**
41
+ * The results page's node tree, root first. Grafted into a layout's slot by
42
+ * `composeNodesWithChrome`, exactly like a screen's own nodes.
43
+ */ export function buildSearchResultsNodes(options) {
44
+ const { query, results, facets, activeFacet, allFacetKey } = options;
45
+ const nodes = {};
46
+ const stackChildren = [];
47
+ const push = (nodeId, parentId, node)=>{
48
+ nodes[nodeId] = _extends({
49
+ $id: nodeId,
50
+ parentId
51
+ }, node);
52
+ return nodeId;
53
+ };
54
+ nodes[Aglyn.NODE_ROOT_ID] = {
55
+ $id: Aglyn.NODE_ROOT_ID,
56
+ componentId: 'div',
57
+ nodes: [
58
+ id('container')
59
+ ]
60
+ };
61
+ push(id('container'), Aglyn.NODE_ROOT_ID, {
62
+ componentId: 'muiContainer',
63
+ pluginId: 'mui',
64
+ // `lg`, not the `md` a collection article takes: a result list is a list,
65
+ // not a prose measure, and the count tabs wrap early at `md`.
66
+ props: {
67
+ maxWidth: 'lg',
68
+ sx: {
69
+ paddingTop: 6,
70
+ paddingBottom: 8
71
+ }
72
+ },
73
+ nodes: [
74
+ id('stack')
75
+ ]
76
+ });
77
+ push(id('stack'), id('container'), {
78
+ componentId: 'muiStack',
79
+ pluginId: 'mui',
80
+ props: {
81
+ spacing: 4
82
+ },
83
+ nodes: stackChildren
84
+ });
85
+ // Heading block: the eyebrow and the question the reader asked.
86
+ push(id('head'), id('stack'), {
87
+ componentId: 'muiStack',
88
+ pluginId: 'mui',
89
+ props: {
90
+ spacing: 1
91
+ },
92
+ nodes: [
93
+ id('eyebrow'),
94
+ id('title')
95
+ ]
96
+ });
97
+ stackChildren.push(id('head'));
98
+ push(id('eyebrow'), id('head'), {
99
+ componentId: 'muiTypography',
100
+ pluginId: 'mui',
101
+ props: {
102
+ variant: 'overline',
103
+ children: 'SEARCH',
104
+ sx: {
105
+ color: 'text.secondary',
106
+ letterSpacing: '0.12em'
107
+ }
108
+ }
109
+ });
110
+ push(id('title'), id('head'), {
111
+ componentId: 'muiTypography',
112
+ pluginId: 'mui',
113
+ props: {
114
+ variant: 'h3',
115
+ component: 'h1',
116
+ children: query ? `Results for “${query}”` : 'Search'
117
+ }
118
+ });
119
+ // The field stays on the page, prefilled. A results page that cannot be
120
+ // refined without going back is a dead end — and `?in=` is deliberately not
121
+ // carried into it: a new query is a new question, and silently keeping the
122
+ // old collection filter is how a reader ends up staring at "no results" for
123
+ // a term the site does match.
124
+ // The width lives on a wrapper, not on the box itself: `searchBox` spreads
125
+ // its remaining props onto a plain `<form>`, so an `sx` handed to it would
126
+ // reach the DOM as an unknown attribute rather than as a style.
127
+ push(id('form'), id('stack'), {
128
+ componentId: 'muiBox',
129
+ pluginId: 'mui',
130
+ props: {
131
+ sx: {
132
+ maxWidth: 560
133
+ }
134
+ },
135
+ nodes: [
136
+ id('form-box')
137
+ ]
138
+ });
139
+ stackChildren.push(id('form'));
140
+ push(id('form-box'), id('form'), {
141
+ componentId: 'searchBox',
142
+ pluginId: 'mui',
143
+ props: {
144
+ placeholder: 'Search this site…',
145
+ defaultValue: query
146
+ }
147
+ });
148
+ // Count tabs. `searchResultFacets` returns nothing when every match came
149
+ // from one place — a lone "All · 6" tab reads as a filter over a list it
150
+ // cannot change.
151
+ if (facets.length) {
152
+ const facetIds = facets.map((facet)=>id(`facet-${facet.key}`));
153
+ push(id('facets'), id('stack'), {
154
+ componentId: 'muiStack',
155
+ pluginId: 'mui',
156
+ props: {
157
+ direction: 'row',
158
+ spacing: 1,
159
+ component: 'nav',
160
+ 'aria-label': 'Filter results by section',
161
+ sx: {
162
+ flexWrap: 'wrap',
163
+ rowGap: 1
164
+ }
165
+ },
166
+ nodes: facetIds
167
+ });
168
+ stackChildren.push(id('facets'));
169
+ for (const facet of facets){
170
+ const active = facet.key === activeFacet;
171
+ push(id(`facet-${facet.key}`), id('facets'), {
172
+ componentId: 'muiScreenLink',
173
+ pluginId: 'mui',
174
+ props: {
175
+ element: 'a',
176
+ href: facetHref(query, facet.key, allFacetKey),
177
+ size: 'small',
178
+ variant: active ? 'contained' : 'outlined',
179
+ // `muiScreenLink` is a MUI Button, and a Button uppercases its
180
+ // label. A count tab is a label, not a call to action.
181
+ sx: {
182
+ textTransform: 'none'
183
+ },
184
+ // The active tab is the one the list is showing, so it is state, not
185
+ // navigation to somewhere else.
186
+ 'aria-current': active ? 'page' : undefined,
187
+ children: `${facet.label} · ${facet.count}`
188
+ }
189
+ });
190
+ }
191
+ }
192
+ if (!results.length) {
193
+ push(id('empty'), id('stack'), {
194
+ componentId: 'muiTypography',
195
+ pluginId: 'mui',
196
+ props: {
197
+ variant: 'body1',
198
+ sx: {
199
+ color: 'text.secondary'
200
+ },
201
+ children: query ? `Nothing matched “${query}”. Try a different word, or fewer of them.` : 'Type something above to search this site.'
202
+ }
203
+ });
204
+ stackChildren.push(id('empty'));
205
+ return nodes;
206
+ }
207
+ const resultIds = [];
208
+ push(id('results'), id('stack'), {
209
+ componentId: 'muiStack',
210
+ pluginId: 'mui',
211
+ props: {
212
+ spacing: 3,
213
+ component: 'ul',
214
+ sx: {
215
+ listStyle: 'none',
216
+ p: 0,
217
+ m: 0
218
+ }
219
+ },
220
+ nodes: resultIds
221
+ });
222
+ stackChildren.push(id('results'));
223
+ results.forEach((result, index)=>{
224
+ var _result_collection;
225
+ const itemId = id(`item-${index}`);
226
+ const children = [
227
+ id(`item-${index}-link`)
228
+ ];
229
+ resultIds.push(itemId);
230
+ push(itemId, id('results'), {
231
+ componentId: 'muiStack',
232
+ pluginId: 'mui',
233
+ props: {
234
+ spacing: 0.5,
235
+ component: 'li'
236
+ },
237
+ nodes: children
238
+ });
239
+ push(id(`item-${index}-link`), itemId, {
240
+ componentId: 'muiScreenLink',
241
+ pluginId: 'mui',
242
+ props: {
243
+ element: 'a',
244
+ href: result.url,
245
+ variant: 'text',
246
+ sx: {
247
+ p: 0,
248
+ justifyContent: 'flex-start',
249
+ textAlign: 'left',
250
+ // Same Button inheritance as the tabs above, and it matters more
251
+ // here: a result's title is the author's own words, and the
252
+ // uppercase default reprints every one of them shouting.
253
+ textTransform: 'none',
254
+ fontSize: '1.0625rem',
255
+ fontWeight: 600
256
+ },
257
+ children: result.title
258
+ }
259
+ });
260
+ // The meta line is one string rather than a row of nodes: it is read as a
261
+ // sentence ("Blog · 12 August 2026"), and a wrapping row of separate
262
+ // elements breaks it in the middle on a phone.
263
+ const meta = [
264
+ (_result_collection = result.collection) == null ? void 0 : _result_collection.title,
265
+ result.date
266
+ ].filter(Boolean).join(' · ');
267
+ if (meta) {
268
+ const metaId = id(`item-${index}-meta`);
269
+ children.push(metaId);
270
+ push(metaId, itemId, {
271
+ componentId: 'muiTypography',
272
+ pluginId: 'mui',
273
+ props: {
274
+ variant: 'caption',
275
+ sx: {
276
+ color: 'text.secondary'
277
+ },
278
+ children: meta
279
+ }
280
+ });
281
+ }
282
+ if (result.snippet) {
283
+ const snippetId = id(`item-${index}-snippet`);
284
+ children.push(snippetId);
285
+ push(snippetId, itemId, {
286
+ componentId: 'muiTypography',
287
+ pluginId: 'mui',
288
+ props: {
289
+ variant: 'body2',
290
+ sx: {
291
+ color: 'text.secondary'
292
+ },
293
+ children: result.snippet
294
+ }
295
+ });
296
+ }
297
+ });
298
+ return nodes;
299
+ }
300
+ export default buildSearchResultsNodes;
301
+
302
+ //# sourceMappingURL=search-results-nodes.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/search-results-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 * as Aglyn from '@aglyn/aglyn/server'\n\n/**\n * The site-search results page as canvas nodes (AGL-2513).\n *\n * ## Why nodes and not the React component it replaces\n *\n * `/search` rendered a bare MUI `Container` — no header, no nav, no footer —\n * because a React body has nowhere to sit inside a designed layout: the\n * layout's slot is filled at COMPOSE time, with nodes. Every other built-in\n * page that wanted the site's chrome hit the same wall and answered it the\n * same way; `collection-fallback-nodes.ts` (AGL-551) is the precedent this\n * file follows, down to the synthetic id prefix.\n *\n * Building the body as nodes puts search on the one render path everything\n * else uses — the site's theme, its shared layout, its nav and footer, its\n * plugin runtimes — instead of a second, chrome-less one that had to be kept\n * in step by hand.\n *\n * Ids are synthetic and NEVER persisted; the `srx__` prefix keeps them out of\n * every id space an author's document uses.\n */\n\nconst ID_PREFIX = 'srx__'\nconst id = (suffix: string) => `${ID_PREFIX}${suffix}`\n\ntype NodesMap = Record<string, Aglyn.AglynNodeSchema>\n\nexport interface SearchResultsNode {\n title: string\n url: string\n snippet: string\n collection?: { slug: string; title: string }\n date?: string\n}\n\nexport interface SearchFacetNode {\n key: string\n label: string\n count: number\n}\n\nexport interface SearchResultsNodesOptions {\n query: string\n results: readonly SearchResultsNode[]\n facets: readonly SearchFacetNode[]\n /** The `?in=` facet in force; `all` (or absent) is the unfiltered view. */\n activeFacet: string\n /** `all` — passed in so the tenant's route table stays the one source. */\n allFacetKey: string\n}\n\nconst facetHref = (query: string, key: string, allKey: string) =>\n key === allKey\n ? `/search?q=${encodeURIComponent(query)}`\n : `/search?q=${encodeURIComponent(query)}&in=${encodeURIComponent(key)}`\n\n/**\n * The results page's node tree, root first. Grafted into a layout's slot by\n * `composeNodesWithChrome`, exactly like a screen's own nodes.\n */\nexport function buildSearchResultsNodes(\n options: SearchResultsNodesOptions,\n): NodesMap {\n const { query, results, facets, activeFacet, allFacetKey } = options\n const nodes: NodesMap = {}\n const stackChildren: string[] = []\n\n const push = (\n nodeId: string,\n parentId: string,\n node: Omit<Aglyn.AglynNodeSchema, '$id' | 'parentId'>,\n ) => {\n nodes[nodeId] = { $id: nodeId, parentId, ...node } as Aglyn.AglynNodeSchema\n return nodeId\n }\n\n nodes[Aglyn.NODE_ROOT_ID] = {\n $id: Aglyn.NODE_ROOT_ID,\n componentId: 'div',\n nodes: [id('container')],\n } as Aglyn.AglynNodeSchema\n push(id('container'), Aglyn.NODE_ROOT_ID, {\n componentId: 'muiContainer',\n pluginId: 'mui',\n // `lg`, not the `md` a collection article takes: a result list is a list,\n // not a prose measure, and the count tabs wrap early at `md`.\n props: { maxWidth: 'lg', sx: { paddingTop: 6, paddingBottom: 8 } },\n nodes: [id('stack')],\n })\n push(id('stack'), id('container'), {\n componentId: 'muiStack',\n pluginId: 'mui',\n props: { spacing: 4 },\n nodes: stackChildren,\n })\n\n // Heading block: the eyebrow and the question the reader asked.\n push(id('head'), id('stack'), {\n componentId: 'muiStack',\n pluginId: 'mui',\n props: { spacing: 1 },\n nodes: [id('eyebrow'), id('title')],\n })\n stackChildren.push(id('head'))\n push(id('eyebrow'), id('head'), {\n componentId: 'muiTypography',\n pluginId: 'mui',\n props: {\n variant: 'overline',\n children: 'SEARCH',\n sx: { color: 'text.secondary', letterSpacing: '0.12em' },\n },\n })\n push(id('title'), id('head'), {\n componentId: 'muiTypography',\n pluginId: 'mui',\n props: {\n variant: 'h3',\n component: 'h1',\n children: query ? `Results for “${query}”` : 'Search',\n },\n })\n\n // The field stays on the page, prefilled. A results page that cannot be\n // refined without going back is a dead end — and `?in=` is deliberately not\n // carried into it: a new query is a new question, and silently keeping the\n // old collection filter is how a reader ends up staring at \"no results\" for\n // a term the site does match.\n // The width lives on a wrapper, not on the box itself: `searchBox` spreads\n // its remaining props onto a plain `<form>`, so an `sx` handed to it would\n // reach the DOM as an unknown attribute rather than as a style.\n push(id('form'), id('stack'), {\n componentId: 'muiBox',\n pluginId: 'mui',\n props: { sx: { maxWidth: 560 } },\n nodes: [id('form-box')],\n })\n stackChildren.push(id('form'))\n push(id('form-box'), id('form'), {\n componentId: 'searchBox',\n pluginId: 'mui',\n props: { placeholder: 'Search this site…', defaultValue: query },\n })\n\n // Count tabs. `searchResultFacets` returns nothing when every match came\n // from one place — a lone \"All · 6\" tab reads as a filter over a list it\n // cannot change.\n if (facets.length) {\n const facetIds = facets.map((facet) => id(`facet-${facet.key}`))\n push(id('facets'), id('stack'), {\n componentId: 'muiStack',\n pluginId: 'mui',\n props: {\n direction: 'row',\n spacing: 1,\n component: 'nav',\n 'aria-label': 'Filter results by section',\n sx: { flexWrap: 'wrap', rowGap: 1 },\n },\n nodes: facetIds,\n })\n stackChildren.push(id('facets'))\n for (const facet of facets) {\n const active = facet.key === activeFacet\n push(id(`facet-${facet.key}`), id('facets'), {\n componentId: 'muiScreenLink',\n pluginId: 'mui',\n props: {\n element: 'a',\n href: facetHref(query, facet.key, allFacetKey),\n size: 'small',\n variant: active ? 'contained' : 'outlined',\n // `muiScreenLink` is a MUI Button, and a Button uppercases its\n // label. A count tab is a label, not a call to action.\n sx: { textTransform: 'none' },\n // The active tab is the one the list is showing, so it is state, not\n // navigation to somewhere else.\n 'aria-current': active ? 'page' : undefined,\n children: `${facet.label} · ${facet.count}`,\n },\n })\n }\n }\n\n if (!results.length) {\n push(id('empty'), id('stack'), {\n componentId: 'muiTypography',\n pluginId: 'mui',\n props: {\n variant: 'body1',\n sx: { color: 'text.secondary' },\n children: query\n ? `Nothing matched “${query}”. Try a different word, or fewer of them.`\n : 'Type something above to search this site.',\n },\n })\n stackChildren.push(id('empty'))\n return nodes\n }\n\n const resultIds: string[] = []\n push(id('results'), id('stack'), {\n componentId: 'muiStack',\n pluginId: 'mui',\n props: { spacing: 3, component: 'ul', sx: { listStyle: 'none', p: 0, m: 0 } },\n nodes: resultIds,\n })\n stackChildren.push(id('results'))\n\n results.forEach((result, index) => {\n const itemId = id(`item-${index}`)\n const children = [id(`item-${index}-link`)]\n resultIds.push(itemId)\n push(itemId, id('results'), {\n componentId: 'muiStack',\n pluginId: 'mui',\n props: { spacing: 0.5, component: 'li' },\n nodes: children,\n })\n push(id(`item-${index}-link`), itemId, {\n componentId: 'muiScreenLink',\n pluginId: 'mui',\n props: {\n element: 'a',\n href: result.url,\n variant: 'text',\n sx: {\n p: 0,\n justifyContent: 'flex-start',\n textAlign: 'left',\n // Same Button inheritance as the tabs above, and it matters more\n // here: a result's title is the author's own words, and the\n // uppercase default reprints every one of them shouting.\n textTransform: 'none',\n fontSize: '1.0625rem',\n fontWeight: 600,\n },\n children: result.title,\n },\n })\n // The meta line is one string rather than a row of nodes: it is read as a\n // sentence (\"Blog · 12 August 2026\"), and a wrapping row of separate\n // elements breaks it in the middle on a phone.\n const meta = [result.collection?.title, result.date]\n .filter(Boolean)\n .join(' · ')\n if (meta) {\n const metaId = id(`item-${index}-meta`)\n children.push(metaId)\n push(metaId, itemId, {\n componentId: 'muiTypography',\n pluginId: 'mui',\n props: {\n variant: 'caption',\n sx: { color: 'text.secondary' },\n children: meta,\n },\n })\n }\n if (result.snippet) {\n const snippetId = id(`item-${index}-snippet`)\n children.push(snippetId)\n push(snippetId, itemId, {\n componentId: 'muiTypography',\n pluginId: 'mui',\n props: {\n variant: 'body2',\n sx: { color: 'text.secondary' },\n children: result.snippet,\n },\n })\n }\n })\n\n return nodes\n}\n\nexport default buildSearchResultsNodes\n"],"names":["Aglyn","ID_PREFIX","id","suffix","facetHref","query","key","allKey","encodeURIComponent","buildSearchResultsNodes","options","results","facets","activeFacet","allFacetKey","nodes","stackChildren","push","nodeId","parentId","node","$id","NODE_ROOT_ID","componentId","pluginId","props","maxWidth","sx","paddingTop","paddingBottom","spacing","variant","children","color","letterSpacing","component","placeholder","defaultValue","length","facetIds","map","facet","direction","flexWrap","rowGap","active","element","href","size","textTransform","undefined","label","count","resultIds","listStyle","p","m","forEach","result","index","itemId","url","justifyContent","textAlign","fontSize","fontWeight","title","meta","collection","date","filter","Boolean","join","metaId","snippet","snippetId"],"mappings":"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"}