@jant/core 0.3.35 → 0.3.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/module-RjUF93sV.js +716 -0
- package/dist/client/assets/native-48B9X9Wg.js +1 -0
- package/dist/client/assets/url-FWFqPJPb.js +1 -0
- package/dist/client/client.css +1 -1
- package/dist/client/client.js +4564 -3013
- package/dist/index.js +12885 -8161
- package/package.json +23 -6
- package/src/__tests__/helpers/app.ts +10 -10
- package/src/__tests__/helpers/db.ts +91 -87
- package/src/app.tsx +157 -31
- 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/{lib → client}/avatar-upload.ts +4 -3
- package/src/{lib → client}/collection-form-bridge.ts +2 -2
- package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +1140 -0
- package/src/client/components/__tests__/jant-compose-editor.test.ts +504 -0
- package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +37 -17
- package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +2 -2
- package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
- package/src/client/components/collection-sidebar-types.ts +43 -0
- package/src/{ui → client}/components/collection-types.ts +3 -4
- package/src/client/components/compose-types.ts +174 -0
- package/src/client/components/jant-collection-form.ts +667 -0
- package/src/client/components/jant-collection-sidebar.ts +805 -0
- package/src/client/components/jant-compose-dialog.ts +2161 -0
- package/src/client/components/jant-compose-editor.ts +1813 -0
- package/src/client/components/jant-compose-fullscreen.ts +283 -0
- package/src/client/components/jant-media-lightbox.ts +259 -0
- package/src/{ui → client}/components/jant-nav-manager.ts +97 -298
- package/src/{ui → client}/components/jant-post-form.ts +141 -12
- package/src/client/components/jant-post-menu.ts +1019 -0
- package/src/{ui → client}/components/jant-settings-avatar.ts +3 -3
- package/src/{ui → client}/components/jant-settings-general.ts +38 -4
- package/src/client/components/jant-text-preview.ts +232 -0
- package/src/{ui → client}/components/nav-manager-types.ts +6 -18
- package/src/{ui → client}/components/post-form-template.ts +137 -38
- package/src/{ui → client}/components/post-form-types.ts +15 -4
- package/src/client/compose-bridge.ts +583 -0
- package/src/{lib → client}/image-processor.ts +26 -8
- package/src/client/lazy-slugify.ts +51 -0
- package/src/client/media-metadata.ts +247 -0
- package/src/client/multipart-upload.ts +160 -0
- package/src/{lib → client}/nav-manager-bridge.ts +1 -1
- package/src/{lib → client}/post-form-bridge.ts +53 -2
- package/src/{lib → client}/settings-bridge.ts +3 -15
- package/src/client/thread-context.ts +140 -0
- package/src/client/tiptap/bubble-menu.ts +205 -0
- package/src/client/tiptap/create-editor.ts +86 -0
- package/src/client/tiptap/exitable-marks.ts +73 -0
- package/src/client/tiptap/extensions.ts +65 -0
- package/src/client/tiptap/image-node.ts +482 -0
- package/src/client/tiptap/link-toolbar.ts +371 -0
- package/src/client/tiptap/more-break.ts +50 -0
- package/src/client/tiptap/paste-image.ts +129 -0
- package/src/client/tiptap/slash-commands.ts +438 -0
- package/src/{lib → client}/toast.ts +101 -3
- package/src/client/types/sortablejs.d.ts +44 -0
- package/src/client/upload-with-metadata.ts +54 -0
- package/src/client/video-processor.ts +207 -0
- package/src/client.ts +27 -17
- 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 -140
- 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 +783 -1087
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/zh-Hans.po +867 -812
- package/src/i18n/locales/zh-Hans.ts +1 -1
- package/src/i18n/locales/zh-Hant.po +878 -823
- 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__/resolve-config.test.ts +2 -2
- package/src/lib/__tests__/schemas.test.ts +186 -65
- 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__/url.test.ts +2 -2
- package/src/lib/__tests__/view.test.ts +140 -65
- package/src/lib/blurhash-placeholder.ts +102 -0
- package/src/lib/constants.ts +3 -1
- package/src/lib/emoji-catalog.ts +963 -0
- package/src/lib/errors.ts +11 -8
- package/src/lib/feed.ts +77 -31
- 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 +22 -12
- package/src/lib/nanoid.ts +29 -0
- package/src/lib/navigation.ts +1 -1
- package/src/lib/render.tsx +24 -5
- package/src/lib/resolve-config.ts +13 -2
- package/src/lib/schemas.ts +226 -58
- 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 +158 -0
- package/src/lib/theme.ts +11 -8
- package/src/lib/timeline.ts +76 -34
- package/src/lib/tiptap-render.ts +191 -0
- package/src/lib/tiptap-to-markdown.ts +305 -0
- package/src/lib/upload.ts +263 -14
- package/src/lib/url.ts +37 -22
- package/src/lib/view.ts +236 -55
- 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/error-handler.ts +3 -3
- package/src/middleware/onboarding.ts +1 -1
- package/src/middleware/secure-headers.ts +40 -0
- package/src/preset.css +83 -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 +57 -31
- 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 +81 -62
- package/src/routes/api/search.ts +3 -4
- package/src/routes/api/upload-multipart.ts +245 -0
- package/src/routes/api/upload.ts +92 -24
- package/src/routes/auth/__tests__/setup.test.ts +20 -60
- package/src/routes/auth/reset.tsx +5 -4
- package/src/routes/auth/setup.tsx +39 -31
- package/src/routes/auth/signin.tsx +13 -14
- package/src/routes/compose.tsx +27 -63
- package/src/routes/dash/__tests__/settings-avatar.test.ts +44 -9
- package/src/routes/dash/custom-urls.tsx +414 -0
- package/src/routes/dash/settings.tsx +475 -99
- package/src/routes/feed/__tests__/rss.test.ts +22 -23
- package/src/routes/feed/rss.ts +6 -2
- 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 +36 -18
- package/src/routes/pages/archive.tsx +177 -37
- package/src/routes/pages/collection.tsx +43 -14
- package/src/routes/pages/collections.tsx +11 -2
- package/src/routes/pages/featured.tsx +27 -3
- package/src/routes/pages/home.tsx +15 -14
- 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 +800 -230
- package/src/services/__tests__/search.test.ts +67 -10
- package/src/services/__tests__/settings.test.ts +3 -3
- 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 +764 -172
- package/src/services/search.ts +161 -74
- package/src/services/settings.ts +6 -2
- package/src/styles/components.css +293 -62
- package/src/styles/tokens.css +93 -5
- package/src/styles/ui.css +4349 -766
- package/src/types/bindings.ts +8 -0
- package/src/types/config.ts +34 -4
- package/src/types/constants.ts +17 -2
- package/src/types/entities.ts +83 -37
- package/src/types/operations.ts +20 -27
- package/src/types/props.ts +52 -17
- package/src/types/views.ts +48 -24
- package/src/ui/color-themes.ts +133 -23
- package/src/ui/compose/ComposeDialog.tsx +255 -16
- 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 +12 -2
- package/src/ui/dash/appearance/AdvancedContent.tsx +71 -59
- package/src/ui/dash/appearance/ColorThemeContent.tsx +48 -33
- package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
- package/src/ui/dash/appearance/NavigationContent.tsx +106 -135
- package/src/ui/dash/index.ts +0 -3
- package/src/ui/dash/settings/AccountContent.tsx +87 -146
- 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 +78 -0
- package/src/ui/dash/settings/GeneralContent.tsx +3 -62
- package/src/ui/dash/settings/SessionsContent.tsx +159 -0
- package/src/ui/dash/settings/SettingsRootContent.tsx +266 -0
- 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 +116 -103
- package/src/ui/pages/ArchivePage.tsx +923 -95
- package/src/ui/pages/CollectionPage.tsx +6 -35
- package/src/ui/pages/CollectionsPage.tsx +2 -1
- package/src/ui/pages/ComposePage.tsx +54 -0
- package/src/ui/pages/FeaturedPage.tsx +2 -1
- package/src/ui/pages/HomePage.tsx +1 -1
- package/src/ui/pages/PostPage.tsx +30 -45
- package/src/ui/pages/SearchPage.tsx +182 -38
- package/src/ui/shared/AdminBreadcrumb.tsx +29 -0
- package/src/ui/shared/CollectionsSidebar.tsx +239 -4
- package/src/ui/shared/MediaGallery.tsx +475 -41
- 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/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/meta/0003_snapshot.json +0 -821
- package/src/lib/__tests__/sqid.test.ts +0 -65
- package/src/lib/collections-reorder.ts +0 -28
- package/src/lib/compose-bridge.ts +0 -280
- package/src/lib/media-upload.ts +0 -148
- 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/appearance.tsx +0 -240
- package/src/routes/dash/collections.tsx +0 -211
- package/src/routes/dash/index.tsx +0 -103
- package/src/routes/dash/media.tsx +0 -132
- package/src/routes/dash/pages.tsx +0 -239
- package/src/routes/dash/posts.tsx +0 -334
- package/src/routes/dash/redirects.tsx +0 -257
- 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 -203
- package/src/services/path-registry.ts +0 -160
- package/src/services/redirect.ts +0 -97
- package/src/types/sortablejs.d.ts +0 -29
- package/src/ui/components/__tests__/jant-compose-dialog.test.ts +0 -512
- package/src/ui/components/__tests__/jant-compose-editor.test.ts +0 -272
- package/src/ui/components/compose-types.ts +0 -75
- package/src/ui/components/jant-collection-form.ts +0 -512
- package/src/ui/components/jant-compose-dialog.ts +0 -495
- package/src/ui/components/jant-compose-editor.ts +0 -814
- package/src/ui/dash/PageForm.tsx +0 -185
- package/src/ui/dash/PostList.tsx +0 -95
- package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
- package/src/ui/dash/collections/CollectionForm.tsx +0 -166
- package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
- package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
- package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
- package/src/ui/dash/media/MediaListContent.tsx +0 -201
- package/src/ui/dash/media/ViewMediaContent.tsx +0 -208
- package/src/ui/dash/pages/PagesContent.tsx +0 -74
- package/src/ui/dash/posts/PostForm.tsx +0 -248
- package/src/ui/dash/settings/SettingsNav.tsx +0 -52
- package/src/ui/layouts/DashLayout.tsx +0 -165
- package/src/ui/pages/SinglePage.tsx +0 -23
- package/src/ui/shared/ThreadView.tsx +0 -136
- /package/src/{ui → client}/components/settings-types.ts +0 -0
package/src/lib/schemas.ts
CHANGED
|
@@ -12,11 +12,48 @@ import { z } from "zod";
|
|
|
12
12
|
import {
|
|
13
13
|
FORMATS,
|
|
14
14
|
STATUSES,
|
|
15
|
+
VISIBILITIES,
|
|
15
16
|
SORT_ORDERS,
|
|
16
17
|
NAV_ITEM_TYPES,
|
|
17
18
|
MAX_MEDIA_ATTACHMENTS,
|
|
18
19
|
} from "../types.js";
|
|
19
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
|
+
}
|
|
20
57
|
|
|
21
58
|
/**
|
|
22
59
|
* Post format enum schema
|
|
@@ -46,6 +83,15 @@ export const NavItemTypeSchema = z.enum(NAV_ITEM_TYPES);
|
|
|
46
83
|
*/
|
|
47
84
|
export const RedirectTypeSchema = z.enum(["301", "302"]);
|
|
48
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Custom URL target type enum schema
|
|
88
|
+
*/
|
|
89
|
+
export const CustomUrlTargetTypeSchema = z.enum([
|
|
90
|
+
"post",
|
|
91
|
+
"collection",
|
|
92
|
+
"redirect",
|
|
93
|
+
]);
|
|
94
|
+
|
|
49
95
|
/**
|
|
50
96
|
* Rating schema (1-5 integer)
|
|
51
97
|
*/
|
|
@@ -59,75 +105,160 @@ export const RatingSchema = z.coerce
|
|
|
59
105
|
.transform((v) => (v === 0 ? undefined : v));
|
|
60
106
|
|
|
61
107
|
/**
|
|
62
|
-
*
|
|
108
|
+
* Base post fields (shared between create and update schemas)
|
|
63
109
|
*/
|
|
64
|
-
|
|
110
|
+
const PostFieldsSchema = z.object({
|
|
65
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)),
|
|
66
124
|
path: z
|
|
67
125
|
.string()
|
|
68
|
-
.
|
|
126
|
+
.min(1)
|
|
127
|
+
.transform(normalizePath)
|
|
128
|
+
.pipe(z.string().min(1))
|
|
129
|
+
.optional()
|
|
130
|
+
.or(z.literal("").transform(() => undefined)),
|
|
131
|
+
title: sanitizeText(300)
|
|
69
132
|
.optional()
|
|
70
133
|
.or(z.literal("").transform(() => undefined)),
|
|
71
|
-
title: z.string().optional(),
|
|
72
134
|
body: z.string().optional(),
|
|
135
|
+
bodyMarkdown: z.string().optional(),
|
|
73
136
|
status: StatusSchema.optional(),
|
|
74
|
-
|
|
75
|
-
.union([z.boolean(), z.literal("on").transform(() => true)])
|
|
76
|
-
.optional(),
|
|
137
|
+
visibility: z.enum(VISIBILITIES).optional(),
|
|
77
138
|
pinned: z
|
|
78
139
|
.union([z.boolean(), z.literal("on").transform(() => true)])
|
|
79
140
|
.optional(),
|
|
80
|
-
|
|
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("")),
|
|
81
149
|
quoteText: z.string().optional(),
|
|
82
150
|
rating: RatingSchema,
|
|
83
151
|
collectionIds: z
|
|
84
|
-
.array(z.
|
|
152
|
+
.array(z.string().min(1))
|
|
85
153
|
.optional()
|
|
86
154
|
.or(z.literal("").transform(() => undefined)),
|
|
87
|
-
replyToId: z.string().optional(),
|
|
155
|
+
replyToId: z.string().optional(),
|
|
88
156
|
publishedAt: z.number().int().positive().optional(),
|
|
89
157
|
mediaIds: z.array(z.string()).max(MAX_MEDIA_ATTACHMENTS).optional(),
|
|
90
158
|
mediaAlts: z.record(z.string(), z.string()).optional(),
|
|
91
159
|
});
|
|
92
160
|
|
|
93
|
-
/**
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
}
|
|
97
235
|
|
|
98
236
|
/**
|
|
99
|
-
* API request body schema for creating a
|
|
237
|
+
* API request body schema for creating a post
|
|
100
238
|
*/
|
|
101
|
-
export const
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
.min(1)
|
|
105
|
-
.transform(normalizeSlug)
|
|
106
|
-
.pipe(
|
|
107
|
-
z
|
|
108
|
-
.string()
|
|
109
|
-
.min(1)
|
|
110
|
-
.regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/),
|
|
111
|
-
),
|
|
112
|
-
title: z.string().optional(),
|
|
113
|
-
body: z.string().optional(),
|
|
114
|
-
status: StatusSchema.optional(),
|
|
115
|
-
});
|
|
239
|
+
export const CreatePostSchema = refineSlugPathExclusivity(
|
|
240
|
+
refineCreatePostFormatShape(refineBodyExclusivity(PostFieldsSchema)),
|
|
241
|
+
);
|
|
116
242
|
|
|
117
243
|
/**
|
|
118
|
-
* API request body schema for updating a
|
|
244
|
+
* API request body schema for updating a post
|
|
119
245
|
*/
|
|
120
|
-
export const
|
|
246
|
+
export const UpdatePostSchema = refineSlugPathExclusivity(
|
|
247
|
+
refineBodyExclusivity(PostFieldsSchema.partial()),
|
|
248
|
+
);
|
|
121
249
|
|
|
122
250
|
/**
|
|
123
251
|
* API request body schema for creating a navigation item
|
|
124
252
|
*/
|
|
125
253
|
export const CreateNavItemSchema = z.object({
|
|
126
254
|
type: NavItemTypeSchema,
|
|
127
|
-
label: z.string().min(1),
|
|
128
|
-
url: z
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
}),
|
|
131
262
|
});
|
|
132
263
|
|
|
133
264
|
/**
|
|
@@ -149,11 +280,29 @@ export const CreateCollectionSchema = z.object({
|
|
|
149
280
|
.min(1)
|
|
150
281
|
.regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/),
|
|
151
282
|
),
|
|
152
|
-
title: z.string().min(1),
|
|
153
|
-
description:
|
|
154
|
-
|
|
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
|
+
),
|
|
155
305
|
sortOrder: SortOrderSchema.optional(),
|
|
156
|
-
position: z.coerce.number().int().min(0).optional(),
|
|
157
306
|
});
|
|
158
307
|
|
|
159
308
|
/**
|
|
@@ -161,6 +310,24 @@ export const CreateCollectionSchema = z.object({
|
|
|
161
310
|
*/
|
|
162
311
|
export const UpdateCollectionSchema = CreateCollectionSchema.partial();
|
|
163
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
|
+
|
|
164
331
|
// =============================================================================
|
|
165
332
|
// Auth Schemas
|
|
166
333
|
// =============================================================================
|
|
@@ -169,17 +336,26 @@ export const UpdateCollectionSchema = CreateCollectionSchema.partial();
|
|
|
169
336
|
* Setup form validation schema
|
|
170
337
|
*/
|
|
171
338
|
export const SetupSchema = z.object({
|
|
172
|
-
|
|
173
|
-
email: z
|
|
174
|
-
|
|
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),
|
|
175
348
|
});
|
|
176
349
|
|
|
177
350
|
/**
|
|
178
351
|
* Sign-in form validation schema
|
|
179
352
|
*/
|
|
180
353
|
export const SigninSchema = z.object({
|
|
181
|
-
email: z
|
|
182
|
-
|
|
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),
|
|
183
359
|
});
|
|
184
360
|
|
|
185
361
|
/**
|
|
@@ -187,7 +363,10 @@ export const SigninSchema = z.object({
|
|
|
187
363
|
*/
|
|
188
364
|
export const ResetPasswordSchema = z
|
|
189
365
|
.object({
|
|
190
|
-
password: z
|
|
366
|
+
password: z
|
|
367
|
+
.string()
|
|
368
|
+
.min(8, "Password must be at least 8 characters")
|
|
369
|
+
.max(128),
|
|
191
370
|
confirmPassword: z.string().min(1),
|
|
192
371
|
token: z.string().min(1),
|
|
193
372
|
})
|
|
@@ -221,17 +400,6 @@ export function normalizeSlug(s: string): string {
|
|
|
221
400
|
.replace(/^-|-$/g, "");
|
|
222
401
|
}
|
|
223
402
|
|
|
224
|
-
// =============================================================================
|
|
225
|
-
// Reorder Schemas
|
|
226
|
-
// =============================================================================
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Reorder request schema for simple ID-based reordering
|
|
230
|
-
*/
|
|
231
|
-
export const ReorderSchema = z.object({
|
|
232
|
-
ids: z.array(z.coerce.number().int().positive()),
|
|
233
|
-
});
|
|
234
|
-
|
|
235
403
|
// =============================================================================
|
|
236
404
|
// Form Data Helpers
|
|
237
405
|
// =============================================================================
|
|
@@ -300,7 +468,7 @@ export function validateMediaCount(mediaIds: string[]): string | null {
|
|
|
300
468
|
* @returns Validated data
|
|
301
469
|
* @example
|
|
302
470
|
* ```ts
|
|
303
|
-
* const body = parseValidated(
|
|
471
|
+
* const body = parseValidated(CreatePostSchema, await c.req.json());
|
|
304
472
|
* ```
|
|
305
473
|
*/
|
|
306
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(
|