@heyramzi/ui 1.54.2 → 2.0.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 (361) hide show
  1. package/COMPONENTS.md +3 -2
  2. package/README.md +34 -9
  3. package/package.json +198 -6
  4. package/src/ai/generation-progress/GenerationProgress.svelte +12 -12
  5. package/src/ambient.d.ts +8 -0
  6. package/src/brand/brand.test.ts +67 -0
  7. package/src/brand/index.ts +137 -0
  8. package/src/canvas/README.md +236 -0
  9. package/src/canvas/card/card-frame.svelte +61 -0
  10. package/src/canvas/card/card.css +39 -0
  11. package/src/canvas/card/resize-handles.svelte +119 -0
  12. package/src/canvas/card/resize.ts +33 -0
  13. package/src/canvas/chrome/chrome-zoom.svelte +120 -0
  14. package/src/canvas/chrome/chrome.css +231 -0
  15. package/src/canvas/chrome/lock-badge.svelte +18 -0
  16. package/src/canvas/chrome/side-handles.svelte +30 -0
  17. package/src/canvas/chrome/size-chip.svelte +45 -0
  18. package/src/canvas/chrome/snap-guides.svelte +69 -0
  19. package/src/canvas/components/icons/ToolIcon.svelte +30 -0
  20. package/src/canvas/components/shared/svg/PreviewSvg.svelte +67 -0
  21. package/src/canvas/components/shared/svg/ShapeOutline.svelte +76 -0
  22. package/src/canvas/components/shared/svg/StrokeNode.svelte +69 -0
  23. package/src/canvas/components/shared/svg/TextRule.svelte +37 -0
  24. package/src/canvas/edges/bend-handle.svelte +76 -0
  25. package/src/canvas/edges/edge-gradient.svelte +30 -0
  26. package/src/canvas/edges/edge-path.test.ts +58 -0
  27. package/src/canvas/edges/edge-path.ts +293 -0
  28. package/src/canvas/edges/gradient-edge.svelte +43 -0
  29. package/src/canvas/editor/context-menu.test.ts +65 -0
  30. package/src/canvas/editor/context-menu.ts +48 -0
  31. package/src/canvas/editor/dispatch.svelte.test.ts +243 -0
  32. package/src/canvas/editor/dispatch.svelte.ts +205 -0
  33. package/src/canvas/editor/editor-context.ts +62 -0
  34. package/src/canvas/editor/editor-mode.ts +67 -0
  35. package/src/canvas/editor/frame.ts +98 -0
  36. package/src/canvas/editor/hand-context.ts +15 -0
  37. package/src/canvas/editor/history-core.test.ts +239 -0
  38. package/src/canvas/editor/history-core.ts +247 -0
  39. package/src/canvas/editor/keymap.test.ts +161 -0
  40. package/src/canvas/editor/keymap.ts +151 -0
  41. package/src/canvas/editor/layout-actions.test.ts +156 -0
  42. package/src/canvas/editor/layout-actions.ts +219 -0
  43. package/src/canvas/editor/lock.test.ts +68 -0
  44. package/src/canvas/editor/lock.ts +87 -0
  45. package/src/canvas/editor/rebase.test.ts +208 -0
  46. package/src/canvas/editor/rebase.ts +272 -0
  47. package/src/canvas/editor/style.ts +184 -0
  48. package/src/canvas/editor/text-field-context.ts +41 -0
  49. package/src/canvas/editor/z-order.test.ts +75 -0
  50. package/src/canvas/editor/z-order.ts +87 -0
  51. package/src/canvas/headless/alignment.test.ts +59 -0
  52. package/src/canvas/headless/alignment.ts +23 -0
  53. package/src/canvas/headless/break-apart-find.ts +418 -0
  54. package/src/canvas/headless/break-apart-pieces.ts +104 -0
  55. package/src/canvas/headless/break-apart-plan.ts +439 -0
  56. package/src/canvas/headless/break-apart-style.ts +106 -0
  57. package/src/canvas/headless/break-apart.ts +244 -0
  58. package/src/canvas/headless/canvas-cleanup.test.ts +181 -0
  59. package/src/canvas/headless/canvas-cleanup.ts +102 -0
  60. package/src/canvas/headless/edge-anchors.test.ts +70 -0
  61. package/src/canvas/headless/edge-anchors.ts +143 -0
  62. package/src/canvas/headless/edge-memo.test.ts +58 -0
  63. package/src/canvas/headless/edge-memo.ts +32 -0
  64. package/src/canvas/headless/edge-serialization.test.ts +55 -0
  65. package/src/canvas/headless/edge-serialization.ts +32 -0
  66. package/src/canvas/headless/figure-geometry.ts +293 -0
  67. package/src/canvas/headless/fit-viewport.test.ts +85 -0
  68. package/src/canvas/headless/fit-viewport.ts +137 -0
  69. package/src/canvas/headless/grid.test.ts +34 -0
  70. package/src/canvas/headless/grid.ts +94 -0
  71. package/src/canvas/headless/icon-node.test.ts +23 -0
  72. package/src/canvas/headless/icon-node.ts +66 -0
  73. package/src/canvas/headless/index.ts +9 -0
  74. package/src/canvas/headless/linkify.test.ts +23 -0
  75. package/src/canvas/headless/linkify.ts +25 -0
  76. package/src/canvas/headless/pen-marks.ts +133 -0
  77. package/src/canvas/headless/sketch.test.ts +99 -0
  78. package/src/canvas/headless/sketch.ts +302 -0
  79. package/src/canvas/headless/snap-guides.test.ts +61 -0
  80. package/src/canvas/headless/snap-guides.ts +146 -0
  81. package/src/canvas/headless/svg-figure.test.ts +49 -0
  82. package/src/canvas/headless/svg-figure.ts +176 -0
  83. package/src/canvas/headless/table-layout.test.ts +158 -0
  84. package/src/canvas/headless/table-layout.ts +309 -0
  85. package/src/canvas/index.ts +35 -0
  86. package/src/canvas/lod/lod-mode.svelte.ts +30 -0
  87. package/src/canvas/lod/lod-switch.svelte +140 -0
  88. package/src/canvas/lod/render-all.svelte.ts +52 -0
  89. package/src/canvas/ops/arrange.ts +108 -0
  90. package/src/canvas/ops/ops.test.ts +98 -0
  91. package/src/canvas/ops/ops.ts +347 -0
  92. package/src/canvas/pen/PenField.svelte +128 -0
  93. package/src/canvas/pen/PenField.test.ts +87 -0
  94. package/src/canvas/pen/PenOverlay.svelte +172 -0
  95. package/src/canvas/pen/ShapeNode.svelte +64 -0
  96. package/src/canvas/pen/TextNode.svelte +97 -0
  97. package/src/canvas/pen/colors.ts +28 -0
  98. package/src/canvas/pen/eraser.ts +46 -0
  99. package/src/canvas/pen/geometry.ts +148 -0
  100. package/src/canvas/pen/host-field.test.svelte +13 -0
  101. package/src/canvas/pen/index.ts +40 -0
  102. package/src/canvas/pen/ops.ts +16 -0
  103. package/src/canvas/pen/pen-session.ts +177 -0
  104. package/src/canvas/pen/pen.css +149 -0
  105. package/src/canvas/pen/plugin.ts +139 -0
  106. package/src/canvas/pen/shapes.ts +104 -0
  107. package/src/canvas/pen/stroke-path.ts +54 -0
  108. package/src/canvas/pen/types.ts +96 -0
  109. package/src/canvas/pen/ui.ts +26 -0
  110. package/src/canvas/plugin.test.ts +45 -0
  111. package/src/canvas/plugin.ts +159 -0
  112. package/src/canvas/screen/GlyphIcon.svelte +25 -0
  113. package/src/canvas/screen/PartView.svelte +176 -0
  114. package/src/canvas/screen/ScreenBody.svelte +142 -0
  115. package/src/canvas/screen/ScreenCard.svelte +32 -0
  116. package/src/canvas/screen/glyphs/button.svg +1 -0
  117. package/src/canvas/screen/glyphs/close.svg +1 -0
  118. package/src/canvas/screen/glyphs/down.svg +1 -0
  119. package/src/canvas/screen/glyphs/header.svg +1 -0
  120. package/src/canvas/screen/glyphs/hills.svg +1 -0
  121. package/src/canvas/screen/glyphs/image.svg +1 -0
  122. package/src/canvas/screen/glyphs/input.svg +1 -0
  123. package/src/canvas/screen/glyphs/list.svg +1 -0
  124. package/src/canvas/screen/glyphs/modal.svg +1 -0
  125. package/src/canvas/screen/glyphs/plus.svg +1 -0
  126. package/src/canvas/screen/glyphs/up.svg +1 -0
  127. package/src/canvas/screen/glyphs.ts +40 -0
  128. package/src/canvas/screen/labels.ts +41 -0
  129. package/src/canvas/screen/parts.ts +164 -0
  130. package/src/canvas/screen/plugin.ts +67 -0
  131. package/src/canvas/screen/screen.css +593 -0
  132. package/src/canvas/screen/types.ts +47 -0
  133. package/src/canvas/screen/ui.ts +13 -0
  134. package/src/canvas/test-utils.ts +18 -0
  135. package/src/canvas/types.ts +53 -0
  136. package/src/canvas/ui/CanvasToolbar.svelte +169 -0
  137. package/src/canvas/ui/FrameLayer.svelte +116 -0
  138. package/src/canvas/ui/PenLayer.svelte +39 -0
  139. package/src/canvas/ui/StylePanel.svelte +268 -0
  140. package/src/canvas/ui/canvas-grid.svelte +156 -0
  141. package/src/canvas/ui/canvas-ui.css +350 -0
  142. package/src/canvas/ui/forward-wheel.ts +29 -0
  143. package/src/canvas/ui/icons.ts +124 -0
  144. package/src/canvas/ui/place-panel.ts +80 -0
  145. package/src/canvas/ui/shortcut-tip.test.ts +16 -0
  146. package/src/canvas/ui/shortcut-tip.ts +17 -0
  147. package/src/canvas/ui/tools.ts +146 -0
  148. package/src/charts/README.md +104 -0
  149. package/src/charts/ambient.d.ts +4 -0
  150. package/src/charts/animated-value.css +7 -0
  151. package/src/charts/animated-value.svelte +110 -0
  152. package/src/charts/area-chart.css +29 -0
  153. package/src/charts/area-chart.svelte +204 -0
  154. package/src/charts/bar-chart.css +119 -0
  155. package/src/charts/bar-chart.svelte +275 -0
  156. package/src/charts/charts.css +201 -0
  157. package/src/charts/crosshair.svelte +35 -0
  158. package/src/charts/dashboard/chart.ts +5 -0
  159. package/src/charts/dashboard/dashboard-empty.svelte +55 -0
  160. package/src/charts/dashboard/dashboard-section.svelte +58 -0
  161. package/src/charts/dashboard/data-panel.svelte +81 -0
  162. package/src/charts/dashboard/data-sort-head.svelte +102 -0
  163. package/src/charts/dashboard/data-table.svelte +278 -0
  164. package/src/charts/dashboard/data-table.ts +26 -0
  165. package/src/charts/dashboard/index.ts +25 -0
  166. package/src/charts/dashboard/panel-grid.svelte +43 -0
  167. package/src/charts/dashboard/period-filter.svelte +53 -0
  168. package/src/charts/dashboard/rating-chip.svelte +35 -0
  169. package/src/charts/dashboard/stat-band.svelte +47 -0
  170. package/src/charts/dashboard/stat-tile.svelte +176 -0
  171. package/src/charts/dashboard/windows.ts +8 -0
  172. package/src/charts/donut-chart.css +56 -0
  173. package/src/charts/donut-chart.svelte +158 -0
  174. package/src/charts/edge-fade.svelte +43 -0
  175. package/src/charts/edge-fade.ts +16 -0
  176. package/src/charts/figure.css +100 -0
  177. package/src/charts/figure.svelte +122 -0
  178. package/src/charts/gauge-chart.css +94 -0
  179. package/src/charts/gauge-chart.svelte +302 -0
  180. package/src/charts/hover-band.test.ts +30 -0
  181. package/src/charts/hover-band.ts +21 -0
  182. package/src/charts/hover-focus.svelte +85 -0
  183. package/src/charts/index.ts +31 -0
  184. package/src/charts/legend.css +136 -0
  185. package/src/charts/legend.svelte +112 -0
  186. package/src/charts/line-chart.css +26 -0
  187. package/src/charts/line-chart.svelte +256 -0
  188. package/src/charts/motion.ts +19 -0
  189. package/src/charts/number-chart.css +179 -0
  190. package/src/charts/number-chart.svelte +187 -0
  191. package/src/charts/primitives/chart-container.svelte +57 -0
  192. package/src/charts/primitives/chart-style.svelte +36 -0
  193. package/src/charts/primitives/chart-tooltip.svelte +161 -0
  194. package/src/charts/primitives/chart-utils.ts +54 -0
  195. package/src/charts/primitives/index.ts +11 -0
  196. package/src/charts/reference-line.css +18 -0
  197. package/src/charts/reference-line.svelte +31 -0
  198. package/src/charts/reveal.svelte +61 -0
  199. package/src/charts/ring-chart.css +104 -0
  200. package/src/charts/ring-chart.svelte +166 -0
  201. package/src/charts/sparkline.css +45 -0
  202. package/src/charts/sparkline.svelte +141 -0
  203. package/src/charts/stat-card.css +105 -0
  204. package/src/charts/stat-card.svelte +267 -0
  205. package/src/charts/time-series-chart.css +28 -0
  206. package/src/charts/time-series-chart.svelte +179 -0
  207. package/src/charts/trend-pill.css +38 -0
  208. package/src/charts/trend-pill.svelte +48 -0
  209. package/src/components/copy/copy-button.svelte +1 -1
  210. package/src/components/docs/doc-page.svelte +1 -1
  211. package/src/components/docs/on-this-page.svelte +2 -2
  212. package/src/components/list-page/empty-state.svelte +1 -1
  213. package/src/components/list-page/filter-popover.svelte +1 -1
  214. package/src/components/list-page/section-label.svelte +1 -1
  215. package/src/components/segmented/segment.svelte +4 -4
  216. package/src/components/theme/theme-provider.svelte +6 -4
  217. package/src/components/toolbar/index.ts +5 -0
  218. package/src/components/toolbar/toolbar.svelte +168 -0
  219. package/src/components/toolbar/toolbar.test.ts +66 -0
  220. package/src/editor/README.md +58 -0
  221. package/src/editor/ambient.d.ts +1 -0
  222. package/src/editor/block-id.ts +77 -0
  223. package/src/editor/block-ops.test.ts +237 -0
  224. package/src/editor/block-ops.ts +157 -0
  225. package/src/editor/content.css +418 -0
  226. package/src/editor/editor-toolbar.svelte +303 -0
  227. package/src/editor/editor.svelte +462 -0
  228. package/src/editor/extensions.ts +145 -0
  229. package/src/editor/image-toolbar.svelte +123 -0
  230. package/src/editor/index.ts +17 -0
  231. package/src/editor/inline-editor.svelte +261 -0
  232. package/src/editor/markdown-escaping.test.ts +158 -0
  233. package/src/editor/markdown-marks.test.ts +89 -0
  234. package/src/editor/markdown-test-helpers.ts +13 -0
  235. package/src/editor/markdown-youtube.test.ts +93 -0
  236. package/src/editor/markdown.test.ts +250 -0
  237. package/src/editor/markdown.ts +511 -0
  238. package/src/editor/renderer.svelte +92 -0
  239. package/src/editor/serializer.test.ts +133 -0
  240. package/src/editor/serializer.ts +370 -0
  241. package/src/editor/slash-items.ts +115 -0
  242. package/src/editor/slash-menu.svelte +159 -0
  243. package/src/editor/youtube-url.ts +35 -0
  244. package/src/effects/interactive-hover-button/InteractiveHoverButton.svelte +13 -13
  245. package/src/lib/result.ts +19 -0
  246. package/src/onboarding/README.md +120 -0
  247. package/src/onboarding/define-tours.test.ts +50 -0
  248. package/src/onboarding/define-tours.ts +31 -0
  249. package/src/onboarding/demo.svelte +86 -0
  250. package/src/onboarding/index.ts +12 -0
  251. package/src/onboarding/onboarding-provider.svelte +120 -0
  252. package/src/onboarding/onboarding.svelte.ts +24 -0
  253. package/src/onboarding/pick.test.ts +79 -0
  254. package/src/onboarding/pick.ts +36 -0
  255. package/src/onboarding/stores.test.ts +103 -0
  256. package/src/onboarding/stores.ts +58 -0
  257. package/src/onboarding/tour-card.svelte +70 -0
  258. package/src/onboarding/tour-runner.svelte +197 -0
  259. package/src/onboarding/types.ts +34 -0
  260. package/src/onboarding/wait.ts +29 -0
  261. package/src/shadcn/command/command-input.svelte +1 -1
  262. package/src/slides/Controls.svelte +138 -0
  263. package/src/slides/Deck.svelte +271 -0
  264. package/src/slides/Fragment.svelte +71 -0
  265. package/src/slides/Overview.svelte +113 -0
  266. package/src/slides/Presenter.svelte +292 -0
  267. package/src/slides/README.md +89 -0
  268. package/src/slides/Slide.svelte +179 -0
  269. package/src/slides/SlideList.svelte +19 -0
  270. package/src/slides/ambient.d.ts +1 -0
  271. package/src/slides/bridges.ts +69 -0
  272. package/src/slides/deck-controller.svelte.ts +292 -0
  273. package/src/slides/demo-slides.svelte +358 -0
  274. package/src/slides/demo.svelte +176 -0
  275. package/src/slides/export/capture.ts +113 -0
  276. package/src/slides/export/ffmpeg.ts +120 -0
  277. package/src/slides/export/index.ts +9 -0
  278. package/src/slides/export/launch.ts +10 -0
  279. package/src/slides/export/pdf.ts +152 -0
  280. package/src/slides/export/types.ts +48 -0
  281. package/src/slides/index.ts +19 -0
  282. package/src/slides/labels.ts +48 -0
  283. package/src/slides/layout.ts +51 -0
  284. package/src/slides/model.ts +47 -0
  285. package/src/slides/navigation.ts +100 -0
  286. package/src/slides/theme.css +205 -0
  287. package/src/slides/types.ts +86 -0
  288. package/src/tasks/README.md +312 -0
  289. package/src/tasks/ambient.d.ts +3 -0
  290. package/src/tasks/board-order.ts +137 -0
  291. package/src/tasks/dates.ts +31 -0
  292. package/src/tasks/define-statuses.ts +32 -0
  293. package/src/tasks/demo.svelte +389 -0
  294. package/src/tasks/detail/detail-demo.svelte +186 -0
  295. package/src/tasks/detail/detail-types.ts +54 -0
  296. package/src/tasks/detail/task-chip.svelte +28 -0
  297. package/src/tasks/detail/task-detail.svelte +315 -0
  298. package/src/tasks/detail/task-property.svelte +17 -0
  299. package/src/tasks/detail/task-thread.svelte +258 -0
  300. package/src/tasks/gantt/gantt-bar.svelte +243 -0
  301. package/src/tasks/gantt/gantt-grid.svelte +94 -0
  302. package/src/tasks/gantt/gantt-group-row.svelte +55 -0
  303. package/src/tasks/gantt/gantt-header.svelte +71 -0
  304. package/src/tasks/gantt/gantt-task-row.svelte +186 -0
  305. package/src/tasks/gantt/gantt.css +22 -0
  306. package/src/tasks/gantt/gantt.test.ts +77 -0
  307. package/src/tasks/gantt/logic.test.ts +163 -0
  308. package/src/tasks/gantt/logic.ts +248 -0
  309. package/src/tasks/inbox/inbox-card.svelte +283 -0
  310. package/src/tasks/inbox/inbox-draft.svelte +84 -0
  311. package/src/tasks/inbox/inbox-logic.ts +55 -0
  312. package/src/tasks/inbox/inbox-statuses.ts +48 -0
  313. package/src/tasks/inbox/inbox-trace.svelte +45 -0
  314. package/src/tasks/inbox/inbox-types.ts +108 -0
  315. package/src/tasks/inbox/inbox.svelte +203 -0
  316. package/src/tasks/inbox/index.ts +23 -0
  317. package/src/tasks/index.ts +85 -0
  318. package/src/tasks/kanban-board.svelte +561 -0
  319. package/src/tasks/kanban-column.svelte +117 -0
  320. package/src/tasks/kanban-drag.ts +38 -0
  321. package/src/tasks/kanban-types.ts +65 -0
  322. package/src/tasks/presets/sales.ts +16 -0
  323. package/src/tasks/record/demo-data.ts +286 -0
  324. package/src/tasks/record/field-section.svelte +53 -0
  325. package/src/tasks/record/fields.ts +142 -0
  326. package/src/tasks/record/format.ts +87 -0
  327. package/src/tasks/record/gutter.ts +2 -0
  328. package/src/tasks/record/index.ts +54 -0
  329. package/src/tasks/record/info-list.svelte +316 -0
  330. package/src/tasks/record/kinds.ts +42 -0
  331. package/src/tasks/record/logic.ts +324 -0
  332. package/src/tasks/record/next-up.svelte +70 -0
  333. package/src/tasks/record/project-list.svelte +133 -0
  334. package/src/tasks/record/record-demo.svelte +324 -0
  335. package/src/tasks/record/record-header.svelte +70 -0
  336. package/src/tasks/record/record-shell.svelte +138 -0
  337. package/src/tasks/record/record-stats.svelte +48 -0
  338. package/src/tasks/record/record-timeline.svelte +231 -0
  339. package/src/tasks/record/record.test.ts +350 -0
  340. package/src/tasks/record/steps-rail.svelte +474 -0
  341. package/src/tasks/record/types.ts +280 -0
  342. package/src/tasks/record/where-we-are.svelte +121 -0
  343. package/src/tasks/reorder.ts +19 -0
  344. package/src/tasks/side-panel-types.ts +16 -0
  345. package/src/tasks/side-panel.svelte +193 -0
  346. package/src/tasks/status-badge.svelte +63 -0
  347. package/src/tasks/status-dot.svelte +228 -0
  348. package/src/tasks/status-icon.svelte +30 -0
  349. package/src/tasks/status-types.ts +40 -0
  350. package/src/tasks/task-gantt.svelte +230 -0
  351. package/src/tasks/task-list.svelte +470 -0
  352. package/src/tasks/task-types.ts +33 -0
  353. package/src/tasks/time/duration.ts +69 -0
  354. package/src/tasks/time/time-chip.svelte +62 -0
  355. package/src/tasks/time/time-dot.svelte +58 -0
  356. package/src/tasks/time/time-ring.svelte +74 -0
  357. package/src/tasks/time/time-tracker.svelte +565 -0
  358. package/src/tasks/time/time-types.ts +74 -0
  359. package/src/tasks/use-kanban-dnd.svelte.ts +173 -0
  360. package/src/tokens/base.css +5 -5
  361. package/src/tokens/palette.css +115 -120
@@ -0,0 +1,133 @@
1
+ import { describe, expect, it } from "vite-plus/test";
2
+ import { renderTiptapContent } from "./serializer.js";
3
+
4
+ function row(...cells: string[]) {
5
+ return {
6
+ type: "tableRow",
7
+ content: cells.map((text) => ({
8
+ type: "tableCell",
9
+ content: [{ type: "paragraph", content: [{ type: "text", text }] }],
10
+ })),
11
+ };
12
+ }
13
+
14
+ function table(...rows: ReturnType<typeof row>[]) {
15
+ return { type: "doc", content: [{ type: "table", content: rows }] };
16
+ }
17
+
18
+ describe("renderTiptapContent", () => {
19
+ it("silently drops unknown cmsBlock nodes", () => {
20
+ const html = renderTiptapContent({
21
+ type: "doc",
22
+ content: [
23
+ {
24
+ type: "cmsBlock",
25
+ attrs: { blockType: "mediaBlock", data: { media: 1117 } },
26
+ },
27
+ { type: "paragraph", content: [{ type: "text", text: "After block" }] },
28
+ ],
29
+ });
30
+
31
+ expect(html).not.toContain("cmsBlock");
32
+ expect(html).not.toContain("mediaBlock");
33
+ expect(html).toContain("After block");
34
+ });
35
+
36
+ it("wraps tables in a scroll container", () => {
37
+ const html = renderTiptapContent(table(row("Cell A", "Cell B"), row("Value 1", "Value 2")));
38
+ expect(html).toContain('<div class="tiptap-table-wrap">');
39
+ expect(html).toContain("<table>");
40
+ });
41
+
42
+ it("tags comparison tables (EN) and pills the Winner column", () => {
43
+ const html = renderTiptapContent(
44
+ table(
45
+ row("Category", "Winner", "Notes"),
46
+ row("Pricing", "Acme", "Cheaper bundle."),
47
+ row("Dashboards", "Tie", "Even."),
48
+ row("Forms", "Globex", "Faster setup."),
49
+ ),
50
+ );
51
+ expect(html).toContain('<table class="comparison-table">');
52
+ expect(html).toContain('class="winner-cell" data-winner="acme"');
53
+ expect(html).toContain('class="winner-cell" data-winner="globex"');
54
+ expect(html).toContain('class="winner-cell" data-winner="tie"');
55
+ });
56
+
57
+ it("tags comparison tables (FR) with Gagnant header and Egalite ties", () => {
58
+ const html = renderTiptapContent(
59
+ table(
60
+ row("Catégorie", "Gagnant", "Notes"),
61
+ row("Tarifs", "Acme", "Moins cher."),
62
+ row("Dashboards", "Égalité", "Pareil."),
63
+ ),
64
+ );
65
+ expect(html).toContain('<table class="comparison-table">');
66
+ expect(html).toContain('data-winner="acme"');
67
+ expect(html).toContain('data-winner="tie"');
68
+ });
69
+
70
+ it("names an unknown winner by its first word and files a blank one as other", () => {
71
+ const html = renderTiptapContent(
72
+ table(row("Category", "Winner"), row("Pricing", "Initech wins"), row("Forms", "")),
73
+ );
74
+ expect(html).toContain('data-winner="initech"');
75
+ expect(html).toContain('data-winner="other"');
76
+ });
77
+
78
+ it("does not add comparison styling to ordinary tables", () => {
79
+ const html = renderTiptapContent(table(row("Name", "Role"), row("Alice", "Engineer")));
80
+ expect(html).not.toContain("comparison-table");
81
+ expect(html).not.toContain("winner-cell");
82
+ });
83
+
84
+ it("ignores the Winner header row itself", () => {
85
+ const html = renderTiptapContent(table(row("Category", "Winner"), row("Pricing", "Acme")));
86
+ expect(html.match(/winner-cell/g)?.length).toBe(1);
87
+ });
88
+ });
89
+
90
+ describe("youtube node", () => {
91
+ function youtubeDoc(attrs: Record<string, unknown>) {
92
+ return { type: "doc", content: [{ type: "youtube", attrs }] };
93
+ }
94
+
95
+ it("renders the click-to-load facade for a valid videoId", () => {
96
+ const html = renderTiptapContent(
97
+ youtubeDoc({ videoId: "dQw4w9WgXcQ", title: "How to build a Acme CRM", start: 0 }),
98
+ );
99
+ expect(html).toContain(
100
+ '<div class="tiptap-youtube" data-video-id="dQw4w9WgXcQ" data-start="0">',
101
+ );
102
+ expect(html).toContain('class="tiptap-youtube-trigger"');
103
+ expect(html).toContain('aria-label="Play video: How to build a Acme CRM"');
104
+ expect(html).toContain('src="https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg"');
105
+ expect(html).toContain('class="tiptap-youtube-play"');
106
+ expect(html).toContain('<span class="tiptap-youtube-title">How to build a Acme CRM</span>');
107
+ expect(html).not.toContain("<iframe");
108
+ });
109
+
110
+ it("renders nothing for an invalid videoId", () => {
111
+ const html = renderTiptapContent(
112
+ youtubeDoc({ videoId: "not-11-chars", title: "Bad id", start: 0 }),
113
+ );
114
+ expect(html).toBe("");
115
+ });
116
+
117
+ it("renders nothing when videoId is missing", () => {
118
+ const html = renderTiptapContent(youtubeDoc({ title: "No id" }));
119
+ expect(html).toBe("");
120
+ });
121
+
122
+ it("carries a non-zero start into data-start", () => {
123
+ const html = renderTiptapContent(youtubeDoc({ videoId: "dQw4w9WgXcQ", title: "T", start: 90 }));
124
+ expect(html).toContain('data-start="90"');
125
+ });
126
+
127
+ it("escapes title and videoId in HTML attributes", () => {
128
+ const html = renderTiptapContent(
129
+ youtubeDoc({ videoId: "dQw4w9WgXcQ", title: '"><script>alert(1)</script>', start: 0 }),
130
+ );
131
+ expect(html).not.toContain("<script>");
132
+ });
133
+ });
@@ -0,0 +1,370 @@
1
+ // WHY our own slugify: the website's uses NFKD, which strips accents from French
2
+ // headings. The vibe-kit utils one doesn't, so accented anchors would differ.
3
+ function slugify(input: string, maxLength = 60): string {
4
+ return input
5
+ .normalize("NFKD")
6
+ .replace(/[̀-ͯ]/g, "")
7
+ .toLowerCase()
8
+ .replace(/[^a-z0-9]+/g, "-")
9
+ .replace(/(^-|-$)/g, "")
10
+ .slice(0, maxLength)
11
+ .replace(/-$/g, "");
12
+ }
13
+
14
+ type Mark = {
15
+ type?: string;
16
+ attrs?: Record<string, unknown>;
17
+ };
18
+
19
+ type Node = {
20
+ type?: string;
21
+ attrs?: Record<string, unknown>;
22
+ content?: Node[];
23
+ marks?: Mark[];
24
+ text?: string;
25
+ };
26
+
27
+ export type ExtractedHeading = {
28
+ id: string;
29
+ level: 1 | 2 | 3 | 4 | 5 | 6;
30
+ text: string;
31
+ };
32
+
33
+ export type ExtractedYoutubeVideo = {
34
+ videoId: string;
35
+ title: string;
36
+ start: number;
37
+ };
38
+
39
+ type RenderContext = { headingSlugs: Map<string, number> };
40
+
41
+ function newContext(): RenderContext {
42
+ return { headingSlugs: new Map() };
43
+ }
44
+
45
+ function uniqueHeadingId(text: string, ctx: RenderContext): string {
46
+ const base = slugify(text) || "section";
47
+ const seen = ctx.headingSlugs.get(base) ?? 0;
48
+ ctx.headingSlugs.set(base, seen + 1);
49
+ return seen === 0 ? base : `${base}-${seen + 1}`;
50
+ }
51
+
52
+ export function renderTiptapContent(content: unknown): string {
53
+ if (!content || typeof content !== "object") return "";
54
+ return renderNode(content as Node, newContext());
55
+ }
56
+
57
+ export function extractHeadings(
58
+ content: unknown,
59
+ options: { levels?: ReadonlyArray<1 | 2 | 3 | 4 | 5 | 6> } = {},
60
+ ): ExtractedHeading[] {
61
+ if (!content || typeof content !== "object") return [];
62
+ const levels = options.levels ?? [2, 3];
63
+ const ctx = newContext();
64
+ const headings: ExtractedHeading[] = [];
65
+ walkHeadings(content as Node, ctx, levels, headings);
66
+ return headings;
67
+ }
68
+
69
+ function walkHeadings(
70
+ node: Node,
71
+ ctx: RenderContext,
72
+ levels: ReadonlyArray<1 | 2 | 3 | 4 | 5 | 6>,
73
+ out: ExtractedHeading[],
74
+ ): void {
75
+ if (node.type === "heading") {
76
+ const level = clampHeadingLevel(node.attrs?.level);
77
+ const text = extractText(node.content).trim();
78
+ const id = uniqueHeadingId(text, ctx);
79
+ if (text && levels.includes(level)) out.push({ id, level, text });
80
+ return;
81
+ }
82
+ if (node.content?.length) {
83
+ for (const child of node.content) walkHeadings(child, ctx, levels, out);
84
+ }
85
+ }
86
+
87
+ // WHY: a consumer like the website's VideoObject schema gets one entry per
88
+ // `youtube` node without redoing the walk or the videoId check.
89
+ export function extractYoutubeVideos(content: unknown): ExtractedYoutubeVideo[] {
90
+ if (!content || typeof content !== "object") return [];
91
+ const videos: ExtractedYoutubeVideo[] = [];
92
+ walkYoutubeVideos(content as Node, videos);
93
+ return videos;
94
+ }
95
+
96
+ function walkYoutubeVideos(node: Node, out: ExtractedYoutubeVideo[]): void {
97
+ if (node.type === "youtube") {
98
+ const video = youtubeVideoOf(node);
99
+ if (video) out.push(video);
100
+ return;
101
+ }
102
+ for (const child of node.content ?? []) walkYoutubeVideos(child, out);
103
+ }
104
+
105
+ function youtubeVideoOf(node: Node): ExtractedYoutubeVideo | null {
106
+ const videoId = strAttr(node.attrs, "videoId") ?? "";
107
+ if (!YOUTUBE_VIDEO_ID_RE.test(videoId)) return null;
108
+ return {
109
+ videoId,
110
+ title: strAttr(node.attrs, "title") ?? "",
111
+ start: numAttr(node.attrs, "start") ?? 0,
112
+ };
113
+ }
114
+
115
+ // A typed read of one attr: the value when it has the asked type, else undefined.
116
+ function strAttr(attrs: Record<string, unknown> | undefined, key: string): string | undefined {
117
+ const value = attrs?.[key];
118
+ return typeof value === "string" ? value : undefined;
119
+ }
120
+
121
+ function numAttr(attrs: Record<string, unknown> | undefined, key: string): number | undefined {
122
+ const value = attrs?.[key];
123
+ return typeof value === "number" ? value : undefined;
124
+ }
125
+
126
+ type Renderer = (node: Node, ctx: RenderContext) => string;
127
+
128
+ // Wraps a node's rendered children in one tag.
129
+ const wrapIn =
130
+ (tag: string): Renderer =>
131
+ (node, ctx) =>
132
+ `<${tag}>${renderChildren(node.content, ctx) || ""}</${tag}>`;
133
+
134
+ function renderHeading(node: Node, ctx: RenderContext): string {
135
+ const level = clampHeadingLevel(node.attrs?.level);
136
+ const text = extractText(node.content).trim();
137
+ const inner = renderChildren(node.content, ctx) || "";
138
+ if (!text) return `<h${level}>${inner}</h${level}>`;
139
+ const id = uniqueHeadingId(text, ctx);
140
+ return `<h${level} id="${escapeAttr(id)}">${inner}</h${level}>`;
141
+ }
142
+
143
+ function renderOrderedList(node: Node, ctx: RenderContext): string {
144
+ const start = numAttr(node.attrs, "start");
145
+ const attrs = renderAttributes({ start: start === undefined ? undefined : String(start) });
146
+ return `<ol${attrs}>${renderChildren(node.content, ctx) || ""}</ol>`;
147
+ }
148
+
149
+ function renderCodeBlock(node: Node): string {
150
+ const lang = strAttr(node.attrs, "language");
151
+ const language = lang ? ` class="language-${escapeAttr(lang)}"` : "";
152
+ return `<pre><code${language}>${escapeHtml(extractText(node.content))}</code></pre>`;
153
+ }
154
+
155
+ function numString(attrs: Record<string, unknown> | undefined, key: string): string | undefined {
156
+ const value = numAttr(attrs, key);
157
+ return value === undefined ? undefined : String(value);
158
+ }
159
+
160
+ function renderImage(node: Node): string {
161
+ const src = strAttr(node.attrs, "src");
162
+ if (!src) return "";
163
+ const attrs = renderAttributes({
164
+ alt: strAttr(node.attrs, "alt") ?? "",
165
+ title: strAttr(node.attrs, "title"),
166
+ width: numString(node.attrs, "width"),
167
+ height: numString(node.attrs, "height"),
168
+ "data-align": strAttr(node.attrs, "align"),
169
+ });
170
+ return `<img src="${escapeAttr(src)}"${attrs} />`;
171
+ }
172
+
173
+ function renderTable(node: Node, ctx: RenderContext): string {
174
+ const winnerColIndex = findWinnerColumnIndex(node);
175
+ const rows = node.content ?? [];
176
+ const body = rows
177
+ .map((row, rowIdx) => renderTableRow(row, ctx, { winnerColIndex, isFirstRow: rowIdx === 0 }))
178
+ .join("");
179
+ const tableClass = winnerColIndex !== -1 ? ' class="comparison-table"' : "";
180
+ return `<div class="tiptap-table-wrap"><table${tableClass}>${body}</table></div>`;
181
+ }
182
+
183
+ const RENDERERS: Record<string, Renderer> = {
184
+ doc: (node, ctx) => renderChildren(node.content, ctx),
185
+ paragraph: wrapIn("p"),
186
+ heading: renderHeading,
187
+ blockquote: wrapIn("blockquote"),
188
+ bulletList: wrapIn("ul"),
189
+ orderedList: renderOrderedList,
190
+ listItem: wrapIn("li"),
191
+ codeBlock: renderCodeBlock,
192
+ horizontalRule: () => "<hr />",
193
+ hardBreak: () => "<br />",
194
+ image: renderImage,
195
+ cmsBlock: () => "",
196
+ youtube: renderYoutube,
197
+ table: renderTable,
198
+ tableRow: (node, ctx) => renderTableRow(node, ctx, { winnerColIndex: -1, isFirstRow: false }),
199
+ tableCell: (node, ctx) => renderTableCell(node, ctx, false),
200
+ tableHeader: (node, ctx) => renderTableCell(node, ctx, false),
201
+ text: renderText,
202
+ };
203
+
204
+ // Own keys only, so a node typed "toString" never reaches Object.prototype.
205
+ function pick<T>(table: Record<string, T>, key: string | undefined): T | undefined {
206
+ return key !== undefined && Object.hasOwn(table, key) ? table[key] : undefined;
207
+ }
208
+
209
+ function renderNode(node: Node, ctx: RenderContext): string {
210
+ const render = pick(RENDERERS, node.type);
211
+ return render ? render(node, ctx) : renderChildren(node.content, ctx);
212
+ }
213
+
214
+ function renderChildren(content: Node[] | undefined, ctx: RenderContext): string {
215
+ if (!content?.length) return "";
216
+ return content.map((child) => renderNode(child, ctx)).join("");
217
+ }
218
+
219
+ // WHY a facade, never a bare <iframe> on first paint: it's roughly 700KB and wrecks
220
+ // LCP. renderer.svelte's delegated click listener swaps in the iframe.
221
+ const YOUTUBE_VIDEO_ID_RE = /^[A-Za-z0-9_-]{11}$/;
222
+
223
+ function renderYoutube(node: Node): string {
224
+ const videoId = typeof node.attrs?.videoId === "string" ? node.attrs.videoId : "";
225
+ if (!YOUTUBE_VIDEO_ID_RE.test(videoId)) return "";
226
+ const title = typeof node.attrs?.title === "string" ? node.attrs.title : "";
227
+ const start = typeof node.attrs?.start === "number" ? node.attrs.start : 0;
228
+ const safeVideoId = escapeAttr(videoId);
229
+ const safeTitle = escapeAttr(title);
230
+
231
+ return (
232
+ `<div class="tiptap-youtube" data-video-id="${safeVideoId}" data-start="${start}">` +
233
+ `<button type="button" class="tiptap-youtube-trigger" aria-label="Play video: ${safeTitle}">` +
234
+ `<img class="tiptap-youtube-poster" src="https://i.ytimg.com/vi/${safeVideoId}/hqdefault.jpg" alt="" loading="lazy" width="480" height="360" />` +
235
+ `<span class="tiptap-youtube-play" aria-hidden="true"></span>` +
236
+ `<span class="tiptap-youtube-title">${escapeHtml(title)}</span>` +
237
+ `</button></div>`
238
+ );
239
+ }
240
+
241
+ function renderTableRow(
242
+ row: Node,
243
+ ctx: RenderContext,
244
+ { winnerColIndex, isFirstRow }: { winnerColIndex: number; isFirstRow: boolean },
245
+ ): string {
246
+ if (row.type !== "tableRow" || !row.content?.length) {
247
+ return `<tr>${renderChildren(row.content, ctx)}</tr>`;
248
+ }
249
+ const cellsHtml = row.content
250
+ .map((cell, cellIdx) => {
251
+ const isWinnerCell = !isFirstRow && winnerColIndex !== -1 && cellIdx === winnerColIndex;
252
+ return renderTableCell(cell, ctx, isWinnerCell);
253
+ })
254
+ .join("");
255
+ return `<tr>${cellsHtml}</tr>`;
256
+ }
257
+
258
+ function renderTableCell(cell: Node, ctx: RenderContext, isWinnerCell: boolean): string {
259
+ const tag = cell.type === "tableHeader" ? "th" : "td";
260
+ const colspan =
261
+ typeof cell.attrs?.colspan === "number" && cell.attrs.colspan !== 1
262
+ ? String(cell.attrs.colspan)
263
+ : undefined;
264
+ const rowspan =
265
+ typeof cell.attrs?.rowspan === "number" && cell.attrs.rowspan !== 1
266
+ ? String(cell.attrs.rowspan)
267
+ : undefined;
268
+ const inner = renderChildren(cell.content, ctx);
269
+ if (!isWinnerCell) {
270
+ const attrs = renderAttributes({ colspan, rowspan });
271
+ return `<${tag}${attrs}>${inner}</${tag}>`;
272
+ }
273
+ const winnerText = extractText(cell.content).trim();
274
+ const winner = normalizeWinner(winnerText);
275
+ const attrs = renderAttributes({
276
+ class: "winner-cell",
277
+ "data-winner": winner,
278
+ colspan,
279
+ rowspan,
280
+ });
281
+ return `<${tag}${attrs}>${inner}</${tag}>`;
282
+ }
283
+
284
+ const WINNER_HEADERS = ["winner", "gagnant", "vainqueur"];
285
+
286
+ function findWinnerColumnIndex(table: Node): number {
287
+ const firstRow = table.content?.[0];
288
+ if (!firstRow?.content) return -1;
289
+ for (let i = 0; i < firstRow.content.length; i++) {
290
+ const headerText = extractText(firstRow.content[i]?.content).trim().toLowerCase();
291
+ if (WINNER_HEADERS.includes(headerText)) return i;
292
+ }
293
+ return -1;
294
+ }
295
+
296
+ // WHY the first word: "Acme wins" and "Acme" are one winner. The host styles a
297
+ // product with `[data-winner="acme"]`, so the serializer names no product.
298
+ function normalizeWinner(text: string): string {
299
+ const lower = text.toLowerCase().trim();
300
+ if (!lower) return "other";
301
+ if (lower.startsWith("tie") || lower.startsWith("égal") || lower.startsWith("egal")) {
302
+ return "tie";
303
+ }
304
+ return lower.match(/^[\p{L}\p{N}]+/u)?.[0] ?? "other";
305
+ }
306
+
307
+ function renderText(node: Node): string {
308
+ const text = escapeHtml(node.text ?? "");
309
+ if (!node.marks?.length) return text;
310
+
311
+ return node.marks.reduce((html, mark) => wrapMark(html, mark), text);
312
+ }
313
+
314
+ const MARK_TAGS: Record<string, string> = {
315
+ bold: "strong",
316
+ italic: "em",
317
+ strike: "s",
318
+ underline: "u",
319
+ code: "code",
320
+ };
321
+
322
+ function wrapMark(content: string, mark: Mark): string {
323
+ if (mark.type === "link") {
324
+ const attrs = renderAttributes({
325
+ href: strAttr(mark.attrs, "href") ?? "",
326
+ target: strAttr(mark.attrs, "target"),
327
+ rel: strAttr(mark.attrs, "rel"),
328
+ });
329
+ return `<a${attrs}>${content}</a>`;
330
+ }
331
+ const tag = pick(MARK_TAGS, mark.type);
332
+ return tag ? `<${tag}>${content}</${tag}>` : content;
333
+ }
334
+
335
+ function renderAttributes(attrs: Record<string, string | undefined>): string {
336
+ const parts = Object.entries(attrs)
337
+ .filter(([, value]) => value !== undefined && value !== "")
338
+ .map(([key, value]) => ` ${key}="${escapeAttr(value ?? "")}"`);
339
+ return parts.join("");
340
+ }
341
+
342
+ function extractText(content: Node[] | undefined): string {
343
+ if (!content?.length) return "";
344
+ return content
345
+ .map((node) => {
346
+ if (node.type === "text") return node.text ?? "";
347
+ return extractText(node.content);
348
+ })
349
+ .join("");
350
+ }
351
+
352
+ function clampHeadingLevel(level: unknown): 1 | 2 | 3 | 4 | 5 | 6 {
353
+ if (level === 1 || level === 2 || level === 3 || level === 4 || level === 5 || level === 6) {
354
+ return level;
355
+ }
356
+ return 2;
357
+ }
358
+
359
+ function escapeHtml(value: string): string {
360
+ return value
361
+ .replaceAll("&", "&amp;")
362
+ .replaceAll("<", "&lt;")
363
+ .replaceAll(">", "&gt;")
364
+ .replaceAll('"', "&quot;")
365
+ .replaceAll("'", "&#39;");
366
+ }
367
+
368
+ function escapeAttr(value: string): string {
369
+ return escapeHtml(value).replaceAll("`", "&#96;");
370
+ }
@@ -0,0 +1,115 @@
1
+ import type { Editor } from "@tiptap/core";
2
+ import {
3
+ TextB,
4
+ TextHOne,
5
+ TextHTwo,
6
+ TextHThree,
7
+ ListBullets,
8
+ ListNumbers,
9
+ Quotes,
10
+ CodeBlock,
11
+ Minus,
12
+ ImageSquare,
13
+ Paragraph,
14
+ VideoCamera,
15
+ } from "phosphor-svelte";
16
+
17
+ export type SlashItem = {
18
+ label: string;
19
+ description: string;
20
+ icon: typeof TextB;
21
+ command: () => void;
22
+ };
23
+
24
+ export type SlashOptions = {
25
+ preset: "full" | "notes";
26
+ insertImage: () => void;
27
+ insertYoutube: () => void;
28
+ };
29
+
30
+ /** Every slash command for a preset, filtered by the text typed after the `/`. */
31
+ export function buildSlashItems(editor: Editor, query: string, options: SlashOptions): SlashItem[] {
32
+ const run = () => editor.chain().focus();
33
+ const full = options.preset === "full";
34
+ const allItems: SlashItem[] = [
35
+ {
36
+ label: "Heading 1",
37
+ description: "Page title",
38
+ icon: TextHOne,
39
+ command: () => run().toggleHeading({ level: 1 }).run(),
40
+ },
41
+ {
42
+ label: "Heading 2",
43
+ description: "Section heading",
44
+ icon: TextHTwo,
45
+ command: () => run().toggleHeading({ level: 2 }).run(),
46
+ },
47
+ {
48
+ label: "Heading 3",
49
+ description: "Subsection heading",
50
+ icon: TextHThree,
51
+ command: () => run().toggleHeading({ level: 3 }).run(),
52
+ },
53
+ {
54
+ label: "Bullet List",
55
+ description: "Unordered list",
56
+ icon: ListBullets,
57
+ command: () => run().toggleBulletList().run(),
58
+ },
59
+ {
60
+ label: "Ordered List",
61
+ description: "Numbered list",
62
+ icon: ListNumbers,
63
+ command: () => run().toggleOrderedList().run(),
64
+ },
65
+ {
66
+ label: "Quote",
67
+ description: "Blockquote",
68
+ icon: Quotes,
69
+ command: () => run().toggleBlockquote().run(),
70
+ },
71
+ {
72
+ label: "Code Block",
73
+ description: "Code snippet",
74
+ icon: CodeBlock,
75
+ command: () => run().toggleCodeBlock().run(),
76
+ },
77
+ {
78
+ label: "Divider",
79
+ description: "Horizontal rule",
80
+ icon: Minus,
81
+ command: () => run().setHorizontalRule().run(),
82
+ },
83
+ ...(full
84
+ ? [
85
+ {
86
+ label: "Image",
87
+ description: "Insert from URL",
88
+ icon: ImageSquare,
89
+ command: options.insertImage,
90
+ },
91
+ ]
92
+ : []),
93
+ ...(full
94
+ ? [
95
+ {
96
+ label: "YouTube",
97
+ description: "Insert a video by URL",
98
+ icon: VideoCamera,
99
+ command: options.insertYoutube,
100
+ },
101
+ ]
102
+ : []),
103
+ {
104
+ label: "Paragraph",
105
+ description: "Plain text block",
106
+ icon: Paragraph,
107
+ command: () => run().setParagraph().run(),
108
+ },
109
+ ];
110
+ if (!query) return allItems;
111
+ const q = query.toLowerCase();
112
+ return allItems.filter(
113
+ (item) => item.label.toLowerCase().includes(q) || item.description.toLowerCase().includes(q),
114
+ );
115
+ }