@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.
- package/bin/commands/uploads/cleanup.js +2 -0
- package/dist/{app-L1UPUArB.js → app-C-jxWmAV.js} +12421 -12033
- package/dist/app-DqHzOwL5.js +6 -0
- package/dist/client/.vite/manifest.json +3 -3
- package/dist/client/_assets/client-CGf2m3qp.css +2 -0
- package/dist/client/_assets/{client-B0MvB2r0.js → client-DWy1LEEk.js} +2 -2
- package/dist/client/_assets/{client-auth-CwwuucF_.js → client-auth-Blg-a5Ep.js} +365 -345
- package/dist/{env-CoSe-1y4.js → env-OHRKGcMj.js} +1 -1
- package/dist/{export-DLukCOO3.js → export-C2DIB7mm.js} +34 -9
- 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-BeDecPen.js → github-sync-7XQ5ZM6z.js} +3 -3
- package/dist/{github-sync-BtHY2AST.js → github-sync-BEFCfLKK.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/__tests__/jant-settings-avatar.test.ts +5 -2
- package/src/client/components/__tests__/jant-settings-general.test.ts +55 -8
- 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 +110 -44
- package/src/client/components/jant-compose-editor.ts +64 -11
- package/src/client/components/jant-settings-general.ts +56 -18
- package/src/client/components/settings-types.ts +11 -0
- 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/settings-bridge.ts +3 -0
- package/src/client/tiptap/__tests__/mark-exit.test.ts +99 -0
- package/src/client/tiptap/bubble-menu.ts +37 -4
- package/src/client.ts +1 -0
- package/src/db/migrations/0026_absent_rhodey.sql +14 -0
- package/src/db/migrations/meta/0026_snapshot.json +2511 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations/pg/0024_high_violations.sql +14 -0
- package/src/db/migrations/pg/meta/0024_snapshot.json +3204 -0
- package/src/db/migrations/pg/meta/_journal.json +7 -0
- package/src/db/pg/schema.ts +36 -0
- package/src/db/schema.ts +36 -0
- package/src/i18n/__tests__/middleware.test.ts +46 -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 +37 -22
- package/src/i18n/locales/settings/en.ts +1 -1
- package/src/i18n/locales/settings/zh-Hans.po +37 -22
- package/src/i18n/locales/settings/zh-Hans.ts +1 -1
- package/src/i18n/locales/settings/zh-Hant.po +37 -22
- package/src/i18n/locales/settings/zh-Hant.ts +1 -1
- package/src/i18n/middleware.ts +17 -8
- package/src/i18n/supported-locales.ts +5 -4
- 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/ids.ts +1 -0
- package/src/lib/navigation.ts +1 -0
- package/src/lib/resolve-config.ts +3 -2
- package/src/lib/summary.ts +42 -3
- package/src/lib/tiptap-render.ts +6 -2
- package/src/lib/upload.ts +16 -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/__tests__/settings.test.ts +1 -4
- package/src/routes/api/__tests__/upload.test.ts +2 -0
- package/src/routes/api/internal/__tests__/uploads.test.ts +86 -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/settings.ts +2 -1
- package/src/routes/api/telegram.ts +2 -1
- package/src/routes/auth/__tests__/setup.test.ts +14 -0
- package/src/routes/dash/__tests__/settings-avatar.test.ts +35 -17
- package/src/routes/dash/custom-urls.tsx +1 -1
- package/src/routes/dash/settings.tsx +23 -7
- 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 +274 -30
- package/src/services/__tests__/post.test.ts +81 -0
- package/src/services/__tests__/settings.test.ts +55 -0
- package/src/services/bootstrap.ts +7 -0
- package/src/services/export-theme/assets/client-site.js +1 -1
- package/src/services/export-theme/layouts/_default/baseof.html +2 -1
- package/src/services/export-theme/styles/main.css +49 -15
- package/src/services/media.ts +199 -42
- package/src/services/post.ts +22 -2
- package/src/services/search.ts +4 -4
- package/src/services/settings.ts +49 -15
- package/src/services/upload-session.ts +28 -0
- package/src/styles/tokens.css +7 -5
- package/src/styles/ui.css +163 -34
- package/src/types/bindings.ts +1 -0
- package/src/types/config.ts +14 -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/GeneralContent.tsx +38 -4
- 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/layouts/BaseLayout.tsx +1 -0
- package/src/ui/layouts/__tests__/BaseLayout.test.tsx +13 -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
|
@@ -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
|
|
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
package/src/lib/navigation.ts
CHANGED
|
@@ -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") ?? "
|
|
178
|
-
|
|
178
|
+
parseInt(getEnvString(env, "UPLOAD_MAX_FILE_SIZE_MB") ?? "1024", 10) ||
|
|
179
|
+
1024,
|
|
179
180
|
|
|
180
181
|
// Summary extraction (ENV only)
|
|
181
182
|
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
|
@@ -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:
|
|
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:
|
|
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
|
|
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
|
}
|