@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
@@ -87,7 +87,7 @@ export async function run(argv) {
87
87
  ["collections"],
88
88
  ["posts", "SELECT * FROM posts WHERE deleted_at IS NULL"],
89
89
  ["post_collections"],
90
- ["collection_dividers"],
90
+ ["sidebar_items"],
91
91
  ["nav_items"],
92
92
  ["media"],
93
93
  ["redirects"],
@@ -0,0 +1,529 @@
1
+ import { readFile, readdir, stat } from "node:fs/promises";
2
+ import { resolve, join, relative } from "node:path";
3
+ import { parseArgs } from "node:util";
4
+
5
+ /**
6
+ * Parse front matter from a Markdown file.
7
+ * Supports both YAML (---...---) and TOML (+++...+++) delimiters.
8
+ * Returns { frontMatter, body }.
9
+ */
10
+ async function parseFrontMatter(content) {
11
+ // Try YAML front matter (---...---)
12
+ const yamlMatch = content.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
13
+ if (yamlMatch) {
14
+ const { parse } = await import("yaml");
15
+ const frontMatter = parse(yamlMatch[1]) || {};
16
+ return { frontMatter, body: yamlMatch[2] };
17
+ }
18
+
19
+ // Try TOML front matter (+++...+++)
20
+ const tomlMatch = content.match(/^\+\+\+\n([\s\S]*?)\n\+\+\+\n?([\s\S]*)$/);
21
+ if (tomlMatch) {
22
+ const { parse } = await import("smol-toml");
23
+ const frontMatter = parse(tomlMatch[1]);
24
+ return { frontMatter, body: tomlMatch[2] };
25
+ }
26
+
27
+ return { frontMatter: {}, body: content };
28
+ }
29
+
30
+ /**
31
+ * Parse reply markers from post body.
32
+ * Returns array of { attrs, body } segments where the first is the root.
33
+ */
34
+ function splitReplies(body) {
35
+ const markerRegex = /<!-- jant:reply (.*?) -->/g;
36
+
37
+ // Split body by markers, keeping the marker data
38
+ const markers = [];
39
+ let match;
40
+ while ((match = markerRegex.exec(body)) !== null) {
41
+ // Parse key="value" pairs from the marker
42
+ const attrs = {};
43
+ const attrRegex = /(\w+)="([^"]*)"/g;
44
+ let attrMatch;
45
+ while ((attrMatch = attrRegex.exec(match[1])) !== null) {
46
+ attrs[attrMatch[1]] = attrMatch[2];
47
+ }
48
+ markers.push({ index: match.index, endIndex: match.index + match[0].length, attrs });
49
+ }
50
+
51
+ if (markers.length === 0) {
52
+ return [{ attrs: null, body: body.trim() }];
53
+ }
54
+
55
+ const segments = [];
56
+
57
+ // Root segment: everything before the first marker
58
+ segments.push({ attrs: null, body: body.slice(0, markers[0].index).trim() });
59
+
60
+ // Reply segments: between consecutive markers
61
+ for (let i = 0; i < markers.length; i++) {
62
+ const start = markers[i].endIndex;
63
+ const end = i + 1 < markers.length ? markers[i + 1].index : body.length;
64
+ segments.push({ attrs: markers[i].attrs, body: body.slice(start, end).trim() });
65
+ }
66
+
67
+ return segments;
68
+ }
69
+
70
+ /**
71
+ * Find image URLs in markdown and return them.
72
+ */
73
+ function findImageUrls(markdown) {
74
+ const urls = [];
75
+ const regex = /!\[[^\]]*\]\(([^)\s]+)/g;
76
+ let match;
77
+ while ((match = regex.exec(markdown)) !== null) {
78
+ urls.push(match[1]);
79
+ }
80
+ return urls;
81
+ }
82
+
83
+ /**
84
+ * Download an image and upload it to the Jant API.
85
+ * Returns the new URL, or null on failure.
86
+ */
87
+ async function uploadImage(imageUrl, apiUrl, token) {
88
+ try {
89
+ const response = await fetch(imageUrl);
90
+ if (!response.ok) return null;
91
+
92
+ const blob = await response.blob();
93
+ const filename = imageUrl.split("/").pop() || "image.jpg";
94
+
95
+ const formData = new FormData();
96
+ formData.append("file", blob, filename);
97
+
98
+ const uploadResponse = await fetch(`${apiUrl}/api/upload`, {
99
+ method: "POST",
100
+ headers: { Authorization: `Bearer ${token}` },
101
+ body: formData,
102
+ });
103
+
104
+ if (!uploadResponse.ok) return null;
105
+ const data = await uploadResponse.json();
106
+ return { url: data.url, id: data.id };
107
+ } catch {
108
+ return null;
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Replace image URLs in markdown with newly uploaded URLs.
114
+ */
115
+ function replaceImageUrls(markdown, urlMap) {
116
+ let result = markdown;
117
+ for (const [oldUrl, newUrl] of urlMap) {
118
+ result = result.replaceAll(oldUrl, newUrl);
119
+ }
120
+ return result;
121
+ }
122
+
123
+ class ApiError extends Error {
124
+ constructor(status, text) {
125
+ super(`HTTP ${status}: ${text}`);
126
+ this.status = status;
127
+ }
128
+ }
129
+
130
+ async function apiCall(method, path, apiUrl, token, body) {
131
+ const headers = {
132
+ Authorization: `Bearer ${token}`,
133
+ "Content-Type": "application/json",
134
+ };
135
+
136
+ let response;
137
+ try {
138
+ response = await fetch(`${apiUrl}${path}`, {
139
+ method,
140
+ headers,
141
+ body: body ? JSON.stringify(body) : undefined,
142
+ });
143
+ } catch (err) {
144
+ const cause = err.cause?.code || err.cause?.message || err.message;
145
+ if (cause === "UNABLE_TO_VERIFY_LEAF_SIGNATURE" || cause?.includes("certificate")) {
146
+ console.error(`\nSSL certificate error connecting to ${apiUrl}`);
147
+ console.error("If using a local/self-signed certificate, run with:");
148
+ console.error(" NODE_TLS_REJECT_UNAUTHORIZED=0 jant import-site ...");
149
+ console.error("Or use: node --use-system-ca bin/jant.js import-site ...");
150
+ process.exit(1);
151
+ }
152
+ throw new Error(`Network error calling ${method} ${apiUrl}${path}: ${cause}`);
153
+ }
154
+
155
+ if (!response.ok) {
156
+ const text = await response.text();
157
+ throw new ApiError(response.status, text);
158
+ }
159
+
160
+ return response.json();
161
+ }
162
+
163
+ /**
164
+ * Recursively walk a directory's content/ folder and collect post/collection files.
165
+ */
166
+ async function walkContent(rootDir, postFiles, collectionFiles) {
167
+ const contentDir = join(rootDir, "content");
168
+ const contentStat = await stat(contentDir).catch(() => null);
169
+ if (!contentStat?.isDirectory()) {
170
+ console.error(`No content/ directory found in ${rootDir}`);
171
+ process.exit(1);
172
+ }
173
+
174
+ async function walk(dir) {
175
+ const entries = await readdir(dir, { withFileTypes: true });
176
+ for (const entry of entries) {
177
+ const fullPath = join(dir, entry.name);
178
+ if (entry.isDirectory()) {
179
+ await walk(fullPath);
180
+ } else if (entry.name === "index.md" || entry.name === "_index.md") {
181
+ const relPath = relative(rootDir, fullPath).replace(/\\/g, "/");
182
+ const content = await readFile(fullPath, "utf-8");
183
+ if (relPath.startsWith("content/c/") && relPath.endsWith("/_index.md")) {
184
+ collectionFiles.push({ path: relPath, content });
185
+ } else if (
186
+ relPath.startsWith("content/") &&
187
+ relPath.endsWith("/index.md") &&
188
+ relPath !== "content/_index.md"
189
+ ) {
190
+ postFiles.push({ path: relPath, content });
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ await walk(contentDir);
197
+ }
198
+
199
+ export async function run(argv) {
200
+ const { values } = parseArgs({
201
+ args: argv,
202
+ options: {
203
+ url: { type: "string" },
204
+ token: { type: "string" },
205
+ path: { type: "string", default: "." },
206
+ "dry-run": { type: "boolean", default: false },
207
+ "skip-media": { type: "boolean", default: false },
208
+ help: { type: "boolean", short: "h" },
209
+ },
210
+ });
211
+
212
+ if (values.help) {
213
+ console.log("Usage: jant import-site --url <url> [options]");
214
+ console.log("");
215
+ console.log("Import a Zola export ZIP into a Jant instance.");
216
+ console.log("");
217
+ console.log("Options:");
218
+ console.log(" --url Target Jant instance URL (required)");
219
+ console.log(" --path Path to export directory or ZIP file (default: .)");
220
+ console.log(" --dry-run Parse and validate without making API calls");
221
+ console.log(" --skip-media Skip image download/upload");
222
+ console.log("");
223
+ console.log("Authentication:");
224
+ console.log(" Set JANT_TOKEN env var (recommended):");
225
+ console.log(" export JANT_TOKEN=jnt_your_token");
226
+ console.log(" jant import-site --url https://your-site.com");
227
+ process.exit(0);
228
+ }
229
+
230
+ if (!values.url) {
231
+ console.error("Error: --url is required");
232
+ process.exit(1);
233
+ }
234
+
235
+ const token = process.env.JANT_TOKEN || values.token;
236
+ if (!token && !values["dry-run"]) {
237
+ console.error("Error: JANT_TOKEN env var is required (unless using --dry-run)");
238
+ console.error("");
239
+ console.error(" export JANT_TOKEN=jnt_your_token");
240
+ process.exit(1);
241
+ }
242
+
243
+ const apiUrl = values.url.replace(/\/$/, "");
244
+ const dryRun = values["dry-run"];
245
+ const skipMedia = values["skip-media"];
246
+
247
+ // 1. Read source — directory or ZIP
248
+ const inputPath = resolve(process.cwd(), values.path);
249
+ const inputStat = await stat(inputPath).catch(() => null);
250
+
251
+ if (!inputStat) {
252
+ console.error(`Path not found: ${inputPath}`);
253
+ process.exit(1);
254
+ }
255
+
256
+ const postFiles = [];
257
+ const collectionFiles = [];
258
+
259
+ if (inputStat.isDirectory()) {
260
+ console.log(`Reading directory ${inputPath}...`);
261
+ await walkContent(inputPath, postFiles, collectionFiles);
262
+ } else {
263
+ console.log(`Reading ZIP ${inputPath}...`);
264
+ const zipData = await readFile(inputPath);
265
+ const { unzipSync } = await import("fflate");
266
+ const files = unzipSync(new Uint8Array(zipData));
267
+ const decoder = new TextDecoder();
268
+
269
+ for (const [path, data] of Object.entries(files)) {
270
+ if (path.startsWith("content/c/") && path.endsWith("/_index.md")) {
271
+ collectionFiles.push({ path, content: decoder.decode(data) });
272
+ } else if (
273
+ path.startsWith("content/") &&
274
+ path.endsWith("/index.md") &&
275
+ path !== "content/_index.md"
276
+ ) {
277
+ postFiles.push({ path, content: decoder.decode(data) });
278
+ }
279
+ }
280
+ }
281
+
282
+ console.log(
283
+ `Found ${postFiles.length} posts and ${collectionFiles.length} collections`,
284
+ );
285
+
286
+ // 3. Fetch existing collections and create missing ones
287
+ const collectionSlugToId = new Map();
288
+
289
+ if (!dryRun) {
290
+ try {
291
+ const existing = await apiCall("GET", "/api/collections", apiUrl, token);
292
+ for (const col of existing.collections || []) {
293
+ collectionSlugToId.set(col.slug, col.id);
294
+ }
295
+ } catch (err) {
296
+ console.error(`Error fetching existing collections: ${err.message}`);
297
+ process.exit(1);
298
+ }
299
+ }
300
+
301
+ for (const { path, content } of collectionFiles) {
302
+ const { frontMatter } = await parseFrontMatter(content);
303
+ const slug = path.replace("content/c/", "").replace("/_index.md", "");
304
+
305
+ if (collectionSlugToId.has(slug)) {
306
+ console.log(`Skipped collection (exists): ${frontMatter.title || slug}`);
307
+ continue;
308
+ }
309
+
310
+ if (dryRun) {
311
+ console.log(`[dry-run] Would create collection: ${frontMatter.title || slug}`);
312
+ collectionSlugToId.set(slug, `dry-run-${slug}`);
313
+ continue;
314
+ }
315
+
316
+ try {
317
+ const result = await apiCall("POST", "/api/collections", apiUrl, token, {
318
+ title: frontMatter.title || slug,
319
+ slug,
320
+ description: frontMatter.description || null,
321
+ });
322
+ collectionSlugToId.set(slug, result.id);
323
+ console.log(`Created collection: ${frontMatter.title || slug}`);
324
+ } catch (err) {
325
+ console.error(`Error creating collection "${slug}": ${err.message}`);
326
+ process.exit(1);
327
+ }
328
+ }
329
+
330
+ // 4. Process posts
331
+ let postsCreated = 0;
332
+ let repliesCreated = 0;
333
+ let imagesUploaded = 0;
334
+ let aliasesCreated = 0;
335
+ let skipped = 0;
336
+
337
+ for (const { path, content } of postFiles) {
338
+ const { frontMatter, body } = await parseFrontMatter(content);
339
+
340
+ const segments = splitReplies(body);
341
+ const rootSegment = segments[0];
342
+ const replySegments = segments.slice(1);
343
+
344
+ // Resolve collection IDs from taxonomy slugs
345
+ const collectionIds = [];
346
+ const taxonomyCollections = frontMatter.taxonomies?.c || frontMatter.taxonomies?.collections || [];
347
+ for (const colSlug of taxonomyCollections) {
348
+ const id = collectionSlugToId.get(colSlug);
349
+ if (id) collectionIds.push(id);
350
+ }
351
+
352
+ // Process images in root body
353
+ let rootBody = rootSegment?.body || "";
354
+ const mediaIds = [];
355
+
356
+ if (!skipMedia && !dryRun && rootBody) {
357
+ const imageUrls = findImageUrls(rootBody);
358
+ const urlMap = new Map();
359
+
360
+ for (const imageUrl of imageUrls) {
361
+ if (imageUrl.startsWith("data:")) continue;
362
+ const result = await uploadImage(imageUrl, apiUrl, token);
363
+ if (result) {
364
+ urlMap.set(imageUrl, result.url);
365
+ mediaIds.push(result.id);
366
+ imagesUploaded++;
367
+ }
368
+ }
369
+
370
+ if (urlMap.size > 0) {
371
+ rootBody = replaceImageUrls(rootBody, urlMap);
372
+ }
373
+ }
374
+
375
+ const extra = frontMatter.extra || {};
376
+ const format = extra.format || "note";
377
+
378
+ const postData = {
379
+ format,
380
+ title: frontMatter.title != null ? String(frontMatter.title) : undefined,
381
+ bodyMarkdown: rootBody || undefined,
382
+ slug: frontMatter.slug != null ? String(frontMatter.slug) : undefined,
383
+ status: frontMatter.draft ? "draft" : "published",
384
+ collectionIds: collectionIds.length > 0 ? collectionIds : undefined,
385
+ mediaIds: mediaIds.length > 0 ? mediaIds : undefined,
386
+ publishedAt:
387
+ !frontMatter.draft && frontMatter.date
388
+ ? Math.floor(new Date(frontMatter.date).getTime() / 1000)
389
+ : undefined,
390
+ pinned: extra.pinned || undefined,
391
+ featured: extra.featured || undefined,
392
+ rating: extra.rating || undefined,
393
+ };
394
+
395
+ if (format === "link" && extra.link_url) {
396
+ postData.url = extra.link_url;
397
+ }
398
+ if (format === "quote" && extra.quote_text) {
399
+ postData.quoteText = extra.quote_text;
400
+ }
401
+
402
+ if (dryRun) {
403
+ console.log(
404
+ `[dry-run] Would create post: ${frontMatter.title || frontMatter.slug || "(untitled)"} (${format})`,
405
+ );
406
+ if (replySegments.length > 0) {
407
+ console.log(` [dry-run] With ${replySegments.length} replies`);
408
+ }
409
+ postsCreated++;
410
+ repliesCreated += replySegments.length;
411
+ continue;
412
+ }
413
+
414
+ const postLabel = frontMatter.title || frontMatter.slug || "(untitled)";
415
+
416
+ const progress = `[${postsCreated + skipped + 1}/${postFiles.length}]`;
417
+
418
+ let post;
419
+ try {
420
+ post = await apiCall("POST", "/api/posts", apiUrl, token, postData);
421
+ postsCreated++;
422
+ const replyInfo = replySegments.length > 0 ? ` (+${replySegments.length} replies)` : "";
423
+ console.log(`${progress} Created: ${postLabel}${replyInfo}`);
424
+ } catch (err) {
425
+ if (err.status === 409) {
426
+ console.log(`${progress} Skipped: ${postLabel}`);
427
+ skipped++;
428
+ } else {
429
+ console.error(`Error creating post "${postLabel}": ${err.message}`);
430
+ process.exit(1);
431
+ }
432
+ }
433
+
434
+ // Create custom URL aliases from front matter (also for skipped posts)
435
+ const aliases = frontMatter.aliases || [];
436
+ const postSlug = frontMatter.slug != null ? String(frontMatter.slug) : post?.slug;
437
+ for (const alias of aliases) {
438
+ const aliasPath = alias.startsWith("/") ? alias : `/${alias}`;
439
+ if (aliasPath === `/${postSlug}`) continue; // skip self-reference
440
+ try {
441
+ await apiCall("POST", "/api/custom-urls", apiUrl, token, {
442
+ path: aliasPath,
443
+ targetType: "post",
444
+ targetId: postSlug,
445
+ });
446
+ aliasesCreated++;
447
+ } catch (err) {
448
+ if (err.status === 409) continue; // alias already exists
449
+ console.warn(` Warning: Failed to create alias "${aliasPath}": ${err.message}`);
450
+ }
451
+ }
452
+
453
+ // Create replies (only for newly created posts)
454
+ if (!post) continue;
455
+ for (const replySegment of replySegments) {
456
+ const replyAttrs = replySegment.attrs || {};
457
+ let replyBody = replySegment.body || "";
458
+ const replyMediaIds = [];
459
+
460
+ if (!skipMedia && replyBody) {
461
+ const imageUrls = findImageUrls(replyBody);
462
+ const urlMap = new Map();
463
+
464
+ for (const imageUrl of imageUrls) {
465
+ if (imageUrl.startsWith("data:")) continue;
466
+ const result = await uploadImage(imageUrl, apiUrl, token);
467
+ if (result) {
468
+ urlMap.set(imageUrl, result.url);
469
+ replyMediaIds.push(result.id);
470
+ imagesUploaded++;
471
+ }
472
+ }
473
+
474
+ if (urlMap.size > 0) {
475
+ replyBody = replaceImageUrls(replyBody, urlMap);
476
+ }
477
+ }
478
+
479
+ const replyFormat = replyAttrs.format || "note";
480
+ const replyData = {
481
+ format: replyFormat,
482
+ title: replyAttrs.title || undefined,
483
+ bodyMarkdown: replyBody || undefined,
484
+ replyToId: post.id,
485
+ mediaIds: replyMediaIds.length > 0 ? replyMediaIds : undefined,
486
+ publishedAt: replyAttrs.date
487
+ ? Math.floor(new Date(replyAttrs.date).getTime() / 1000)
488
+ : undefined,
489
+ rating: replyAttrs.rating ? Number(replyAttrs.rating) : undefined,
490
+ };
491
+
492
+ if (replyFormat === "link" && replyAttrs.url) {
493
+ replyData.url = replyAttrs.url;
494
+ }
495
+ if (replyFormat === "quote" && replyAttrs.quote_text) {
496
+ replyData.quoteText = decodeURIComponent(replyAttrs.quote_text);
497
+ }
498
+
499
+ try {
500
+ await apiCall("POST", "/api/posts", apiUrl, token, replyData);
501
+ repliesCreated++;
502
+ } catch (err) {
503
+ if (err.status === 409) {
504
+ console.log(` Skipped reply (exists)`);
505
+ skipped++;
506
+ continue;
507
+ }
508
+ console.error(` Error creating reply: ${err.message}`);
509
+ process.exit(1);
510
+ }
511
+ }
512
+ }
513
+
514
+ // 5. Summary
515
+ console.log("");
516
+ console.log("Import complete:");
517
+ console.log(` Posts created: ${postsCreated}`);
518
+ console.log(` Replies created: ${repliesCreated}`);
519
+ console.log(` Images uploaded: ${imagesUploaded}`);
520
+ if (aliasesCreated > 0) {
521
+ console.log(` Aliases created: ${aliasesCreated}`);
522
+ }
523
+ if (skipped > 0) {
524
+ console.log(` Skipped (already exist): ${skipped}`);
525
+ }
526
+ if (dryRun) {
527
+ console.log(" (dry-run mode — no changes were made)");
528
+ }
529
+ }
@@ -1,4 +1,4 @@
1
- import { randomBytes } from "node:crypto";
1
+ import { randomBytes, createHash } from "node:crypto";
2
2
  import { execSync } from "node:child_process";
3
3
  import { parseArgs } from "node:util";
4
4
 
@@ -24,8 +24,9 @@ export async function run(argv) {
24
24
  const flag = values.remote ? "--remote" : "--local";
25
25
 
26
26
  const token = randomBytes(32).toString("hex");
27
+ const hash = createHash("sha256").update(token).digest("hex");
27
28
  const expiry = Math.floor(Date.now() / 1000) + 15 * 60;
28
- const value = `${token}:${expiry}`;
29
+ const value = `${hash}:${expiry}`;
29
30
  const timestamp = Math.floor(Date.now() / 1000);
30
31
 
31
32
  const sql = `INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES ('PASSWORD_RESET_TOKEN', '${value}', ${timestamp})`;