@jant/core 0.3.36 → 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.
- 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
|
@@ -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}`;
|
|
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 ``;
|
|
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
|
|
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
|
|
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
|
|
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
|
|
35
|
-
const DOCUMENT_MIME_TYPES = [
|
|
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
|
-
/**
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
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 =
|
|
54
|
-
",",
|
|
55
|
-
);
|
|
153
|
+
export const UPLOAD_ACCEPT = "*/*";
|
|
56
154
|
|
|
57
|
-
export type MediaCategory =
|
|
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
|
|
172
|
+
* @returns The media category
|
|
64
173
|
* @example
|
|
65
174
|
* ```ts
|
|
66
175
|
* getMediaCategory("video/mp4"); // "video"
|
|
67
|
-
* getMediaCategory("text/plain"); //
|
|
176
|
+
* getMediaCategory("text/plain"); // "text"
|
|
177
|
+
* getMediaCategory("application/octet-stream"); // "archive"
|
|
68
178
|
* ```
|
|
69
179
|
*/
|
|
70
|
-
export function getMediaCategory(mimeType: string): MediaCategory
|
|
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
|
|
75
|
-
return
|
|
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(
|
|
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 (
|
|
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
|
+
}
|