@jant/core 0.6.7 → 0.6.9

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 (131) hide show
  1. package/bin/commands/uploads/cleanup.js +2 -0
  2. package/dist/{app-L1UPUArB.js → app-C-jxWmAV.js} +12421 -12033
  3. package/dist/app-DqHzOwL5.js +6 -0
  4. package/dist/client/.vite/manifest.json +3 -3
  5. package/dist/client/_assets/client-CGf2m3qp.css +2 -0
  6. package/dist/client/_assets/{client-B0MvB2r0.js → client-DWy1LEEk.js} +2 -2
  7. package/dist/client/_assets/{client-auth-CwwuucF_.js → client-auth-Blg-a5Ep.js} +365 -345
  8. package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
  9. package/dist/{export-DLukCOO3.js → export-C2DIB7mm.js} +34 -9
  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-BeDecPen.js → github-sync-7XQ5ZM6z.js} +3 -3
  13. package/dist/{github-sync-BtHY2AST.js → github-sync-BEFCfLKK.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/__tests__/jant-settings-avatar.test.ts +5 -2
  29. package/src/client/components/__tests__/jant-settings-general.test.ts +55 -8
  30. package/src/client/components/compose-format-convert.ts +255 -0
  31. package/src/client/components/compose-types.ts +2 -0
  32. package/src/client/components/jant-compose-dialog.ts +110 -44
  33. package/src/client/components/jant-compose-editor.ts +64 -11
  34. package/src/client/components/jant-settings-general.ts +56 -18
  35. package/src/client/components/settings-types.ts +11 -0
  36. package/src/client/compose-bridge.ts +17 -0
  37. package/src/client/feed-video-player.ts +1 -1
  38. package/src/client/hydrate-partial.ts +25 -0
  39. package/src/client/note-expand.ts +63 -0
  40. package/src/client/settings-bridge.ts +3 -0
  41. package/src/client/tiptap/__tests__/mark-exit.test.ts +99 -0
  42. package/src/client/tiptap/bubble-menu.ts +37 -4
  43. package/src/client.ts +1 -0
  44. package/src/db/migrations/0026_absent_rhodey.sql +14 -0
  45. package/src/db/migrations/meta/0026_snapshot.json +2511 -0
  46. package/src/db/migrations/meta/_journal.json +7 -0
  47. package/src/db/migrations/pg/0024_high_violations.sql +14 -0
  48. package/src/db/migrations/pg/meta/0024_snapshot.json +3204 -0
  49. package/src/db/migrations/pg/meta/_journal.json +7 -0
  50. package/src/db/pg/schema.ts +36 -0
  51. package/src/db/schema.ts +36 -0
  52. package/src/i18n/__tests__/middleware.test.ts +46 -0
  53. package/src/i18n/locales/public/en.po +41 -0
  54. package/src/i18n/locales/public/en.ts +1 -1
  55. package/src/i18n/locales/public/zh-Hans.po +41 -0
  56. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  57. package/src/i18n/locales/public/zh-Hant.po +41 -0
  58. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  59. package/src/i18n/locales/settings/en.po +37 -22
  60. package/src/i18n/locales/settings/en.ts +1 -1
  61. package/src/i18n/locales/settings/zh-Hans.po +37 -22
  62. package/src/i18n/locales/settings/zh-Hans.ts +1 -1
  63. package/src/i18n/locales/settings/zh-Hant.po +37 -22
  64. package/src/i18n/locales/settings/zh-Hant.ts +1 -1
  65. package/src/i18n/middleware.ts +17 -8
  66. package/src/i18n/supported-locales.ts +5 -4
  67. package/src/lib/__tests__/markdown-to-tiptap.test.ts +1 -1
  68. package/src/lib/__tests__/markdown.test.ts +1 -1
  69. package/src/lib/__tests__/summary.test.ts +87 -0
  70. package/src/lib/__tests__/timeline.test.ts +48 -1
  71. package/src/lib/__tests__/tiptap-render.test.ts +4 -4
  72. package/src/lib/__tests__/url.test.ts +44 -0
  73. package/src/lib/__tests__/view.test.ts +168 -1
  74. package/src/lib/ids.ts +1 -0
  75. package/src/lib/navigation.ts +1 -0
  76. package/src/lib/resolve-config.ts +3 -2
  77. package/src/lib/summary.ts +42 -3
  78. package/src/lib/tiptap-render.ts +6 -2
  79. package/src/lib/upload.ts +16 -2
  80. package/src/lib/url.ts +41 -0
  81. package/src/lib/view.ts +102 -40
  82. package/src/preset.css +7 -1
  83. package/src/routes/api/__tests__/settings.test.ts +1 -4
  84. package/src/routes/api/__tests__/upload.test.ts +2 -0
  85. package/src/routes/api/internal/__tests__/uploads.test.ts +86 -0
  86. package/src/routes/api/internal/sites.ts +44 -1
  87. package/src/routes/api/public/__tests__/archive.test.ts +66 -0
  88. package/src/routes/api/public/archive.ts +22 -6
  89. package/src/routes/api/settings.ts +2 -1
  90. package/src/routes/api/telegram.ts +2 -1
  91. package/src/routes/auth/__tests__/setup.test.ts +14 -0
  92. package/src/routes/dash/__tests__/settings-avatar.test.ts +35 -17
  93. package/src/routes/dash/custom-urls.tsx +1 -1
  94. package/src/routes/dash/settings.tsx +23 -7
  95. package/src/routes/pages/__tests__/archive-params.test.ts +135 -0
  96. package/src/routes/pages/archive.tsx +116 -20
  97. package/src/routes/pages/collections.tsx +1 -0
  98. package/src/services/__tests__/media.test.ts +274 -30
  99. package/src/services/__tests__/post.test.ts +81 -0
  100. package/src/services/__tests__/settings.test.ts +55 -0
  101. package/src/services/bootstrap.ts +7 -0
  102. package/src/services/export-theme/assets/client-site.js +1 -1
  103. package/src/services/export-theme/layouts/_default/baseof.html +2 -1
  104. package/src/services/export-theme/styles/main.css +49 -15
  105. package/src/services/media.ts +199 -42
  106. package/src/services/post.ts +22 -2
  107. package/src/services/search.ts +4 -4
  108. package/src/services/settings.ts +49 -15
  109. package/src/services/upload-session.ts +28 -0
  110. package/src/styles/tokens.css +7 -5
  111. package/src/styles/ui.css +163 -34
  112. package/src/types/bindings.ts +1 -0
  113. package/src/types/config.ts +14 -1
  114. package/src/types/props.ts +3 -0
  115. package/src/ui/compose/ComposeDialog.tsx +13 -0
  116. package/src/ui/dash/settings/AccountMenuContent.tsx +0 -39
  117. package/src/ui/dash/settings/GeneralContent.tsx +38 -4
  118. package/src/ui/dash/settings/SettingsDirectory.tsx +26 -1
  119. package/src/ui/dash/settings/SettingsRootContent.tsx +46 -1
  120. package/src/ui/dash/settings/__tests__/SettingsRootContent.test.tsx +55 -0
  121. package/src/ui/feed/NoteCard.tsx +54 -5
  122. package/src/ui/feed/__tests__/timeline-cards.test.ts +73 -0
  123. package/src/ui/layouts/BaseLayout.tsx +1 -0
  124. package/src/ui/layouts/__tests__/BaseLayout.test.tsx +13 -0
  125. package/src/ui/pages/ArchivePage.tsx +89 -6
  126. package/src/ui/pages/CollectionsPage.tsx +7 -1
  127. package/src/ui/pages/__tests__/ArchivePage.test.tsx +37 -0
  128. package/src/ui/shared/CollectionDirectory.tsx +13 -3
  129. package/src/ui/shared/CollectionsManager.tsx +3 -0
  130. package/dist/app-C1QgMNRY.js +0 -6
  131. package/dist/client/_assets/client-BMPMuwvV.css +0 -2
@@ -7,6 +7,7 @@ import {
7
7
  normalizePath,
8
8
  stripSitePathPrefix,
9
9
  slugify,
10
+ toSameSitePath,
10
11
  } from "../url.js";
11
12
 
12
13
  describe("extractDomain", () => {
@@ -143,6 +144,49 @@ describe("isFullUrl", () => {
143
144
  });
144
145
  });
145
146
 
147
+ describe("toSameSitePath", () => {
148
+ it("returns the path for a same-host absolute URL", () => {
149
+ expect(
150
+ toSameSitePath("https://example.com/about", "https://example.com"),
151
+ ).toBe("/about");
152
+ });
153
+
154
+ it("preserves query and hash", () => {
155
+ expect(
156
+ toSameSitePath(
157
+ "https://example.com/about?x=1#top",
158
+ "https://example.com",
159
+ ),
160
+ ).toBe("/about?x=1#top");
161
+ });
162
+
163
+ it("ignores scheme and port differences (same host)", () => {
164
+ expect(
165
+ toSameSitePath("https://example.com/about", "http://example.com:8787"),
166
+ ).toBe("/about");
167
+ });
168
+
169
+ it("returns / for the bare same-host origin", () => {
170
+ expect(toSameSitePath("https://example.com", "https://example.com")).toBe(
171
+ "/",
172
+ );
173
+ });
174
+
175
+ it("returns null for a different host", () => {
176
+ expect(
177
+ toSameSitePath("https://other.com/about", "https://example.com"),
178
+ ).toBeNull();
179
+ });
180
+
181
+ it("returns null for relative paths", () => {
182
+ expect(toSameSitePath("/about", "https://example.com")).toBeNull();
183
+ });
184
+
185
+ it("returns null when no site origin is configured", () => {
186
+ expect(toSameSitePath("https://example.com/about", "")).toBeNull();
187
+ });
188
+ });
189
+
146
190
  describe("isSafeAbsoluteUrl", () => {
147
191
  it("accepts http and https URLs", () => {
148
192
  expect(isSafeAbsoluteUrl("https://example.com")).toBe(true);
@@ -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" }),
package/src/lib/ids.ts CHANGED
@@ -19,6 +19,7 @@ export const ID_PREFIX = {
19
19
  telegramBinding: "tgb",
20
20
  telegramBindingCode: "tgc",
21
21
  telegramMediaGroupItem: "tmg",
22
+ storagePurge: "spg",
22
23
  } as const;
23
24
 
24
25
  export type IdPrefix = (typeof ID_PREFIX)[keyof typeof ID_PREFIX];
@@ -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)
@@ -142,6 +142,7 @@ export function resolveConfig(
142
142
  siteDescription: resolve("SITE_DESCRIPTION", allSettings, env),
143
143
  siteDescriptionExplicit,
144
144
  siteLanguage: resolve("SITE_LANGUAGE", allSettings, env),
145
+ dashboardLanguage: resolve("DASHBOARD_LANGUAGE", allSettings, env),
145
146
  cjkSerifFont: resolve("CJK_SERIF_FONT", allSettings, env),
146
147
  homeDefaultView: resolve("HOME_DEFAULT_VIEW", allSettings, env),
147
148
  mainRssFeed: resolve("MAIN_RSS_FEED", allSettings, env),
@@ -174,8 +175,8 @@ export function resolveConfig(
174
175
 
175
176
  // Upload (ENV only)
176
177
  uploadMaxFileSize:
177
- parseInt(getEnvString(env, "UPLOAD_MAX_FILE_SIZE_MB") ?? "500", 10) ||
178
- 500,
178
+ parseInt(getEnvString(env, "UPLOAD_MAX_FILE_SIZE_MB") ?? "1024", 10) ||
179
+ 1024,
179
180
 
180
181
  // Summary extraction (ENV only)
181
182
  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
@@ -13,6 +13,20 @@ const MEDIA_POSTERS_STORAGE_PREFIX = "posters";
13
13
  const MEDIA_ASSET_STORAGE_PREFIX = "assets";
14
14
  const MEDIA_PREVIEWS_STORAGE_PREFIX = "previews";
15
15
 
16
+ /**
17
+ * SQL `LIKE` pattern matching site asset objects (avatars, favicons) stored
18
+ * under `media/<siteId>/assets/<kind>/...`.
19
+ *
20
+ * These assets are referenced from site settings (`SITE_AVATAR`,
21
+ * `SITE_FAVICON_*`), not from posts, so they are intentionally persisted with
22
+ * `postId = null`. The orphan-media reaper must exclude them — otherwise it
23
+ * deletes avatars and favicons as if they were abandoned compose uploads.
24
+ *
25
+ * @example
26
+ * media.storageKey LIKE this pattern → it is a site asset, never an orphan.
27
+ */
28
+ export const SITE_ASSET_STORAGE_KEY_LIKE_PATTERN = `%/${MEDIA_ASSET_STORAGE_PREFIX}/%`;
29
+
16
30
  /** MIME types — images */
17
31
  const IMAGE_MIME_TYPES = [
18
32
  "image/jpeg",
@@ -278,7 +292,7 @@ export interface ValidateUploadOptions {
278
292
  * @returns null if valid, error message string if invalid
279
293
  * @example
280
294
  * ```ts
281
- * const error = validateUploadFile(file, { maxFileSizeMB: 500 });
295
+ * const error = validateUploadFile(file, { maxFileSizeMB: 1024 });
282
296
  * if (error) return dsToast(error, "error");
283
297
  * ```
284
298
  */
@@ -300,7 +314,7 @@ export function validateUploadFile(
300
314
  * @returns null if valid, error message string if invalid
301
315
  * @example
302
316
  * ```ts
303
- * const error = validateUploadFileMetadata("image/jpeg", 1024000, { maxFileSizeMB: 500 });
317
+ * const error = validateUploadFileMetadata("image/jpeg", 1024000, { maxFileSizeMB: 1024 });
304
318
  * ```
305
319
  */
306
320
  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
  }