@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
@@ -90,6 +90,111 @@ describe("markdownToTiptapJson", () => {
90
90
  expect(doc.content[0].content).toHaveLength(3);
91
91
  });
92
92
 
93
+ // Tiptap's own tokenizer gave an empty item no content at all, which the
94
+ // schema rejects and the Markdown serializer threw on.
95
+ it("gives an empty list item the paragraph the schema requires", () => {
96
+ const doc = parse("1. First\n2.\n3. Third");
97
+ expect(doc.content[0].type).toBe("orderedList");
98
+ expect(doc.content[0].content).toHaveLength(3);
99
+ expect(doc.content[0].content[1].type).toBe("listItem");
100
+ expect(doc.content[0].content[1].content).toMatchObject([
101
+ { type: "paragraph" },
102
+ ]);
103
+ });
104
+
105
+ it.each([
106
+ ["<strong>粗</strong>", "bold"],
107
+ ["<b>粗</b>", "bold"],
108
+ ["<em>斜</em>", "italic"],
109
+ ["<i>斜</i>", "italic"],
110
+ ["<s>删</s>", "strike"],
111
+ ["<del>删</del>", "strike"],
112
+ ])("reads %s as a %s mark", (html, mark) => {
113
+ const doc = parse(`前${html}后`);
114
+ const marked = doc.content[0].content.find(
115
+ (node: TiptapNode) => node.marks?.length,
116
+ );
117
+ expect(marked.marks).toEqual([{ type: mark }]);
118
+ expect(
119
+ doc.content[0].content.map((n: TiptapNode) => n.text).join(""),
120
+ ).toBe(`前${html.replace(/<[^>]+>/g, "")}后`);
121
+ });
122
+
123
+ it("parses Markdown inside an HTML emphasis tag", () => {
124
+ const doc = parse("<strong>[链接](https://example.com)。</strong>后");
125
+ const first = doc.content[0].content[0];
126
+ expect(first.text).toBe("链接");
127
+ expect(first.marks.map((mark: TiptapMark) => mark.type).sort()).toEqual([
128
+ "bold",
129
+ "link",
130
+ ]);
131
+ });
132
+
133
+ it.each([
134
+ '<strong onclick="alert(1)">x</strong>',
135
+ "<span>x</span>",
136
+ "<script>x</script>",
137
+ ])("keeps %j as text", (html) => {
138
+ const doc = parse(html);
139
+ expect(doc.content[0].content).toEqual([{ type: "text", text: html }]);
140
+ });
141
+
142
+ it("keeps an ordered list's start number", () => {
143
+ const doc = parse("3. Third\n4. Fourth");
144
+ expect(doc.content[0].type).toBe("orderedList");
145
+ expect(doc.content[0].attrs).toEqual({ start: 3 });
146
+ });
147
+
148
+ // CommonMark list markers are digits. Tiptap's own tokenizer also takes
149
+ // letters and roman numerals, which ate the first word of these lines.
150
+ it.each([
151
+ "Ps. 今天发现苹果尼日利亚区的终身会员",
152
+ "PS. 补充一句",
153
+ "Mr. Smith went to Washington.",
154
+ "No. 5 is here.",
155
+ "OK. Fine.",
156
+ "I. Introduction",
157
+ "a. first",
158
+ "mix. of words",
159
+ ])("keeps %j a paragraph", (line) => {
160
+ const doc = parse(line);
161
+ expect(doc.content).toEqual([
162
+ { type: "paragraph", content: [{ type: "text", text: line }] },
163
+ ]);
164
+ });
165
+
166
+ it("keeps a code block inside a list item exactly as written", () => {
167
+ const doc = parse(
168
+ [
169
+ "1. Create the project:",
170
+ " ```bash",
171
+ " ├── .github",
172
+ " │ └── workflows",
173
+ " └── package.json",
174
+ " ```",
175
+ "2. Clone it",
176
+ ].join("\n"),
177
+ );
178
+ const list = doc.content[0];
179
+ expect(list.type).toBe("orderedList");
180
+ expect(list.content).toHaveLength(2);
181
+ const codeBlock = list.content[0].content.find(
182
+ (node: TiptapNode) => node.type === "codeBlock",
183
+ );
184
+ expect(codeBlock.content[0].text).toBe(
185
+ "├── .github\n│ └── workflows\n└── package.json",
186
+ );
187
+ });
188
+
189
+ it("keeps a wide marker's continuation lines intact", () => {
190
+ const doc = parse("10. Run:\n\n ```sh\n ls -la\n ```");
191
+ const codeBlock = doc.content[0].content[0].content.find(
192
+ (node: TiptapNode) => node.type === "codeBlock",
193
+ );
194
+ expect(doc.content[0].attrs).toEqual({ start: 10 });
195
+ expect(codeBlock.content[0].text).toBe("ls -la");
196
+ });
197
+
93
198
  it("converts horizontal rules", () => {
94
199
  const doc = parse("Above\n\n---\n\nBelow");
95
200
  const hr = doc.content.find(
@@ -2,6 +2,16 @@ import { describe, it, expect } from "vitest";
2
2
  import { render, toPlainText, extractTitle } from "../markdown.js";
3
3
 
4
4
  describe("render", () => {
5
+ it("renders the HTML emphasis tags the serializer writes for CJK text", () => {
6
+ expect(render("而是:<strong>固定时间。</strong>来的人")).toBe(
7
+ "<p>而是:<strong>固定时间。</strong>来的人</p>",
8
+ );
9
+ });
10
+
11
+ it("escapes HTML tags outside the emphasis allowlist", () => {
12
+ expect(render("<span>x</span>")).toBe("<p>&lt;span&gt;x&lt;/span&gt;</p>");
13
+ });
14
+
5
15
  it("renders a heading", () => {
6
16
  const html = render("# Hello");
7
17
  expect(html).toContain("<h1>");
@@ -52,6 +52,73 @@ describe("resolveConfig", () => {
52
52
  expect(config.siteName).toBe("FromEnv");
53
53
  });
54
54
 
55
+ // A settings row holding an empty string is not always the same thing. The
56
+ // editor decides: a boolean, a number, and an enum with fixed options cannot
57
+ // be saved empty, so an empty one of those came from a path that skipped
58
+ // validation — a snapshot import replaying raw SQL — and the environment
59
+ // variable should still be heard.
60
+ // Text can legitimately be empty, and then the row wins.
61
+ describe("empty settings rows", () => {
62
+ it("lets an environment value through an empty boolean row", () => {
63
+ const config = resolveConfig(makeEnv({ NOINDEX: "true" }), {
64
+ NOINDEX: "",
65
+ });
66
+
67
+ expect(config.noindex).toBe(true);
68
+ });
69
+
70
+ it("falls back to the default for an empty boolean row with no env value", () => {
71
+ const config = resolveConfig(makeEnv(), { NOINDEX: "" });
72
+
73
+ expect(config.noindex).toBe(false);
74
+ });
75
+
76
+ it("still lets a saved boolean row override the environment", () => {
77
+ const config = resolveConfig(makeEnv({ NOINDEX: "true" }), {
78
+ NOINDEX: "false",
79
+ });
80
+
81
+ expect(config.noindex).toBe(false);
82
+ });
83
+
84
+ it("lets an environment value through an empty numeric row", () => {
85
+ const config = resolveConfig(makeEnv({ PAGE_SIZE: "20" }), {
86
+ PAGE_SIZE: "",
87
+ });
88
+
89
+ expect(config.pageSize).toBe(20);
90
+ });
91
+
92
+ it("keeps an empty enum row ahead of the environment when empty is an option", () => {
93
+ // `DASHBOARD_LANGUAGE` lists "" among its options — it is how the
94
+ // dashboard is told to follow the site language.
95
+ const config = resolveConfig(makeEnv({ DASHBOARD_LANGUAGE: "zh-Hans" }), {
96
+ DASHBOARD_LANGUAGE: "",
97
+ });
98
+
99
+ expect(config.dashboardLanguage).toBe("");
100
+ });
101
+
102
+ it("keeps an empty text row ahead of the environment", () => {
103
+ const config = resolveConfig(
104
+ makeEnv({ SITE_FOOTER: "From environment" }),
105
+ { SITE_FOOTER: "" },
106
+ );
107
+
108
+ expect(config.siteFooter).toBe("");
109
+ });
110
+
111
+ it("lets an environment value through an empty enum row", () => {
112
+ // `MAIN_RSS_FEED` offers "featured" and "latest" and nothing else, so an
113
+ // empty row is as impossible to save as an empty boolean.
114
+ const config = resolveConfig(makeEnv({ MAIN_RSS_FEED: "latest" }), {
115
+ MAIN_RSS_FEED: "",
116
+ });
117
+
118
+ expect(config.mainRssFeed).toBe("latest");
119
+ });
120
+ });
121
+
55
122
  it("lets an explicit empty DB value override an environment fallback", () => {
56
123
  const config = resolveConfig(
57
124
  makeEnv({ SITE_DESCRIPTION: "From environment" }),
@@ -61,10 +61,17 @@ describe("RedirectTypeSchema", () => {
61
61
  expect(RedirectTypeSchema.parse("302")).toBe("302");
62
62
  });
63
63
 
64
+ // The custom URL list answers `redirectType: 301`; sending that back to
65
+ // create one has to work.
66
+ it("accepts 301 and 302 as numbers, as the API answers them", () => {
67
+ expect(RedirectTypeSchema.parse(301)).toBe("301");
68
+ expect(RedirectTypeSchema.parse(302)).toBe("302");
69
+ });
70
+
64
71
  it("rejects other values", () => {
65
72
  expect(() => RedirectTypeSchema.parse("200")).toThrow();
66
73
  expect(() => RedirectTypeSchema.parse("404")).toThrow();
67
- expect(() => RedirectTypeSchema.parse(301)).toThrow();
74
+ expect(() => RedirectTypeSchema.parse(308)).toThrow();
68
75
  });
69
76
  });
70
77
 
@@ -617,6 +624,25 @@ describe("CreatePostApiSchema", () => {
617
624
  status: "published",
618
625
  };
619
626
 
627
+ it("accepts the creation and edit times a restore passes", () => {
628
+ expect(
629
+ CreatePostApiSchema.parse({
630
+ ...validPost,
631
+ createdAt: 1700000000,
632
+ updatedAt: 1700009000,
633
+ }),
634
+ ).toMatchObject({ createdAt: 1700000000, updatedAt: 1700009000 });
635
+ });
636
+
637
+ it("leaves record timestamps out of updates", () => {
638
+ expect(
639
+ UpdatePostApiSchema.safeParse({ createdAt: 1700000000 }).success,
640
+ ).toBe(false);
641
+ expect(
642
+ UpdatePostApiSchema.safeParse({ updatedAt: 1700000000 }).success,
643
+ ).toBe(false);
644
+ });
645
+
620
646
  it("accepts ordered attachment inputs", () => {
621
647
  const mediaId = createEntityId("media");
622
648
  const result = CreatePostApiSchema.parse({
@@ -733,6 +733,93 @@ describe("Timeline data assembly", () => {
733
733
  ]);
734
734
  });
735
735
 
736
+ // A reply can be older than its root (a post moved into the Thread keeps
737
+ // its creation time; an export without `created` dates posts by
738
+ // publication). The Thread still opens on its root.
739
+ async function createThreadWithOlderReplies() {
740
+ const root = await postService.create({
741
+ format: "note",
742
+ bodyMarkdown: "Root",
743
+ featured: true,
744
+ publishedAt: 3000,
745
+ });
746
+ const first = await postService.create({
747
+ format: "note",
748
+ bodyMarkdown: "First reply",
749
+ replyToId: root.id,
750
+ publishedAt: 1000,
751
+ });
752
+ const second = await postService.create({
753
+ format: "note",
754
+ bodyMarkdown: "Second reply",
755
+ replyToId: first.id,
756
+ publishedAt: 2000,
757
+ });
758
+ for (const [id, createdAt] of [
759
+ [root.id, 3000],
760
+ [first.id, 1000],
761
+ [second.id, 2000],
762
+ ] as const) {
763
+ await db.update(postTable).set({ createdAt }).where(eq(postTable.id, id));
764
+ }
765
+ return { root, first, second };
766
+ }
767
+
768
+ it("keeps a Featured Thread whose replies are older than its root", async () => {
769
+ const { root, second } = await createThreadWithOlderReplies();
770
+
771
+ const result = await assembleFeaturedTimeline(createTimelineContext(), {
772
+ isAuthenticated: true,
773
+ });
774
+
775
+ expect(result.items).toHaveLength(1);
776
+ expect(result.items[0]?.post.id).toBe(root.id);
777
+ expect(result.items[0]?.curatedThread?.segments).toEqual([
778
+ expect.objectContaining({
779
+ post: expect.objectContaining({ id: root.id }),
780
+ hiddenBeforeCount: 0,
781
+ highlighted: true,
782
+ }),
783
+ expect.objectContaining({
784
+ post: expect.objectContaining({ id: second.id }),
785
+ hiddenBeforeCount: 1,
786
+ highlighted: false,
787
+ }),
788
+ ]);
789
+ });
790
+
791
+ it("renders a collected Thread whose replies are older than its root", async () => {
792
+ const collection = await collectionService.create({
793
+ slug: "older-replies",
794
+ title: "Older replies",
795
+ });
796
+ const { root, first, second } = await createThreadWithOlderReplies();
797
+ await collectionService.addThread(collection.id, root.id);
798
+
799
+ const result = await assembleCollectionTimeline(createTimelineContext(), {
800
+ collectionIds: [collection.id],
801
+ isAuthenticated: true,
802
+ sortOrder: "newest",
803
+ });
804
+
805
+ expect(result.items).toHaveLength(1);
806
+ expect(result.items[0]?.post.id).toBe(root.id);
807
+ expect(result.items[0]?.curatedThread?.segments).toEqual([
808
+ expect.objectContaining({
809
+ post: expect.objectContaining({ id: root.id }),
810
+ highlighted: false,
811
+ }),
812
+ expect.objectContaining({
813
+ post: expect.objectContaining({ id: first.id }),
814
+ highlighted: false,
815
+ }),
816
+ expect.objectContaining({
817
+ post: expect.objectContaining({ id: second.id }),
818
+ highlighted: true,
819
+ }),
820
+ ]);
821
+ });
822
+
736
823
  it("deduplicates Threads selected through multiple collections", async () => {
737
824
  const smart = await collectionService.create({
738
825
  slug: "smart",
@@ -361,6 +361,154 @@ describe("tiptapJsonToMarkdown", () => {
361
361
  );
362
362
  expect(tiptapJsonToMarkdown(json)).toBe("Line 1 \nLine 2");
363
363
  });
364
+
365
+ // Trailing spaces on a line with nothing else on it make a blank line,
366
+ // which ends the paragraph (or leaves a list item empty).
367
+ it("keeps two hard breaks in a row inside one paragraph", () => {
368
+ const br = { type: "hardBreak" };
369
+ const json = doc(p(text("A"), br, br, text("B")));
370
+ const markdown = tiptapJsonToMarkdown(json);
371
+ expect(markdown).toBe("A \n\\\nB");
372
+ expect(JSON.parse(markdownToTiptapJson(markdown))).toEqual(
373
+ JSON.parse(json),
374
+ );
375
+ });
376
+
377
+ it("keeps a list item that opens with a hard break in the list", () => {
378
+ const json = doc({
379
+ type: "bulletList",
380
+ content: [
381
+ {
382
+ type: "listItem",
383
+ content: [p({ type: "hardBreak" }, text("Ships in 3–7 days"))],
384
+ },
385
+ { type: "listItem", content: [p(text("Next"))] },
386
+ ],
387
+ });
388
+ const markdown = tiptapJsonToMarkdown(json);
389
+ expect(JSON.parse(markdownToTiptapJson(markdown))).toEqual(
390
+ JSON.parse(json),
391
+ );
392
+ });
393
+ });
394
+
395
+ // CommonMark only closes `**` after punctuation when a space or another
396
+ // punctuation mark follows, so `**句子。**后文` stays literal asterisks in
397
+ // Hugo and in Jant. Those runs use HTML tags, which both read.
398
+ describe("emphasis a delimiter can't open or close", () => {
399
+ const bold = [{ type: "bold" }];
400
+
401
+ it.each([
402
+ [
403
+ "bold ending in CJK punctuation before a letter",
404
+ [text("而是:"), text("固定时间,一群人。", bold), text("来的人")],
405
+ "而是:<strong>固定时间,一群人。</strong>来的人",
406
+ ],
407
+ [
408
+ "bold opening on CJK punctuation after a letter",
409
+ [text("说"), text("「引用」", bold), text("后")],
410
+ "说<strong>「引用」</strong>后",
411
+ ],
412
+ [
413
+ "italic ending in punctuation before a letter",
414
+ [text("这是"), text("强调。", [{ type: "italic" }]), text("后面")],
415
+ "这是<em>强调。</em>后面",
416
+ ],
417
+ [
418
+ "strike ending in punctuation before a letter",
419
+ [text("价格"), text("100元。", [{ type: "strike" }]), text("现在")],
420
+ "价格<s>100元。</s>现在",
421
+ ],
422
+ [
423
+ "English bold ending in a period before a letter",
424
+ [text("Say "), text("hello.", bold), text("World")],
425
+ "Say <strong>hello.</strong>World",
426
+ ],
427
+ ])("writes %s as HTML", (_label, content, expected) => {
428
+ const json = doc(p(...content));
429
+ expect(tiptapJsonToMarkdown(json)).toBe(expected);
430
+ expect(JSON.parse(markdownToTiptapJson(expected))).toEqual(
431
+ JSON.parse(json),
432
+ );
433
+ });
434
+
435
+ it.each([
436
+ [
437
+ "between spaces",
438
+ [text("a "), text("bold", bold), text(" b")],
439
+ "a **bold** b",
440
+ ],
441
+ [
442
+ "inside CJK text",
443
+ [text("中"), text("加粗", bold), text("文")],
444
+ "中**加粗**文",
445
+ ],
446
+ [
447
+ "ending in punctuation before punctuation",
448
+ [text("说"), text("好。", bold), text("」")],
449
+ "说**好。**」",
450
+ ],
451
+ ])("keeps Markdown delimiters %s", (_label, content, expected) => {
452
+ const json = doc(p(...content));
453
+ expect(tiptapJsonToMarkdown(json)).toBe(expected);
454
+ expect(JSON.parse(markdownToTiptapJson(expected))).toEqual(
455
+ JSON.parse(json),
456
+ );
457
+ });
458
+ });
459
+
460
+ // Text that happens to open a line with block syntax must come back as the
461
+ // same paragraph, not as a list, heading, or setext underline.
462
+ describe("block markers at the start of a line", () => {
463
+ const br = { type: "hardBreak" };
464
+
465
+ it.each([
466
+ ["a numbered line", [text("1986. A good year")], "1986\\. A good year"],
467
+ ["a parenthesized number", [text("2) Second")], "2\\) Second"],
468
+ ["a dash", [text("- not a list")], "\\- not a list"],
469
+ ["a plus", [text("+ not a list")], "\\+ not a list"],
470
+ ["a hash", [text("# not a heading")], "\\# not a heading"],
471
+ [
472
+ "numbered lines after hard breaks",
473
+ [text("Update:"), br, text("1. Pony"), br, text("2. Next")],
474
+ "Update: \n1\\. Pony \n2\\. Next",
475
+ ],
476
+ [
477
+ "a dash rule after a hard break",
478
+ [text("Title"), br, text("---")],
479
+ "Title \n\\---",
480
+ ],
481
+ [
482
+ "an equals rule after a hard break",
483
+ [text("Title"), br, text("===")],
484
+ "Title \n\\===",
485
+ ],
486
+ ])("escapes %s", (_label, content, expected) => {
487
+ const json = doc(p(...content));
488
+ expect(tiptapJsonToMarkdown(json)).toBe(expected);
489
+ expect(JSON.parse(markdownToTiptapJson(expected))).toEqual(
490
+ JSON.parse(json),
491
+ );
492
+ });
493
+
494
+ it("leaves markers in the middle of a line alone", () => {
495
+ expect(tiptapJsonToMarkdown(doc(p(text("Step 1. then 2."))))).toBe(
496
+ "Step 1. then 2.",
497
+ );
498
+ });
499
+
500
+ it("escapes a numbered line inside a list item", () => {
501
+ const json = doc({
502
+ type: "orderedList",
503
+ content: [
504
+ { type: "listItem", content: [p(text("1. nested-looking"))] },
505
+ ],
506
+ });
507
+ expect(tiptapJsonToMarkdown(json)).toBe("1. 1\\. nested-looking");
508
+ expect(
509
+ JSON.parse(markdownToTiptapJson("1. 1\\. nested-looking")),
510
+ ).toEqual(JSON.parse(json));
511
+ });
364
512
  });
365
513
 
366
514
  describe("round-trip", () => {
@@ -411,12 +559,32 @@ describe("tiptapJsonToMarkdown", () => {
411
559
  });
412
560
 
413
561
  describe("edge cases", () => {
414
- it("returns empty string for invalid JSON", () => {
415
- expect(tiptapJsonToMarkdown("not json")).toBe("");
562
+ it("throws for invalid JSON instead of returning nothing", () => {
563
+ expect(() => tiptapJsonToMarkdown("not json")).toThrow();
416
564
  });
417
565
 
418
- it("returns empty string for non-doc node", () => {
419
- expect(tiptapJsonToMarkdown('{"type":"paragraph"}')).toBe("");
566
+ it("throws for a root that is not a doc", () => {
567
+ expect(() => tiptapJsonToMarkdown('{"type":"paragraph"}')).toThrow(/doc/);
568
+ });
569
+
570
+ // A stored body with an empty list item made the serializer throw, and
571
+ // the catch-all turned the whole post into "" in the export.
572
+ it("fills an empty list item instead of dropping the document", () => {
573
+ const json = doc(
574
+ { type: "heading", attrs: { level: 2 }, content: [text("Setup")] },
575
+ {
576
+ type: "orderedList",
577
+ content: [
578
+ { type: "listItem", content: [] },
579
+ { type: "listItem", content: [p(text("Install"))] },
580
+ ],
581
+ },
582
+ { type: "codeBlock", content: [text("nix run")] },
583
+ );
584
+ const markdown = tiptapJsonToMarkdown(json);
585
+ expect(markdown).toContain("## Setup");
586
+ expect(markdown).toContain("Install");
587
+ expect(markdown).toContain("nix run");
420
588
  });
421
589
 
422
590
  it("handles empty doc", () => {
@@ -400,7 +400,9 @@ export function linkTerms(
400
400
  * than on the checkbox label. The settings page and the setup screen both
401
401
  * render their line through here, so the two can never link different words;
402
402
  * setup's line is the settings line's opening sentence, so only the name is
403
- * there to find.
403
+ * there to find. The notice that replaces the settings line while the box is
404
+ * locked in demo mode goes through here too, and it also names only the
405
+ * directory.
404
406
  *
405
407
  * @param intro - The translated help line
406
408
  * @param terms - The translated directory and rules-page names, as `intro`
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The repository name Jant proposes for a site's GitHub Sync mirror.
3
+ *
4
+ * The settings page prefills it on github.com/new, and a site export with no
5
+ * repository behind it uses it as the Worker name in `wrangler.jsonc`.
6
+ * Cloudflare names a Worker imported from a repository after the repository,
7
+ * so an export and a repository created with the default name agree without
8
+ * anyone editing either.
9
+ */
10
+
11
+ /** Used when the site URL yields no host label. */
12
+ const FALLBACK_REPO_NAME = "jant-site-sync";
13
+
14
+ /**
15
+ * Derive the default repository name for a site's sync mirror.
16
+ *
17
+ * Uses the first DNS label of the site's host — a stable, URL-safe
18
+ * identifier tied to this Jant instance. A leading `www` is skipped: it
19
+ * names nothing about the site, and every `www.` site would otherwise
20
+ * propose the same `www-jant-sync`. It is kept when only a top-level domain
21
+ * follows it, since that label would say even less. The `-jant-sync` suffix
22
+ * tells the sync mirror apart from a user's own `{slug}-jant` source repo.
23
+ *
24
+ * @param siteUrl - The site's public URL.
25
+ * @returns A GitHub-safe repository name; `jant-site-sync` when the URL has
26
+ * no usable host.
27
+ * @example
28
+ * suggestSyncRepoName("https://notes.example.com"); // "notes-jant-sync"
29
+ * suggestSyncRepoName("https://www.example.com"); // "example-jant-sync"
30
+ */
31
+ export function suggestSyncRepoName(siteUrl: string): string {
32
+ let labels: string[] = [];
33
+ try {
34
+ labels = new URL(siteUrl).host.split(".");
35
+ } catch {
36
+ /* fall through */
37
+ }
38
+ const [first = "", second = "", ...more] = labels;
39
+ const label = first === "www" && more.length > 0 ? second : first;
40
+ const slug = label
41
+ .toLowerCase()
42
+ .replace(/[^a-z0-9-_]+/g, "-")
43
+ .replace(/^-+|-+$/g, "");
44
+ return slug ? `${slug}-jant-sync` : FALLBACK_REPO_NAME;
45
+ }
@@ -34,6 +34,24 @@ export interface HugoCollectionRef {
34
34
  pinned_at?: string | null;
35
35
  }
36
36
 
37
+ /**
38
+ * A smart collection's conditions on its section page (`type:
39
+ * smart_collection`). The stored selection, with two changes of spelling:
40
+ * `collection` is one collection's slug rather than a list of IDs, and
41
+ * `media` is `any`, `none`, or a list of kinds. The theme's
42
+ * `smart-collection-members` partial evaluates it; an import recreates the
43
+ * smart collection from it.
44
+ */
45
+ export interface HugoSmartCollectionSelection {
46
+ collection?: string;
47
+ format?: string;
48
+ title?: boolean;
49
+ year?: number;
50
+ media?: "any" | "none" | string[];
51
+ replies?: boolean;
52
+ visibility?: string;
53
+ }
54
+
37
55
  /**
38
56
  * Jant media attachment descriptor. Stored flat inside the `media:` front-
39
57
  * matter array. `src` is either:
@@ -90,6 +108,12 @@ export interface HugoFrontMatter {
90
108
  id?: string;
91
109
  title?: string;
92
110
  date?: string;
111
+ /**
112
+ * When the post was created, written only when it differs from `date` (a
113
+ * published post dated before or after it was written). Restored on import
114
+ * so thread order and "last edited" survive a move.
115
+ */
116
+ created?: string;
93
117
  updated?: string;
94
118
  slug?: string;
95
119
  type?: string;
@@ -97,7 +121,20 @@ export interface HugoFrontMatter {
97
121
 
98
122
  // Hugo routing
99
123
  aliases?: string[];
124
+ /**
125
+ * Published root bundles only: the `<id>` Jant's feeds gave the Thread's
126
+ * entry — its permalink, absolute, with no trailing slash. The theme's feed
127
+ * writes it as the entry's `<id>` so readers don't show the post again after
128
+ * a move. Written by the export, never read by the import.
129
+ */
130
+ feed_id?: string;
100
131
  build?: HugoBuildOptions;
132
+ /**
133
+ * Reply bundles only: 1-based position in the Thread. Jant orders a Thread
134
+ * by creation time, then ID; replies written in the same second tie on
135
+ * `date`, so the theme and the importer order by this instead.
136
+ */
137
+ weight?: number;
101
138
 
102
139
  // Jant post payload (flat — no `extra` nesting)
103
140
  format?: string;
@@ -143,6 +180,14 @@ export interface HugoFrontMatter {
143
180
  collections?: HugoCollectionRef[];
144
181
  media?: JantMedia[];
145
182
 
183
+ // Smart collection section pages
184
+ selection?: HugoSmartCollectionSelection;
185
+ /**
186
+ * `list` or `grid`; absent when the smart collection follows the site's
187
+ * archive layout. Not `layout`, which Hugo reads as a template name.
188
+ */
189
+ display_layout?: string;
190
+
146
191
  // Escape hatch for page-specific export metadata.
147
192
  [key: string]: unknown;
148
193
  }
@@ -213,6 +258,7 @@ const FRONT_MATTER_KEY_ORDER: readonly string[] = [
213
258
  "type",
214
259
  "draft",
215
260
  "aliases",
261
+ "feed_id",
216
262
  "build",
217
263
 
218
264
  // Post payload