@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.
- package/bin/commands/export.js +1 -1
- package/bin/commands/import-site.js +529 -0
- package/bin/commands/reset-password.js +3 -2
- package/dist/client/assets/heic-to-DIRPI3VF.js +1 -0
- package/dist/client/assets/url-FWFqPJPb.js +1 -0
- package/dist/client/client.css +1 -1
- package/dist/client/client.js +4012 -3276
- package/dist/index.js +10285 -5809
- package/package.json +11 -3
- package/src/__tests__/helpers/app.ts +9 -9
- package/src/__tests__/helpers/db.ts +91 -93
- package/src/app.tsx +157 -27
- package/src/auth.ts +20 -2
- package/src/client/archive-nav.js +187 -0
- package/src/client/audio-player.ts +478 -0
- package/src/client/audio-processor.ts +84 -0
- package/src/client/avatar-upload.ts +3 -2
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +645 -49
- package/src/client/components/__tests__/jant-compose-editor.test.ts +208 -16
- package/src/client/components/__tests__/jant-post-form.test.ts +19 -9
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -2
- package/src/client/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-sidebar-types.ts +7 -9
- package/src/client/components/compose-types.ts +101 -4
- package/src/client/components/jant-collection-form.ts +43 -7
- package/src/client/components/jant-collection-sidebar.ts +88 -84
- package/src/client/components/jant-compose-dialog.ts +1655 -219
- package/src/client/components/jant-compose-editor.ts +732 -168
- package/src/client/components/jant-compose-fullscreen.ts +23 -78
- package/src/client/components/jant-media-lightbox.ts +2 -0
- package/src/client/components/jant-nav-manager.ts +24 -284
- package/src/client/components/jant-post-form.ts +89 -9
- package/src/client/components/jant-post-menu.ts +1019 -0
- package/src/client/components/jant-settings-avatar.ts +3 -3
- package/src/client/components/jant-settings-general.ts +38 -4
- package/src/client/components/jant-text-preview.ts +232 -0
- package/src/client/components/nav-manager-types.ts +4 -19
- package/src/client/components/post-form-template.ts +107 -12
- package/src/client/components/post-form-types.ts +11 -4
- package/src/client/compose-bridge.ts +410 -109
- package/src/client/image-processor.ts +26 -8
- package/src/client/media-metadata.ts +247 -0
- package/src/client/multipart-upload.ts +160 -0
- package/src/client/post-form-bridge.ts +52 -1
- package/src/client/settings-bridge.ts +0 -12
- package/src/client/thread-context.ts +140 -0
- package/src/client/tiptap/create-editor.ts +46 -0
- package/src/client/tiptap/extensions.ts +5 -0
- package/src/client/tiptap/image-node.ts +2 -8
- package/src/client/tiptap/paste-image.ts +2 -13
- package/src/client/tiptap/slash-commands.ts +173 -63
- package/src/client/toast.ts +101 -3
- package/src/client/types/sortablejs.d.ts +15 -0
- package/src/client/upload-with-metadata.ts +54 -0
- package/src/client/video-processor.ts +207 -0
- package/src/client.ts +5 -2
- package/src/db/__tests__/migrations.test.ts +118 -0
- package/src/db/index.ts +52 -0
- package/src/db/migrations/0000_baseline.sql +269 -0
- package/src/db/migrations/0001_fts_setup.sql +31 -0
- package/src/db/migrations/meta/0000_snapshot.json +703 -119
- package/src/db/migrations/meta/0001_snapshot.json +1337 -0
- package/src/db/migrations/meta/_journal.json +4 -39
- package/src/db/schema.ts +409 -145
- package/src/i18n/__tests__/detect.test.ts +115 -0
- package/src/i18n/context.tsx +2 -2
- package/src/i18n/detect.ts +85 -1
- package/src/i18n/i18n.ts +1 -1
- package/src/i18n/index.ts +2 -1
- package/src/i18n/locales/en.po +487 -1217
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +613 -996
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +624 -1007
- package/src/i18n/locales/zh-Hant.ts +1 -1
- package/src/i18n/middleware.ts +6 -0
- package/src/index.ts +5 -7
- package/src/lib/__tests__/blurhash-placeholder.test.ts +75 -0
- package/src/lib/__tests__/constants.test.ts +0 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +358 -0
- package/src/lib/__tests__/nanoid.test.ts +26 -0
- package/src/lib/__tests__/schemas.test.ts +181 -63
- package/src/lib/__tests__/slug.test.ts +126 -0
- package/src/lib/__tests__/sse.test.ts +6 -6
- package/src/lib/__tests__/summary.test.ts +264 -0
- package/src/lib/__tests__/theme.test.ts +1 -1
- package/src/lib/__tests__/timeline.test.ts +33 -30
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +346 -0
- package/src/lib/__tests__/view.test.ts +141 -66
- package/src/lib/blurhash-placeholder.ts +102 -0
- package/src/lib/constants.ts +3 -1
- package/src/lib/emoji-catalog.ts +885 -68
- package/src/lib/errors.ts +11 -8
- package/src/lib/feed.ts +78 -32
- package/src/lib/html.ts +2 -1
- package/src/lib/icon-catalog.ts +5033 -1
- package/src/lib/icons.ts +3 -2
- package/src/lib/index.ts +0 -1
- package/src/lib/markdown-to-tiptap.ts +286 -0
- package/src/lib/media-helpers.ts +12 -3
- package/src/lib/nanoid.ts +29 -0
- package/src/lib/navigation.ts +1 -1
- package/src/lib/render.tsx +20 -2
- package/src/lib/resolve-config.ts +6 -2
- package/src/lib/schemas.ts +224 -55
- package/src/lib/search-snippet.ts +34 -0
- package/src/lib/slug.ts +96 -0
- package/src/lib/sse.ts +6 -6
- package/src/lib/storage.ts +115 -7
- package/src/lib/summary.ts +66 -0
- package/src/lib/theme.ts +11 -8
- package/src/lib/timeline.ts +74 -34
- package/src/lib/tiptap-render.ts +5 -10
- package/src/lib/tiptap-to-markdown.ts +305 -0
- package/src/lib/upload.ts +190 -29
- package/src/lib/url.ts +31 -0
- package/src/lib/view.ts +204 -37
- package/src/middleware/__tests__/auth.test.ts +191 -11
- package/src/middleware/__tests__/onboarding.test.ts +12 -10
- package/src/middleware/auth.ts +63 -9
- package/src/middleware/onboarding.ts +1 -1
- package/src/middleware/secure-headers.ts +40 -0
- package/src/preset.css +45 -2
- package/src/routes/__tests__/compose.test.ts +17 -24
- package/src/routes/api/__tests__/collections.test.ts +109 -61
- package/src/routes/api/__tests__/nav-items.test.ts +46 -29
- package/src/routes/api/__tests__/posts.test.ts +132 -68
- package/src/routes/api/__tests__/search.test.ts +15 -2
- package/src/routes/api/__tests__/upload-multipart.test.ts +534 -0
- package/src/routes/api/collections.ts +51 -42
- package/src/routes/api/custom-urls.ts +80 -0
- package/src/routes/api/export.ts +31 -0
- package/src/routes/api/nav-items.ts +23 -19
- package/src/routes/api/posts.ts +43 -39
- package/src/routes/api/search.ts +3 -4
- package/src/routes/api/upload-multipart.ts +245 -0
- package/src/routes/api/upload.ts +85 -19
- package/src/routes/auth/__tests__/setup.test.ts +20 -60
- package/src/routes/auth/setup.tsx +26 -33
- package/src/routes/auth/signin.tsx +3 -7
- package/src/routes/compose.tsx +10 -55
- package/src/routes/dash/__tests__/settings-avatar.test.ts +1 -1
- package/src/routes/dash/custom-urls.tsx +414 -0
- package/src/routes/dash/settings.tsx +304 -232
- package/src/routes/feed/__tests__/rss.test.ts +27 -28
- package/src/routes/feed/rss.ts +6 -4
- package/src/routes/feed/sitemap.ts +2 -12
- package/src/routes/pages/__tests__/collections.test.ts +5 -6
- package/src/routes/pages/__tests__/featured.test.ts +41 -22
- package/src/routes/pages/archive.tsx +175 -39
- package/src/routes/pages/collection.tsx +22 -10
- package/src/routes/pages/collections.tsx +3 -3
- package/src/routes/pages/featured.tsx +28 -4
- package/src/routes/pages/home.tsx +16 -15
- package/src/routes/pages/latest.tsx +1 -11
- package/src/routes/pages/new.tsx +39 -0
- package/src/routes/pages/page.tsx +95 -49
- package/src/routes/pages/search.tsx +1 -1
- package/src/services/__tests__/api-token.test.ts +135 -0
- package/src/services/__tests__/collection.test.ts +275 -227
- package/src/services/__tests__/custom-url.test.ts +213 -0
- package/src/services/__tests__/media.test.ts +162 -22
- package/src/services/__tests__/navigation.test.ts +109 -68
- package/src/services/__tests__/post-timeline.test.ts +205 -32
- package/src/services/__tests__/post.test.ts +713 -234
- package/src/services/__tests__/search.test.ts +67 -10
- package/src/services/api-token.ts +166 -0
- package/src/services/auth.ts +17 -2
- package/src/services/collection.ts +397 -131
- package/src/services/custom-url.ts +188 -0
- package/src/services/export.ts +802 -0
- package/src/services/index.ts +26 -19
- package/src/services/media.ts +100 -22
- package/src/services/navigation.ts +158 -47
- package/src/services/path.ts +339 -0
- package/src/services/post.ts +687 -154
- package/src/services/search.ts +160 -75
- package/src/styles/components.css +58 -7
- package/src/styles/tokens.css +84 -6
- package/src/styles/ui.css +2964 -457
- package/src/types/bindings.ts +4 -1
- package/src/types/config.ts +12 -4
- package/src/types/constants.ts +15 -3
- package/src/types/entities.ts +74 -35
- package/src/types/operations.ts +11 -24
- package/src/types/props.ts +51 -16
- package/src/types/views.ts +45 -22
- package/src/ui/color-themes.ts +133 -23
- package/src/ui/compose/ComposeDialog.tsx +239 -17
- package/src/ui/compose/ComposePrompt.tsx +1 -1
- package/src/ui/dash/CrudPageHeader.tsx +1 -1
- package/src/ui/dash/ListItemRow.tsx +1 -1
- package/src/ui/dash/StatusBadge.tsx +3 -1
- package/src/ui/dash/appearance/AdvancedContent.tsx +22 -1
- package/src/ui/dash/appearance/ColorThemeContent.tsx +22 -2
- package/src/ui/dash/appearance/FontThemeContent.tsx +1 -1
- package/src/ui/dash/appearance/NavigationContent.tsx +5 -45
- package/src/ui/dash/index.ts +0 -3
- package/src/ui/dash/settings/AccountContent.tsx +3 -57
- package/src/ui/dash/settings/AccountMenuContent.tsx +147 -0
- package/src/ui/dash/settings/ApiTokensContent.tsx +232 -0
- package/src/ui/dash/settings/AvatarContent.tsx +8 -0
- package/src/ui/dash/settings/SessionsContent.tsx +159 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +45 -15
- package/src/ui/feed/LinkCard.tsx +89 -40
- package/src/ui/feed/NoteCard.tsx +39 -25
- package/src/ui/feed/PostStatusBadges.tsx +67 -0
- package/src/ui/feed/QuoteCard.tsx +38 -23
- package/src/ui/feed/ThreadPreview.tsx +90 -26
- package/src/ui/feed/TimelineFeed.tsx +3 -2
- package/src/ui/feed/TimelineItem.tsx +15 -6
- package/src/ui/feed/__tests__/thread-preview.test.ts +107 -0
- package/src/ui/feed/thread-preview-state.ts +61 -0
- package/src/ui/font-themes.ts +2 -2
- package/src/ui/layouts/BaseLayout.tsx +2 -2
- package/src/ui/layouts/SiteLayout.tsx +105 -92
- package/src/ui/pages/ArchivePage.tsx +923 -98
- package/src/ui/pages/ComposePage.tsx +54 -0
- package/src/ui/pages/PostPage.tsx +30 -45
- package/src/ui/pages/SearchPage.tsx +181 -37
- package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
- package/src/ui/shared/CollectionsSidebar.tsx +47 -37
- package/src/ui/shared/MediaGallery.tsx +445 -149
- package/src/ui/shared/PostFooter.tsx +204 -0
- package/src/ui/shared/StarRating.tsx +27 -0
- package/src/ui/shared/__tests__/format-chars.test.ts +35 -0
- package/src/ui/shared/index.ts +0 -1
- package/dist/client/assets/url-8Dj-5CLW.js +0 -1
- package/src/client/media-upload.ts +0 -161
- package/src/client/page-slug-bridge.ts +0 -42
- package/src/db/migrations/0000_square_wallflower.sql +0 -118
- package/src/db/migrations/0001_add_search_fts.sql +0 -34
- package/src/db/migrations/0002_add_media_attachments.sql +0 -3
- package/src/db/migrations/0003_add_navigation_links.sql +0 -8
- package/src/db/migrations/0004_add_storage_provider.sql +0 -3
- package/src/db/migrations/0005_v2_schema_migration.sql +0 -268
- package/src/db/migrations/0006_rename_slug_to_path.sql +0 -5
- package/src/db/migrations/0007_post_collections_m2m.sql +0 -94
- package/src/db/migrations/0008_add_collection_dividers.sql +0 -8
- package/src/db/migrations/0009_drop_collection_show_divider.sql +0 -2
- package/src/db/migrations/0010_add_performance_indexes.sql +0 -16
- package/src/db/migrations/0011_add_path_registry.sql +0 -23
- package/src/db/migrations/0012_add_tiptap_columns.sql +0 -2
- package/src/db/migrations/0013_replace_featured_with_visibility.sql +0 -8
- package/src/db/migrations/meta/0003_snapshot.json +0 -821
- package/src/lib/__tests__/sqid.test.ts +0 -65
- package/src/lib/sqid.ts +0 -79
- package/src/routes/api/__tests__/pages.test.ts +0 -218
- package/src/routes/api/pages.ts +0 -73
- package/src/routes/dash/__tests__/pages.test.ts +0 -226
- package/src/routes/dash/index.tsx +0 -109
- package/src/routes/dash/media.tsx +0 -135
- package/src/routes/dash/pages.tsx +0 -245
- package/src/routes/dash/posts.tsx +0 -338
- package/src/routes/dash/redirects.tsx +0 -263
- package/src/routes/pages/post.tsx +0 -59
- package/src/services/__tests__/page.test.ts +0 -298
- package/src/services/__tests__/path-registry.test.ts +0 -165
- package/src/services/__tests__/redirect.test.ts +0 -159
- package/src/services/page.ts +0 -216
- package/src/services/path-registry.ts +0 -160
- package/src/services/redirect.ts +0 -97
- package/src/ui/dash/PageForm.tsx +0 -187
- package/src/ui/dash/PostList.tsx +0 -95
- package/src/ui/dash/media/MediaListContent.tsx +0 -206
- package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
- package/src/ui/dash/pages/PagesContent.tsx +0 -75
- package/src/ui/dash/posts/PostForm.tsx +0 -260
- package/src/ui/layouts/DashLayout.tsx +0 -247
- package/src/ui/pages/SinglePage.tsx +0 -23
- package/src/ui/shared/ThreadView.tsx +0 -136
package/bin/commands/export.js
CHANGED
|
@@ -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 = `${
|
|
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})`;
|