@jant/core 0.3.35 → 0.3.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/dist/client/assets/module-RjUF93sV.js +716 -0
  2. package/dist/client/assets/native-48B9X9Wg.js +1 -0
  3. package/dist/client/assets/url-8Dj-5CLW.js +1 -0
  4. package/dist/client/client.css +1 -1
  5. package/dist/client/client.js +3109 -2294
  6. package/dist/index.js +3026 -2778
  7. package/package.json +13 -4
  8. package/src/__tests__/helpers/app.ts +1 -1
  9. package/src/__tests__/helpers/db.ts +6 -0
  10. package/src/app.tsx +1 -5
  11. package/src/{lib → client}/avatar-upload.ts +1 -1
  12. package/src/{lib → client}/collection-form-bridge.ts +2 -2
  13. package/src/{ui → client}/components/__tests__/jant-collection-form.test.ts +26 -9
  14. package/src/{ui → client}/components/__tests__/jant-compose-dialog.test.ts +46 -14
  15. package/src/{ui → client}/components/__tests__/jant-compose-editor.test.ts +64 -24
  16. package/src/{ui → client}/components/__tests__/jant-post-form.test.ts +24 -14
  17. package/src/{ui → client}/components/__tests__/jant-settings-general.test.ts +3 -3
  18. package/src/client/components/collection-sidebar-types.ts +45 -0
  19. package/src/{ui → client}/components/collection-types.ts +3 -4
  20. package/src/{ui → client}/components/compose-types.ts +3 -1
  21. package/src/{ui → client}/components/jant-collection-form.ts +301 -182
  22. package/src/client/components/jant-collection-sidebar.ts +801 -0
  23. package/src/{ui → client}/components/jant-compose-dialog.ts +231 -1
  24. package/src/client/components/jant-compose-editor.ts +1249 -0
  25. package/src/client/components/jant-compose-fullscreen.ts +338 -0
  26. package/src/client/components/jant-media-lightbox.ts +257 -0
  27. package/src/{ui → client}/components/jant-nav-manager.ts +143 -84
  28. package/src/{ui → client}/components/jant-post-form.ts +57 -8
  29. package/src/{ui → client}/components/jant-settings-general.ts +2 -2
  30. package/src/{ui → client}/components/nav-manager-types.ts +3 -0
  31. package/src/{ui → client}/components/post-form-template.ts +35 -31
  32. package/src/{ui → client}/components/post-form-types.ts +7 -3
  33. package/src/{lib → client}/compose-bridge.ts +9 -7
  34. package/src/client/lazy-slugify.ts +51 -0
  35. package/src/{lib → client}/media-upload.ts +16 -3
  36. package/src/{lib → client}/nav-manager-bridge.ts +1 -1
  37. package/src/client/page-slug-bridge.ts +42 -0
  38. package/src/{lib → client}/post-form-bridge.ts +2 -2
  39. package/src/{lib → client}/settings-bridge.ts +3 -3
  40. package/src/client/tiptap/bubble-menu.ts +205 -0
  41. package/src/client/tiptap/create-editor.ts +40 -0
  42. package/src/client/tiptap/exitable-marks.ts +73 -0
  43. package/src/client/tiptap/extensions.ts +60 -0
  44. package/src/client/tiptap/image-node.ts +488 -0
  45. package/src/client/tiptap/link-toolbar.ts +371 -0
  46. package/src/client/tiptap/more-break.ts +50 -0
  47. package/src/client/tiptap/paste-image.ts +140 -0
  48. package/src/client/tiptap/slash-commands.ts +328 -0
  49. package/src/{types → client/types}/sortablejs.d.ts +1 -1
  50. package/src/client.ts +24 -17
  51. package/src/db/migrations/0012_add_tiptap_columns.sql +2 -0
  52. package/src/db/migrations/0013_replace_featured_with_visibility.sql +8 -0
  53. package/src/db/schema.ts +6 -1
  54. package/src/i18n/locales/en.po +641 -215
  55. package/src/i18n/locales/en.ts +1 -1
  56. package/src/i18n/locales/zh-Hans.po +642 -204
  57. package/src/i18n/locales/zh-Hans.ts +1 -1
  58. package/src/i18n/locales/zh-Hant.po +642 -204
  59. package/src/i18n/locales/zh-Hant.ts +1 -1
  60. package/src/lib/__tests__/resolve-config.test.ts +2 -2
  61. package/src/lib/__tests__/schemas.test.ts +9 -6
  62. package/src/lib/__tests__/url.test.ts +2 -2
  63. package/src/lib/__tests__/view.test.ts +9 -9
  64. package/src/lib/emoji-catalog.ts +146 -0
  65. package/src/lib/feed.ts +1 -1
  66. package/src/lib/media-helpers.ts +10 -9
  67. package/src/lib/render.tsx +4 -3
  68. package/src/lib/resolve-config.ts +8 -1
  69. package/src/lib/schemas.ts +2 -3
  70. package/src/lib/summary.ts +92 -0
  71. package/src/lib/timeline.ts +2 -0
  72. package/src/lib/tiptap-render.ts +196 -0
  73. package/src/lib/upload.ts +97 -9
  74. package/src/lib/url.ts +7 -23
  75. package/src/lib/view.ts +33 -19
  76. package/src/middleware/error-handler.ts +3 -3
  77. package/src/preset.css +38 -0
  78. package/src/routes/api/collections.ts +20 -3
  79. package/src/routes/api/posts.ts +48 -33
  80. package/src/routes/api/upload.ts +7 -5
  81. package/src/routes/auth/reset.tsx +5 -4
  82. package/src/routes/auth/setup.tsx +26 -11
  83. package/src/routes/auth/signin.tsx +10 -7
  84. package/src/routes/compose.tsx +20 -11
  85. package/src/routes/dash/__tests__/settings-avatar.test.ts +43 -8
  86. package/src/routes/dash/index.tsx +7 -1
  87. package/src/routes/dash/media.tsx +3 -0
  88. package/src/routes/dash/pages.tsx +8 -2
  89. package/src/routes/dash/posts.tsx +6 -2
  90. package/src/routes/dash/redirects.tsx +15 -9
  91. package/src/routes/dash/settings.tsx +336 -32
  92. package/src/routes/feed/__tests__/rss.test.ts +7 -7
  93. package/src/routes/feed/rss.ts +8 -6
  94. package/src/routes/pages/__tests__/featured.test.ts +6 -7
  95. package/src/routes/pages/archive.tsx +11 -7
  96. package/src/routes/pages/collection.tsx +32 -15
  97. package/src/routes/pages/collections.tsx +11 -2
  98. package/src/routes/pages/featured.tsx +1 -1
  99. package/src/routes/pages/home.tsx +1 -1
  100. package/src/services/__tests__/post.test.ts +124 -33
  101. package/src/services/__tests__/settings.test.ts +3 -3
  102. package/src/services/page.ts +16 -3
  103. package/src/services/post.ts +96 -37
  104. package/src/services/search.ts +4 -2
  105. package/src/services/settings.ts +6 -2
  106. package/src/styles/components.css +240 -60
  107. package/src/styles/tokens.css +10 -0
  108. package/src/styles/ui.css +1157 -81
  109. package/src/types/bindings.ts +5 -0
  110. package/src/types/config.ts +23 -1
  111. package/src/types/constants.ts +3 -0
  112. package/src/types/entities.ts +9 -2
  113. package/src/types/operations.ts +9 -3
  114. package/src/types/props.ts +3 -3
  115. package/src/types/views.ts +3 -2
  116. package/src/ui/compose/ComposeDialog.tsx +24 -7
  117. package/src/ui/dash/PageForm.tsx +2 -0
  118. package/src/ui/dash/PostList.tsx +5 -5
  119. package/src/ui/dash/StatusBadge.tsx +13 -5
  120. package/src/ui/dash/appearance/AdvancedContent.tsx +52 -61
  121. package/src/ui/dash/appearance/ColorThemeContent.tsx +30 -35
  122. package/src/ui/dash/appearance/FontThemeContent.tsx +65 -73
  123. package/src/ui/dash/appearance/NavigationContent.tsx +107 -96
  124. package/src/ui/dash/media/MediaListContent.tsx +9 -4
  125. package/src/ui/dash/media/ViewMediaContent.tsx +2 -2
  126. package/src/ui/dash/pages/PagesContent.tsx +2 -1
  127. package/src/ui/dash/posts/PostForm.tsx +19 -7
  128. package/src/ui/dash/settings/AccountContent.tsx +133 -138
  129. package/src/ui/dash/settings/AvatarContent.tsx +70 -0
  130. package/src/ui/dash/settings/GeneralContent.tsx +3 -62
  131. package/src/ui/dash/settings/SettingsRootContent.tsx +236 -0
  132. package/src/ui/layouts/DashLayout.tsx +157 -75
  133. package/src/ui/layouts/SiteLayout.tsx +13 -13
  134. package/src/ui/pages/ArchivePage.tsx +10 -7
  135. package/src/ui/pages/CollectionPage.tsx +6 -35
  136. package/src/ui/pages/CollectionsPage.tsx +2 -1
  137. package/src/ui/pages/FeaturedPage.tsx +2 -1
  138. package/src/ui/pages/HomePage.tsx +1 -1
  139. package/src/ui/pages/SearchPage.tsx +1 -1
  140. package/src/ui/shared/CollectionsSidebar.tsx +228 -3
  141. package/src/ui/shared/MediaGallery.tsx +179 -41
  142. package/src/lib/collections-reorder.ts +0 -28
  143. package/src/routes/dash/appearance.tsx +0 -240
  144. package/src/routes/dash/collections.tsx +0 -211
  145. package/src/ui/components/jant-compose-editor.ts +0 -814
  146. package/src/ui/dash/appearance/AppearanceNav.tsx +0 -60
  147. package/src/ui/dash/collections/CollectionForm.tsx +0 -166
  148. package/src/ui/dash/collections/CollectionsListContent.tsx +0 -146
  149. package/src/ui/dash/collections/IconPickerGrid.tsx +0 -50
  150. package/src/ui/dash/collections/ViewCollectionContent.tsx +0 -103
  151. package/src/ui/dash/settings/SettingsNav.tsx +0 -52
  152. /package/src/{ui → client}/components/__tests__/jant-settings-avatar.test.ts +0 -0
  153. /package/src/{ui → client}/components/jant-settings-avatar.ts +0 -0
  154. /package/src/{ui → client}/components/settings-types.ts +0 -0
  155. /package/src/{lib → client}/image-processor.ts +0 -0
  156. /package/src/{lib → client}/toast.ts +0 -0
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Tiptap JSON → HTML Renderer
3
+ *
4
+ * Lightweight server-side renderer that converts Tiptap JSON documents
5
+ * to HTML strings. Pure string concatenation — no DOM required.
6
+ * Works on Cloudflare Workers and any JS runtime.
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
+ function escapeHtml(str: string): string {
23
+ return str
24
+ .replace(/&/g, "&amp;")
25
+ .replace(/</g, "&lt;")
26
+ .replace(/>/g, "&gt;")
27
+ .replace(/"/g, "&quot;");
28
+ }
29
+
30
+ function renderMarks(text: string, marks: TiptapMark[]): string {
31
+ let result = escapeHtml(text);
32
+
33
+ for (const mark of marks) {
34
+ switch (mark.type) {
35
+ case "bold":
36
+ result = `<strong>${result}</strong>`;
37
+ break;
38
+ case "italic":
39
+ result = `<em>${result}</em>`;
40
+ break;
41
+ case "strike":
42
+ result = `<s>${result}</s>`;
43
+ break;
44
+ case "code":
45
+ result = `<code>${result}</code>`;
46
+ break;
47
+ case "link": {
48
+ const href = escapeHtml(String(mark.attrs?.href ?? ""));
49
+ const target = mark.attrs?.target
50
+ ? ` target="${escapeHtml(String(mark.attrs.target))}"`
51
+ : "";
52
+ const rel = mark.attrs?.target
53
+ ? ' rel="noopener noreferrer nofollow"'
54
+ : "";
55
+ result = `<a href="${href}"${target}${rel}>${result}</a>`;
56
+ break;
57
+ }
58
+ }
59
+ }
60
+
61
+ return result;
62
+ }
63
+
64
+ function renderNode(node: TiptapNode): string {
65
+ switch (node.type) {
66
+ case "doc":
67
+ return (node.content ?? []).map(renderNode).join("");
68
+
69
+ case "paragraph":
70
+ return `<p>${renderChildren(node)}</p>`;
71
+
72
+ case "heading": {
73
+ const level = Math.min(Math.max(Number(node.attrs?.level ?? 1), 1), 6);
74
+ return `<h${level}>${renderChildren(node)}</h${level}>`;
75
+ }
76
+
77
+ case "text":
78
+ if (node.marks && node.marks.length > 0) {
79
+ return renderMarks(node.text ?? "", node.marks);
80
+ }
81
+ return escapeHtml(node.text ?? "");
82
+
83
+ case "bulletList":
84
+ return `<ul>${renderChildren(node)}</ul>`;
85
+
86
+ case "orderedList": {
87
+ const start = node.attrs?.start;
88
+ const startAttr = start && start !== 1 ? ` start="${start}"` : "";
89
+ return `<ol${startAttr}>${renderChildren(node)}</ol>`;
90
+ }
91
+
92
+ case "listItem":
93
+ return `<li>${renderChildren(node)}</li>`;
94
+
95
+ case "blockquote":
96
+ return `<blockquote>${renderChildren(node)}</blockquote>`;
97
+
98
+ case "codeBlock": {
99
+ const lang = node.attrs?.language;
100
+ const langClass = lang
101
+ ? ` class="language-${escapeHtml(String(lang))}"`
102
+ : "";
103
+ return `<pre><code${langClass}>${renderChildren(node)}</code></pre>`;
104
+ }
105
+
106
+ case "table":
107
+ return `<table>${renderChildren(node)}</table>`;
108
+
109
+ case "tableRow":
110
+ return `<tr>${renderChildren(node)}</tr>`;
111
+
112
+ case "tableCell": {
113
+ const colspan = node.attrs?.colspan;
114
+ const rowspan = node.attrs?.rowspan;
115
+ const colspanAttr =
116
+ colspan && colspan !== 1 ? ` colspan="${colspan}"` : "";
117
+ const rowspanAttr =
118
+ rowspan && rowspan !== 1 ? ` rowspan="${rowspan}"` : "";
119
+ return `<td${colspanAttr}${rowspanAttr}>${renderChildren(node)}</td>`;
120
+ }
121
+
122
+ case "tableHeader": {
123
+ const thColspan = node.attrs?.colspan;
124
+ const thRowspan = node.attrs?.rowspan;
125
+ const thColspanAttr =
126
+ thColspan && thColspan !== 1 ? ` colspan="${thColspan}"` : "";
127
+ const thRowspanAttr =
128
+ thRowspan && thRowspan !== 1 ? ` rowspan="${thRowspan}"` : "";
129
+ return `<th${thColspanAttr}${thRowspanAttr}>${renderChildren(node)}</th>`;
130
+ }
131
+
132
+ case "horizontalRule":
133
+ return "<hr>";
134
+
135
+ case "hardBreak":
136
+ return "<br>";
137
+
138
+ case "image": {
139
+ const src = escapeHtml(String(node.attrs?.src ?? ""));
140
+ const alt = node.attrs?.alt
141
+ ? ` alt="${escapeHtml(String(node.attrs.alt))}"`
142
+ : "";
143
+ const title = node.attrs?.title
144
+ ? ` title="${escapeHtml(String(node.attrs.title))}"`
145
+ : "";
146
+ const caption = node.attrs?.caption ? String(node.attrs.caption) : "";
147
+ const layout = node.attrs?.layout ?? "regular";
148
+ const href = node.attrs?.href ? String(node.attrs.href) : "";
149
+ const layoutAttr =
150
+ layout !== "regular"
151
+ ? ` data-layout="${escapeHtml(String(layout))}"`
152
+ : "";
153
+ const imgTag = `<img src="${src}"${alt}${title}>`;
154
+ const linkedImg = href
155
+ ? `<a href="${escapeHtml(href)}">${imgTag}</a>`
156
+ : imgTag;
157
+ const figcaption = caption
158
+ ? `<figcaption>${escapeHtml(caption)}</figcaption>`
159
+ : "";
160
+ return `<figure${layoutAttr}>${linkedImg}${figcaption}</figure>`;
161
+ }
162
+
163
+ case "moreBreak":
164
+ return "<!--more-->";
165
+
166
+ default:
167
+ // Unknown node: render children if any, skip otherwise
168
+ return node.content ? renderChildren(node) : "";
169
+ }
170
+ }
171
+
172
+ function renderChildren(node: TiptapNode): string {
173
+ return (node.content ?? []).map(renderNode).join("");
174
+ }
175
+
176
+ /**
177
+ * Renders a Tiptap JSON document to an HTML string.
178
+ *
179
+ * @param json - Tiptap JSON string or parsed document object
180
+ * @returns HTML string
181
+ *
182
+ * @example
183
+ * ```ts
184
+ * const html = renderTiptapJson('{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":"Hello"}]}]}');
185
+ * // "<p>Hello</p>"
186
+ * ```
187
+ */
188
+ export function renderTiptapJson(json: string): string {
189
+ try {
190
+ const doc = JSON.parse(json) as TiptapNode;
191
+ if (doc.type !== "doc") return "";
192
+ return renderNode(doc);
193
+ } catch {
194
+ return "";
195
+ }
196
+ }
package/src/lib/upload.ts CHANGED
@@ -6,8 +6,8 @@
6
6
 
7
7
  import { uuidv7 } from "uuidv7";
8
8
 
9
- /** MIME types allowed for upload */
10
- const ALLOWED_UPLOAD_TYPES = [
9
+ /** MIME types allowed for upload — images */
10
+ const IMAGE_MIME_TYPES = [
11
11
  "image/jpeg",
12
12
  "image/png",
13
13
  "image/gif",
@@ -15,30 +15,118 @@ const ALLOWED_UPLOAD_TYPES = [
15
15
  "image/svg+xml",
16
16
  ] as const;
17
17
 
18
- /** Maximum file size in bytes (10MB) */
19
- const MAX_UPLOAD_SIZE = 10 * 1024 * 1024;
18
+ /** MIME types allowed for upload video */
19
+ const VIDEO_MIME_TYPES = [
20
+ "video/mp4",
21
+ "video/webm",
22
+ "video/quicktime",
23
+ ] as const;
24
+
25
+ /** MIME types allowed for upload — audio */
26
+ const AUDIO_MIME_TYPES = [
27
+ "audio/mpeg",
28
+ "audio/ogg",
29
+ "audio/wav",
30
+ "audio/mp4",
31
+ "audio/x-m4a",
32
+ ] as const;
33
+
34
+ /** MIME types allowed for upload — documents */
35
+ const DOCUMENT_MIME_TYPES = ["application/pdf"] as const;
36
+
37
+ /** All allowed MIME types */
38
+ const ALLOWED_UPLOAD_TYPES = [
39
+ ...IMAGE_MIME_TYPES,
40
+ ...VIDEO_MIME_TYPES,
41
+ ...AUDIO_MIME_TYPES,
42
+ ...DOCUMENT_MIME_TYPES,
43
+ ] as const;
44
+
45
+ /**
46
+ * Accept string for file inputs, covering all allowed upload types.
47
+ *
48
+ * @example
49
+ * ```ts
50
+ * <input type="file" accept={UPLOAD_ACCEPT} />
51
+ * ```
52
+ */
53
+ export const UPLOAD_ACCEPT = (ALLOWED_UPLOAD_TYPES as readonly string[]).join(
54
+ ",",
55
+ );
56
+
57
+ export type MediaCategory = "image" | "video" | "audio" | "document";
58
+
59
+ /**
60
+ * Returns the media category for a given MIME type.
61
+ *
62
+ * @param mimeType - The MIME type to classify
63
+ * @returns The media category, or null if the MIME type is not supported
64
+ * @example
65
+ * ```ts
66
+ * getMediaCategory("video/mp4"); // "video"
67
+ * getMediaCategory("text/plain"); // null
68
+ * ```
69
+ */
70
+ export function getMediaCategory(mimeType: string): MediaCategory | null {
71
+ if (mimeType.startsWith("image/")) return "image";
72
+ if (mimeType.startsWith("video/")) return "video";
73
+ if (mimeType.startsWith("audio/")) return "audio";
74
+ if (mimeType === "application/pdf") return "document";
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * Returns true if the given MIME type is an image type.
80
+ *
81
+ * @param mimeType - The MIME type to check
82
+ * @returns Whether the MIME type is an image
83
+ * @example
84
+ * ```ts
85
+ * isImageMimeType("image/jpeg"); // true
86
+ * isImageMimeType("video/mp4"); // false
87
+ * ```
88
+ */
89
+ export function isImageMimeType(mimeType: string): boolean {
90
+ return mimeType.startsWith("image/");
91
+ }
92
+
93
+ export interface ValidateUploadOptions {
94
+ /** When true, only image MIME types are accepted (e.g. for avatar uploads). */
95
+ imagesOnly?: boolean;
96
+ /** Max file size in MB. */
97
+ maxFileSizeMB: number;
98
+ }
20
99
 
21
100
  /**
22
101
  * Validates an uploaded file's type and size.
23
102
  *
24
103
  * @param file - The uploaded File object
104
+ * @param options - Validation constraints
25
105
  * @returns null if valid, error message string if invalid
26
106
  * @example
27
107
  * ```ts
28
- * const error = validateUploadFile(file);
108
+ * const error = validateUploadFile(file, { maxFileSizeMB: 500 });
29
109
  * if (error) return dsToast(error, "error");
30
110
  * ```
31
111
  */
32
- export function validateUploadFile(file: File): string | null {
33
- if (
112
+ export function validateUploadFile(
113
+ file: File,
114
+ options: ValidateUploadOptions,
115
+ ): string | null {
116
+ if (options?.imagesOnly) {
117
+ if (!isImageMimeType(file.type)) {
118
+ return "File type not allowed.";
119
+ }
120
+ } else if (
34
121
  !ALLOWED_UPLOAD_TYPES.includes(
35
122
  file.type as (typeof ALLOWED_UPLOAD_TYPES)[number],
36
123
  )
37
124
  ) {
38
125
  return "File type not allowed.";
39
126
  }
40
- if (file.size > MAX_UPLOAD_SIZE) {
41
- return "File too large (max 10MB).";
127
+ const maxMB = options.maxFileSizeMB;
128
+ if (file.size > maxMB * 1024 * 1024) {
129
+ return `File too large (max ${maxMB}MB).`;
42
130
  }
43
131
  return null;
44
132
  }
package/src/lib/url.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * URL Utilities
3
3
  */
4
4
 
5
- import { pinyin } from "pinyin-pro";
5
+ import limax from "limax";
6
6
 
7
7
  /**
8
8
  * Extracts the hostname (domain) from a URL string.
@@ -79,37 +79,21 @@ export function isFullUrl(str: string): boolean {
79
79
  /**
80
80
  * Converts text to a URL-friendly slug.
81
81
  *
82
- * Transforms text into a lowercase, hyphen-separated slug by:
83
- * - Converting to lowercase
84
- * - Removing special characters (keeping only word characters, spaces, and hyphens)
85
- * - Replacing whitespace and underscores with hyphens
86
- * - Removing leading and trailing hyphens
87
- *
88
- * Used for generating clean URLs from titles and names.
82
+ * Transforms text into a lowercase, hyphen-separated slug using limax for
83
+ * i18n-aware transliteration (CJK → Pinyin, Japanese → Romaji, accented → ASCII).
89
84
  *
90
85
  * @param text - The text to convert to a slug
91
86
  * @returns The slugified string
92
87
  *
93
88
  * @example
94
89
  * ```ts
95
- * const slug = slugify("Hello World! This is a Test.");
90
+ * slugify("Hello World! This is a Test.");
96
91
  * // Returns: "hello-world-this-is-a-test"
97
92
  *
98
- * const slug = slugify(" Multiple Spaces ");
99
- * // Returns: "multiple-spaces"
93
+ * slugify("书评");
94
+ * // Returns: "shu-ping"
100
95
  * ```
101
96
  */
102
97
  export function slugify(text: string): string {
103
- // Replace CJK characters with their pinyin equivalents, preserving non-CJK text
104
- const converted = text.replace(
105
- /[\u4e00-\u9fff\u3400-\u4dbf]+/g,
106
- (match) => ` ${pinyin(match, { toneType: "none", separator: " " })} `,
107
- );
108
-
109
- return converted
110
- .toLowerCase()
111
- .trim()
112
- .replace(/[^\w\s-]/g, "")
113
- .replace(/[\s_-]+/g, "-")
114
- .replace(/^-+|-+$/g, "");
98
+ return limax(text, { tone: false }).replace(/_/g, "-");
115
99
  }
package/src/lib/view.ts CHANGED
@@ -78,13 +78,17 @@ export function toMediaView(media: Media, ctx: MediaContext): MediaView {
78
78
  ctx.s3PublicUrl,
79
79
  );
80
80
  const url = getMediaUrl(media.storageKey, publicUrl);
81
- const thumbnailUrl = getImageUrl(url, ctx.imageTransformUrl, {
82
- width: 1200,
83
- height: 768,
84
- quality: 80,
85
- format: "auto",
86
- fit: "scale-down",
87
- });
81
+
82
+ // Only apply image transforms for image MIME types
83
+ const thumbnailUrl = media.mimeType.startsWith("image/")
84
+ ? getImageUrl(url, ctx.imageTransformUrl, {
85
+ width: 1200,
86
+ height: 768,
87
+ quality: 80,
88
+ format: "auto",
89
+ fit: "scale-down",
90
+ })
91
+ : url;
88
92
 
89
93
  return {
90
94
  id: media.id,
@@ -124,17 +128,27 @@ export function toPostView(post: PostWithMedia, _ctx: MediaContext): PostView {
124
128
  let summaryHasMore: boolean | undefined;
125
129
  let bodyHtmlWithAnchor = post.bodyHtml;
126
130
  if (post.title && post.bodyHtml) {
127
- const result = getHtmlExcerpt(post.bodyHtml);
128
- summaryHtml = result.excerpt;
129
- summaryHasMore = result.hasMore;
130
-
131
- // Inject #continue anchor at the excerpt boundary for scroll targeting
132
- if (result.hasMore) {
133
- const pos = result.excerptEnd;
134
- bodyHtmlWithAnchor =
135
- post.bodyHtml.slice(0, pos) +
136
- '<span id="continue"></span>' +
137
- post.bodyHtml.slice(pos);
131
+ if (post.summary) {
132
+ // Use stored summary (generated from Tiptap JSON)
133
+ summaryHtml = post.summary
134
+ .split("\n\n")
135
+ .map((p) => `<p>${p}</p>`)
136
+ .join("");
137
+ summaryHasMore = true;
138
+ } else {
139
+ // Fallback: extract from rendered HTML
140
+ const result = getHtmlExcerpt(post.bodyHtml);
141
+ summaryHtml = result.excerpt;
142
+ summaryHasMore = result.hasMore;
143
+
144
+ // Inject #continue anchor at the excerpt boundary for scroll targeting
145
+ if (result.hasMore) {
146
+ const pos = result.excerptEnd;
147
+ bodyHtmlWithAnchor =
148
+ post.bodyHtml.slice(0, pos) +
149
+ '<span id="continue"></span>' +
150
+ post.bodyHtml.slice(pos);
151
+ }
138
152
  }
139
153
  }
140
154
 
@@ -162,7 +176,7 @@ export function toPostView(post: PostWithMedia, _ctx: MediaContext): PostView {
162
176
  quoteText: post.quoteText ?? undefined,
163
177
  format: post.format as Format,
164
178
  status: post.status as Status,
165
- featured: post.featured === 1,
179
+ visibility: post.visibility,
166
180
  pinned: post.pinned === 1,
167
181
  rating: post.rating ?? undefined,
168
182
  publishedAt: toISOString(post.publishedAt),
@@ -33,7 +33,7 @@ export const errorHandler: ErrorHandler<Env> = (err, c) => {
33
33
  // Unknown API error
34
34
  // eslint-disable-next-line no-console -- Server error logging is intentional
35
35
  console.error("[Jant] Unhandled error:", err);
36
- return c.json({ error: "Internal server error" }, 500);
36
+ return c.json({ error: "Something went wrong on our end" }, 500);
37
37
  }
38
38
 
39
39
  // Datastar requests: return toast
@@ -43,7 +43,7 @@ export const errorHandler: ErrorHandler<Env> = (err, c) => {
43
43
  }
44
44
  // eslint-disable-next-line no-console -- Server error logging is intentional
45
45
  console.error("[Jant] Unhandled error:", err);
46
- return dsToast("An unexpected error occurred", "error");
46
+ return dsToast("Something went wrong. Try refreshing the page.", "error");
47
47
  }
48
48
 
49
49
  // JSON-accepting requests (Lit bridges)
@@ -59,7 +59,7 @@ export const errorHandler: ErrorHandler<Env> = (err, c) => {
59
59
  }
60
60
  // eslint-disable-next-line no-console -- Server error logging is intentional
61
61
  console.error("[Jant] Unhandled error:", err);
62
- return c.json({ error: "Internal server error" }, 500);
62
+ return c.json({ error: "Something went wrong on our end" }, 500);
63
63
  }
64
64
 
65
65
  // Non-API routes: map NotFoundError to Hono's built-in 404
package/src/preset.css CHANGED
@@ -73,4 +73,42 @@
73
73
  :where(h1, h2, h3, h4, h5, h6) {
74
74
  font-family: var(--font-heading);
75
75
  }
76
+
77
+ /* Image figures */
78
+ figure {
79
+ margin: 1.5em 0;
80
+ }
81
+
82
+ figure img {
83
+ width: 100%;
84
+ max-height: 500px;
85
+ object-fit: contain;
86
+ border-radius: 6px;
87
+ cursor: pointer;
88
+ }
89
+
90
+ figcaption {
91
+ text-align: center;
92
+ font-size: 0.875rem;
93
+ color: var(--tw-prose-captions);
94
+ margin-top: 0.5em;
95
+ }
96
+
97
+ /* Layout variants — center-breakout via margin+transform (no scrollbars) */
98
+ figure[data-layout="wide"] {
99
+ width: 1200px;
100
+ max-width: 100vw;
101
+ margin-left: 50%;
102
+ transform: translateX(-50%);
103
+ }
104
+
105
+ figure[data-layout="full"] {
106
+ width: 100vw;
107
+ margin-left: 50%;
108
+ transform: translateX(-50%);
109
+ }
110
+
111
+ figure[data-layout="full"] img {
112
+ border-radius: 0;
113
+ }
76
114
  }
@@ -36,19 +36,36 @@ const PostAssignSchema = z.object({
36
36
  postId: z.number().int().positive(),
37
37
  });
38
38
 
39
- // List collections (includes post counts)
39
+ // List collections (includes post counts and dividers)
40
40
  collectionsApiRoutes.get("/", async (c) => {
41
- const collections = await c.var.services.collections.list();
42
- const postCounts = await c.var.services.collections.getPostCounts();
41
+ const [collections, dividers, postCounts] = await Promise.all([
42
+ c.var.services.collections.list(),
43
+ c.var.services.collections.listDividers(),
44
+ c.var.services.collections.getPostCounts(),
45
+ ]);
43
46
 
44
47
  return c.json({
45
48
  collections: collections.map((col) => ({
46
49
  ...col,
47
50
  postCount: postCounts.get(col.id) ?? 0,
48
51
  })),
52
+ dividers,
49
53
  });
50
54
  });
51
55
 
56
+ // Create divider (requires auth) — must be before /:id
57
+ collectionsApiRoutes.post("/dividers", requireAuthApi(), async (c) => {
58
+ const divider = await c.var.services.collections.createDivider();
59
+ return c.json(divider, 201);
60
+ });
61
+
62
+ // Delete divider (requires auth) — must be before /:id
63
+ collectionsApiRoutes.delete("/dividers/:id", requireAuthApi(), async (c) => {
64
+ const id = parseIntParam(c.req.param("id"));
65
+ await c.var.services.collections.deleteDivider(id);
66
+ return c.json({ success: true });
67
+ });
68
+
52
69
  // Get single collection
53
70
  collectionsApiRoutes.get("/:id", async (c) => {
54
71
  const id = parseIntParam(c.req.param("id"));
@@ -126,23 +126,31 @@ postsApiRoutes.post("/", requireAuthApi(), async (c) => {
126
126
  await c.var.services.media.validateIds(body.mediaIds);
127
127
  }
128
128
 
129
- const post = await c.var.services.posts.create({
130
- format: body.format,
131
- title: body.title,
132
- body: body.body,
133
- path: body.path || undefined,
134
- status: body.status,
135
- featured: body.featured,
136
- pinned: body.pinned,
137
- url: body.url || undefined,
138
- quoteText: body.quoteText,
139
- rating: body.rating || undefined,
140
- collectionIds: body.collectionIds?.length ? body.collectionIds : undefined,
141
- replyToId: body.replyToId
142
- ? (sqid.decode(body.replyToId) ?? undefined)
143
- : undefined,
144
- publishedAt: body.publishedAt,
145
- });
129
+ const post = await c.var.services.posts.create(
130
+ {
131
+ format: body.format,
132
+ title: body.title,
133
+ body: body.body,
134
+ path: body.path || undefined,
135
+ status: body.status,
136
+ visibility: body.visibility,
137
+ pinned: body.pinned,
138
+ url: body.url || undefined,
139
+ quoteText: body.quoteText,
140
+ rating: body.rating || undefined,
141
+ collectionIds: body.collectionIds?.length
142
+ ? body.collectionIds
143
+ : undefined,
144
+ replyToId: body.replyToId
145
+ ? (sqid.decode(body.replyToId) ?? undefined)
146
+ : undefined,
147
+ publishedAt: body.publishedAt,
148
+ },
149
+ {
150
+ maxParagraphs: c.var.appConfig.summaryMaxParagraphs,
151
+ maxChars: c.var.appConfig.summaryMaxChars,
152
+ },
153
+ );
146
154
 
147
155
  // Attach media
148
156
  if (body.mediaIds && body.mediaIds.length > 0) {
@@ -177,22 +185,29 @@ postsApiRoutes.put("/:id", requireAuthApi(), async (c) => {
177
185
  }
178
186
 
179
187
  const post = assertFound(
180
- await c.var.services.posts.update(id, {
181
- format: body.format,
182
- title: body.title,
183
- body: body.body,
184
- path: body.path,
185
- status: body.status,
186
- featured: body.featured,
187
- pinned: body.pinned,
188
- url: body.url,
189
- quoteText: body.quoteText,
190
- rating: body.rating || undefined,
191
- collectionIds: body.collectionIds?.length
192
- ? body.collectionIds
193
- : undefined,
194
- publishedAt: body.publishedAt,
195
- }),
188
+ await c.var.services.posts.update(
189
+ id,
190
+ {
191
+ format: body.format,
192
+ title: body.title,
193
+ body: body.body,
194
+ path: body.path,
195
+ status: body.status,
196
+ visibility: body.visibility,
197
+ pinned: body.pinned,
198
+ url: body.url,
199
+ quoteText: body.quoteText,
200
+ rating: body.rating || undefined,
201
+ collectionIds: body.collectionIds?.length
202
+ ? body.collectionIds
203
+ : undefined,
204
+ publishedAt: body.publishedAt,
205
+ },
206
+ {
207
+ maxParagraphs: c.var.appConfig.summaryMaxParagraphs,
208
+ maxChars: c.var.appConfig.summaryMaxChars,
209
+ },
210
+ ),
196
211
  "Post",
197
212
  );
198
213