@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.
- package/bin/commands/export.js +3 -1
- package/bin/commands/import-site.js +689 -216
- package/bin/commands/setup.js +136 -0
- package/bin/commands/site/export.js +71 -34
- package/bin/commands/site/pull-media.js +2 -6
- package/bin/commands/site/snapshot/export.js +18 -60
- package/bin/commands/site/snapshot/import.js +22 -23
- package/bin/lib/d1-query.js +87 -2
- package/bin/lib/hugo-markdown.js +4 -0
- package/bin/lib/site-pull-media.js +21 -28
- package/bin/lib/site-selection.js +10 -1
- package/bin/lib/site-snapshot.js +338 -3
- package/bin/lib/sql-export.js +68 -5
- package/bin/lib/wrangler-cli.js +9 -0
- package/bin/lib/zip-archive.js +187 -0
- package/dist/{app-K_Aa1MMn.js → app-ZcaI1kPN.js} +732 -217
- package/dist/client/.vite/manifest.json +20 -20
- package/dist/client/_assets/chunks/{create-editor-CD3FhrOB.js → create-editor-B-m7X7S5.js} +50 -46
- package/dist/client/_assets/chunks/{sortable-list-CgaL2jCs.js → sortable-list-BJyd-LXE.js} +1 -1
- package/dist/client/_assets/chunks/{unsafe-svg-0QCkP0vZ.js → unsafe-svg-BkscJx69.js} +2 -2
- package/dist/client/_assets/{client-GiYENVw8.css → client-DAhoqPdr.css} +1 -1
- package/dist/client/_assets/{client-auth-k8gJ6QJj.js → client-auth-CAMfTrKW.js} +1 -1
- package/dist/client/_assets/{client-compose-B_kDtWkW.js → client-compose-D6rExKVK.js} +1 -1
- package/dist/client/_assets/{client-VnFxJN7G.js → client-knSEyUJO.js} +1 -1
- package/dist/client/_assets/{client-manage-M90aSTOk.js → client-manage-sVOkqVbH.js} +1 -1
- package/dist/client/_assets/{client-settings-DfYs9n1F.js → client-settings-CGR_nZ78.js} +7 -7
- package/dist/{github-sync-BPAvT999.js → github-sync-Gw4orAHk.js} +799 -110
- package/dist/index.js +2 -2
- package/dist/node.js +9 -5
- package/package.json +13 -3
- package/src/__tests__/dev-scripts.test.ts +203 -0
- package/src/__tests__/export-collection-order.test.ts +276 -0
- package/src/__tests__/export-feed-ids.test.ts +184 -0
- package/src/__tests__/export-feed-order.test.ts +294 -0
- package/src/__tests__/export-hugo-build.test.ts +130 -0
- package/src/__tests__/export-import-roundtrip.test.ts +94 -0
- package/src/__tests__/export-service.test.ts +611 -28
- package/src/__tests__/export-smart-collection.test.ts +329 -0
- package/src/__tests__/helpers/hugo-site.ts +146 -0
- package/src/__tests__/import-site-command.test.ts +487 -1
- package/src/__tests__/mise-config.test.ts +75 -4
- package/src/__tests__/node-dev-tasks.test.ts +264 -0
- package/src/__tests__/site-export-canonical-import.test.ts +149 -0
- package/src/__tests__/snapshot-canonical-replay.test.ts +179 -0
- package/src/__tests__/snapshot-settings.test.ts +97 -0
- package/src/__tests__/snapshot-tables.test.ts +219 -0
- package/src/__tests__/sql-export.test.ts +173 -0
- package/src/__tests__/zip-archive.test.ts +106 -0
- package/src/app.tsx +15 -6
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +1 -1
- package/src/client/components/__tests__/jant-settings-general.test.ts +21 -1
- package/src/client/components/jant-repo-picker-types.ts +6 -1
- package/src/client/components/jant-repo-picker.ts +4 -7
- package/src/client/components/jant-settings-general.ts +17 -12
- package/src/client/tiptap/__tests__/list-editing.test.ts +224 -86
- package/src/client/tiptap/__tests__/mark-exit.test.ts +1 -2
- package/src/client/tiptap/__tests__/markdown-clipboard.test.ts +26 -0
- package/src/client/tiptap/extensions.ts +0 -3
- package/src/client/tiptap/structural-keymap.ts +158 -47
- package/src/db/__tests__/d1-query.test.ts +56 -1
- package/src/i18n/locales/settings/en.po +8 -8
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +8 -8
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +8 -8
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/github-sync-repo-name.test.ts +40 -0
- package/src/lib/__tests__/image.test.ts +27 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +105 -0
- package/src/lib/__tests__/markdown.test.ts +10 -0
- package/src/lib/__tests__/resolve-config.test.ts +67 -0
- package/src/lib/__tests__/schemas.test.ts +27 -1
- package/src/lib/__tests__/timeline.test.ts +87 -0
- package/src/lib/__tests__/tiptap-to-markdown.test.ts +172 -4
- package/src/lib/discover.ts +3 -1
- package/src/lib/github-sync-repo-name.ts +45 -0
- package/src/lib/hugo-markdown.ts +46 -0
- package/src/lib/image.ts +17 -4
- package/src/lib/markdown-manager.ts +392 -2
- package/src/lib/post-body-html.ts +11 -4
- package/src/lib/resolve-config.ts +58 -1
- package/src/lib/schemas.ts +50 -5
- package/src/lib/thread-fold.ts +3 -3
- package/src/lib/timeline.ts +1 -1
- package/src/lib/tiptap-to-markdown.ts +13 -7
- package/src/lib/url.ts +20 -0
- package/src/lib/view.ts +2 -2
- package/src/node/__tests__/cli-setup.test.ts +163 -0
- package/src/node/__tests__/cli-site-snapshot.test.ts +67 -0
- package/src/node/__tests__/cli-snapshot-meta.test.ts +20 -0
- package/src/node/__tests__/runtime.test.ts +38 -0
- package/src/node/index.ts +2 -0
- package/src/node/request-handler.ts +3 -1
- package/src/routes/api/__tests__/posts.test.ts +24 -0
- package/src/routes/api/__tests__/upload.test.ts +34 -0
- package/src/routes/api/export.ts +3 -3
- package/src/routes/api/internal/sites.ts +0 -1
- package/src/routes/api/posts.ts +2 -0
- package/src/routes/api/public/posts.ts +21 -1
- package/src/routes/api/upload.ts +10 -3
- package/src/routes/compose.tsx +4 -0
- package/src/routes/dash/__tests__/github-sync-app.test.ts +365 -0
- package/src/routes/dash/settings.tsx +212 -70
- package/src/routes/pages/__tests__/post-page-round-trips.test.ts +211 -0
- package/src/routes/pages/__tests__/thread-order.test.ts +179 -0
- package/src/routes/pages/archive.tsx +17 -11
- package/src/routes/pages/featured.tsx +11 -5
- package/src/routes/pages/page.tsx +53 -31
- package/src/routes/pages/search.tsx +4 -2
- package/src/runtime/__tests__/readiness.test.ts +23 -0
- package/src/runtime/node.ts +49 -0
- package/src/runtime/readiness.ts +15 -0
- package/src/services/__tests__/bootstrap-setup-instance.test.ts +230 -0
- package/src/services/__tests__/custom-url.test.ts +34 -0
- package/src/services/__tests__/github-app-installations.test.ts +153 -0
- package/src/services/__tests__/github-sync-push.test.ts +46 -0
- package/src/services/__tests__/media.test.ts +31 -0
- package/src/services/__tests__/path.test.ts +56 -0
- package/src/services/__tests__/post-timeline.test.ts +127 -0
- package/src/services/__tests__/post.test.ts +74 -0
- package/src/services/bootstrap.ts +263 -44
- package/src/services/custom-url.ts +2 -2
- package/src/services/export-theme/layouts/_default/alias.html +27 -1
- package/src/services/export-theme/layouts/_default/list.html +2 -63
- package/src/services/export-theme/layouts/_default/rss.xml +27 -38
- package/src/services/export-theme/layouts/collections/list.html +3 -3
- package/src/services/export-theme/layouts/featured/list.html +1 -4
- package/src/services/export-theme/layouts/index.html +40 -26
- package/src/services/export-theme/layouts/partials/collection-members.html +100 -0
- package/src/services/export-theme/layouts/partials/collection-threads.html +33 -0
- package/src/services/export-theme/layouts/partials/featured-members.html +35 -0
- package/src/services/export-theme/layouts/partials/featured-thread.html +1 -1
- package/src/services/export-theme/layouts/partials/footer.html +1 -1
- package/src/services/export-theme/layouts/partials/head.html +1 -1
- package/src/services/export-theme/layouts/partials/header.html +5 -3
- package/src/services/export-theme/layouts/partials/jant-data.html +23 -0
- package/src/services/export-theme/layouts/partials/latest-members.html +48 -0
- package/src/services/export-theme/layouts/partials/smart-collection-members.html +129 -0
- package/src/services/export-theme/layouts/partials/thread-preview.html +1 -1
- package/src/services/export-theme/layouts/post/list.html +1 -1
- package/src/services/export-theme/layouts/smart_collection/list.html +24 -0
- package/src/services/export-theme/styles/main.css +0 -1
- package/src/services/export-theme/theme.toml +1 -1
- package/src/services/export.ts +620 -71
- package/src/services/github-app-installations.ts +171 -4
- package/src/services/github-sync.ts +69 -15
- package/src/services/mcp.ts +19 -1
- package/src/services/media.ts +8 -1
- package/src/services/path.ts +34 -1
- package/src/services/post.ts +214 -39
- package/src/services/site-admin.ts +3 -7
- package/src/services/site.ts +83 -31
- package/src/styles/ui.css +7 -1
- package/src/types/app-context.ts +16 -0
- package/src/types/bindings.ts +5 -0
- package/src/types/operations.ts +10 -0
- package/src/ui/dash/settings/GeneralContent.tsx +10 -8
- 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 {
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
package/src/services/mcp.ts
CHANGED
|
@@ -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:
|
|
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
|
},
|
package/src/services/media.ts
CHANGED
|
@@ -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.
|
|
788
|
+
.orderBy(desc(media.id))
|
|
782
789
|
.limit(limit);
|
|
783
790
|
return rows.map(toMedia);
|
|
784
791
|
},
|
package/src/services/path.ts
CHANGED
|
@@ -6,7 +6,17 @@
|
|
|
6
6
|
* slash (for example: "hello-world" or "collections/reading+tools").
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import {
|
|
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
|
|