@jant/core 0.3.36 → 0.3.38

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 (271) 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/url-FWFqPJPb.js +1 -0
  6. package/dist/client/client.css +1 -1
  7. package/dist/client/client.js +4012 -3276
  8. package/dist/index.js +10285 -5809
  9. package/package.json +11 -3
  10. package/src/__tests__/helpers/app.ts +9 -9
  11. package/src/__tests__/helpers/db.ts +91 -93
  12. package/src/app.tsx +157 -27
  13. package/src/auth.ts +20 -2
  14. package/src/client/archive-nav.js +187 -0
  15. package/src/client/audio-player.ts +478 -0
  16. package/src/client/audio-processor.ts +84 -0
  17. package/src/client/avatar-upload.ts +3 -2
  18. package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
  19. package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
  20. package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
  21. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
  22. package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
  23. package/src/client/components/collection-sidebar-types.ts +7 -9
  24. package/src/client/components/compose-types.ts +101 -4
  25. package/src/client/components/jant-collection-form.ts +43 -7
  26. package/src/client/components/jant-collection-sidebar.ts +88 -84
  27. package/src/client/components/jant-compose-dialog.ts +1655 -219
  28. package/src/client/components/jant-compose-editor.ts +732 -168
  29. package/src/client/components/jant-compose-fullscreen.ts +23 -78
  30. package/src/client/components/jant-media-lightbox.ts +2 -0
  31. package/src/client/components/jant-nav-manager.ts +24 -284
  32. package/src/client/components/jant-post-form.ts +89 -9
  33. package/src/client/components/jant-post-menu.ts +1019 -0
  34. package/src/client/components/jant-settings-avatar.ts +3 -3
  35. package/src/client/components/jant-settings-general.ts +38 -4
  36. package/src/client/components/jant-text-preview.ts +232 -0
  37. package/src/client/components/nav-manager-types.ts +4 -19
  38. package/src/client/components/post-form-template.ts +107 -12
  39. package/src/client/components/post-form-types.ts +11 -4
  40. package/src/client/compose-bridge.ts +410 -109
  41. package/src/client/image-processor.ts +26 -8
  42. package/src/client/media-metadata.ts +247 -0
  43. package/src/client/multipart-upload.ts +160 -0
  44. package/src/client/post-form-bridge.ts +52 -1
  45. package/src/client/settings-bridge.ts +0 -12
  46. package/src/client/thread-context.ts +140 -0
  47. package/src/client/tiptap/create-editor.ts +46 -0
  48. package/src/client/tiptap/extensions.ts +5 -0
  49. package/src/client/tiptap/image-node.ts +2 -8
  50. package/src/client/tiptap/paste-image.ts +2 -13
  51. package/src/client/tiptap/slash-commands.ts +173 -63
  52. package/src/client/toast.ts +101 -3
  53. package/src/client/types/sortablejs.d.ts +15 -0
  54. package/src/client/upload-with-metadata.ts +54 -0
  55. package/src/client/video-processor.ts +207 -0
  56. package/src/client.ts +5 -2
  57. package/src/db/__tests__/migrations.test.ts +118 -0
  58. package/src/db/index.ts +52 -0
  59. package/src/db/migrations/0000_baseline.sql +269 -0
  60. package/src/db/migrations/0001_fts_setup.sql +31 -0
  61. package/src/db/migrations/meta/0000_snapshot.json +703 -119
  62. package/src/db/migrations/meta/0001_snapshot.json +1337 -0
  63. package/src/db/migrations/meta/_journal.json +4 -39
  64. package/src/db/schema.ts +409 -145
  65. package/src/i18n/__tests__/detect.test.ts +115 -0
  66. package/src/i18n/context.tsx +2 -2
  67. package/src/i18n/detect.ts +85 -1
  68. package/src/i18n/i18n.ts +1 -1
  69. package/src/i18n/index.ts +2 -1
  70. package/src/i18n/locales/en.po +487 -1217
  71. package/src/i18n/locales/en.ts +1 -1
  72. package/src/i18n/locales/zh-Hans.po +613 -996
  73. package/src/i18n/locales/zh-Hans.ts +1 -1
  74. package/src/i18n/locales/zh-Hant.po +624 -1007
  75. package/src/i18n/locales/zh-Hant.ts +1 -1
  76. package/src/i18n/middleware.ts +6 -0
  77. package/src/index.ts +5 -7
  78. package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
  79. package/src/lib/__tests__/constants.test.ts +0 -1
  80. package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
  81. package/src/lib/__tests__/nanoid.test.ts +26 -0
  82. package/src/lib/__tests__/schemas.test.ts +181 -63
  83. package/src/lib/__tests__/slug.test.ts +126 -0
  84. package/src/lib/__tests__/sse.test.ts +6 -6
  85. package/src/lib/__tests__/summary.test.ts +264 -0
  86. package/src/lib/__tests__/theme.test.ts +1 -1
  87. package/src/lib/__tests__/timeline.test.ts +33 -30
  88. package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
  89. package/src/lib/__tests__/view.test.ts +141 -66
  90. package/src/lib/blurhash-placeholder.ts +102 -0
  91. package/src/lib/constants.ts +3 -1
  92. package/src/lib/emoji-catalog.ts +885 -68
  93. package/src/lib/errors.ts +11 -8
  94. package/src/lib/feed.ts +78 -32
  95. package/src/lib/html.ts +2 -1
  96. package/src/lib/icon-catalog.ts +5033 -1
  97. package/src/lib/icons.ts +3 -2
  98. package/src/lib/index.ts +0 -1
  99. package/src/lib/markdown-to-tiptap.ts +286 -0
  100. package/src/lib/media-helpers.ts +12 -3
  101. package/src/lib/nanoid.ts +29 -0
  102. package/src/lib/navigation.ts +1 -1
  103. package/src/lib/render.tsx +20 -2
  104. package/src/lib/resolve-config.ts +6 -2
  105. package/src/lib/schemas.ts +224 -55
  106. package/src/lib/search-snippet.ts +34 -0
  107. package/src/lib/slug.ts +96 -0
  108. package/src/lib/sse.ts +6 -6
  109. package/src/lib/storage.ts +115 -7
  110. package/src/lib/summary.ts +66 -0
  111. package/src/lib/theme.ts +11 -8
  112. package/src/lib/timeline.ts +74 -34
  113. package/src/lib/tiptap-render.ts +5 -10
  114. package/src/lib/tiptap-to-markdown.ts +305 -0
  115. package/src/lib/upload.ts +190 -29
  116. package/src/lib/url.ts +31 -0
  117. package/src/lib/view.ts +204 -37
  118. package/src/middleware/__tests__/auth.test.ts +191 -11
  119. package/src/middleware/__tests__/onboarding.test.ts +12 -10
  120. package/src/middleware/auth.ts +63 -9
  121. package/src/middleware/onboarding.ts +1 -1
  122. package/src/middleware/secure-headers.ts +40 -0
  123. package/src/preset.css +45 -2
  124. package/src/routes/__tests__/compose.test.ts +17 -24
  125. package/src/routes/api/__tests__/collections.test.ts +109 -61
  126. package/src/routes/api/__tests__/nav-items.test.ts +46 -29
  127. package/src/routes/api/__tests__/posts.test.ts +132 -68
  128. package/src/routes/api/__tests__/search.test.ts +15 -2
  129. package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
  130. package/src/routes/api/collections.ts +51 -42
  131. package/src/routes/api/custom-urls.ts +80 -0
  132. package/src/routes/api/export.ts +31 -0
  133. package/src/routes/api/nav-items.ts +23 -19
  134. package/src/routes/api/posts.ts +43 -39
  135. package/src/routes/api/search.ts +3 -4
  136. package/src/routes/api/upload-multipart.ts +245 -0
  137. package/src/routes/api/upload.ts +85 -19
  138. package/src/routes/auth/__tests__/setup.test.ts +20 -60
  139. package/src/routes/auth/setup.tsx +26 -33
  140. package/src/routes/auth/signin.tsx +3 -7
  141. package/src/routes/compose.tsx +10 -55
  142. package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
  143. package/src/routes/dash/custom-urls.tsx +414 -0
  144. package/src/routes/dash/settings.tsx +304 -232
  145. package/src/routes/feed/__tests__/rss.test.ts +27 -28
  146. package/src/routes/feed/rss.ts +6 -4
  147. package/src/routes/feed/sitemap.ts +2 -12
  148. package/src/routes/pages/__tests__/collections.test.ts +5 -6
  149. package/src/routes/pages/__tests__/featured.test.ts +41 -22
  150. package/src/routes/pages/archive.tsx +175 -39
  151. package/src/routes/pages/collection.tsx +22 -10
  152. package/src/routes/pages/collections.tsx +3 -3
  153. package/src/routes/pages/featured.tsx +28 -4
  154. package/src/routes/pages/home.tsx +16 -15
  155. package/src/routes/pages/latest.tsx +1 -11
  156. package/src/routes/pages/new.tsx +39 -0
  157. package/src/routes/pages/page.tsx +95 -49
  158. package/src/routes/pages/search.tsx +1 -1
  159. package/src/services/__tests__/api-token.test.ts +135 -0
  160. package/src/services/__tests__/collection.test.ts +275 -227
  161. package/src/services/__tests__/custom-url.test.ts +213 -0
  162. package/src/services/__tests__/media.test.ts +162 -22
  163. package/src/services/__tests__/navigation.test.ts +109 -68
  164. package/src/services/__tests__/post-timeline.test.ts +205 -32
  165. package/src/services/__tests__/post.test.ts +713 -234
  166. package/src/services/__tests__/search.test.ts +67 -10
  167. package/src/services/api-token.ts +166 -0
  168. package/src/services/auth.ts +17 -2
  169. package/src/services/collection.ts +397 -131
  170. package/src/services/custom-url.ts +188 -0
  171. package/src/services/export.ts +802 -0
  172. package/src/services/index.ts +26 -19
  173. package/src/services/media.ts +100 -22
  174. package/src/services/navigation.ts +158 -47
  175. package/src/services/path.ts +339 -0
  176. package/src/services/post.ts +687 -154
  177. package/src/services/search.ts +160 -75
  178. package/src/styles/components.css +58 -7
  179. package/src/styles/tokens.css +84 -6
  180. package/src/styles/ui.css +2964 -457
  181. package/src/types/bindings.ts +4 -1
  182. package/src/types/config.ts +12 -4
  183. package/src/types/constants.ts +15 -3
  184. package/src/types/entities.ts +74 -35
  185. package/src/types/operations.ts +11 -24
  186. package/src/types/props.ts +51 -16
  187. package/src/types/views.ts +45 -22
  188. package/src/ui/color-themes.ts +133 -23
  189. package/src/ui/compose/ComposeDialog.tsx +239 -17
  190. package/src/ui/compose/ComposePrompt.tsx +1 -1
  191. package/src/ui/dash/CrudPageHeader.tsx +1 -1
  192. package/src/ui/dash/ListItemRow.tsx +1 -1
  193. package/src/ui/dash/StatusBadge.tsx +3 -1
  194. package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
  195. package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
  196. package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
  197. package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
  198. package/src/ui/dash/index.ts +0 -3
  199. package/src/ui/dash/settings/AccountContent.tsx +3 -57
  200. package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
  201. package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
  202. package/src/ui/dash/settings/AvatarContent.tsx +8 -0
  203. package/src/ui/dash/settings/SessionsContent.tsx +159 -0
  204. package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
  205. package/src/ui/feed/LinkCard.tsx +89 -40
  206. package/src/ui/feed/NoteCard.tsx +39 -25
  207. package/src/ui/feed/PostStatusBadges.tsx +67 -0
  208. package/src/ui/feed/QuoteCard.tsx +38 -23
  209. package/src/ui/feed/ThreadPreview.tsx +90 -26
  210. package/src/ui/feed/TimelineFeed.tsx +3 -2
  211. package/src/ui/feed/TimelineItem.tsx +15 -6
  212. package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
  213. package/src/ui/feed/thread-preview-state.ts +61 -0
  214. package/src/ui/font-themes.ts +2 -2
  215. package/src/ui/layouts/BaseLayout.tsx +2 -2
  216. package/src/ui/layouts/SiteLayout.tsx +105 -92
  217. package/src/ui/pages/ArchivePage.tsx +923 -98
  218. package/src/ui/pages/ComposePage.tsx +54 -0
  219. package/src/ui/pages/PostPage.tsx +30 -45
  220. package/src/ui/pages/SearchPage.tsx +181 -37
  221. package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
  222. package/src/ui/shared/CollectionsSidebar.tsx +47 -37
  223. package/src/ui/shared/MediaGallery.tsx +445 -149
  224. package/src/ui/shared/PostFooter.tsx +204 -0
  225. package/src/ui/shared/StarRating.tsx +27 -0
  226. package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
  227. package/src/ui/shared/index.ts +0 -1
  228. package/dist/client/assets/url-8Dj-5CLW.js +0 -1
  229. package/src/client/media-upload.ts +0 -161
  230. package/src/client/page-slug-bridge.ts +0 -42
  231. package/src/db/migrations/0000_square_wallflower.sql +0 -118
  232. package/src/db/migrations/0001_add_search_fts.sql +0 -34
  233. package/src/db/migrations/0002_add_media_attachments.sql +0 -3
  234. package/src/db/migrations/0003_add_navigation_links.sql +0 -8
  235. package/src/db/migrations/0004_add_storage_provider.sql +0 -3
  236. package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
  237. package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
  238. package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
  239. package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
  240. package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
  241. package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
  242. package/src/db/migrations/0011_add_path_registry.sql +0 -23
  243. package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
  244. package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
  245. package/src/db/migrations/meta/0003_snapshot.json +0 -821
  246. package/src/lib/__tests__/sqid.test.ts +0 -65
  247. package/src/lib/sqid.ts +0 -79
  248. package/src/routes/api/__tests__/pages.test.ts +0 -218
  249. package/src/routes/api/pages.ts +0 -73
  250. package/src/routes/dash/__tests__/pages.test.ts +0 -226
  251. package/src/routes/dash/index.tsx +0 -109
  252. package/src/routes/dash/media.tsx +0 -135
  253. package/src/routes/dash/pages.tsx +0 -245
  254. package/src/routes/dash/posts.tsx +0 -338
  255. package/src/routes/dash/redirects.tsx +0 -263
  256. package/src/routes/pages/post.tsx +0 -59
  257. package/src/services/__tests__/page.test.ts +0 -298
  258. package/src/services/__tests__/path-registry.test.ts +0 -165
  259. package/src/services/__tests__/redirect.test.ts +0 -159
  260. package/src/services/page.ts +0 -216
  261. package/src/services/path-registry.ts +0 -160
  262. package/src/services/redirect.ts +0 -97
  263. package/src/ui/dash/PageForm.tsx +0 -187
  264. package/src/ui/dash/PostList.tsx +0 -95
  265. package/src/ui/dash/media/MediaListContent.tsx +0 -206
  266. package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
  267. package/src/ui/dash/pages/PagesContent.tsx +0 -75
  268. package/src/ui/dash/posts/PostForm.tsx +0 -260
  269. package/src/ui/layouts/DashLayout.tsx +0 -247
  270. package/src/ui/pages/SinglePage.tsx +0 -23
  271. package/src/ui/shared/ThreadView.tsx +0 -136
@@ -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
+ }
package/src/lib/upload.ts CHANGED
@@ -5,74 +5,219 @@
5
5
  */
6
6
 
7
7
  import { uuidv7 } from "uuidv7";
8
+ import type { MediaKind } from "../types/constants.js";
8
9
 
9
- /** MIME types allowed for upload — images */
10
+ /** MIME types — images */
10
11
  const IMAGE_MIME_TYPES = [
11
12
  "image/jpeg",
12
13
  "image/png",
13
14
  "image/gif",
14
15
  "image/webp",
15
16
  "image/svg+xml",
17
+ "image/avif",
18
+ "image/bmp",
19
+ "image/x-icon",
16
20
  ] as const;
17
21
 
18
- /** MIME types allowed for upload — video */
22
+ /** MIME types — video */
19
23
  const VIDEO_MIME_TYPES = [
20
24
  "video/mp4",
21
25
  "video/webm",
22
26
  "video/quicktime",
27
+ "video/x-msvideo",
28
+ "video/x-matroska",
29
+ "video/mpeg",
30
+ "video/3gpp",
31
+ "video/x-flv",
32
+ "video/ogg",
23
33
  ] as const;
24
34
 
25
- /** MIME types allowed for upload — audio */
35
+ /** MIME types — audio */
26
36
  const AUDIO_MIME_TYPES = [
27
37
  "audio/mpeg",
28
38
  "audio/ogg",
29
39
  "audio/wav",
30
40
  "audio/mp4",
31
41
  "audio/x-m4a",
42
+ "audio/flac",
43
+ "audio/aac",
44
+ "audio/webm",
45
+ "audio/x-aiff",
46
+ "audio/opus",
47
+ "audio/3gpp",
48
+ "audio/midi",
32
49
  ] as const;
33
50
 
34
- /** MIME types allowed for upload documents */
35
- const DOCUMENT_MIME_TYPES = ["application/pdf"] as const;
51
+ /** MIME types documents (books, PDFs) */
52
+ const DOCUMENT_MIME_TYPES = [
53
+ "application/pdf",
54
+ "application/epub+zip",
55
+ "application/x-mobipocket-ebook",
56
+ "application/vnd.amazon.ebook",
57
+ ] as const;
58
+
59
+ /** MIME types — office documents */
60
+ const OFFICE_MIME_TYPES = [
61
+ "application/msword",
62
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
63
+ "application/vnd.ms-excel",
64
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
65
+ "application/vnd.ms-powerpoint",
66
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
67
+ "application/vnd.oasis.opendocument.text",
68
+ "application/vnd.oasis.opendocument.spreadsheet",
69
+ "application/vnd.oasis.opendocument.presentation",
70
+ "application/vnd.apple.pages",
71
+ "application/vnd.apple.numbers",
72
+ "application/vnd.apple.keynote",
73
+ ] as const;
74
+
75
+ /** MIME types — text & structured data */
76
+ const TEXT_MIME_TYPES = [
77
+ "text/plain",
78
+ "text/markdown",
79
+ "text/csv",
80
+ "text/x-tiptap+json",
81
+ "text/html",
82
+ "text/css",
83
+ "text/javascript",
84
+ "text/xml",
85
+ "text/rtf",
86
+ "text/tab-separated-values",
87
+ "text/calendar",
88
+ "application/json",
89
+ "application/xml",
90
+ "application/yaml",
91
+ "application/toml",
92
+ ] as const;
36
93
 
37
- /** All allowed MIME types */
38
- const ALLOWED_UPLOAD_TYPES = [
39
- ...IMAGE_MIME_TYPES,
40
- ...VIDEO_MIME_TYPES,
41
- ...AUDIO_MIME_TYPES,
42
- ...DOCUMENT_MIME_TYPES,
94
+ /** MIME types — archives */
95
+ const ARCHIVE_MIME_TYPES = [
96
+ "application/zip",
97
+ "application/x-tar",
98
+ "application/gzip",
99
+ "application/x-bzip2",
100
+ "application/x-7z-compressed",
101
+ "application/x-rar-compressed",
102
+ "application/zstd",
43
103
  ] as const;
44
104
 
105
+ /** MIME types — fonts */
106
+ const FONT_MIME_TYPES = [
107
+ "font/ttf",
108
+ "font/otf",
109
+ "font/woff",
110
+ "font/woff2",
111
+ ] as const;
112
+
113
+ /** MIME types — 3D & design */
114
+ const THREE_D_MIME_TYPES = [
115
+ "model/gltf+json",
116
+ "model/gltf-binary",
117
+ "model/obj",
118
+ "application/x-figma",
119
+ "image/vnd.dxf",
120
+ ] as const;
121
+
122
+ /** MIME types — data & code */
123
+ const CODE_MIME_TYPES = [
124
+ "application/sql",
125
+ "application/wasm",
126
+ "application/x-ipynb+json",
127
+ "application/x-sh",
128
+ "application/x-python-code",
129
+ ] as const;
130
+
131
+ /** Lookup table from MIME type to category */
132
+ const MIME_CATEGORY_MAP = new Map<string, MediaCategory>([
133
+ ...IMAGE_MIME_TYPES.map((t) => [t, "image" as const] as const),
134
+ ...VIDEO_MIME_TYPES.map((t) => [t, "video" as const] as const),
135
+ ...AUDIO_MIME_TYPES.map((t) => [t, "audio" as const] as const),
136
+ ...DOCUMENT_MIME_TYPES.map((t) => [t, "document" as const] as const),
137
+ ...OFFICE_MIME_TYPES.map((t) => [t, "office" as const] as const),
138
+ ...TEXT_MIME_TYPES.map((t) => [t, "text" as const] as const),
139
+ ...ARCHIVE_MIME_TYPES.map((t) => [t, "archive" as const] as const),
140
+ ...FONT_MIME_TYPES.map((t) => [t, "font" as const] as const),
141
+ ...THREE_D_MIME_TYPES.map((t) => [t, "3d" as const] as const),
142
+ ...CODE_MIME_TYPES.map((t) => [t, "code" as const] as const),
143
+ ]);
144
+
45
145
  /**
46
- * Accept string for file inputs, covering all allowed upload types.
146
+ * Accept string for file inputs. Accepts all file types.
47
147
  *
48
148
  * @example
49
149
  * ```ts
50
150
  * <input type="file" accept={UPLOAD_ACCEPT} />
51
151
  * ```
52
152
  */
53
- export const UPLOAD_ACCEPT = (ALLOWED_UPLOAD_TYPES as readonly string[]).join(
54
- ",",
55
- );
153
+ export const UPLOAD_ACCEPT = "*/*";
56
154
 
57
- export type MediaCategory = "image" | "video" | "audio" | "document";
155
+ export type MediaCategory =
156
+ | "image"
157
+ | "video"
158
+ | "audio"
159
+ | "document"
160
+ | "office"
161
+ | "text"
162
+ | "archive"
163
+ | "font"
164
+ | "3d"
165
+ | "code";
58
166
 
59
167
  /**
60
168
  * Returns the media category for a given MIME type.
169
+ * Unrecognized types default to "archive".
61
170
  *
62
171
  * @param mimeType - The MIME type to classify
63
- * @returns The media category, or null if the MIME type is not supported
172
+ * @returns The media category
64
173
  * @example
65
174
  * ```ts
66
175
  * getMediaCategory("video/mp4"); // "video"
67
- * getMediaCategory("text/plain"); // null
176
+ * getMediaCategory("text/plain"); // "text"
177
+ * getMediaCategory("application/octet-stream"); // "archive"
68
178
  * ```
69
179
  */
70
- export function getMediaCategory(mimeType: string): MediaCategory | null {
180
+ export function getMediaCategory(mimeType: string): MediaCategory {
181
+ // Exact match from known types
182
+ const exact = MIME_CATEGORY_MAP.get(mimeType);
183
+ if (exact) return exact;
184
+
185
+ // Prefix-based fallback for unknown subtypes
71
186
  if (mimeType.startsWith("image/")) return "image";
72
187
  if (mimeType.startsWith("video/")) return "video";
73
188
  if (mimeType.startsWith("audio/")) return "audio";
74
- if (mimeType === "application/pdf") return "document";
75
- return null;
189
+ if (mimeType.startsWith("font/")) return "font";
190
+ if (mimeType.startsWith("model/")) return "3d";
191
+ if (mimeType.startsWith("text/")) return "text";
192
+
193
+ // Unknown types default to archive
194
+ return "archive";
195
+ }
196
+
197
+ /**
198
+ * Maps a MIME type to one of the five media kind categories.
199
+ * image/video/audio/text pass through; everything else becomes "document".
200
+ *
201
+ * @param mimeType - The MIME type to classify
202
+ * @returns The media kind
203
+ * @example
204
+ * ```ts
205
+ * toMediaKind("image/jpeg"); // "image"
206
+ * toMediaKind("application/pdf"); // "document"
207
+ * toMediaKind("text/plain"); // "text"
208
+ * ```
209
+ */
210
+ export function toMediaKind(mimeType: string): MediaKind {
211
+ const category = getMediaCategory(mimeType);
212
+ switch (category) {
213
+ case "image":
214
+ case "video":
215
+ case "audio":
216
+ case "text":
217
+ return category;
218
+ default:
219
+ return "document";
220
+ }
76
221
  }
77
222
 
78
223
  /**
@@ -112,20 +257,36 @@ export interface ValidateUploadOptions {
112
257
  export function validateUploadFile(
113
258
  file: File,
114
259
  options: ValidateUploadOptions,
260
+ ): string | null {
261
+ return validateUploadFileMetadata(file.type, file.size, options);
262
+ }
263
+
264
+ /**
265
+ * Validates file metadata (type and size) without requiring a File object.
266
+ * Used by the multipart upload initiation endpoint which receives JSON metadata.
267
+ * All MIME types are accepted; unrecognized types are categorized as archive.
268
+ *
269
+ * @param contentType - The MIME type of the file
270
+ * @param size - The file size in bytes
271
+ * @param options - Validation constraints
272
+ * @returns null if valid, error message string if invalid
273
+ * @example
274
+ * ```ts
275
+ * const error = validateUploadFileMetadata("image/jpeg", 1024000, { maxFileSizeMB: 500 });
276
+ * ```
277
+ */
278
+ export function validateUploadFileMetadata(
279
+ contentType: string,
280
+ size: number,
281
+ options: ValidateUploadOptions,
115
282
  ): string | null {
116
283
  if (options?.imagesOnly) {
117
- if (!isImageMimeType(file.type)) {
284
+ if (!isImageMimeType(contentType)) {
118
285
  return "File type not allowed.";
119
286
  }
120
- } else if (
121
- !ALLOWED_UPLOAD_TYPES.includes(
122
- file.type as (typeof ALLOWED_UPLOAD_TYPES)[number],
123
- )
124
- ) {
125
- return "File type not allowed.";
126
287
  }
127
288
  const maxMB = options.maxFileSizeMB;
128
- if (file.size > maxMB * 1024 * 1024) {
289
+ if (size > maxMB * 1024 * 1024) {
129
290
  return `File too large (max ${maxMB}MB).`;
130
291
  }
131
292
  return null;
package/src/lib/url.ts CHANGED
@@ -97,3 +97,34 @@ export function isFullUrl(str: string): boolean {
97
97
  export function slugify(text: string): string {
98
98
  return limax(text, { tone: false }).replace(/_/g, "-");
99
99
  }
100
+
101
+ const SAFE_URL_PROTOCOLS = new Set(["http:", "https:", "mailto:"]);
102
+
103
+ /**
104
+ * Sanitizes a URL by ensuring it uses a safe protocol.
105
+ *
106
+ * Returns the URL unchanged if it uses an allowed protocol (http:, https:, mailto:)
107
+ * or is a relative path. Returns an empty string for dangerous protocols like
108
+ * `javascript:`, `data:`, or `vbscript:`.
109
+ *
110
+ * @param url - The URL string to sanitize
111
+ * @returns The original URL if safe, or an empty string if the protocol is disallowed
112
+ *
113
+ * @example
114
+ * ```ts
115
+ * sanitizeUrl("https://example.com"); // "https://example.com"
116
+ * sanitizeUrl("/about"); // "/about"
117
+ * sanitizeUrl("javascript:alert(1)"); // ""
118
+ * sanitizeUrl("data:text/html,<h1>Hi</h1>"); // ""
119
+ * ```
120
+ */
121
+ export function sanitizeUrl(url: string): string {
122
+ try {
123
+ const parsed = new URL(url, "https://placeholder.invalid");
124
+ // Relative URLs resolve against the placeholder and get https: — allow them
125
+ if (SAFE_URL_PROTOCOLS.has(parsed.protocol)) return url;
126
+ return "";
127
+ } catch {
128
+ return "";
129
+ }
130
+ }