@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
@@ -9,6 +9,15 @@
9
9
  * Routes call these methods instead of touching the settings table to
10
10
  * keep the model relational (one entry per (installation_id, site_id)
11
11
  * pair) instead of serialising a JSON list into a single settings row.
12
+ *
13
+ * Two scopes matter and they are not interchangeable. A *site* scope
14
+ * answers "which accounts is this blog syncing through". A *user* scope
15
+ * answers "which accounts has this person already authorized anywhere",
16
+ * which is what the connect flow needs: a GitHub App installs once per
17
+ * GitHub account, so someone who connected their first site can never
18
+ * install it a second time — GitHub sends them to the installation's
19
+ * Configure page and never calls back. Their other sites have to be able
20
+ * to reuse that installation without leaving Jant.
12
21
  */
13
22
 
14
23
  import { and, eq, inArray } from "drizzle-orm";
@@ -35,11 +44,56 @@ export interface StoredGitHubAppInstallation {
35
44
  addedAt: number;
36
45
  }
37
46
 
47
+ /**
48
+ * An installation as seen by one person rather than by one site: the
49
+ * same installation may back several of their sites, so the site id
50
+ * drops out and `addedAt` carries the most recent binding.
51
+ */
52
+ export interface VisibleGitHubAppInstallation {
53
+ installationId: string;
54
+ account: GitHubInstallationAccount;
55
+ /** Unix seconds — most recent binding of this installation to any of the user's sites. */
56
+ addedAt: number;
57
+ }
58
+
38
59
  export interface GitHubAppInstallationsService {
39
60
  /** Installations this site has authorized, newest-first by `addedAt`. */
40
61
  listInstallationsForSite(
41
62
  siteId: string,
42
63
  ): Promise<StoredGitHubAppInstallation[]>;
64
+ /**
65
+ * Installations authorized on any site this user is a member of,
66
+ * deduplicated by installation and newest-first by `addedAt`.
67
+ *
68
+ * This is the set the connect flow may offer and act on: membership is
69
+ * the tenancy boundary, so one person's sites share their GitHub
70
+ * accounts while other tenants' installations stay invisible.
71
+ */
72
+ listInstallationsForUser(
73
+ userId: string,
74
+ ): Promise<VisibleGitHubAppInstallation[]>;
75
+ /**
76
+ * One installation from `listInstallationsForUser`, or `null` when the
77
+ * user has no site bound to it. Routes use this to authorize an
78
+ * `installationId` that arrived in a request.
79
+ */
80
+ findInstallationForUser(
81
+ installationId: string,
82
+ userId: string,
83
+ ): Promise<VisibleGitHubAppInstallation | null>;
84
+ /**
85
+ * Installations the user's sites are *actively syncing through*, read
86
+ * from each site's settings rather than from the junction table.
87
+ *
88
+ * The two can disagree: settings are written when a site connects,
89
+ * the binding when it was installed, and a site that connected while
90
+ * the binding write failed keeps syncing with nothing to show for it.
91
+ * Callers use this to rebuild the missing row — they have to fetch the
92
+ * account from GitHub, which is why this returns ids and not accounts.
93
+ */
94
+ listSyncingInstallationsForUser(
95
+ userId: string,
96
+ ): Promise<Array<{ siteId: string; installationId: string }>>;
43
97
  /** Site ids that share the given installation. */
44
98
  listSitesForInstallation(installationId: string): Promise<string[]>;
45
99
  /**
@@ -56,6 +110,19 @@ export interface GitHubAppInstallationsService {
56
110
  ): Promise<void>;
57
111
  /** Remove a single (installation, site) binding. */
58
112
  removeInstallation(installationId: string, siteId: string): Promise<void>;
113
+ /**
114
+ * Remove the installation from every site this user is a member of.
115
+ *
116
+ * Used when GitHub reports the installation as gone (401/404): the
117
+ * binding that made it visible may live on another of the user's
118
+ * sites, so clearing only the current site would leave a dead account
119
+ * in their picker forever. Other tenants' bindings are left to the
120
+ * App-level webhook, which sees the authoritative `installation.deleted`.
121
+ */
122
+ removeInstallationForUser(
123
+ installationId: string,
124
+ userId: string,
125
+ ): Promise<void>;
59
126
  /**
60
127
  * Remove the installation from every site it's bound to and return
61
128
  * the previously-bound site ids so callers can fan out side effects
@@ -94,7 +161,63 @@ export function createGitHubAppInstallationsService(
94
161
  db: Database,
95
162
  databaseSchema: DatabaseSchema = sqliteSchemaBundle,
96
163
  ): GitHubAppInstallationsService {
97
- const { githubAppInstallation, settings } = databaseSchema;
164
+ const { githubAppInstallation, settings, siteMembers } = databaseSchema;
165
+
166
+ /**
167
+ * Rows for every binding on a site the user belongs to. One join, not
168
+ * a per-site fan-out — a user has few sites but the query shape is
169
+ * what gets copied.
170
+ */
171
+ function selectUserBindings(userId: string, installationId?: string) {
172
+ const scope = eq(siteMembers.userId, userId);
173
+ return db
174
+ .select({
175
+ installationId: githubAppInstallation.installationId,
176
+ accountLogin: githubAppInstallation.accountLogin,
177
+ accountType: githubAppInstallation.accountType,
178
+ accountAvatarUrl: githubAppInstallation.accountAvatarUrl,
179
+ addedAt: githubAppInstallation.addedAt,
180
+ })
181
+ .from(githubAppInstallation)
182
+ .innerJoin(
183
+ siteMembers,
184
+ eq(siteMembers.siteId, githubAppInstallation.siteId),
185
+ )
186
+ .where(
187
+ installationId
188
+ ? and(scope, eq(githubAppInstallation.installationId, installationId))
189
+ : scope,
190
+ );
191
+ }
192
+
193
+ /** Collapse per-site bindings into one entry per installation. */
194
+ function toVisible(
195
+ rows: Array<{
196
+ installationId: string;
197
+ accountLogin: string;
198
+ accountType: string;
199
+ accountAvatarUrl: string;
200
+ addedAt: number;
201
+ }>,
202
+ ): VisibleGitHubAppInstallation[] {
203
+ const byInstallation = new Map<string, VisibleGitHubAppInstallation>();
204
+ for (const row of rows) {
205
+ const entry = {
206
+ installationId: row.installationId,
207
+ account: {
208
+ login: row.accountLogin,
209
+ type: toAccountType(row.accountType),
210
+ avatarUrl: row.accountAvatarUrl,
211
+ },
212
+ addedAt: row.addedAt,
213
+ };
214
+ const existing = byInstallation.get(row.installationId);
215
+ if (!existing || existing.addedAt < entry.addedAt) {
216
+ byInstallation.set(row.installationId, entry);
217
+ }
218
+ }
219
+ return [...byInstallation.values()].sort((a, b) => b.addedAt - a.addedAt);
220
+ }
98
221
 
99
222
  // Per-site settings mutation helpers. These sit alongside the
100
223
  // junction-table operations because the webhook fan-out inherently
@@ -152,6 +275,32 @@ export function createGitHubAppInstallationsService(
152
275
  return rows.map(toStored).sort((a, b) => b.addedAt - a.addedAt);
153
276
  },
154
277
 
278
+ async listInstallationsForUser(userId) {
279
+ return toVisible(await selectUserBindings(userId));
280
+ },
281
+
282
+ async findInstallationForUser(installationId, userId) {
283
+ const rows = await selectUserBindings(userId, installationId);
284
+ return toVisible(rows)[0] ?? null;
285
+ },
286
+
287
+ async listSyncingInstallationsForUser(userId) {
288
+ const rows = await db
289
+ .select({
290
+ siteId: settings.siteId,
291
+ installationId: settings.value,
292
+ })
293
+ .from(settings)
294
+ .innerJoin(siteMembers, eq(siteMembers.siteId, settings.siteId))
295
+ .where(
296
+ and(
297
+ eq(siteMembers.userId, userId),
298
+ eq(settings.key, "GITHUB_SYNC_APP_INSTALLATION_ID"),
299
+ ),
300
+ );
301
+ return rows.filter((row) => row.installationId.trim().length > 0);
302
+ },
303
+
155
304
  async listSitesForInstallation(installationId) {
156
305
  const rows = await db
157
306
  .select({ siteId: githubAppInstallation.siteId })
@@ -198,6 +347,21 @@ export function createGitHubAppInstallationsService(
198
347
  );
199
348
  },
200
349
 
350
+ async removeInstallationForUser(installationId, userId) {
351
+ const memberSites = db
352
+ .select({ siteId: siteMembers.siteId })
353
+ .from(siteMembers)
354
+ .where(eq(siteMembers.userId, userId));
355
+ await db
356
+ .delete(githubAppInstallation)
357
+ .where(
358
+ and(
359
+ eq(githubAppInstallation.installationId, installationId),
360
+ inArray(githubAppInstallation.siteId, memberSites),
361
+ ),
362
+ );
363
+ },
364
+
201
365
  async removeInstallationEverywhere(installationId) {
202
366
  const rows = await db
203
367
  .select({ siteId: githubAppInstallation.siteId })
@@ -277,6 +441,11 @@ export function createGitHubAppInstallationsService(
277
441
  };
278
442
  }
279
443
 
444
+ /** The column is a checked enum; narrow it without trusting the read. */
445
+ function toAccountType(value: string): GitHubAccountType {
446
+ return value === "Organization" ? "Organization" : "User";
447
+ }
448
+
280
449
  function toStored(row: {
281
450
  installationId: string;
282
451
  siteId: string;
@@ -285,14 +454,12 @@ function toStored(row: {
285
454
  accountAvatarUrl: string;
286
455
  addedAt: number;
287
456
  }): StoredGitHubAppInstallation {
288
- const type: GitHubAccountType =
289
- row.accountType === "Organization" ? "Organization" : "User";
290
457
  return {
291
458
  installationId: row.installationId,
292
459
  siteId: row.siteId,
293
460
  account: {
294
461
  login: row.accountLogin,
295
- type,
462
+ type: toAccountType(row.accountType),
296
463
  avatarUrl: row.accountAvatarUrl,
297
464
  },
298
465
  addedAt: row.addedAt,
@@ -32,7 +32,13 @@ import { getInstallationToken } from "../lib/github-app.js";
32
32
  import type { GitHubAppEnvConfig } from "../lib/env.js";
33
33
  import { parseFrontMatter } from "../lib/hugo-markdown.js";
34
34
  import { markdownToTiptapJson } from "../lib/markdown-to-tiptap.js";
35
- import { createExportService, type SiteConfig } from "./export.js";
35
+ import {
36
+ createExportService,
37
+ isStoredExportFile,
38
+ readStoredExportFile,
39
+ type ExportFile,
40
+ type SiteConfig,
41
+ } from "./export.js";
36
42
  import type { PostService } from "./post.js";
37
43
  import type { PathService } from "./path.js";
38
44
  import type { CollectionService } from "./collection.js";
@@ -116,6 +122,33 @@ export function isManagedPath(path: string): boolean {
116
122
  return JANT_MANAGED_GLOBS.some((g) => pathMatchesManagedGlob(path, g));
117
123
  }
118
124
 
125
+ /**
126
+ * Pick the export files this push should write.
127
+ *
128
+ * Everything is written every push except scaffolding the repo already has.
129
+ * `wrangler.jsonc` is the case that needs this: its Worker name defaults to
130
+ * the repository name, and a user whose Worker is named differently has to
131
+ * correct it by hand. Rewriting the file every push would revert that, and
132
+ * every build after it would fail on the name mismatch.
133
+ *
134
+ * @param exportFiles - Everything the export generated.
135
+ * @param existingPaths - Repo-relative paths already present on the remote.
136
+ * @returns The subset to include in the commit.
137
+ * @example
138
+ * selectFilesToWrite(files, new Set(["wrangler.jsonc"]));
139
+ */
140
+ export function selectFilesToWrite(
141
+ exportFiles: readonly ExportFile[],
142
+ existingPaths: ReadonlySet<string>,
143
+ ): ExportFile[] {
144
+ return exportFiles.filter(
145
+ (file) =>
146
+ isStoredExportFile(file) ||
147
+ !file.scaffoldOnce ||
148
+ !existingPaths.has(file.path),
149
+ );
150
+ }
151
+
119
152
  /**
120
153
  * Compute the tree items that should null-out files on the remote HEAD
121
154
  * which Jant claims ownership of (matches `JANT_MANAGED_GLOBS`) but is
@@ -458,6 +491,9 @@ export function createGitHubSyncService(
458
491
  const exportService = createExportService(services, siteConfig, {
459
492
  storage: deps.storage,
460
493
  bundleMedia: false,
494
+ // Cloudflare names a Worker imported from this repository after it,
495
+ // so the deploy config's Worker name matches with no editing.
496
+ repoName: repo,
461
497
  });
462
498
  const exportFiles = await exportService.generateHugoFiles();
463
499
 
@@ -488,6 +524,26 @@ export function createGitHubSyncService(
488
524
  marker,
489
525
  );
490
526
 
527
+ // The HEAD tree is needed twice: to decide which scaffold files the
528
+ // repo already has, and (below) to delete managed files this push no
529
+ // longer generates. Reading it once, before the tree is assembled,
530
+ // covers both.
531
+ const headCommit = await client.getCommit(owner, repo, headSha);
532
+ const headTree = await client.getTree(owner, repo, headCommit.treeSha, {
533
+ recursive: true,
534
+ });
535
+ if (headTree.truncated) {
536
+ throw new Error(
537
+ "GitHub tree exceeds API limits (>100k entries or >7MB); " +
538
+ "incremental deletion cannot run safely against this repo.",
539
+ );
540
+ }
541
+ const existingPaths = new Set<string>(
542
+ headTree.tree
543
+ .filter((item) => item.type === "blob")
544
+ .map((item) => item.path),
545
+ );
546
+
491
547
  // Convert to Git tree items. The ownership marker is always emitted;
492
548
  // `created_at` is preserved across pushes (Git dedupes identical
493
549
  // blobs by SHA, so a no-op marker push is still cheap).
@@ -500,20 +556,28 @@ export function createGitHubSyncService(
500
556
  },
501
557
  ];
502
558
 
503
- for (const file of exportFiles) {
504
- if (typeof file.content === "string") {
559
+ for (const file of selectFilesToWrite(exportFiles, existingPaths)) {
560
+ // Sync links media by URL, so a stored file only turns up when the
561
+ // site URL is unknown and the export falls back to bundling.
562
+ const content = isStoredExportFile(file)
563
+ ? deps.storage
564
+ ? await readStoredExportFile(file, deps.storage)
565
+ : null
566
+ : file.content;
567
+ if (content === null) continue;
568
+ if (typeof content === "string") {
505
569
  treeItems.push({
506
570
  path: file.path,
507
571
  mode: "100644",
508
572
  type: "blob",
509
- content: file.content,
573
+ content,
510
574
  });
511
575
  } else {
512
576
  // Binary files need to be created as blobs first
513
577
  const blob = await client.createBlob(
514
578
  owner,
515
579
  repo,
516
- uint8ArrayToBase64(file.content),
580
+ uint8ArrayToBase64(content),
517
581
  "base64",
518
582
  );
519
583
  treeItems.push({
@@ -530,16 +594,6 @@ export function createGitHubSyncService(
530
594
  // removed. Without this, deleting a post in Jant would leave the
531
595
  // old file behind on GitHub, because base_tree preserves everything
532
596
  // we don't explicitly overwrite.
533
- const headCommit = await client.getCommit(owner, repo, headSha);
534
- const headTree = await client.getTree(owner, repo, headCommit.treeSha, {
535
- recursive: true,
536
- });
537
- if (headTree.truncated) {
538
- throw new Error(
539
- "GitHub tree exceeds API limits (>100k entries or >7MB); " +
540
- "incremental deletion cannot run safely against this repo.",
541
- );
542
- }
543
597
  const writtenPaths = new Set<string>(treeItems.map((item) => item.path));
544
598
  treeItems.push(...computeManagedDeletions(headTree.tree, writtenPaths));
545
599
 
@@ -102,6 +102,7 @@ const SearchPostsToolSchema = z.object({
102
102
 
103
103
  const ListMediaToolSchema = z.object({
104
104
  limit: z.coerce.number().int().min(1).max(200).optional().default(50),
105
+ cursor: z.string().optional(),
105
106
  mimePrefix: z.string().trim().min(1).optional(),
106
107
  });
107
108
 
@@ -244,6 +245,16 @@ const mcpTools: McpToolDefinition[] = [
244
245
  replyToId: { type: "string" },
245
246
  quietReply: { type: "boolean" },
246
247
  publishedAt: { type: "integer" },
248
+ createdAt: {
249
+ type: "integer",
250
+ description:
251
+ "Unix seconds. When moving a post from another site, the time it was written.",
252
+ },
253
+ updatedAt: {
254
+ type: "integer",
255
+ description:
256
+ "Unix seconds. When moving a post from another site, the time it was last edited.",
257
+ },
247
258
  attachments: {
248
259
  type: "array",
249
260
  items: { type: "object" },
@@ -276,6 +287,8 @@ const mcpTools: McpToolDefinition[] = [
276
287
  replyToId: input.replyToId,
277
288
  quietReply: input.quietReply,
278
289
  publishedAt: input.publishedAt,
290
+ createdAt: input.createdAt,
291
+ updatedAt: input.updatedAt,
279
292
  },
280
293
  input.attachments,
281
294
  {
@@ -542,12 +555,14 @@ const mcpTools: McpToolDefinition[] = [
542
555
  },
543
556
  {
544
557
  name: "jant_media_list",
545
- description: "List uploaded media, optionally filtered by MIME prefix.",
558
+ description:
559
+ "List uploaded media, newest first, optionally filtered by MIME prefix. Pass nextCursor back as cursor for the next page.",
546
560
  inputSchema: {
547
561
  type: "object",
548
562
  properties: {
549
563
  limit: { type: "integer", minimum: 1, maximum: 200, default: 50 },
550
564
  mimePrefix: { type: "string" },
565
+ cursor: { type: "string" },
551
566
  },
552
567
  additionalProperties: false,
553
568
  },
@@ -556,10 +571,13 @@ const mcpTools: McpToolDefinition[] = [
556
571
  const media = await context.services.media.list({
557
572
  limit: input.limit,
558
573
  mimePrefix: input.mimePrefix,
574
+ cursor: input.cursor,
559
575
  });
560
576
 
561
577
  return {
562
578
  media: media.map((item) => serializeMedia(item, context.appConfig)),
579
+ nextCursor:
580
+ media.length === input.limit ? (media.at(-1)?.id ?? null) : null,
563
581
  };
564
582
  },
565
583
  },
@@ -205,6 +205,8 @@ export interface MediaFilters {
205
205
  limit?: number;
206
206
  /** Filter by MIME type prefix, e.g. "image/" */
207
207
  mimePrefix?: string;
208
+ /** Exclusive media ID cursor: the last item of the previous page. */
209
+ cursor?: string;
208
210
  }
209
211
 
210
212
  export interface CreateTextAttachmentData {
@@ -774,11 +776,16 @@ export function createMediaService(
774
776
  sql`${media.mimeType} LIKE ${filters.mimePrefix + "%"}`,
775
777
  );
776
778
  }
779
+ if (filters?.cursor) {
780
+ conditions.push(lt(media.id, filters.cursor));
781
+ }
782
+ // Newest first. A TypeID sorts by the time it was minted, so the ID
783
+ // orders like `created_at` and never ties, which a cursor needs.
777
784
  const rows = await db
778
785
  .select()
779
786
  .from(media)
780
787
  .where(conditions.length > 0 ? and(...conditions) : undefined)
781
- .orderBy(desc(media.createdAt))
788
+ .orderBy(desc(media.id))
782
789
  .limit(limit);
783
790
  return rows.map(toMedia);
784
791
  },
@@ -6,7 +6,17 @@
6
6
  * slash (for example: "hello-world" or "collections/reading+tools").
7
7
  */
8
8
 
9
- import { and, asc, eq, inArray, isNotNull, ne, or, sql } from "drizzle-orm";
9
+ import {
10
+ and,
11
+ asc,
12
+ eq,
13
+ inArray,
14
+ isNotNull,
15
+ isNull,
16
+ ne,
17
+ or,
18
+ sql,
19
+ } from "drizzle-orm";
10
20
  import { type Database, batchQuery } from "../db/index.js";
11
21
  import {
12
22
  sqliteSchemaBundle,
@@ -83,6 +93,12 @@ export interface PathService {
83
93
  * not have its feed identity reassigned to the newer one.
84
94
  */
85
95
  getPostAliases(postIds: string[]): Promise<Map<string, string[]>>;
96
+ /**
97
+ * Custom URLs that stand on their own rather than naming a post or a
98
+ * collection: redirects to another path, and legacy archive views. Oldest
99
+ * first.
100
+ */
101
+ listStandalonePaths(): Promise<PathRecord[]>;
86
102
  listNavigableItems(): Promise<NavigableItem[]>;
87
103
  /**
88
104
  * Find registered paths that a URL segment would shadow: the segment itself
@@ -491,6 +507,23 @@ export function createPathService(
491
507
  );
492
508
  },
493
509
 
510
+ async listStandalonePaths() {
511
+ const rows = await db
512
+ .select()
513
+ .from(pathRegistry)
514
+ .where(
515
+ and(
516
+ eq(pathRegistry.siteId, siteId),
517
+ inArray(pathRegistry.kind, ["redirect", "archive"]),
518
+ isNull(pathRegistry.postId),
519
+ isNull(pathRegistry.collectionId),
520
+ isNull(pathRegistry.smartCollectionId),
521
+ ),
522
+ )
523
+ .orderBy(asc(pathRegistry.createdAt), asc(pathRegistry.id));
524
+ return rows.map(toPathRecord);
525
+ },
526
+
494
527
  async getPostAliases(postIds) {
495
528
  if (postIds.length === 0) return new Map<string, string[]>();
496
529