@jant/core 0.6.2 → 0.6.4

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 (34) hide show
  1. package/dist/{app-Ct9c4zYF.js → app-B-wKZB8f.js} +267 -205
  2. package/dist/app-qwMcaTML.js +6 -0
  3. package/dist/client/.vite/manifest.json +3 -3
  4. package/dist/client/_assets/{client-Bp2IPjDe.js → client-B1XjvRqE.js} +1 -1
  5. package/dist/client/_assets/client-BMPMuwvV.css +2 -0
  6. package/dist/client/_assets/{client-auth-C4hQWqH1.js → client-auth-B9T2QFl2.js} +21 -21
  7. package/dist/{export-O2w3AsZX.js → export-CzuQyg5h.js} +28 -15
  8. package/dist/{github-sync-BUzIYouS.js → github-sync-CerNYCAn.js} +2 -2
  9. package/dist/{github-sync-D49RADci.js → github-sync-Dbrb1DS5.js} +5 -2
  10. package/dist/index.js +3 -3
  11. package/dist/node.js +4 -4
  12. package/package.json +1 -1
  13. package/src/__tests__/export-service.test.ts +127 -0
  14. package/src/app.tsx +7 -0
  15. package/src/client/components/__tests__/jant-collection-directory.test.ts +0 -42
  16. package/src/client/components/collection-manager-types.ts +0 -2
  17. package/src/client/components/jant-collection-directory.ts +0 -23
  18. package/src/i18n/locales/public/en.po +0 -12
  19. package/src/i18n/locales/public/zh-Hans.po +0 -12
  20. package/src/i18n/locales/public/zh-Hant.po +0 -12
  21. package/src/lib/github-sync-site-config.ts +4 -2
  22. package/src/middleware/__tests__/cache-control.test.ts +50 -0
  23. package/src/middleware/cache-control.ts +60 -0
  24. package/src/services/export-theme/styles/main.css +4 -3
  25. package/src/services/export.ts +47 -17
  26. package/src/services/github-sync.ts +8 -2
  27. package/src/styles/ui.css +23 -46
  28. package/src/ui/feed/ThreadPreview.tsx +17 -9
  29. package/src/ui/feed/__tests__/thread-preview.test.ts +131 -6
  30. package/src/ui/feed/thread-preview-state.ts +43 -0
  31. package/src/ui/pages/CollectionsPage.tsx +0 -22
  32. package/src/ui/shared/CollectionsManager.tsx +6 -41
  33. package/dist/app-CUZaVgsC.js +0 -6
  34. package/dist/client/_assets/client-YVrRjAid.css +0 -2
@@ -800,4 +800,131 @@ describe("createExportService (Hugo)", () => {
800
800
  const files = filesToMap(await service.generateHugoFiles());
801
801
  expect(files.has("static/media/med-x.webp")).toBe(false);
802
802
  });
803
+
804
+ it("Sync mode (bundleMedia false) links media by absolute site URL without reading bytes", async () => {
805
+ const root = makePost({ id: "post-root", slug: "with-media" });
806
+ const media = makeMedia({
807
+ id: "med-1",
808
+ filename: "photo.webp",
809
+ storageKey: "media/med-1.webp",
810
+ });
811
+ const storage = {
812
+ // Must NOT be called — Sync links by URL, never reads attachment bytes.
813
+ get: async () => {
814
+ throw new Error("storage.get should not be called in Sync mode");
815
+ },
816
+ };
817
+ const service = createExportService(
818
+ buildServices({
819
+ posts: [root],
820
+ mediaByPost: new Map([["post-root", [media]]]),
821
+ }),
822
+ makeSiteConfig(),
823
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
824
+ { storage: storage as any, bundleMedia: false },
825
+ );
826
+ const files = filesToMap(await service.generateHugoFiles());
827
+ expect(files.has("static/media/med-1.webp")).toBe(false);
828
+ const { frontMatter } = await parseFrontMatter(
829
+ files.get("content/with-media/_index.md") as string,
830
+ );
831
+ const entry = frontMatter.media![0];
832
+ expect(entry.src).toBe("https://example.com/media/med-1.webp");
833
+ expect(entry.storage_key).toBe("media/med-1.webp");
834
+ });
835
+
836
+ it("Sync mode links the video poster by absolute site URL without bundling", async () => {
837
+ const root = makePost({ id: "post-root", slug: "with-video" });
838
+ const videoMedia = makeMedia({
839
+ id: "med-video",
840
+ filename: "clip.mp4",
841
+ mimeType: "video/mp4",
842
+ storageKey: "media/med-video.mp4",
843
+ mediaKind: "video",
844
+ posterKey: "media/posters/med-video.webp",
845
+ });
846
+ const storage = {
847
+ get: async () => {
848
+ throw new Error("storage.get should not be called in Sync mode");
849
+ },
850
+ };
851
+ const service = createExportService(
852
+ buildServices({
853
+ posts: [root],
854
+ mediaByPost: new Map([["post-root", [videoMedia]]]),
855
+ }),
856
+ makeSiteConfig(),
857
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
858
+ { storage: storage as any, bundleMedia: false },
859
+ );
860
+ const files = filesToMap(await service.generateHugoFiles());
861
+ expect(files.has("static/media/med-video.mp4")).toBe(false);
862
+ expect(files.has("static/media/med-video-poster.webp")).toBe(false);
863
+ const { frontMatter } = await parseFrontMatter(
864
+ files.get("content/with-video/_index.md") as string,
865
+ );
866
+ const entry = frontMatter.media![0];
867
+ expect(entry.src).toBe("https://example.com/media/med-video.mp4");
868
+ expect(entry.poster).toBe(
869
+ "https://example.com/media/posters/med-video.webp",
870
+ );
871
+ });
872
+
873
+ it("Sync mode still prefers a dedicated provider public URL when configured", async () => {
874
+ const root = makePost({ id: "post-root", slug: "with-cdn" });
875
+ const media = makeMedia({
876
+ id: "med-cdn",
877
+ filename: "cdn.webp",
878
+ storageKey: "media/med-cdn.webp",
879
+ });
880
+ const service = createExportService(
881
+ buildServices({
882
+ posts: [root],
883
+ mediaByPost: new Map([["post-root", [media]]]),
884
+ }),
885
+ makeSiteConfig({ r2PublicUrl: "https://cdn.example.com" }),
886
+ { bundleMedia: false },
887
+ );
888
+ const files = filesToMap(await service.generateHugoFiles());
889
+ expect(files.has("static/media/med-cdn.webp")).toBe(false);
890
+ const { frontMatter } = await parseFrontMatter(
891
+ files.get("content/with-cdn/_index.md") as string,
892
+ );
893
+ expect(frontMatter.media![0].src).toBe(
894
+ "https://cdn.example.com/media/med-cdn.webp",
895
+ );
896
+ });
897
+
898
+ it("Sync mode falls back to bundling bytes when the site URL is unknown", async () => {
899
+ const root = makePost({ id: "post-root", slug: "with-media" });
900
+ const media = makeMedia({
901
+ id: "med-1",
902
+ filename: "photo.webp",
903
+ storageKey: "media/med-1.webp",
904
+ });
905
+ const storage = {
906
+ get: async (key: string) =>
907
+ key === "media/med-1.webp"
908
+ ? { body: new Blob([new Uint8Array([1, 2, 3])]).stream() }
909
+ : null,
910
+ };
911
+ const service = createExportService(
912
+ buildServices({
913
+ posts: [root],
914
+ mediaByPost: new Map([["post-root", [media]]]),
915
+ }),
916
+ makeSiteConfig({ siteUrl: "" }),
917
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
918
+ { storage: storage as any, bundleMedia: false },
919
+ );
920
+ const files = filesToMap(await service.generateHugoFiles());
921
+ // No resolvable URL — bundling is the only way to avoid a broken link.
922
+ expect(files.get("static/media/med-1.webp")).toEqual(
923
+ new Uint8Array([1, 2, 3]),
924
+ );
925
+ const { frontMatter } = await parseFrontMatter(
926
+ files.get("content/with-media/_index.md") as string,
927
+ );
928
+ expect(frontMatter.media![0].src).toBe("/media/med-1.webp");
929
+ });
803
930
  });
package/src/app.tsx CHANGED
@@ -69,6 +69,7 @@ import { manifestRoutes } from "./routes/feed/manifest.js";
69
69
  // Middleware
70
70
  import { requireAuth } from "./middleware/auth.js";
71
71
  import { attachSession } from "./middleware/session.js";
72
+ import { defaultCacheControl } from "./middleware/cache-control.js";
72
73
  import { requireOnboarding } from "./middleware/onboarding.js";
73
74
  import { errorHandler } from "./middleware/error-handler.js";
74
75
  import { withConfig } from "./middleware/config.js";
@@ -320,6 +321,12 @@ export function createApp(): App {
320
321
  // downstream handlers don't each call auth.api.getSession themselves.
321
322
  app.use("*", attachSession());
322
323
 
324
+ // Default every response without an explicit Cache-Control to
325
+ // `private, no-store`. Jant pages are auth-variant, so a shared/CDN cache
326
+ // must never store them; routes serving genuinely public resources (media,
327
+ // feeds, sitemaps, favicons) set their own Cache-Control and are untouched.
328
+ app.use("*", defaultCacheControl());
329
+
323
330
  app.use("*", async (c, next) => {
324
331
  const redirectUrl = await getHostedCanonicalRedirect({
325
332
  currentSite: c.var.currentSite,
@@ -24,8 +24,6 @@ import type { JantCollectionsManager } from "../jant-collection-directory.js";
24
24
 
25
25
  const labels: CollectionManagerLabels = {
26
26
  collectionsTitle: "Collections",
27
- collectionSingular: "collection",
28
- collectionPlural: "collections",
29
27
  organize: "Organize",
30
28
  done: "Done",
31
29
  organizeHint: "Drag to reorder.",
@@ -186,7 +184,6 @@ async function createElementWithManagerRoot(): Promise<JantCollectionsManager> {
186
184
  const root = document.createElement("div");
187
185
  root.setAttribute("data-collections-manager-root", "");
188
186
  root.innerHTML = `
189
- <p data-collections-count></p>
190
187
  <div data-collections-reorder-actions hidden>
191
188
  <button type="button" data-collections-action="divider">New divider</button>
192
189
  <button type="button" data-collections-action="done">Done</button>
@@ -208,32 +205,6 @@ async function createElementWithManagerRoot(): Promise<JantCollectionsManager> {
208
205
  return el;
209
206
  }
210
207
 
211
- async function createEmptyElementWithManagerRoot(): Promise<JantCollectionsManager> {
212
- const root = document.createElement("div");
213
- root.setAttribute("data-collections-manager-root", "");
214
- root.innerHTML = `
215
- <p data-collections-count hidden></p>
216
- <div data-collections-reorder-actions hidden>
217
- <button type="button" data-collections-action="divider">New divider</button>
218
- <button type="button" data-collections-action="done">Done</button>
219
- </div>
220
- <div data-collections-toolbar></div>
221
- <p data-collections-hint hidden></p>
222
- <div data-collections-more-menu hidden></div>
223
- <button type="button" data-collections-action="toggle-menu"></button>
224
- `;
225
-
226
- const el = document.createElement(
227
- "jant-collections-manager",
228
- ) as JantCollectionsManager;
229
- el.labels = labels;
230
- el.items = [];
231
- root.appendChild(el);
232
- document.body.appendChild(root);
233
- await el.updateComplete;
234
- return el;
235
- }
236
-
237
208
  describe("JantCollectionsManager", () => {
238
209
  beforeEach(() => {
239
210
  document.body.innerHTML = "";
@@ -308,19 +279,6 @@ describe("JantCollectionsManager", () => {
308
279
  expect(toolbar?.hidden).toBe(true);
309
280
  });
310
281
 
311
- it("keeps the collection count visible when the list is empty", async () => {
312
- const el = await createEmptyElementWithManagerRoot();
313
- const root = el.closest<HTMLElement>("[data-collections-manager-root]");
314
-
315
- expect(root).not.toBeNull();
316
- if (!root) throw new Error("Expected collections manager root");
317
-
318
- const count = root.querySelector<HTMLElement>("[data-collections-count]");
319
-
320
- expect(count?.hidden).toBe(false);
321
- expect(count?.textContent).toBe("0 collections");
322
- });
323
-
324
282
  it("renders divider labels as aggregate links when followed by a grouped section", async () => {
325
283
  const el = await createElementWithItems(groupedItems);
326
284
 
@@ -6,8 +6,6 @@ import type { CollectionFormLabels } from "./collection-types.js";
6
6
 
7
7
  export interface CollectionManagerLabels {
8
8
  collectionsTitle: string;
9
- collectionSingular: string;
10
- collectionPlural: string;
11
9
  organize: string;
12
10
  done: string;
13
11
  organizeHint: string;
@@ -245,21 +245,6 @@ export class JantCollectionsManager extends LitElement {
245
245
  );
246
246
  }
247
247
 
248
- #collectionCount() {
249
- return this._items.filter(
250
- (item) => item.type === "collection" && item.collection,
251
- ).length;
252
- }
253
-
254
- #collectionCountLabel() {
255
- const count = this.#collectionCount();
256
- return `${count} ${
257
- count === 1
258
- ? this.labels.collectionSingular
259
- : this.labels.collectionPlural
260
- }`;
261
- }
262
-
263
248
  #countLabel(count: number) {
264
249
  return `${count} ${
265
250
  count === 1 ? this.labels.entrySingular : this.labels.entryPlural
@@ -280,14 +265,6 @@ export class JantCollectionsManager extends LitElement {
280
265
  }
281
266
 
282
267
  #syncHeaderState() {
283
- const countEl = this.#queryHeaderElement<HTMLElement>(
284
- "[data-collections-count]",
285
- );
286
- if (countEl) {
287
- countEl.textContent = this.#collectionCountLabel();
288
- countEl.hidden = false;
289
- }
290
-
291
268
  const doneButton = this.#queryHeaderElement<HTMLButtonElement>(
292
269
  '[data-collections-action="done"]',
293
270
  );
@@ -371,14 +371,8 @@ msgstr "Clear filter"
371
371
  msgid "Close menu"
372
372
  msgstr "Close menu"
373
373
 
374
- #. @context: Singular collection count label
375
- #. @context: Singular collection count label
376
- #. @context: Singular collection count label
377
374
  #. @context: Singular collection count label on an aggregate collection page
378
375
  #: src/ui/pages/CollectionPage.tsx
379
- #: src/ui/pages/CollectionsPage.tsx
380
- #: src/ui/shared/CollectionsManager.tsx
381
- #: src/ui/shared/CollectionsManager.tsx
382
376
  msgid "collection"
383
377
  msgstr "collection"
384
378
 
@@ -412,14 +406,8 @@ msgstr "Collection order updated."
412
406
  msgid "Collection saved."
413
407
  msgstr "Collection saved."
414
408
 
415
- #. @context: Plural collection count label
416
- #. @context: Plural collection count label
417
- #. @context: Plural collection count label
418
409
  #. @context: Plural collection count label on an aggregate collection page
419
410
  #: src/ui/pages/CollectionPage.tsx
420
- #: src/ui/pages/CollectionsPage.tsx
421
- #: src/ui/shared/CollectionsManager.tsx
422
- #: src/ui/shared/CollectionsManager.tsx
423
411
  msgid "collections"
424
412
  msgstr "collections"
425
413
 
@@ -371,14 +371,8 @@ msgstr ""
371
371
  msgid "Close menu"
372
372
  msgstr ""
373
373
 
374
- #. @context: Singular collection count label
375
- #. @context: Singular collection count label
376
- #. @context: Singular collection count label
377
374
  #. @context: Singular collection count label on an aggregate collection page
378
375
  #: src/ui/pages/CollectionPage.tsx
379
- #: src/ui/pages/CollectionsPage.tsx
380
- #: src/ui/shared/CollectionsManager.tsx
381
- #: src/ui/shared/CollectionsManager.tsx
382
376
  msgid "collection"
383
377
  msgstr ""
384
378
 
@@ -412,14 +406,8 @@ msgstr ""
412
406
  msgid "Collection saved."
413
407
  msgstr ""
414
408
 
415
- #. @context: Plural collection count label
416
- #. @context: Plural collection count label
417
- #. @context: Plural collection count label
418
409
  #. @context: Plural collection count label on an aggregate collection page
419
410
  #: src/ui/pages/CollectionPage.tsx
420
- #: src/ui/pages/CollectionsPage.tsx
421
- #: src/ui/shared/CollectionsManager.tsx
422
- #: src/ui/shared/CollectionsManager.tsx
423
411
  msgid "collections"
424
412
  msgstr ""
425
413
 
@@ -371,14 +371,8 @@ msgstr ""
371
371
  msgid "Close menu"
372
372
  msgstr ""
373
373
 
374
- #. @context: Singular collection count label
375
- #. @context: Singular collection count label
376
- #. @context: Singular collection count label
377
374
  #. @context: Singular collection count label on an aggregate collection page
378
375
  #: src/ui/pages/CollectionPage.tsx
379
- #: src/ui/pages/CollectionsPage.tsx
380
- #: src/ui/shared/CollectionsManager.tsx
381
- #: src/ui/shared/CollectionsManager.tsx
382
376
  msgid "collection"
383
377
  msgstr ""
384
378
 
@@ -412,14 +406,8 @@ msgstr ""
412
406
  msgid "Collection saved."
413
407
  msgstr ""
414
408
 
415
- #. @context: Plural collection count label
416
- #. @context: Plural collection count label
417
- #. @context: Plural collection count label
418
409
  #. @context: Plural collection count label on an aggregate collection page
419
410
  #: src/ui/pages/CollectionPage.tsx
420
- #: src/ui/pages/CollectionsPage.tsx
421
- #: src/ui/shared/CollectionsManager.tsx
422
- #: src/ui/shared/CollectionsManager.tsx
423
411
  msgid "collections"
424
412
  msgstr ""
425
413
 
@@ -5,8 +5,10 @@
5
5
  * call site had its own near-identical copy.
6
6
  *
7
7
  * The shape mirrors `routes/api/export.ts` so `jant site export` and
8
- * `jant github sync` produce byte-identical Hugo sites (modulo media
9
- * files, which Sync intentionally skips). If you add a field to the
8
+ * `jant github sync` produce matching Hugo sites. They differ only in
9
+ * media: `site export` bundles attachment bytes into `static/media/` for
10
+ * a self-contained archive, while Sync links attachments by URL and
11
+ * never writes their bytes into the repo. If you add a field to the
10
12
  * export route, add it here too.
11
13
  */
12
14
 
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { Hono } from "hono";
3
+ import type { Bindings } from "../../types.js";
4
+ import type { AppVariables } from "../../types/app-context.js";
5
+ import { defaultCacheControl } from "../cache-control.js";
6
+
7
+ type Env = { Bindings: Bindings; Variables: AppVariables };
8
+
9
+ function buildApp(): Hono<Env> {
10
+ const app = new Hono<Env>();
11
+ app.use("*", defaultCacheControl());
12
+
13
+ // Un-annotated dynamic page — the common case.
14
+ app.get("/", (c) => c.html("<h1>home</h1>"));
15
+
16
+ // Route that declares its own public cache policy (e.g. a feed).
17
+ app.get("/feed", (c) =>
18
+ c.body("<feed/>", 200, { "Cache-Control": "public, max-age=180" }),
19
+ );
20
+
21
+ // Route that already opts out explicitly.
22
+ app.get("/api/thing", (c) =>
23
+ c.json({ ok: true }, 200, { "Cache-Control": "no-store" }),
24
+ );
25
+
26
+ return app;
27
+ }
28
+
29
+ describe("defaultCacheControl", () => {
30
+ it("defaults un-annotated responses to private, no-store", async () => {
31
+ const response = await buildApp().request("/");
32
+ expect(response.headers.get("Cache-Control")).toBe("private, no-store");
33
+ });
34
+
35
+ it("leaves an explicit public cache policy untouched", async () => {
36
+ const response = await buildApp().request("/feed");
37
+ expect(response.headers.get("Cache-Control")).toBe("public, max-age=180");
38
+ });
39
+
40
+ it("leaves an explicit opt-out untouched", async () => {
41
+ const response = await buildApp().request("/api/thing");
42
+ expect(response.headers.get("Cache-Control")).toBe("no-store");
43
+ });
44
+
45
+ it("defaults not-found responses too", async () => {
46
+ const response = await buildApp().request("/missing");
47
+ expect(response.status).toBe(404);
48
+ expect(response.headers.get("Cache-Control")).toBe("private, no-store");
49
+ });
50
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Cache-Control Middleware
3
+ *
4
+ * Sets a safe default `Cache-Control` on responses that don't declare one.
5
+ *
6
+ * Almost every Jant page is auth-variant: the same URL renders differently
7
+ * for the signed-in author (nav, the "more" menu, edit affordances) than for
8
+ * an anonymous visitor. A shared/CDN cache keyed only by URL must therefore
9
+ * never store these pages — otherwise it serves a stale or wrong-audience
10
+ * snapshot, which both breaks the UI ("you still look signed out", "your edit
11
+ * didn't take effect") and can leak the authenticated dashboard to the public.
12
+ *
13
+ * Jant is self-hosted software that runs behind whatever reverse proxy or CDN
14
+ * the operator chooses, so it cannot rely on infrastructure config to get
15
+ * this right — it must declare its own cache policy. The critical mistake is
16
+ * emitting `Cache-Control: public`: that word is an explicit invitation for
17
+ * any shared cache to store the response.
18
+ *
19
+ * Routes that serve genuinely public, auth-invariant resources (media, feeds,
20
+ * sitemaps, favicons, manifests, static assets) set their own `Cache-Control`
21
+ * explicitly; this middleware leaves those untouched and only fills in the
22
+ * default for the un-annotated dynamic responses.
23
+ */
24
+
25
+ import type { MiddlewareHandler } from "hono";
26
+ import type { Bindings } from "../types.js";
27
+ import type { AppVariables } from "../types/app-context.js";
28
+
29
+ type Env = { Bindings: Bindings; Variables: AppVariables };
30
+
31
+ /**
32
+ * Default cache directive for dynamic, potentially auth-variant responses.
33
+ * `private` forbids shared/CDN caches from storing the response; `no-store`
34
+ * prevents any cache (including the browser) from keeping a copy.
35
+ */
36
+ const DEFAULT_CACHE_CONTROL = "private, no-store";
37
+
38
+ /**
39
+ * Middleware that defaults a missing `Cache-Control` header to
40
+ * `private, no-store`.
41
+ *
42
+ * Runs after the route handler: if the handler (or an inner middleware)
43
+ * already set `Cache-Control`, that explicit value wins. Only responses that
44
+ * declare nothing receive the safe default.
45
+ *
46
+ * @returns Hono middleware enforcing the default cache policy.
47
+ *
48
+ * @example
49
+ * ```ts
50
+ * app.use("*", defaultCacheControl());
51
+ * ```
52
+ */
53
+ export function defaultCacheControl(): MiddlewareHandler<Env> {
54
+ return async (c, next) => {
55
+ await next();
56
+ if (!c.res.headers.has("Cache-Control")) {
57
+ c.res.headers.set("Cache-Control", DEFAULT_CACHE_CONTROL);
58
+ }
59
+ };
60
+ }
@@ -379,11 +379,12 @@ time {
379
379
  }
380
380
 
381
381
  .site-logo-avatar {
382
- width: calc(var(--avatar-size) + 2px);
383
- height: calc(var(--avatar-size) + 2px);
382
+ box-sizing: border-box;
383
+ width: calc(var(--avatar-size) + 4px);
384
+ height: calc(var(--avatar-size) + 4px);
384
385
  border-radius: var(--avatar-radius);
385
386
  object-fit: cover;
386
- box-shadow: 0 0 0 1px color-mix(in srgb, var(--site-divider) 82%, transparent);
387
+ border: 1px solid color-mix(in srgb, var(--site-divider) 82%, transparent);
387
388
  flex: none;
388
389
  }
389
390
 
@@ -227,7 +227,17 @@ export function createExportService(
227
227
  media: MediaService;
228
228
  },
229
229
  siteConfig: SiteConfig,
230
- deps: { storage?: StorageDriver | null } = {},
230
+ deps: {
231
+ storage?: StorageDriver | null;
232
+ /**
233
+ * Whether to bundle media bytes into the exported site under
234
+ * `static/media/`. Defaults to `true` (the `jant site export`
235
+ * archive, which must be self-contained). GitHub Sync passes
236
+ * `false` so media is linked by URL instead — it never reads or
237
+ * base64-encodes attachment bytes.
238
+ */
239
+ bundleMedia?: boolean;
240
+ } = {},
231
241
  ): ExportService {
232
242
  return {
233
243
  async generateHugoFiles() {
@@ -308,6 +318,7 @@ export function createExportService(
308
318
 
309
319
  // 3. Build file list
310
320
  const exportFiles: ExportFile[] = [];
321
+ const bundleMedia = deps.bundleMedia ?? true;
311
322
 
312
323
  // Generate thread bundles (root _index.md + per-reply index.md).
313
324
  for (const root of roots) {
@@ -335,6 +346,7 @@ export function createExportService(
335
346
  rawMediaByPost,
336
347
  siteConfig,
337
348
  deps.storage ?? null,
349
+ bundleMedia,
338
350
  );
339
351
  exportFiles.push(...bundleFiles);
340
352
  }
@@ -662,14 +674,13 @@ interface MediaEmission {
662
674
  entry: JantMedia;
663
675
  /**
664
676
  * Site-relative path under `static/` where the primary bytes should
665
- * land, or null when the media links to a remote public URL and no
666
- * bytes need to be emitted.
677
+ * land, or null when the media is linked by URL and no bytes need to
678
+ * be emitted.
667
679
  */
668
680
  inlinePath: string | null;
669
681
  /**
670
682
  * Site-relative path under `static/` where the poster bytes should
671
- * land, or null when there's no poster or the poster is linked via
672
- * a public URL.
683
+ * land, or null when there's no poster or the poster is linked by URL.
673
684
  */
674
685
  inlinePosterPath: string | null;
675
686
  }
@@ -682,26 +693,42 @@ interface MediaEmission {
682
693
  * When the media's provider has a reachable public URL (R2/S3/local
683
694
  * proxy configured with a `*_public_url`), `src` points at that absolute
684
695
  * URL and no bytes are emitted — the exported site stays small and the
685
- * media keeps being served from wherever it already lives. Without a
686
- * public URL the bytes are written to `static/media/{id}.ext` and `src`
687
- * is the site-relative path.
696
+ * media keeps being served from wherever it already lives.
697
+ *
698
+ * Otherwise behavior depends on `bundleMedia`:
699
+ * - `true` (the `jant site export` archive): bytes are written to
700
+ * `static/media/{id}.ext` and `src` is the site-relative path, so the
701
+ * archive is self-contained.
702
+ * - `false` (GitHub Sync): no bytes are emitted. `src` falls back to the
703
+ * site's own URL so it stays an absolute, resolvable link — the worker
704
+ * already serves these objects at `/{storageKey}`. This keeps Sync
705
+ * from reading and base64-encoding every attachment on every push.
706
+ *
707
+ * When `bundleMedia` is false but the site URL is unknown, bundling is
708
+ * used as a last resort to avoid emitting a broken relative link.
688
709
  */
689
710
  function buildMediaEmission(
690
711
  media: Media,
691
712
  siteConfig: SiteConfig,
713
+ bundleMedia: boolean,
692
714
  ): MediaEmission {
693
- const publicUrl = getPublicUrlForProvider(
715
+ const dedicatedPublicUrl = getPublicUrlForProvider(
694
716
  media.provider,
695
717
  siteConfig.r2PublicUrl,
696
718
  siteConfig.s3PublicUrl,
697
719
  siteConfig.localPublicUrl,
698
720
  );
699
- const hasPublic = Boolean(publicUrl);
721
+ const siteFallbackUrl =
722
+ !bundleMedia && siteConfig.siteUrl.trim() ? siteConfig.siteUrl : undefined;
723
+ const mediaBaseUrl = dedicatedPublicUrl || siteFallbackUrl;
724
+ const hasRemoteUrl = Boolean(mediaBaseUrl);
700
725
 
701
726
  const ext = extOfFilename(media.filename);
702
727
  const localName = `${media.id}${ext}`;
703
728
  const localPath = `/media/${localName}`;
704
- const src = hasPublic ? getMediaUrl(media.storageKey, publicUrl) : localPath;
729
+ const src = hasRemoteUrl
730
+ ? getMediaUrl(media.storageKey, mediaBaseUrl)
731
+ : localPath;
705
732
 
706
733
  const entry: JantMedia = {
707
734
  id: media.id,
@@ -730,18 +757,18 @@ function buildMediaEmission(
730
757
  if (media.posterKey) {
731
758
  const posterExt = extOfStorageKey(media.posterKey);
732
759
  const posterLocalName = `${media.id}-poster.${posterExt}`;
733
- entry.poster = hasPublic
734
- ? getMediaUrl(media.posterKey, publicUrl)
760
+ entry.poster = hasRemoteUrl
761
+ ? getMediaUrl(media.posterKey, mediaBaseUrl)
735
762
  : `/media/${posterLocalName}`;
736
763
  entry.poster_key = media.posterKey;
737
- if (!hasPublic) {
764
+ if (!hasRemoteUrl) {
738
765
  inlinePosterPath = `static/media/${posterLocalName}`;
739
766
  }
740
767
  }
741
768
 
742
769
  return {
743
770
  entry,
744
- inlinePath: hasPublic ? null : `static/media/${localName}`,
771
+ inlinePath: hasRemoteUrl ? null : `static/media/${localName}`,
745
772
  inlinePosterPath,
746
773
  };
747
774
  }
@@ -793,6 +820,7 @@ async function buildThreadBundle(
793
820
  mediaByPost: Map<string, Media[]>,
794
821
  siteConfig: SiteConfig,
795
822
  storage: StorageDriver | null,
823
+ bundleMedia: boolean,
796
824
  ): Promise<ExportFile[]> {
797
825
  const files: ExportFile[] = [];
798
826
 
@@ -807,7 +835,9 @@ async function buildThreadBundle(
807
835
 
808
836
  // Root front matter.
809
837
  const rootMedia = mediaByPost.get(root.id) ?? [];
810
- const rootEmissions = rootMedia.map((m) => buildMediaEmission(m, siteConfig));
838
+ const rootEmissions = rootMedia.map((m) =>
839
+ buildMediaEmission(m, siteConfig, bundleMedia),
840
+ );
811
841
  const rootMediaList = rootEmissions.map((e) => e.entry);
812
842
  const rootFrontMatter: HugoFrontMatter = {
813
843
  id: root.id,
@@ -894,7 +924,7 @@ async function buildThreadBundle(
894
924
  const replySlug = slugMap.get(reply.id) ?? reply.slug;
895
925
  const replyMedia = mediaByPost.get(reply.id) ?? [];
896
926
  const replyEmissions = replyMedia.map((m) =>
897
- buildMediaEmission(m, siteConfig),
927
+ buildMediaEmission(m, siteConfig, bundleMedia),
898
928
  );
899
929
  const replyMediaList = replyEmissions.map((e) => e.entry);
900
930
  const replyCollectionEntries = buildExportedCollectionEntriesForPost(
@@ -451,8 +451,14 @@ export function createGitHubSyncService(
451
451
  if (!config) throw new Error("GitHub Sync is not configured");
452
452
  const { client, owner, repo } = createClient(config);
453
453
 
454
- // Generate full Hugo site via the shared export service
455
- const exportService = createExportService(services, siteConfig, deps);
454
+ // Generate full Hugo site via the shared export service.
455
+ // `bundleMedia: false` Sync links attachments by URL instead of
456
+ // writing their bytes into the repo, so a push never reads or
457
+ // base64-encodes media. Media stays served from the live site.
458
+ const exportService = createExportService(services, siteConfig, {
459
+ storage: deps.storage,
460
+ bundleMedia: false,
461
+ });
456
462
  const exportFiles = await exportService.generateHugoFiles();
457
463
 
458
464
  // Resolve HEAD before building the tree — needed as the commit