@jant/core 0.3.46 → 0.3.47
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/db/execute-file.js +12 -4
- package/bin/commands/db/rehearse.js +2 -2
- package/bin/commands/export.js +12 -4
- package/bin/commands/import-site.js +60 -267
- package/bin/commands/migrate.js +36 -69
- package/bin/commands/reset-password.js +10 -4
- package/bin/commands/site/export.js +59 -248
- package/bin/commands/site/snapshot/export.js +58 -45
- package/bin/commands/site/snapshot/import.js +104 -52
- package/bin/lib/node-env.js +100 -0
- package/bin/lib/runtime-target.js +64 -0
- package/bin/lib/site-snapshot.js +185 -54
- package/bin/lib/sql-export.js +19 -2
- package/dist/{app-DB-P66E5.js → app-3REcR-3U.js} +331 -189
- package/dist/app-B67XOEyo.js +6 -0
- package/dist/client/.vite/manifest.json +2 -2
- package/dist/client/_assets/{client-auth-BLCUje4M.js → client-auth-Ce5WEAVS.js} +102 -49
- package/dist/client/_assets/client-s71Js1Cu.css +2 -0
- package/dist/{github-sync-CQ1x271f.js → export-ZBlfKSKm.js} +12 -439
- package/dist/github-sync-C593r22F.js +4 -0
- package/dist/github-sync-bL1hnx3Q.js +428 -0
- package/dist/index.js +3 -2
- package/dist/node.js +5 -4
- package/package.json +3 -2
- package/src/__tests__/helpers/export-fixtures.ts +0 -1
- package/src/client/components/__tests__/jant-settings-avatar.test.ts +2 -0
- package/src/client/components/__tests__/jant-settings-general.test.ts +70 -0
- package/src/client/components/jant-settings-general.ts +164 -22
- package/src/client/components/settings-types.ts +4 -6
- package/src/client-auth.ts +1 -1
- package/src/db/__tests__/demo-canonical-snapshot.test.ts +1 -1
- package/src/db/__tests__/migration-rehearsal.test.ts +2 -5
- package/src/db/backfills/0004_register_apple_touch_media_rows.sql +65 -0
- package/src/db/migrations/0021_thankful_phalanx.sql +16 -0
- package/src/db/migrations/meta/0021_snapshot.json +2121 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations/pg/0019_gray_natasha_romanoff.sql +20 -0
- package/src/db/migrations/pg/meta/0019_snapshot.json +2718 -0
- package/src/db/migrations/pg/meta/_journal.json +7 -0
- package/src/db/pg/schema.ts +21 -26
- package/src/db/rehearsal-fixtures/demo-current.json +1 -1
- package/src/db/schema.ts +16 -20
- package/src/i18n/__tests__/middleware.test.ts +43 -1
- package/src/i18n/coverage.generated.ts +17 -0
- package/src/i18n/i18n.ts +18 -2
- package/src/i18n/index.ts +3 -0
- package/src/i18n/locales/settings/en.po +16 -11
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +17 -12
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +16 -11
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/locales.ts +84 -2
- package/src/i18n/middleware.ts +25 -16
- package/src/i18n/supported-locales.ts +153 -0
- package/src/lib/__tests__/csp-builder.test.ts +19 -2
- package/src/lib/__tests__/feed.test.ts +242 -1
- package/src/lib/__tests__/post-meta.test.ts +0 -1
- package/src/lib/__tests__/view.test.ts +0 -1
- package/src/lib/csp-builder.ts +28 -10
- package/src/lib/feed.ts +153 -3
- package/src/middleware/__tests__/secure-headers.test.ts +89 -0
- package/src/middleware/auth.ts +1 -1
- package/src/middleware/secure-headers.ts +47 -1
- package/src/node/__tests__/cli-runtime-target.test.ts +110 -2
- package/src/node/__tests__/cli-site-snapshot.test.ts +308 -13
- package/src/node/__tests__/cli-site-token-env.test.ts +2 -7
- package/src/node/__tests__/cli-snapshot-meta.test.ts +85 -0
- package/src/node/__tests__/cli-sql-export.test.ts +49 -0
- package/src/node/index.ts +1 -0
- package/src/preset.css +8 -2
- package/src/routes/api/__tests__/settings.test.ts +3 -2
- package/src/routes/api/github-sync.tsx +1 -1
- package/src/routes/api/settings.ts +4 -1
- package/src/routes/auth/signin.tsx +6 -0
- package/src/routes/pages/archive.tsx +4 -2
- package/src/services/__tests__/post.test.ts +19 -19
- package/src/services/__tests__/search.test.ts +0 -1
- package/src/services/__tests__/settings.test.ts +22 -3
- package/src/services/bootstrap.ts +7 -3
- package/src/services/collection.ts +3 -3
- package/src/services/export.ts +0 -3
- package/src/services/navigation.ts +0 -2
- package/src/services/path.ts +1 -38
- package/src/services/post.ts +32 -66
- package/src/services/search.ts +0 -6
- package/src/services/settings.ts +47 -6
- package/src/services/site-admin.ts +6 -1
- package/src/styles/ui.css +12 -23
- package/src/types/entities.ts +0 -1
- package/src/ui/color-themes.ts +1 -1
- package/src/ui/dash/settings/GeneralContent.tsx +17 -19
- package/src/ui/dash/settings/SettingsRootContent.tsx +17 -28
- package/src/ui/feed/NoteCard.tsx +1 -11
- package/src/ui/feed/__tests__/timeline-cards.test.ts +1 -1
- package/src/ui/pages/PostPage.tsx +2 -0
- package/bin/commands/collections.js +0 -268
- package/bin/commands/media.js +0 -302
- package/bin/commands/posts.js +0 -262
- package/bin/commands/search.js +0 -53
- package/bin/commands/settings.js +0 -93
- package/bin/lib/http-api.js +0 -223
- package/bin/lib/media-upload.js +0 -206
- package/dist/app-CM7sb3xO.js +0 -5
- package/dist/client/_assets/client-DDs6NzB3.css +0 -2
- package/src/__tests__/bin/content-cli.test.ts +0 -179
- package/src/__tests__/bin/media-cli.test.ts +0 -192
- /package/dist/{github-api-BkRWnqMx.js → github-api-Bh0PH3zr.js} +0 -0
- /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
|
|
3
|
-
import {
|
|
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-3REcR-3U.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
|
|
3
|
-
import {
|
|
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-3REcR-3U.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-
|
|
477
|
+
app: async () => app ?? (await import("./app-B67XOEyo.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.
|
|
3
|
+
"version": "0.3.47",
|
|
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:
|
|
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",
|
|
@@ -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(
|