@prenta/admin 0.92.0 → 0.93.0

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 (374) hide show
  1. package/CHANGELOG.md +552 -0
  2. package/design-system/tokens.css +173 -71
  3. package/design-system/tokens.json +244 -63
  4. package/design-system/tokens.scss +203 -98
  5. package/design-system/tokens.tailwind.cjs +93 -38
  6. package/dist/AdminRoot.d.ts +27 -6
  7. package/dist/AdminRoot.d.ts.map +1 -1
  8. package/dist/AdminRoot.js.map +1 -1
  9. package/dist/__tests__/components/breakpoint-controls.render.test.d.ts +2 -0
  10. package/dist/__tests__/components/breakpoint-controls.render.test.d.ts.map +1 -0
  11. package/dist/__tests__/components/breakpoint-controls.render.test.js +51 -0
  12. package/dist/__tests__/components/breakpoint-controls.render.test.js.map +1 -0
  13. package/dist/__tests__/components/canvas-edit-tip.render.test.d.ts +2 -0
  14. package/dist/__tests__/components/canvas-edit-tip.render.test.d.ts.map +1 -0
  15. package/dist/__tests__/components/canvas-edit-tip.render.test.js +37 -0
  16. package/dist/__tests__/components/canvas-edit-tip.render.test.js.map +1 -0
  17. package/dist/__tests__/components/editor-rail.render.test.d.ts +2 -0
  18. package/dist/__tests__/components/editor-rail.render.test.d.ts.map +1 -0
  19. package/dist/__tests__/components/editor-rail.render.test.js +138 -0
  20. package/dist/__tests__/components/editor-rail.render.test.js.map +1 -0
  21. package/dist/__tests__/components/media-block.render.test.d.ts +2 -0
  22. package/dist/__tests__/components/media-block.render.test.d.ts.map +1 -0
  23. package/dist/__tests__/components/media-block.render.test.js +108 -0
  24. package/dist/__tests__/components/media-block.render.test.js.map +1 -0
  25. package/dist/__tests__/components/page-audit-panel.render.test.d.ts +2 -0
  26. package/dist/__tests__/components/page-audit-panel.render.test.d.ts.map +1 -0
  27. package/dist/__tests__/components/page-audit-panel.render.test.js +59 -0
  28. package/dist/__tests__/components/page-audit-panel.render.test.js.map +1 -0
  29. package/dist/__tests__/components/reflow-meter.render.test.d.ts +2 -0
  30. package/dist/__tests__/components/reflow-meter.render.test.d.ts.map +1 -0
  31. package/dist/__tests__/components/reflow-meter.render.test.js +94 -0
  32. package/dist/__tests__/components/reflow-meter.render.test.js.map +1 -0
  33. package/dist/__tests__/components/seo-char-count.test.js +24 -6
  34. package/dist/__tests__/components/seo-char-count.test.js.map +1 -1
  35. package/dist/__tests__/components/serp-preview.render.test.d.ts +2 -0
  36. package/dist/__tests__/components/serp-preview.render.test.d.ts.map +1 -0
  37. package/dist/__tests__/components/serp-preview.render.test.js +25 -0
  38. package/dist/__tests__/components/serp-preview.render.test.js.map +1 -0
  39. package/dist/__tests__/lib/breakpoints.test.d.ts +2 -0
  40. package/dist/__tests__/lib/breakpoints.test.d.ts.map +1 -0
  41. package/dist/__tests__/lib/breakpoints.test.js +67 -0
  42. package/dist/__tests__/lib/breakpoints.test.js.map +1 -0
  43. package/dist/__tests__/lib/canvas-edit-chrome.test.d.ts +2 -0
  44. package/dist/__tests__/lib/canvas-edit-chrome.test.d.ts.map +1 -0
  45. package/dist/__tests__/lib/canvas-edit-chrome.test.js +44 -0
  46. package/dist/__tests__/lib/canvas-edit-chrome.test.js.map +1 -0
  47. package/dist/__tests__/lib/media-format.test.d.ts +2 -0
  48. package/dist/__tests__/lib/media-format.test.d.ts.map +1 -0
  49. package/dist/__tests__/lib/media-format.test.js +70 -0
  50. package/dist/__tests__/lib/media-format.test.js.map +1 -0
  51. package/dist/__tests__/lib/page-audit-input.test.d.ts +2 -0
  52. package/dist/__tests__/lib/page-audit-input.test.d.ts.map +1 -0
  53. package/dist/__tests__/lib/page-audit-input.test.js +122 -0
  54. package/dist/__tests__/lib/page-audit-input.test.js.map +1 -0
  55. package/dist/__tests__/lib/page-editor-service.test.js +2 -0
  56. package/dist/__tests__/lib/page-editor-service.test.js.map +1 -1
  57. package/dist/__tests__/lib/page-redirect.test.d.ts +2 -0
  58. package/dist/__tests__/lib/page-redirect.test.d.ts.map +1 -0
  59. package/dist/__tests__/lib/page-redirect.test.js +26 -0
  60. package/dist/__tests__/lib/page-redirect.test.js.map +1 -0
  61. package/dist/__tests__/lib/rail-width.test.d.ts +2 -0
  62. package/dist/__tests__/lib/rail-width.test.d.ts.map +1 -0
  63. package/dist/__tests__/lib/rail-width.test.js +69 -0
  64. package/dist/__tests__/lib/rail-width.test.js.map +1 -0
  65. package/dist/__tests__/lib/reflow-baseline.test.d.ts +2 -0
  66. package/dist/__tests__/lib/reflow-baseline.test.d.ts.map +1 -0
  67. package/dist/__tests__/lib/reflow-baseline.test.js +56 -0
  68. package/dist/__tests__/lib/reflow-baseline.test.js.map +1 -0
  69. package/dist/__tests__/lib/reflow-probe.test.d.ts +2 -0
  70. package/dist/__tests__/lib/reflow-probe.test.d.ts.map +1 -0
  71. package/dist/__tests__/lib/reflow-probe.test.js +73 -0
  72. package/dist/__tests__/lib/reflow-probe.test.js.map +1 -0
  73. package/dist/__tests__/styles/canvas-container-queries.test.d.ts +2 -0
  74. package/dist/__tests__/styles/canvas-container-queries.test.d.ts.map +1 -0
  75. package/dist/__tests__/styles/canvas-container-queries.test.js +87 -0
  76. package/dist/__tests__/styles/canvas-container-queries.test.js.map +1 -0
  77. package/dist/__tests__/styles/packaging.test.d.ts +2 -0
  78. package/dist/__tests__/styles/packaging.test.d.ts.map +1 -0
  79. package/dist/__tests__/styles/packaging.test.js +31 -0
  80. package/dist/__tests__/styles/packaging.test.js.map +1 -0
  81. package/dist/__tests__/styles/tokens-drift.test.d.ts +2 -0
  82. package/dist/__tests__/styles/tokens-drift.test.d.ts.map +1 -0
  83. package/dist/__tests__/styles/tokens-drift.test.js +92 -0
  84. package/dist/__tests__/styles/tokens-drift.test.js.map +1 -0
  85. package/dist/__tests__/views/admin-renderer-compat.test.d.ts +2 -0
  86. package/dist/__tests__/views/admin-renderer-compat.test.d.ts.map +1 -0
  87. package/dist/__tests__/views/admin-renderer-compat.test.js +43 -0
  88. package/dist/__tests__/views/admin-renderer-compat.test.js.map +1 -0
  89. package/dist/__tests__/views/admin-section-renderers.test.js +52 -4
  90. package/dist/__tests__/views/admin-section-renderers.test.js.map +1 -1
  91. package/dist/__tests__/views/editor-top-bar.render.test.js +1 -1
  92. package/dist/__tests__/views/editor-top-bar.render.test.js.map +1 -1
  93. package/dist/__tests__/views/page-tab.render.test.d.ts +2 -0
  94. package/dist/__tests__/views/page-tab.render.test.d.ts.map +1 -0
  95. package/dist/__tests__/views/page-tab.render.test.js +151 -0
  96. package/dist/__tests__/views/page-tab.render.test.js.map +1 -0
  97. package/dist/__tests__/views/section-class-parity.test.d.ts +2 -0
  98. package/dist/__tests__/views/section-class-parity.test.d.ts.map +1 -0
  99. package/dist/__tests__/views/section-class-parity.test.js +81 -0
  100. package/dist/__tests__/views/section-class-parity.test.js.map +1 -0
  101. package/dist/__tests__/views/sections-panel-design-owned.render.test.d.ts +2 -0
  102. package/dist/__tests__/views/sections-panel-design-owned.render.test.d.ts.map +1 -0
  103. package/dist/__tests__/views/sections-panel-design-owned.render.test.js +86 -0
  104. package/dist/__tests__/views/sections-panel-design-owned.render.test.js.map +1 -0
  105. package/dist/components/BreakpointPicker.d.ts +18 -0
  106. package/dist/components/BreakpointPicker.d.ts.map +1 -0
  107. package/dist/components/BreakpointPicker.js +31 -0
  108. package/dist/components/BreakpointPicker.js.map +1 -0
  109. package/dist/components/CanvasEditTip.d.ts +6 -0
  110. package/dist/components/CanvasEditTip.d.ts.map +1 -0
  111. package/dist/components/CanvasEditTip.js +22 -0
  112. package/dist/components/CanvasEditTip.js.map +1 -0
  113. package/dist/components/EditorRail.d.ts +36 -0
  114. package/dist/components/EditorRail.d.ts.map +1 -0
  115. package/dist/components/EditorRail.js +104 -0
  116. package/dist/components/EditorRail.js.map +1 -0
  117. package/dist/components/EditorRailTabs.d.ts +32 -0
  118. package/dist/components/EditorRailTabs.d.ts.map +1 -0
  119. package/dist/components/EditorRailTabs.js +45 -0
  120. package/dist/components/EditorRailTabs.js.map +1 -0
  121. package/dist/components/MediaBlock.d.ts +49 -0
  122. package/dist/components/MediaBlock.d.ts.map +1 -0
  123. package/dist/components/MediaBlock.js +50 -0
  124. package/dist/components/MediaBlock.js.map +1 -0
  125. package/dist/components/ReflowMeter.d.ts +28 -0
  126. package/dist/components/ReflowMeter.d.ts.map +1 -0
  127. package/dist/components/ReflowMeter.js +42 -0
  128. package/dist/components/ReflowMeter.js.map +1 -0
  129. package/dist/components/ZoomChip.d.ts +17 -0
  130. package/dist/components/ZoomChip.d.ts.map +1 -0
  131. package/dist/components/ZoomChip.js +17 -0
  132. package/dist/components/ZoomChip.js.map +1 -0
  133. package/dist/components/breakpoint-icons.d.ts +3 -0
  134. package/dist/components/breakpoint-icons.d.ts.map +1 -0
  135. package/dist/components/breakpoint-icons.js +16 -0
  136. package/dist/components/breakpoint-icons.js.map +1 -0
  137. package/dist/components/page-tab/AuthorPicker.d.ts +31 -0
  138. package/dist/components/page-tab/AuthorPicker.d.ts.map +1 -0
  139. package/dist/components/page-tab/AuthorPicker.js +95 -0
  140. package/dist/components/page-tab/AuthorPicker.js.map +1 -0
  141. package/dist/components/page-tab/DirectiveSwitch.d.ts +38 -0
  142. package/dist/components/page-tab/DirectiveSwitch.d.ts.map +1 -0
  143. package/dist/components/page-tab/DirectiveSwitch.js +29 -0
  144. package/dist/components/page-tab/DirectiveSwitch.js.map +1 -0
  145. package/dist/components/seo/EditorSeoScoreBadge.d.ts +8 -1
  146. package/dist/components/seo/EditorSeoScoreBadge.d.ts.map +1 -1
  147. package/dist/components/seo/EditorSeoScoreBadge.js +2 -2
  148. package/dist/components/seo/EditorSeoScoreBadge.js.map +1 -1
  149. package/dist/components/seo/PageAuditPanel.d.ts +14 -0
  150. package/dist/components/seo/PageAuditPanel.d.ts.map +1 -0
  151. package/dist/components/seo/PageAuditPanel.js +59 -0
  152. package/dist/components/seo/PageAuditPanel.js.map +1 -0
  153. package/dist/components/seo/SeoCharCount.d.ts +19 -7
  154. package/dist/components/seo/SeoCharCount.d.ts.map +1 -1
  155. package/dist/components/seo/SeoCharCount.js +30 -16
  156. package/dist/components/seo/SeoCharCount.js.map +1 -1
  157. package/dist/components/seo/SeoEditorPane.d.ts +15 -1
  158. package/dist/components/seo/SeoEditorPane.d.ts.map +1 -1
  159. package/dist/components/seo/SeoEditorPane.js +118 -21
  160. package/dist/components/seo/SeoEditorPane.js.map +1 -1
  161. package/dist/components/seo/SerpPreview.d.ts +20 -0
  162. package/dist/components/seo/SerpPreview.d.ts.map +1 -0
  163. package/dist/components/seo/SerpPreview.js +6 -0
  164. package/dist/components/seo/SerpPreview.js.map +1 -0
  165. package/dist/hooks/usePageAudit.d.ts +41 -0
  166. package/dist/hooks/usePageAudit.d.ts.map +1 -0
  167. package/dist/hooks/usePageAudit.js +68 -0
  168. package/dist/hooks/usePageAudit.js.map +1 -0
  169. package/dist/hooks/useReflowGuard.d.ts +47 -0
  170. package/dist/hooks/useReflowGuard.d.ts.map +1 -0
  171. package/dist/hooks/useReflowGuard.js +52 -0
  172. package/dist/hooks/useReflowGuard.js.map +1 -0
  173. package/dist/lib/breakpoints.d.ts +51 -0
  174. package/dist/lib/breakpoints.d.ts.map +1 -0
  175. package/dist/lib/breakpoints.js +84 -0
  176. package/dist/lib/breakpoints.js.map +1 -0
  177. package/dist/lib/canvas-edit-chrome.d.ts +22 -0
  178. package/dist/lib/canvas-edit-chrome.d.ts.map +1 -0
  179. package/dist/lib/canvas-edit-chrome.js +81 -0
  180. package/dist/lib/canvas-edit-chrome.js.map +1 -0
  181. package/dist/lib/media-format.d.ts +55 -0
  182. package/dist/lib/media-format.d.ts.map +1 -0
  183. package/dist/lib/media-format.js +93 -0
  184. package/dist/lib/media-format.js.map +1 -0
  185. package/dist/lib/page-audit-input.d.ts +45 -0
  186. package/dist/lib/page-audit-input.d.ts.map +1 -0
  187. package/dist/lib/page-audit-input.js +53 -0
  188. package/dist/lib/page-audit-input.js.map +1 -0
  189. package/dist/lib/page-editor-service.d.ts +12 -0
  190. package/dist/lib/page-editor-service.d.ts.map +1 -1
  191. package/dist/lib/page-editor-service.js +6 -0
  192. package/dist/lib/page-editor-service.js.map +1 -1
  193. package/dist/lib/page-redirect.d.ts +39 -0
  194. package/dist/lib/page-redirect.d.ts.map +1 -0
  195. package/dist/lib/page-redirect.js +38 -0
  196. package/dist/lib/page-redirect.js.map +1 -0
  197. package/dist/lib/rail-width.d.ts +23 -0
  198. package/dist/lib/rail-width.d.ts.map +1 -0
  199. package/dist/lib/rail-width.js +59 -0
  200. package/dist/lib/rail-width.js.map +1 -0
  201. package/dist/lib/reflow-baseline.d.ts +45 -0
  202. package/dist/lib/reflow-baseline.d.ts.map +1 -0
  203. package/dist/lib/reflow-baseline.js +38 -0
  204. package/dist/lib/reflow-baseline.js.map +1 -0
  205. package/dist/lib/reflow-probe.d.ts +159 -0
  206. package/dist/lib/reflow-probe.d.ts.map +1 -0
  207. package/dist/lib/reflow-probe.js +228 -0
  208. package/dist/lib/reflow-probe.js.map +1 -0
  209. package/dist/lib/seo-service.d.ts +10 -0
  210. package/dist/lib/seo-service.d.ts.map +1 -1
  211. package/dist/lib/seo-service.js.map +1 -1
  212. package/dist/prenta-admin.css +1 -1
  213. package/dist/views/page-editor/CanvasMetaLine.d.ts +19 -0
  214. package/dist/views/page-editor/CanvasMetaLine.d.ts.map +1 -0
  215. package/dist/views/page-editor/CanvasMetaLine.js +23 -0
  216. package/dist/views/page-editor/CanvasMetaLine.js.map +1 -0
  217. package/dist/views/page-editor/EditorCanvas.d.ts +34 -8
  218. package/dist/views/page-editor/EditorCanvas.d.ts.map +1 -1
  219. package/dist/views/page-editor/EditorCanvas.js +68 -34
  220. package/dist/views/page-editor/EditorCanvas.js.map +1 -1
  221. package/dist/views/page-editor/EditorTopBar.d.ts +14 -1
  222. package/dist/views/page-editor/EditorTopBar.d.ts.map +1 -1
  223. package/dist/views/page-editor/EditorTopBar.js +8 -17
  224. package/dist/views/page-editor/EditorTopBar.js.map +1 -1
  225. package/dist/views/page-editor/PageSectionEditor.d.ts.map +1 -1
  226. package/dist/views/page-editor/PageSectionEditor.js +275 -53
  227. package/dist/views/page-editor/PageSectionEditor.js.map +1 -1
  228. package/dist/views/page-editor/PageTab.d.ts +36 -0
  229. package/dist/views/page-editor/PageTab.d.ts.map +1 -0
  230. package/dist/views/page-editor/PageTab.js +65 -0
  231. package/dist/views/page-editor/PageTab.js.map +1 -0
  232. package/dist/views/page-editor/SectionInspector.d.ts.map +1 -1
  233. package/dist/views/page-editor/SectionInspector.js +63 -19
  234. package/dist/views/page-editor/SectionInspector.js.map +1 -1
  235. package/dist/views/page-editor/SectionsPanel.d.ts +30 -5
  236. package/dist/views/page-editor/SectionsPanel.d.ts.map +1 -1
  237. package/dist/views/page-editor/SectionsPanel.js +38 -7
  238. package/dist/views/page-editor/SectionsPanel.js.map +1 -1
  239. package/dist/views/page-editor/SitePreviewFrame.d.ts.map +1 -1
  240. package/dist/views/page-editor/SitePreviewFrame.js +3 -0
  241. package/dist/views/page-editor/SitePreviewFrame.js.map +1 -1
  242. package/dist/views/page-editor/reflow-context.d.ts +28 -0
  243. package/dist/views/page-editor/reflow-context.d.ts.map +1 -0
  244. package/dist/views/page-editor/reflow-context.js +14 -0
  245. package/dist/views/page-editor/reflow-context.js.map +1 -0
  246. package/dist/views/page-editor/sections/ByTheNumbersSection.d.ts.map +1 -1
  247. package/dist/views/page-editor/sections/ByTheNumbersSection.js +1 -1
  248. package/dist/views/page-editor/sections/ByTheNumbersSection.js.map +1 -1
  249. package/dist/views/page-editor/sections/CtaBannerSection.d.ts.map +1 -1
  250. package/dist/views/page-editor/sections/CtaBannerSection.js +1 -1
  251. package/dist/views/page-editor/sections/CtaBannerSection.js.map +1 -1
  252. package/dist/views/page-editor/sections/FeatureHighlightSection.d.ts.map +1 -1
  253. package/dist/views/page-editor/sections/FeatureHighlightSection.js +2 -2
  254. package/dist/views/page-editor/sections/FeatureHighlightSection.js.map +1 -1
  255. package/dist/views/page-editor/sections/GallerySection.d.ts.map +1 -1
  256. package/dist/views/page-editor/sections/GallerySection.js +7 -3
  257. package/dist/views/page-editor/sections/GallerySection.js.map +1 -1
  258. package/dist/views/page-editor/sections/HeroBannerSection.js +1 -1
  259. package/dist/views/page-editor/sections/HeroBannerSection.js.map +1 -1
  260. package/dist/views/page-editor/sections/MissionSection.d.ts.map +1 -1
  261. package/dist/views/page-editor/sections/MissionSection.js +1 -1
  262. package/dist/views/page-editor/sections/MissionSection.js.map +1 -1
  263. package/dist/views/page-editor/sections/PullQuoteSection.d.ts.map +1 -1
  264. package/dist/views/page-editor/sections/PullQuoteSection.js +1 -1
  265. package/dist/views/page-editor/sections/PullQuoteSection.js.map +1 -1
  266. package/dist/views/page-editor/sections/RelatedPostsSection.d.ts.map +1 -1
  267. package/dist/views/page-editor/sections/RelatedPostsSection.js +1 -1
  268. package/dist/views/page-editor/sections/RelatedPostsSection.js.map +1 -1
  269. package/dist/views/page-editor/sections/SectionRenderer.d.ts.map +1 -1
  270. package/dist/views/page-editor/sections/SectionRenderer.js +49 -24
  271. package/dist/views/page-editor/sections/SectionRenderer.js.map +1 -1
  272. package/dist/views/page-editor/sections/admin-renderers.d.ts +47 -15
  273. package/dist/views/page-editor/sections/admin-renderers.d.ts.map +1 -1
  274. package/dist/views/page-editor/sections/admin-renderers.js +27 -15
  275. package/dist/views/page-editor/sections/admin-renderers.js.map +1 -1
  276. package/dist/views/page-editor/sections/parts.d.ts +0 -1
  277. package/dist/views/page-editor/sections/parts.d.ts.map +1 -1
  278. package/dist/views/page-editor/sections/parts.js +41 -16
  279. package/dist/views/page-editor/sections/parts.js.map +1 -1
  280. package/dist/views/page-editor/viewports.d.ts +7 -14
  281. package/dist/views/page-editor/viewports.d.ts.map +1 -1
  282. package/dist/views/page-editor/viewports.js +7 -12
  283. package/dist/views/page-editor/viewports.js.map +1 -1
  284. package/dist/views/post-editor/PostEditorCanvas.d.ts +2 -1
  285. package/dist/views/post-editor/PostEditorCanvas.d.ts.map +1 -1
  286. package/dist/views/post-editor/PostEditorCanvas.js +5 -2
  287. package/dist/views/post-editor/PostEditorCanvas.js.map +1 -1
  288. package/dist/views/post-editor/PostSectionEditor.d.ts.map +1 -1
  289. package/dist/views/post-editor/PostSectionEditor.js +2 -20
  290. package/dist/views/post-editor/PostSectionEditor.js.map +1 -1
  291. package/dist/views/post-editor/PostTemplateEditor.d.ts.map +1 -1
  292. package/dist/views/post-editor/PostTemplateEditor.js +4 -1
  293. package/dist/views/post-editor/PostTemplateEditor.js.map +1 -1
  294. package/package.json +5 -3
  295. package/src/AdminRoot.tsx +27 -6
  296. package/src/__tests__/components/breakpoint-controls.render.test.tsx +57 -0
  297. package/src/__tests__/components/canvas-edit-tip.render.test.tsx +41 -0
  298. package/src/__tests__/components/editor-rail.render.test.tsx +192 -0
  299. package/src/__tests__/components/media-block.render.test.tsx +126 -0
  300. package/src/__tests__/components/page-audit-panel.render.test.tsx +64 -0
  301. package/src/__tests__/components/reflow-meter.render.test.tsx +144 -0
  302. package/src/__tests__/components/seo-char-count.test.tsx +26 -6
  303. package/src/__tests__/components/serp-preview.render.test.tsx +32 -0
  304. package/src/__tests__/lib/breakpoints.test.ts +84 -0
  305. package/src/__tests__/lib/canvas-edit-chrome.test.ts +56 -0
  306. package/src/__tests__/lib/media-format.test.ts +89 -0
  307. package/src/__tests__/lib/page-audit-input.test.ts +144 -0
  308. package/src/__tests__/lib/page-editor-service.test.ts +2 -0
  309. package/src/__tests__/lib/page-redirect.test.ts +29 -0
  310. package/src/__tests__/lib/rail-width.test.ts +86 -0
  311. package/src/__tests__/lib/reflow-baseline.test.ts +64 -0
  312. package/src/__tests__/lib/reflow-probe.test.ts +95 -0
  313. package/src/__tests__/styles/canvas-container-queries.test.ts +103 -0
  314. package/src/__tests__/styles/packaging.test.ts +38 -0
  315. package/src/__tests__/styles/tokens-drift.test.ts +110 -0
  316. package/src/__tests__/views/admin-renderer-compat.test.ts +48 -0
  317. package/src/__tests__/views/admin-section-renderers.test.tsx +70 -4
  318. package/src/__tests__/views/editor-top-bar.render.test.tsx +1 -1
  319. package/src/__tests__/views/page-tab.render.test.tsx +226 -0
  320. package/src/__tests__/views/section-class-parity.test.ts +98 -0
  321. package/src/__tests__/views/sections-panel-design-owned.render.test.tsx +104 -0
  322. package/src/components/BreakpointPicker.tsx +114 -0
  323. package/src/components/CanvasEditTip.tsx +46 -0
  324. package/src/components/EditorRail.tsx +219 -0
  325. package/src/components/EditorRailTabs.tsx +117 -0
  326. package/src/components/MediaBlock.tsx +223 -0
  327. package/src/components/ReflowMeter.tsx +125 -0
  328. package/src/components/ZoomChip.tsx +39 -0
  329. package/src/components/breakpoint-icons.ts +17 -0
  330. package/src/components/page-tab/AuthorPicker.tsx +240 -0
  331. package/src/components/page-tab/DirectiveSwitch.tsx +86 -0
  332. package/src/components/seo/EditorSeoScoreBadge.tsx +9 -1
  333. package/src/components/seo/PageAuditPanel.tsx +192 -0
  334. package/src/components/seo/SeoCharCount.tsx +55 -24
  335. package/src/components/seo/SeoEditorPane.tsx +213 -31
  336. package/src/components/seo/SerpPreview.tsx +41 -0
  337. package/src/hooks/usePageAudit.ts +98 -0
  338. package/src/hooks/useReflowGuard.ts +96 -0
  339. package/src/lib/breakpoints.ts +107 -0
  340. package/src/lib/canvas-edit-chrome.ts +82 -0
  341. package/src/lib/media-format.ts +99 -0
  342. package/src/lib/page-audit-input.ts +104 -0
  343. package/src/lib/page-editor-service.ts +18 -0
  344. package/src/lib/page-redirect.ts +60 -0
  345. package/src/lib/rail-width.ts +58 -0
  346. package/src/lib/reflow-baseline.ts +70 -0
  347. package/src/lib/reflow-probe.ts +299 -0
  348. package/src/lib/seo-service.ts +10 -0
  349. package/src/styles/prenta-tokens.css +120 -42
  350. package/src/styles/theme.css +130 -0
  351. package/src/views/page-editor/CanvasMetaLine.tsx +50 -0
  352. package/src/views/page-editor/EditorCanvas.tsx +109 -50
  353. package/src/views/page-editor/EditorTopBar.tsx +35 -43
  354. package/src/views/page-editor/PageSectionEditor.tsx +419 -148
  355. package/src/views/page-editor/PageTab.tsx +433 -0
  356. package/src/views/page-editor/SectionInspector.tsx +82 -41
  357. package/src/views/page-editor/SectionsPanel.tsx +281 -140
  358. package/src/views/page-editor/SitePreviewFrame.tsx +3 -0
  359. package/src/views/page-editor/reflow-context.ts +43 -0
  360. package/src/views/page-editor/sections/ByTheNumbersSection.tsx +3 -2
  361. package/src/views/page-editor/sections/CtaBannerSection.tsx +3 -1
  362. package/src/views/page-editor/sections/FeatureHighlightSection.tsx +3 -2
  363. package/src/views/page-editor/sections/GallerySection.tsx +7 -3
  364. package/src/views/page-editor/sections/HeroBannerSection.tsx +2 -2
  365. package/src/views/page-editor/sections/MissionSection.tsx +4 -2
  366. package/src/views/page-editor/sections/PullQuoteSection.tsx +3 -1
  367. package/src/views/page-editor/sections/RelatedPostsSection.tsx +2 -1
  368. package/src/views/page-editor/sections/SectionRenderer.tsx +69 -29
  369. package/src/views/page-editor/sections/admin-renderers.tsx +54 -15
  370. package/src/views/page-editor/sections/parts.tsx +46 -18
  371. package/src/views/page-editor/viewports.ts +13 -26
  372. package/src/views/post-editor/PostEditorCanvas.tsx +18 -11
  373. package/src/views/post-editor/PostSectionEditor.tsx +1 -56
  374. package/src/views/post-editor/PostTemplateEditor.tsx +3 -0
@@ -0,0 +1,144 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it, vi } from 'vitest'
3
+ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
4
+ import { ReflowMeter } from '../../components/ReflowMeter.js'
5
+ import type { ReflowGuardResult, ReflowRow } from '../../hooks/useReflowGuard.js'
6
+ import { BREAKPOINTS } from '../../lib/breakpoints.js'
7
+
8
+ function rows(overrides: Partial<Record<string, Partial<ReflowRow>>> = {}): ReflowRow[] {
9
+ return BREAKPOINTS.map((bp) => {
10
+ const o = overrides[bp.id] ?? {}
11
+ const lines = o.lines ?? 2
12
+ const was = o.was ?? 2
13
+ return { id: bp.id, lines, was, diff: lines - was, state: o.state ?? 'same' }
14
+ })
15
+ }
16
+
17
+ function guard(partial: Partial<ReflowGuardResult> = {}): ReflowGuardResult {
18
+ const r = partial.rows ?? rows()
19
+ return {
20
+ rows: r,
21
+ grown: partial.grown ?? r.filter((x) => x.diff > 0),
22
+ worst: partial.worst ?? null,
23
+ delta: partial.delta ?? 0,
24
+ ...partial,
25
+ }
26
+ }
27
+
28
+ describe('ReflowMeter', () => {
29
+ it('reports the line count for the breakpoint being viewed, not the worst one', () => {
30
+ // "lines here" has to mean the canvas the editor is actually looking at.
31
+ // Showing the worst breakpoint's count under a canvas rendering a different
32
+ // one makes the editor mistrust both numbers.
33
+ const { container } = render(
34
+ <ReflowMeter
35
+ guard={guard({ rows: rows({ mobile: { lines: 5 }, laptop: { lines: 2 } }) })}
36
+ length={64}
37
+ device="laptop"
38
+ onProof={() => {}}
39
+ />,
40
+ )
41
+ const readout = container.querySelector('span')!.textContent!.replace(/\s+/g, ' ')
42
+ expect(readout).toContain('2 lines here')
43
+ expect(readout).not.toContain('5 lines here')
44
+ cleanup()
45
+ })
46
+
47
+ it('states the character delta with its direction', () => {
48
+ render(
49
+ <ReflowMeter guard={guard({ delta: 12 })} length={80} device="wide" onProof={() => {}} />,
50
+ )
51
+ expect(screen.getByText('(+12)')).toBeTruthy()
52
+ cleanup()
53
+
54
+ render(
55
+ <ReflowMeter guard={guard({ delta: -9 })} length={40} device="wide" onProof={() => {}} />,
56
+ )
57
+ expect(screen.getByText('(-9)')).toBeTruthy()
58
+ cleanup()
59
+ })
60
+
61
+ it('names the line count and its published value on every dot', () => {
62
+ // Colour alone must not carry the finding. Each dot's accessible name says
63
+ // what it measured and what it was, so the whole readout is reachable
64
+ // without seeing the tones.
65
+ render(
66
+ <ReflowMeter
67
+ guard={guard({
68
+ rows: rows({ mobile: { lines: 4, was: 2, state: 'grew-badly' } }),
69
+ worst: 'mobile',
70
+ })}
71
+ length={64}
72
+ device="wide"
73
+ onProof={() => {}}
74
+ />,
75
+ )
76
+ const dot = screen.getByRole('button', { name: /^Mobile: 4 lines/ })
77
+ expect(dot.textContent).toContain('was 2 when published')
78
+ // The dot and the alert's proof button are distinguishable by name — two
79
+ // controls reading "Proof on Mobile" in one region are the same control to
80
+ // anyone navigating by voice or screen reader.
81
+ expect(screen.getByRole('button', { name: 'Proof on Mobile' })).not.toBe(dot)
82
+ cleanup()
83
+ })
84
+
85
+ it('takes the canvas to the breakpoint a dot names', () => {
86
+ const onProof = vi.fn()
87
+ render(
88
+ <ReflowMeter
89
+ guard={guard({ rows: rows({ mobile: { lines: 4, was: 2, state: 'grew-badly' } }) })}
90
+ length={64}
91
+ device="wide"
92
+ onProof={onProof}
93
+ />,
94
+ )
95
+ fireEvent.click(screen.getByRole('button', { name: /^Mobile: 4 lines/ }))
96
+ expect(onProof).toHaveBeenCalledWith('mobile')
97
+ cleanup()
98
+ })
99
+
100
+ it('stays silent when nothing grew', () => {
101
+ // The alert is the interrupting element. It must appear only when wrapping
102
+ // actually changed, or editors learn to scroll past it.
103
+ render(<ReflowMeter guard={guard()} length={40} device="wide" onProof={() => {}} />)
104
+ expect(screen.queryByText(/Wrapping changed/)).toBeNull()
105
+ cleanup()
106
+ })
107
+
108
+ it('says "every breakpoint" rather than listing all six', () => {
109
+ const all = rows(
110
+ Object.fromEntries(BREAKPOINTS.map((b) => [b.id, { lines: 3, was: 2, state: 'grew' }])),
111
+ )
112
+ render(
113
+ <ReflowMeter
114
+ guard={guard({ rows: all, worst: 'mobile' })}
115
+ length={90}
116
+ device="wide"
117
+ onProof={() => {}}
118
+ />,
119
+ )
120
+ expect(screen.getByText('every breakpoint')).toBeTruthy()
121
+ cleanup()
122
+ })
123
+
124
+ it('names the breakpoints while the list is short enough to read', () => {
125
+ render(
126
+ <ReflowMeter
127
+ guard={guard({
128
+ rows: rows({
129
+ mobile: { lines: 4, was: 2, state: 'grew-badly' },
130
+ tabletP: { lines: 3, was: 2, state: 'grew' },
131
+ }),
132
+ worst: 'mobile',
133
+ })}
134
+ length={90}
135
+ device="wide"
136
+ onProof={() => {}}
137
+ />,
138
+ )
139
+ expect(screen.getByText('Tablet Portrait, Mobile')).toBeTruthy()
140
+ // The proof button names the one worth looking at, not the list.
141
+ expect(screen.getByRole('button', { name: 'Proof on Mobile' })).toBeTruthy()
142
+ cleanup()
143
+ })
144
+ })
@@ -6,22 +6,42 @@ import { SeoCharCount } from '../../components/seo/SeoCharCount.js'
6
6
  describe('SeoCharCount', () => {
7
7
  it('shows a neutral empty state', () => {
8
8
  render(<SeoCharCount value="" min={30} max={60} />)
9
- expect(screen.getByText('0/60')).toBeTruthy()
9
+ expect(screen.getByText(/0 \/ 60 characters/)).toBeTruthy()
10
10
  })
11
11
 
12
12
  it('marks a value within the ideal range as good', () => {
13
13
  render(<SeoCharCount value={'x'.repeat(40)} min={30} max={60} />)
14
- expect(screen.getByText(/40\/60 · good/)).toBeTruthy()
14
+ expect(screen.getByText(/40 \/ 60 characters · good/)).toBeTruthy()
15
15
  })
16
16
 
17
- it('flags a value over the max as too long (destructive)', () => {
17
+ it('says the tail will be truncated rather than just "too long"', () => {
18
+ // Over and under are different problems. "Will be truncated" tells the
19
+ // editor the words are written but nobody will read them.
18
20
  const { container } = render(<SeoCharCount value={'x'.repeat(70)} min={30} max={60} />)
19
- expect(screen.getByText(/70\/60 · too long/)).toBeTruthy()
21
+ expect(screen.getByText(/70 \/ 60 characters · will be truncated/)).toBeTruthy()
20
22
  expect(container.querySelector('.text-destructive')).toBeTruthy()
21
23
  })
22
24
 
23
- it('hints to aim for the range when below min', () => {
25
+ it('says too short rather than restating the target range', () => {
26
+ // "Aim for 30–60" describes the goal without saying which way to edit.
24
27
  render(<SeoCharCount value={'x'.repeat(10)} min={30} max={60} />)
25
- expect(screen.getByText(/aim for 30–60/)).toBeTruthy()
28
+ expect(screen.getByText(/10 \/ 60 characters · too short/)).toBeTruthy()
29
+ })
30
+
31
+ it('caps the meter at full instead of overflowing', () => {
32
+ // A bar that kept growing past the maximum would imply more is better.
33
+ const { container } = render(<SeoCharCount value={'x'.repeat(200)} min={30} max={60} />)
34
+ const fill = container.querySelector('span[aria-hidden] > span') as HTMLElement | null
35
+ expect(fill?.style.width).toBe('100%')
36
+ })
37
+
38
+ it('has no short or good state for a cap-only field', () => {
39
+ // Without a minimum there is no target to hit, only a ceiling not to cross.
40
+ // The count is split across JSX text nodes, so match on the rendered text.
41
+ const { container } = render(<SeoCharCount value={'x'.repeat(5)} max={200} />)
42
+ const text = container.textContent ?? ''
43
+ expect(text).toContain('5 / 200 characters')
44
+ expect(text).not.toContain('too short')
45
+ expect(text).not.toContain('good')
26
46
  })
27
47
  })
@@ -0,0 +1,32 @@
1
+ // @vitest-environment happy-dom
2
+ import { describe, expect, it } from 'vitest'
3
+ import { render, screen } from '@testing-library/react'
4
+ import { SerpPreview } from '../../components/seo/SerpPreview.js'
5
+
6
+ describe('SerpPreview', () => {
7
+ it('shows url, title and description as a searcher sees them', () => {
8
+ render(
9
+ <SerpPreview
10
+ title="Headless CMS for modern teams"
11
+ url="https://example.com/about"
12
+ description="Prenta gives developers an API-first architecture."
13
+ />,
14
+ )
15
+ expect(screen.getByText('https://example.com/about')).toBeTruthy()
16
+ expect(screen.getByText('Headless CMS for modern teams')).toBeTruthy()
17
+ })
18
+
19
+ it('renders the title in the search-result blue, not the brand accent', () => {
20
+ // The card's job is to look like the thing being previewed. Painted in the
21
+ // brand colour it previews the admin instead.
22
+ render(<SerpPreview title="T" url="u" description="d" />)
23
+ // Assert on the class list rather than a CSS selector — escaping `[`, `#`
24
+ // and `]` in a selector is brittle and fails for the wrong reason.
25
+ expect(screen.getByText('T').className).toContain('#1a0dab')
26
+ })
27
+
28
+ it('says what a missing description means rather than rendering blank', () => {
29
+ render(<SerpPreview title="T" url="u" description="" />)
30
+ expect(screen.getByText(/Search engines will improvise one/i)).toBeTruthy()
31
+ })
32
+ })
@@ -0,0 +1,84 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ BREAKPOINTS,
4
+ DEFAULT_BREAKPOINT,
5
+ breakpointsInGroup,
6
+ coerceBreakpointId,
7
+ fitScale,
8
+ getBreakpoint,
9
+ } from '../../lib/breakpoints.js'
10
+
11
+ describe('breakpoint presets', () => {
12
+ it('renders 4K at the CSS width such displays report, not the physical one', () => {
13
+ // 3840 is the pixel count; 2560 is what a stylesheet sees once DPR is
14
+ // applied. Rendering at 3840 would put the canvas in a container-query
15
+ // bucket no real visitor is ever in.
16
+ expect(getBreakpoint('4k').width).toBe(2560)
17
+ })
18
+
19
+ it('has exactly one preset above the 2000px container-query step', () => {
20
+ // The canvas's `@container (min-width: 2000px)` branch scales type and
21
+ // padding up. If no preset exercised it, the branch would be untestable
22
+ // from the editor.
23
+ const above = BREAKPOINTS.filter((b) => b.width >= 2000)
24
+ expect(above.map((b) => b.id)).toEqual(['4k'])
25
+ })
26
+
27
+ it('covers every container-query step with a distinct width', () => {
28
+ const widths = BREAKPOINTS.map((b) => b.width)
29
+ expect(widths).toEqual([2560, 1920, 1440, 1024, 768, 390])
30
+ expect(new Set(widths).size).toBe(widths.length)
31
+ })
32
+
33
+ it('groups presets in menu order', () => {
34
+ expect(breakpointsInGroup('DESKTOP').map((b) => b.id)).toEqual(['4k', 'wide', 'laptop'])
35
+ expect(breakpointsInGroup('TABLET').map((b) => b.id)).toEqual(['tabletL', 'tabletP'])
36
+ expect(breakpointsInGroup('MOBILE').map((b) => b.id)).toEqual(['mobile'])
37
+ })
38
+
39
+ it('falls back to the default for an unknown id', () => {
40
+ expect(getBreakpoint('nope' as never).id).toBe(DEFAULT_BREAKPOINT)
41
+ })
42
+ })
43
+
44
+ describe('coerceBreakpointId', () => {
45
+ it('maps retired v0.92 ids onto their equivalent width', () => {
46
+ // `1080p` was 1440px and `tablet` was 768px. Mapping by width keeps an
47
+ // editor that was open across the upgrade on the same preview.
48
+ expect(getBreakpoint(coerceBreakpointId('1080p')).width).toBe(1440)
49
+ expect(getBreakpoint(coerceBreakpointId('tablet')).width).toBe(768)
50
+ })
51
+
52
+ it('passes through current ids and defaults anything else', () => {
53
+ expect(coerceBreakpointId('tabletL')).toBe('tabletL')
54
+ expect(coerceBreakpointId('garbage')).toBe(DEFAULT_BREAKPOINT)
55
+ expect(coerceBreakpointId(undefined)).toBe(DEFAULT_BREAKPOINT)
56
+ expect(coerceBreakpointId(1920)).toBe(DEFAULT_BREAKPOINT)
57
+ })
58
+ })
59
+
60
+ describe('fitScale', () => {
61
+ it('shrinks a wide breakpoint to the available width', () => {
62
+ expect(fitScale(800, 1600)).toBe(0.5)
63
+ })
64
+
65
+ it('never scales above 1', () => {
66
+ // A 390px mobile canvas stretched across a 1200px column would render text
67
+ // at 3x the size a visitor sees — worse than useless as a proof.
68
+ expect(fitScale(1200, 390)).toBe(1)
69
+ })
70
+
71
+ it('returns 1 at 100% zoom regardless of available width', () => {
72
+ expect(fitScale(400, 2560, 1)).toBe(1)
73
+ })
74
+
75
+ it('returns 1 rather than 0 or NaN before the first measurement', () => {
76
+ // The sentinel reads 0 on the very first paint. Propagating that would
77
+ // produce `scale(0)` and a canvas that measures as zero-height, which then
78
+ // feeds back into the next measurement.
79
+ expect(fitScale(0, 1920)).toBe(1)
80
+ expect(fitScale(Number.NaN, 1920)).toBe(1)
81
+ expect(fitScale(-10, 1920)).toBe(1)
82
+ expect(fitScale(800, 0)).toBe(1)
83
+ })
84
+ })
@@ -0,0 +1,56 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, describe, expect, it, vi } from 'vitest'
3
+ import {
4
+ CANVAS_EDIT_TIP_STORAGE_KEY,
5
+ dismissCanvasEditTip,
6
+ injectPreviewEditChrome,
7
+ isCanvasEditTipDismissed,
8
+ PREVIEW_EDITABLE_FIELD_CHROME_CSS,
9
+ } from '../../lib/canvas-edit-chrome.js'
10
+
11
+ // happy-dom localStorage is non-functional in this Node setup — stub a Map.
12
+ const storage = new Map<string, string>()
13
+ vi.stubGlobal('localStorage', {
14
+ getItem: (key: string) => storage.get(key) ?? null,
15
+ setItem: (key: string, value: string) => void storage.set(key, String(value)),
16
+ removeItem: (key: string) => void storage.delete(key),
17
+ clear: () => storage.clear(),
18
+ })
19
+
20
+ describe('canvas-edit-chrome', () => {
21
+ afterEach(() => {
22
+ storage.clear()
23
+ })
24
+
25
+ it('tracks tip dismiss in localStorage', () => {
26
+ expect(isCanvasEditTipDismissed()).toBe(false)
27
+ dismissCanvasEditTip()
28
+ expect(isCanvasEditTipDismissed()).toBe(true)
29
+ expect(localStorage.getItem(CANVAS_EDIT_TIP_STORAGE_KEY)).toBe('1')
30
+ })
31
+
32
+ it('injects editable field chrome into a same-origin iframe document', () => {
33
+ const iframe = document.createElement('iframe')
34
+ document.body.appendChild(iframe)
35
+ const doc = iframe.contentDocument
36
+ expect(doc).toBeTruthy()
37
+ if (!doc) return
38
+
39
+ expect(injectPreviewEditChrome(iframe)).toBe(true)
40
+ const style = doc.head.querySelector('style[data-prenta-edit-chrome]')
41
+ expect(style?.textContent).toContain('[data-cms-field]')
42
+ expect(style?.textContent).toContain('data-cms-field-kind')
43
+ expect(PREVIEW_EDITABLE_FIELD_CHROME_CSS.length).toBeGreaterThan(0)
44
+ expect(doc.documentElement.getAttribute('data-prenta-preview-editing')).toBe('true')
45
+
46
+ // Refresh is idempotent — one style tag.
47
+ expect(injectPreviewEditChrome(iframe)).toBe(true)
48
+ expect(doc.head.querySelectorAll('style[data-prenta-edit-chrome]')).toHaveLength(1)
49
+
50
+ iframe.remove()
51
+ })
52
+
53
+ it('returns false when iframe has no contentDocument', () => {
54
+ expect(injectPreviewEditChrome(null)).toBe(false)
55
+ })
56
+ })
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ assetWash,
4
+ displayName,
5
+ formatBadge,
6
+ ratioLabel,
7
+ CROP_RATIO,
8
+ } from '../../lib/media-format.js'
9
+
10
+ describe('ratioLabel', () => {
11
+ it('names the ratios an editor recognises', () => {
12
+ expect(ratioLabel(1920, 1080)).toBe('16:9')
13
+ expect(ratioLabel(2400, 1600)).toBe('3:2')
14
+ expect(ratioLabel(1200, 630)).toBe('1.91:1')
15
+ expect(ratioLabel(800, 800)).toBe('1:1')
16
+ expect(ratioLabel(1080, 1350)).toBe('4:5')
17
+ expect(ratioLabel(1080, 1920)).toBe('9:16')
18
+ })
19
+
20
+ it('tolerates an off-by-a-pixel scan', () => {
21
+ // 2401×1600 is 1.5006, not 1.5. An editor should still read "3:2" rather
22
+ // than "1.50:1" — the tolerance is what makes the label useful on real
23
+ // assets instead of only on synthetic ones.
24
+ expect(ratioLabel(2401, 1600)).toBe('3:2')
25
+ })
26
+
27
+ it('reports the number when nothing named is close', () => {
28
+ expect(ratioLabel(1000, 300)).toBe('3.33:1')
29
+ })
30
+
31
+ it('says nothing when dimensions are unknown', () => {
32
+ // The library does not always carry width/height. Inventing "1.00:1" would
33
+ // be a confident wrong answer about the one thing this label exists for.
34
+ expect(ratioLabel(undefined, undefined)).toBeNull()
35
+ expect(ratioLabel(0, 100)).toBeNull()
36
+ expect(ratioLabel(100, 0)).toBeNull()
37
+ })
38
+ })
39
+
40
+ describe('formatBadge', () => {
41
+ it('uses the file extension', () => {
42
+ expect(formatBadge('team-offsite.jpg')).toBe('JPG')
43
+ expect(formatBadge('/uploads/dashboard-hero.PNG')).toBe('PNG')
44
+ })
45
+
46
+ it('ignores a query string', () => {
47
+ expect(formatBadge('https://cdn.example.com/a/b.webp?token=abc123&x=1')).toBe('WEBP')
48
+ })
49
+
50
+ it('falls back to the MIME subtype when there is no extension', () => {
51
+ // Signed CDN links and upload routes often have no extension at all.
52
+ expect(formatBadge('https://cdn.example.com/asset/9f3a2', 'image/avif')).toBe('AVIF')
53
+ expect(formatBadge('https://cdn.example.com/asset/9f3a2', 'image/svg+xml')).toBe('SVG')
54
+ })
55
+
56
+ it('never renders an empty chip', () => {
57
+ expect(formatBadge('')).toBe('FILE')
58
+ expect(formatBadge('no-extension-here')).toBe('FILE')
59
+ })
60
+ })
61
+
62
+ describe('displayName', () => {
63
+ it('shows the filename, not the path or the token', () => {
64
+ expect(displayName('https://cdn.example.com/media/team-offsite-2026.jpg?sig=xyz')).toBe(
65
+ 'team-offsite-2026.jpg',
66
+ )
67
+ })
68
+
69
+ it('decodes an escaped name', () => {
70
+ expect(displayName('/media/team%20offsite.jpg')).toBe('team offsite.jpg')
71
+ })
72
+ })
73
+
74
+ describe('assetWash', () => {
75
+ it('gives the same asset the same colour every time', () => {
76
+ // Recognising a thumbnail depends on it not changing between renders.
77
+ expect(assetWash('hero.jpg')).toBe(assetWash('hero.jpg'))
78
+ expect(assetWash('hero.jpg')).not.toBe(assetWash('other.jpg'))
79
+ })
80
+ })
81
+
82
+ describe('CROP_RATIO', () => {
83
+ it('describes the crop the template renders, not the asset', () => {
84
+ // Showing the asset's own ratio would tell an editor their portrait photo
85
+ // is fine, then crop its subject out on the page.
86
+ expect(CROP_RATIO.section).toBe('16 / 10')
87
+ expect(CROP_RATIO.social).toBe('1.91 / 1')
88
+ })
89
+ })
@@ -0,0 +1,144 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { auditPage } from '@prenta/core/seo/page-audit'
3
+ import type { PageSection } from '@prenta/core/page-sections'
4
+ import { buildPageAuditInput, metaFromRecord } from '../../lib/page-audit-input.js'
5
+ import type { SeoContentRecord } from '../../lib/seo-service.js'
6
+
7
+ /**
8
+ * Two callers build the audit — the page editor from the saved record (so the
9
+ * header chip has a number from first paint) and the SEO pane from live form
10
+ * state (so it moves as you type). They must produce the same score for the
11
+ * same data, or the editor is back to showing numbers that disagree.
12
+ */
13
+
14
+ const sections = [
15
+ {
16
+ id: 's1',
17
+ sectionType: 'hero',
18
+ name: 'Hero',
19
+ visible: true,
20
+ content: {
21
+ heading: 'Headless CMS for modern teams',
22
+ body: 'Prenta gives developers an API-first architecture and editors a design-owned authoring surface, so content ships without layout drift.',
23
+ imageAlt: 'A described image',
24
+ },
25
+ settings: {},
26
+ },
27
+ ] as unknown as PageSection[]
28
+
29
+ const record = {
30
+ title: 'About Us',
31
+ siteName: 'Prenta',
32
+ titleTemplate: '%page% | %site%',
33
+ url: '/about',
34
+ metaTitlePage: 'Headless CMS for teams',
35
+ metaDescription:
36
+ 'Prenta gives developers an API-first architecture and editors a design-owned authoring surface for shipping content at scale.',
37
+ focusKeyword: 'headless cms',
38
+ structuredDataType: 'WebPage',
39
+ noindex: false,
40
+ llmsInclude: 'AUTO',
41
+ ogImage: null,
42
+ } as unknown as SeoContentRecord
43
+
44
+ describe('page audit input', () => {
45
+ it('produces the same score from a record as from equivalent form state', () => {
46
+ // The page editor's path.
47
+ const fromRecord = auditPage(
48
+ buildPageAuditInput({ sections, meta: metaFromRecord(record), slug: 'about', record }),
49
+ )
50
+
51
+ // The SEO pane's path, with the form holding exactly what was saved.
52
+ const fromForm = auditPage(
53
+ buildPageAuditInput({
54
+ sections,
55
+ meta: {
56
+ metaTitle: 'Headless CMS for teams',
57
+ metaDescription: record.metaDescription!,
58
+ focusKeyword: 'headless cms',
59
+ structuredDataType: 'WebPage',
60
+ noindex: false,
61
+ nosnippet: false,
62
+ llmsInclude: 'AUTO',
63
+ hasOgImage: false,
64
+ },
65
+ slug: 'about',
66
+ record,
67
+ }),
68
+ )
69
+
70
+ expect(fromForm.seo.score).toBe(fromRecord.seo.score)
71
+ expect(fromForm.geo.score).toBe(fromRecord.geo.score)
72
+ })
73
+
74
+ it('measures the rendered title, not the raw field', () => {
75
+ // The site template is applied before the length check, so the advice
76
+ // matches what a searcher sees. Checking the raw field would call a title
77
+ // "too short" that renders well over the limit once the template lands.
78
+ const input = buildPageAuditInput({
79
+ sections,
80
+ meta: { ...metaFromRecord(record), metaTitle: 'Short' },
81
+ slug: 'about',
82
+ record,
83
+ })
84
+ expect(input.seo.title).toContain('Prenta')
85
+ expect(input.seo.title.length).toBeGreaterThan('Short'.length)
86
+ })
87
+
88
+ it('treats a set OG image as unknown, and an absent one as failing', () => {
89
+ // The record stores a URL with no dimensions, and the check needs width
90
+ // and ratio — so "an image exists" is not the same as "it passes".
91
+ const withImage = buildPageAuditInput({
92
+ sections,
93
+ meta: { ...metaFromRecord(record), hasOgImage: true },
94
+ slug: 'about',
95
+ record,
96
+ })
97
+ expect(withImage.seo.ogImage).toBeUndefined()
98
+
99
+ const without = buildPageAuditInput({
100
+ sections,
101
+ meta: { ...metaFromRecord(record), hasOgImage: false },
102
+ slug: 'about',
103
+ record,
104
+ })
105
+ expect(without.seo.ogImage).toBeNull()
106
+ })
107
+
108
+ it('distinguishes a removed share image from an unset one', () => {
109
+ // The share card lets an editor clear the image. An empty string is a
110
+ // deliberate removal and must fail the check; `undefined` means "an image
111
+ // exists but its dimensions are unknown here" and must not be scored.
112
+ const removed = buildPageAuditInput({
113
+ sections,
114
+ meta: { ...metaFromRecord(record), hasOgImage: false },
115
+ slug: 'about',
116
+ record,
117
+ })
118
+ expect(removed.seo.ogImage).toBeNull()
119
+ expect(auditPage(removed).seo.checks.find((c) => c.id === 'social-image')?.outcome).toBe('fail')
120
+
121
+ const chosen = buildPageAuditInput({
122
+ sections,
123
+ meta: { ...metaFromRecord(record), hasOgImage: true },
124
+ slug: 'about',
125
+ record,
126
+ })
127
+ expect(auditPage(chosen).seo.checks.find((c) => c.id === 'social-image')?.outcome).toBe(
128
+ 'not-applicable',
129
+ )
130
+ })
131
+
132
+ it('reflects an unsaved noindex immediately', () => {
133
+ // Live form state must be able to gate the score before a save round-trip.
134
+ const audit = auditPage(
135
+ buildPageAuditInput({
136
+ sections,
137
+ meta: { ...metaFromRecord(record), noindex: true },
138
+ slug: 'about',
139
+ record,
140
+ }),
141
+ )
142
+ expect(audit.seo.score).toBeNull()
143
+ })
144
+ })
@@ -169,6 +169,8 @@ describe('section type registry', () => {
169
169
  'text',
170
170
  'feature',
171
171
  'stats',
172
+ 'badges',
173
+ 'faq',
172
174
  'cta',
173
175
  'form',
174
176
  'article-body',
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { pendingSlugChange } from '../../lib/page-redirect.js'
3
+
4
+ describe('pendingSlugChange', () => {
5
+ it('reports the move when a live page changes path', () => {
6
+ expect(pendingSlugChange('/about', '/about-us')).toEqual({ from: '/about', to: '/about-us' })
7
+ })
8
+
9
+ it('stays quiet for a page that has never been live', () => {
10
+ // Nothing out there points at the old path, so there is nothing to redirect
11
+ // — and a redirect for a URL that was never public is dead weight in a
12
+ // table consulted on every request.
13
+ expect(pendingSlugChange(null, '/about-us')).toBeNull()
14
+ })
15
+
16
+ it('stays quiet when the path has not moved', () => {
17
+ expect(pendingSlugChange('/about', '/about')).toBeNull()
18
+ // Trailing whitespace from an input is not a move.
19
+ expect(pendingSlugChange('/about', ' /about ')).toBeNull()
20
+ })
21
+
22
+ it('refuses a redirect with an empty side', () => {
23
+ // A rule from or to nothing is not a redirect; posting one would put a
24
+ // broken entry in the table rather than fail loudly.
25
+ expect(pendingSlugChange('', '/about')).toBeNull()
26
+ expect(pendingSlugChange('/about', '')).toBeNull()
27
+ expect(pendingSlugChange('/about', ' ')).toBeNull()
28
+ })
29
+ })