@jant/core 0.3.35 → 0.3.37

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 (307) hide show
  1. package/bin/commands/export.js +1 -1
  2. package/bin/commands/import-site.js +529 -0
  3. package/bin/commands/reset-password.js +3 -2
  4. package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
  5. package/dist/client/assets/module-RjUF93sV.js +716 -0
  6. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  7. package/dist/client/assets/url-FWFqPJPb.js +1 -0
  8. package/dist/client/client.css +1 -1
  9. package/dist/client/client.js +4564 -3013
  10. package/dist/index.js +12885 -8161
  11. package/package.json +23 -6
  12. package/src/__tests__/helpers/app.ts +10 -10
  13. package/src/__tests__/helpers/db.ts +91 -87
  14. package/src/app.tsx +157 -31
  15. package/src/auth.ts +20 -2
  16. package/src/client/archive-nav.js +187 -0
  17. package/src/client/audio-player.ts +478 -0
  18. package/src/client/audio-processor.ts +84 -0
  19. package/src/{lib → client}/avatar-upload.ts +4 -3
  20. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  21. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  22. package/src/client/components/__tests__/jant-compose-dialog.test.ts +1140 -0
  23. package/src/client/components/__tests__/jant-compose-editor.test.ts +504 -0
  24. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +37 -17
  25. package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +2 -2
  26. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  27. package/src/client/components/collection-sidebar-types.ts +43 -0
  28. package/src/{ui → client}/components/collection-types.ts +3 -4
  29. package/src/client/components/compose-types.ts +174 -0
  30. package/src/client/components/jant-collection-form.ts +667 -0
  31. package/src/client/components/jant-collection-sidebar.ts +805 -0
  32. package/src/client/components/jant-compose-dialog.ts +2161 -0
  33. package/src/client/components/jant-compose-editor.ts +1813 -0
  34. package/src/client/components/jant-compose-fullscreen.ts +283 -0
  35. package/src/client/components/jant-media-lightbox.ts +259 -0
  36. package/src/{ui → client}/components/jant-nav-manager.ts +97 -298
  37. package/src/{ui → client}/components/jant-post-form.ts +141 -12
  38. package/src/client/components/jant-post-menu.ts +1019 -0
  39. package/src/{ui → client}/components/jant-settings-avatar.ts +3 -3
  40. package/src/{ui → client}/components/jant-settings-general.ts +38 -4
  41. package/src/client/components/jant-text-preview.ts +232 -0
  42. package/src/{ui → client}/components/nav-manager-types.ts +6 -18
  43. package/src/{ui → client}/components/post-form-template.ts +137 -38
  44. package/src/{ui → client}/components/post-form-types.ts +15 -4
  45. package/src/client/compose-bridge.ts +583 -0
  46. package/src/{lib → client}/image-processor.ts +26 -8
  47. package/src/client/lazy-slugify.ts +51 -0
  48. package/src/client/media-metadata.ts +247 -0
  49. package/src/client/multipart-upload.ts +160 -0
  50. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  51. package/src/{lib → client}/post-form-bridge.ts +53 -2
  52. package/src/{lib → client}/settings-bridge.ts +3 -15
  53. package/src/client/thread-context.ts +140 -0
  54. package/src/client/tiptap/bubble-menu.ts +205 -0
  55. package/src/client/tiptap/create-editor.ts +86 -0
  56. package/src/client/tiptap/exitable-marks.ts +73 -0
  57. package/src/client/tiptap/extensions.ts +65 -0
  58. package/src/client/tiptap/image-node.ts +482 -0
  59. package/src/client/tiptap/link-toolbar.ts +371 -0
  60. package/src/client/tiptap/more-break.ts +50 -0
  61. package/src/client/tiptap/paste-image.ts +129 -0
  62. package/src/client/tiptap/slash-commands.ts +438 -0
  63. package/src/{lib → client}/toast.ts +101 -3
  64. package/src/client/types/sortablejs.d.ts +44 -0
  65. package/src/client/upload-with-metadata.ts +54 -0
  66. package/src/client/video-processor.ts +207 -0
  67. package/src/client.ts +27 -17
  68. package/src/db/__tests__/migrations.test.ts +118 -0
  69. package/src/db/index.ts +52 -0
  70. package/src/db/migrations/0000_baseline.sql +269 -0
  71. package/src/db/migrations/0001_fts_setup.sql +31 -0
  72. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  73. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  74. package/src/db/migrations/meta/_journal.json +4 -39
  75. package/src/db/schema.ts +409 -140
  76. package/src/i18n/__tests__/detect.test.ts +115 -0
  77. package/src/i18n/context.tsx +2 -2
  78. package/src/i18n/detect.ts +85 -1
  79. package/src/i18n/i18n.ts +1 -1
  80. package/src/i18n/index.ts +2 -1
  81. package/src/i18n/locales/en.po +783 -1087
  82. package/src/i18n/locales/en.ts +1 -1
  83. package/src/i18n/locales/zh-Hans.po +867 -812
  84. package/src/i18n/locales/zh-Hans.ts +1 -1
  85. package/src/i18n/locales/zh-Hant.po +878 -823
  86. package/src/i18n/locales/zh-Hant.ts +1 -1
  87. package/src/i18n/middleware.ts +6 -0
  88. package/src/index.ts +5 -7
  89. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  90. package/src/lib/__tests__/constants.test.ts +0 -1
  91. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  92. package/src/lib/__tests__/nanoid.test.ts +26 -0
  93. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  94. package/src/lib/__tests__/schemas.test.ts +186 -65
  95. package/src/lib/__tests__/slug.test.ts +126 -0
  96. package/src/lib/__tests__/sse.test.ts +6 -6
  97. package/src/lib/__tests__/summary.test.ts +264 -0
  98. package/src/lib/__tests__/theme.test.ts +1 -1
  99. package/src/lib/__tests__/timeline.test.ts +33 -30
  100. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  101. package/src/lib/__tests__/url.test.ts +2 -2
  102. package/src/lib/__tests__/view.test.ts +140 -65
  103. package/src/lib/blurhash-placeholder.ts +102 -0
  104. package/src/lib/constants.ts +3 -1
  105. package/src/lib/emoji-catalog.ts +963 -0
  106. package/src/lib/errors.ts +11 -8
  107. package/src/lib/feed.ts +77 -31
  108. package/src/lib/html.ts +2 -1
  109. package/src/lib/icon-catalog.ts +5033 -1
  110. package/src/lib/icons.ts +3 -2
  111. package/src/lib/index.ts +0 -1
  112. package/src/lib/markdown-to-tiptap.ts +286 -0
  113. package/src/lib/media-helpers.ts +22 -12
  114. package/src/lib/nanoid.ts +29 -0
  115. package/src/lib/navigation.ts +1 -1
  116. package/src/lib/render.tsx +24 -5
  117. package/src/lib/resolve-config.ts +13 -2
  118. package/src/lib/schemas.ts +226 -58
  119. package/src/lib/search-snippet.ts +34 -0
  120. package/src/lib/slug.ts +96 -0
  121. package/src/lib/sse.ts +6 -6
  122. package/src/lib/storage.ts +115 -7
  123. package/src/lib/summary.ts +158 -0
  124. package/src/lib/theme.ts +11 -8
  125. package/src/lib/timeline.ts +76 -34
  126. package/src/lib/tiptap-render.ts +191 -0
  127. package/src/lib/tiptap-to-markdown.ts +305 -0
  128. package/src/lib/upload.ts +263 -14
  129. package/src/lib/url.ts +37 -22
  130. package/src/lib/view.ts +236 -55
  131. package/src/middleware/__tests__/auth.test.ts +191 -11
  132. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  133. package/src/middleware/auth.ts +63 -9
  134. package/src/middleware/error-handler.ts +3 -3
  135. package/src/middleware/onboarding.ts +1 -1
  136. package/src/middleware/secure-headers.ts +40 -0
  137. package/src/preset.css +83 -2
  138. package/src/routes/__tests__/compose.test.ts +17 -24
  139. package/src/routes/api/__tests__/collections.test.ts +109 -61
  140. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  141. package/src/routes/api/__tests__/posts.test.ts +132 -68
  142. package/src/routes/api/__tests__/search.test.ts +15 -2
  143. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  144. package/src/routes/api/collections.ts +57 -31
  145. package/src/routes/api/custom-urls.ts +80 -0
  146. package/src/routes/api/export.ts +31 -0
  147. package/src/routes/api/nav-items.ts +23 -19
  148. package/src/routes/api/posts.ts +81 -62
  149. package/src/routes/api/search.ts +3 -4
  150. package/src/routes/api/upload-multipart.ts +245 -0
  151. package/src/routes/api/upload.ts +92 -24
  152. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  153. package/src/routes/auth/reset.tsx +5 -4
  154. package/src/routes/auth/setup.tsx +39 -31
  155. package/src/routes/auth/signin.tsx +13 -14
  156. package/src/routes/compose.tsx +27 -63
  157. package/src/routes/dash/__tests__/settings-avatar.test.ts +44 -9
  158. package/src/routes/dash/custom-urls.tsx +414 -0
  159. package/src/routes/dash/settings.tsx +475 -99
  160. package/src/routes/feed/__tests__/rss.test.ts +22 -23
  161. package/src/routes/feed/rss.ts +6 -2
  162. package/src/routes/feed/sitemap.ts +2 -12
  163. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  164. package/src/routes/pages/__tests__/featured.test.ts +36 -18
  165. package/src/routes/pages/archive.tsx +177 -37
  166. package/src/routes/pages/collection.tsx +43 -14
  167. package/src/routes/pages/collections.tsx +11 -2
  168. package/src/routes/pages/featured.tsx +27 -3
  169. package/src/routes/pages/home.tsx +15 -14
  170. package/src/routes/pages/latest.tsx +1 -11
  171. package/src/routes/pages/new.tsx +39 -0
  172. package/src/routes/pages/page.tsx +95 -49
  173. package/src/routes/pages/search.tsx +1 -1
  174. package/src/services/__tests__/api-token.test.ts +135 -0
  175. package/src/services/__tests__/collection.test.ts +275 -227
  176. package/src/services/__tests__/custom-url.test.ts +213 -0
  177. package/src/services/__tests__/media.test.ts +162 -22
  178. package/src/services/__tests__/navigation.test.ts +109 -68
  179. package/src/services/__tests__/post-timeline.test.ts +205 -32
  180. package/src/services/__tests__/post.test.ts +800 -230
  181. package/src/services/__tests__/search.test.ts +67 -10
  182. package/src/services/__tests__/settings.test.ts +3 -3
  183. package/src/services/api-token.ts +166 -0
  184. package/src/services/auth.ts +17 -2
  185. package/src/services/collection.ts +397 -131
  186. package/src/services/custom-url.ts +188 -0
  187. package/src/services/export.ts +802 -0
  188. package/src/services/index.ts +26 -19
  189. package/src/services/media.ts +100 -22
  190. package/src/services/navigation.ts +158 -47
  191. package/src/services/path.ts +339 -0
  192. package/src/services/post.ts +764 -172
  193. package/src/services/search.ts +161 -74
  194. package/src/services/settings.ts +6 -2
  195. package/src/styles/components.css +293 -62
  196. package/src/styles/tokens.css +93 -5
  197. package/src/styles/ui.css +4349 -766
  198. package/src/types/bindings.ts +8 -0
  199. package/src/types/config.ts +34 -4
  200. package/src/types/constants.ts +17 -2
  201. package/src/types/entities.ts +83 -37
  202. package/src/types/operations.ts +20 -27
  203. package/src/types/props.ts +52 -17
  204. package/src/types/views.ts +48 -24
  205. package/src/ui/color-themes.ts +133 -23
  206. package/src/ui/compose/ComposeDialog.tsx +255 -16
  207. package/src/ui/compose/ComposePrompt.tsx +1 -1
  208. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  209. package/src/ui/dash/ListItemRow.tsx +1 -1
  210. package/src/ui/dash/StatusBadge.tsx +12 -2
  211. package/src/ui/dash/appearance/AdvancedContent.tsx +71 -59
  212. package/src/ui/dash/appearance/ColorThemeContent.tsx +48 -33
  213. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  214. package/src/ui/dash/appearance/NavigationContent.tsx +106 -135
  215. package/src/ui/dash/index.ts +0 -3
  216. package/src/ui/dash/settings/AccountContent.tsx +87 -146
  217. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  218. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  219. package/src/ui/dash/settings/AvatarContent.tsx +78 -0
  220. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  221. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  222. package/src/ui/dash/settings/SettingsRootContent.tsx +266 -0
  223. package/src/ui/feed/LinkCard.tsx +89 -40
  224. package/src/ui/feed/NoteCard.tsx +39 -25
  225. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  226. package/src/ui/feed/QuoteCard.tsx +38 -23
  227. package/src/ui/feed/ThreadPreview.tsx +90 -26
  228. package/src/ui/feed/TimelineFeed.tsx +3 -2
  229. package/src/ui/feed/TimelineItem.tsx +15 -6
  230. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  231. package/src/ui/feed/thread-preview-state.ts +61 -0
  232. package/src/ui/font-themes.ts +2 -2
  233. package/src/ui/layouts/BaseLayout.tsx +2 -2
  234. package/src/ui/layouts/SiteLayout.tsx +116 -103
  235. package/src/ui/pages/ArchivePage.tsx +923 -95
  236. package/src/ui/pages/CollectionPage.tsx +6 -35
  237. package/src/ui/pages/CollectionsPage.tsx +2 -1
  238. package/src/ui/pages/ComposePage.tsx +54 -0
  239. package/src/ui/pages/FeaturedPage.tsx +2 -1
  240. package/src/ui/pages/HomePage.tsx +1 -1
  241. package/src/ui/pages/PostPage.tsx +30 -45
  242. package/src/ui/pages/SearchPage.tsx +182 -38
  243. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  244. package/src/ui/shared/CollectionsSidebar.tsx +239 -4
  245. package/src/ui/shared/MediaGallery.tsx +475 -41
  246. package/src/ui/shared/PostFooter.tsx +204 -0
  247. package/src/ui/shared/StarRating.tsx +27 -0
  248. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  249. package/src/ui/shared/index.ts +0 -1
  250. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  251. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  252. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  253. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  254. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  255. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  256. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  257. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  258. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  259. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  260. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  261. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  262. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  263. package/src/lib/__tests__/sqid.test.ts +0 -65
  264. package/src/lib/collections-reorder.ts +0 -28
  265. package/src/lib/compose-bridge.ts +0 -280
  266. package/src/lib/media-upload.ts +0 -148
  267. package/src/lib/sqid.ts +0 -79
  268. package/src/routes/api/__tests__/pages.test.ts +0 -218
  269. package/src/routes/api/pages.ts +0 -73
  270. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  271. package/src/routes/dash/appearance.tsx +0 -240
  272. package/src/routes/dash/collections.tsx +0 -211
  273. package/src/routes/dash/index.tsx +0 -103
  274. package/src/routes/dash/media.tsx +0 -132
  275. package/src/routes/dash/pages.tsx +0 -239
  276. package/src/routes/dash/posts.tsx +0 -334
  277. package/src/routes/dash/redirects.tsx +0 -257
  278. package/src/routes/pages/post.tsx +0 -59
  279. package/src/services/__tests__/page.test.ts +0 -298
  280. package/src/services/__tests__/path-registry.test.ts +0 -165
  281. package/src/services/__tests__/redirect.test.ts +0 -159
  282. package/src/services/page.ts +0 -203
  283. package/src/services/path-registry.ts +0 -160
  284. package/src/services/redirect.ts +0 -97
  285. package/src/types/sortablejs.d.ts +0 -29
  286. package/src/ui/components/__tests__/jant-compose-dialog.test.ts +0 -512
  287. package/src/ui/components/__tests__/jant-compose-editor.test.ts +0 -272
  288. package/src/ui/components/compose-types.ts +0 -75
  289. package/src/ui/components/jant-collection-form.ts +0 -512
  290. package/src/ui/components/jant-compose-dialog.ts +0 -495
  291. package/src/ui/components/jant-compose-editor.ts +0 -814
  292. package/src/ui/dash/PageForm.tsx +0 -185
  293. package/src/ui/dash/PostList.tsx +0 -95
  294. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  295. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  296. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  297. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  298. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  299. package/src/ui/dash/media/MediaListContent.tsx +0 -201
  300. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  301. package/src/ui/dash/pages/PagesContent.tsx +0 -74
  302. package/src/ui/dash/posts/PostForm.tsx +0 -248
  303. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  304. package/src/ui/layouts/DashLayout.tsx +0 -165
  305. package/src/ui/pages/SinglePage.tsx +0 -23
  306. package/src/ui/shared/ThreadView.tsx +0 -136
  307. /package/src/{ui → client}/components/settings-types.ts +0 -0
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Tiptap JSON → HTML Renderer
3
+ *
4
+ * Lightweight server-side renderer that converts Tiptap JSON documents
5
+ * to HTML strings. Pure string concatenation — no DOM required.
6
+ * Works on Cloudflare Workers and any JS runtime.
7
+ */
8
+
9
+ import { escapeHtml } from "./html.js";
10
+ import { sanitizeUrl } from "./url.js";
11
+
12
+ interface TiptapMark {
13
+ type: string;
14
+ attrs?: Record<string, unknown>;
15
+ }
16
+
17
+ interface TiptapNode {
18
+ type: string;
19
+ content?: TiptapNode[];
20
+ text?: string;
21
+ marks?: TiptapMark[];
22
+ attrs?: Record<string, unknown>;
23
+ }
24
+
25
+ function renderMarks(text: string, marks: TiptapMark[]): string {
26
+ let result = escapeHtml(text);
27
+
28
+ for (const mark of marks) {
29
+ switch (mark.type) {
30
+ case "bold":
31
+ result = `<strong>${result}</strong>`;
32
+ break;
33
+ case "italic":
34
+ result = `<em>${result}</em>`;
35
+ break;
36
+ case "strike":
37
+ result = `<s>${result}</s>`;
38
+ break;
39
+ case "code":
40
+ result = `<code>${result}</code>`;
41
+ break;
42
+ case "link": {
43
+ const href = escapeHtml(sanitizeUrl(String(mark.attrs?.href ?? "")));
44
+ const target = mark.attrs?.target
45
+ ? ` target="${escapeHtml(String(mark.attrs.target))}"`
46
+ : "";
47
+ const rel = mark.attrs?.target
48
+ ? ' rel="noopener noreferrer nofollow"'
49
+ : "";
50
+ result = `<a href="${href}"${target}${rel}>${result}</a>`;
51
+ break;
52
+ }
53
+ }
54
+ }
55
+
56
+ return result;
57
+ }
58
+
59
+ function renderNode(node: TiptapNode): string {
60
+ switch (node.type) {
61
+ case "doc":
62
+ return (node.content ?? []).map(renderNode).join("");
63
+
64
+ case "paragraph":
65
+ return `<p>${renderChildren(node)}</p>`;
66
+
67
+ case "heading": {
68
+ const level = Math.min(Math.max(Number(node.attrs?.level ?? 1), 1), 6);
69
+ return `<h${level}>${renderChildren(node)}</h${level}>`;
70
+ }
71
+
72
+ case "text":
73
+ if (node.marks && node.marks.length > 0) {
74
+ return renderMarks(node.text ?? "", node.marks);
75
+ }
76
+ return escapeHtml(node.text ?? "");
77
+
78
+ case "bulletList":
79
+ return `<ul>${renderChildren(node)}</ul>`;
80
+
81
+ case "orderedList": {
82
+ const start = node.attrs?.start;
83
+ const startAttr = start && start !== 1 ? ` start="${start}"` : "";
84
+ return `<ol${startAttr}>${renderChildren(node)}</ol>`;
85
+ }
86
+
87
+ case "listItem":
88
+ return `<li>${renderChildren(node)}</li>`;
89
+
90
+ case "blockquote":
91
+ return `<blockquote>${renderChildren(node)}</blockquote>`;
92
+
93
+ case "codeBlock": {
94
+ const lang = node.attrs?.language;
95
+ const langClass = lang
96
+ ? ` class="language-${escapeHtml(String(lang))}"`
97
+ : "";
98
+ return `<pre><code${langClass}>${renderChildren(node)}</code></pre>`;
99
+ }
100
+
101
+ case "table":
102
+ return `<table>${renderChildren(node)}</table>`;
103
+
104
+ case "tableRow":
105
+ return `<tr>${renderChildren(node)}</tr>`;
106
+
107
+ case "tableCell": {
108
+ const colspan = node.attrs?.colspan;
109
+ const rowspan = node.attrs?.rowspan;
110
+ const colspanAttr =
111
+ colspan && colspan !== 1 ? ` colspan="${colspan}"` : "";
112
+ const rowspanAttr =
113
+ rowspan && rowspan !== 1 ? ` rowspan="${rowspan}"` : "";
114
+ return `<td${colspanAttr}${rowspanAttr}>${renderChildren(node)}</td>`;
115
+ }
116
+
117
+ case "tableHeader": {
118
+ const thColspan = node.attrs?.colspan;
119
+ const thRowspan = node.attrs?.rowspan;
120
+ const thColspanAttr =
121
+ thColspan && thColspan !== 1 ? ` colspan="${thColspan}"` : "";
122
+ const thRowspanAttr =
123
+ thRowspan && thRowspan !== 1 ? ` rowspan="${thRowspan}"` : "";
124
+ return `<th${thColspanAttr}${thRowspanAttr}>${renderChildren(node)}</th>`;
125
+ }
126
+
127
+ case "horizontalRule":
128
+ return "<hr>";
129
+
130
+ case "hardBreak":
131
+ return "<br>";
132
+
133
+ case "image": {
134
+ const src = escapeHtml(String(node.attrs?.src ?? ""));
135
+ const alt = node.attrs?.alt
136
+ ? ` alt="${escapeHtml(String(node.attrs.alt))}"`
137
+ : "";
138
+ const title = node.attrs?.title
139
+ ? ` title="${escapeHtml(String(node.attrs.title))}"`
140
+ : "";
141
+ const caption = node.attrs?.caption ? String(node.attrs.caption) : "";
142
+ const layout = node.attrs?.layout ?? "regular";
143
+ const href = node.attrs?.href ? sanitizeUrl(String(node.attrs.href)) : "";
144
+ const layoutAttr =
145
+ layout !== "regular"
146
+ ? ` data-layout="${escapeHtml(String(layout))}"`
147
+ : "";
148
+ const imgTag = `<img src="${src}"${alt}${title}>`;
149
+ const linkedImg = href
150
+ ? `<a href="${escapeHtml(href)}">${imgTag}</a>`
151
+ : imgTag;
152
+ const figcaption = caption
153
+ ? `<figcaption>${escapeHtml(caption)}</figcaption>`
154
+ : "";
155
+ return `<figure${layoutAttr}>${linkedImg}${figcaption}</figure>`;
156
+ }
157
+
158
+ case "moreBreak":
159
+ return "<!--more-->";
160
+
161
+ default:
162
+ // Unknown node: render children if any, skip otherwise
163
+ return node.content ? renderChildren(node) : "";
164
+ }
165
+ }
166
+
167
+ function renderChildren(node: TiptapNode): string {
168
+ return (node.content ?? []).map(renderNode).join("");
169
+ }
170
+
171
+ /**
172
+ * Renders a Tiptap JSON document to an HTML string.
173
+ *
174
+ * @param json - Tiptap JSON string or parsed document object
175
+ * @returns HTML string
176
+ *
177
+ * @example
178
+ * ```ts
179
+ * const html = renderTiptapJson('{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}]}');
180
+ * // "<p>Hello</p>"
181
+ * ```
182
+ */
183
+ export function renderTiptapJson(json: string): string {
184
+ try {
185
+ const doc = JSON.parse(json) as TiptapNode;
186
+ if (doc.type !== "doc") return "";
187
+ return renderNode(doc);
188
+ } catch {
189
+ return "";
190
+ }
191
+ }
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Tiptap JSON → Markdown Converter
3
+ *
4
+ * Server-side converter that transforms Tiptap JSON documents to Markdown strings.
5
+ * Pure string concatenation — no DOM required. Mirrors the node types
6
+ * supported by `tiptap-render.ts`.
7
+ */
8
+
9
+ interface TiptapMark {
10
+ type: string;
11
+ attrs?: Record<string, unknown>;
12
+ }
13
+
14
+ interface TiptapNode {
15
+ type: string;
16
+ content?: TiptapNode[];
17
+ text?: string;
18
+ marks?: TiptapMark[];
19
+ attrs?: Record<string, unknown>;
20
+ }
21
+
22
+ /**
23
+ * Converts a Tiptap JSON document to a Markdown string.
24
+ *
25
+ * @param json - Tiptap JSON string or parsed document object
26
+ * @returns Markdown string
27
+ *
28
+ * @example
29
+ * ```ts
30
+ * const md = tiptapJsonToMarkdown('{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}]}');
31
+ * // "Hello"
32
+ * ```
33
+ */
34
+ export function tiptapJsonToMarkdown(json: string): string {
35
+ try {
36
+ const doc = JSON.parse(json) as TiptapNode;
37
+ if (doc.type !== "doc") return "";
38
+ return renderBlocks(doc.content ?? []).trimEnd();
39
+ } catch {
40
+ return "";
41
+ }
42
+ }
43
+
44
+ function renderBlocks(nodes: TiptapNode[], indent = ""): string {
45
+ const parts: string[] = [];
46
+
47
+ for (const node of nodes) {
48
+ const rendered = renderBlockNode(node, indent);
49
+ if (rendered !== null) {
50
+ parts.push(rendered);
51
+ }
52
+ }
53
+
54
+ return parts.join("\n\n");
55
+ }
56
+
57
+ function renderBlockNode(node: TiptapNode, indent: string): string | null {
58
+ switch (node.type) {
59
+ case "paragraph": {
60
+ const text = renderInline(node.content ?? []);
61
+ return indent + text;
62
+ }
63
+
64
+ case "heading": {
65
+ const level = Math.min(Math.max(Number(node.attrs?.level ?? 1), 1), 6);
66
+ const prefix = "#".repeat(level);
67
+ const text = renderInline(node.content ?? []);
68
+ return `${indent}${prefix} ${text}`;
69
+ }
70
+
71
+ case "bulletList":
72
+ return renderList(node.content ?? [], indent, "bullet");
73
+
74
+ case "orderedList": {
75
+ const start = Number(node.attrs?.start ?? 1);
76
+ return renderList(node.content ?? [], indent, "ordered", start);
77
+ }
78
+
79
+ case "blockquote": {
80
+ const inner = renderBlocks(node.content ?? []);
81
+ return inner
82
+ .split("\n")
83
+ .map((line) => indent + (line ? `> ${line}` : ">"))
84
+ .join("\n");
85
+ }
86
+
87
+ case "codeBlock": {
88
+ const lang = node.attrs?.language ? String(node.attrs.language) : "";
89
+ const content = getPlainText(node.content ?? []);
90
+ const fence = chooseFence(content);
91
+ return `${indent}${fence}${lang}\n${content}\n${indent}${fence}`;
92
+ }
93
+
94
+ case "table":
95
+ return renderTable(node.content ?? [], indent);
96
+
97
+ case "horizontalRule":
98
+ return `${indent}---`;
99
+
100
+ case "hardBreak":
101
+ return null;
102
+
103
+ case "image": {
104
+ const src = String(node.attrs?.src ?? "");
105
+ const alt = node.attrs?.alt ? String(node.attrs.alt) : "";
106
+ const title = node.attrs?.title ? String(node.attrs.title) : "";
107
+ const titlePart = title ? ` "${title}"` : "";
108
+ return `${indent}![${alt}](${src}${titlePart})`;
109
+ }
110
+
111
+ case "moreBreak":
112
+ return `${indent}<!--more-->`;
113
+
114
+ default:
115
+ if (node.content) {
116
+ return renderBlocks(node.content, indent);
117
+ }
118
+ return null;
119
+ }
120
+ }
121
+
122
+ function renderList(
123
+ items: TiptapNode[],
124
+ indent: string,
125
+ type: "bullet" | "ordered",
126
+ start = 1,
127
+ ): string {
128
+ const lines: string[] = [];
129
+
130
+ for (let i = 0; i < items.length; i++) {
131
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index-bounded loop
132
+ const item = items[i]!;
133
+ const marker = type === "bullet" ? "-" : `${(start + i).toString()}.`;
134
+ const children = item.content ?? [];
135
+
136
+ for (let j = 0; j < children.length; j++) {
137
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index-bounded loop
138
+ const child = children[j]!;
139
+ if (j === 0) {
140
+ // First child gets the list marker
141
+ if (child.type === "bulletList" || child.type === "orderedList") {
142
+ // Nested list as first child — render with increased indent
143
+ const nested = renderBlockNode(child, indent + " ");
144
+ if (nested !== null) {
145
+ lines.push(`${indent}${marker} \n${nested}`);
146
+ }
147
+ } else {
148
+ const text = renderInline(child.content ?? []);
149
+ lines.push(`${indent}${marker} ${text}`);
150
+ }
151
+ } else {
152
+ // Subsequent children: indent to align with first line content
153
+ const childIndent = indent + " ".repeat(marker.length + 1);
154
+ if (child.type === "bulletList" || child.type === "orderedList") {
155
+ const nested = renderBlockNode(child, childIndent);
156
+ if (nested !== null) lines.push(nested);
157
+ } else if (child.type === "paragraph") {
158
+ const text = renderInline(child.content ?? []);
159
+ lines.push("");
160
+ lines.push(`${childIndent}${text}`);
161
+ } else {
162
+ const rendered = renderBlockNode(child, childIndent);
163
+ if (rendered !== null) {
164
+ lines.push("");
165
+ lines.push(rendered);
166
+ }
167
+ }
168
+ }
169
+ }
170
+ }
171
+
172
+ return lines.join("\n");
173
+ }
174
+
175
+ function renderTable(rows: TiptapNode[], indent: string): string {
176
+ if (rows.length === 0) return "";
177
+
178
+ const matrix: string[][] = [];
179
+
180
+ for (const row of rows) {
181
+ const cells: string[] = [];
182
+ for (const cell of row.content ?? []) {
183
+ // Each cell may contain paragraphs — render inline content
184
+ const parts: string[] = [];
185
+ for (const child of cell.content ?? []) {
186
+ parts.push(renderInline(child.content ?? []));
187
+ }
188
+ cells.push(parts.join(" "));
189
+ }
190
+ matrix.push(cells);
191
+ }
192
+
193
+ // Calculate column widths
194
+ const colCount = Math.max(...matrix.map((r) => r.length));
195
+ const widths: number[] = [];
196
+ for (let c = 0; c < colCount; c++) {
197
+ widths.push(Math.max(3, ...matrix.map((r) => (r[c] ?? "").length)));
198
+ }
199
+
200
+ const lines: string[] = [];
201
+
202
+ // Header row
203
+ const headerRow = matrix[0] ?? [];
204
+ lines.push(
205
+ indent +
206
+ "| " +
207
+ widths.map((w, i) => (headerRow[i] ?? "").padEnd(w)).join(" | ") +
208
+ " |",
209
+ );
210
+
211
+ // Separator row (first row is always the header)
212
+ lines.push(
213
+ indent + "| " + widths.map((w) => "-".repeat(w)).join(" | ") + " |",
214
+ );
215
+
216
+ // Body rows
217
+ for (let r = 1; r < matrix.length; r++) {
218
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- index-bounded loop
219
+ const row = matrix[r]!;
220
+ lines.push(
221
+ indent +
222
+ "| " +
223
+ widths.map((w, i) => (row[i] ?? "").padEnd(w)).join(" | ") +
224
+ " |",
225
+ );
226
+ }
227
+
228
+ return lines.join("\n");
229
+ }
230
+
231
+ function renderInline(nodes: TiptapNode[]): string {
232
+ return nodes.map(renderInlineNode).join("");
233
+ }
234
+
235
+ function renderInlineNode(node: TiptapNode): string {
236
+ switch (node.type) {
237
+ case "text": {
238
+ let text = node.text ?? "";
239
+ if (node.marks && node.marks.length > 0) {
240
+ text = applyMarks(text, node.marks);
241
+ }
242
+ return text;
243
+ }
244
+
245
+ case "hardBreak":
246
+ return " \n";
247
+
248
+ case "image": {
249
+ const src = String(node.attrs?.src ?? "");
250
+ const alt = node.attrs?.alt ? String(node.attrs.alt) : "";
251
+ const title = node.attrs?.title ? String(node.attrs.title) : "";
252
+ const titlePart = title ? ` "${title}"` : "";
253
+ return `![${alt}](${src}${titlePart})`;
254
+ }
255
+
256
+ default:
257
+ if (node.content) return renderInline(node.content);
258
+ return "";
259
+ }
260
+ }
261
+
262
+ function applyMarks(text: string, marks: TiptapMark[]): string {
263
+ let result = text;
264
+
265
+ for (const mark of marks) {
266
+ switch (mark.type) {
267
+ case "bold":
268
+ result = `**${result}**`;
269
+ break;
270
+ case "italic":
271
+ result = `*${result}*`;
272
+ break;
273
+ case "strike":
274
+ result = `~~${result}~~`;
275
+ break;
276
+ case "code":
277
+ result = `\`${result}\``;
278
+ break;
279
+ case "link": {
280
+ const href = String(mark.attrs?.href ?? "");
281
+ result = `[${result}](${href})`;
282
+ break;
283
+ }
284
+ }
285
+ }
286
+
287
+ return result;
288
+ }
289
+
290
+ function getPlainText(nodes: TiptapNode[]): string {
291
+ return nodes.map((n) => n.text ?? "").join("");
292
+ }
293
+
294
+ function chooseFence(content: string): string {
295
+ let count = 3;
296
+ const regex = /(`{3,})/g;
297
+ let match;
298
+ while ((match = regex.exec(content)) !== null) {
299
+ const backticks = match[1] ?? "";
300
+ if (backticks.length >= count) {
301
+ count = backticks.length + 1;
302
+ }
303
+ }
304
+ return "`".repeat(count);
305
+ }