@jant/core 0.6.7 → 0.6.8
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/uploads/cleanup.js +1 -0
- package/dist/{app-L1UPUArB.js → app-9P4rVCe2.js} +338 -117
- package/dist/app-DaxS_Cz-.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-C6peCkkD.css +2 -0
- package/dist/client/_assets/{client-B0MvB2r0.js → client-CXnEhyyv.js} +2 -2
- package/dist/client/_assets/{client-auth-CwwuucF_.js → client-auth-CSItbyU8.js} +357 -355
- package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
- package/dist/{export-DLukCOO3.js → export-Be082J0n.js} +33 -8
- package/dist/{github-api-UD4u_7fa.js → github-api-BgSiE71w.js} +1 -1
- package/dist/{github-app-DeX6Td1O.js → github-app-BbklkFmU.js} +1 -1
- package/dist/{github-sync-BtHY2AST.js → github-sync-D1Cw8mOY.js} +3 -3
- package/dist/{github-sync-BeDecPen.js → github-sync-_kPWM4m9.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +6 -6
- package/dist/{url-XF0GbKGO.js → url-BMYO-Zlt.js} +42 -2
- package/package.json +1 -1
- package/src/__tests__/bin/uploads-cleanup.test.ts +2 -0
- package/src/client/__tests__/compose-bridge.test.ts +105 -0
- package/src/client/__tests__/hydrate-partial.test.ts +27 -0
- package/src/client/__tests__/note-expand.test.ts +130 -0
- package/src/client/archive-nav.js +2 -1
- package/src/client/audio-player.ts +7 -3
- package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +313 -0
- package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
- package/src/client/components/compose-format-convert.ts +255 -0
- package/src/client/components/compose-types.ts +2 -0
- package/src/client/components/jant-compose-dialog.ts +98 -44
- package/src/client/components/jant-compose-editor.ts +64 -11
- package/src/client/compose-bridge.ts +17 -0
- package/src/client/feed-video-player.ts +1 -1
- package/src/client/hydrate-partial.ts +25 -0
- package/src/client/note-expand.ts +63 -0
- package/src/client.ts +1 -0
- package/src/i18n/locales/public/en.po +41 -0
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +41 -0
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +41 -0
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/i18n/locales/settings/en.po +12 -12
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +12 -12
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +12 -12
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
- package/src/lib/__tests__/markdown.test.ts +1 -1
- package/src/lib/__tests__/summary.test.ts +87 -0
- package/src/lib/__tests__/timeline.test.ts +48 -1
- package/src/lib/__tests__/tiptap-render.test.ts +4 -4
- package/src/lib/__tests__/url.test.ts +44 -0
- package/src/lib/__tests__/view.test.ts +168 -1
- package/src/lib/navigation.ts +1 -0
- package/src/lib/resolve-config.ts +2 -2
- package/src/lib/summary.ts +42 -3
- package/src/lib/tiptap-render.ts +6 -2
- package/src/lib/upload.ts +2 -2
- package/src/lib/url.ts +41 -0
- package/src/lib/view.ts +102 -40
- package/src/preset.css +7 -1
- package/src/routes/api/internal/__tests__/uploads.test.ts +68 -0
- package/src/routes/api/internal/sites.ts +44 -1
- package/src/routes/api/public/__tests__/archive.test.ts +66 -0
- package/src/routes/api/public/archive.ts +22 -6
- package/src/routes/api/telegram.ts +2 -1
- package/src/routes/dash/custom-urls.tsx +1 -1
- package/src/routes/dash/settings.tsx +8 -5
- package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
- package/src/routes/pages/archive.tsx +116 -20
- package/src/routes/pages/collections.tsx +1 -0
- package/src/services/__tests__/media.test.ts +83 -0
- package/src/services/__tests__/post.test.ts +81 -0
- package/src/services/export-theme/assets/client-site.js +1 -1
- package/src/services/export-theme/styles/main.css +49 -15
- package/src/services/media.ts +31 -1
- package/src/services/post.ts +22 -2
- package/src/services/search.ts +4 -4
- package/src/services/upload-session.ts +18 -0
- package/src/styles/tokens.css +1 -1
- package/src/styles/ui.css +163 -34
- package/src/types/config.ts +1 -1
- package/src/types/props.ts +3 -0
- package/src/ui/compose/ComposeDialog.tsx +13 -0
- package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
- package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
- package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
- package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
- package/src/ui/feed/NoteCard.tsx +54 -5
- package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
- package/src/ui/pages/ArchivePage.tsx +89 -6
- package/src/ui/pages/CollectionsPage.tsx +7 -1
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
- package/src/ui/shared/CollectionDirectory.tsx +13 -3
- package/src/ui/shared/CollectionsManager.tsx +3 -0
- package/dist/app-C1QgMNRY.js +0 -6
- package/dist/client/_assets/client-BMPMuwvV.css +0 -2
|
@@ -271,18 +271,123 @@ describe("toPostView", () => {
|
|
|
271
271
|
);
|
|
272
272
|
});
|
|
273
273
|
|
|
274
|
-
it("does not
|
|
274
|
+
it("does not attach summaryHtml for short untitled notes", () => {
|
|
275
|
+
const body = JSON.stringify({
|
|
276
|
+
type: "doc",
|
|
277
|
+
content: [
|
|
278
|
+
{ type: "paragraph", content: [{ type: "text", text: "Just a note" }] },
|
|
279
|
+
],
|
|
280
|
+
});
|
|
275
281
|
const view = toPostView(
|
|
276
282
|
makePostWithMedia({
|
|
277
283
|
title: null,
|
|
284
|
+
body,
|
|
278
285
|
bodyHtml: "<p>Just a note</p>",
|
|
279
286
|
}),
|
|
280
287
|
EMPTY_CTX,
|
|
281
288
|
);
|
|
289
|
+
// Untitled notes render their body in full unless it is long enough to
|
|
290
|
+
// truncate, so a short note carries no summary.
|
|
291
|
+
expect(view.summaryHtml).toBeUndefined();
|
|
292
|
+
expect(view.summaryHasMore).toBeUndefined();
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("marks the summary boundary on long untitled notes", () => {
|
|
296
|
+
const textA = "A".repeat(1000);
|
|
297
|
+
const textB = "B".repeat(1000);
|
|
298
|
+
const body = JSON.stringify({
|
|
299
|
+
type: "doc",
|
|
300
|
+
content: [
|
|
301
|
+
{ type: "paragraph", content: [{ type: "text", text: textA }] },
|
|
302
|
+
{ type: "paragraph", content: [{ type: "text", text: textB }] },
|
|
303
|
+
],
|
|
304
|
+
});
|
|
305
|
+
const p1 = `<p>${textA}</p>`;
|
|
306
|
+
const p2 = `<p>${textB}</p>`;
|
|
307
|
+
const view = toPostView(
|
|
308
|
+
makePostWithMedia({ title: null, body, bodyHtml: p1 + p2 }),
|
|
309
|
+
EMPTY_CTX,
|
|
310
|
+
);
|
|
311
|
+
// Untitled notes carry no excerpt — the card renders the full body and the
|
|
312
|
+
// marker tells the feed where to clamp the tail for expand-in-place.
|
|
313
|
+
expect(view.summaryHtml).toBeUndefined();
|
|
314
|
+
expect(view.summaryHasMore).toBe(true);
|
|
315
|
+
expect(view.bodyHtml).toBe(`${p1}<span data-note-break></span>${p2}`);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("uses the larger untitled limits before truncating", () => {
|
|
319
|
+
// Seven ~100-char blocks (700 chars) fit the note limits (10 blocks /
|
|
320
|
+
// 1500 chars) but would have exceeded the old article limits (5 / 500).
|
|
321
|
+
const texts = Array.from({ length: 7 }, (_, i) => `${i}`.padEnd(100, "x"));
|
|
322
|
+
const body = JSON.stringify({
|
|
323
|
+
type: "doc",
|
|
324
|
+
content: texts.map((text) => ({
|
|
325
|
+
type: "paragraph",
|
|
326
|
+
content: [{ type: "text", text }],
|
|
327
|
+
})),
|
|
328
|
+
});
|
|
329
|
+
const view = toPostView(
|
|
330
|
+
makePostWithMedia({
|
|
331
|
+
title: null,
|
|
332
|
+
body,
|
|
333
|
+
bodyHtml: texts.map((t) => `<p>${t}</p>`).join(""),
|
|
334
|
+
}),
|
|
335
|
+
EMPTY_CTX,
|
|
336
|
+
);
|
|
282
337
|
expect(view.summaryHtml).toBeUndefined();
|
|
283
338
|
expect(view.summaryHasMore).toBeUndefined();
|
|
284
339
|
});
|
|
285
340
|
|
|
341
|
+
it("does not truncate an untitled note to hide a tiny tail", () => {
|
|
342
|
+
const body = JSON.stringify({
|
|
343
|
+
type: "doc",
|
|
344
|
+
content: [
|
|
345
|
+
{
|
|
346
|
+
type: "paragraph",
|
|
347
|
+
content: [{ type: "text", text: "A".repeat(1400) }],
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
type: "paragraph",
|
|
351
|
+
content: [{ type: "text", text: "B".repeat(150) }],
|
|
352
|
+
},
|
|
353
|
+
],
|
|
354
|
+
});
|
|
355
|
+
const view = toPostView(
|
|
356
|
+
makePostWithMedia({
|
|
357
|
+
title: null,
|
|
358
|
+
body,
|
|
359
|
+
bodyHtml: `<p>${"A".repeat(1400)}</p><p>${"B".repeat(150)}</p>`,
|
|
360
|
+
}),
|
|
361
|
+
EMPTY_CTX,
|
|
362
|
+
);
|
|
363
|
+
expect(view.summaryHtml).toBeUndefined();
|
|
364
|
+
expect(view.summaryHasMore).toBeUndefined();
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("honors moreBreak on untitled notes regardless of tail size", () => {
|
|
368
|
+
const body = JSON.stringify({
|
|
369
|
+
type: "doc",
|
|
370
|
+
content: [
|
|
371
|
+
{ type: "paragraph", content: [{ type: "text", text: "Lead" }] },
|
|
372
|
+
{ type: "moreBreak" },
|
|
373
|
+
{ type: "paragraph", content: [{ type: "text", text: "tiny" }] },
|
|
374
|
+
],
|
|
375
|
+
});
|
|
376
|
+
const view = toPostView(
|
|
377
|
+
makePostWithMedia({
|
|
378
|
+
title: null,
|
|
379
|
+
body,
|
|
380
|
+
bodyHtml: "<p>Lead</p><!--more--><p>tiny</p>",
|
|
381
|
+
}),
|
|
382
|
+
EMPTY_CTX,
|
|
383
|
+
);
|
|
384
|
+
expect(view.summaryHtml).toBeUndefined();
|
|
385
|
+
expect(view.summaryHasMore).toBe(true);
|
|
386
|
+
expect(view.bodyHtml).toBe(
|
|
387
|
+
"<p>Lead</p><span data-note-break></span><!--more--><p>tiny</p>",
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
|
|
286
391
|
it("does not compute summaryHtml for posts without body", () => {
|
|
287
392
|
const view = toPostView(
|
|
288
393
|
makePostWithMedia({
|
|
@@ -614,6 +719,68 @@ describe("toNavItemView", () => {
|
|
|
614
719
|
expect(view.isActive).toBe(false);
|
|
615
720
|
});
|
|
616
721
|
|
|
722
|
+
it("treats a self-referential absolute URL as an internal link", () => {
|
|
723
|
+
const view = toNavItemView(
|
|
724
|
+
makeNavItem({ url: "https://example.com/about" }),
|
|
725
|
+
"/about",
|
|
726
|
+
"latest",
|
|
727
|
+
false,
|
|
728
|
+
"",
|
|
729
|
+
undefined,
|
|
730
|
+
"https://example.com",
|
|
731
|
+
);
|
|
732
|
+
expect(view.isExternal).toBe(false);
|
|
733
|
+
expect(view.url).toBe("/about");
|
|
734
|
+
expect(view.isActive).toBe(true);
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
it("keeps absolute URLs on other origins external", () => {
|
|
738
|
+
const view = toNavItemView(
|
|
739
|
+
makeNavItem({ url: "https://other.com/about" }),
|
|
740
|
+
"/about",
|
|
741
|
+
"latest",
|
|
742
|
+
false,
|
|
743
|
+
"",
|
|
744
|
+
undefined,
|
|
745
|
+
"https://example.com",
|
|
746
|
+
);
|
|
747
|
+
expect(view.isExternal).toBe(true);
|
|
748
|
+
expect(view.url).toBe("https://other.com/about");
|
|
749
|
+
expect(view.isActive).toBe(false);
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
it("treats a same-host URL as internal despite scheme/port differences", () => {
|
|
753
|
+
// Dev serves over http://host:<port> while the nav stores the canonical
|
|
754
|
+
// https URL — same site to the user, so no external-link affordances.
|
|
755
|
+
const view = toNavItemView(
|
|
756
|
+
makeNavItem({ url: "https://jant.example/about" }),
|
|
757
|
+
"/about",
|
|
758
|
+
"latest",
|
|
759
|
+
false,
|
|
760
|
+
"",
|
|
761
|
+
undefined,
|
|
762
|
+
"http://jant.example:8787",
|
|
763
|
+
);
|
|
764
|
+
expect(view.isExternal).toBe(false);
|
|
765
|
+
expect(view.url).toBe("/about");
|
|
766
|
+
expect(view.isActive).toBe(true);
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
it("normalizes a same-origin absolute URL under a site path prefix", () => {
|
|
770
|
+
const view = toNavItemView(
|
|
771
|
+
makeNavItem({ url: "https://example.com/blog/about" }),
|
|
772
|
+
"/blog/about",
|
|
773
|
+
"latest",
|
|
774
|
+
false,
|
|
775
|
+
"/blog",
|
|
776
|
+
undefined,
|
|
777
|
+
"https://example.com",
|
|
778
|
+
);
|
|
779
|
+
expect(view.isExternal).toBe(false);
|
|
780
|
+
expect(view.url).toBe("/blog/about");
|
|
781
|
+
expect(view.isActive).toBe(true);
|
|
782
|
+
});
|
|
783
|
+
|
|
617
784
|
it("includes type in view", () => {
|
|
618
785
|
const view = toNavItemView(
|
|
619
786
|
makeNavItem({ type: "system", systemKey: "rss", url: "/feed" }),
|
package/src/lib/navigation.ts
CHANGED
|
@@ -174,8 +174,8 @@ export function resolveConfig(
|
|
|
174
174
|
|
|
175
175
|
// Upload (ENV only)
|
|
176
176
|
uploadMaxFileSize:
|
|
177
|
-
parseInt(getEnvString(env, "UPLOAD_MAX_FILE_SIZE_MB") ?? "
|
|
178
|
-
|
|
177
|
+
parseInt(getEnvString(env, "UPLOAD_MAX_FILE_SIZE_MB") ?? "1024", 10) ||
|
|
178
|
+
1024,
|
|
179
179
|
|
|
180
180
|
// Summary extraction (ENV only)
|
|
181
181
|
summaryMaxParagraphs:
|
package/src/lib/summary.ts
CHANGED
|
@@ -214,6 +214,12 @@ export function extractSummary(
|
|
|
214
214
|
* @param bodyJson - Tiptap JSON string
|
|
215
215
|
* @param maxBlocks - Maximum number of top-level blocks to include
|
|
216
216
|
* @param maxChars - Maximum total plain-text character count
|
|
217
|
+
* @param minHiddenChars - Tolerance for limit-based truncation: when > 0 and a
|
|
218
|
+
* block/char limit would hide a tail shorter than this many plain-text
|
|
219
|
+
* characters, the truncation is cancelled — all remaining content blocks are
|
|
220
|
+
* included and `hasMore` is `false`. Avoids a "read more" that reveals only a
|
|
221
|
+
* sliver of text. Explicit `moreBreak` markers reflect author intent and are
|
|
222
|
+
* never subject to this tolerance.
|
|
217
223
|
* @returns HTML summary, whether content was truncated, and the index in
|
|
218
224
|
* `doc.content` where the content after the summary boundary begins, or null.
|
|
219
225
|
* `breakAtIndex` lets callers align the summary with the full-body rendering
|
|
@@ -229,6 +235,7 @@ export function extractSummaryHtml(
|
|
|
229
235
|
bodyJson: string,
|
|
230
236
|
maxBlocks: number = 5,
|
|
231
237
|
maxChars: number = 500,
|
|
238
|
+
minHiddenChars: number = 0,
|
|
232
239
|
): { html: string; hasMore: boolean; breakAtIndex: number } | null {
|
|
233
240
|
let doc: TiptapNode;
|
|
234
241
|
try {
|
|
@@ -265,7 +272,19 @@ export function extractSummaryHtml(
|
|
|
265
272
|
};
|
|
266
273
|
}
|
|
267
274
|
|
|
268
|
-
// No moreBreak — accumulate blocks up to limits
|
|
275
|
+
// No moreBreak — accumulate blocks up to limits.
|
|
276
|
+
// Pre-extract plain text per content node so the tolerance check below can
|
|
277
|
+
// measure the hidden tail without a second extraction pass.
|
|
278
|
+
const contentText = new Map<number, string>();
|
|
279
|
+
let totalContentChars = 0;
|
|
280
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
281
|
+
const node = nodes[i];
|
|
282
|
+
if (!node || !SUMMARY_BLOCK_TYPES.has(node.type)) continue;
|
|
283
|
+
const text = extractPlainText(node).trim();
|
|
284
|
+
contentText.set(i, text);
|
|
285
|
+
totalContentChars += text.length;
|
|
286
|
+
}
|
|
287
|
+
|
|
269
288
|
const selected: TiptapNode[] = [];
|
|
270
289
|
let totalChars = 0;
|
|
271
290
|
let lastSelectedIdx = -1;
|
|
@@ -274,7 +293,7 @@ export function extractSummaryHtml(
|
|
|
274
293
|
const node = nodes[i];
|
|
275
294
|
if (!node || !SUMMARY_BLOCK_TYPES.has(node.type)) continue;
|
|
276
295
|
|
|
277
|
-
const text =
|
|
296
|
+
const text = contentText.get(i) ?? "";
|
|
278
297
|
if (
|
|
279
298
|
(selected.length >= maxBlocks || totalChars + text.length > maxChars) &&
|
|
280
299
|
selected.length > 0
|
|
@@ -288,13 +307,33 @@ export function extractSummaryHtml(
|
|
|
288
307
|
|
|
289
308
|
if (selected.length === 0) return null;
|
|
290
309
|
|
|
310
|
+
let hasMore = selected.length < totalContentNodes;
|
|
311
|
+
|
|
312
|
+
// Tolerance: don't truncate just to hide a tiny tail. When a block/char limit
|
|
313
|
+
// triggered the cut and the hidden content is shorter than `minHiddenChars`,
|
|
314
|
+
// include the remaining content blocks instead. `moreBreak` is handled above
|
|
315
|
+
// and never reaches this path.
|
|
316
|
+
if (
|
|
317
|
+
hasMore &&
|
|
318
|
+
minHiddenChars > 0 &&
|
|
319
|
+
totalContentChars - totalChars < minHiddenChars
|
|
320
|
+
) {
|
|
321
|
+
for (let i = lastSelectedIdx + 1; i < nodes.length; i++) {
|
|
322
|
+
const node = nodes[i];
|
|
323
|
+
if (!node || !SUMMARY_BLOCK_TYPES.has(node.type)) continue;
|
|
324
|
+
selected.push(node);
|
|
325
|
+
lastSelectedIdx = i;
|
|
326
|
+
}
|
|
327
|
+
hasMore = false;
|
|
328
|
+
}
|
|
329
|
+
|
|
291
330
|
const subDoc: JSONContent = {
|
|
292
331
|
type: "doc",
|
|
293
332
|
content: selected as JSONContent[],
|
|
294
333
|
};
|
|
295
334
|
return {
|
|
296
335
|
html: renderTiptapDocument(subDoc),
|
|
297
|
-
hasMore
|
|
336
|
+
hasMore,
|
|
298
337
|
breakAtIndex: lastSelectedIdx + 1,
|
|
299
338
|
};
|
|
300
339
|
}
|
package/src/lib/tiptap-render.ts
CHANGED
|
@@ -113,9 +113,13 @@ function renderSidenoteReference(
|
|
|
113
113
|
? stripSingleParagraph(context.renderChildren(definitionNode.content))
|
|
114
114
|
: "";
|
|
115
115
|
|
|
116
|
+
// The `footref` / `footref-toggle` classes are not styled by us; they mark the
|
|
117
|
+
// Tufte sidenote trio as an Org-mode-style footnote so HTML-to-Markdown readers
|
|
118
|
+
// (Defuddle, used by Obsidian Web Clipper) recover `[^n]` references instead of
|
|
119
|
+
// silently dropping `span.sidenote`. See docs/internal/markdown-contract.md.
|
|
116
120
|
return (
|
|
117
|
-
`<label for="sn-${escapeHtml(footnoteId)}" class="margin-toggle sidenote-number"></label>` +
|
|
118
|
-
`<input type="checkbox" id="sn-${escapeHtml(footnoteId)}" class="margin-toggle"/>` +
|
|
121
|
+
`<label for="sn-${escapeHtml(footnoteId)}" class="margin-toggle sidenote-number footref"></label>` +
|
|
122
|
+
`<input type="checkbox" id="sn-${escapeHtml(footnoteId)}" class="margin-toggle footref-toggle"/>` +
|
|
119
123
|
`<span class="sidenote">${bodyHtml}</span>`
|
|
120
124
|
);
|
|
121
125
|
}
|
package/src/lib/upload.ts
CHANGED
|
@@ -278,7 +278,7 @@ export interface ValidateUploadOptions {
|
|
|
278
278
|
* @returns null if valid, error message string if invalid
|
|
279
279
|
* @example
|
|
280
280
|
* ```ts
|
|
281
|
-
* const error = validateUploadFile(file, { maxFileSizeMB:
|
|
281
|
+
* const error = validateUploadFile(file, { maxFileSizeMB: 1024 });
|
|
282
282
|
* if (error) return dsToast(error, "error");
|
|
283
283
|
* ```
|
|
284
284
|
*/
|
|
@@ -300,7 +300,7 @@ export function validateUploadFile(
|
|
|
300
300
|
* @returns null if valid, error message string if invalid
|
|
301
301
|
* @example
|
|
302
302
|
* ```ts
|
|
303
|
-
* const error = validateUploadFileMetadata("image/jpeg", 1024000, { maxFileSizeMB:
|
|
303
|
+
* const error = validateUploadFileMetadata("image/jpeg", 1024000, { maxFileSizeMB: 1024 });
|
|
304
304
|
* ```
|
|
305
305
|
*/
|
|
306
306
|
export function validateUploadFileMetadata(
|
package/src/lib/url.ts
CHANGED
|
@@ -92,6 +92,47 @@ export function isFullUrl(str: string): boolean {
|
|
|
92
92
|
return str.startsWith("http://") || str.startsWith("https://");
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* If a full URL points at the site's own host, return its same-site path
|
|
97
|
+
* (`pathname` + `search` + `hash`). Returns `null` when the input is not a full
|
|
98
|
+
* URL, is unparseable, or points at a different host.
|
|
99
|
+
*
|
|
100
|
+
* Self-referential absolute links — e.g. a nav item set to
|
|
101
|
+
* `https://example.com/about` on `example.com` — should behave like the
|
|
102
|
+
* internal path `/about`: no external-link icon, no `target="_blank"`.
|
|
103
|
+
*
|
|
104
|
+
* Matching is by **hostname**, not full origin: scheme and port differences are
|
|
105
|
+
* treated as same-site. This keeps the check intuitive ("same domain") and
|
|
106
|
+
* robust in dev, where the site is often served over `http://host:<port>` while
|
|
107
|
+
* a nav link stores the canonical `https://host` URL. In production (canonical
|
|
108
|
+
* https + default port) hostname match is equivalent to origin match.
|
|
109
|
+
*
|
|
110
|
+
* @param url - Candidate URL (full URL or relative path)
|
|
111
|
+
* @param siteOrigin - The site's own origin, e.g. `https://example.com`
|
|
112
|
+
* @returns The same-site path, or `null` when the URL is external/non-absolute
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* ```ts
|
|
116
|
+
* toSameSitePath("https://example.com/about", "https://example.com"); // "/about"
|
|
117
|
+
* toSameSitePath("https://example.com/about", "http://example.com:8787"); // "/about"
|
|
118
|
+
* toSameSitePath("https://other.com/about", "https://example.com"); // null
|
|
119
|
+
* toSameSitePath("/about", "https://example.com"); // null
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
export function toSameSitePath(url: string, siteOrigin: string): string | null {
|
|
123
|
+
if (!siteOrigin || !isFullUrl(url)) return null;
|
|
124
|
+
let parsed: URL;
|
|
125
|
+
let reference: URL;
|
|
126
|
+
try {
|
|
127
|
+
parsed = new URL(url);
|
|
128
|
+
reference = new URL(siteOrigin);
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
if (parsed.hostname !== reference.hostname) return null;
|
|
133
|
+
return `${parsed.pathname}${parsed.search}${parsed.hash}` || "/";
|
|
134
|
+
}
|
|
135
|
+
|
|
95
136
|
/**
|
|
96
137
|
* Converts text to a URL-friendly slug.
|
|
97
138
|
*
|
package/src/lib/view.ts
CHANGED
|
@@ -38,7 +38,7 @@ import { getMediaUrl, getImageUrl, getPublicUrlForProvider } from "./image.js";
|
|
|
38
38
|
import { extractSummaryHtml, extractBodyText } from "./summary.js";
|
|
39
39
|
import { renderTiptapDocument } from "./tiptap-render.js";
|
|
40
40
|
import { highlightText } from "./search-snippet.js";
|
|
41
|
-
import { toPublicPath } from "./url.js";
|
|
41
|
+
import { isFullUrl, toPublicPath, toSameSitePath } from "./url.js";
|
|
42
42
|
|
|
43
43
|
// =============================================================================
|
|
44
44
|
// Media Context
|
|
@@ -140,6 +140,59 @@ export function toMediaView(media: Media, ctx: MediaContext): MediaView {
|
|
|
140
140
|
// Post Conversions
|
|
141
141
|
// =============================================================================
|
|
142
142
|
|
|
143
|
+
/** Feed summary limits for titled, article-style posts (the excerpt is a teaser). */
|
|
144
|
+
const ARTICLE_SUMMARY_MAX_BLOCKS = 5;
|
|
145
|
+
const ARTICLE_SUMMARY_MAX_CHARS = 500;
|
|
146
|
+
/** Larger feed summary limits for untitled notes — the body itself is the content. */
|
|
147
|
+
const NOTE_SUMMARY_MAX_BLOCKS = 10;
|
|
148
|
+
const NOTE_SUMMARY_MAX_CHARS = 1500;
|
|
149
|
+
/** Don't truncate an untitled note just to hide a tail under this many chars. */
|
|
150
|
+
const NOTE_SUMMARY_MIN_HIDDEN_CHARS = 200;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Splice a zero-width marker into rendered body HTML at a summary boundary.
|
|
154
|
+
*
|
|
155
|
+
* The summary HTML is not a byte-prefix of `bodyHtml` — structural nodes
|
|
156
|
+
* (horizontalRule, moreBreak, image) appear in `bodyHtml` but are excluded from
|
|
157
|
+
* the summary, so slicing `bodyHtml` by summary length lands mid-tag and
|
|
158
|
+
* corrupts the markup. Instead we render the pre-boundary doc slice and splice
|
|
159
|
+
* the marker at that exact block boundary.
|
|
160
|
+
*
|
|
161
|
+
* @param bodyJson - Tiptap JSON string for the post body
|
|
162
|
+
* @param bodyHtml - Rendered body HTML to splice the marker into
|
|
163
|
+
* @param breakAtIndex - Index in `doc.content` where the post-summary content begins
|
|
164
|
+
* @param markerHtml - Inert marker to insert at the boundary (e.g. an anchor span)
|
|
165
|
+
* @returns `bodyHtml` with the marker inserted, or null when the split can't be
|
|
166
|
+
* computed safely (caller should fall back to the untouched body)
|
|
167
|
+
*/
|
|
168
|
+
function spliceAtSummaryBoundary(
|
|
169
|
+
bodyJson: string,
|
|
170
|
+
bodyHtml: string,
|
|
171
|
+
breakAtIndex: number,
|
|
172
|
+
markerHtml: string,
|
|
173
|
+
): string | null {
|
|
174
|
+
try {
|
|
175
|
+
const doc = JSON.parse(bodyJson) as { type?: string; content?: unknown[] };
|
|
176
|
+
if (
|
|
177
|
+
doc.type !== "doc" ||
|
|
178
|
+
!Array.isArray(doc.content) ||
|
|
179
|
+
breakAtIndex <= 0 ||
|
|
180
|
+
breakAtIndex > doc.content.length
|
|
181
|
+
) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
const beforeHtml = renderTiptapDocument({
|
|
185
|
+
type: "doc",
|
|
186
|
+
content: doc.content.slice(0, breakAtIndex) as never[],
|
|
187
|
+
});
|
|
188
|
+
if (!bodyHtml.startsWith(beforeHtml)) return null;
|
|
189
|
+
return beforeHtml + markerHtml + bodyHtml.slice(beforeHtml.length);
|
|
190
|
+
} catch {
|
|
191
|
+
// Better an untouched body than corrupted markup.
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
143
196
|
function normalizePreviewText(
|
|
144
197
|
text: string | null | undefined,
|
|
145
198
|
): string | undefined {
|
|
@@ -215,49 +268,50 @@ export function toPostView(
|
|
|
215
268
|
// Pre-compute excerpt from the unified plain-text summary.
|
|
216
269
|
const excerpt = clipPreviewText(summary, 160);
|
|
217
270
|
|
|
218
|
-
// Pre-compute
|
|
271
|
+
// Pre-compute feed/list truncation. The two formats differ:
|
|
272
|
+
//
|
|
273
|
+
// - Titled (article-style) posts get an excerpt teaser (`summaryHtml`) and a
|
|
274
|
+
// "Continue" link to the full page; a `#continue` anchor is spliced into the
|
|
275
|
+
// body for scroll targeting on that page.
|
|
276
|
+
// - Untitled notes render their body in full and expand in place. When the
|
|
277
|
+
// body is long enough to truncate (larger limit + tolerance guard) we splice
|
|
278
|
+
// a `data-note-break` marker at the boundary so the feed can clamp the tail
|
|
279
|
+
// with CSS and reveal it on click — no excerpt, no extra fetch. We do NOT
|
|
280
|
+
// set `summaryHtml` for notes (the card renders the full marked body), and
|
|
281
|
+
// only flag `summaryHasMore` when the split actually succeeds.
|
|
219
282
|
let summaryHtml: string | undefined;
|
|
220
283
|
let summaryHasMore: boolean | undefined;
|
|
221
284
|
let bodyHtmlWithAnchor = post.bodyHtml;
|
|
222
|
-
if (post.
|
|
223
|
-
const
|
|
224
|
-
|
|
285
|
+
if (post.body) {
|
|
286
|
+
const isArticle = !!post.title;
|
|
287
|
+
const result = extractSummaryHtml(
|
|
288
|
+
post.body,
|
|
289
|
+
isArticle ? ARTICLE_SUMMARY_MAX_BLOCKS : NOTE_SUMMARY_MAX_BLOCKS,
|
|
290
|
+
isArticle ? ARTICLE_SUMMARY_MAX_CHARS : NOTE_SUMMARY_MAX_CHARS,
|
|
291
|
+
isArticle ? 0 : NOTE_SUMMARY_MIN_HIDDEN_CHARS,
|
|
292
|
+
);
|
|
293
|
+
if (result && isArticle) {
|
|
225
294
|
summaryHtml = result.html;
|
|
226
295
|
summaryHasMore = result.hasMore;
|
|
227
|
-
|
|
228
|
-
// Inject #continue anchor at the excerpt boundary for scroll targeting.
|
|
229
|
-
// The summary HTML is NOT a byte-prefix of bodyHtml — structural nodes
|
|
230
|
-
// like `horizontalRule` and `moreBreak` appear in bodyHtml but are
|
|
231
|
-
// excluded from the summary, so slicing bodyHtml by summary.length lands
|
|
232
|
-
// mid-tag and corrupts the markup. Instead, render the pre-boundary
|
|
233
|
-
// doc slice and splice the anchor at that exact block boundary.
|
|
234
296
|
if (result.hasMore && post.bodyHtml) {
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
'<span id="continue"></span>' +
|
|
254
|
-
post.bodyHtml.slice(beforeHtml.length);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
} catch {
|
|
258
|
-
// Fallback: leave bodyHtml untouched if the split can't be computed
|
|
259
|
-
// safely. Better no anchor than a broken document.
|
|
260
|
-
}
|
|
297
|
+
const spliced = spliceAtSummaryBoundary(
|
|
298
|
+
post.body,
|
|
299
|
+
post.bodyHtml,
|
|
300
|
+
result.breakAtIndex,
|
|
301
|
+
'<span id="continue"></span>',
|
|
302
|
+
);
|
|
303
|
+
if (spliced) bodyHtmlWithAnchor = spliced;
|
|
304
|
+
}
|
|
305
|
+
} else if (result && result.hasMore && post.bodyHtml) {
|
|
306
|
+
const spliced = spliceAtSummaryBoundary(
|
|
307
|
+
post.body,
|
|
308
|
+
post.bodyHtml,
|
|
309
|
+
result.breakAtIndex,
|
|
310
|
+
"<span data-note-break></span>",
|
|
311
|
+
);
|
|
312
|
+
if (spliced) {
|
|
313
|
+
bodyHtmlWithAnchor = spliced;
|
|
314
|
+
summaryHasMore = true;
|
|
261
315
|
}
|
|
262
316
|
}
|
|
263
317
|
}
|
|
@@ -424,6 +478,7 @@ export function toNavItemView(
|
|
|
424
478
|
isAuthenticated = false,
|
|
425
479
|
sitePathPrefix = "",
|
|
426
480
|
collectionFreshness?: Map<string, number>,
|
|
481
|
+
siteOrigin = "",
|
|
427
482
|
): NavItemView {
|
|
428
483
|
let url = item.url;
|
|
429
484
|
let label = item.label;
|
|
@@ -453,8 +508,13 @@ export function toNavItemView(
|
|
|
453
508
|
}
|
|
454
509
|
}
|
|
455
510
|
|
|
456
|
-
|
|
457
|
-
|
|
511
|
+
// A full URL pointing at this site's own origin is really an internal link,
|
|
512
|
+
// so strip it back to a path and skip external-link affordances.
|
|
513
|
+
const sameSitePath = toSameSitePath(url, siteOrigin);
|
|
514
|
+
const isExternal = sameSitePath === null && isFullUrl(url);
|
|
515
|
+
const publicUrl = isExternal
|
|
516
|
+
? url
|
|
517
|
+
: toPublicPath(sameSitePath ?? url, sitePathPrefix);
|
|
458
518
|
|
|
459
519
|
let isActive = false;
|
|
460
520
|
if (!isExternal) {
|
|
@@ -501,6 +561,7 @@ export function toNavItemViews(
|
|
|
501
561
|
isAuthenticated = false,
|
|
502
562
|
sitePathPrefix = "",
|
|
503
563
|
collectionFreshness?: Map<string, number>,
|
|
564
|
+
siteOrigin = "",
|
|
504
565
|
): NavItemView[] {
|
|
505
566
|
return items.map((item) =>
|
|
506
567
|
toNavItemView(
|
|
@@ -510,6 +571,7 @@ export function toNavItemViews(
|
|
|
510
571
|
isAuthenticated,
|
|
511
572
|
sitePathPrefix,
|
|
512
573
|
collectionFreshness,
|
|
574
|
+
siteOrigin,
|
|
513
575
|
),
|
|
514
576
|
);
|
|
515
577
|
}
|
package/src/preset.css
CHANGED
|
@@ -315,9 +315,15 @@ html {
|
|
|
315
315
|
|
|
316
316
|
/* Blockquotes — tinted background card with quote icon */
|
|
317
317
|
blockquote {
|
|
318
|
+
--_blockquote-pad-x: 1rem;
|
|
318
319
|
position: relative;
|
|
319
320
|
margin: 1.4rem 0;
|
|
320
|
-
padding: 1.4rem
|
|
321
|
+
padding: 1.4rem var(--_blockquote-pad-x) 0.75rem;
|
|
322
|
+
/* A footnote referenced inside a quote floats within this padded box, so its
|
|
323
|
+
gutter anchor is inset by the right padding and the note drifts left. Expose
|
|
324
|
+
that inset (same value as the padding, so the two can't drift) — the sidenote
|
|
325
|
+
rule in ui.css subtracts it to pull the note back out to the gutter. */
|
|
326
|
+
--sidenote-anchor-inset: var(--_blockquote-pad-x);
|
|
321
327
|
border: none;
|
|
322
328
|
background: var(--site-blockquote-bg);
|
|
323
329
|
border-radius: 6px;
|
|
@@ -120,6 +120,7 @@ describe("Internal upload admin routes", () => {
|
|
|
120
120
|
await expect(res.json()).resolves.toEqual({
|
|
121
121
|
abortedMultipartUploads: 0,
|
|
122
122
|
deletedSessions: 1,
|
|
123
|
+
deletedOrphanMedia: 0,
|
|
123
124
|
});
|
|
124
125
|
|
|
125
126
|
const remaining = sqlite
|
|
@@ -128,4 +129,71 @@ describe("Internal upload admin routes", () => {
|
|
|
128
129
|
expect(remaining).toBeUndefined();
|
|
129
130
|
expect(storage.files.has(row.tempStorageKey)).toBe(false);
|
|
130
131
|
});
|
|
132
|
+
|
|
133
|
+
it("deletes orphaned media past the grace window and keeps fresh orphans", async () => {
|
|
134
|
+
const storage = createMockStorage();
|
|
135
|
+
const { app, services, sqlite } = createTestApp({
|
|
136
|
+
authenticated: false,
|
|
137
|
+
internalAdminToken: "internal-secret",
|
|
138
|
+
storage,
|
|
139
|
+
});
|
|
140
|
+
app.route("/api/internal/uploads", internalUploadsRoutes);
|
|
141
|
+
|
|
142
|
+
const oldStorageKey = "media/old-orphan.jpg";
|
|
143
|
+
const freshStorageKey = "media/fresh-orphan.jpg";
|
|
144
|
+
await storage.put(oldStorageKey, createFakeWebpBytes(), {
|
|
145
|
+
contentType: "image/jpeg",
|
|
146
|
+
});
|
|
147
|
+
await storage.put(freshStorageKey, createFakeWebpBytes(), {
|
|
148
|
+
contentType: "image/jpeg",
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const oldOrphan = await services.media.create({
|
|
152
|
+
filename: "old.jpg",
|
|
153
|
+
originalName: "old.jpg",
|
|
154
|
+
mimeType: "image/jpeg",
|
|
155
|
+
size: 1024,
|
|
156
|
+
storageKey: oldStorageKey,
|
|
157
|
+
});
|
|
158
|
+
const freshOrphan = await services.media.create({
|
|
159
|
+
filename: "fresh.jpg",
|
|
160
|
+
originalName: "fresh.jpg",
|
|
161
|
+
mimeType: "image/jpeg",
|
|
162
|
+
size: 1024,
|
|
163
|
+
storageKey: freshStorageKey,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Backdate the first orphan beyond the 7-day grace window.
|
|
167
|
+
sqlite
|
|
168
|
+
.prepare("update media set created_at = 0 where id = ?")
|
|
169
|
+
.run(oldOrphan.id);
|
|
170
|
+
|
|
171
|
+
const res = await app.request("/api/internal/uploads/cleanup", {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: {
|
|
174
|
+
Authorization: "Bearer internal-secret",
|
|
175
|
+
"Content-Type": "application/json",
|
|
176
|
+
},
|
|
177
|
+
body: JSON.stringify({ limit: 10 }),
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(res.status).toBe(200);
|
|
181
|
+
await expect(res.json()).resolves.toEqual({
|
|
182
|
+
abortedMultipartUploads: 0,
|
|
183
|
+
deletedSessions: 0,
|
|
184
|
+
deletedOrphanMedia: 1,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Old orphan: DB row and storage object both gone.
|
|
188
|
+
expect(
|
|
189
|
+
sqlite.prepare("select id from media where id = ?").get(oldOrphan.id),
|
|
190
|
+
).toBeUndefined();
|
|
191
|
+
expect(storage.files.has(oldStorageKey)).toBe(false);
|
|
192
|
+
|
|
193
|
+
// Fresh orphan: untouched.
|
|
194
|
+
expect(
|
|
195
|
+
sqlite.prepare("select id from media where id = ?").get(freshOrphan.id),
|
|
196
|
+
).toBeDefined();
|
|
197
|
+
expect(storage.files.has(freshStorageKey)).toBe(true);
|
|
198
|
+
});
|
|
131
199
|
});
|