@aglyn/plugins-ai 1.0.0-beta.217 → 1.0.0-beta.219

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 (187) hide show
  1. package/README.md +2 -2
  2. package/package.json +17 -17
  3. package/src/index.d.ts +1 -0
  4. package/src/index.js.map +1 -1
  5. package/src/lib/activity/ai-activity-actions.js +10 -2
  6. package/src/lib/activity/ai-activity-actions.js.map +1 -1
  7. package/src/lib/components/ai-assist-provider.component.js +2 -2
  8. package/src/lib/components/ai-assist-provider.component.js.map +1 -1
  9. package/src/lib/components/ai-brief-dialog.component.js +3 -3
  10. package/src/lib/components/ai-brief-dialog.component.js.map +1 -1
  11. package/src/lib/components/ai-experiment-cards.component.js +1 -1
  12. package/src/lib/components/ai-experiment-cards.component.js.map +1 -1
  13. package/src/lib/components/ai-job-plan.component.d.ts +8 -0
  14. package/src/lib/components/ai-job-plan.component.js +21 -4
  15. package/src/lib/components/ai-job-plan.component.js.map +1 -1
  16. package/src/lib/components/ai-permissions-on-host.component.d.ts +12 -2
  17. package/src/lib/components/ai-permissions-on-host.component.js +12 -3
  18. package/src/lib/components/ai-permissions-on-host.component.js.map +1 -1
  19. package/src/lib/components/ai-product-copy-card.component.d.ts +1 -1
  20. package/src/lib/components/ai-product-copy-card.component.js.map +1 -1
  21. package/src/lib/components/ai-product-import-option.component.d.ts +1 -1
  22. package/src/lib/components/ai-product-import-option.component.js.map +1 -1
  23. package/src/lib/components/ai-product-zones.d.ts +124 -0
  24. package/src/lib/components/ai-product-zones.js +32 -0
  25. package/src/lib/components/ai-product-zones.js.map +1 -0
  26. package/src/lib/components/ai-products-hub-card.component.d.ts +1 -1
  27. package/src/lib/components/ai-products-hub-card.component.js.map +1 -1
  28. package/src/lib/components/ai-products-proposals.component.d.ts +1 -1
  29. package/src/lib/components/ai-products-proposals.component.js.map +1 -1
  30. package/src/lib/components/ai-seo-audit-card.component.js +35 -92
  31. package/src/lib/components/ai-seo-audit-card.component.js.map +1 -1
  32. package/src/lib/components/ai-seo-fields-card.component.js.map +1 -1
  33. package/src/lib/components/ai-theme-proposal-card.component.js.map +1 -1
  34. package/src/lib/components/assist-jobs-drawer.component.js +21 -15
  35. package/src/lib/components/assist-jobs-drawer.component.js.map +1 -1
  36. package/src/lib/components/assist-panel.component.js +2 -2
  37. package/src/lib/components/assist-panel.component.js.map +1 -1
  38. package/src/lib/components/assist-signals-page.component.js +2 -2
  39. package/src/lib/components/assist-signals-page.component.js.map +1 -1
  40. package/src/lib/jobs/ai-email-bindings.d.ts +28 -21
  41. package/src/lib/jobs/ai-email-bindings.js +75 -42
  42. package/src/lib/jobs/ai-email-bindings.js.map +1 -1
  43. package/src/lib/jobs/ai-job-campaign-step.d.ts +0 -2
  44. package/src/lib/jobs/ai-job-campaign-step.js +5 -10
  45. package/src/lib/jobs/ai-job-campaign-step.js.map +1 -1
  46. package/src/lib/jobs/ai-job-component-step.js +9 -3
  47. package/src/lib/jobs/ai-job-component-step.js.map +1 -1
  48. package/src/lib/jobs/ai-job-drafts.d.ts +9 -1
  49. package/src/lib/jobs/ai-job-drafts.js +12 -5
  50. package/src/lib/jobs/ai-job-drafts.js.map +1 -1
  51. package/src/lib/jobs/ai-job-email-step.d.ts +0 -2
  52. package/src/lib/jobs/ai-job-email-step.js +1 -3
  53. package/src/lib/jobs/ai-job-email-step.js.map +1 -1
  54. package/src/lib/jobs/ai-job-experiment-step.d.ts +1 -1
  55. package/src/lib/jobs/ai-job-experiment-step.js +1 -1
  56. package/src/lib/jobs/ai-job-experiment-step.js.map +1 -1
  57. package/src/lib/jobs/ai-job-generation.d.ts +1 -1
  58. package/src/lib/jobs/ai-job-generation.js +10 -2
  59. package/src/lib/jobs/ai-job-generation.js.map +1 -1
  60. package/src/lib/jobs/ai-job-layout-step.js +1 -1
  61. package/src/lib/jobs/ai-job-layout-step.js.map +1 -1
  62. package/src/lib/jobs/ai-job-page-sections.d.ts +4 -2
  63. package/src/lib/jobs/ai-job-page-sections.js +7 -4
  64. package/src/lib/jobs/ai-job-page-sections.js.map +1 -1
  65. package/src/lib/jobs/ai-job-page-step.js +7 -2
  66. package/src/lib/jobs/ai-job-page-step.js.map +1 -1
  67. package/src/lib/jobs/ai-job-plan-conformance.js +33 -0
  68. package/src/lib/jobs/ai-job-plan-conformance.js.map +1 -1
  69. package/src/lib/jobs/ai-job-plan-step.d.ts +3 -0
  70. package/src/lib/jobs/ai-job-plan-step.js +29 -17
  71. package/src/lib/jobs/ai-job-plan-step.js.map +1 -1
  72. package/src/lib/jobs/ai-job-plugin-drafts.d.ts +14 -6
  73. package/src/lib/jobs/ai-job-plugin-drafts.js +12 -4
  74. package/src/lib/jobs/ai-job-plugin-drafts.js.map +1 -1
  75. package/src/lib/jobs/ai-job-products-step.d.ts +7 -0
  76. package/src/lib/jobs/ai-job-products-step.js +54 -32
  77. package/src/lib/jobs/ai-job-products-step.js.map +1 -1
  78. package/src/lib/jobs/ai-job-seo-step.d.ts +3 -24
  79. package/src/lib/jobs/ai-job-seo-step.js +35 -108
  80. package/src/lib/jobs/ai-job-seo-step.js.map +1 -1
  81. package/src/lib/jobs/ai-job-template-step.js +1 -0
  82. package/src/lib/jobs/ai-job-template-step.js.map +1 -1
  83. package/src/lib/jobs/ai-job-theme-step.d.ts +1 -1
  84. package/src/lib/jobs/ai-job-theme-step.js +1 -1
  85. package/src/lib/jobs/ai-job-theme-step.js.map +1 -1
  86. package/src/lib/jobs/ai-job-workflow-step.js +17 -9
  87. package/src/lib/jobs/ai-job-workflow-step.js.map +1 -1
  88. package/src/lib/jobs/ai-workflow-records.d.ts +19 -11
  89. package/src/lib/jobs/ai-workflow-records.js +60 -27
  90. package/src/lib/jobs/ai-workflow-records.js.map +1 -1
  91. package/src/lib/model/ai-allotments.d.ts +11 -3
  92. package/src/lib/model/ai-allotments.js +21 -4
  93. package/src/lib/model/ai-allotments.js.map +1 -1
  94. package/src/lib/model/ai-automation-outline.d.ts +21 -2
  95. package/src/lib/model/ai-automation-outline.js.map +1 -1
  96. package/src/lib/model/ai-build-plan.d.ts +57 -0
  97. package/src/lib/model/ai-build-plan.js +116 -7
  98. package/src/lib/model/ai-build-plan.js.map +1 -1
  99. package/src/lib/model/ai-seo.d.ts +11 -51
  100. package/src/lib/model/ai-seo.js +0 -17
  101. package/src/lib/model/ai-seo.js.map +1 -1
  102. package/src/lib/model/ai-site-inventory.js +1 -1
  103. package/src/lib/model/ai-site-inventory.js.map +1 -1
  104. package/src/lib/model/ai-template-subjects.js +2 -2
  105. package/src/lib/model/ai-template-subjects.js.map +1 -1
  106. package/src/lib/model/ai-theme-proposal.d.ts +1 -1
  107. package/src/lib/model/ai-theme-proposal.js +1 -1
  108. package/src/lib/model/ai-theme-proposal.js.map +1 -1
  109. package/src/lib/model/ai-workflow-job.d.ts +1 -3
  110. package/src/lib/model/ai-workflow-job.js +1 -2
  111. package/src/lib/model/ai-workflow-job.js.map +1 -1
  112. package/src/lib/plan-entitlements.d.ts +149 -0
  113. package/src/lib/plan-entitlements.js +168 -0
  114. package/src/lib/plan-entitlements.js.map +1 -0
  115. package/src/lib/plugin.js +1 -1
  116. package/src/lib/plugin.js.map +1 -1
  117. package/src/lib/runtime/ai-doctrine-validators.d.ts +25 -4
  118. package/src/lib/runtime/ai-doctrine-validators.js +179 -23
  119. package/src/lib/runtime/ai-doctrine-validators.js.map +1 -1
  120. package/src/lib/runtime/ai-doctrine.js +5 -5
  121. package/src/lib/runtime/ai-doctrine.js.map +1 -1
  122. package/src/lib/runtime/ai-eval-live.js +29 -14
  123. package/src/lib/runtime/ai-eval-live.js.map +1 -1
  124. package/src/lib/runtime/ai-eval.d.ts +8 -1
  125. package/src/lib/runtime/ai-eval.js +29 -2
  126. package/src/lib/runtime/ai-eval.js.map +1 -1
  127. package/src/lib/runtime/ai-node-tree.js +3 -3
  128. package/src/lib/runtime/ai-node-tree.js.map +1 -1
  129. package/src/lib/runtime/ai-palette.d.ts +6 -0
  130. package/src/lib/runtime/ai-palette.generated.js +21 -255
  131. package/src/lib/runtime/ai-palette.generated.js.map +1 -1
  132. package/src/lib/runtime/ai-palette.js +7 -1
  133. package/src/lib/runtime/ai-palette.js.map +1 -1
  134. package/src/lib/runtime/seo-audit.d.ts +13 -63
  135. package/src/lib/runtime/seo-audit.js +22 -207
  136. package/src/lib/runtime/seo-audit.js.map +1 -1
  137. package/src/lib/runtime/site-inventory.js +1 -1
  138. package/src/lib/runtime/site-inventory.js.map +1 -1
  139. package/src/lib/server/ai-admin-org.js +4 -25
  140. package/src/lib/server/ai-admin-org.js.map +1 -1
  141. package/src/lib/server/ai-admin-user.js +2 -19
  142. package/src/lib/server/ai-admin-user.js.map +1 -1
  143. package/src/lib/server/ai-assist-prompts.js +3 -3
  144. package/src/lib/server/ai-assist-prompts.js.map +1 -1
  145. package/src/lib/server/ai-insight-digest-route.js +8 -1
  146. package/src/lib/server/ai-insight-digest-route.js.map +1 -1
  147. package/src/lib/server/assist-chat.js +11 -10
  148. package/src/lib/server/assist-chat.js.map +1 -1
  149. package/src/lib/server/assist-view-context.js +23 -23
  150. package/src/lib/server/assist-view-context.js.map +1 -1
  151. package/src/lib/server.d.ts +1 -0
  152. package/src/lib/server.js.map +1 -1
  153. package/src/lib/testing/stand-in-automation-index.d.ts +20 -0
  154. package/src/lib/testing/stand-in-automation-index.js +102 -0
  155. package/src/lib/testing/stand-in-automation-index.js.map +1 -0
  156. package/src/lib/testing/stand-in-list-send-time.d.ts +4 -0
  157. package/src/lib/testing/stand-in-list-send-time.js +77 -0
  158. package/src/lib/testing/stand-in-list-send-time.js.map +1 -0
  159. package/src/lib/testing/stand-in-pipeline-index.d.ts +32 -0
  160. package/src/lib/testing/stand-in-pipeline-index.js +59 -0
  161. package/src/lib/testing/stand-in-pipeline-index.js.map +1 -0
  162. package/src/lib/testing/stand-in-product-index.d.ts +20 -0
  163. package/src/lib/testing/stand-in-product-index.js +105 -0
  164. package/src/lib/testing/stand-in-product-index.js.map +1 -0
  165. package/src/lib/tools/ai-component-tool.js +2 -2
  166. package/src/lib/tools/ai-component-tool.js.map +1 -1
  167. package/src/lib/tools/ai-duplicate-resource-tool.js +1 -1
  168. package/src/lib/tools/ai-duplicate-resource-tool.js.map +1 -1
  169. package/src/lib/tools/ai-seo-tool.d.ts +6 -18
  170. package/src/lib/tools/ai-seo-tool.js +7 -38
  171. package/src/lib/tools/ai-seo-tool.js.map +1 -1
  172. package/src/lib/tools/ai-workflow-tool.d.ts +1 -1
  173. package/src/lib/tools/ai-workflow-tool.js +1 -1
  174. package/src/lib/tools/ai-workflow-tool.js.map +1 -1
  175. package/src/lib/usage/ai-allotment-alerts.d.ts +3 -0
  176. package/src/lib/usage/ai-allotment-alerts.js +20 -8
  177. package/src/lib/usage/ai-allotment-alerts.js.map +1 -1
  178. package/src/lib/usage/assist-free-taste.js +2 -1
  179. package/src/lib/usage/assist-free-taste.js.map +1 -1
  180. package/src/lib/usage/assist-signal-mining.js +1 -1
  181. package/src/lib/usage/assist-signal-mining.js.map +1 -1
  182. package/src/lib/usage-axes.d.ts +39 -0
  183. package/src/lib/usage-axes.js +70 -0
  184. package/src/lib/usage-axes.js.map +1 -0
  185. package/src/lib/runtime/seo-page-facts.d.ts +0 -61
  186. package/src/lib/runtime/seo-page-facts.js +0 -150
  187. package/src/lib/runtime/seo-page-facts.js.map +0 -1
@@ -135,6 +135,12 @@ export const AI_OUTPUT_KINDS = [
135
135
  * page loads, so it carries the least imagery; a form is fields and a
136
136
  * button; an email is held to the clipping threshold on its rendered HTML.
137
137
  * No kind names a font beyond the theme's.
138
+ *
139
+ * A third-party player is a page's or a component's to carry, and only where
140
+ * a confirmed plan lists it (rule 16). A template is rendered for every
141
+ * record it serves and a layout around every page, so neither carries one
142
+ * (AGL-3433): an embed there loads a stranger's code on every page of a
143
+ * collection or a site.
138
144
  */ export const AI_OUTPUT_BUDGETS = {
139
145
  page: {
140
146
  nodes: 400,
@@ -147,7 +153,7 @@ export const AI_OUTPUT_KINDS = [
147
153
  nodes: 400,
148
154
  bytes: 60000,
149
155
  imageBytes: 1500000,
150
- embeds: 1,
156
+ embeds: 0,
151
157
  fontFamilies: 0
152
158
  },
153
159
  component: {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/ai-palette.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { JsonLinealOrder } from '@aglyn/aglyn/app-utils/lineal-order'\n\n/**\n * The element palette as a model sees it (AGL-2905).\n *\n * `ai-palette.generated.ts` is produced from the registered plugin bundles by\n * `tools/scripts/generate-ai-palette.mts` and holds the data; this module\n * holds the shapes that data is typed against and the small readers over it,\n * so a regeneration never rewrites a type.\n */\n\n/** The document kinds a model is asked to compose. */\nexport type AiSurface = 'screen' | 'email' | 'form' | 'layout' | 'component'\n\nexport const AI_SURFACE_NAMES: readonly AiSurface[] = [\n 'screen',\n 'email',\n 'form',\n 'layout',\n 'component',\n]\n\n/**\n * The subset of JSON Schema a prop declaration is expressed in. Enough to\n * validate what an attribute field can persist; nothing the field editor\n * cannot itself model.\n */\nexport interface AiPropSchema {\n type?: 'string' | 'number' | 'integer' | 'boolean'\n enum?: readonly string[]\n minLength?: number\n maxLength?: number\n minimum?: number\n maximum?: number\n pattern?: string\n description?: string\n}\n\nexport interface AiPropsSchema {\n type: 'object'\n properties: Record<string, AiPropSchema>\n required: readonly string[]\n additionalProperties: false\n}\n\n/**\n * What a string prop MEANS, which decides how the validator checks it.\n *\n * - `text` — copy the visitor reads; held to a length ceiling.\n * - `url` — a navigable address: an `https:` URL or a root-relative path.\n * - `screen` — a screen id, checked against the screens the host has.\n * - `media` — an image or video source: a DAM reference or an `https:` URL.\n */\nexport type AiPropRole = 'text' | 'url' | 'screen' | 'media'\n\n/**\n * A `restrictChildren` / `restrictParent` directive as generated data: the\n * same tuple the schema declares, with the flag spelled as the string the\n * enum holds so a JSON emitter can write it.\n */\nexport type AiLinealOrder = JsonLinealOrder\n\nexport interface AiPaletteEntry {\n pluginId: string\n kind: 'element' | 'plaintext' | 'markdown'\n category: string\n displayName: string\n /** One sentence, from the schema's own description. */\n summary: string\n /** Whether the editor lets anything be dropped inside this element. */\n acceptsChildren: boolean\n restrictChildren?: AiLinealOrder\n restrictParent?: AiLinealOrder\n propsSchema: AiPropsSchema\n /** Roles of the string props the validator treats specially. */\n propRoles: Record<string, AiPropRole>\n /**\n * The attribute field kind each declared prop is edited with, as its\n * `FieldComponentType` value (`switch`, `select`, `screen-select`, …): what\n * the editor reads to decide which component properties a field can be\n * bound to (AGL-2908).\n */\n propFields: Record<string, string>\n /** Length ceilings for `text` props, in characters. */\n textLimits: Record<string, number>\n /** Names of the presets that place this component. */\n presets: readonly string[]\n}\n\n/**\n * What every NODE can do, as against what one component declares (AGL-3156).\n *\n * Generated from the node capabilities core declares, beside the per-component\n * schemas rather than out of them: repeating is not a Stack's feature, so no\n * component schema names it and a palette built from schemas alone advertises\n * it on nothing. Declared ONCE for the whole palette — a model may write these\n * props on any element the surface allows.\n */\nexport interface AiNodeCapability {\n /** Stable id, as core declares it. */\n id: string\n displayName: string\n /** One sentence, for the prompt catalog. */\n summary: string\n propsSchema: AiPropsSchema\n propRoles: Record<string, AiPropRole>\n propFields: Record<string, string>\n textLimits: Record<string, number>\n /** Props offered only on a node that holds a child list. */\n childrenOnlyProps: readonly string[]\n /**\n * Declared attributes the palette deliberately does not offer: a picker\n * that chooses a record the site holds, which a model cannot name from a\n * description. Recorded so a field left off on purpose reads differently\n * from one that went missing.\n */\n omittedProps: readonly string[]\n}\n\n/**\n * The capability props a node with this child contract may carry, merged into\n * one map the prop sanitizer can read beside the element's own declarations.\n */\nexport function nodeCapabilityProps(\n capabilities: Readonly<Record<string, AiNodeCapability>>,\n acceptsChildren: boolean,\n): {\n properties: Record<string, AiPropSchema>\n propRoles: Record<string, AiPropRole>\n textLimits: Record<string, number>\n} {\n const properties: Record<string, AiPropSchema> = {}\n const propRoles: Record<string, AiPropRole> = {}\n const textLimits: Record<string, number> = {}\n for (const capability of Object.values(capabilities)) {\n for (const [name, schema] of Object.entries(\n capability.propsSchema.properties,\n )) {\n if (!acceptsChildren && capability.childrenOnlyProps.includes(name))\n continue\n properties[name] = schema\n const role = capability.propRoles[name]\n if (role) propRoles[name] = role\n const limit = capability.textLimits[name]\n if (limit !== undefined) textLimits[name] = limit\n }\n }\n return { properties, propRoles, textLimits }\n}\n\n/**\n * What a node capability declared in core and one carried by the palette\n * disagree about (AGL-3156).\n *\n * `check:ai-palette` pins the generated file to the generator's output for its\n * inputs, which cannot notice a capability leaving those inputs: when repeat\n * stopped being a component's attribute, the generator regenerated cleanly and\n * the palette simply stopped mentioning it. This reads the core declarations\n * instead and asks whether the palette still accounts for each one, so the\n * next generic capability cannot vanish the same way.\n *\n * `offeredFieldKinds` is every field kind the palette DOES carry somewhere,\n * derived from the generated entries rather than restated here: a capability\n * attribute drawn with one of those kinds has no excuse for being omitted.\n */\nexport function auditNodeCapabilities(\n declared: ReadonlyArray<{\n id: string\n label: string\n attributes: ReadonlyArray<{ name: string; component: string }>\n }>,\n carried: Readonly<Record<string, AiNodeCapability>>,\n offeredFieldKinds: ReadonlySet<string>,\n): string[] {\n const problems: string[] = []\n for (const capability of declared) {\n const entry = carried[capability.id]\n if (!entry) {\n problems.push(\n `the palette carries no node capability \"${capability.id}\": a model cannot use it at all`,\n )\n continue\n }\n if (!Object.keys(entry.propsSchema.properties).length) {\n problems.push(\n `node capability \"${capability.id}\" is carried with no prop a model can write`,\n )\n }\n for (const attribute of capability.attributes) {\n if (entry.propsSchema.properties[attribute.name]) continue\n if (!entry.omittedProps.includes(attribute.name)) {\n problems.push(\n `node capability \"${capability.id}\" declares \"${attribute.name}\", which the palette neither offers nor records as omitted`,\n )\n continue\n }\n if (offeredFieldKinds.has(attribute.component)) {\n problems.push(\n `node capability \"${capability.id}\" omits \"${attribute.name}\", whose \"${attribute.component}\" field the palette offers elsewhere`,\n )\n }\n }\n }\n for (const id of Object.keys(carried)) {\n if (!declared.some((capability) => capability.id === id)) {\n problems.push(\n `the palette carries node capability \"${id}\", which core no longer declares`,\n )\n }\n }\n return problems\n}\n\nexport interface AiSurfaceDefinition {\n /** The component id at the top of a tree for this surface. */\n root: string\n /** Every component id a node in this surface may carry. */\n allow: readonly string[]\n}\n\n/**\n * Theme vocabulary an `sx` value may name, read off the theme sources so the\n * validator's allowlist is derived rather than typed by hand.\n */\nexport interface AiSxTokens {\n /** Dotted palette paths such as `primary.main`. */\n palette: readonly string[]\n /** Responsive object keys. */\n breakpoints: readonly string[]\n /** Values `typography` may take. */\n typographyVariants: readonly string[]\n}\n\n/**\n * Text ceilings per role of copy (AGL-2905). A headline is one line of a\n * hero, body copy is a paragraph or two, and a button label has to fit a\n * button.\n */\nexport const AI_TEXT_LIMITS = {\n headline: 120,\n body: 2_000,\n button: 40,\n /** A short label, title or caption. */\n label: 200,\n} as const\n\n/**\n * Prompt budget per surface catalog, in characters at four per token.\n * Asserted by a spec so a component that grows its description cannot\n * silently push a system prompt past what the route pays for.\n */\nexport const AI_PALETTE_CATALOG_MAX_TOKENS = 2_500\nexport const AI_PALETTE_CATALOG_MAX_CHARS = AI_PALETTE_CATALOG_MAX_TOKENS * 4\n\n/** The model's own token estimate: characters over four. */\nexport function estimateCatalogTokens(catalog: string): number {\n return Math.ceil(catalog.length / 4)\n}\n\n/**\n * The documents a generator emits (AGL-2935). Each is composed on one\n * surface of the palette and held to its own budget below.\n */\nexport type AiOutputKind =\n | 'page'\n | 'template'\n | 'component'\n | 'layout'\n | 'form'\n | 'email'\n\nexport const AI_OUTPUT_KINDS: readonly AiOutputKind[] = [\n 'page',\n 'template',\n 'component',\n 'layout',\n 'form',\n 'email',\n]\n\n/** The surface each output kind is composed on. */\nexport const AI_OUTPUT_SURFACE: Record<AiOutputKind, AiSurface> = {\n page: 'screen',\n template: 'screen',\n component: 'component',\n layout: 'layout',\n form: 'form',\n email: 'email',\n}\n\n/**\n * What the rule-17 scorer measures on an emitted tree:\n *\n * - `nodes` — how many elements the tree carries;\n * - `bytes` — the stored node map, in the unit the save ceiling uses\n * (`nodeMapBytes`, msgpack);\n * - `imageBytes` — what the placed library images request at the slot's\n * variant, from each asset's recorded size;\n * - `embeds` — third-party players and frames the output loads;\n * - `fontFamilies` — families named beyond the theme's own;\n * - `emailHtmlBytes` — an email's rendered HTML, which is what a mail\n * client clips.\n */\nexport type AiBudgetMetric =\n | 'nodes'\n | 'bytes'\n | 'imageBytes'\n | 'embeds'\n | 'fontFamilies'\n | 'emailHtmlBytes'\n\nexport type AiOutputBudget = Record<Exclude<AiBudgetMetric, 'emailHtmlBytes'>, number> & {\n emailHtmlBytes?: number\n}\n\n/**\n * The size past which Gmail clips a message and hides the rest behind a\n * \"View entire message\" link — the unsubscribe footer included.\n */\nexport const AI_EMAIL_CLIP_BYTES = 102_000\n\n/**\n * The budget per output kind (AGL-2935, rule 17), beside the catalog budget\n * above for the same reason: a generator that grows its output past what\n * its kind is for is caught by a number, not noticed by a reader.\n *\n * A page is a few hundred elements and tens of kilobytes of stored map; a\n * reusable component is one repeated block; a layout is the chrome every\n * page loads, so it carries the least imagery; a form is fields and a\n * button; an email is held to the clipping threshold on its rendered HTML.\n * No kind names a font beyond the theme's.\n */\nexport const AI_OUTPUT_BUDGETS: Record<AiOutputKind, AiOutputBudget> = {\n page: {\n nodes: 400,\n bytes: 60_000,\n imageBytes: 1_500_000,\n embeds: 1,\n fontFamilies: 0,\n },\n template: {\n nodes: 400,\n bytes: 60_000,\n imageBytes: 1_500_000,\n embeds: 1,\n fontFamilies: 0,\n },\n component: {\n nodes: 80,\n bytes: 12_000,\n imageBytes: 500_000,\n embeds: 1,\n fontFamilies: 0,\n },\n layout: {\n nodes: 160,\n bytes: 24_000,\n imageBytes: 200_000,\n embeds: 0,\n fontFamilies: 0,\n },\n form: {\n nodes: 60,\n bytes: 8_000,\n imageBytes: 0,\n embeds: 0,\n fontFamilies: 0,\n },\n email: {\n nodes: 150,\n bytes: 24_000,\n imageBytes: 1_000_000,\n embeds: 0,\n fontFamilies: 0,\n emailHtmlBytes: AI_EMAIL_CLIP_BYTES,\n },\n}\n\n/**\n * What a first visit to a generated output is estimated to transfer — the\n * weight the proposal shows before anything is applied (rule 17). A page\n * starts from the platform's measured page weight; a component or a layout\n * reports only what it adds to a page; an email is not a page load.\n */\nexport interface AiLoadEstimate {\n /** The measured weight of a published page before this output's own bytes; 0 for a fragment. */\n pageBytes: number\n /** The stored node map, in the save's own bytes. */\n documentBytes: number\n /** What the placed library images request at their slots. */\n imageBytes: number\n /** Images the estimate could not size: no recorded size, or not from the library. */\n imagesUnmeasured: number\n embeds: number\n totalBytes: number\n}\n\n/** Typography variants that read as headlines, for the `children` ceiling. */\nexport function isHeadlineVariant(variant: unknown): boolean {\n return typeof variant === 'string' && /^(display\\w*|h[1-6]|heading)$/.test(variant)\n}\n"],"names":["AI_SURFACE_NAMES","nodeCapabilityProps","capabilities","acceptsChildren","properties","propRoles","textLimits","capability","Object","values","name","schema","entries","propsSchema","childrenOnlyProps","includes","role","limit","undefined","auditNodeCapabilities","declared","carried","offeredFieldKinds","problems","entry","id","push","keys","length","attribute","attributes","omittedProps","has","component","some","AI_TEXT_LIMITS","headline","body","button","label","AI_PALETTE_CATALOG_MAX_TOKENS","AI_PALETTE_CATALOG_MAX_CHARS","estimateCatalogTokens","catalog","Math","ceil","AI_OUTPUT_KINDS","AI_OUTPUT_SURFACE","page","template","layout","form","email","AI_EMAIL_CLIP_BYTES","AI_OUTPUT_BUDGETS","nodes","bytes","imageBytes","embeds","fontFamilies","emailHtmlBytes","isHeadlineVariant","variant","test"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/ai-palette.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { JsonLinealOrder } from '@aglyn/aglyn/app-utils/lineal-order'\n\n/**\n * The element palette as a model sees it (AGL-2905).\n *\n * `ai-palette.generated.ts` is produced from the registered plugin bundles by\n * `tools/scripts/generate-ai-palette.mts` and holds the data; this module\n * holds the shapes that data is typed against and the small readers over it,\n * so a regeneration never rewrites a type.\n */\n\n/** The document kinds a model is asked to compose. */\nexport type AiSurface = 'screen' | 'email' | 'form' | 'layout' | 'component'\n\nexport const AI_SURFACE_NAMES: readonly AiSurface[] = [\n 'screen',\n 'email',\n 'form',\n 'layout',\n 'component',\n]\n\n/**\n * The subset of JSON Schema a prop declaration is expressed in. Enough to\n * validate what an attribute field can persist; nothing the field editor\n * cannot itself model.\n */\nexport interface AiPropSchema {\n type?: 'string' | 'number' | 'integer' | 'boolean'\n enum?: readonly string[]\n minLength?: number\n maxLength?: number\n minimum?: number\n maximum?: number\n pattern?: string\n description?: string\n}\n\nexport interface AiPropsSchema {\n type: 'object'\n properties: Record<string, AiPropSchema>\n required: readonly string[]\n additionalProperties: false\n}\n\n/**\n * What a string prop MEANS, which decides how the validator checks it.\n *\n * - `text` — copy the visitor reads; held to a length ceiling.\n * - `url` — a navigable address: an `https:` URL or a root-relative path.\n * - `screen` — a screen id, checked against the screens the host has.\n * - `media` — an image or video source: a DAM reference or an `https:` URL.\n */\nexport type AiPropRole = 'text' | 'url' | 'screen' | 'media'\n\n/**\n * A `restrictChildren` / `restrictParent` directive as generated data: the\n * same tuple the schema declares, with the flag spelled as the string the\n * enum holds so a JSON emitter can write it.\n */\nexport type AiLinealOrder = JsonLinealOrder\n\nexport interface AiPaletteEntry {\n pluginId: string\n kind: 'element' | 'plaintext' | 'markdown'\n category: string\n displayName: string\n /** One sentence, from the schema's own description. */\n summary: string\n /** Whether the editor lets anything be dropped inside this element. */\n acceptsChildren: boolean\n restrictChildren?: AiLinealOrder\n restrictParent?: AiLinealOrder\n propsSchema: AiPropsSchema\n /** Roles of the string props the validator treats specially. */\n propRoles: Record<string, AiPropRole>\n /**\n * The attribute field kind each declared prop is edited with, as its\n * `FieldComponentType` value (`switch`, `select`, `screen-select`, …): what\n * the editor reads to decide which component properties a field can be\n * bound to (AGL-2908).\n */\n propFields: Record<string, string>\n /** Length ceilings for `text` props, in characters. */\n textLimits: Record<string, number>\n /** Names of the presets that place this component. */\n presets: readonly string[]\n}\n\n/**\n * What every NODE can do, as against what one component declares (AGL-3156).\n *\n * Generated from the node capabilities core declares, beside the per-component\n * schemas rather than out of them: repeating is not a Stack's feature, so no\n * component schema names it and a palette built from schemas alone advertises\n * it on nothing. Declared ONCE for the whole palette — a model may write these\n * props on any element the surface allows.\n */\nexport interface AiNodeCapability {\n /** Stable id, as core declares it. */\n id: string\n displayName: string\n /** One sentence, for the prompt catalog. */\n summary: string\n propsSchema: AiPropsSchema\n propRoles: Record<string, AiPropRole>\n propFields: Record<string, string>\n textLimits: Record<string, number>\n /** Props offered only on a node that holds a child list. */\n childrenOnlyProps: readonly string[]\n /**\n * Declared attributes the palette deliberately does not offer: a picker\n * that chooses a record the site holds, which a model cannot name from a\n * description. Recorded so a field left off on purpose reads differently\n * from one that went missing.\n */\n omittedProps: readonly string[]\n}\n\n/**\n * The capability props a node with this child contract may carry, merged into\n * one map the prop sanitizer can read beside the element's own declarations.\n */\nexport function nodeCapabilityProps(\n capabilities: Readonly<Record<string, AiNodeCapability>>,\n acceptsChildren: boolean,\n): {\n properties: Record<string, AiPropSchema>\n propRoles: Record<string, AiPropRole>\n textLimits: Record<string, number>\n} {\n const properties: Record<string, AiPropSchema> = {}\n const propRoles: Record<string, AiPropRole> = {}\n const textLimits: Record<string, number> = {}\n for (const capability of Object.values(capabilities)) {\n for (const [name, schema] of Object.entries(\n capability.propsSchema.properties,\n )) {\n if (!acceptsChildren && capability.childrenOnlyProps.includes(name))\n continue\n properties[name] = schema\n const role = capability.propRoles[name]\n if (role) propRoles[name] = role\n const limit = capability.textLimits[name]\n if (limit !== undefined) textLimits[name] = limit\n }\n }\n return { properties, propRoles, textLimits }\n}\n\n/**\n * What a node capability declared in core and one carried by the palette\n * disagree about (AGL-3156).\n *\n * `check:ai-palette` pins the generated file to the generator's output for its\n * inputs, which cannot notice a capability leaving those inputs: when repeat\n * stopped being a component's attribute, the generator regenerated cleanly and\n * the palette simply stopped mentioning it. This reads the core declarations\n * instead and asks whether the palette still accounts for each one, so the\n * next generic capability cannot vanish the same way.\n *\n * `offeredFieldKinds` is every field kind the palette DOES carry somewhere,\n * derived from the generated entries rather than restated here: a capability\n * attribute drawn with one of those kinds has no excuse for being omitted.\n */\nexport function auditNodeCapabilities(\n declared: ReadonlyArray<{\n id: string\n label: string\n attributes: ReadonlyArray<{ name: string; component: string }>\n }>,\n carried: Readonly<Record<string, AiNodeCapability>>,\n offeredFieldKinds: ReadonlySet<string>,\n): string[] {\n const problems: string[] = []\n for (const capability of declared) {\n const entry = carried[capability.id]\n if (!entry) {\n problems.push(\n `the palette carries no node capability \"${capability.id}\": a model cannot use it at all`,\n )\n continue\n }\n if (!Object.keys(entry.propsSchema.properties).length) {\n problems.push(\n `node capability \"${capability.id}\" is carried with no prop a model can write`,\n )\n }\n for (const attribute of capability.attributes) {\n if (entry.propsSchema.properties[attribute.name]) continue\n if (!entry.omittedProps.includes(attribute.name)) {\n problems.push(\n `node capability \"${capability.id}\" declares \"${attribute.name}\", which the palette neither offers nor records as omitted`,\n )\n continue\n }\n if (offeredFieldKinds.has(attribute.component)) {\n problems.push(\n `node capability \"${capability.id}\" omits \"${attribute.name}\", whose \"${attribute.component}\" field the palette offers elsewhere`,\n )\n }\n }\n }\n for (const id of Object.keys(carried)) {\n if (!declared.some((capability) => capability.id === id)) {\n problems.push(\n `the palette carries node capability \"${id}\", which core no longer declares`,\n )\n }\n }\n return problems\n}\n\nexport interface AiSurfaceDefinition {\n /** The component id at the top of a tree for this surface. */\n root: string\n /** Every component id a node in this surface may carry. */\n allow: readonly string[]\n}\n\n/**\n * Theme vocabulary an `sx` value may name, read off the theme sources so the\n * validator's allowlist is derived rather than typed by hand.\n */\nexport interface AiSxTokens {\n /** Dotted palette paths such as `primary.main`. */\n palette: readonly string[]\n /** Responsive object keys. */\n breakpoints: readonly string[]\n /** Values `typography` may take. */\n typographyVariants: readonly string[]\n}\n\n/**\n * Text ceilings per role of copy (AGL-2905). A headline is one line of a\n * hero, body copy is a paragraph or two, and a button label has to fit a\n * button.\n */\nexport const AI_TEXT_LIMITS = {\n headline: 120,\n body: 2_000,\n button: 40,\n /** A short label, title or caption. */\n label: 200,\n} as const\n\n/**\n * Prompt budget per surface catalog, in characters at four per token.\n * Asserted by a spec so a component that grows its description cannot\n * silently push a system prompt past what the route pays for.\n */\nexport const AI_PALETTE_CATALOG_MAX_TOKENS = 2_500\nexport const AI_PALETTE_CATALOG_MAX_CHARS = AI_PALETTE_CATALOG_MAX_TOKENS * 4\n\n/** The model's own token estimate: characters over four. */\nexport function estimateCatalogTokens(catalog: string): number {\n return Math.ceil(catalog.length / 4)\n}\n\n/**\n * The documents a generator emits (AGL-2935). Each is composed on one\n * surface of the palette and held to its own budget below.\n */\nexport type AiOutputKind =\n | 'page'\n | 'template'\n | 'component'\n | 'layout'\n | 'form'\n | 'email'\n\nexport const AI_OUTPUT_KINDS: readonly AiOutputKind[] = [\n 'page',\n 'template',\n 'component',\n 'layout',\n 'form',\n 'email',\n]\n\n/** The surface each output kind is composed on. */\nexport const AI_OUTPUT_SURFACE: Record<AiOutputKind, AiSurface> = {\n page: 'screen',\n template: 'screen',\n component: 'component',\n layout: 'layout',\n form: 'form',\n email: 'email',\n}\n\n/**\n * What the rule-17 scorer measures on an emitted tree:\n *\n * - `nodes` — how many elements the tree carries;\n * - `bytes` — the stored node map, in the unit the save ceiling uses\n * (`nodeMapBytes`, msgpack);\n * - `imageBytes` — what the placed library images request at the slot's\n * variant, from each asset's recorded size;\n * - `embeds` — third-party players and frames the output loads;\n * - `fontFamilies` — families named beyond the theme's own;\n * - `emailHtmlBytes` — an email's rendered HTML, which is what a mail\n * client clips.\n */\nexport type AiBudgetMetric =\n | 'nodes'\n | 'bytes'\n | 'imageBytes'\n | 'embeds'\n | 'fontFamilies'\n | 'emailHtmlBytes'\n\nexport type AiOutputBudget = Record<Exclude<AiBudgetMetric, 'emailHtmlBytes'>, number> & {\n emailHtmlBytes?: number\n}\n\n/**\n * The size past which Gmail clips a message and hides the rest behind a\n * \"View entire message\" link — the unsubscribe footer included.\n */\nexport const AI_EMAIL_CLIP_BYTES = 102_000\n\n/**\n * The budget per output kind (AGL-2935, rule 17), beside the catalog budget\n * above for the same reason: a generator that grows its output past what\n * its kind is for is caught by a number, not noticed by a reader.\n *\n * A page is a few hundred elements and tens of kilobytes of stored map; a\n * reusable component is one repeated block; a layout is the chrome every\n * page loads, so it carries the least imagery; a form is fields and a\n * button; an email is held to the clipping threshold on its rendered HTML.\n * No kind names a font beyond the theme's.\n *\n * A third-party player is a page's or a component's to carry, and only where\n * a confirmed plan lists it (rule 16). A template is rendered for every\n * record it serves and a layout around every page, so neither carries one\n * (AGL-3433): an embed there loads a stranger's code on every page of a\n * collection or a site.\n */\nexport const AI_OUTPUT_BUDGETS: Record<AiOutputKind, AiOutputBudget> = {\n page: {\n nodes: 400,\n bytes: 60_000,\n imageBytes: 1_500_000,\n embeds: 1,\n fontFamilies: 0,\n },\n template: {\n nodes: 400,\n bytes: 60_000,\n imageBytes: 1_500_000,\n embeds: 0,\n fontFamilies: 0,\n },\n component: {\n nodes: 80,\n bytes: 12_000,\n imageBytes: 500_000,\n embeds: 1,\n fontFamilies: 0,\n },\n layout: {\n nodes: 160,\n bytes: 24_000,\n imageBytes: 200_000,\n embeds: 0,\n fontFamilies: 0,\n },\n form: {\n nodes: 60,\n bytes: 8_000,\n imageBytes: 0,\n embeds: 0,\n fontFamilies: 0,\n },\n email: {\n nodes: 150,\n bytes: 24_000,\n imageBytes: 1_000_000,\n embeds: 0,\n fontFamilies: 0,\n emailHtmlBytes: AI_EMAIL_CLIP_BYTES,\n },\n}\n\n/**\n * What a first visit to a generated output is estimated to transfer — the\n * weight the proposal shows before anything is applied (rule 17). A page\n * starts from the platform's measured page weight; a component or a layout\n * reports only what it adds to a page; an email is not a page load.\n */\nexport interface AiLoadEstimate {\n /** The measured weight of a published page before this output's own bytes; 0 for a fragment. */\n pageBytes: number\n /** The stored node map, in the save's own bytes. */\n documentBytes: number\n /** What the placed library images request at their slots. */\n imageBytes: number\n /** Images the estimate could not size: no recorded size, or not from the library. */\n imagesUnmeasured: number\n embeds: number\n totalBytes: number\n}\n\n/** Typography variants that read as headlines, for the `children` ceiling. */\nexport function isHeadlineVariant(variant: unknown): boolean {\n return typeof variant === 'string' && /^(display\\w*|h[1-6]|heading)$/.test(variant)\n}\n"],"names":["AI_SURFACE_NAMES","nodeCapabilityProps","capabilities","acceptsChildren","properties","propRoles","textLimits","capability","Object","values","name","schema","entries","propsSchema","childrenOnlyProps","includes","role","limit","undefined","auditNodeCapabilities","declared","carried","offeredFieldKinds","problems","entry","id","push","keys","length","attribute","attributes","omittedProps","has","component","some","AI_TEXT_LIMITS","headline","body","button","label","AI_PALETTE_CATALOG_MAX_TOKENS","AI_PALETTE_CATALOG_MAX_CHARS","estimateCatalogTokens","catalog","Math","ceil","AI_OUTPUT_KINDS","AI_OUTPUT_SURFACE","page","template","layout","form","email","AI_EMAIL_CLIP_BYTES","AI_OUTPUT_BUDGETS","nodes","bytes","imageBytes","embeds","fontFamilies","emailHtmlBytes","isHeadlineVariant","variant","test"],"mappings":"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"}
@@ -14,80 +14,30 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
+ import { type SeoAuditPage, type SeoAuditSite } from '@aglyn/aglyn/app-utils/seo-audit';
18
+ import type { SeoPageFacts } from '@aglyn/aglyn/app-utils/seo-page-facts';
17
19
  import type { AiSeoAuditReport, AiSeoContentFix } from '../model/ai-seo';
18
- import type { AiSeoPageFacts } from './seo-page-facts';
19
20
  /**
20
- * The site SEO audit's scoring (AGL-2910): every audited page held to what a
21
- * search result and a crawler need, with nothing asked of a model. Pure, so
22
- * the audit is proved against fixtures and a job only spends tokens on the
23
- * fixes it cannot make without writing.
24
- *
25
- * Per page: a title and a description of its own, within their lengths and
26
- * used on no other page; one main heading that says something; a description
27
- * on every image; a link from somewhere else on the site; and the target
28
- * keywords a person named, reported where the page already says them.
29
- * Per site: whether search engines are told to stay away, whether the
30
- * structured data names and describes the publisher, and whether agents get
31
- * guidance of the author's own.
32
- *
33
- * Keyword coverage is reported, never forced. A page that does not say a
34
- * keyword is told so; the fix a job proposes uses the keyword only where the
35
- * page is about it, and the listing check refuses a keyword used past its
36
- * per-field count.
21
+ * The site audit an `seo` job runs (AGL-2910): the platform's SEO check
22
+ * (`@aglyn/aglyn/app-utils/seo-audit`), which any site owner sees without
23
+ * this plugin, plus what a job owes on top of it — which pages need
24
+ * GENERATED fixes, in what order, and whether the site-wide proposal is
25
+ * owed. The findings and the scores are the check's; this module never
26
+ * judges a page itself, so the fixes a job proposes answer exactly the
27
+ * findings the site's SEO check lists.
37
28
  */
38
- /** Pages one audit covers. A larger site is audited on its first pages by path, and says how many it left. */
39
- export declare const AI_SEO_AUDIT_MAX_PAGES = 150;
40
29
  /** Pages one batch of fixes carries: one model call a batch. */
41
30
  export declare const AI_SEO_AUDIT_BATCH_SIZE = 8;
42
- /** One page as the audit reads it. */
43
- export interface AiSeoAuditPage {
44
- screenId: string;
45
- /** The public path, `/` for the home page. */
46
- path: string;
47
- name: string;
48
- versionId: string | null;
49
- seo: {
50
- title?: string | null;
51
- description?: string | null;
52
- breadcrumb?: string | null;
53
- image?: string | null;
54
- imageAlt?: string | null;
55
- };
56
- /** The screen's own description, which the head falls back to. */
57
- description?: string | null;
58
- facts: AiSeoPageFacts;
59
- /** Whether another audited page or a shared layout links here. */
60
- linkedFrom: boolean;
61
- keywords: readonly string[];
62
- }
63
- /** The site as the audit reads it. */
64
- export interface AiSeoAuditSite {
65
- discouraged: boolean;
66
- entity: {
67
- name?: string | null;
68
- description?: string | null;
69
- };
70
- agent: {
71
- whenToUse?: string | null;
72
- };
73
- }
74
- /** A path as the keyword lines and the routing map both spell it: leading slash, no trailing one. */
75
- export declare function aiSeoNormalizePath(path: string): string;
76
- /**
77
- * Target keywords per page, one line a page: `/pricing: plans, pricing`.
78
- * A line without a path, or with a path the site does not have, names no
79
- * page; the step reports the paths it could not match.
80
- */
81
- export declare function parseAiSeoKeywordLines(raw: unknown): Record<string, string[]>;
82
- /** Audit the pages and the site; nothing here reads, writes or generates. */
83
- export declare function aiSeoAudit(pages: readonly AiSeoAuditPage[], site: AiSeoAuditSite, options?: {
31
+ /** Check the site, then queue the pages whose findings need generated text, the worst first. */
32
+ export declare function aiSeoAudit(pages: readonly SeoAuditPage[], site: SeoAuditSite, options?: {
84
33
  skipped?: number;
34
+ notes?: readonly string[];
85
35
  batchSize?: number;
86
36
  }): AiSeoAuditReport;
87
37
  /**
88
38
  * The content fixes that need no model: every main heading after the first
89
39
  * editable one becomes an `h2`, so the page keeps one.
90
40
  */
91
- export declare function aiSeoHeadingDemotions(facts: AiSeoPageFacts): AiSeoContentFix[];
41
+ export declare function aiSeoHeadingDemotions(facts: SeoPageFacts): AiSeoContentFix[];
92
42
  /** The queue's batches, in order. */
93
43
  export declare function aiSeoAuditBatches(report: Pick<AiSeoAuditReport, 'queue' | 'batchSize'>): string[][];
@@ -14,46 +14,16 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
14
14
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
- */ import { SEO_LISTING_FIELDS, seoListingFieldTooLong } from "@aglyn/aglyn/app-utils/seo-listing-fields";
18
- import { aiSeoKeywordCoverage, aiSeoKeywordList } from "../tools/ai-seo-tool.js";
17
+ */ import { seoAudit } from "@aglyn/aglyn/app-utils/seo-audit";
19
18
  /**
20
- * The site SEO audit's scoring (AGL-2910): every audited page held to what a
21
- * search result and a crawler need, with nothing asked of a model. Pure, so
22
- * the audit is proved against fixtures and a job only spends tokens on the
23
- * fixes it cannot make without writing.
24
- *
25
- * Per page: a title and a description of its own, within their lengths and
26
- * used on no other page; one main heading that says something; a description
27
- * on every image; a link from somewhere else on the site; and the target
28
- * keywords a person named, reported where the page already says them.
29
- * Per site: whether search engines are told to stay away, whether the
30
- * structured data names and describes the publisher, and whether agents get
31
- * guidance of the author's own.
32
- *
33
- * Keyword coverage is reported, never forced. A page that does not say a
34
- * keyword is told so; the fix a job proposes uses the keyword only where the
35
- * page is about it, and the listing check refuses a keyword used past its
36
- * per-field count.
37
- */ /** Pages one audit covers. A larger site is audited on its first pages by path, and says how many it left. */ export const AI_SEO_AUDIT_MAX_PAGES = 150;
38
- /** Pages one batch of fixes carries: one model call a batch. */ export const AI_SEO_AUDIT_BATCH_SIZE = 8;
39
- /** What each severity takes off a page's score. */ const SEVERITY_WEIGHT = {
40
- high: 25,
41
- medium: 10,
42
- low: 3
43
- };
44
- /** A main heading this short or this generic says nothing a searcher can use. */ const THIN_H1_MIN_CHARS = 10;
45
- const GENERIC_H1 = new Set([
46
- 'home',
47
- 'homepage',
48
- 'home page',
49
- 'welcome',
50
- 'hello',
51
- 'untitled',
52
- 'new page',
53
- 'page',
54
- 'title',
55
- 'heading'
56
- ]);
19
+ * The site audit an `seo` job runs (AGL-2910): the platform's SEO check
20
+ * (`@aglyn/aglyn/app-utils/seo-audit`), which any site owner sees without
21
+ * this plugin, plus what a job owes on top of it — which pages need
22
+ * GENERATED fixes, in what order, and whether the site-wide proposal is
23
+ * owed. The findings and the scores are the check's; this module never
24
+ * judges a page itself, so the fixes a job proposes answer exactly the
25
+ * findings the site's SEO check lists.
26
+ */ /** Pages one batch of fixes carries: one model call a batch. */ export const AI_SEO_AUDIT_BATCH_SIZE = 8;
57
27
  /** The findings a batch of generated fixes answers. */ const GENERATED_FIX_CODES = new Set([
58
28
  'title-missing',
59
29
  'title-too-long',
@@ -66,176 +36,21 @@ const GENERIC_H1 = new Set([
66
36
  'image-alt-missing',
67
37
  'keyword-missing'
68
38
  ]);
69
- const blank = (value)=>!String(value != null ? value : '').trim();
70
- const normalized = (value)=>String(value != null ? value : '').replace(/\s+/g, ' ').trim().toLowerCase();
71
- /** A path as the keyword lines and the routing map both spell it: leading slash, no trailing one. */ export function aiSeoNormalizePath(path) {
72
- const trimmed = String(path != null ? path : '').trim();
73
- if (!trimmed || trimmed === '/') return '/';
74
- return `/${trimmed.replace(/^\/+/, '').replace(/\/+$/, '')}`;
75
- }
76
- /**
77
- * Target keywords per page, one line a page: `/pricing: plans, pricing`.
78
- * A line without a path, or with a path the site does not have, names no
79
- * page; the step reports the paths it could not match.
80
- */ export function parseAiSeoKeywordLines(raw) {
81
- const out = {};
82
- for (const line of String(raw != null ? raw : '').split('\n')){
83
- var _out_path;
84
- const at = line.indexOf(':');
85
- if (at <= 0) continue;
86
- const path = aiSeoNormalizePath(line.slice(0, at));
87
- const keywords = aiSeoKeywordList(line.slice(at + 1));
88
- if (keywords.length) out[path] = aiSeoKeywordList([
89
- ...(_out_path = out[path]) != null ? _out_path : [],
90
- ...keywords
91
- ]);
92
- }
93
- return out;
94
- }
95
- function finding(code, severity, message, extra = {}) {
39
+ /** The site-wide findings the site proposal (structured data, agent guidance) answers. */ const SITE_PROPOSAL_CODES = new Set([
40
+ 'entity-incomplete',
41
+ 'llms-guidance-missing'
42
+ ]);
43
+ /** Check the site, then queue the pages whose findings need generated text, the worst first. */ export function aiSeoAudit(pages, site, options = {}) {
44
+ var _options_batchSize;
45
+ const report = seoAudit(pages, site, options);
46
+ const queue = report.pages.filter((page)=>page.findings.some((entry)=>GENERATED_FIX_CODES.has(entry.code))).sort((a, b)=>a.score - b.score || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)).map((page)=>page.screenId);
96
47
  return _extends({
97
- code,
98
- severity,
99
- message
100
- }, extra);
101
- }
102
- /** The pages that share one value, by that value. */ function sharedValues(pages, read) {
103
- const groups = new Map();
104
- for (const page of pages){
105
- var _groups_get;
106
- const key = normalized(read(page));
107
- if (!key) continue;
108
- groups.set(key, [
109
- ...(_groups_get = groups.get(key)) != null ? _groups_get : [],
110
- page.screenId
111
- ]);
112
- }
113
- return new Map([
114
- ...groups
115
- ].filter(([, ids])=>ids.length > 1));
116
- }
117
- function pageFindings(page, titles, descriptions) {
118
- var _h1s_;
119
- const out = [];
120
- const title = page.seo.title;
121
- if (blank(title)) {
122
- out.push(finding('title-missing', 'medium', 'This page has no search title, so results show its name beside the site title.'));
123
- } else {
124
- var _titles_get;
125
- if (seoListingFieldTooLong('title', title)) {
126
- out.push(finding('title-too-long', 'medium', `The search title is over ${SEO_LISTING_FIELDS.title.maxLength} characters and will be cut off in results.`));
127
- }
128
- const twins = (_titles_get = titles.get(normalized(title))) == null ? void 0 : _titles_get.filter((id)=>id !== page.screenId);
129
- if (twins == null ? void 0 : twins.length) {
130
- out.push(finding('title-duplicate', 'high', 'Another page uses the same search title, so results cannot tell them apart.', {
131
- screenIds: twins
132
- }));
133
- }
134
- }
135
- const description = page.seo.description;
136
- if (blank(description) && blank(page.description)) {
137
- out.push(finding('description-missing', 'medium', 'This page has no search description, so results show the site’s.'));
138
- } else if (!blank(description)) {
139
- var _descriptions_get;
140
- if (seoListingFieldTooLong('description', description)) {
141
- out.push(finding('description-too-long', 'low', `The search description is over ${SEO_LISTING_FIELDS.description.maxLength} characters and will be cut off.`));
142
- }
143
- const twins = (_descriptions_get = descriptions.get(normalized(description))) == null ? void 0 : _descriptions_get.filter((id)=>id !== page.screenId);
144
- if (twins == null ? void 0 : twins.length) {
145
- out.push(finding('description-duplicate', 'medium', 'Another page uses the same search description.', {
146
- screenIds: twins
147
- }));
148
- }
149
- }
150
- const { h1s } = page.facts;
151
- if (!h1s.length) {
152
- out.push(finding('h1-missing', 'high', 'This page has no main heading.'));
153
- } else {
154
- if (h1s.length > 1) {
155
- out.push(finding('h1-multiple', 'medium', `This page has ${h1s.length} main headings; it should have one.`, {
156
- nodeIds: h1s.slice(1).map((heading)=>heading.nodeId).filter((id)=>Boolean(id))
157
- }));
158
- }
159
- const first = h1s[0];
160
- if (first.text.length < THIN_H1_MIN_CHARS || GENERIC_H1.has(normalized(first.text))) {
161
- out.push(finding('h1-thin', 'medium', `The main heading “${first.text}” says little about the page.`, {
162
- nodeIds: first.nodeId ? [
163
- first.nodeId
164
- ] : []
165
- }));
166
- }
167
- }
168
- const missingAlt = page.facts.imagesMissingAlt;
169
- if (missingAlt.length) {
170
- out.push(finding('image-alt-missing', 'medium', `${missingAlt.length} ${missingAlt.length === 1 ? 'image has' : 'images have'} no description for readers who cannot see ${missingAlt.length === 1 ? 'it' : 'them'}.`, {
171
- nodeIds: missingAlt.map((image)=>image.nodeId)
172
- }));
173
- }
174
- if (!page.linkedFrom && page.path !== '/') {
175
- out.push(finding('orphan', 'medium', 'No other page or shared layout links here, so visitors and crawlers may never find it.'));
176
- }
177
- const coverage = aiSeoKeywordCoverage(page.keywords, {
178
- title: page.seo.title,
179
- description: page.seo.description,
180
- h1: (_h1s_ = h1s[0]) == null ? void 0 : _h1s_.text,
181
- body: page.facts.text
182
- });
183
- for (const entry of coverage){
184
- if (!entry.inTitle && !entry.inDescription && !entry.inH1 && !entry.inBody) {
185
- out.push(finding('keyword-missing', 'low', `The page never says “${entry.keyword}”. Only use it if the page is about it.`, {
186
- keyword: entry.keyword
187
- }));
188
- }
189
- }
190
- return out;
191
- }
192
- function scoreOf(findings) {
193
- return Math.max(0, 100 - findings.reduce((sum, entry)=>sum + SEVERITY_WEIGHT[entry.severity], 0));
194
- }
195
- /** Audit the pages and the site; nothing here reads, writes or generates. */ export function aiSeoAudit(pages, site, options = {}) {
196
- var _options_skipped, _options_batchSize;
197
- const titles = sharedValues(pages, (page)=>page.seo.title);
198
- const descriptions = sharedValues(pages, (page)=>page.seo.description);
199
- const reports = pages.map((page)=>{
200
- var _page_facts_h1s_;
201
- const findings = pageFindings(page, titles, descriptions);
202
- return {
203
- screenId: page.screenId,
204
- path: page.path,
205
- name: page.name,
206
- versionId: page.versionId,
207
- score: scoreOf(findings),
208
- findings,
209
- keywords: aiSeoKeywordCoverage(page.keywords, {
210
- title: page.seo.title,
211
- description: page.seo.description,
212
- h1: (_page_facts_h1s_ = page.facts.h1s[0]) == null ? void 0 : _page_facts_h1s_.text,
213
- body: page.facts.text
214
- })
215
- };
216
- });
217
- const siteFindings = [];
218
- if (site.discouraged) {
219
- siteFindings.push(finding('search-discouraged', 'high', 'Search engines are asked not to index this site, so none of its pages appear in results.'));
220
- }
221
- if (blank(site.entity.name) || blank(site.entity.description)) {
222
- siteFindings.push(finding('entity-incomplete', 'low', 'The structured data does not name and describe who publishes the site.'));
223
- }
224
- if (blank(site.agent.whenToUse)) {
225
- siteFindings.push(finding('llms-guidance-missing', 'low', '/llms.txt carries no guidance of your own for AI agents.'));
226
- }
227
- const queue = reports.filter((report)=>report.findings.some((entry)=>GENERATED_FIX_CODES.has(entry.code))).sort((a, b)=>a.score - b.score || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)).map((report)=>report.screenId);
228
- return {
229
- kind: 'audit',
230
- pages: reports,
231
- skipped: Math.max(0, (_options_skipped = options.skipped) != null ? _options_skipped : 0),
232
- site: siteFindings,
233
- siteProposal: siteFindings.some((entry)=>entry.code === 'entity-incomplete' || entry.code === 'llms-guidance-missing'),
48
+ kind: 'audit'
49
+ }, report, {
50
+ siteProposal: report.site.some((entry)=>SITE_PROPOSAL_CODES.has(entry.code)),
234
51
  queue,
235
- batchSize: Math.max(1, (_options_batchSize = options.batchSize) != null ? _options_batchSize : AI_SEO_AUDIT_BATCH_SIZE),
236
- score: reports.length ? Math.round(reports.reduce((sum, report)=>sum + report.score, 0) / reports.length) : 100,
237
- notes: []
238
- };
52
+ batchSize: Math.max(1, (_options_batchSize = options.batchSize) != null ? _options_batchSize : AI_SEO_AUDIT_BATCH_SIZE)
53
+ });
239
54
  }
240
55
  /**
241
56
  * The content fixes that need no model: every main heading after the first
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/seo-audit.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 {\n SEO_LISTING_FIELDS,\n seoListingFieldTooLong,\n} from '@aglyn/aglyn/app-utils/seo-listing-fields'\nimport type {\n AiSeoAuditReport,\n AiSeoContentFix,\n AiSeoFinding,\n AiSeoFindingCode,\n AiSeoPageReport,\n AiSeoSeverity,\n} from '../model/ai-seo'\nimport { aiSeoKeywordCoverage, aiSeoKeywordList } from '../tools/ai-seo-tool'\nimport type { AiSeoPageFacts } from './seo-page-facts'\n\n/**\n * The site SEO audit's scoring (AGL-2910): every audited page held to what a\n * search result and a crawler need, with nothing asked of a model. Pure, so\n * the audit is proved against fixtures and a job only spends tokens on the\n * fixes it cannot make without writing.\n *\n * Per page: a title and a description of its own, within their lengths and\n * used on no other page; one main heading that says something; a description\n * on every image; a link from somewhere else on the site; and the target\n * keywords a person named, reported where the page already says them.\n * Per site: whether search engines are told to stay away, whether the\n * structured data names and describes the publisher, and whether agents get\n * guidance of the author's own.\n *\n * Keyword coverage is reported, never forced. A page that does not say a\n * keyword is told so; the fix a job proposes uses the keyword only where the\n * page is about it, and the listing check refuses a keyword used past its\n * per-field count.\n */\n\n/** Pages one audit covers. A larger site is audited on its first pages by path, and says how many it left. */\nexport const AI_SEO_AUDIT_MAX_PAGES = 150\n\n/** Pages one batch of fixes carries: one model call a batch. */\nexport const AI_SEO_AUDIT_BATCH_SIZE = 8\n\n/** What each severity takes off a page's score. */\nconst SEVERITY_WEIGHT: Record<AiSeoSeverity, number> = { high: 25, medium: 10, low: 3 }\n\n/** A main heading this short or this generic says nothing a searcher can use. */\nconst THIN_H1_MIN_CHARS = 10\nconst GENERIC_H1: ReadonlySet<string> = new Set([\n 'home',\n 'homepage',\n 'home page',\n 'welcome',\n 'hello',\n 'untitled',\n 'new page',\n 'page',\n 'title',\n 'heading',\n])\n\n/** The findings a batch of generated fixes answers. */\nconst GENERATED_FIX_CODES: ReadonlySet<AiSeoFindingCode> = new Set([\n 'title-missing',\n 'title-too-long',\n 'title-duplicate',\n 'description-missing',\n 'description-too-long',\n 'description-duplicate',\n 'h1-missing',\n 'h1-thin',\n 'image-alt-missing',\n 'keyword-missing',\n])\n\n/** One page as the audit reads it. */\nexport interface AiSeoAuditPage {\n screenId: string\n /** The public path, `/` for the home page. */\n path: string\n name: string\n versionId: string | null\n seo: {\n title?: string | null\n description?: string | null\n breadcrumb?: string | null\n image?: string | null\n imageAlt?: string | null\n }\n /** The screen's own description, which the head falls back to. */\n description?: string | null\n facts: AiSeoPageFacts\n /** Whether another audited page or a shared layout links here. */\n linkedFrom: boolean\n keywords: readonly string[]\n}\n\n/** The site as the audit reads it. */\nexport interface AiSeoAuditSite {\n discouraged: boolean\n entity: { name?: string | null; description?: string | null }\n agent: { whenToUse?: string | null }\n}\n\nconst blank = (value: string | null | undefined): boolean => !String(value ?? '').trim()\nconst normalized = (value: string | null | undefined): string =>\n String(value ?? '').replace(/\\s+/g, ' ').trim().toLowerCase()\n\n/** A path as the keyword lines and the routing map both spell it: leading slash, no trailing one. */\nexport function aiSeoNormalizePath(path: string): string {\n const trimmed = String(path ?? '').trim()\n if (!trimmed || trimmed === '/') return '/'\n return `/${trimmed.replace(/^\\/+/, '').replace(/\\/+$/, '')}`\n}\n\n/**\n * Target keywords per page, one line a page: `/pricing: plans, pricing`.\n * A line without a path, or with a path the site does not have, names no\n * page; the step reports the paths it could not match.\n */\nexport function parseAiSeoKeywordLines(raw: unknown): Record<string, string[]> {\n const out: Record<string, string[]> = {}\n for (const line of String(raw ?? '').split('\\n')) {\n const at = line.indexOf(':')\n if (at <= 0) continue\n const path = aiSeoNormalizePath(line.slice(0, at))\n const keywords = aiSeoKeywordList(line.slice(at + 1))\n if (keywords.length) out[path] = aiSeoKeywordList([...(out[path] ?? []), ...keywords])\n }\n return out\n}\n\nfunction finding(\n code: AiSeoFindingCode,\n severity: AiSeoSeverity,\n message: string,\n extra: Partial<Pick<AiSeoFinding, 'nodeIds' | 'screenIds' | 'keyword'>> = {},\n): AiSeoFinding {\n return { code, severity, message, ...extra }\n}\n\n/** The pages that share one value, by that value. */\nfunction sharedValues(\n pages: readonly AiSeoAuditPage[],\n read: (page: AiSeoAuditPage) => string | null | undefined,\n): Map<string, string[]> {\n const groups = new Map<string, string[]>()\n for (const page of pages) {\n const key = normalized(read(page))\n if (!key) continue\n groups.set(key, [...(groups.get(key) ?? []), page.screenId])\n }\n return new Map([...groups].filter(([, ids]) => ids.length > 1))\n}\n\nfunction pageFindings(\n page: AiSeoAuditPage,\n titles: Map<string, string[]>,\n descriptions: Map<string, string[]>,\n): AiSeoFinding[] {\n const out: AiSeoFinding[] = []\n const title = page.seo.title\n if (blank(title)) {\n out.push(finding('title-missing', 'medium', 'This page has no search title, so results show its name beside the site title.'))\n } else {\n if (seoListingFieldTooLong('title', title)) {\n out.push(finding('title-too-long', 'medium', `The search title is over ${SEO_LISTING_FIELDS.title.maxLength} characters and will be cut off in results.`))\n }\n const twins = titles.get(normalized(title))?.filter((id) => id !== page.screenId)\n if (twins?.length) {\n out.push(finding('title-duplicate', 'high', 'Another page uses the same search title, so results cannot tell them apart.', { screenIds: twins }))\n }\n }\n\n const description = page.seo.description\n if (blank(description) && blank(page.description)) {\n out.push(finding('description-missing', 'medium', 'This page has no search description, so results show the site’s.'))\n } else if (!blank(description)) {\n if (seoListingFieldTooLong('description', description)) {\n out.push(finding('description-too-long', 'low', `The search description is over ${SEO_LISTING_FIELDS.description.maxLength} characters and will be cut off.`))\n }\n const twins = descriptions.get(normalized(description))?.filter((id) => id !== page.screenId)\n if (twins?.length) {\n out.push(finding('description-duplicate', 'medium', 'Another page uses the same search description.', { screenIds: twins }))\n }\n }\n\n const { h1s } = page.facts\n if (!h1s.length) {\n out.push(finding('h1-missing', 'high', 'This page has no main heading.'))\n } else {\n if (h1s.length > 1) {\n out.push(\n finding('h1-multiple', 'medium', `This page has ${h1s.length} main headings; it should have one.`, {\n nodeIds: h1s.slice(1).map((heading) => heading.nodeId).filter((id): id is string => Boolean(id)),\n }),\n )\n }\n const first = h1s[0]\n if (first.text.length < THIN_H1_MIN_CHARS || GENERIC_H1.has(normalized(first.text))) {\n out.push(\n finding('h1-thin', 'medium', `The main heading “${first.text}” says little about the page.`, {\n nodeIds: first.nodeId ? [first.nodeId] : [],\n }),\n )\n }\n }\n\n const missingAlt = page.facts.imagesMissingAlt\n if (missingAlt.length) {\n out.push(\n finding(\n 'image-alt-missing',\n 'medium',\n `${missingAlt.length} ${missingAlt.length === 1 ? 'image has' : 'images have'} no description for readers who cannot see ${missingAlt.length === 1 ? 'it' : 'them'}.`,\n { nodeIds: missingAlt.map((image) => image.nodeId) },\n ),\n )\n }\n\n if (!page.linkedFrom && page.path !== '/') {\n out.push(finding('orphan', 'medium', 'No other page or shared layout links here, so visitors and crawlers may never find it.'))\n }\n\n const coverage = aiSeoKeywordCoverage(page.keywords, {\n title: page.seo.title,\n description: page.seo.description,\n h1: h1s[0]?.text,\n body: page.facts.text,\n })\n for (const entry of coverage) {\n if (!entry.inTitle && !entry.inDescription && !entry.inH1 && !entry.inBody) {\n out.push(\n finding('keyword-missing', 'low', `The page never says “${entry.keyword}”. Only use it if the page is about it.`, {\n keyword: entry.keyword,\n }),\n )\n }\n }\n return out\n}\n\nfunction scoreOf(findings: readonly AiSeoFinding[]): number {\n return Math.max(0, 100 - findings.reduce((sum, entry) => sum + SEVERITY_WEIGHT[entry.severity], 0))\n}\n\n/** Audit the pages and the site; nothing here reads, writes or generates. */\nexport function aiSeoAudit(\n pages: readonly AiSeoAuditPage[],\n site: AiSeoAuditSite,\n options: { skipped?: number; batchSize?: number } = {},\n): AiSeoAuditReport {\n const titles = sharedValues(pages, (page) => page.seo.title)\n const descriptions = sharedValues(pages, (page) => page.seo.description)\n const reports: AiSeoPageReport[] = pages.map((page) => {\n const findings = pageFindings(page, titles, descriptions)\n return {\n screenId: page.screenId,\n path: page.path,\n name: page.name,\n versionId: page.versionId,\n score: scoreOf(findings),\n findings,\n keywords: aiSeoKeywordCoverage(page.keywords, {\n title: page.seo.title,\n description: page.seo.description,\n h1: page.facts.h1s[0]?.text,\n body: page.facts.text,\n }),\n }\n })\n\n const siteFindings: AiSeoFinding[] = []\n if (site.discouraged) {\n siteFindings.push(finding('search-discouraged', 'high', 'Search engines are asked not to index this site, so none of its pages appear in results.'))\n }\n if (blank(site.entity.name) || blank(site.entity.description)) {\n siteFindings.push(finding('entity-incomplete', 'low', 'The structured data does not name and describe who publishes the site.'))\n }\n if (blank(site.agent.whenToUse)) {\n siteFindings.push(finding('llms-guidance-missing', 'low', '/llms.txt carries no guidance of your own for AI agents.'))\n }\n\n const queue = reports\n .filter((report) => report.findings.some((entry) => GENERATED_FIX_CODES.has(entry.code)))\n .sort((a, b) => a.score - b.score || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))\n .map((report) => report.screenId)\n\n return {\n kind: 'audit',\n pages: reports,\n skipped: Math.max(0, options.skipped ?? 0),\n site: siteFindings,\n siteProposal: siteFindings.some((entry) => entry.code === 'entity-incomplete' || entry.code === 'llms-guidance-missing'),\n queue,\n batchSize: Math.max(1, options.batchSize ?? AI_SEO_AUDIT_BATCH_SIZE),\n score: reports.length\n ? Math.round(reports.reduce((sum, report) => sum + report.score, 0) / reports.length)\n : 100,\n notes: [],\n }\n}\n\n/**\n * The content fixes that need no model: every main heading after the first\n * editable one becomes an `h2`, so the page keeps one.\n */\nexport function aiSeoHeadingDemotions(facts: AiSeoPageFacts): AiSeoContentFix[] {\n const editable = facts.h1s.filter((heading) => heading.editable && heading.nodeId)\n if (facts.h1s.length < 2 || !editable.length) return []\n const keep = editable[0].nodeId\n return editable\n .filter((heading) => heading.nodeId !== keep)\n .map((heading) => ({ kind: 'h1-demote', nodeId: heading.nodeId as string }))\n}\n\n/** The queue's batches, in order. */\nexport function aiSeoAuditBatches(report: Pick<AiSeoAuditReport, 'queue' | 'batchSize'>): string[][] {\n const batches: string[][] = []\n for (let start = 0; start < report.queue.length; start += report.batchSize) {\n batches.push(report.queue.slice(start, start + report.batchSize))\n }\n return batches\n}\n"],"names":["SEO_LISTING_FIELDS","seoListingFieldTooLong","aiSeoKeywordCoverage","aiSeoKeywordList","AI_SEO_AUDIT_MAX_PAGES","AI_SEO_AUDIT_BATCH_SIZE","SEVERITY_WEIGHT","high","medium","low","THIN_H1_MIN_CHARS","GENERIC_H1","Set","GENERATED_FIX_CODES","blank","value","String","trim","normalized","replace","toLowerCase","aiSeoNormalizePath","path","trimmed","parseAiSeoKeywordLines","raw","out","line","split","at","indexOf","slice","keywords","length","finding","code","severity","message","extra","sharedValues","pages","read","groups","Map","page","key","set","get","screenId","filter","ids","pageFindings","titles","descriptions","h1s","title","seo","push","maxLength","twins","id","screenIds","description","facts","nodeIds","map","heading","nodeId","Boolean","first","text","has","missingAlt","imagesMissingAlt","image","linkedFrom","coverage","h1","body","entry","inTitle","inDescription","inH1","inBody","keyword","scoreOf","findings","Math","max","reduce","sum","aiSeoAudit","site","options","reports","name","versionId","score","siteFindings","discouraged","entity","agent","whenToUse","queue","report","some","sort","a","b","kind","skipped","siteProposal","batchSize","round","notes","aiSeoHeadingDemotions","editable","keep","aiSeoAuditBatches","batches","start"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/ai/src/lib/runtime/seo-audit.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 {\n seoAudit,\n type SeoAuditPage,\n type SeoAuditSite,\n type SeoFindingCode,\n} from '@aglyn/aglyn/app-utils/seo-audit'\nimport type { SeoPageFacts } from '@aglyn/aglyn/app-utils/seo-page-facts'\nimport type { AiSeoAuditReport, AiSeoContentFix } from '../model/ai-seo'\n\n/**\n * The site audit an `seo` job runs (AGL-2910): the platform's SEO check\n * (`@aglyn/aglyn/app-utils/seo-audit`), which any site owner sees without\n * this plugin, plus what a job owes on top of it — which pages need\n * GENERATED fixes, in what order, and whether the site-wide proposal is\n * owed. The findings and the scores are the check's; this module never\n * judges a page itself, so the fixes a job proposes answer exactly the\n * findings the site's SEO check lists.\n */\n\n/** Pages one batch of fixes carries: one model call a batch. */\nexport const AI_SEO_AUDIT_BATCH_SIZE = 8\n\n/** The findings a batch of generated fixes answers. */\nconst GENERATED_FIX_CODES: ReadonlySet<SeoFindingCode> = new Set([\n 'title-missing',\n 'title-too-long',\n 'title-duplicate',\n 'description-missing',\n 'description-too-long',\n 'description-duplicate',\n 'h1-missing',\n 'h1-thin',\n 'image-alt-missing',\n 'keyword-missing',\n])\n\n/** The site-wide findings the site proposal (structured data, agent guidance) answers. */\nconst SITE_PROPOSAL_CODES: ReadonlySet<SeoFindingCode> = new Set(['entity-incomplete', 'llms-guidance-missing'])\n\n/** Check the site, then queue the pages whose findings need generated text, the worst first. */\nexport function aiSeoAudit(\n pages: readonly SeoAuditPage[],\n site: SeoAuditSite,\n options: { skipped?: number; notes?: readonly string[]; batchSize?: number } = {},\n): AiSeoAuditReport {\n const report = seoAudit(pages, site, options)\n const queue = report.pages\n .filter((page) => page.findings.some((entry) => GENERATED_FIX_CODES.has(entry.code)))\n .sort((a, b) => a.score - b.score || (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))\n .map((page) => page.screenId)\n return {\n kind: 'audit',\n ...report,\n siteProposal: report.site.some((entry) => SITE_PROPOSAL_CODES.has(entry.code)),\n queue,\n batchSize: Math.max(1, options.batchSize ?? AI_SEO_AUDIT_BATCH_SIZE),\n }\n}\n\n/**\n * The content fixes that need no model: every main heading after the first\n * editable one becomes an `h2`, so the page keeps one.\n */\nexport function aiSeoHeadingDemotions(facts: SeoPageFacts): AiSeoContentFix[] {\n const editable = facts.h1s.filter((heading) => heading.editable && heading.nodeId)\n if (facts.h1s.length < 2 || !editable.length) return []\n const keep = editable[0].nodeId\n return editable\n .filter((heading) => heading.nodeId !== keep)\n .map((heading) => ({ kind: 'h1-demote', nodeId: heading.nodeId as string }))\n}\n\n/** The queue's batches, in order. */\nexport function aiSeoAuditBatches(report: Pick<AiSeoAuditReport, 'queue' | 'batchSize'>): string[][] {\n const batches: string[][] = []\n for (let start = 0; start < report.queue.length; start += report.batchSize) {\n batches.push(report.queue.slice(start, start + report.batchSize))\n }\n return batches\n}\n"],"names":["seoAudit","AI_SEO_AUDIT_BATCH_SIZE","GENERATED_FIX_CODES","Set","SITE_PROPOSAL_CODES","aiSeoAudit","pages","site","options","report","queue","filter","page","findings","some","entry","has","code","sort","a","b","score","path","map","screenId","kind","siteProposal","batchSize","Math","max","aiSeoHeadingDemotions","facts","editable","h1s","heading","nodeId","length","keep","aiSeoAuditBatches","batches","start","push","slice"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,QAAQ,QAIH,mCAAkC;AAIzC;;;;;;;;CAQC,GAED,8DAA8D,GAC9D,OAAO,MAAMC,0BAA0B,EAAC;AAExC,qDAAqD,GACrD,MAAMC,sBAAmD,IAAIC,IAAI;IAC/D;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;CACD;AAED,wFAAwF,GACxF,MAAMC,sBAAmD,IAAID,IAAI;IAAC;IAAqB;CAAwB;AAE/G,8FAA8F,GAC9F,OAAO,SAASE,WACdC,KAA8B,EAC9BC,IAAkB,EAClBC,UAA+E,CAAC,CAAC;QAYxDA;IAVzB,MAAMC,SAAST,SAASM,OAAOC,MAAMC;IACrC,MAAME,QAAQD,OAAOH,KAAK,CACvBK,MAAM,CAAC,CAACC,OAASA,KAAKC,QAAQ,CAACC,IAAI,CAAC,CAACC,QAAUb,oBAAoBc,GAAG,CAACD,MAAME,IAAI,IACjFC,IAAI,CAAC,CAACC,GAAGC,IAAMD,EAAEE,KAAK,GAAGD,EAAEC,KAAK,IAAKF,CAAAA,EAAEG,IAAI,GAAGF,EAAEE,IAAI,GAAG,CAAC,IAAIH,EAAEG,IAAI,GAAGF,EAAEE,IAAI,GAAG,IAAI,CAAA,GAClFC,GAAG,CAAC,CAACX,OAASA,KAAKY,QAAQ;IAC9B,OAAO;QACLC,MAAM;OACHhB;QACHiB,cAAcjB,OAAOF,IAAI,CAACO,IAAI,CAAC,CAACC,QAAUX,oBAAoBY,GAAG,CAACD,MAAME,IAAI;QAC5EP;QACAiB,WAAWC,KAAKC,GAAG,CAAC,IAAGrB,qBAAAA,QAAQmB,SAAS,YAAjBnB,qBAAqBP;;AAEhD;AAEA;;;CAGC,GACD,OAAO,SAAS6B,sBAAsBC,KAAmB;IACvD,MAAMC,WAAWD,MAAME,GAAG,CAACtB,MAAM,CAAC,CAACuB,UAAYA,QAAQF,QAAQ,IAAIE,QAAQC,MAAM;IACjF,IAAIJ,MAAME,GAAG,CAACG,MAAM,GAAG,KAAK,CAACJ,SAASI,MAAM,EAAE,OAAO,EAAE;IACvD,MAAMC,OAAOL,QAAQ,CAAC,EAAE,CAACG,MAAM;IAC/B,OAAOH,SACJrB,MAAM,CAAC,CAACuB,UAAYA,QAAQC,MAAM,KAAKE,MACvCd,GAAG,CAAC,CAACW,UAAa,CAAA;YAAET,MAAM;YAAaU,QAAQD,QAAQC,MAAM;QAAW,CAAA;AAC7E;AAEA,mCAAmC,GACnC,OAAO,SAASG,kBAAkB7B,MAAqD;IACrF,MAAM8B,UAAsB,EAAE;IAC9B,IAAK,IAAIC,QAAQ,GAAGA,QAAQ/B,OAAOC,KAAK,CAAC0B,MAAM,EAAEI,SAAS/B,OAAOkB,SAAS,CAAE;QAC1EY,QAAQE,IAAI,CAAChC,OAAOC,KAAK,CAACgC,KAAK,CAACF,OAAOA,QAAQ/B,OAAOkB,SAAS;IACjE;IACA,OAAOY;AACT"}
@@ -18,7 +18,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
18
18
  import { effectiveDatasetModel } from "@aglyn/aglyn/app-utils/dataset-models";
19
19
  import { datasetDisplayName } from "@aglyn/aglyn/app-utils/datasets";
20
20
  import { isFormArchived } from "@aglyn/aglyn/app-utils/forms";
21
- import { describeTheme, resolveSiteTheme } from "@aglyn/aglyn/app-utils/marketplace-theme";
21
+ import { describeTheme, resolveSiteTheme } from "@aglyn/aglyn/app-utils/site-theme";
22
22
  import { REUSABLE_COMPONENT_KIND_EMAIL, reusableComponentKindOf } from "@aglyn/aglyn/app-utils/reusable-component-kind";
23
23
  import { SCREEN_KIND_TEMPLATE } from "@aglyn/aglyn/app-utils/screen-route";
24
24
  import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";