@jant/core 0.3.46 → 0.3.48

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 (110) hide show
  1. package/bin/commands/db/execute-file.js +12 -4
  2. package/bin/commands/db/rehearse.js +2 -2
  3. package/bin/commands/export.js +12 -4
  4. package/bin/commands/import-site.js +60 -267
  5. package/bin/commands/migrate.js +36 -69
  6. package/bin/commands/reset-password.js +10 -4
  7. package/bin/commands/site/export.js +59 -248
  8. package/bin/commands/site/snapshot/export.js +58 -45
  9. package/bin/commands/site/snapshot/import.js +104 -52
  10. package/bin/lib/node-env.js +100 -0
  11. package/bin/lib/runtime-target.js +64 -0
  12. package/bin/lib/site-snapshot.js +185 -54
  13. package/bin/lib/sql-export.js +19 -2
  14. package/dist/app-DU7dpJID.js +6 -0
  15. package/dist/{app-DB-P66E5.js → app-DdnIoX7y.js} +333 -191
  16. package/dist/client/.vite/manifest.json +2 -2
  17. package/dist/client/_assets/client-BoUn7xBo.css +2 -0
  18. package/dist/client/_assets/{client-auth-BLCUje4M.js → client-auth-Ce5WEAVS.js} +102 -49
  19. package/dist/{github-sync-CQ1x271f.js → export-ZBlfKSKm.js} +12 -439
  20. package/dist/github-sync-C593r22F.js +4 -0
  21. package/dist/github-sync-bL1hnx3Q.js +428 -0
  22. package/dist/index.js +3 -2
  23. package/dist/node.js +5 -4
  24. package/package.json +3 -2
  25. package/src/__tests__/helpers/export-fixtures.ts +0 -1
  26. package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -0
  27. package/src/client/components/__tests__/jant-settings-general.test.ts +70 -0
  28. package/src/client/components/jant-settings-general.ts +164 -22
  29. package/src/client/components/settings-types.ts +4 -6
  30. package/src/client-auth.ts +1 -1
  31. package/src/db/__tests__/demo-canonical-snapshot.test.ts +1 -1
  32. package/src/db/__tests__/migration-rehearsal.test.ts +2 -5
  33. package/src/db/backfills/0004_register_apple_touch_media_rows.sql +65 -0
  34. package/src/db/migrations/0021_thankful_phalanx.sql +16 -0
  35. package/src/db/migrations/meta/0021_snapshot.json +2121 -0
  36. package/src/db/migrations/meta/_journal.json +7 -0
  37. package/src/db/migrations/pg/0019_gray_natasha_romanoff.sql +20 -0
  38. package/src/db/migrations/pg/meta/0019_snapshot.json +2718 -0
  39. package/src/db/migrations/pg/meta/_journal.json +7 -0
  40. package/src/db/pg/schema.ts +21 -26
  41. package/src/db/rehearsal-fixtures/demo-current.json +1 -1
  42. package/src/db/schema.ts +16 -20
  43. package/src/i18n/__tests__/middleware.test.ts +43 -1
  44. package/src/i18n/coverage.generated.ts +17 -0
  45. package/src/i18n/i18n.ts +18 -2
  46. package/src/i18n/index.ts +3 -0
  47. package/src/i18n/locales/settings/en.po +16 -11
  48. package/src/i18n/locales/settings/en.ts +1 -1
  49. package/src/i18n/locales/settings/zh-Hans.po +17 -12
  50. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  51. package/src/i18n/locales/settings/zh-Hant.po +16 -11
  52. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  53. package/src/i18n/locales.ts +84 -2
  54. package/src/i18n/middleware.ts +25 -16
  55. package/src/i18n/supported-locales.ts +153 -0
  56. package/src/lib/__tests__/csp-builder.test.ts +19 -2
  57. package/src/lib/__tests__/feed.test.ts +242 -1
  58. package/src/lib/__tests__/post-meta.test.ts +0 -1
  59. package/src/lib/__tests__/view.test.ts +0 -1
  60. package/src/lib/api-posts.ts +9 -7
  61. package/src/lib/csp-builder.ts +28 -10
  62. package/src/lib/feed.ts +153 -3
  63. package/src/middleware/__tests__/secure-headers.test.ts +89 -0
  64. package/src/middleware/auth.ts +1 -1
  65. package/src/middleware/secure-headers.ts +47 -1
  66. package/src/node/__tests__/cli-runtime-target.test.ts +110 -2
  67. package/src/node/__tests__/cli-site-snapshot.test.ts +308 -13
  68. package/src/node/__tests__/cli-site-token-env.test.ts +2 -7
  69. package/src/node/__tests__/cli-snapshot-meta.test.ts +85 -0
  70. package/src/node/__tests__/cli-sql-export.test.ts +49 -0
  71. package/src/node/index.ts +1 -0
  72. package/src/preset.css +8 -2
  73. package/src/routes/api/__tests__/settings.test.ts +3 -2
  74. package/src/routes/api/github-sync.tsx +1 -1
  75. package/src/routes/api/settings.ts +4 -1
  76. package/src/routes/auth/signin.tsx +6 -0
  77. package/src/routes/pages/archive.tsx +4 -2
  78. package/src/services/__tests__/post.test.ts +19 -19
  79. package/src/services/__tests__/search.test.ts +0 -1
  80. package/src/services/__tests__/settings.test.ts +22 -3
  81. package/src/services/bootstrap.ts +7 -3
  82. package/src/services/collection.ts +3 -3
  83. package/src/services/export.ts +0 -3
  84. package/src/services/navigation.ts +0 -2
  85. package/src/services/path.ts +1 -38
  86. package/src/services/post.ts +32 -66
  87. package/src/services/search.ts +0 -6
  88. package/src/services/settings.ts +47 -6
  89. package/src/services/site-admin.ts +6 -1
  90. package/src/styles/ui.css +14 -25
  91. package/src/types/entities.ts +0 -1
  92. package/src/ui/color-themes.ts +1 -1
  93. package/src/ui/dash/settings/GeneralContent.tsx +17 -19
  94. package/src/ui/dash/settings/SettingsRootContent.tsx +17 -28
  95. package/src/ui/feed/NoteCard.tsx +1 -11
  96. package/src/ui/feed/__tests__/timeline-cards.test.ts +1 -1
  97. package/src/ui/pages/PostPage.tsx +2 -0
  98. package/bin/commands/collections.js +0 -268
  99. package/bin/commands/media.js +0 -302
  100. package/bin/commands/posts.js +0 -262
  101. package/bin/commands/search.js +0 -53
  102. package/bin/commands/settings.js +0 -93
  103. package/bin/lib/http-api.js +0 -223
  104. package/bin/lib/media-upload.js +0 -206
  105. package/dist/app-CM7sb3xO.js +0 -5
  106. package/dist/client/_assets/client-DDs6NzB3.css +0 -2
  107. package/src/__tests__/bin/content-cli.test.ts +0 -179
  108. package/src/__tests__/bin/media-cli.test.ts +0 -192
  109. /package/dist/{github-api-BkRWnqMx.js → github-api-Bh0PH3zr.js} +0 -0
  110. /package/dist/{github-app-WeadXMb8.js → github-app-D0GvNnqp.js} +0 -0
@@ -0,0 +1,428 @@
1
+ import { c as parseMarkdownDocument, r as parseFrontMatter, t as createExportService } from "./export-ZBlfKSKm.js";
2
+ import { r as getInstallationToken } from "./github-app-D0GvNnqp.js";
3
+ import { r as parseRepoSlug, t as createGitHubClient } from "./github-api-Bh0PH3zr.js";
4
+ //#region src/lib/markdown-to-tiptap.ts
5
+ /**
6
+ * Markdown → TipTap JSON Conversion
7
+ *
8
+ * Converts Markdown strings to TipTap JSON documents using the official
9
+ * Tiptap MarkdownManager and the same extension schema used elsewhere in Jant.
10
+ */
11
+ /**
12
+ * Converts a Markdown string to a TipTap JSON document string.
13
+ *
14
+ * @param markdown - Markdown source text
15
+ * @returns Stringified TipTap JSON document
16
+ *
17
+ * @example
18
+ * ```ts
19
+ * const json = markdownToTiptapJson("Hello **world**");
20
+ * // '{"type":"doc","content":[{"type":"paragraph","content":[...]}]}'
21
+ * ```
22
+ */ function markdownToTiptapJson(markdown) {
23
+ return JSON.stringify(parseMarkdownDocument(markdown));
24
+ }
25
+ //#endregion
26
+ //#region src/services/github-sync.ts
27
+ /**
28
+ * GitHub Sync Service
29
+ *
30
+ * Handles bidirectional content synchronization between Jant and a GitHub repo.
31
+ * Posts are serialized as Hugo-format Markdown bundles (reusing the export
32
+ * format): each post is a branch bundle at `content/{slug}/_index.md` with
33
+ * reply leaves at `content/{root-slug}/{reply-slug}/index.md`.
34
+ *
35
+ * Push (Jant → GitHub):
36
+ * - Always full sync: regenerate Jant-managed files in a single atomic commit
37
+ * - Uses base_tree so untracked files in the repo (READMEs, CI, etc.) are preserved
38
+ * - Debounced: multiple rapid changes collapse into one sync
39
+ *
40
+ * Pull (GitHub → Jant):
41
+ * - Webhook-triggered: match modified files to existing posts by slug and update
42
+ * - Unknown files are skipped; new posts cannot be created from GitHub
43
+ * - File deletions are intentionally ignored to avoid catastrophic data loss
44
+ * (e.g. user deletes the repo → site wiped). Deletes must go through Jant's UI.
45
+ *
46
+ * Anti-loop: all commits from Jant include `[jant-sync]` in the message.
47
+ * Incoming webhooks with this marker are skipped.
48
+ */
49
+ /** Marker included in commit messages to prevent webhook loops. */ var SYNC_COMMIT_MARKER = "[jant-sync]";
50
+ /**
51
+ * Path of the ownership marker file written at the repo root.
52
+ *
53
+ * Presence of this file (with a matching `site_id`) identifies the repo
54
+ * as actively managed by a Jant site. Used to distinguish three states
55
+ * during connect: empty repo, Jant-owned repo, foreign repo with existing
56
+ * content. Also serves as the initialization file for empty repos so we
57
+ * don't need a separate throwaway placeholder.
58
+ */ var JANT_SYNC_MARKER_PATH = ".jant-sync";
59
+ /**
60
+ * Hard list of paths Jant fully owns and always overwrites on push.
61
+ * Anything outside this set is user territory and preserved via base_tree.
62
+ * Files inside this set that Jant no longer generates are deleted on the
63
+ * next push (see `computeManagedDeletions`).
64
+ *
65
+ * - `content/**` — posts, collections, sections (rendered by Hugo)
66
+ * - `data/jant.toml` — nav, branding, collections directory (the rest of
67
+ * `data/` is user territory so Hugo's `data/menu.toml` convention, etc.,
68
+ * can be used freely)
69
+ * - `themes/jant/**` — the packaged Jant theme (layouts + static assets)
70
+ * - `hugo.toml` — site config, including `theme = "jant"`
71
+ * - `.gitignore`, `README.md` — scaffolded once, then kept in sync
72
+ * - `.jant-sync` — ownership marker; written by this service, not by export
73
+ *
74
+ * The list is also stored in the marker itself (`managed_globs`) so future
75
+ * schema bumps can diff the old and new sets to decide what needs cleanup.
76
+ */ var JANT_MANAGED_GLOBS = [
77
+ "content/**",
78
+ "data/jant.toml",
79
+ "themes/jant/**",
80
+ "hugo.toml",
81
+ ".gitignore",
82
+ "README.md",
83
+ ".jant-sync"
84
+ ];
85
+ /**
86
+ * Match a repo-relative path against a single glob from
87
+ * `JANT_MANAGED_GLOBS`. Only two forms are supported because those are
88
+ * the only two shapes the constant uses:
89
+ *
90
+ * - Exact path (`"hugo.toml"`, `"data/jant.toml"`)
91
+ * - Directory prefix + `/**` (`"content/**"`, `"themes/jant/**"`)
92
+ *
93
+ * Exported for testing.
94
+ */ function pathMatchesManagedGlob(path, glob) {
95
+ if (glob.endsWith("/**")) {
96
+ const prefix = glob.slice(0, -3);
97
+ return path === prefix || path.startsWith(prefix + "/");
98
+ }
99
+ return path === glob;
100
+ }
101
+ /**
102
+ * True when `path` falls inside one of Jant's managed globs.
103
+ *
104
+ * Exported for testing.
105
+ */ function isManagedPath(path) {
106
+ return JANT_MANAGED_GLOBS.some((g) => pathMatchesManagedGlob(path, g));
107
+ }
108
+ /**
109
+ * Compute the tree items that should null-out files on the remote HEAD
110
+ * which Jant claims ownership of (matches `JANT_MANAGED_GLOBS`) but is
111
+ * not writing in the current push (not in `writtenPaths`).
112
+ *
113
+ * Returns a list of `{ sha: null }` tree entries suitable for appending
114
+ * to the payload of `createTree`. Exported for testing.
115
+ */ function computeManagedDeletions(headTreeItems, writtenPaths) {
116
+ const items = [];
117
+ for (const item of headTreeItems) {
118
+ if (item.type !== "blob") continue;
119
+ if (!isManagedPath(item.path)) continue;
120
+ if (writtenPaths.has(item.path)) continue;
121
+ items.push({
122
+ path: item.path,
123
+ mode: "100644",
124
+ type: "blob",
125
+ sha: null
126
+ });
127
+ }
128
+ return items;
129
+ }
130
+ function parseMarker(text) {
131
+ try {
132
+ const parsed = JSON.parse(text);
133
+ if (typeof parsed.site_id === "string" && typeof parsed.site_host === "string" && typeof parsed.created_at === "number" && typeof parsed.schema_version === "number") return parsed;
134
+ } catch {}
135
+ return null;
136
+ }
137
+ function decodeMarkerContent(file) {
138
+ if (file.encoding === "base64") try {
139
+ const cleaned = file.content.replace(/\s+/g, "");
140
+ const binary = atob(cleaned);
141
+ const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
142
+ return new TextDecoder("utf-8").decode(bytes);
143
+ } catch {
144
+ return "";
145
+ }
146
+ return file.content;
147
+ }
148
+ function formatMarker(marker) {
149
+ return `${JSON.stringify(marker, null, 2)}\n`;
150
+ }
151
+ function safeHost(siteUrl) {
152
+ try {
153
+ return new URL(siteUrl).host;
154
+ } catch {
155
+ return "";
156
+ }
157
+ }
158
+ /**
159
+ * Classify a repository to decide how to proceed with a sync connection.
160
+ *
161
+ * - `empty`: repo has no commits on the default branch (or repo is brand new).
162
+ * - `owned`: `.jant-sync` marker present with matching `site_id`.
163
+ * - `owned-by-other-site`: `.jant-sync` present but `site_id` differs —
164
+ * another Jant site already backs up here, blocking the connect.
165
+ * - `foreign`: non-empty repo without a marker — requires explicit
166
+ * user confirmation before connect.
167
+ */ async function classifyRepoForSync(client, owner, repo, siteId) {
168
+ const defaultBranch = (await client.getRepo(owner, repo)).default_branch;
169
+ try {
170
+ await client.getRef(owner, repo, `heads/${defaultBranch}`);
171
+ } catch {
172
+ return { kind: "empty" };
173
+ }
174
+ const markerFile = await client.getFileContent(owner, repo, JANT_SYNC_MARKER_PATH);
175
+ if (!markerFile) return {
176
+ kind: "foreign",
177
+ defaultBranch
178
+ };
179
+ const marker = parseMarker(decodeMarkerContent(markerFile));
180
+ if (!marker) return {
181
+ kind: "foreign",
182
+ defaultBranch
183
+ };
184
+ if (marker.site_id === siteId) return {
185
+ kind: "owned",
186
+ marker
187
+ };
188
+ return {
189
+ kind: "owned-by-other-site",
190
+ marker
191
+ };
192
+ }
193
+ function createGitHubSyncService(services, siteId, siteConfig, deps = {}) {
194
+ /**
195
+ * Build the ownership marker for this push. Preserves `created_at`
196
+ * from an existing marker (when readable) so the timestamp reflects
197
+ * when this repo was first bound to this site, not the latest push.
198
+ */ function buildMarker(existingContent, now) {
199
+ const existing = existingContent ? parseMarker(existingContent) : null;
200
+ const preservedCreatedAt = existing && existing.site_id === siteId ? existing.created_at : now;
201
+ return {
202
+ schema_version: 3,
203
+ site_id: siteId,
204
+ site_host: safeHost(siteConfig.siteUrl),
205
+ created_at: preservedCreatedAt,
206
+ managed_globs: [...JANT_MANAGED_GLOBS]
207
+ };
208
+ }
209
+ async function loadConfig() {
210
+ const [repo, enabled, authModeRaw] = await Promise.all([
211
+ services.settings.get("GITHUB_SYNC_REPO"),
212
+ services.settings.get("GITHUB_SYNC_ENABLED"),
213
+ services.settings.get("GITHUB_SYNC_AUTH_MODE")
214
+ ]);
215
+ if (!repo || enabled !== "true") return null;
216
+ const authMode = authModeRaw === "app" ? "app" : "pat";
217
+ const [token, installationId, webhookId, webhookSecret, lastPushSha] = await Promise.all([
218
+ services.settings.get("GITHUB_SYNC_TOKEN"),
219
+ services.settings.get("GITHUB_SYNC_APP_INSTALLATION_ID"),
220
+ services.settings.get("GITHUB_SYNC_WEBHOOK_ID"),
221
+ services.settings.get("GITHUB_SYNC_WEBHOOK_SECRET"),
222
+ services.settings.get("GITHUB_SYNC_LAST_PUSH_SHA")
223
+ ]);
224
+ if (authMode === "pat" && !token) return null;
225
+ if (authMode === "app" && !installationId) return null;
226
+ return {
227
+ authMode,
228
+ token: token ?? void 0,
229
+ installationId: installationId ?? void 0,
230
+ repo,
231
+ enabled: true,
232
+ webhookId: webhookId ?? void 0,
233
+ webhookSecret: webhookSecret ?? void 0,
234
+ lastPushSha: lastPushSha ?? void 0
235
+ };
236
+ }
237
+ function createClient(config) {
238
+ const parsed = parseRepoSlug(config.repo);
239
+ if (!parsed) throw new Error(`Invalid repo slug: ${config.repo}`);
240
+ let client;
241
+ if (config.authMode === "app") {
242
+ if (!deps.githubApp) throw new Error("GitHub App is not configured on this deployment. Set GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, and GITHUB_APP_SLUG to use App auth.");
243
+ if (!config.installationId) throw new Error("GitHub App installation id is missing.");
244
+ const app = deps.githubApp;
245
+ const installationId = config.installationId;
246
+ client = createGitHubClient(() => getInstallationToken(app, installationId));
247
+ } else {
248
+ if (!config.token) throw new Error("GitHub sync PAT is missing.");
249
+ client = createGitHubClient(config.token);
250
+ }
251
+ return {
252
+ client,
253
+ owner: parsed.owner,
254
+ repo: parsed.repo
255
+ };
256
+ }
257
+ /**
258
+ * Get the HEAD SHA, initializing an empty repo if needed.
259
+ * GitHub's Git Trees API requires at least one commit to exist.
260
+ */ async function getOrInitHead(client, owner, repo, defaultBranch, seedMarker) {
261
+ try {
262
+ return { sha: (await client.getRef(owner, repo, `heads/${defaultBranch}`)).sha };
263
+ } catch {
264
+ await client.createOrUpdateFile(owner, repo, JANT_SYNC_MARKER_PATH, {
265
+ content: formatMarker(seedMarker),
266
+ message: `Initialize Jant sync ${SYNC_COMMIT_MARKER}`
267
+ });
268
+ return { sha: (await client.getRef(owner, repo, `heads/${defaultBranch}`)).sha };
269
+ }
270
+ }
271
+ return {
272
+ getConfig: loadConfig,
273
+ async pushFullSync() {
274
+ const config = await loadConfig();
275
+ if (!config) throw new Error("GitHub Sync is not configured");
276
+ const { client, owner, repo } = createClient(config);
277
+ const exportFiles = await createExportService(services, siteConfig, deps).generateHugoFiles();
278
+ const defaultBranch = (await client.getRepo(owner, repo)).default_branch;
279
+ const now = Math.floor(Date.now() / 1e3);
280
+ const existingMarkerBeforeInit = await client.getFileContent(owner, repo, JANT_SYNC_MARKER_PATH).catch(() => null);
281
+ const marker = buildMarker(existingMarkerBeforeInit ? decodeMarkerContent(existingMarkerBeforeInit) : null, now);
282
+ const { sha: headSha } = await getOrInitHead(client, owner, repo, defaultBranch, marker);
283
+ const treeItems = [{
284
+ path: JANT_SYNC_MARKER_PATH,
285
+ mode: "100644",
286
+ type: "blob",
287
+ content: formatMarker(marker)
288
+ }];
289
+ for (const file of exportFiles) if (typeof file.content === "string") treeItems.push({
290
+ path: file.path,
291
+ mode: "100644",
292
+ type: "blob",
293
+ content: file.content
294
+ });
295
+ else {
296
+ const blob = await client.createBlob(owner, repo, uint8ArrayToBase64(file.content), "base64");
297
+ treeItems.push({
298
+ path: file.path,
299
+ mode: "100644",
300
+ type: "blob",
301
+ sha: blob.sha
302
+ });
303
+ }
304
+ const headCommit = await client.getCommit(owner, repo, headSha);
305
+ const headTree = await client.getTree(owner, repo, headCommit.treeSha, { recursive: true });
306
+ if (headTree.truncated) throw new Error("GitHub tree exceeds API limits (>100k entries or >7MB); incremental deletion cannot run safely against this repo.");
307
+ const writtenPaths = new Set(treeItems.map((item) => item.path));
308
+ treeItems.push(...computeManagedDeletions(headTree.tree, writtenPaths));
309
+ const tree = await client.createTree(owner, repo, treeItems, headCommit.treeSha);
310
+ const commit = await client.createCommit(owner, repo, {
311
+ message: `Sync site ${SYNC_COMMIT_MARKER}`,
312
+ tree: tree.sha,
313
+ parents: [headSha]
314
+ });
315
+ await client.updateRef(owner, repo, `heads/${defaultBranch}`, commit.sha);
316
+ await services.settings.set("GITHUB_SYNC_LAST_PUSH_SHA", commit.sha);
317
+ await services.settings.set("GITHUB_SYNC_LAST_PUSH_AT", String(Math.floor(Date.now() / 1e3)));
318
+ return { commitSha: commit.sha };
319
+ },
320
+ async handleWebhookPush(payload) {
321
+ if (payload.commits.some((c) => c.message.includes("[jant-sync]")) && payload.commits.length === 1) return;
322
+ const modified = /* @__PURE__ */ new Set();
323
+ for (const commit of payload.commits) {
324
+ if (commit.message.includes("[jant-sync]")) continue;
325
+ for (const file of [...commit.modified, ...commit.added]) if (classifyBundlePath(file)) modified.add(file);
326
+ }
327
+ if (modified.size === 0) return;
328
+ const config = await loadConfig();
329
+ if (!config) return;
330
+ const { client, owner, repo } = createClient(config);
331
+ for (const filePath of modified) {
332
+ const classification = classifyBundlePath(filePath);
333
+ if (!classification) continue;
334
+ const fileContent = await client.getFileContent(owner, repo, filePath, payload.after);
335
+ if (!fileContent) continue;
336
+ const { frontMatter, body } = await parseFrontMatter(decodeBase64Content(fileContent.content));
337
+ const slug = typeof frontMatter.slug === "string" && frontMatter.slug.trim() ? frontMatter.slug.trim() : classification.slug;
338
+ if (!slug) continue;
339
+ const pathRecord = await services.paths.getByPath(slug);
340
+ if (!pathRecord?.postId) continue;
341
+ const existingPost = await services.posts.getById(pathRecord.postId);
342
+ if (!existingPost) continue;
343
+ if (classification.kind === "reply") {
344
+ const rootPath = await services.paths.getByPath(classification.rootSlug);
345
+ if (!rootPath?.postId) continue;
346
+ if (existingPost.threadId !== rootPath.postId) continue;
347
+ }
348
+ const trimmedBody = body.trim();
349
+ const tiptapBody = trimmedBody ? markdownToTiptapJson(trimmedBody) : null;
350
+ const updateData = {};
351
+ if (tiptapBody !== null) updateData.body = tiptapBody;
352
+ if (frontMatter.title !== void 0) updateData.title = frontMatter.title;
353
+ if (frontMatter.link_url !== void 0) updateData.url = frontMatter.link_url;
354
+ if (frontMatter.source_name !== void 0) updateData.sourceName = frontMatter.source_name;
355
+ if (frontMatter.source_url !== void 0) updateData.sourceUrl = frontMatter.source_url;
356
+ if (frontMatter.quote_text !== void 0) updateData.quoteText = frontMatter.quote_text;
357
+ if (frontMatter.rating !== void 0) updateData.rating = frontMatter.rating;
358
+ if (Object.keys(updateData).length > 0) await services.posts.update(existingPost.id, updateData);
359
+ }
360
+ },
361
+ async setupWebhook(callbackUrl) {
362
+ const config = await loadConfig();
363
+ if (!config) throw new Error("GitHub Sync is not configured");
364
+ const { client, owner, repo } = createClient(config);
365
+ const secretBytes = new Uint8Array(32);
366
+ crypto.getRandomValues(secretBytes);
367
+ const secret = Array.from(secretBytes).map((b) => b.toString(16).padStart(2, "0")).join("");
368
+ const webhook = await client.createWebhook(owner, repo, {
369
+ url: callbackUrl,
370
+ secret,
371
+ events: ["push"]
372
+ });
373
+ await services.settings.set("GITHUB_SYNC_WEBHOOK_SECRET", secret);
374
+ await services.settings.set("GITHUB_SYNC_WEBHOOK_ID", String(webhook.id));
375
+ return { webhookId: webhook.id };
376
+ },
377
+ async teardownWebhook() {
378
+ const config = await loadConfig();
379
+ if (!config) return;
380
+ if (config.webhookId) try {
381
+ const { client, owner, repo } = createClient(config);
382
+ await client.deleteWebhook(owner, repo, parseInt(config.webhookId));
383
+ } catch {}
384
+ await services.settings.set("GITHUB_SYNC_ENABLED", "false");
385
+ await services.settings.set("GITHUB_SYNC_TOKEN", "");
386
+ await services.settings.set("GITHUB_SYNC_REPO", "");
387
+ await services.settings.set("GITHUB_SYNC_WEBHOOK_SECRET", "");
388
+ await services.settings.set("GITHUB_SYNC_WEBHOOK_ID", "");
389
+ await services.settings.set("GITHUB_SYNC_LAST_PUSH_SHA", "");
390
+ await services.settings.set("GITHUB_SYNC_AUTH_MODE", "pat");
391
+ await services.settings.set("GITHUB_SYNC_APP_INSTALLATION_ID", "");
392
+ }
393
+ };
394
+ }
395
+ function decodeBase64Content(content) {
396
+ const cleaned = content.replace(/\n/g, "");
397
+ return decodeURIComponent(escape(atob(cleaned)));
398
+ }
399
+ function classifyBundlePath(path) {
400
+ if (!path.startsWith("content/")) return null;
401
+ const segments = path.slice(8).split("/");
402
+ if (segments.length === 2 && segments[1] === "_index.md") {
403
+ const slug = segments[0];
404
+ if (!slug) return null;
405
+ return {
406
+ kind: "root",
407
+ slug
408
+ };
409
+ }
410
+ if (segments.length === 3 && segments[2] === "index.md") {
411
+ const rootSlug = segments[0];
412
+ const slug = segments[1];
413
+ if (!rootSlug || !slug) return null;
414
+ return {
415
+ kind: "reply",
416
+ rootSlug,
417
+ slug
418
+ };
419
+ }
420
+ return null;
421
+ }
422
+ function uint8ArrayToBase64(bytes) {
423
+ let binary = "";
424
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
425
+ return btoa(binary);
426
+ }
427
+ //#endregion
428
+ export { computeManagedDeletions as a, pathMatchesManagedGlob as c, classifyRepoForSync as i, markdownToTiptapJson as l, JANT_SYNC_MARKER_PATH as n, createGitHubSyncService as o, SYNC_COMMIT_MARKER as r, isManagedPath as s, JANT_MANAGED_GLOBS as t };
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { _ as url_exports } from "./url-umUptr5z.js";
2
- import { A as SORT_ORDERS, C as toPostViews, D as MAX_PINNED_POSTS, E as MAX_MEDIA_ATTACHMENTS, M as TEXT_ATTACHMENT_CONTENT_FORMATS, O as MEDIA_KINDS, S as toPostView, T as FORMATS, _ as toArchiveGroups, b as toNavItemView, d as defaultFeedRenderer, g as createMediaContext, j as STATUSES, k as NAV_ITEM_TYPES, t as createApp, v as toArchiveGroupsWithMedia, w as toSearchResultView, x as toNavItemViews, y as toMediaView } from "./app-DB-P66E5.js";
3
- import { E as time_exports, s as markdown_exports } from "./github-sync-CQ1x271f.js";
2
+ import { A as NAV_ITEM_TYPES, C as toPostView, D as MAX_MEDIA_ATTACHMENTS, E as FORMATS, M as STATUSES, N as TEXT_ATTACHMENT_CONTENT_FORMATS, O as MAX_PINNED_POSTS, S as toNavItemViews, T as toSearchResultView, _ as createMediaContext, b as toMediaView, f as defaultFeedRenderer, j as SORT_ORDERS, k as MEDIA_KINDS, t as createApp, v as toArchiveGroups, w as toPostViews, x as toNavItemView, y as toArchiveGroupsWithMedia } from "./app-DdnIoX7y.js";
3
+ import { T as time_exports, a as markdown_exports } from "./export-ZBlfKSKm.js";
4
4
  import "./env-CgaH9Mut.js";
5
+ import "./github-sync-bL1hnx3Q.js";
5
6
  export { FORMATS, MAX_MEDIA_ATTACHMENTS, MAX_PINNED_POSTS, MEDIA_KINDS, NAV_ITEM_TYPES, SORT_ORDERS, STATUSES, TEXT_ATTACHMENT_CONTENT_FORMATS, createApp, createMediaContext, defaultFeedRenderer, markdown_exports as markdown, time_exports as time, toArchiveGroups, toArchiveGroupsWithMedia, toMediaView, toNavItemView, toNavItemViews, toPostView, toPostViews, toSearchResultView, url_exports as url };
package/dist/node.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import "./url-umUptr5z.js";
2
- import { F as getPublicAssetBasePath, I as isAssetPath, N as buildThemeStyle, P as BUILTIN_COLOR_THEMES, a as resolveDatabaseDialect, c as BUILTIN_FONT_THEMES, f as pgSchemaBundle, h as schema_exports, i as createSiteService, l as getCjkSerifCssVariables, m as createNodeDatabase, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as sqliteSchemaBundle, r as createNodeRequestRuntime, s as resolveConfig, t as createApp, u as getFontThemeCssVariables } from "./app-DB-P66E5.js";
3
- import { i as createExportService } from "./github-sync-CQ1x271f.js";
2
+ import { F as BUILTIN_COLOR_THEMES, I as getPublicAssetBasePath, L as isAssetPath, P as buildThemeStyle, a as resolveDatabaseDialect, c as resolveConfig, d as getFontThemeCssVariables, g as schema_exports, h as createNodeDatabase, i as createSiteService, l as BUILTIN_FONT_THEMES, m as sqliteSchemaBundle, n as createNodeCliRuntime, o as getHostBasedStartupConfigurationIssues, p as pgSchemaBundle, r as createNodeRequestRuntime, s as createStorageDriver, t as createApp, u as getCjkSerifCssVariables } from "./app-DdnIoX7y.js";
3
+ import { t as createExportService } from "./export-ZBlfKSKm.js";
4
4
  import { b as getSiteResolutionMode, i as getConfiguredSingleSitePathPrefix, l as getEnvString, r as getConfiguredSingleSiteOrigin, x as shouldTrustProxy, y as getPort } from "./env-CgaH9Mut.js";
5
+ import "./github-sync-bL1hnx3Q.js";
5
6
  import { drizzle } from "drizzle-orm/better-sqlite3";
6
7
  import { serve } from "@hono/node-server";
7
8
  import Database from "better-sqlite3";
@@ -473,7 +474,7 @@ async function createNodeRequestHandler(options) {
473
474
  async function start(env = process.env, app) {
474
475
  const handler = await createNodeRequestHandler({
475
476
  env,
476
- app: async () => app ?? (await import("./app-CM7sb3xO.js")).createApp()
477
+ app: async () => app ?? (await import("./app-DU7dpJID.js")).createApp()
477
478
  });
478
479
  const hostname = resolveHost(env);
479
480
  const port = resolvePort(env);
@@ -517,4 +518,4 @@ async function start(env = process.env, app) {
517
518
  });
518
519
  }
519
520
  //#endregion
520
- export { BUILTIN_COLOR_THEMES, BUILTIN_FONT_THEMES, buildThemeStyle, createApp, createExportService, createNodeBindings, createNodeCliRuntime, createNodeRequestHandler, createNodeRequestRuntime, getCjkSerifCssVariables, getFontThemeCssVariables, migrate, resolveConfig, start };
521
+ export { BUILTIN_COLOR_THEMES, BUILTIN_FONT_THEMES, buildThemeStyle, createApp, createExportService, createNodeBindings, createNodeCliRuntime, createNodeRequestHandler, createNodeRequestRuntime, createStorageDriver, getCjkSerifCssVariables, getFontThemeCssVariables, migrate, resolveConfig, start };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jant/core",
3
- "version": "0.3.46",
3
+ "version": "0.3.48",
4
4
  "description": "A modern, open-source microblogging platform built on Cloudflare Workers",
5
5
  "type": "module",
6
6
  "exports": {
@@ -143,7 +143,8 @@
143
143
  "fonts:generate:tc": "node dev/scripts/generate-noto-serif-cjk.mjs tc",
144
144
  "i18n:extract": "lingui extract --clean",
145
145
  "i18n:compile": "lingui compile --typescript",
146
- "i18n:build": "pnpm i18n:extract && pnpm i18n:compile",
146
+ "i18n:coverage": "node dev/scripts/compute-i18n-coverage.mjs",
147
+ "i18n:build": "pnpm i18n:extract && pnpm i18n:compile && pnpm i18n:coverage",
147
148
  "dev": "pnpm db:migrate:local && vite dev",
148
149
  "dev:node": "vite dev --config vite.config.node.ts --mode node --host 0.0.0.0",
149
150
  "dev:debug": "node dev/scripts/start-dev-debug.mjs",
@@ -31,7 +31,6 @@ export function makePost(over: Partial<Post> = {}): Post {
31
31
  previewProvider: null,
32
32
  replyToId: null,
33
33
  threadId: "post-root",
34
- deletedAt: null,
35
34
  publishedAt: 1773014400,
36
35
  lastActivityAt: 1773014400,
37
36
  createdAt: 1773014400,
@@ -40,6 +40,8 @@ const labels: SettingsLabels = {
40
40
  aboutBlogHelp: "Displayed above your blog posts.",
41
41
  siteLanguage: "Site Language",
42
42
  siteLanguageHelp: "Language used for the site UI.",
43
+ siteLanguageSearchPlaceholder: "Search…",
44
+ siteLanguageNoMatches: "No matches.",
43
45
  cjkFont: "CJK Font",
44
46
  cjkFontHelp:
45
47
  "Load a serif font optimized for Chinese, Japanese, or Korean content.",
@@ -83,6 +83,8 @@ const labels: SettingsLabels = {
83
83
  aboutBlogHelp: "Displayed above your blog posts.",
84
84
  siteLanguage: "Site Language",
85
85
  siteLanguageHelp: "Language used for the site UI.",
86
+ siteLanguageSearchPlaceholder: "Search…",
87
+ siteLanguageNoMatches: "No matches.",
86
88
  cjkFont: "CJK Font",
87
89
  cjkFontHelp:
88
90
  "Load a serif font optimized for Chinese, Japanese, or Korean content.",
@@ -220,6 +222,74 @@ describe("JantSettingsGeneral", () => {
220
222
  expect(options?.[0]?.value).toBe("UTC");
221
223
  });
222
224
 
225
+ it("opens the locale combobox and filters options as the user searches", async () => {
226
+ const el = await createElement();
227
+
228
+ const trigger = requireElement(
229
+ el.querySelector<HTMLButtonElement>(
230
+ 'button[aria-haspopup="listbox"][aria-labelledby="site-language-label"]',
231
+ ),
232
+ "expected locale picker trigger",
233
+ );
234
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
235
+
236
+ trigger.click();
237
+ await el.updateComplete;
238
+
239
+ expect(trigger.getAttribute("aria-expanded")).toBe("true");
240
+
241
+ const options = el.querySelectorAll<HTMLButtonElement>('[role="option"]');
242
+ expect(options.length).toBeGreaterThanOrEqual(20);
243
+ // Each option carries the universal "translated" coverage suffix.
244
+ for (const option of options) {
245
+ expect(option.textContent).toMatch(/% translated/);
246
+ }
247
+
248
+ const search = requireElement(
249
+ el.querySelector<HTMLInputElement>("[data-locale-search]"),
250
+ "expected search input",
251
+ );
252
+ search.value = "fin";
253
+ search.dispatchEvent(new Event("input", { bubbles: true }));
254
+ await el.updateComplete;
255
+
256
+ const filtered = el.querySelectorAll<HTMLButtonElement>('[role="option"]');
257
+ expect(filtered.length).toBe(1);
258
+ expect(filtered[0]?.textContent).toMatch(/Suomi|Finnish/);
259
+ });
260
+
261
+ it("selects a non-catalog locale and reports 0% translated coverage on it", async () => {
262
+ const el = await createElement();
263
+ const trigger = requireElement(
264
+ el.querySelector<HTMLButtonElement>(
265
+ 'button[aria-labelledby="site-language-label"]',
266
+ ),
267
+ "expected trigger",
268
+ );
269
+ trigger.click();
270
+ await el.updateComplete;
271
+
272
+ const search = requireElement(
273
+ el.querySelector<HTMLInputElement>("[data-locale-search]"),
274
+ "expected search input",
275
+ );
276
+ search.value = "fi";
277
+ search.dispatchEvent(new Event("input", { bubbles: true }));
278
+ await el.updateComplete;
279
+
280
+ const finnishOption = Array.from(
281
+ el.querySelectorAll<HTMLButtonElement>('[role="option"]'),
282
+ ).find((opt) => /Suomi|Finnish/.test(opt.textContent ?? ""));
283
+ finnishOption?.click();
284
+ await el.updateComplete;
285
+
286
+ // Picker closes after selection.
287
+ expect(trigger.getAttribute("aria-expanded")).toBe("false");
288
+ // Trigger reflects the new tag and shows 0% coverage.
289
+ expect(trigger.textContent).toMatch(/fi/);
290
+ expect(trigger.textContent).toMatch(/0% translated/);
291
+ });
292
+
223
293
  it("renders CJK font options", async () => {
224
294
  const el = await createElement();
225
295
  const cjkSelect = requireElement(