@jant/core 0.7.0 → 0.7.1

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 (158) hide show
  1. package/bin/commands/export.js +3 -1
  2. package/bin/commands/import-site.js +689 -216
  3. package/bin/commands/setup.js +136 -0
  4. package/bin/commands/site/export.js +71 -34
  5. package/bin/commands/site/pull-media.js +2 -6
  6. package/bin/commands/site/snapshot/export.js +18 -60
  7. package/bin/commands/site/snapshot/import.js +22 -23
  8. package/bin/lib/d1-query.js +87 -2
  9. package/bin/lib/hugo-markdown.js +4 -0
  10. package/bin/lib/site-pull-media.js +21 -28
  11. package/bin/lib/site-selection.js +10 -1
  12. package/bin/lib/site-snapshot.js +338 -3
  13. package/bin/lib/sql-export.js +68 -5
  14. package/bin/lib/wrangler-cli.js +9 -0
  15. package/bin/lib/zip-archive.js +187 -0
  16. package/dist/{app-K_Aa1MMn.js → app-ZcaI1kPN.js} +732 -217
  17. package/dist/client/.vite/manifest.json +20 -20
  18. package/dist/client/_assets/chunks/{create-editor-CD3FhrOB.js → create-editor-B-m7X7S5.js} +50 -46
  19. package/dist/client/_assets/chunks/{sortable-list-CgaL2jCs.js → sortable-list-BJyd-LXE.js} +1 -1
  20. package/dist/client/_assets/chunks/{unsafe-svg-0QCkP0vZ.js → unsafe-svg-BkscJx69.js} +2 -2
  21. package/dist/client/_assets/{client-GiYENVw8.css → client-DAhoqPdr.css} +1 -1
  22. package/dist/client/_assets/{client-auth-k8gJ6QJj.js → client-auth-CAMfTrKW.js} +1 -1
  23. package/dist/client/_assets/{client-compose-B_kDtWkW.js → client-compose-D6rExKVK.js} +1 -1
  24. package/dist/client/_assets/{client-VnFxJN7G.js → client-knSEyUJO.js} +1 -1
  25. package/dist/client/_assets/{client-manage-M90aSTOk.js → client-manage-sVOkqVbH.js} +1 -1
  26. package/dist/client/_assets/{client-settings-DfYs9n1F.js → client-settings-CGR_nZ78.js} +7 -7
  27. package/dist/{github-sync-BPAvT999.js → github-sync-Gw4orAHk.js} +799 -110
  28. package/dist/index.js +2 -2
  29. package/dist/node.js +9 -5
  30. package/package.json +13 -3
  31. package/src/__tests__/dev-scripts.test.ts +203 -0
  32. package/src/__tests__/export-collection-order.test.ts +276 -0
  33. package/src/__tests__/export-feed-ids.test.ts +184 -0
  34. package/src/__tests__/export-feed-order.test.ts +294 -0
  35. package/src/__tests__/export-hugo-build.test.ts +130 -0
  36. package/src/__tests__/export-import-roundtrip.test.ts +94 -0
  37. package/src/__tests__/export-service.test.ts +611 -28
  38. package/src/__tests__/export-smart-collection.test.ts +329 -0
  39. package/src/__tests__/helpers/hugo-site.ts +146 -0
  40. package/src/__tests__/import-site-command.test.ts +487 -1
  41. package/src/__tests__/mise-config.test.ts +75 -4
  42. package/src/__tests__/node-dev-tasks.test.ts +264 -0
  43. package/src/__tests__/site-export-canonical-import.test.ts +149 -0
  44. package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
  45. package/src/__tests__/snapshot-settings.test.ts +97 -0
  46. package/src/__tests__/snapshot-tables.test.ts +219 -0
  47. package/src/__tests__/sql-export.test.ts +173 -0
  48. package/src/__tests__/zip-archive.test.ts +106 -0
  49. package/src/app.tsx +15 -6
  50. package/src/client/components/__tests__/jant-settings-avatar.test.ts +1 -1
  51. package/src/client/components/__tests__/jant-settings-general.test.ts +21 -1
  52. package/src/client/components/jant-repo-picker-types.ts +6 -1
  53. package/src/client/components/jant-repo-picker.ts +4 -7
  54. package/src/client/components/jant-settings-general.ts +17 -12
  55. package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
  56. package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
  57. package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
  58. package/src/client/tiptap/extensions.ts +0 -3
  59. package/src/client/tiptap/structural-keymap.ts +158 -47
  60. package/src/db/__tests__/d1-query.test.ts +56 -1
  61. package/src/i18n/locales/settings/en.po +8 -8
  62. package/src/i18n/locales/settings/en.ts +1 -1
  63. package/src/i18n/locales/settings/zh-Hans.po +8 -8
  64. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  65. package/src/i18n/locales/settings/zh-Hant.po +8 -8
  66. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  67. package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
  68. package/src/lib/__tests__/image.test.ts +27 -1
  69. package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
  70. package/src/lib/__tests__/markdown.test.ts +10 -0
  71. package/src/lib/__tests__/resolve-config.test.ts +67 -0
  72. package/src/lib/__tests__/schemas.test.ts +27 -1
  73. package/src/lib/__tests__/timeline.test.ts +87 -0
  74. package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
  75. package/src/lib/discover.ts +3 -1
  76. package/src/lib/github-sync-repo-name.ts +45 -0
  77. package/src/lib/hugo-markdown.ts +46 -0
  78. package/src/lib/image.ts +17 -4
  79. package/src/lib/markdown-manager.ts +392 -2
  80. package/src/lib/post-body-html.ts +11 -4
  81. package/src/lib/resolve-config.ts +58 -1
  82. package/src/lib/schemas.ts +50 -5
  83. package/src/lib/thread-fold.ts +3 -3
  84. package/src/lib/timeline.ts +1 -1
  85. package/src/lib/tiptap-to-markdown.ts +13 -7
  86. package/src/lib/url.ts +20 -0
  87. package/src/lib/view.ts +2 -2
  88. package/src/node/__tests__/cli-setup.test.ts +163 -0
  89. package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
  90. package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
  91. package/src/node/__tests__/runtime.test.ts +38 -0
  92. package/src/node/index.ts +2 -0
  93. package/src/node/request-handler.ts +3 -1
  94. package/src/routes/api/__tests__/posts.test.ts +24 -0
  95. package/src/routes/api/__tests__/upload.test.ts +34 -0
  96. package/src/routes/api/export.ts +3 -3
  97. package/src/routes/api/internal/sites.ts +0 -1
  98. package/src/routes/api/posts.ts +2 -0
  99. package/src/routes/api/public/posts.ts +21 -1
  100. package/src/routes/api/upload.ts +10 -3
  101. package/src/routes/compose.tsx +4 -0
  102. package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
  103. package/src/routes/dash/settings.tsx +212 -70
  104. package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
  105. package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
  106. package/src/routes/pages/archive.tsx +17 -11
  107. package/src/routes/pages/featured.tsx +11 -5
  108. package/src/routes/pages/page.tsx +53 -31
  109. package/src/routes/pages/search.tsx +4 -2
  110. package/src/runtime/__tests__/readiness.test.ts +23 -0
  111. package/src/runtime/node.ts +49 -0
  112. package/src/runtime/readiness.ts +15 -0
  113. package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
  114. package/src/services/__tests__/custom-url.test.ts +34 -0
  115. package/src/services/__tests__/github-app-installations.test.ts +153 -0
  116. package/src/services/__tests__/github-sync-push.test.ts +46 -0
  117. package/src/services/__tests__/media.test.ts +31 -0
  118. package/src/services/__tests__/path.test.ts +56 -0
  119. package/src/services/__tests__/post-timeline.test.ts +127 -0
  120. package/src/services/__tests__/post.test.ts +74 -0
  121. package/src/services/bootstrap.ts +263 -44
  122. package/src/services/custom-url.ts +2 -2
  123. package/src/services/export-theme/layouts/_default/alias.html +27 -1
  124. package/src/services/export-theme/layouts/_default/list.html +2 -63
  125. package/src/services/export-theme/layouts/_default/rss.xml +27 -38
  126. package/src/services/export-theme/layouts/collections/list.html +3 -3
  127. package/src/services/export-theme/layouts/featured/list.html +1 -4
  128. package/src/services/export-theme/layouts/index.html +40 -26
  129. package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
  130. package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
  131. package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
  132. package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
  133. package/src/services/export-theme/layouts/partials/footer.html +1 -1
  134. package/src/services/export-theme/layouts/partials/head.html +1 -1
  135. package/src/services/export-theme/layouts/partials/header.html +5 -3
  136. package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
  137. package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
  138. package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
  139. package/src/services/export-theme/layouts/partials/thread-preview.html +1 -1
  140. package/src/services/export-theme/layouts/post/list.html +1 -1
  141. package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
  142. package/src/services/export-theme/styles/main.css +0 -1
  143. package/src/services/export-theme/theme.toml +1 -1
  144. package/src/services/export.ts +620 -71
  145. package/src/services/github-app-installations.ts +171 -4
  146. package/src/services/github-sync.ts +69 -15
  147. package/src/services/mcp.ts +19 -1
  148. package/src/services/media.ts +8 -1
  149. package/src/services/path.ts +34 -1
  150. package/src/services/post.ts +214 -39
  151. package/src/services/site-admin.ts +3 -7
  152. package/src/services/site.ts +83 -31
  153. package/src/styles/ui.css +7 -1
  154. package/src/types/app-context.ts +16 -0
  155. package/src/types/bindings.ts +5 -0
  156. package/src/types/operations.ts +10 -0
  157. package/src/ui/dash/settings/GeneralContent.tsx +10 -8
  158. package/src/client/tiptap/exitable-marks.ts +0 -73
package/src/lib/image.ts CHANGED
@@ -34,7 +34,11 @@ export interface ImageOptions {
34
34
  * - Cloudinary
35
35
  * - Any service with similar URL-based transformation API
36
36
  *
37
- * @param originalUrl - The original image URL
37
+ * A root-relative source is written without its leading slash, the form the
38
+ * transformation service resolves against its own host. Kept, it would produce
39
+ * `…/width=200//media/abc123`, which Cloudflare cannot fetch (`err=9404`).
40
+ *
41
+ * @param originalUrl - The original image URL, absolute or root-relative
38
42
  * @param transformUrl - The base URL for transformations (e.g., `https://example.com/cdn-cgi/image`)
39
43
  * @param options - Transformation options (width, height, quality, format, fit)
40
44
  * @returns The transformed URL or original URL if transformations are not configured
@@ -45,9 +49,13 @@ export interface ImageOptions {
45
49
  * getImageUrl("/media/abc123", undefined, { width: 200 });
46
50
  * // Returns: "/media/abc123"
47
51
  *
48
- * // With transform URL - returns transformed
52
+ * // With transform URL and a root-relative source
49
53
  * getImageUrl("/media/abc123", "https://example.com/cdn-cgi/image", { width: 200, quality: 80 });
50
- * // Returns: "https://example.com/cdn-cgi/image/width=200,quality=80/https://example.com/media/abc123"
54
+ * // Returns: "https://example.com/cdn-cgi/image/width=200,quality=80/media/abc123"
55
+ *
56
+ * // With transform URL and an absolute source
57
+ * getImageUrl("https://cdn.example.com/media/abc123", "https://example.com/cdn-cgi/image", { width: 200 });
58
+ * // Returns: "https://example.com/cdn-cgi/image/width=200/https://cdn.example.com/media/abc123"
51
59
  * ```
52
60
  */
53
61
  export function getImageUrl(
@@ -70,7 +78,12 @@ export function getImageUrl(
70
78
  return originalUrl;
71
79
  }
72
80
 
73
- return `${transformUrl}/${params.join(",")}/${originalUrl}`;
81
+ const base = transformUrl.replace(/\/+$/, "");
82
+ const source =
83
+ originalUrl.startsWith("/") && !originalUrl.startsWith("//")
84
+ ? originalUrl.slice(1)
85
+ : originalUrl;
86
+ return `${base}/${params.join(",")}/${source}`;
74
87
  }
75
88
 
76
89
  /**
@@ -1,12 +1,20 @@
1
1
  import {
2
2
  Extension,
3
3
  Node,
4
+ getSchema,
4
5
  type AnyExtension,
5
6
  type Extensions,
6
7
  type JSONContent,
7
8
  } from "@tiptap/core";
9
+ import { Fragment, type Schema } from "@tiptap/pm/model";
8
10
  import { MarkdownManager } from "@tiptap/markdown";
9
11
  import CodeBlock from "@tiptap/extension-code-block";
12
+ import { OrderedList } from "@tiptap/extension-list";
13
+ import HardBreak from "@tiptap/extension-hard-break";
14
+ import Paragraph from "@tiptap/extension-paragraph";
15
+ import Bold from "@tiptap/extension-bold";
16
+ import Italic from "@tiptap/extension-italic";
17
+ import Strike from "@tiptap/extension-strike";
10
18
  import Link from "@tiptap/extension-link";
11
19
  import StarterKit from "@tiptap/starter-kit";
12
20
  import {
@@ -224,6 +232,333 @@ const SemanticLink = Link.extend({
224
232
  clearable: false,
225
233
  });
226
234
 
235
+ const LINE_START_BLOCK_SYNTAX: ReadonlyArray<[RegExp, string]> = [
236
+ // `1986. A good year`, `2) Second`: an ordered-list marker
237
+ [/^([ \t]{0,3})(\d{1,9})([.)])(?=[ \t]|$)/, "$1$2\\$3"],
238
+ // `- note`, `+ note`: a bullet-list marker (`*` is escaped inline already)
239
+ [/^([ \t]{0,3})([-+])(?=[ \t]|$)/, "$1\\$2"],
240
+ // `# note`: an ATX heading
241
+ [/^([ \t]{0,3})(#{1,6})(?=[ \t]|$)/, "$1\\$2"],
242
+ // `---`, `===`: a thematic break or a setext underline for the line above
243
+ [/^([ \t]{0,3})([-=])(?=[-= \t]*$)/, "$1\\$2"],
244
+ ];
245
+
246
+ /**
247
+ * Backslash-escape block syntax that a paragraph line happens to open with.
248
+ *
249
+ * Text nodes only get inline escaping, so a paragraph reading `1. Pony`, or a
250
+ * hard break followed by `- note`, came back from the Markdown as a list.
251
+ *
252
+ * @param markdown - A paragraph's rendered Markdown
253
+ * @returns The same Markdown with each line's leading block syntax escaped
254
+ * @example
255
+ * escapeLineStartBlockSyntax("Update: \n1. Pony"); // "Update: \n1\\. Pony"
256
+ */
257
+ function escapeLineStartBlockSyntax(markdown: string): string {
258
+ return markdown
259
+ .split("\n")
260
+ .map((line) =>
261
+ LINE_START_BLOCK_SYNTAX.reduce(
262
+ (escaped, [pattern, replacement]) =>
263
+ escaped.replace(pattern, replacement),
264
+ line,
265
+ ),
266
+ )
267
+ .join("\n");
268
+ }
269
+
270
+ /** The HTML tag each emphasis mark falls back to. */
271
+ const HTML_EMPHASIS_TAGS = {
272
+ bold: "strong",
273
+ italic: "em",
274
+ strike: "s",
275
+ } as const;
276
+
277
+ type EmphasisMarkType = keyof typeof HTML_EMPHASIS_TAGS;
278
+
279
+ const EMPHASIS_MARK_TYPES = Object.keys(
280
+ HTML_EMPHASIS_TAGS,
281
+ ) as EmphasisMarkType[];
282
+
283
+ /** Marks that write their own delimiter characters around the text. */
284
+ const DELIMITED_MARK_TYPES = new Set<string>([
285
+ ...EMPHASIS_MARK_TYPES,
286
+ "code",
287
+ "link",
288
+ ]);
289
+
290
+ /**
291
+ * Serialization-only mark attribute: write this run with HTML tags. It is
292
+ * set by `markEmphasisDelimiters` on a copy of the document and never stored.
293
+ */
294
+ const HTML_EMPHASIS_ATTR = "markdownAsHtml";
295
+
296
+ type FlankingClass = "space" | "punctuation" | "other";
297
+
298
+ function classifyFlankingChar(char: string | undefined): FlankingClass {
299
+ if (char === undefined || /\s/u.test(char)) return "space";
300
+ return /[\p{P}\p{S}]/u.test(char) ? "punctuation" : "other";
301
+ }
302
+
303
+ function hasMark(node: JSONContent | undefined, type: string): boolean {
304
+ return node?.marks?.some((mark) => mark.type === type) ?? false;
305
+ }
306
+
307
+ /**
308
+ * Whether a `**`, `*`, or `~~` at one end of a run can open or close it.
309
+ *
310
+ * CommonMark's flanking rule, which GFM strikethrough shares: the character
311
+ * on the text side must not be whitespace, and when it is punctuation, the
312
+ * character on the outside must be whitespace or punctuation too. The rule is
313
+ * symmetric, so one check covers the opening and the closing delimiter.
314
+ *
315
+ * @param nodes - The inline nodes of one block
316
+ * @param index - The run's first node (`side: "start"`) or last (`"end"`)
317
+ * @param side - Which end of the run
318
+ * @param markType - The run's mark
319
+ * @returns True when Markdown delimiters work at this end
320
+ */
321
+ function delimiterFlanks(
322
+ nodes: JSONContent[],
323
+ index: number,
324
+ side: "start" | "end",
325
+ markType: string,
326
+ ): boolean {
327
+ const node = nodes[index];
328
+ const neighbor = nodes[side === "start" ? index - 1 : index + 1];
329
+ const chars = [...(node?.text ?? "")];
330
+ const edge = side === "start" ? chars : chars.slice().reverse();
331
+ const innerChar = edge.find((char) => !/\s/u.test(char));
332
+
333
+ // Another mark opening or closing at the same edge writes its delimiter
334
+ // between ours and the text; it counts as punctuation.
335
+ const sharesEdge =
336
+ node?.marks?.some(
337
+ (mark) =>
338
+ mark.type !== markType &&
339
+ DELIMITED_MARK_TYPES.has(mark.type) &&
340
+ !hasMark(neighbor, mark.type),
341
+ ) ?? false;
342
+ const inner = sharesEdge ? "punctuation" : classifyFlankingChar(innerChar);
343
+
344
+ // The serializer moves whitespace at a run's edge outside the delimiter.
345
+ let outer: FlankingClass;
346
+ if (edge[0] !== undefined && /\s/u.test(edge[0])) outer = "space";
347
+ else if (!neighbor || neighbor.type === "hardBreak") outer = "space";
348
+ else if (neighbor.type === "text") {
349
+ const neighborChars = [...(neighbor.text ?? "")];
350
+ outer = classifyFlankingChar(
351
+ side === "start" ? neighborChars.at(-1) : neighborChars[0],
352
+ );
353
+ } else outer = "punctuation"; // `![…](…)`, `[^1]`
354
+
355
+ return inner !== "space" && (inner !== "punctuation" || outer !== "other");
356
+ }
357
+
358
+ function markEmphasisRuns(nodes: JSONContent[]): JSONContent[] {
359
+ const result = nodes.map((node) => ({ ...node }));
360
+
361
+ for (const markType of EMPHASIS_MARK_TYPES) {
362
+ let start = 0;
363
+ while (start < result.length) {
364
+ if (result[start]?.type !== "text" || !hasMark(result[start], markType)) {
365
+ start += 1;
366
+ continue;
367
+ }
368
+
369
+ // The serializer closes marks around any non-text node, so a run is
370
+ // consecutive text nodes.
371
+ let end = start;
372
+ while (
373
+ result[end + 1]?.type === "text" &&
374
+ hasMark(result[end + 1], markType)
375
+ ) {
376
+ end += 1;
377
+ }
378
+
379
+ if (
380
+ !delimiterFlanks(result, start, "start", markType) ||
381
+ !delimiterFlanks(result, end, "end", markType)
382
+ ) {
383
+ for (let index = start; index <= end; index += 1) {
384
+ const node = result[index] as JSONContent;
385
+ node.marks = node.marks?.map((mark) =>
386
+ mark.type === markType
387
+ ? {
388
+ ...mark,
389
+ attrs: { ...mark.attrs, [HTML_EMPHASIS_ATTR]: true },
390
+ }
391
+ : mark,
392
+ );
393
+ }
394
+ }
395
+
396
+ start = end + 1;
397
+ }
398
+ }
399
+
400
+ return result;
401
+ }
402
+
403
+ /**
404
+ * Flag the emphasis runs whose Markdown delimiters a CommonMark parser would
405
+ * leave as literal characters, so they serialize as HTML tags instead.
406
+ *
407
+ * Chinese and Japanese put no space around punctuation, so `**说话。**来的人`
408
+ * is common, and neither Hugo (goldmark) nor Jant's own parser reads the
409
+ * closing `**` after `。` followed by `来`. `<strong>…</strong>` reads the
410
+ * same in both, and the rest of the Markdown stays as it was.
411
+ *
412
+ * @param node - A TipTap document or descendant
413
+ * @returns A copy with unflankable runs flagged
414
+ * @example
415
+ * markEmphasisDelimiters(doc); // bold "说话。" before "来" gets the flag
416
+ */
417
+ function markEmphasisDelimiters(node: JSONContent): JSONContent {
418
+ if (!node.content || node.type === "codeBlock") return node;
419
+
420
+ const content = node.content.map(markEmphasisDelimiters);
421
+ const hasInline = content.some(
422
+ (child) => child.type === "text" || child.type === "hardBreak",
423
+ );
424
+ return { ...node, content: hasInline ? markEmphasisRuns(content) : content };
425
+ }
426
+
427
+ function renderEmphasis(
428
+ node: JSONContent,
429
+ content: string,
430
+ markType: EmphasisMarkType,
431
+ delimiter: string,
432
+ ): string {
433
+ if (!node.attrs?.[HTML_EMPHASIS_ATTR]) {
434
+ return `${delimiter}${content}${delimiter}`;
435
+ }
436
+ const tag = HTML_EMPHASIS_TAGS[markType];
437
+ return `<${tag}>${content}</${tag}>`;
438
+ }
439
+
440
+ const MarkdownBold = Bold.extend({
441
+ renderMarkdown(node, helpers) {
442
+ return renderEmphasis(node, helpers.renderChildren(node), "bold", "**");
443
+ },
444
+ });
445
+
446
+ const MarkdownItalic = Italic.extend({
447
+ renderMarkdown(node, helpers) {
448
+ return renderEmphasis(node, helpers.renderChildren(node), "italic", "*");
449
+ },
450
+ });
451
+
452
+ const MarkdownStrike = Strike.extend({
453
+ renderMarkdown(node, helpers) {
454
+ return renderEmphasis(node, helpers.renderChildren(node), "strike", "~~");
455
+ },
456
+ });
457
+
458
+ /** Marked token for each HTML emphasis tag the parser accepts. */
459
+ const HTML_EMPHASIS_TOKEN_TYPES: Record<string, "strong" | "em" | "del"> = {
460
+ strong: "strong",
461
+ b: "strong",
462
+ em: "em",
463
+ i: "em",
464
+ s: "del",
465
+ del: "del",
466
+ };
467
+
468
+ const HTML_EMPHASIS_OPEN_PATTERN = /<(?:strong|b|em|i|s|del)>/i;
469
+ const HTML_EMPHASIS_PATTERN = /^<(strong|b|em|i|s|del)>([\s\S]*?)<\/\1>/i;
470
+
471
+ /**
472
+ * Reads the HTML tags `markEmphasisDelimiters` writes back as marks.
473
+ *
474
+ * Only bare tags: `<strong onclick=…>` and every other tag stay text, as
475
+ * inline HTML always has. The content between the tags is Markdown.
476
+ */
477
+ const MarkdownHtmlEmphasis = Extension.create({
478
+ name: "markdownHtmlEmphasis",
479
+
480
+ markdownTokenizer: {
481
+ name: "htmlEmphasis",
482
+ level: "inline",
483
+ start(src: string) {
484
+ return src.search(HTML_EMPHASIS_OPEN_PATTERN);
485
+ },
486
+ tokenize(src: string, _tokens, helpers) {
487
+ const match = HTML_EMPHASIS_PATTERN.exec(src);
488
+ if (!match) return undefined;
489
+
490
+ const [raw, tag = "", text = ""] = match;
491
+ const type = HTML_EMPHASIS_TOKEN_TYPES[tag.toLowerCase()];
492
+ if (!type) return undefined;
493
+ return { type, raw, text, tokens: helpers.inlineTokens(text) };
494
+ },
495
+ },
496
+ });
497
+
498
+ /**
499
+ * Hard breaks as two trailing spaces, except where that line would be blank.
500
+ *
501
+ * A break at the start of a paragraph, or right after another break, puts
502
+ * the spaces on a line of their own. A line of spaces is blank in Markdown:
503
+ * it ended the paragraph, and `- ` left a list item empty with the text
504
+ * after it outside the list. The backslash form keeps something on the line.
505
+ */
506
+ const MarkdownHardBreak = HardBreak.extend({
507
+ renderMarkdown(_node, _helpers, context) {
508
+ const previous = context?.previousNode;
509
+ return !previous || previous.type === "hardBreak" ? "\\\n" : " \n";
510
+ },
511
+ });
512
+
513
+ const renderParagraphMarkdown = Paragraph.config.renderMarkdown;
514
+
515
+ const MarkdownParagraph = Paragraph.extend({
516
+ renderMarkdown(node, helpers, context) {
517
+ const rendered =
518
+ renderParagraphMarkdown?.call(this, node, helpers, context) ?? "";
519
+ return escapeLineStartBlockSyntax(rendered);
520
+ },
521
+ });
522
+
523
+ /**
524
+ * Ordered lists with CommonMark markers: digits only.
525
+ *
526
+ * Tiptap's ordered list also reads letters and roman numerals as markers
527
+ * (`a.`, `IV.`, anything of one or two letters), so a line such as
528
+ * `PS. 补充一句` or `Mr. Smith went` became a list item and lost its first
529
+ * word. Its tokenizer also measured a marker's width without the `.`, which
530
+ * left one stray space on every line of a code block inside a list item.
531
+ *
532
+ * Jant's Markdown is CommonMark plus GFM (docs/internal/markdown-contract.md),
533
+ * the same dialect Hugo reads in an export, so list tokenizing goes back to
534
+ * marked: a tokenizer that never matches leaves the built-in one in charge,
535
+ * and marked's list items go to `listItem` the way bullet lists' do. Tiptap's
536
+ * plain-text paste plugin goes too; it applied the same markers, and the
537
+ * editors already parse pasted plain text as Markdown (`MarkdownClipboard`).
538
+ */
539
+ const CommonMarkOrderedList = OrderedList.extend({
540
+ markdownTokenizer: {
541
+ name: "orderedList",
542
+ level: "block",
543
+ start: () => -1,
544
+ tokenize: () => undefined,
545
+ },
546
+
547
+ parseMarkdown: (token, helpers) => {
548
+ if (token.type !== "list" || !token.ordered) return [];
549
+ const start = typeof token.start === "number" ? token.start : 1;
550
+ return {
551
+ type: "orderedList",
552
+ ...(start === 1 ? {} : { attrs: { start } }),
553
+ content: token.items ? helpers.parseChildren(token.items) : [],
554
+ };
555
+ },
556
+
557
+ addProseMirrorPlugins() {
558
+ return [];
559
+ },
560
+ });
561
+
227
562
  const MarkdownFigureImageSupport = Extension.create({
228
563
  name: "markdownFigureImageSupport",
229
564
 
@@ -824,8 +1159,21 @@ export function createMarkdownContentExtensions(
824
1159
  heading: { levels: [1, 2, 3] },
825
1160
  link: false,
826
1161
  codeBlock: false,
1162
+ orderedList: false,
1163
+ paragraph: false,
1164
+ hardBreak: false,
1165
+ bold: false,
1166
+ italic: false,
1167
+ strike: false,
827
1168
  trailingNode: { notAfter: ["footnoteDefinition"] },
828
1169
  }),
1170
+ MarkdownParagraph,
1171
+ MarkdownHardBreak,
1172
+ MarkdownBold,
1173
+ MarkdownItalic,
1174
+ MarkdownStrike,
1175
+ MarkdownHtmlEmphasis,
1176
+ CommonMarkOrderedList,
829
1177
  SemanticLink.configure({
830
1178
  openOnClick: false,
831
1179
  autolink: false,
@@ -1050,12 +1398,54 @@ export function getMarkdownManager(): MarkdownManager {
1050
1398
  return sharedMarkdownManager;
1051
1399
  }
1052
1400
 
1401
+ let sharedContentSchema: Schema | null = null;
1402
+
1403
+ function getContentSchema(): Schema {
1404
+ sharedContentSchema ??= getSchema(createMarkdownContentExtensions());
1405
+ return sharedContentSchema;
1406
+ }
1407
+
1408
+ /**
1409
+ * Fill in the children a node's schema requires but the document left out.
1410
+ *
1411
+ * A list item must hold a paragraph, a blockquote a block, a doc a block.
1412
+ * Documents that skipped one (an empty `1. ` item from an older Markdown
1413
+ * parser, or JSON posted through the API) made the Markdown serializer throw.
1414
+ * Each empty node that cannot be empty gets what `createAndFill` would give it.
1415
+ *
1416
+ * @param node - A TipTap document or descendant
1417
+ * @returns A copy whose empty required containers are filled
1418
+ * @example
1419
+ * fillRequiredContent({ type: "listItem", content: [] });
1420
+ * // { type: "listItem", content: [{ type: "paragraph" }] }
1421
+ */
1422
+ export function fillRequiredContent(node: JSONContent): JSONContent {
1423
+ const nodeType = node.type ? getContentSchema().nodes[node.type] : undefined;
1424
+ if (!nodeType || nodeType.isLeaf) return node;
1425
+
1426
+ const content = node.content?.map(fillRequiredContent);
1427
+ if ((content?.length ?? 0) === 0 && !nodeType.contentMatch.validEnd) {
1428
+ const filled = nodeType.contentMatch.fillBefore(Fragment.empty, true);
1429
+ if (filled) {
1430
+ return { ...node, content: filled.toJSON() as JSONContent[] };
1431
+ }
1432
+ }
1433
+
1434
+ return content ? { ...node, content } : node;
1435
+ }
1436
+
1053
1437
  export function parseMarkdownDocument(markdown: string): JSONContent {
1054
- return normalizeMarkdownDocument(getMarkdownManager().parse(markdown));
1438
+ return fillRequiredContent(
1439
+ normalizeMarkdownDocument(getMarkdownManager().parse(markdown)),
1440
+ );
1055
1441
  }
1056
1442
 
1057
1443
  export function serializeMarkdownDocument(doc: JSONContent): string {
1058
1444
  return expandCodeBlockFences(
1059
- getMarkdownManager().serialize(normalizeFootnoteArtifacts(doc)),
1445
+ getMarkdownManager().serialize(
1446
+ normalizeFootnoteArtifacts(
1447
+ markEmphasisDelimiters(fillRequiredContent(doc)),
1448
+ ),
1449
+ ),
1060
1450
  );
1061
1451
  }
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { JSONContent } from "@tiptap/core";
10
10
  import { upgradeLegacyFootnotes } from "./footnotes.js";
11
+ import { fillRequiredContent } from "./markdown-manager.js";
11
12
  import {
12
13
  renderTiptapDocument,
13
14
  type TiptapRenderResult,
@@ -50,8 +51,9 @@ export function renderPostBodyHtml(postId: string, body: string): string {
50
51
  }
51
52
 
52
53
  /**
53
- * Normalize recognized historical footnotes and render the current HTML
54
- * projection in one parse pass.
54
+ * Normalize recognized historical footnotes, fill in children the schema
55
+ * requires (an empty list item gets its paragraph), and render the current
56
+ * HTML projection in one parse pass.
55
57
  *
56
58
  * @param postId - Immutable post TypeID
57
59
  * @param body - Canonical TipTap JSON
@@ -73,10 +75,15 @@ export function tryPreparePostBodyHtml(
73
75
  }
74
76
 
75
77
  const upgraded = upgradeLegacyFootnotes(parsed);
78
+ const doc = fillRequiredContent(upgraded.doc);
79
+ const canonical = JSON.stringify(doc);
76
80
  return {
77
81
  ok: true,
78
- body: upgraded.upgraded ? JSON.stringify(upgraded.doc) : body,
79
- html: renderTiptapDocument(upgraded.doc, { namespace: postId }),
82
+ body:
83
+ upgraded.upgraded || canonical !== JSON.stringify(upgraded.doc)
84
+ ? canonical
85
+ : body,
86
+ html: renderTiptapDocument(doc, { namespace: postId }),
80
87
  upgradedLegacyFootnotes: upgraded.upgraded,
81
88
  };
82
89
  } catch (error) {
@@ -27,9 +27,63 @@ import { getPublicUrlForProvider, getMediaUrl, getImageUrl } from "./image.js";
27
27
  import { normalizeTimeZone } from "./timezones.js";
28
28
  import { getSiteOrigin, getSitePathPrefix, normalizeSiteUrl } from "./url.js";
29
29
 
30
+ /**
31
+ * Whether an empty stored value means "never configured" for this field.
32
+ *
33
+ * The test is whether the editor could have produced the empty value, and it
34
+ * mirrors what `normalizeConfigEditorDefinitionValue` accepts. A boolean takes
35
+ * only `true` or `false`; a number has to parse; an enum has to name one of its
36
+ * options. None of those can be stored empty, so an empty row for one of them
37
+ * is not a choice anybody made — it comes from a path that skips validation,
38
+ * and a snapshot's `db.sql`, replayed as raw SQL, is exactly that. Read as a
39
+ * configured value it silences the environment variable the operator did set,
40
+ * for as long as the row exists.
41
+ *
42
+ * Text is the opposite: clearing a description or a footer is an ordinary edit,
43
+ * so an empty value there is a decision and keeps its precedence. So is an enum
44
+ * that lists `""` among its options, the way `DASHBOARD_LANGUAGE` does, and one
45
+ * whose options come from a runtime source, where nothing here can rule it
46
+ * out.
47
+ *
48
+ * @param field - Config registry entry for the key being resolved
49
+ * @returns True when an empty stored value should fall through to env/default
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * isUnsetWhenEmpty(CONFIG_FIELDS.NOINDEX); // true (boolean)
54
+ * isUnsetWhenEmpty(CONFIG_FIELDS.SITE_FOOTER); // false (string)
55
+ * ```
56
+ */
57
+ function isUnsetWhenEmpty(
58
+ field: (typeof CONFIG_FIELDS)[keyof typeof CONFIG_FIELDS],
59
+ ): boolean {
60
+ if (!("editor" in field)) return false;
61
+ const editor = field.editor;
62
+ switch (editor.type) {
63
+ case "boolean":
64
+ case "number":
65
+ return true;
66
+ case "enum":
67
+ // Only a fixed option list proves the value is impossible. `""` is a real
68
+ // choice for `DASHBOARD_LANGUAGE`, which offers it, and an enum drawing
69
+ // its options from a runtime source cannot rule it out either.
70
+ return (
71
+ "options" in editor &&
72
+ !(editor.options as readonly string[]).includes("")
73
+ );
74
+ default:
75
+ return false;
76
+ }
77
+ }
78
+
30
79
  /**
31
80
  * Resolve a single config value following priority rules.
32
81
  *
82
+ * Settings saved in the dashboard outrank the environment, which outranks the
83
+ * default. The exception is an empty boolean or numeric value — see
84
+ * {@link isUnsetWhenEmpty}, which explains why that is absence rather than
85
+ * choice.
86
+ *
33
87
  * @param key - CONFIG_FIELDS key
34
88
  * @param allSettings - DB settings map
35
89
  * @param env - Worker bindings
@@ -46,7 +100,10 @@ function resolve(
46
100
 
47
101
  // User-configurable: DB > ENV > Default
48
102
  if (!field.envOnly && Object.hasOwn(allSettings, key)) {
49
- return allSettings[key] ?? "";
103
+ const stored = allSettings[key] ?? "";
104
+ if (stored !== "" || !isUnsetWhenEmpty(field)) {
105
+ return stored;
106
+ }
50
107
  }
51
108
 
52
109
  // ENV > Default
@@ -195,10 +195,14 @@ export const NavItemTypeSchema = z.enum(NAV_ITEM_TYPES);
195
195
  export const SystemNavKeySchema = z.enum(SYSTEM_NAV_KEY_VALUES);
196
196
 
197
197
  /**
198
- * Redirect type enum schema
199
- * Form input validation for redirect type (stored as number in DB)
198
+ * Redirect type, as forms send it ("301") or as the API answers it (301).
199
+ * Parses to the string form; stored as a number.
200
200
  */
201
- export const RedirectTypeSchema = z.enum(["301", "302"]);
201
+ export const RedirectTypeSchema = z.union([
202
+ z.enum(["301", "302"]),
203
+ z.literal(301).transform(() => "301" as const),
204
+ z.literal(302).transform(() => "302" as const),
205
+ ]);
202
206
 
203
207
  /**
204
208
  * Custom URL target type enum schema.
@@ -547,15 +551,32 @@ function refineSlugPathExclusivity<T extends { slug?: string; path?: string }>(
547
551
  });
548
552
  }
549
553
 
554
+ /**
555
+ * Create only: a post's own record timestamps, in Unix seconds. An import or
556
+ * a migration restores them, so "last edited" (feed `<updated>`, sitemap
557
+ * `lastmod`) and Thread order (the root, then replies by creation time, then
558
+ * ID) survive the move.
559
+ * Omitted, both are the time of the request; `updatedAt` alone defaults to
560
+ * `createdAt`.
561
+ */
562
+ const RestoredTimestampFields = {
563
+ createdAt: z.number().int().positive().optional(),
564
+ updatedAt: z.number().int().positive().optional(),
565
+ };
566
+
550
567
  /**
551
568
  * API request body schema for creating a post
552
569
  */
553
570
  export const CreatePostSchema = refineSlugPathExclusivity(
554
- refineCreatePostFormatShape(refineBodyExclusivity(PostFieldsSchema)),
571
+ refineCreatePostFormatShape(
572
+ refineBodyExclusivity(PostFieldsSchema.extend(RestoredTimestampFields)),
573
+ ),
555
574
  );
556
575
 
557
576
  export const CreatePostApiSchema = refineSlugPathExclusivity(
558
- refineCreatePostFormatShape(refineBodyExclusivity(ApiPostFieldsSchema)),
577
+ refineCreatePostFormatShape(
578
+ refineBodyExclusivity(ApiPostFieldsSchema.extend(RestoredTimestampFields)),
579
+ ),
559
580
  );
560
581
 
561
582
  /**
@@ -1054,6 +1075,30 @@ export const SetupSiteSchema = SetupLanguageSchema.extend({
1054
1075
  /** The answers the last setup screen collects from either install kind. */
1055
1076
  export type SetupLanguageAnswers = z.infer<typeof SetupLanguageSchema>;
1056
1077
 
1078
+ /**
1079
+ * Both setup screens' answers at once, for `jant setup`: a self-hosted install
1080
+ * set up by its deployment rather than in a browser.
1081
+ *
1082
+ * Only the credentials are required. Everything the second screen asks has
1083
+ * the default an empty answer gets there, and a browser's guesses — its
1084
+ * language, its time zone — have no counterpart here.
1085
+ */
1086
+ export const InstanceSetupSchema = SetupAccountSchema.extend({
1087
+ siteId: createTypeIdSchema(ID_PREFIX.site).optional(),
1088
+ siteName: z
1089
+ .string()
1090
+ .trim()
1091
+ .min(1, "Site name cannot be empty")
1092
+ .max(MAX_SITE_NAME_LENGTH)
1093
+ .optional(),
1094
+ siteLanguage: ContentLanguageSchema.optional(),
1095
+ timeZone: z
1096
+ .string()
1097
+ .refine(isSupportedTimeZone, "Choose a valid time zone.")
1098
+ .transform(normalizeTimeZone)
1099
+ .optional(),
1100
+ });
1101
+
1057
1102
  /**
1058
1103
  * Sign-in form validation schema
1059
1104
  */
@@ -106,9 +106,9 @@ export interface ThreadFold<T> {
106
106
  *
107
107
  * Takes the same slices the SQL path ranks for: the first
108
108
  * `THREAD_LEADING_REPLIES`, and the last `THREAD_TRAILING_REPLIES` with the
109
- * newest as the hero. `getPublishedThreads` orders replies by
110
- * `(threadId, createdAt, id)` ascending, which is the window function's own
111
- * `ORDER BY`, so position in this array *is* the rank — no approximation.
109
+ * newest as the hero. `getPublishedThreads` returns each Thread in Thread
110
+ * order, the same `threadOrder` the window functions rank by, so once the root
111
+ * is set aside position in this array *is* the rank — no approximation.
112
112
  *
113
113
  * @param replies - Every published reply, oldest first
114
114
  * @returns The fold, or null when the thread has no replies