@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
package/src/lib/schemas.ts
CHANGED
|
@@ -18,6 +18,42 @@ import {
|
|
|
18
18
|
MAX_MEDIA_ATTACHMENTS,
|
|
19
19
|
} from "../types.js";
|
|
20
20
|
import { ValidationError } from "./errors.js";
|
|
21
|
+
import { sanitizeUrl, normalizePath } from "./url.js";
|
|
22
|
+
|
|
23
|
+
// =============================================================================
|
|
24
|
+
// Shared Transforms
|
|
25
|
+
// =============================================================================
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Strip C0 control characters (except HT, LF, CR) that can break rendering
|
|
29
|
+
* or interfere with FTS5 highlight sentinels (STX/ETX).
|
|
30
|
+
*/
|
|
31
|
+
// eslint-disable-next-line no-control-regex -- intentionally matching C0 control characters
|
|
32
|
+
const CONTROL_CHAR_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Normalize an email address for storage and lookup.
|
|
36
|
+
*
|
|
37
|
+
* @param email - Raw email input
|
|
38
|
+
* @returns Trimmed, lowercased email
|
|
39
|
+
* @example
|
|
40
|
+
* ```ts
|
|
41
|
+
* normalizeEmail(" User@Example.COM ");
|
|
42
|
+
* // Returns: "user@example.com"
|
|
43
|
+
* ```
|
|
44
|
+
*/
|
|
45
|
+
export function normalizeEmail(email: string): string {
|
|
46
|
+
return email.trim().toLowerCase();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Trim, strip control characters, and collapse to undefined when empty. */
|
|
50
|
+
function sanitizeText(maxLength: number) {
|
|
51
|
+
return z
|
|
52
|
+
.string()
|
|
53
|
+
.trim()
|
|
54
|
+
.max(maxLength)
|
|
55
|
+
.transform((s) => s.replace(CONTROL_CHAR_RE, "") || undefined);
|
|
56
|
+
}
|
|
21
57
|
|
|
22
58
|
/**
|
|
23
59
|
* Post format enum schema
|
|
@@ -47,6 +83,15 @@ export const NavItemTypeSchema = z.enum(NAV_ITEM_TYPES);
|
|
|
47
83
|
*/
|
|
48
84
|
export const RedirectTypeSchema = z.enum(["301", "302"]);
|
|
49
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Custom URL target type enum schema
|
|
88
|
+
*/
|
|
89
|
+
export const CustomUrlTargetTypeSchema = z.enum([
|
|
90
|
+
"post",
|
|
91
|
+
"collection",
|
|
92
|
+
"redirect",
|
|
93
|
+
]);
|
|
94
|
+
|
|
50
95
|
/**
|
|
51
96
|
* Rating schema (1-5 integer)
|
|
52
97
|
*/
|
|
@@ -60,73 +105,160 @@ export const RatingSchema = z.coerce
|
|
|
60
105
|
.transform((v) => (v === 0 ? undefined : v));
|
|
61
106
|
|
|
62
107
|
/**
|
|
63
|
-
*
|
|
108
|
+
* Base post fields (shared between create and update schemas)
|
|
64
109
|
*/
|
|
65
|
-
|
|
110
|
+
const PostFieldsSchema = z.object({
|
|
66
111
|
format: FormatSchema,
|
|
112
|
+
slug: z
|
|
113
|
+
.string()
|
|
114
|
+
.min(1)
|
|
115
|
+
.transform(normalizeSlug)
|
|
116
|
+
.pipe(
|
|
117
|
+
z
|
|
118
|
+
.string()
|
|
119
|
+
.min(1)
|
|
120
|
+
.regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/),
|
|
121
|
+
)
|
|
122
|
+
.optional()
|
|
123
|
+
.or(z.literal("").transform(() => undefined)),
|
|
67
124
|
path: z
|
|
68
125
|
.string()
|
|
69
|
-
.
|
|
126
|
+
.min(1)
|
|
127
|
+
.transform(normalizePath)
|
|
128
|
+
.pipe(z.string().min(1))
|
|
129
|
+
.optional()
|
|
130
|
+
.or(z.literal("").transform(() => undefined)),
|
|
131
|
+
title: sanitizeText(300)
|
|
70
132
|
.optional()
|
|
71
133
|
.or(z.literal("").transform(() => undefined)),
|
|
72
|
-
title: z.string().optional(),
|
|
73
134
|
body: z.string().optional(),
|
|
135
|
+
bodyMarkdown: z.string().optional(),
|
|
74
136
|
status: StatusSchema.optional(),
|
|
75
137
|
visibility: z.enum(VISIBILITIES).optional(),
|
|
76
138
|
pinned: z
|
|
77
139
|
.union([z.boolean(), z.literal("on").transform(() => true)])
|
|
78
140
|
.optional(),
|
|
79
|
-
|
|
141
|
+
featured: z.boolean().optional(),
|
|
142
|
+
url: z
|
|
143
|
+
.url()
|
|
144
|
+
.refine((val) => sanitizeUrl(val) !== "", {
|
|
145
|
+
message: "URL must use http:, https:, or mailto: protocol",
|
|
146
|
+
})
|
|
147
|
+
.optional()
|
|
148
|
+
.or(z.literal("")),
|
|
80
149
|
quoteText: z.string().optional(),
|
|
81
150
|
rating: RatingSchema,
|
|
82
151
|
collectionIds: z
|
|
83
|
-
.array(z.
|
|
152
|
+
.array(z.string().min(1))
|
|
84
153
|
.optional()
|
|
85
154
|
.or(z.literal("").transform(() => undefined)),
|
|
86
|
-
replyToId: z.string().optional(),
|
|
155
|
+
replyToId: z.string().optional(),
|
|
87
156
|
publishedAt: z.number().int().positive().optional(),
|
|
88
157
|
mediaIds: z.array(z.string()).max(MAX_MEDIA_ATTACHMENTS).optional(),
|
|
89
158
|
mediaAlts: z.record(z.string(), z.string()).optional(),
|
|
90
159
|
});
|
|
91
160
|
|
|
92
|
-
/**
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
161
|
+
/** Mutual exclusivity: body and bodyMarkdown cannot both be provided */
|
|
162
|
+
function refineBodyExclusivity<
|
|
163
|
+
T extends { body?: string; bodyMarkdown?: string },
|
|
164
|
+
>(schema: z.ZodType<T>) {
|
|
165
|
+
return schema.refine((data) => !(data.body && data.bodyMarkdown), {
|
|
166
|
+
message: "Provide either body or bodyMarkdown, not both",
|
|
167
|
+
path: ["bodyMarkdown"],
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function hasNonEmptyText(value: string | null | undefined): boolean {
|
|
172
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function refineCreatePostFormatShape<
|
|
176
|
+
T extends { format: string; url?: string; quoteText?: string },
|
|
177
|
+
>(schema: z.ZodType<T>) {
|
|
178
|
+
return schema.superRefine((data, ctx) => {
|
|
179
|
+
const hasUrl = hasNonEmptyText(data.url);
|
|
180
|
+
const hasQuoteText = hasNonEmptyText(data.quoteText);
|
|
181
|
+
|
|
182
|
+
if (data.format === "note") {
|
|
183
|
+
if (hasUrl) {
|
|
184
|
+
ctx.addIssue({
|
|
185
|
+
code: z.ZodIssueCode.custom,
|
|
186
|
+
path: ["url"],
|
|
187
|
+
message: "Notes can't include a URL.",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
if (hasQuoteText) {
|
|
191
|
+
ctx.addIssue({
|
|
192
|
+
code: z.ZodIssueCode.custom,
|
|
193
|
+
path: ["quoteText"],
|
|
194
|
+
message: "Notes can't include quoted text.",
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (data.format === "link") {
|
|
200
|
+
if (!hasUrl) {
|
|
201
|
+
ctx.addIssue({
|
|
202
|
+
code: z.ZodIssueCode.custom,
|
|
203
|
+
path: ["url"],
|
|
204
|
+
message: "Link posts need a URL.",
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (hasQuoteText) {
|
|
208
|
+
ctx.addIssue({
|
|
209
|
+
code: z.ZodIssueCode.custom,
|
|
210
|
+
path: ["quoteText"],
|
|
211
|
+
message: "Link posts can't include quoted text.",
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (data.format === "quote" && !hasQuoteText) {
|
|
217
|
+
ctx.addIssue({
|
|
218
|
+
code: z.ZodIssueCode.custom,
|
|
219
|
+
path: ["quoteText"],
|
|
220
|
+
message: "Quote posts need quoted text.",
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Mutual exclusivity: slug and path cannot both be provided */
|
|
227
|
+
function refineSlugPathExclusivity<T extends { slug?: string; path?: string }>(
|
|
228
|
+
schema: z.ZodType<T>,
|
|
229
|
+
) {
|
|
230
|
+
return schema.refine((data) => !(data.slug && data.path), {
|
|
231
|
+
message: "Provide either slug or path, not both",
|
|
232
|
+
path: ["path"],
|
|
233
|
+
});
|
|
234
|
+
}
|
|
96
235
|
|
|
97
236
|
/**
|
|
98
|
-
* API request body schema for creating a
|
|
237
|
+
* API request body schema for creating a post
|
|
99
238
|
*/
|
|
100
|
-
export const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
.min(1)
|
|
104
|
-
.transform(normalizeSlug)
|
|
105
|
-
.pipe(
|
|
106
|
-
z
|
|
107
|
-
.string()
|
|
108
|
-
.min(1)
|
|
109
|
-
.regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/),
|
|
110
|
-
),
|
|
111
|
-
title: z.string().optional(),
|
|
112
|
-
body: z.string().optional(),
|
|
113
|
-
status: StatusSchema.optional(),
|
|
114
|
-
});
|
|
239
|
+
export const CreatePostSchema = refineSlugPathExclusivity(
|
|
240
|
+
refineCreatePostFormatShape(refineBodyExclusivity(PostFieldsSchema)),
|
|
241
|
+
);
|
|
115
242
|
|
|
116
243
|
/**
|
|
117
|
-
* API request body schema for updating a
|
|
244
|
+
* API request body schema for updating a post
|
|
118
245
|
*/
|
|
119
|
-
export const
|
|
246
|
+
export const UpdatePostSchema = refineSlugPathExclusivity(
|
|
247
|
+
refineBodyExclusivity(PostFieldsSchema.partial()),
|
|
248
|
+
);
|
|
120
249
|
|
|
121
250
|
/**
|
|
122
251
|
* API request body schema for creating a navigation item
|
|
123
252
|
*/
|
|
124
253
|
export const CreateNavItemSchema = z.object({
|
|
125
254
|
type: NavItemTypeSchema,
|
|
126
|
-
label: z.string().min(1),
|
|
127
|
-
url: z
|
|
128
|
-
|
|
129
|
-
|
|
255
|
+
label: sanitizeText(100).pipe(z.string().min(1)),
|
|
256
|
+
url: z
|
|
257
|
+
.string()
|
|
258
|
+
.min(1)
|
|
259
|
+
.refine((val) => sanitizeUrl(val) !== "", {
|
|
260
|
+
message: "URL must use http:, https:, or mailto: protocol",
|
|
261
|
+
}),
|
|
130
262
|
});
|
|
131
263
|
|
|
132
264
|
/**
|
|
@@ -148,11 +280,29 @@ export const CreateCollectionSchema = z.object({
|
|
|
148
280
|
.min(1)
|
|
149
281
|
.regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/),
|
|
150
282
|
),
|
|
151
|
-
title: z.string().min(1),
|
|
152
|
-
description:
|
|
153
|
-
|
|
283
|
+
title: sanitizeText(300).pipe(z.string().min(1)),
|
|
284
|
+
description: sanitizeText(500)
|
|
285
|
+
.optional()
|
|
286
|
+
.or(z.literal("").transform(() => undefined)),
|
|
287
|
+
icon: z
|
|
288
|
+
.string()
|
|
289
|
+
.optional()
|
|
290
|
+
.refine(
|
|
291
|
+
(val) => {
|
|
292
|
+
if (!val || !val.startsWith("{")) return true;
|
|
293
|
+
try {
|
|
294
|
+
const parsed = JSON.parse(val) as Record<string, unknown>;
|
|
295
|
+
if (typeof parsed.color === "string") {
|
|
296
|
+
return /^#[0-9a-f]{3,6}$/i.test(parsed.color);
|
|
297
|
+
}
|
|
298
|
+
return true;
|
|
299
|
+
} catch {
|
|
300
|
+
return true; // non-JSON icons (legacy emoji) are fine
|
|
301
|
+
}
|
|
302
|
+
},
|
|
303
|
+
{ message: "Icon color must be a valid hex color (e.g. #fff, #ff0000)" },
|
|
304
|
+
),
|
|
154
305
|
sortOrder: SortOrderSchema.optional(),
|
|
155
|
-
position: z.coerce.number().int().min(0).optional(),
|
|
156
306
|
});
|
|
157
307
|
|
|
158
308
|
/**
|
|
@@ -160,6 +310,24 @@ export const CreateCollectionSchema = z.object({
|
|
|
160
310
|
*/
|
|
161
311
|
export const UpdateCollectionSchema = CreateCollectionSchema.partial();
|
|
162
312
|
|
|
313
|
+
/**
|
|
314
|
+
* API request body schema for creating a custom URL
|
|
315
|
+
*/
|
|
316
|
+
export const CreateCustomUrlSchema = z.object({
|
|
317
|
+
path: z
|
|
318
|
+
.string()
|
|
319
|
+
.min(1)
|
|
320
|
+
.max(512)
|
|
321
|
+
.regex(
|
|
322
|
+
/^\/[a-z0-9][a-z0-9\-/]*$/,
|
|
323
|
+
"Path must start with / and contain only lowercase alphanumeric characters, hyphens, and slashes",
|
|
324
|
+
),
|
|
325
|
+
targetType: CustomUrlTargetTypeSchema,
|
|
326
|
+
targetId: z.string().optional(),
|
|
327
|
+
toPath: z.string().optional(),
|
|
328
|
+
redirectType: RedirectTypeSchema.optional(),
|
|
329
|
+
});
|
|
330
|
+
|
|
163
331
|
// =============================================================================
|
|
164
332
|
// Auth Schemas
|
|
165
333
|
// =============================================================================
|
|
@@ -168,17 +336,26 @@ export const UpdateCollectionSchema = CreateCollectionSchema.partial();
|
|
|
168
336
|
* Setup form validation schema
|
|
169
337
|
*/
|
|
170
338
|
export const SetupSchema = z.object({
|
|
171
|
-
|
|
172
|
-
email: z
|
|
173
|
-
|
|
339
|
+
siteName: z.string().min(1, "Site name is required"),
|
|
340
|
+
email: z
|
|
341
|
+
.string()
|
|
342
|
+
.transform(normalizeEmail)
|
|
343
|
+
.pipe(z.string().email("Invalid email address")),
|
|
344
|
+
password: z
|
|
345
|
+
.string()
|
|
346
|
+
.min(8, "Password must be at least 8 characters")
|
|
347
|
+
.max(128),
|
|
174
348
|
});
|
|
175
349
|
|
|
176
350
|
/**
|
|
177
351
|
* Sign-in form validation schema
|
|
178
352
|
*/
|
|
179
353
|
export const SigninSchema = z.object({
|
|
180
|
-
email: z
|
|
181
|
-
|
|
354
|
+
email: z
|
|
355
|
+
.string()
|
|
356
|
+
.transform(normalizeEmail)
|
|
357
|
+
.pipe(z.string().email("Invalid email address")),
|
|
358
|
+
password: z.string().min(1, "Password is required").max(128),
|
|
182
359
|
});
|
|
183
360
|
|
|
184
361
|
/**
|
|
@@ -186,7 +363,10 @@ export const SigninSchema = z.object({
|
|
|
186
363
|
*/
|
|
187
364
|
export const ResetPasswordSchema = z
|
|
188
365
|
.object({
|
|
189
|
-
password: z
|
|
366
|
+
password: z
|
|
367
|
+
.string()
|
|
368
|
+
.min(8, "Password must be at least 8 characters")
|
|
369
|
+
.max(128),
|
|
190
370
|
confirmPassword: z.string().min(1),
|
|
191
371
|
token: z.string().min(1),
|
|
192
372
|
})
|
|
@@ -220,17 +400,6 @@ export function normalizeSlug(s: string): string {
|
|
|
220
400
|
.replace(/^-|-$/g, "");
|
|
221
401
|
}
|
|
222
402
|
|
|
223
|
-
// =============================================================================
|
|
224
|
-
// Reorder Schemas
|
|
225
|
-
// =============================================================================
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Reorder request schema for simple ID-based reordering
|
|
229
|
-
*/
|
|
230
|
-
export const ReorderSchema = z.object({
|
|
231
|
-
ids: z.array(z.coerce.number().int().positive()),
|
|
232
|
-
});
|
|
233
|
-
|
|
234
403
|
// =============================================================================
|
|
235
404
|
// Form Data Helpers
|
|
236
405
|
// =============================================================================
|
|
@@ -299,7 +468,7 @@ export function validateMediaCount(mediaIds: string[]): string | null {
|
|
|
299
468
|
* @returns Validated data
|
|
300
469
|
* @example
|
|
301
470
|
* ```ts
|
|
302
|
-
* const body = parseValidated(
|
|
471
|
+
* const body = parseValidated(CreatePostSchema, await c.req.json());
|
|
303
472
|
* ```
|
|
304
473
|
*/
|
|
305
474
|
export function parseValidated<T>(schema: z.ZodSchema<T>, data: unknown): T {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { escapeHtml } from "./html.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Search Snippet Utilities
|
|
5
|
+
*
|
|
6
|
+
* Application-layer text highlighting for search results.
|
|
7
|
+
* Used for fields not covered by FTS5 snippet() (title, quoteText).
|
|
8
|
+
*
|
|
9
|
+
* @param text - Plain text to highlight (already stored content, not user input)
|
|
10
|
+
* @param query - Raw search query string (space-separated terms)
|
|
11
|
+
* @returns HTML-safe string with matched terms wrapped in <mark> tags; escaped original if no terms
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* highlightText("Hello world", "world")
|
|
16
|
+
* // → "Hello <mark>world</mark>"
|
|
17
|
+
*
|
|
18
|
+
* highlightText("TypeScript basics", "type script")
|
|
19
|
+
* // → "<mark>TypeScript</mark> basics"
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export function highlightText(text: string, query: string): string {
|
|
23
|
+
const escaped = escapeHtml(text);
|
|
24
|
+
const terms = query
|
|
25
|
+
.trim()
|
|
26
|
+
.split(/\s+/)
|
|
27
|
+
.filter((t) => t.length > 0)
|
|
28
|
+
.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
|
|
29
|
+
|
|
30
|
+
if (terms.length === 0) return escaped;
|
|
31
|
+
|
|
32
|
+
const pattern = new RegExp(`(${terms.join("|")})`, "gi");
|
|
33
|
+
return escaped.replace(pattern, "<mark>$1</mark>");
|
|
34
|
+
}
|
package/src/lib/slug.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slug Generation
|
|
3
|
+
*
|
|
4
|
+
* Generates URL slugs for posts with conflict resolution.
|
|
5
|
+
* Handles three cases: user-provided slug, title-based slug, and random-only slug.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { slugify } from "./url.js";
|
|
9
|
+
import { generateRandomId } from "./nanoid.js";
|
|
10
|
+
import { isReservedPath } from "./constants.js";
|
|
11
|
+
import { ValidationError, ConflictError } from "./errors.js";
|
|
12
|
+
|
|
13
|
+
const MAX_RETRIES = 10;
|
|
14
|
+
|
|
15
|
+
export interface SlugOptions {
|
|
16
|
+
/** User-provided slug (takes priority) */
|
|
17
|
+
slug?: string;
|
|
18
|
+
/** Post title (used for slug generation if no explicit slug) */
|
|
19
|
+
title?: string;
|
|
20
|
+
/** Length of random IDs */
|
|
21
|
+
idLength: number;
|
|
22
|
+
/** Callback to check if a slug is available (checks posts.slug + custom_urls.path) */
|
|
23
|
+
isAvailable: (slug: string) => Promise<boolean>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Generates a post slug with conflict resolution.
|
|
28
|
+
*
|
|
29
|
+
* Resolution order:
|
|
30
|
+
* 1. User-provided slug → validate format, check reserved, check availability
|
|
31
|
+
* 2. Title exists → slugify(title), append -{randomId} if conflict
|
|
32
|
+
* 3. No title → pure random ID
|
|
33
|
+
*
|
|
34
|
+
* @param opts - Slug generation options
|
|
35
|
+
* @returns A unique, valid slug
|
|
36
|
+
*
|
|
37
|
+
* @example
|
|
38
|
+
* ```ts
|
|
39
|
+
* // User-provided
|
|
40
|
+
* await generatePostSlug({ slug: "my-post", idLength: 5, isAvailable: check });
|
|
41
|
+
*
|
|
42
|
+
* // Title-based
|
|
43
|
+
* await generatePostSlug({ title: "Hello World", idLength: 5, isAvailable: check });
|
|
44
|
+
*
|
|
45
|
+
* // Random
|
|
46
|
+
* await generatePostSlug({ idLength: 5, isAvailable: check });
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export async function generatePostSlug(opts: SlugOptions): Promise<string> {
|
|
50
|
+
const { slug, title, idLength, isAvailable } = opts;
|
|
51
|
+
|
|
52
|
+
// Case 1: User-provided slug
|
|
53
|
+
if (slug) {
|
|
54
|
+
if (isReservedPath(slug)) {
|
|
55
|
+
throw new ValidationError(
|
|
56
|
+
`Slug "${slug}" is reserved and cannot be used`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const available = await isAvailable(slug);
|
|
60
|
+
if (!available) {
|
|
61
|
+
throw new ConflictError(`Slug "${slug}" is already in use`);
|
|
62
|
+
}
|
|
63
|
+
return slug;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Case 2: Title-based slug
|
|
67
|
+
if (title) {
|
|
68
|
+
const base = slugify(title);
|
|
69
|
+
if (base && !isReservedPath(base)) {
|
|
70
|
+
const available = await isAvailable(base);
|
|
71
|
+
if (available) return base;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Append random suffix on conflict or reserved base
|
|
75
|
+
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
76
|
+
const candidate = `${base || generateRandomId(idLength)}-${generateRandomId(idLength)}`;
|
|
77
|
+
if (!isReservedPath(candidate) && (await isAvailable(candidate))) {
|
|
78
|
+
return candidate;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
throw new ConflictError(
|
|
82
|
+
"Could not generate a unique slug after multiple attempts",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Case 3: Pure random
|
|
87
|
+
for (let i = 0; i < MAX_RETRIES; i++) {
|
|
88
|
+
const candidate = generateRandomId(idLength);
|
|
89
|
+
if (!isReservedPath(candidate) && (await isAvailable(candidate))) {
|
|
90
|
+
return candidate;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
throw new ConflictError(
|
|
94
|
+
"Could not generate a unique slug after multiple attempts",
|
|
95
|
+
);
|
|
96
|
+
}
|
package/src/lib/sse.ts
CHANGED
|
@@ -91,7 +91,7 @@ export interface SSEStream {
|
|
|
91
91
|
*
|
|
92
92
|
* @example
|
|
93
93
|
* ```ts
|
|
94
|
-
* await stream.redirect('/
|
|
94
|
+
* await stream.redirect('/settings');
|
|
95
95
|
* ```
|
|
96
96
|
*/
|
|
97
97
|
redirect(url: string): void;
|
|
@@ -132,7 +132,7 @@ export interface SSEStream {
|
|
|
132
132
|
/** Build the redirect script tag for Datastar patch-elements */
|
|
133
133
|
function buildRedirectScript(url: string): string {
|
|
134
134
|
const escapedUrl = url.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
135
|
-
return `<
|
|
135
|
+
return `<div data-init="window.location.href='${escapedUrl}'; el.remove()"></div>`;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
/** Build a toast notification HTML element */
|
|
@@ -147,7 +147,7 @@ function buildToastHtml(message: string, type: "success" | "error"): string {
|
|
|
147
147
|
.replace(/&/g, "&")
|
|
148
148
|
.replace(/</g, "<")
|
|
149
149
|
.replace(/>/g, ">");
|
|
150
|
-
return `<div class="toast ${cls}" data-init="setTimeout(() => { el.classList.add('toast-out'); el.addEventListener('animationend', () => el.remove()) }, 3000)">${icon}<span>${escapedMessage}</span>${closeBtn}</div>`;
|
|
150
|
+
return `<div class="toast ${cls}" data-init="el.closest('[popover]')?.showPopover(); setTimeout(() => { el.classList.add('toast-out'); el.addEventListener('animationend', () => el.remove()) }, 3000)">${icon}<span>${escapedMessage}</span>${closeBtn}</div>`;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
// ---------------------------------------------------------------------------
|
|
@@ -193,7 +193,7 @@ function formatEvent(eventType: string, dataLines: readonly string[]): string {
|
|
|
193
193
|
* // With cookie forwarding (for auth)
|
|
194
194
|
* app.post("/signin", (c) => {
|
|
195
195
|
* return sse(c, async (stream) => {
|
|
196
|
-
* await stream.redirect('/
|
|
196
|
+
* await stream.redirect('/settings');
|
|
197
197
|
* }, { headers: { 'Set-Cookie': cookieValue } });
|
|
198
198
|
* });
|
|
199
199
|
* ```
|
|
@@ -304,10 +304,10 @@ export function sse(
|
|
|
304
304
|
*
|
|
305
305
|
* @example
|
|
306
306
|
* ```ts
|
|
307
|
-
* return dsRedirect("/
|
|
307
|
+
* return dsRedirect("/settings");
|
|
308
308
|
*
|
|
309
309
|
* // With cookie forwarding (for auth)
|
|
310
|
-
* return dsRedirect("/
|
|
310
|
+
* return dsRedirect("/settings", { headers: authResponse.headers });
|
|
311
311
|
* ```
|
|
312
312
|
*/
|
|
313
313
|
export function dsRedirect(
|