@wildo-ai/saas-website 1.1.1

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 (383) hide show
  1. package/LICENSE +34 -0
  2. package/dist/esm/.builder.pid +9 -0
  3. package/dist/esm/astro/blog-post-bridge.d.ts +324 -0
  4. package/dist/esm/astro/blog-post-bridge.d.ts.map +1 -0
  5. package/dist/esm/astro/blog-post-bridge.js +57 -0
  6. package/dist/esm/astro/blog-post-bridge.js.map +1 -0
  7. package/dist/esm/astro/blog-post-head.renderer.d.ts +129 -0
  8. package/dist/esm/astro/blog-post-head.renderer.d.ts.map +1 -0
  9. package/dist/esm/astro/blog-post-head.renderer.js +139 -0
  10. package/dist/esm/astro/blog-post-head.renderer.js.map +1 -0
  11. package/dist/esm/astro/bridge-runtime.d.ts +224 -0
  12. package/dist/esm/astro/bridge-runtime.d.ts.map +1 -0
  13. package/dist/esm/astro/bridge-runtime.js +30 -0
  14. package/dist/esm/astro/bridge-runtime.js.map +1 -0
  15. package/dist/esm/astro/collect-expected-label-keys.d.ts +65 -0
  16. package/dist/esm/astro/collect-expected-label-keys.d.ts.map +1 -0
  17. package/dist/esm/astro/collect-expected-label-keys.js +101 -0
  18. package/dist/esm/astro/collect-expected-label-keys.js.map +1 -0
  19. package/dist/esm/astro/headers-renderer.d.ts +32 -0
  20. package/dist/esm/astro/headers-renderer.d.ts.map +1 -0
  21. package/dist/esm/astro/headers-renderer.js +64 -0
  22. package/dist/esm/astro/headers-renderer.js.map +1 -0
  23. package/dist/esm/astro/i18n-routing.helper.d.ts +91 -0
  24. package/dist/esm/astro/i18n-routing.helper.d.ts.map +1 -0
  25. package/dist/esm/astro/i18n-routing.helper.js +23 -0
  26. package/dist/esm/astro/i18n-routing.helper.js.map +1 -0
  27. package/dist/esm/astro/internal/html-escape.d.ts +31 -0
  28. package/dist/esm/astro/internal/html-escape.d.ts.map +1 -0
  29. package/dist/esm/astro/internal/html-escape.js +40 -0
  30. package/dist/esm/astro/internal/html-escape.js.map +1 -0
  31. package/dist/esm/astro/label-pack-loader.d.ts +134 -0
  32. package/dist/esm/astro/label-pack-loader.d.ts.map +1 -0
  33. package/dist/esm/astro/label-pack-loader.js +298 -0
  34. package/dist/esm/astro/label-pack-loader.js.map +1 -0
  35. package/dist/esm/astro/llms-txt-server.d.ts +28 -0
  36. package/dist/esm/astro/llms-txt-server.d.ts.map +1 -0
  37. package/dist/esm/astro/llms-txt-server.js +33 -0
  38. package/dist/esm/astro/llms-txt-server.js.map +1 -0
  39. package/dist/esm/astro/pick-labels-for-page.d.ts +63 -0
  40. package/dist/esm/astro/pick-labels-for-page.d.ts.map +1 -0
  41. package/dist/esm/astro/pick-labels-for-page.js +73 -0
  42. package/dist/esm/astro/pick-labels-for-page.js.map +1 -0
  43. package/dist/esm/astro/robots-renderer.d.ts +85 -0
  44. package/dist/esm/astro/robots-renderer.d.ts.map +1 -0
  45. package/dist/esm/astro/robots-renderer.js +157 -0
  46. package/dist/esm/astro/robots-renderer.js.map +1 -0
  47. package/dist/esm/astro/sitemap-coverage.d.ts +101 -0
  48. package/dist/esm/astro/sitemap-coverage.d.ts.map +1 -0
  49. package/dist/esm/astro/sitemap-coverage.js +67 -0
  50. package/dist/esm/astro/sitemap-coverage.js.map +1 -0
  51. package/dist/esm/astro/structured-data.renderer.d.ts +21 -0
  52. package/dist/esm/astro/structured-data.renderer.d.ts.map +1 -0
  53. package/dist/esm/astro/structured-data.renderer.js +142 -0
  54. package/dist/esm/astro/structured-data.renderer.js.map +1 -0
  55. package/dist/esm/astro/website-page-head.renderer.d.ts +93 -0
  56. package/dist/esm/astro/website-page-head.renderer.d.ts.map +1 -0
  57. package/dist/esm/astro/website-page-head.renderer.js +184 -0
  58. package/dist/esm/astro/website-page-head.renderer.js.map +1 -0
  59. package/dist/esm/astro/website-page-runtime.helper.d.ts +112 -0
  60. package/dist/esm/astro/website-page-runtime.helper.d.ts.map +1 -0
  61. package/dist/esm/astro/website-page-runtime.helper.js +60 -0
  62. package/dist/esm/astro/website-page-runtime.helper.js.map +1 -0
  63. package/dist/esm/astro/website-site-context.d.ts +126 -0
  64. package/dist/esm/astro/website-site-context.d.ts.map +1 -0
  65. package/dist/esm/astro/website-site-context.js +37 -0
  66. package/dist/esm/astro/website-site-context.js.map +1 -0
  67. package/dist/esm/astro-island.d.ts +56 -0
  68. package/dist/esm/astro-island.d.ts.map +1 -0
  69. package/dist/esm/astro-island.js +61 -0
  70. package/dist/esm/astro-island.js.map +1 -0
  71. package/dist/esm/astro.d.ts +151 -0
  72. package/dist/esm/astro.d.ts.map +1 -0
  73. package/dist/esm/astro.js +151 -0
  74. package/dist/esm/astro.js.map +1 -0
  75. package/dist/esm/companion-exports.d.ts +38 -0
  76. package/dist/esm/companion-exports.d.ts.map +1 -0
  77. package/dist/esm/companion-exports.js +38 -0
  78. package/dist/esm/companion-exports.js.map +1 -0
  79. package/dist/esm/components/low-level/WebsiteButton.d.ts +73 -0
  80. package/dist/esm/components/low-level/WebsiteButton.d.ts.map +1 -0
  81. package/dist/esm/components/low-level/WebsiteButton.js +68 -0
  82. package/dist/esm/components/low-level/WebsiteButton.js.map +1 -0
  83. package/dist/esm/components/low-level/WebsiteHeading.d.ts +78 -0
  84. package/dist/esm/components/low-level/WebsiteHeading.d.ts.map +1 -0
  85. package/dist/esm/components/low-level/WebsiteHeading.js +41 -0
  86. package/dist/esm/components/low-level/WebsiteHeading.js.map +1 -0
  87. package/dist/esm/components/low-level/WebsiteImage.d.ts +73 -0
  88. package/dist/esm/components/low-level/WebsiteImage.d.ts.map +1 -0
  89. package/dist/esm/components/low-level/WebsiteImage.js +21 -0
  90. package/dist/esm/components/low-level/WebsiteImage.js.map +1 -0
  91. package/dist/esm/components/low-level/WebsiteInternalButton.d.ts +90 -0
  92. package/dist/esm/components/low-level/WebsiteInternalButton.d.ts.map +1 -0
  93. package/dist/esm/components/low-level/WebsiteInternalButton.js +47 -0
  94. package/dist/esm/components/low-level/WebsiteInternalButton.js.map +1 -0
  95. package/dist/esm/components/low-level/WebsiteInternalLink.d.ts +99 -0
  96. package/dist/esm/components/low-level/WebsiteInternalLink.d.ts.map +1 -0
  97. package/dist/esm/components/low-level/WebsiteInternalLink.js +68 -0
  98. package/dist/esm/components/low-level/WebsiteInternalLink.js.map +1 -0
  99. package/dist/esm/components/low-level/WebsiteLink.d.ts +50 -0
  100. package/dist/esm/components/low-level/WebsiteLink.d.ts.map +1 -0
  101. package/dist/esm/components/low-level/WebsiteLink.js +22 -0
  102. package/dist/esm/components/low-level/WebsiteLink.js.map +1 -0
  103. package/dist/esm/components/low-level/WebsiteText.d.ts +57 -0
  104. package/dist/esm/components/low-level/WebsiteText.d.ts.map +1 -0
  105. package/dist/esm/components/low-level/WebsiteText.js +57 -0
  106. package/dist/esm/components/low-level/WebsiteText.js.map +1 -0
  107. package/dist/esm/config/define-website-config.d.ts +83 -0
  108. package/dist/esm/config/define-website-config.d.ts.map +1 -0
  109. package/dist/esm/config/define-website-config.js +126 -0
  110. package/dist/esm/config/define-website-config.js.map +1 -0
  111. package/dist/esm/config/index.d.ts +38 -0
  112. package/dist/esm/config/index.d.ts.map +1 -0
  113. package/dist/esm/config/index.js +38 -0
  114. package/dist/esm/config/index.js.map +1 -0
  115. package/dist/esm/config/load-website-config.d.ts +136 -0
  116. package/dist/esm/config/load-website-config.d.ts.map +1 -0
  117. package/dist/esm/config/load-website-config.js +177 -0
  118. package/dist/esm/config/load-website-config.js.map +1 -0
  119. package/dist/esm/config/wildo-website-config.schemas.d.ts +251 -0
  120. package/dist/esm/config/wildo-website-config.schemas.d.ts.map +1 -0
  121. package/dist/esm/config/wildo-website-config.schemas.js +302 -0
  122. package/dist/esm/config/wildo-website-config.schemas.js.map +1 -0
  123. package/dist/esm/config-loader.d.ts +35 -0
  124. package/dist/esm/config-loader.d.ts.map +1 -0
  125. package/dist/esm/config-loader.js +35 -0
  126. package/dist/esm/config-loader.js.map +1 -0
  127. package/dist/esm/core/anonymous-session/InboundContactForm.d.ts +38 -0
  128. package/dist/esm/core/anonymous-session/InboundContactForm.d.ts.map +1 -0
  129. package/dist/esm/core/anonymous-session/InboundContactForm.js +77 -0
  130. package/dist/esm/core/anonymous-session/InboundContactForm.js.map +1 -0
  131. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts +41 -0
  132. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.d.ts.map +1 -0
  133. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js +58 -0
  134. package/dist/esm/core/anonymous-session/inbound-contact-form.schema.js.map +1 -0
  135. package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts +81 -0
  136. package/dist/esm/core/anonymous-session/website-anonymous-session-client.d.ts.map +1 -0
  137. package/dist/esm/core/anonymous-session/website-anonymous-session-client.js +177 -0
  138. package/dist/esm/core/anonymous-session/website-anonymous-session-client.js.map +1 -0
  139. package/dist/esm/core/contexts/WebsitePageContext.d.ts +40 -0
  140. package/dist/esm/core/contexts/WebsitePageContext.d.ts.map +1 -0
  141. package/dist/esm/core/contexts/WebsitePageContext.js +8 -0
  142. package/dist/esm/core/contexts/WebsitePageContext.js.map +1 -0
  143. package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts +162 -0
  144. package/dist/esm/core/contexts/WebsiteRuntimeContext.d.ts.map +1 -0
  145. package/dist/esm/core/contexts/WebsiteRuntimeContext.js +22 -0
  146. package/dist/esm/core/contexts/WebsiteRuntimeContext.js.map +1 -0
  147. package/dist/esm/core/contexts/WebsiteSectionContext.d.ts +47 -0
  148. package/dist/esm/core/contexts/WebsiteSectionContext.d.ts.map +1 -0
  149. package/dist/esm/core/contexts/WebsiteSectionContext.js +8 -0
  150. package/dist/esm/core/contexts/WebsiteSectionContext.js.map +1 -0
  151. package/dist/esm/core/contexts/useWebsitePage.d.ts +13 -0
  152. package/dist/esm/core/contexts/useWebsitePage.d.ts.map +1 -0
  153. package/dist/esm/core/contexts/useWebsitePage.js +22 -0
  154. package/dist/esm/core/contexts/useWebsitePage.js.map +1 -0
  155. package/dist/esm/core/contexts/useWebsiteRuntime.d.ts +19 -0
  156. package/dist/esm/core/contexts/useWebsiteRuntime.d.ts.map +1 -0
  157. package/dist/esm/core/contexts/useWebsiteRuntime.js +28 -0
  158. package/dist/esm/core/contexts/useWebsiteRuntime.js.map +1 -0
  159. package/dist/esm/core/contexts/useWebsiteSection.d.ts +12 -0
  160. package/dist/esm/core/contexts/useWebsiteSection.d.ts.map +1 -0
  161. package/dist/esm/core/contexts/useWebsiteSection.js +22 -0
  162. package/dist/esm/core/contexts/useWebsiteSection.js.map +1 -0
  163. package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts +17 -0
  164. package/dist/esm/core/external-providers/frontend-provider-registry.website.d.ts.map +1 -0
  165. package/dist/esm/core/external-providers/frontend-provider-registry.website.js +23 -0
  166. package/dist/esm/core/external-providers/frontend-provider-registry.website.js.map +1 -0
  167. package/dist/esm/core/factories/define-website-page-manifest.d.ts +70 -0
  168. package/dist/esm/core/factories/define-website-page-manifest.d.ts.map +1 -0
  169. package/dist/esm/core/factories/define-website-page-manifest.js +51 -0
  170. package/dist/esm/core/factories/define-website-page-manifest.js.map +1 -0
  171. package/dist/esm/core/factories/define-website-section.d.ts +101 -0
  172. package/dist/esm/core/factories/define-website-section.d.ts.map +1 -0
  173. package/dist/esm/core/factories/define-website-section.js +71 -0
  174. package/dist/esm/core/factories/define-website-section.js.map +1 -0
  175. package/dist/esm/core/hooks/useWebsiteDesignTokens.d.ts +23 -0
  176. package/dist/esm/core/hooks/useWebsiteDesignTokens.d.ts.map +1 -0
  177. package/dist/esm/core/hooks/useWebsiteDesignTokens.js +25 -0
  178. package/dist/esm/core/hooks/useWebsiteDesignTokens.js.map +1 -0
  179. package/dist/esm/core/hooks/useWebsiteLabel.d.ts +68 -0
  180. package/dist/esm/core/hooks/useWebsiteLabel.d.ts.map +1 -0
  181. package/dist/esm/core/hooks/useWebsiteLabel.js +103 -0
  182. package/dist/esm/core/hooks/useWebsiteLabel.js.map +1 -0
  183. package/dist/esm/core/layouts/WebsitePageLayout.d.ts +87 -0
  184. package/dist/esm/core/layouts/WebsitePageLayout.d.ts.map +1 -0
  185. package/dist/esm/core/layouts/WebsitePageLayout.js +170 -0
  186. package/dist/esm/core/layouts/WebsitePageLayout.js.map +1 -0
  187. package/dist/esm/core/layouts/WebsiteSection.d.ts +91 -0
  188. package/dist/esm/core/layouts/WebsiteSection.d.ts.map +1 -0
  189. package/dist/esm/core/layouts/WebsiteSection.js +26 -0
  190. package/dist/esm/core/layouts/WebsiteSection.js.map +1 -0
  191. package/dist/esm/core/routing/internal-routing.utils.d.ts +65 -0
  192. package/dist/esm/core/routing/internal-routing.utils.d.ts.map +1 -0
  193. package/dist/esm/core/routing/internal-routing.utils.js +14 -0
  194. package/dist/esm/core/routing/internal-routing.utils.js.map +1 -0
  195. package/dist/esm/index.d.ts +129 -0
  196. package/dist/esm/index.d.ts.map +1 -0
  197. package/dist/esm/index.js +129 -0
  198. package/dist/esm/index.js.map +1 -0
  199. package/dist/esm/mdx/BlogPost.d.ts +193 -0
  200. package/dist/esm/mdx/BlogPost.d.ts.map +1 -0
  201. package/dist/esm/mdx/BlogPost.js +161 -0
  202. package/dist/esm/mdx/BlogPost.js.map +1 -0
  203. package/dist/esm/mdx/blog-post-collection.config.d.ts +139 -0
  204. package/dist/esm/mdx/blog-post-collection.config.d.ts.map +1 -0
  205. package/dist/esm/mdx/blog-post-collection.config.js +27 -0
  206. package/dist/esm/mdx/blog-post-collection.config.js.map +1 -0
  207. package/dist/esm/mdx/blog-post-frontmatter-source.d.ts +12 -0
  208. package/dist/esm/mdx/blog-post-frontmatter-source.d.ts.map +1 -0
  209. package/dist/esm/mdx/blog-post-frontmatter-source.js +13 -0
  210. package/dist/esm/mdx/blog-post-frontmatter-source.js.map +1 -0
  211. package/dist/esm/mdx/index.d.ts +22 -0
  212. package/dist/esm/mdx/index.d.ts.map +1 -0
  213. package/dist/esm/mdx/index.js +22 -0
  214. package/dist/esm/mdx/index.js.map +1 -0
  215. package/dist/esm/mdx/website-mdx-provider.d.ts +90 -0
  216. package/dist/esm/mdx/website-mdx-provider.d.ts.map +1 -0
  217. package/dist/esm/mdx/website-mdx-provider.js +8 -0
  218. package/dist/esm/mdx/website-mdx-provider.js.map +1 -0
  219. package/dist/esm/mdx.d.ts +38 -0
  220. package/dist/esm/mdx.d.ts.map +1 -0
  221. package/dist/esm/mdx.js +38 -0
  222. package/dist/esm/mdx.js.map +1 -0
  223. package/dist/esm/schemas/blog/blog-post-frontmatter.shared.schemas.d.ts +75 -0
  224. package/dist/esm/schemas/blog/blog-post-frontmatter.shared.schemas.d.ts.map +1 -0
  225. package/dist/esm/schemas/blog/blog-post-frontmatter.shared.schemas.js +85 -0
  226. package/dist/esm/schemas/blog/blog-post-frontmatter.shared.schemas.js.map +1 -0
  227. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.d.ts +120 -0
  228. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.d.ts.map +1 -0
  229. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.js +152 -0
  230. package/dist/esm/schemas/design-tokens/website-design-tokens.shared.schemas.js.map +1 -0
  231. package/dist/esm/schemas/label-keys/website-label-key.schemas.d.ts +73 -0
  232. package/dist/esm/schemas/label-keys/website-label-key.schemas.d.ts.map +1 -0
  233. package/dist/esm/schemas/label-keys/website-label-key.schemas.js +76 -0
  234. package/dist/esm/schemas/label-keys/website-label-key.schemas.js.map +1 -0
  235. package/dist/esm/schemas/manifests/website-page-manifest.shared.schemas.d.ts +209 -0
  236. package/dist/esm/schemas/manifests/website-page-manifest.shared.schemas.d.ts.map +1 -0
  237. package/dist/esm/schemas/manifests/website-page-manifest.shared.schemas.js +218 -0
  238. package/dist/esm/schemas/manifests/website-page-manifest.shared.schemas.js.map +1 -0
  239. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts +431 -0
  240. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.d.ts.map +1 -0
  241. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.js +438 -0
  242. package/dist/esm/schemas/manifests/website-root-config.shared.schemas.js.map +1 -0
  243. package/dist/esm/schemas/refs/page-ref.schemas.d.ts +40 -0
  244. package/dist/esm/schemas/refs/page-ref.schemas.d.ts.map +1 -0
  245. package/dist/esm/schemas/refs/page-ref.schemas.js +43 -0
  246. package/dist/esm/schemas/refs/page-ref.schemas.js.map +1 -0
  247. package/dist/esm/schemas/refs/section-ref.schemas.d.ts +41 -0
  248. package/dist/esm/schemas/refs/section-ref.schemas.d.ts.map +1 -0
  249. package/dist/esm/schemas/refs/section-ref.schemas.js +44 -0
  250. package/dist/esm/schemas/refs/section-ref.schemas.js.map +1 -0
  251. package/dist/esm/schemas/sections/website-section-category.shared.d.ts +63 -0
  252. package/dist/esm/schemas/sections/website-section-category.shared.d.ts.map +1 -0
  253. package/dist/esm/schemas/sections/website-section-category.shared.js +64 -0
  254. package/dist/esm/schemas/sections/website-section-category.shared.js.map +1 -0
  255. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts +114 -0
  256. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.d.ts.map +1 -0
  257. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.js +69 -0
  258. package/dist/esm/schemas/sections/website-section-definition.shared.schemas.js.map +1 -0
  259. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.d.ts +100 -0
  260. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.d.ts.map +1 -0
  261. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.js +355 -0
  262. package/dist/esm/schemas/structured-data/structured-data-reconciliation.shared.js.map +1 -0
  263. package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.d.ts +58 -0
  264. package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.d.ts.map +1 -0
  265. package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.js +2 -0
  266. package/dist/esm/schemas/structured-data/website-structured-data.shared.schemas.js.map +1 -0
  267. package/dist/esm/schemas/validators/label-pack.validator.d.ts +144 -0
  268. package/dist/esm/schemas/validators/label-pack.validator.d.ts.map +1 -0
  269. package/dist/esm/schemas/validators/label-pack.validator.js +100 -0
  270. package/dist/esm/schemas/validators/label-pack.validator.js.map +1 -0
  271. package/dist/tsconfig.build.tsbuildinfo +1 -0
  272. package/package.json +143 -0
  273. package/src/__tests__/bundle-isolation.test.ts +607 -0
  274. package/src/astro/__tests__/blog-post-bridge.test.tsx +288 -0
  275. package/src/astro/__tests__/blog-post-head.renderer.test.ts +249 -0
  276. package/src/astro/__tests__/bridge-runtime.test.tsx +198 -0
  277. package/src/astro/__tests__/collect-expected-label-keys.test.ts +69 -0
  278. package/src/astro/__tests__/headers-renderer.test.ts +143 -0
  279. package/src/astro/__tests__/i18n-routing.helper.test.ts +116 -0
  280. package/src/astro/__tests__/label-pack-loader.test.ts +305 -0
  281. package/src/astro/__tests__/llms-txt-server.test.ts +127 -0
  282. package/src/astro/__tests__/pick-labels-for-page.test.ts +60 -0
  283. package/src/astro/__tests__/robots-renderer.test.ts +228 -0
  284. package/src/astro/__tests__/sitemap-coverage.test.ts +230 -0
  285. package/src/astro/__tests__/structured-data.renderer.test.ts +172 -0
  286. package/src/astro/__tests__/website-page-head.renderer.test.ts +424 -0
  287. package/src/astro/__tests__/website-page-runtime.helper.test.ts +157 -0
  288. package/src/astro/__tests__/website-site-context.test.ts +121 -0
  289. package/src/astro/blog-post-bridge.tsx +404 -0
  290. package/src/astro/blog-post-head.renderer.ts +293 -0
  291. package/src/astro/bridge-runtime.tsx +270 -0
  292. package/src/astro/collect-expected-label-keys.ts +113 -0
  293. package/src/astro/headers-renderer.ts +82 -0
  294. package/src/astro/i18n-routing.helper.ts +116 -0
  295. package/src/astro/internal/html-escape.ts +41 -0
  296. package/src/astro/label-pack-loader.ts +385 -0
  297. package/src/astro/llms-txt-server.ts +56 -0
  298. package/src/astro/pick-labels-for-page.ts +85 -0
  299. package/src/astro/robots-renderer.ts +171 -0
  300. package/src/astro/sitemap-coverage.ts +186 -0
  301. package/src/astro/structured-data.renderer.ts +171 -0
  302. package/src/astro/website-page-head.renderer.ts +279 -0
  303. package/src/astro/website-page-runtime.helper.ts +188 -0
  304. package/src/astro/website-site-context.ts +136 -0
  305. package/src/astro-island.ts +61 -0
  306. package/src/astro.ts +151 -0
  307. package/src/companion-exports.ts +38 -0
  308. package/src/components/low-level/WebsiteButton.tsx +118 -0
  309. package/src/components/low-level/WebsiteHeading.tsx +96 -0
  310. package/src/components/low-level/WebsiteImage.tsx +96 -0
  311. package/src/components/low-level/WebsiteInternalButton.tsx +172 -0
  312. package/src/components/low-level/WebsiteInternalLink.tsx +193 -0
  313. package/src/components/low-level/WebsiteLink.tsx +71 -0
  314. package/src/components/low-level/WebsiteText.tsx +75 -0
  315. package/src/components/low-level/__tests__/WebsiteInternalButton.test.tsx +169 -0
  316. package/src/components/low-level/__tests__/WebsiteInternalLink.test.tsx +202 -0
  317. package/src/components/low-level/__tests__/primitives.test.tsx +141 -0
  318. package/src/config/__tests__/define-website-config.test.ts +231 -0
  319. package/src/config/__tests__/load-website-config.test.ts +215 -0
  320. package/src/config/define-website-config.ts +134 -0
  321. package/src/config/index.ts +38 -0
  322. package/src/config/load-website-config.ts +229 -0
  323. package/src/config/wildo-website-config.schemas.ts +322 -0
  324. package/src/config-loader.ts +35 -0
  325. package/src/core/__tests__/WebsitePageLayout.test.tsx +184 -0
  326. package/src/core/__tests__/WebsiteSection.test.tsx +85 -0
  327. package/src/core/__tests__/contexts.test.tsx +133 -0
  328. package/src/core/__tests__/core-boundary.test.ts +85 -0
  329. package/src/core/__tests__/define-website-page-manifest.test.tsx +111 -0
  330. package/src/core/__tests__/define-website-section.test.tsx +89 -0
  331. package/src/core/__tests__/useWebsiteDesignTokens.test.tsx +41 -0
  332. package/src/core/__tests__/useWebsiteLabel.test.tsx +117 -0
  333. package/src/core/anonymous-session/InboundContactForm.tsx +157 -0
  334. package/src/core/anonymous-session/__tests__/inbound-contact-form.schema.test.ts +44 -0
  335. package/src/core/anonymous-session/inbound-contact-form.schema.ts +62 -0
  336. package/src/core/anonymous-session/website-anonymous-session-client.ts +194 -0
  337. package/src/core/contexts/WebsitePageContext.tsx +52 -0
  338. package/src/core/contexts/WebsiteRuntimeContext.tsx +175 -0
  339. package/src/core/contexts/WebsiteSectionContext.tsx +59 -0
  340. package/src/core/contexts/useWebsitePage.ts +28 -0
  341. package/src/core/contexts/useWebsiteRuntime.ts +34 -0
  342. package/src/core/contexts/useWebsiteSection.ts +28 -0
  343. package/src/core/external-providers/frontend-provider-registry.website.ts +62 -0
  344. package/src/core/factories/define-website-page-manifest.ts +102 -0
  345. package/src/core/factories/define-website-section.ts +128 -0
  346. package/src/core/hooks/useWebsiteDesignTokens.ts +26 -0
  347. package/src/core/hooks/useWebsiteLabel.ts +121 -0
  348. package/src/core/layouts/WebsitePageLayout.tsx +311 -0
  349. package/src/core/layouts/WebsiteSection.tsx +139 -0
  350. package/src/core/routing/__tests__/internal-routing.utils.test.ts +134 -0
  351. package/src/core/routing/internal-routing.utils.ts +77 -0
  352. package/src/index.ts +132 -0
  353. package/src/mdx/BlogPost.tsx +296 -0
  354. package/src/mdx/__tests__/BlogPost.test.tsx +218 -0
  355. package/src/mdx/__tests__/blog-post-collection.config.test.ts +127 -0
  356. package/src/mdx/__tests__/website-mdx-provider.test.tsx +82 -0
  357. package/src/mdx/blog-post-collection.config.ts +177 -0
  358. package/src/mdx/blog-post-frontmatter-source.ts +31 -0
  359. package/src/mdx/index.ts +39 -0
  360. package/src/mdx/website-mdx-provider.tsx +95 -0
  361. package/src/mdx.ts +38 -0
  362. package/src/schemas/__tests__/blog-frontmatter.test.ts +50 -0
  363. package/src/schemas/__tests__/companion-exports-react-free.test.ts +51 -0
  364. package/src/schemas/__tests__/design-tokens.test.ts +163 -0
  365. package/src/schemas/__tests__/label-key.test.ts +46 -0
  366. package/src/schemas/__tests__/label-pack-validator.test.ts +146 -0
  367. package/src/schemas/__tests__/page-manifest.test.ts +66 -0
  368. package/src/schemas/__tests__/refs.test.ts +37 -0
  369. package/src/schemas/__tests__/root-config.test.ts +292 -0
  370. package/src/schemas/__tests__/section-definition.test.ts +47 -0
  371. package/src/schemas/__tests__/structured-data-reconciliation.test.ts +543 -0
  372. package/src/schemas/blog/blog-post-frontmatter.shared.schemas.ts +111 -0
  373. package/src/schemas/design-tokens/website-design-tokens.shared.schemas.ts +166 -0
  374. package/src/schemas/label-keys/website-label-key.schemas.ts +88 -0
  375. package/src/schemas/manifests/website-page-manifest.shared.schemas.ts +266 -0
  376. package/src/schemas/manifests/website-root-config.shared.schemas.ts +504 -0
  377. package/src/schemas/refs/page-ref.schemas.ts +49 -0
  378. package/src/schemas/refs/section-ref.schemas.ts +50 -0
  379. package/src/schemas/sections/website-section-category.shared.ts +65 -0
  380. package/src/schemas/sections/website-section-definition.shared.schemas.ts +120 -0
  381. package/src/schemas/structured-data/structured-data-reconciliation.shared.ts +579 -0
  382. package/src/schemas/structured-data/website-structured-data.shared.schemas.ts +62 -0
  383. package/src/schemas/validators/label-pack.validator.ts +201 -0
@@ -0,0 +1,288 @@
1
+ import '@testing-library/jest-dom/vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { useMDXComponents } from '@mdx-js/react';
4
+ import React, { type ComponentType } from 'react';
5
+ import { describe, expect, it } from 'vitest';
6
+
7
+ import { BUILTIN_PROVIDER_CAPABILITY } from '@wildo-ai/saas-models/public-runtime';
8
+ import type { FrontendProvidersBlock } from '@wildo-ai/saas-models/public-runtime';
9
+ import { AvailableLanguage } from '@wildo-ai/saas-models/public-runtime';
10
+
11
+ import { WebsiteLabelKeySchema } from '../../schemas/label-keys/website-label-key.schemas';
12
+ import type { WebsiteNavigationConfig } from '../../schemas/manifests/website-root-config.shared.schemas';
13
+ import { useWebsiteRuntime } from '../../core/contexts/useWebsiteRuntime';
14
+ import {
15
+ createWebsiteBlogPostBridgeIsland,
16
+ type WebsiteBlogPostBridgeIslandProps,
17
+ } from '../blog-post-bridge';
18
+
19
+ const COLORS = {
20
+ background: '#fff',
21
+ foreground: '#111',
22
+ primary: '#0070f3',
23
+ primaryForeground: '#fff',
24
+ accent: '#ff5e5b',
25
+ accentForeground: '#fff',
26
+ surface: '#fafafa',
27
+ surfaceForeground: '#111',
28
+ border: '#e5e5e5',
29
+ muted: '#f5f5f5',
30
+ mutedForeground: '#737373',
31
+ };
32
+ const RADII = {
33
+ none: '0',
34
+ sm: '0.125rem',
35
+ md: '0.375rem',
36
+ lg: '0.75rem',
37
+ xl: '1.5rem',
38
+ full: '9999px',
39
+ };
40
+ const DESIGN_TOKENS = { colors: COLORS, radii: RADII };
41
+
42
+ function NoopComponent(): React.ReactElement | null {
43
+ return null;
44
+ }
45
+
46
+ function buildNavigation(): WebsiteNavigationConfig {
47
+ return { headerComponent: NoopComponent, footerComponent: NoopComponent };
48
+ }
49
+
50
+ const SKIP_KEY = WebsiteLabelKeySchema.parse(
51
+ 'website.chrome.layout.skip-to-main-content',
52
+ );
53
+ const SKIP_LABEL_PACK = {
54
+ 'website.chrome.layout.skip-to-main-content': 'Skip to main content',
55
+ } as const;
56
+ // Typed as the real block rather than `as const`: the prop takes `FrontendProvidersBlock`,
57
+ // whose `providerCapabilities` / `protocols` are mutable `string[]`, so a `readonly` tuple
58
+ // fixture is not assignable to the surface under test.
59
+ const FRONTEND_PROVIDERS: FrontendProvidersBlock = {
60
+ providers: {
61
+ sentry: {
62
+ metadata: { ref: 'sentry' },
63
+ providerCapabilities: [BUILTIN_PROVIDER_CAPABILITY.FRONTEND_ERROR_MONITORING],
64
+ protocols: ['FRONTEND_SDK'],
65
+ publicConfig: {
66
+ integrationKind: 'browser-error-monitoring',
67
+ websiteRuntime: true,
68
+ },
69
+ },
70
+ },
71
+ };
72
+
73
+ const VALID_FRONTMATTER = {
74
+ title: 'Why Context Beats Content',
75
+ summary: 'Compress the loop your team actually runs every week.',
76
+ slug: 'why-context-beats-content',
77
+ locale: AvailableLanguage.EN,
78
+ publishedAt: '2026-04-17T09:00:00.000Z',
79
+ authorRef: 'paul-cayon',
80
+ tags: ['context', 'product-strategy'],
81
+ };
82
+
83
+ function MdxBodyA(): React.ReactElement {
84
+ return <p data-testid="mdx-body-a">Body A prose</p>;
85
+ }
86
+ function MdxBodyB(): React.ReactElement {
87
+ return <p data-testid="mdx-body-b">Body B prose</p>;
88
+ }
89
+ function ProviderAwareMdxBody(): React.ReactElement {
90
+ const runtime = useWebsiteRuntime();
91
+ const sentryProvider = runtime.frontendProviderRegistry
92
+ ?.findProviderByProviderCapability(BUILTIN_PROVIDER_CAPABILITY.FRONTEND_ERROR_MONITORING);
93
+ return <p data-testid="provider-ref">{sentryProvider?.metadata.ref ?? '<missing>'}</p>;
94
+ }
95
+
96
+ describe('createWebsiteBlogPostBridgeIsland', () => {
97
+ it('returns a React component (closes over deps; never crosses prop boundary)', () => {
98
+ const Island = createWebsiteBlogPostBridgeIsland({
99
+ slugToMdxBody: { 'why-context-beats-content': MdxBodyA },
100
+ navigation: buildNavigation(),
101
+ });
102
+ expect(typeof Island).toBe('function');
103
+ expect(Island.displayName).toBe('WebsiteBlogPostBridgeIsland');
104
+ });
105
+
106
+ it('renders the MDX body matched by `slug` inside <BlogPost> + runtime context', () => {
107
+ const Island = createWebsiteBlogPostBridgeIsland({
108
+ slugToMdxBody: {
109
+ 'why-context-beats-content': MdxBodyA,
110
+ 'a-different-post': MdxBodyB,
111
+ },
112
+ navigation: buildNavigation(),
113
+ });
114
+ render(
115
+ <Island
116
+ slug="why-context-beats-content"
117
+ rawFrontmatter={VALID_FRONTMATTER}
118
+ source="en/why-context-beats-content.mdx"
119
+ locale={AvailableLanguage.EN}
120
+ defaultLocale={AvailableLanguage.EN}
121
+ labelPack={SKIP_LABEL_PACK}
122
+ designTokens={DESIGN_TOKENS}
123
+ skipToMainContentLabelKey={SKIP_KEY}
124
+ />,
125
+ );
126
+ expect(screen.getByTestId('mdx-body-a').textContent).toBe('Body A prose');
127
+ expect(screen.queryByTestId('mdx-body-b')).toBeNull();
128
+ expect(
129
+ screen.getByRole('heading', { level: 1, name: 'Why Context Beats Content' }),
130
+ ).toBeInTheDocument();
131
+ });
132
+
133
+ it('hydrates the website frontend provider registry inside the blog runtime context', () => {
134
+ const Island = createWebsiteBlogPostBridgeIsland({
135
+ slugToMdxBody: {
136
+ 'why-context-beats-content': ProviderAwareMdxBody,
137
+ },
138
+ navigation: buildNavigation(),
139
+ });
140
+ render(
141
+ <Island
142
+ slug="why-context-beats-content"
143
+ rawFrontmatter={VALID_FRONTMATTER}
144
+ source="en/why-context-beats-content.mdx"
145
+ locale={AvailableLanguage.EN}
146
+ defaultLocale={AvailableLanguage.EN}
147
+ labelPack={SKIP_LABEL_PACK}
148
+ designTokens={DESIGN_TOKENS}
149
+ skipToMainContentLabelKey={SKIP_KEY}
150
+ frontendProviders={FRONTEND_PROVIDERS}
151
+ />,
152
+ );
153
+
154
+ expect(screen.getByTestId('provider-ref').textContent).toBe('sentry');
155
+ });
156
+
157
+ it('emits the BLOG_POST_PAGE_REF on the layout root data attribute', () => {
158
+ const Island = createWebsiteBlogPostBridgeIsland({
159
+ slugToMdxBody: { 'why-context-beats-content': MdxBodyA },
160
+ navigation: buildNavigation(),
161
+ });
162
+ const { container } = render(
163
+ <Island
164
+ slug="why-context-beats-content"
165
+ rawFrontmatter={VALID_FRONTMATTER}
166
+ locale={AvailableLanguage.EN}
167
+ defaultLocale={AvailableLanguage.EN}
168
+ labelPack={SKIP_LABEL_PACK}
169
+ designTokens={DESIGN_TOKENS}
170
+ skipToMainContentLabelKey={SKIP_KEY}
171
+ frontendProviders={FRONTEND_PROVIDERS}
172
+ />,
173
+ );
174
+ expect(
175
+ container.querySelector('[data-website-page-ref="blog-post"]'),
176
+ ).not.toBeNull();
177
+ expect(
178
+ container.querySelector('[data-website-error-monitoring-provider-ref="sentry"]'),
179
+ ).not.toBeNull();
180
+ });
181
+
182
+ it('forwards mdxComponents into <WebsiteMdxProvider>; embedded sections resolve through the provider context (NOT via direct imports inside the MDX body)', () => {
183
+ /**
184
+ * Strong test of the provider scope: the MDX body MUST reach the
185
+ * `CTASection` component THROUGH the `@mdx-js/react` provider's
186
+ * `useMDXComponents` lookup hook — the same lookup mechanism MDX
187
+ * compilation generates for tag references in `.mdx` source. If
188
+ * the bridge factory failed to forward `mdxComponents` into
189
+ * `<WebsiteMdxProvider>`, the lookup would return an empty
190
+ * components map and the assertion below would fail.
191
+ *
192
+ * (The earlier weaker version of this test imported `StubCtaSection`
193
+ * directly into the MDX body, which bypassed the provider entirely
194
+ * and would have passed even with the provider wiring removed.)
195
+ */
196
+ function StubCtaSection(): React.ReactElement {
197
+ return <div data-testid="stub-cta">CTA SECTION</div>;
198
+ }
199
+ function MdxBodySimulatingTagLookup(): React.ReactElement {
200
+ const components = useMDXComponents({});
201
+ const Resolved = components['CTASection'] as
202
+ | ComponentType<Record<string, never>>
203
+ | undefined;
204
+ if (Resolved === undefined) {
205
+ return <div data-testid="provider-miss">PROVIDER MISS</div>;
206
+ }
207
+ return (
208
+ <div>
209
+ <p>Pre-CTA prose.</p>
210
+ <Resolved />
211
+ </div>
212
+ );
213
+ }
214
+ const Island = createWebsiteBlogPostBridgeIsland({
215
+ slugToMdxBody: { 'with-cta': MdxBodySimulatingTagLookup },
216
+ navigation: buildNavigation(),
217
+ mdxComponents: { CTASection: StubCtaSection },
218
+ });
219
+ render(
220
+ <Island
221
+ slug="with-cta"
222
+ rawFrontmatter={{ ...VALID_FRONTMATTER, slug: 'with-cta' }}
223
+ locale={AvailableLanguage.EN}
224
+ defaultLocale={AvailableLanguage.EN}
225
+ labelPack={SKIP_LABEL_PACK}
226
+ designTokens={DESIGN_TOKENS}
227
+ skipToMainContentLabelKey={SKIP_KEY}
228
+ frontendProviders={FRONTEND_PROVIDERS}
229
+ />,
230
+ );
231
+ expect(screen.queryByTestId('provider-miss')).toBeNull();
232
+ expect(screen.getByTestId('stub-cta').textContent).toBe('CTA SECTION');
233
+ });
234
+
235
+ it('throws an actionable error when the slug has no entry in slugToMdxBody', () => {
236
+ const Island = createWebsiteBlogPostBridgeIsland({
237
+ slugToMdxBody: { 'a-real-post': MdxBodyA },
238
+ navigation: buildNavigation(),
239
+ });
240
+ /**
241
+ * Render a wrapper in a try/catch — `render` swallows the throw
242
+ * into React's reconciler error path, but the message is what we
243
+ * care about. We assert the error message via `console.error` spy
244
+ * patterns elsewhere in the suite; here, the simplest reliable
245
+ * assertion is to call the island as a plain function and observe
246
+ * the synchronous throw from inside the body of
247
+ * `WebsiteBlogPostBridgeIsland`.
248
+ */
249
+ expect(() => {
250
+ Island({
251
+ slug: 'unknown-slug',
252
+ rawFrontmatter: { ...VALID_FRONTMATTER, slug: 'unknown-slug' },
253
+ locale: AvailableLanguage.EN,
254
+ defaultLocale: AvailableLanguage.EN,
255
+ labelPack: SKIP_LABEL_PACK,
256
+ designTokens: DESIGN_TOKENS,
257
+ skipToMainContentLabelKey: SKIP_KEY,
258
+ frontendProviders: FRONTEND_PROVIDERS,
259
+ });
260
+ }).toThrowError(/No MDX body registered for slug="unknown-slug"/);
261
+ });
262
+
263
+ /**
264
+ * Mirrors the regression guard in `bridge-runtime.test.tsx` — island
265
+ * props MUST stay JSON-serializable so they can survive Astro's
266
+ * `devalue`-style hydration boundary. ComponentType refs (the MDX
267
+ * body, the navigation header/footer, the embedded section
268
+ * components) MUST stay closure-trapped, never on the prop signature.
269
+ */
270
+ it('island props are JSON-serializable (no ComponentType on the prop boundary)', () => {
271
+ const props: WebsiteBlogPostBridgeIslandProps = {
272
+ slug: 'why-context-beats-content',
273
+ rawFrontmatter: VALID_FRONTMATTER,
274
+ source: 'en/why-context-beats-content.mdx',
275
+ locale: AvailableLanguage.EN,
276
+ defaultLocale: AvailableLanguage.EN,
277
+ labelPack: SKIP_LABEL_PACK,
278
+ designTokens: DESIGN_TOKENS,
279
+ skipToMainContentLabelKey: SKIP_KEY,
280
+ frontendProviders: FRONTEND_PROVIDERS,
281
+ };
282
+ expect(() => structuredClone(props)).not.toThrow();
283
+ const cloned = structuredClone(props);
284
+ expect(cloned.slug).toBe('why-context-beats-content');
285
+ expect(cloned.locale).toBe(AvailableLanguage.EN);
286
+ expect(cloned.frontendProviders?.providers.sentry.metadata.ref).toBe('sentry');
287
+ });
288
+ });
@@ -0,0 +1,249 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { AvailableLanguage } from '@wildo-ai/saas-models/public-runtime';
4
+
5
+ import { WildoMarketingWebsiteConfigSchema } from '../../config/wildo-website-config.schemas';
6
+ import {
7
+ BlogPostFrontmatterSchema,
8
+ type BlogPostFrontmatter,
9
+ } from '../../schemas/blog/blog-post-frontmatter.shared.schemas';
10
+ import { WebsiteLabelKeySchema } from '../../schemas/label-keys/website-label-key.schemas';
11
+ import {
12
+ WebsitePageManifestSchema,
13
+ type WebsitePageManifest,
14
+ } from '../../schemas/manifests/website-page-manifest.shared.schemas';
15
+ import {
16
+ WebsiteRootConfigSchema,
17
+ type WebsiteRootConfig,
18
+ } from '../../schemas/manifests/website-root-config.shared.schemas';
19
+ import { WebsitePageRefSchema } from '../../schemas/refs/page-ref.schemas';
20
+ import { WebsiteSectionRefSchema } from '../../schemas/refs/section-ref.schemas';
21
+ import { renderBlogPostHeadHtml } from '../blog-post-head.renderer';
22
+ import {
23
+ composeWebsiteSiteContext,
24
+ type WebsiteSiteContext,
25
+ } from '../website-site-context';
26
+
27
+ const NoopComponent = () => null;
28
+
29
+ const DESIGN_TOKENS = {
30
+ colors: {
31
+ background: '#fff',
32
+ foreground: '#111',
33
+ primary: '#0070f3',
34
+ primaryForeground: '#fff',
35
+ accent: '#ff5e5b',
36
+ accentForeground: '#fff',
37
+ surface: '#fafafa',
38
+ surfaceForeground: '#111',
39
+ border: '#e5e5e5',
40
+ muted: '#f5f5f5',
41
+ mutedForeground: '#737373',
42
+ },
43
+ radii: {
44
+ none: '0',
45
+ sm: '0.125rem',
46
+ md: '0.375rem',
47
+ lg: '0.75rem',
48
+ xl: '1.5rem',
49
+ full: '9999px',
50
+ },
51
+ };
52
+
53
+ const FILLER_MANIFEST: WebsitePageManifest = WebsitePageManifestSchema.parse({
54
+ ref: WebsitePageRefSchema.parse('blog-post'),
55
+ routePath: '/blog/[slug]',
56
+ pageComponent: NoopComponent,
57
+ sectionRefs: [WebsiteSectionRefSchema.parse('post-cta')],
58
+ metaLabelKeys: {
59
+ titleKey: WebsiteLabelKeySchema.parse('website.blog-post.meta.title'),
60
+ descriptionKey: WebsiteLabelKeySchema.parse('website.blog-post.meta.description'),
61
+ },
62
+ }) as WebsitePageManifest;
63
+
64
+ function buildSiteContext(
65
+ locales: AvailableLanguage[],
66
+ defaultLocale: AvailableLanguage,
67
+ publicOrigin = 'https://example.com',
68
+ siteName = 'Example Site',
69
+ ): WebsiteSiteContext {
70
+ const rootConfig = WebsiteRootConfigSchema.parse({
71
+ pageManifests: [FILLER_MANIFEST],
72
+ designTokens: DESIGN_TOKENS,
73
+ navigation: { headerComponent: NoopComponent, footerComponent: NoopComponent },
74
+ seo: { siteName },
75
+ skipToMainContentLabelKey: WebsiteLabelKeySchema.parse(
76
+ 'website.chrome.layout.skip-to-main-content',
77
+ ),
78
+ }) as WebsiteRootConfig;
79
+
80
+ const websiteConfig = WildoMarketingWebsiteConfigSchema.parse({
81
+ publicOrigin,
82
+ appOrigin: 'https://app.example.com',
83
+ defaultLocale,
84
+ locales: locales.map((locale) => ({ locale, path: String(locale) })),
85
+ });
86
+
87
+ return composeWebsiteSiteContext({ rootConfig, websiteConfig });
88
+ }
89
+
90
+ const VALID_FRONTMATTER: BlogPostFrontmatter = BlogPostFrontmatterSchema.parse({
91
+ title: 'Why Context Beats Content',
92
+ summary: 'Compress the loop your team actually runs every week.',
93
+ slug: 'why-context-beats-content',
94
+ locale: AvailableLanguage.EN,
95
+ publishedAt: '2026-04-17T09:00:00.000Z',
96
+ authorRef: 'paul-cayon',
97
+ tags: ['context', 'product-strategy'],
98
+ coverImageUrl: 'https://cdn.wildo.ai/blog/why-context-cover.png',
99
+ });
100
+
101
+ describe('renderBlogPostHeadHtml', () => {
102
+ it('emits title, description, og:type=article, and canonical from frontmatter + site context', () => {
103
+ const html = renderBlogPostHeadHtml({
104
+ siteContext: buildSiteContext(
105
+ [AvailableLanguage.EN, AvailableLanguage.FR],
106
+ AvailableLanguage.EN,
107
+ ),
108
+ locale: AvailableLanguage.EN,
109
+ routePath: '/blog/why-context-beats-content',
110
+ frontmatter: VALID_FRONTMATTER,
111
+ });
112
+ expect(html).toContain('<title>Why Context Beats Content</title>');
113
+ expect(html).toContain(
114
+ '<meta name="description" content="Compress the loop your team actually runs every week." />',
115
+ );
116
+ expect(html).toContain('<meta property="og:type" content="article" />');
117
+ expect(html).toContain(
118
+ '<link rel="canonical" href="https://example.com/blog/why-context-beats-content" />',
119
+ );
120
+ });
121
+
122
+ it('emits article:published_time, article:author, and one article:tag per tag', () => {
123
+ const html = renderBlogPostHeadHtml({
124
+ siteContext: buildSiteContext([AvailableLanguage.EN], AvailableLanguage.EN),
125
+ locale: AvailableLanguage.EN,
126
+ routePath: '/blog/why-context-beats-content',
127
+ frontmatter: VALID_FRONTMATTER,
128
+ });
129
+ expect(html).toContain(
130
+ '<meta property="article:published_time" content="2026-04-17T09:00:00.000Z" />',
131
+ );
132
+ expect(html).toContain(
133
+ '<meta property="article:author" content="paul-cayon" />',
134
+ );
135
+ expect(html).toContain('<meta property="article:tag" content="context" />');
136
+ expect(html).toContain(
137
+ '<meta property="article:tag" content="product-strategy" />',
138
+ );
139
+ });
140
+
141
+ it('emits BlogPosting JSON-LD alongside the OG article metadata', () => {
142
+ const html = renderBlogPostHeadHtml({
143
+ siteContext: buildSiteContext([AvailableLanguage.EN], AvailableLanguage.EN),
144
+ locale: AvailableLanguage.EN,
145
+ routePath: '/blog/why-context-beats-content',
146
+ frontmatter: VALID_FRONTMATTER,
147
+ });
148
+ expect(html).toContain('<script type="application/ld+json">');
149
+ expect(html).toContain('"@type":"BlogPosting"');
150
+ expect(html).toContain('"headline":"Why Context Beats Content"');
151
+ expect(html).toContain('"mainEntityOfPage":"https://example.com/blog/why-context-beats-content"');
152
+ expect(html).toContain('"inLanguage":"en"');
153
+ });
154
+
155
+ it('emits og:image and twitter:card=summary_large_image when coverImageUrl is set', () => {
156
+ const html = renderBlogPostHeadHtml({
157
+ siteContext: buildSiteContext([AvailableLanguage.EN], AvailableLanguage.EN),
158
+ locale: AvailableLanguage.EN,
159
+ routePath: '/blog/why-context-beats-content',
160
+ frontmatter: VALID_FRONTMATTER,
161
+ });
162
+ expect(html).toContain(
163
+ '<meta property="og:image" content="https://cdn.wildo.ai/blog/why-context-cover.png" />',
164
+ );
165
+ expect(html).toContain('<meta name="twitter:card" content="summary_large_image" />');
166
+ });
167
+
168
+ it('omits og:image and downgrades twitter card to summary when coverImageUrl is absent', () => {
169
+ const noCover = BlogPostFrontmatterSchema.parse({
170
+ ...VALID_FRONTMATTER,
171
+ coverImageUrl: undefined,
172
+ });
173
+ const html = renderBlogPostHeadHtml({
174
+ siteContext: buildSiteContext([AvailableLanguage.EN], AvailableLanguage.EN),
175
+ locale: AvailableLanguage.EN,
176
+ routePath: '/blog/why-context-beats-content',
177
+ frontmatter: noCover,
178
+ });
179
+ expect(html).not.toContain('og:image');
180
+ expect(html).toContain('<meta name="twitter:card" content="summary" />');
181
+ });
182
+
183
+ it('emits one hreflang per declared locale + an x-default pointing at the default-locale URL', () => {
184
+ const html = renderBlogPostHeadHtml({
185
+ siteContext: buildSiteContext(
186
+ [AvailableLanguage.EN, AvailableLanguage.FR],
187
+ AvailableLanguage.EN,
188
+ ),
189
+ locale: AvailableLanguage.EN,
190
+ routePath: '/blog/why-context-beats-content',
191
+ frontmatter: VALID_FRONTMATTER,
192
+ });
193
+ expect(html).toContain(
194
+ '<link rel="alternate" hreflang="en" href="https://example.com/blog/why-context-beats-content" />',
195
+ );
196
+ expect(html).toContain(
197
+ '<link rel="alternate" hreflang="fr" href="https://example.com/fr/blog/why-context-beats-content" />',
198
+ );
199
+ expect(html).toContain(
200
+ '<link rel="alternate" hreflang="x-default" href="https://example.com/blog/why-context-beats-content" />',
201
+ );
202
+ });
203
+
204
+ it('locale-prefixes the canonical URL when the current locale is not the default locale', () => {
205
+ const html = renderBlogPostHeadHtml({
206
+ siteContext: buildSiteContext(
207
+ [AvailableLanguage.EN, AvailableLanguage.FR],
208
+ AvailableLanguage.EN,
209
+ ),
210
+ locale: AvailableLanguage.FR,
211
+ routePath: '/blog/why-context-beats-content',
212
+ frontmatter: VALID_FRONTMATTER,
213
+ });
214
+ expect(html).toContain(
215
+ '<link rel="canonical" href="https://example.com/fr/blog/why-context-beats-content" />',
216
+ );
217
+ expect(html).toContain(
218
+ '<meta property="og:url" content="https://example.com/fr/blog/why-context-beats-content" />',
219
+ );
220
+ expect(html).toContain('"url":"https://example.com/fr/blog/why-context-beats-content"');
221
+ });
222
+
223
+ it('emits noai robots meta when the blog post opts out of AI training', () => {
224
+ const html = renderBlogPostHeadHtml({
225
+ siteContext: buildSiteContext([AvailableLanguage.EN], AvailableLanguage.EN),
226
+ locale: AvailableLanguage.EN,
227
+ routePath: '/blog/why-context-beats-content',
228
+ frontmatter: VALID_FRONTMATTER,
229
+ noaiOptOut: true,
230
+ });
231
+ expect(html).toContain('<meta name="robots" content="noai, noimageai" />');
232
+ });
233
+
234
+ it('escapes HTML-unsafe characters in title and summary', () => {
235
+ const tricky = BlogPostFrontmatterSchema.parse({
236
+ ...VALID_FRONTMATTER,
237
+ title: 'Quotes "and" <angle> brackets',
238
+ summary: "Apostrophe's & ampersands break HTML.",
239
+ });
240
+ const html = renderBlogPostHeadHtml({
241
+ siteContext: buildSiteContext([AvailableLanguage.EN], AvailableLanguage.EN),
242
+ locale: AvailableLanguage.EN,
243
+ routePath: '/blog/quotes-and-angles',
244
+ frontmatter: tricky,
245
+ });
246
+ expect(html).toContain('<title>Quotes &quot;and&quot; &lt;angle&gt; brackets</title>');
247
+ expect(html).toContain('Apostrophe&#39;s &amp; ampersands break HTML.');
248
+ });
249
+ });