@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.
Files changed (99) hide show
  1. package/bin/commands/uploads/cleanup.js +1 -0
  2. package/dist/{app-L1UPUArB.js → app-9P4rVCe2.js} +338 -117
  3. package/dist/app-DaxS_Cz-.js +6 -0
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-C6peCkkD.css +2 -0
  6. package/dist/client/_assets/{client-B0MvB2r0.js → client-CXnEhyyv.js} +2 -2
  7. package/dist/client/_assets/{client-auth-CwwuucF_.js → client-auth-CSItbyU8.js} +357 -355
  8. package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
  9. package/dist/{export-DLukCOO3.js → export-Be082J0n.js} +33 -8
  10. package/dist/{github-api-UD4u_7fa.js → github-api-BgSiE71w.js} +1 -1
  11. package/dist/{github-app-DeX6Td1O.js → github-app-BbklkFmU.js} +1 -1
  12. package/dist/{github-sync-BtHY2AST.js → github-sync-D1Cw8mOY.js} +3 -3
  13. package/dist/{github-sync-BeDecPen.js → github-sync-_kPWM4m9.js} +3 -3
  14. package/dist/index.js +5 -5
  15. package/dist/node.js +6 -6
  16. package/dist/{url-XF0GbKGO.js → url-BMYO-Zlt.js} +42 -2
  17. package/package.json +1 -1
  18. package/src/__tests__/bin/uploads-cleanup.test.ts +2 -0
  19. package/src/client/__tests__/compose-bridge.test.ts +105 -0
  20. package/src/client/__tests__/hydrate-partial.test.ts +27 -0
  21. package/src/client/__tests__/note-expand.test.ts +130 -0
  22. package/src/client/archive-nav.js +2 -1
  23. package/src/client/audio-player.ts +7 -3
  24. package/src/client/components/__tests__/compose-format-convert.test.ts +357 -0
  25. package/src/client/components/__tests__/jant-compose-dialog.test.ts +313 -0
  26. package/src/client/components/__tests__/jant-compose-editor.test.ts +2 -0
  27. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +2 -0
  28. package/src/client/components/compose-format-convert.ts +255 -0
  29. package/src/client/components/compose-types.ts +2 -0
  30. package/src/client/components/jant-compose-dialog.ts +98 -44
  31. package/src/client/components/jant-compose-editor.ts +64 -11
  32. package/src/client/compose-bridge.ts +17 -0
  33. package/src/client/feed-video-player.ts +1 -1
  34. package/src/client/hydrate-partial.ts +25 -0
  35. package/src/client/note-expand.ts +63 -0
  36. package/src/client.ts +1 -0
  37. package/src/i18n/locales/public/en.po +41 -0
  38. package/src/i18n/locales/public/en.ts +1 -1
  39. package/src/i18n/locales/public/zh-Hans.po +41 -0
  40. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  41. package/src/i18n/locales/public/zh-Hant.po +41 -0
  42. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  43. package/src/i18n/locales/settings/en.po +12 -12
  44. package/src/i18n/locales/settings/en.ts +1 -1
  45. package/src/i18n/locales/settings/zh-Hans.po +12 -12
  46. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  47. package/src/i18n/locales/settings/zh-Hant.po +12 -12
  48. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  49. package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
  50. package/src/lib/__tests__/markdown.test.ts +1 -1
  51. package/src/lib/__tests__/summary.test.ts +87 -0
  52. package/src/lib/__tests__/timeline.test.ts +48 -1
  53. package/src/lib/__tests__/tiptap-render.test.ts +4 -4
  54. package/src/lib/__tests__/url.test.ts +44 -0
  55. package/src/lib/__tests__/view.test.ts +168 -1
  56. package/src/lib/navigation.ts +1 -0
  57. package/src/lib/resolve-config.ts +2 -2
  58. package/src/lib/summary.ts +42 -3
  59. package/src/lib/tiptap-render.ts +6 -2
  60. package/src/lib/upload.ts +2 -2
  61. package/src/lib/url.ts +41 -0
  62. package/src/lib/view.ts +102 -40
  63. package/src/preset.css +7 -1
  64. package/src/routes/api/internal/__tests__/uploads.test.ts +68 -0
  65. package/src/routes/api/internal/sites.ts +44 -1
  66. package/src/routes/api/public/__tests__/archive.test.ts +66 -0
  67. package/src/routes/api/public/archive.ts +22 -6
  68. package/src/routes/api/telegram.ts +2 -1
  69. package/src/routes/dash/custom-urls.tsx +1 -1
  70. package/src/routes/dash/settings.tsx +8 -5
  71. package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
  72. package/src/routes/pages/archive.tsx +116 -20
  73. package/src/routes/pages/collections.tsx +1 -0
  74. package/src/services/__tests__/media.test.ts +83 -0
  75. package/src/services/__tests__/post.test.ts +81 -0
  76. package/src/services/export-theme/assets/client-site.js +1 -1
  77. package/src/services/export-theme/styles/main.css +49 -15
  78. package/src/services/media.ts +31 -1
  79. package/src/services/post.ts +22 -2
  80. package/src/services/search.ts +4 -4
  81. package/src/services/upload-session.ts +18 -0
  82. package/src/styles/tokens.css +1 -1
  83. package/src/styles/ui.css +163 -34
  84. package/src/types/config.ts +1 -1
  85. package/src/types/props.ts +3 -0
  86. package/src/ui/compose/ComposeDialog.tsx +13 -0
  87. package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
  88. package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
  89. package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
  90. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
  91. package/src/ui/feed/NoteCard.tsx +54 -5
  92. package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
  93. package/src/ui/pages/ArchivePage.tsx +89 -6
  94. package/src/ui/pages/CollectionsPage.tsx +7 -1
  95. package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
  96. package/src/ui/shared/CollectionDirectory.tsx +13 -3
  97. package/src/ui/shared/CollectionsManager.tsx +3 -0
  98. package/dist/app-C1QgMNRY.js +0 -6
  99. 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 compute summaryHtml for posts without title", () => {
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" }),
@@ -116,6 +116,7 @@ export async function getNavigationData(
116
116
  isAuthenticated,
117
117
  appConfig.sitePathPrefix,
118
118
  collectionFreshness,
119
+ appConfig.siteOrigin,
119
120
  );
120
121
 
121
122
  // Only load collections when authenticated (for compose dialog)
@@ -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") ?? "500", 10) ||
178
- 500,
177
+ parseInt(getEnvString(env, "UPLOAD_MAX_FILE_SIZE_MB") ?? "1024", 10) ||
178
+ 1024,
179
179
 
180
180
  // Summary extraction (ENV only)
181
181
  summaryMaxParagraphs:
@@ -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 = extractPlainText(node).trim();
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: selected.length < totalContentNodes,
336
+ hasMore,
298
337
  breakAtIndex: lastSelectedIdx + 1,
299
338
  };
300
339
  }
@@ -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: 500 });
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: 500 });
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 HTML summary for article-style posts (with title)
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.title && post.body) {
223
- const result = extractSummaryHtml(post.body);
224
- if (result) {
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
- try {
236
- const doc = JSON.parse(post.body) as {
237
- type?: string;
238
- content?: unknown[];
239
- };
240
- if (
241
- doc.type === "doc" &&
242
- Array.isArray(doc.content) &&
243
- result.breakAtIndex > 0 &&
244
- result.breakAtIndex <= doc.content.length
245
- ) {
246
- const beforeHtml = renderTiptapDocument({
247
- type: "doc",
248
- content: doc.content.slice(0, result.breakAtIndex) as never[],
249
- });
250
- if (post.bodyHtml.startsWith(beforeHtml)) {
251
- bodyHtmlWithAnchor =
252
- beforeHtml +
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
- const isExternal = url.startsWith("http://") || url.startsWith("https://");
457
- const publicUrl = isExternal ? url : toPublicPath(url, sitePathPrefix);
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 1rem 0.75rem;
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
  });