@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.
- package/dist/{app-Ct9c4zYF.js → app-B-wKZB8f.js} +267 -205
- package/dist/app-qwMcaTML.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/{client-Bp2IPjDe.js → client-B1XjvRqE.js} +1 -1
- package/dist/client/_assets/client-BMPMuwvV.css +2 -0
- package/dist/client/_assets/{client-auth-C4hQWqH1.js → client-auth-B9T2QFl2.js} +21 -21
- package/dist/{export-O2w3AsZX.js → export-CzuQyg5h.js} +28 -15
- package/dist/{github-sync-BUzIYouS.js → github-sync-CerNYCAn.js} +2 -2
- package/dist/{github-sync-D49RADci.js → github-sync-Dbrb1DS5.js} +5 -2
- package/dist/index.js +3 -3
- package/dist/node.js +4 -4
- package/package.json +1 -1
- package/src/__tests__/export-service.test.ts +127 -0
- package/src/app.tsx +7 -0
- package/src/client/components/__tests__/jant-collection-directory.test.ts +0 -42
- package/src/client/components/collection-manager-types.ts +0 -2
- package/src/client/components/jant-collection-directory.ts +0 -23
- package/src/i18n/locales/public/en.po +0 -12
- package/src/i18n/locales/public/zh-Hans.po +0 -12
- package/src/i18n/locales/public/zh-Hant.po +0 -12
- package/src/lib/github-sync-site-config.ts +4 -2
- package/src/middleware/__tests__/cache-control.test.ts +50 -0
- package/src/middleware/cache-control.ts +60 -0
- package/src/services/export-theme/styles/main.css +4 -3
- package/src/services/export.ts +47 -17
- package/src/services/github-sync.ts +8 -2
- package/src/styles/ui.css +23 -46
- package/src/ui/feed/ThreadPreview.tsx +17 -9
- package/src/ui/feed/__tests__/thread-preview.test.ts +131 -6
- package/src/ui/feed/thread-preview-state.ts +43 -0
- package/src/ui/pages/CollectionsPage.tsx +0 -22
- package/src/ui/shared/CollectionsManager.tsx +6 -41
- package/dist/app-CUZaVgsC.js +0 -6
- 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
|
|
|
@@ -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
|
|
9
|
-
*
|
|
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
|
-
|
|
383
|
-
|
|
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
|
-
|
|
387
|
+
border: 1px solid color-mix(in srgb, var(--site-divider) 82%, transparent);
|
|
387
388
|
flex: none;
|
|
388
389
|
}
|
|
389
390
|
|
package/src/services/export.ts
CHANGED
|
@@ -227,7 +227,17 @@ export function createExportService(
|
|
|
227
227
|
media: MediaService;
|
|
228
228
|
},
|
|
229
229
|
siteConfig: SiteConfig,
|
|
230
|
-
deps: {
|
|
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
|
|
666
|
-
*
|
|
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
|
|
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.
|
|
686
|
-
*
|
|
687
|
-
*
|
|
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
|
|
715
|
+
const dedicatedPublicUrl = getPublicUrlForProvider(
|
|
694
716
|
media.provider,
|
|
695
717
|
siteConfig.r2PublicUrl,
|
|
696
718
|
siteConfig.s3PublicUrl,
|
|
697
719
|
siteConfig.localPublicUrl,
|
|
698
720
|
);
|
|
699
|
-
const
|
|
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 =
|
|
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 =
|
|
734
|
-
? getMediaUrl(media.posterKey,
|
|
760
|
+
entry.poster = hasRemoteUrl
|
|
761
|
+
? getMediaUrl(media.posterKey, mediaBaseUrl)
|
|
735
762
|
: `/media/${posterLocalName}`;
|
|
736
763
|
entry.poster_key = media.posterKey;
|
|
737
|
-
if (!
|
|
764
|
+
if (!hasRemoteUrl) {
|
|
738
765
|
inlinePosterPath = `static/media/${posterLocalName}`;
|
|
739
766
|
}
|
|
740
767
|
}
|
|
741
768
|
|
|
742
769
|
return {
|
|
743
770
|
entry,
|
|
744
|
-
inlinePath:
|
|
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) =>
|
|
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
|
-
|
|
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
|