@jant/core 0.6.15 → 0.6.16
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/import-site.js +7 -0
- package/dist/app-C75Zc8dD.js +6 -0
- package/dist/{app-C-oi_t8W.js → app-C7_4oab9.js} +480 -196
- package/dist/client/.vite/manifest.json +8 -8
- package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
- package/dist/client/_assets/{client-DxY5BFe8.js → client-Bq0vre8Y.js} +3 -3
- package/dist/client/_assets/client-D3VZyG4L.css +2 -0
- package/dist/client/_assets/{client-auth-CakPESv9.js → client-auth-F2rb9WfF.js} +1015 -977
- package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
- package/dist/{export-CsFx6F_M.js → export-Dl5KCiEs.js} +93 -7
- package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
- package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
- package/dist/{github-sync-ppWXN3jb.js → github-sync-Bqg62IvV.js} +3 -3
- package/dist/{github-sync-Cq1pzzOI.js → github-sync-HdK2EgvJ.js} +3 -3
- package/dist/index.js +5 -5
- package/dist/node.js +6 -6
- package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
- package/package.json +1 -1
- package/src/__tests__/mise-config.test.ts +22 -0
- package/src/client/__tests__/compose-bridge.test.ts +252 -2
- package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
- package/src/client/__tests__/toast.test.ts +65 -1
- package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
- package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
- package/src/client/components/__tests__/jant-compose-dialog.test.ts +2015 -318
- package/src/client/components/__tests__/jant-compose-editor.test.ts +53 -62
- package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +35 -89
- package/src/client/components/compose-format-convert.ts +5 -1
- package/src/client/components/compose-types.ts +47 -3
- package/src/client/components/jant-command-palette.ts +12 -6
- package/src/client/components/jant-compose-dialog.ts +1464 -680
- package/src/client/components/jant-compose-editor.ts +225 -93
- package/src/client/components/jant-compose-fullscreen.ts +3 -5
- package/src/client/components/jant-post-menu.ts +91 -21
- package/src/client/compose-bridge.ts +143 -122
- package/src/client/compose-launch.ts +13 -0
- package/src/client/compose-shortcuts.ts +5 -1
- package/src/client/post-refresh.ts +135 -0
- package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
- package/src/client/tiptap/link-input-rules.ts +4 -4
- package/src/client/toast.ts +130 -38
- package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
- package/src/db/__tests__/thread-activity.test.ts +113 -0
- package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
- package/src/db/migrations/0031_many_ego.sql +3 -0
- package/src/db/migrations/meta/0031_snapshot.json +2562 -0
- package/src/db/migrations/meta/_journal.json +7 -0
- package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
- package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
- package/src/db/migrations/pg/meta/_journal.json +7 -0
- package/src/db/pg/schema.ts +18 -0
- package/src/db/schema.ts +20 -0
- package/src/db/thread-activity.ts +101 -0
- package/src/i18n/locales/public/en.po +89 -21
- package/src/i18n/locales/public/en.ts +1 -1
- package/src/i18n/locales/public/zh-Hans.po +88 -20
- package/src/i18n/locales/public/zh-Hans.ts +1 -1
- package/src/i18n/locales/public/zh-Hant.po +88 -20
- package/src/i18n/locales/public/zh-Hant.ts +1 -1
- package/src/lib/__tests__/feed.test.ts +168 -0
- package/src/lib/__tests__/markdown.test.ts +16 -0
- package/src/lib/__tests__/slug.test.ts +18 -0
- package/src/lib/__tests__/translit.test.ts +87 -0
- package/src/lib/__tests__/url.test.ts +53 -0
- package/src/lib/feed.ts +113 -11
- package/src/lib/hugo-markdown.ts +5 -0
- package/src/lib/link-preview.ts +25 -0
- package/src/lib/markdown-manager.ts +6 -1
- package/src/lib/post-display.ts +42 -9
- package/src/lib/timeline.ts +39 -9
- package/src/lib/tiptap-render.ts +4 -2
- package/src/lib/translit.ts +288 -0
- package/src/lib/url.ts +123 -12
- package/src/lib/view.ts +28 -0
- package/src/routes/api/__tests__/palette.test.ts +44 -0
- package/src/routes/api/posts.ts +13 -9
- package/src/routes/api/public/__tests__/posts.test.ts +38 -0
- package/src/routes/api/public/posts.ts +7 -0
- package/src/routes/compose.tsx +6 -2
- package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
- package/src/routes/pages/__tests__/archive-params.test.ts +292 -0
- package/src/routes/pages/__tests__/featured.test.ts +6 -2
- package/src/routes/pages/__tests__/preview.test.ts +3 -1
- package/src/routes/pages/archive.tsx +65 -17
- package/src/routes/pages/page.tsx +5 -2
- package/src/services/__tests__/collection.test.ts +32 -1
- package/src/services/__tests__/post-timeline.test.ts +142 -11
- package/src/services/__tests__/post.test.ts +277 -0
- package/src/services/collection.ts +8 -10
- package/src/services/export-theme/assets/client-site.css +1 -1
- package/src/services/export.ts +3 -5
- package/src/services/path.ts +4 -1
- package/src/services/post.ts +297 -100
- package/src/services/search.ts +9 -0
- package/src/styles/components.css +0 -14
- package/src/styles/site-media.css +4 -4
- package/src/styles/tokens.css +62 -1
- package/src/styles/ui.css +1363 -1283
- package/src/types/entities.ts +5 -0
- package/src/types/props.ts +12 -0
- package/src/types/views.ts +6 -0
- package/src/ui/compose/ComposeDialog.tsx +17 -17
- package/src/ui/feed/LinkPreview.tsx +2 -7
- package/src/ui/feed/PostStatusBadges.tsx +42 -3
- package/src/ui/pages/ArchivePage.tsx +131 -24
- package/src/ui/pages/__tests__/ArchivePage.test.tsx +63 -0
- package/src/ui/shared/DraftPreviewBar.tsx +25 -9
- package/src/ui/shared/PostFooter.tsx +35 -12
- package/src/ui/shared/custom-icons.ts +5 -0
- package/src/ui/shared/post-article-attributes.ts +6 -0
- package/dist/app-BxCOR3Uc.js +0 -6
- package/dist/client/_assets/client-B_eGKN5p.css +0 -2
package/src/lib/feed.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import type { FeedData, FeedPostView, MediaView, PostView } from "../types.js";
|
|
14
|
+
import { getLinkPreviewProviderLabel } from "./link-preview.js";
|
|
14
15
|
import { extractDisplayDomain } from "./url.js";
|
|
15
16
|
import { getMediaCategory } from "./upload.js";
|
|
16
17
|
|
|
@@ -40,6 +41,49 @@ function escapeCdata(str: string): string {
|
|
|
40
41
|
return str.replaceAll("]]>", "]]]]><![CDATA[>");
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Resolve a URL for use outside the feed document's browser context.
|
|
46
|
+
*
|
|
47
|
+
* Feed readers do not consistently resolve root-relative URLs found inside
|
|
48
|
+
* Atom HTML content or enclosure attributes, so every non-fragment URL must
|
|
49
|
+
* carry its own origin.
|
|
50
|
+
*/
|
|
51
|
+
function toAbsoluteFeedUrl(url: string, siteUrl: string): string {
|
|
52
|
+
const normalizedUrl = url.trim();
|
|
53
|
+
if (!normalizedUrl || normalizedUrl.startsWith("#")) return normalizedUrl;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const baseUrl = siteUrl.endsWith("/") ? siteUrl : `${siteUrl}/`;
|
|
57
|
+
return new URL(normalizedUrl, baseUrl).toString();
|
|
58
|
+
} catch {
|
|
59
|
+
return normalizedUrl;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve navigational and media URL attributes inside trusted post HTML.
|
|
65
|
+
*
|
|
66
|
+
* Fragment-only links stay local to the rendered feed entry so footnotes and
|
|
67
|
+
* other in-entry references continue to work.
|
|
68
|
+
*/
|
|
69
|
+
function absolutizeFeedHtmlUrls(html: string, siteUrl: string): string {
|
|
70
|
+
return html.replaceAll(
|
|
71
|
+
/(\s)(href|poster|src)=(["'])([^"']*)\3/gi,
|
|
72
|
+
(
|
|
73
|
+
match,
|
|
74
|
+
whitespace: string,
|
|
75
|
+
attribute: string,
|
|
76
|
+
quote: string,
|
|
77
|
+
url: string,
|
|
78
|
+
) => {
|
|
79
|
+
const absoluteUrl = toAbsoluteFeedUrl(url, siteUrl);
|
|
80
|
+
return absoluteUrl
|
|
81
|
+
? `${whitespace}${attribute}=${quote}${absoluteUrl}${quote}`
|
|
82
|
+
: match;
|
|
83
|
+
},
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
43
87
|
/**
|
|
44
88
|
* Strip embedded content that is unsafe or unsupported in feed readers.
|
|
45
89
|
*
|
|
@@ -144,6 +188,35 @@ function renderRatingHtml(rating: number): string {
|
|
|
144
188
|
return `<p>${filled}${empty} ${rating}/5</p>`;
|
|
145
189
|
}
|
|
146
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Render a Link post preview as feed-safe HTML.
|
|
193
|
+
*
|
|
194
|
+
* Feed readers commonly strip CSS overlays and embedded players, so video
|
|
195
|
+
* previews use a linked thumbnail plus a visible provider-aware action.
|
|
196
|
+
* Non-video Link previews keep the linked thumbnail without a video label.
|
|
197
|
+
*/
|
|
198
|
+
function renderLinkPreviewForFeed(post: PostView, siteUrl: string): string {
|
|
199
|
+
if (post.format !== "link") return "";
|
|
200
|
+
|
|
201
|
+
const imageUrl = post.previewImageUrl?.trim();
|
|
202
|
+
const linkUrl = post.url?.trim();
|
|
203
|
+
if (!imageUrl || !linkUrl) return "";
|
|
204
|
+
|
|
205
|
+
const isVideo = post.previewKind?.trim().toLowerCase() === "video";
|
|
206
|
+
const providerLabel = getLinkPreviewProviderLabel(post.previewProvider);
|
|
207
|
+
const fallbackAlt = isVideo
|
|
208
|
+
? providerLabel
|
|
209
|
+
? `${providerLabel} video`
|
|
210
|
+
: "Video preview"
|
|
211
|
+
: "Link preview";
|
|
212
|
+
const altText = post.title?.trim() || fallbackAlt;
|
|
213
|
+
const caption = isVideo
|
|
214
|
+
? `<figcaption><a href="${escapeXml(linkUrl)}">▶ ${providerLabel ? `Watch on ${providerLabel}` : "Watch video"}</a></figcaption>`
|
|
215
|
+
: "";
|
|
216
|
+
|
|
217
|
+
return `<figure><a href="${escapeXml(linkUrl)}"><img src="${escapeXml(toAbsoluteFeedUrl(imageUrl, siteUrl))}" alt="${escapeXml(altText)}"/></a>${caption}</figure>`;
|
|
218
|
+
}
|
|
219
|
+
|
|
147
220
|
function formatFeedBytes(bytes: number): string {
|
|
148
221
|
if (bytes < 1024) return `${bytes} B`;
|
|
149
222
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
@@ -203,9 +276,13 @@ function buildAttachmentLinkText(
|
|
|
203
276
|
* and duration metadata when known. Text attachments link to the rendered
|
|
204
277
|
* preview page when a post permalink is available.
|
|
205
278
|
*/
|
|
206
|
-
function renderMediaItem(
|
|
279
|
+
function renderMediaItem(
|
|
280
|
+
item: MediaView,
|
|
281
|
+
siteUrl: string,
|
|
282
|
+
postPermalinkUrl?: string,
|
|
283
|
+
): string {
|
|
207
284
|
const category = getMediaCategory(item.mimeType);
|
|
208
|
-
const url = escapeXml(item.url);
|
|
285
|
+
const url = escapeXml(toAbsoluteFeedUrl(item.url, siteUrl));
|
|
209
286
|
const name = item.originalName ?? "";
|
|
210
287
|
const altText = item.altText ?? "";
|
|
211
288
|
const caption = item.altText?.trim() || "";
|
|
@@ -223,7 +300,10 @@ function renderMediaItem(item: MediaView, postPermalinkUrl?: string): string {
|
|
|
223
300
|
}
|
|
224
301
|
|
|
225
302
|
if (category === "video") {
|
|
226
|
-
const poster =
|
|
303
|
+
const poster = toAbsoluteFeedUrl(
|
|
304
|
+
item.posterUrl || item.thumbnailUrl,
|
|
305
|
+
siteUrl,
|
|
306
|
+
);
|
|
227
307
|
const dims =
|
|
228
308
|
item.width && item.height
|
|
229
309
|
? ` width="${item.width}" height="${item.height}"`
|
|
@@ -272,11 +352,12 @@ function renderMediaItem(item: MediaView, postPermalinkUrl?: string): string {
|
|
|
272
352
|
*/
|
|
273
353
|
function renderMediaForFeed(
|
|
274
354
|
media: MediaView[],
|
|
355
|
+
siteUrl: string,
|
|
275
356
|
postPermalinkUrl?: string,
|
|
276
357
|
): string {
|
|
277
358
|
if (media.length === 0) return "";
|
|
278
359
|
return media
|
|
279
|
-
.map((item) => renderMediaItem(item, postPermalinkUrl))
|
|
360
|
+
.map((item) => renderMediaItem(item, siteUrl, postPermalinkUrl))
|
|
280
361
|
.join("\n");
|
|
281
362
|
}
|
|
282
363
|
|
|
@@ -289,6 +370,7 @@ function renderMediaForFeed(
|
|
|
289
370
|
*/
|
|
290
371
|
function buildSinglePostContent(
|
|
291
372
|
post: PostView,
|
|
373
|
+
siteUrl: string,
|
|
292
374
|
permalinkUrl?: string,
|
|
293
375
|
options: SinglePostContentOptions = {},
|
|
294
376
|
): string {
|
|
@@ -314,11 +396,18 @@ function buildSinglePostContent(
|
|
|
314
396
|
}
|
|
315
397
|
}
|
|
316
398
|
|
|
399
|
+
const linkPreviewHtml = renderLinkPreviewForFeed(post, siteUrl);
|
|
400
|
+
if (linkPreviewHtml) {
|
|
401
|
+
parts.push(linkPreviewHtml);
|
|
402
|
+
}
|
|
403
|
+
|
|
317
404
|
if (post.bodyHtml) {
|
|
318
|
-
parts.push(
|
|
405
|
+
parts.push(
|
|
406
|
+
absolutizeFeedHtmlUrls(stripUnsafeFeedHtml(post.bodyHtml), siteUrl),
|
|
407
|
+
);
|
|
319
408
|
}
|
|
320
409
|
|
|
321
|
-
const mediaHtml = renderMediaForFeed(post.media, permalinkUrl);
|
|
410
|
+
const mediaHtml = renderMediaForFeed(post.media, siteUrl, permalinkUrl);
|
|
322
411
|
if (mediaHtml) {
|
|
323
412
|
parts.push(mediaHtml);
|
|
324
413
|
}
|
|
@@ -353,7 +442,7 @@ function buildFeedContent(
|
|
|
353
442
|
siteUrl: string,
|
|
354
443
|
permalinkUrl?: string,
|
|
355
444
|
): string {
|
|
356
|
-
const rootContent = buildSinglePostContent(post, permalinkUrl);
|
|
445
|
+
const rootContent = buildSinglePostContent(post, siteUrl, permalinkUrl);
|
|
357
446
|
const replies = post.threadReplies;
|
|
358
447
|
|
|
359
448
|
if (!replies || replies.length === 0) {
|
|
@@ -368,7 +457,9 @@ function buildFeedContent(
|
|
|
368
457
|
parts.push(
|
|
369
458
|
`<p><small><time datetime="${escapeXml(reply.publishedAt)}">${escapeXml(reply.publishedAtFormatted)}</time></small></p>`,
|
|
370
459
|
);
|
|
371
|
-
parts.push(
|
|
460
|
+
parts.push(
|
|
461
|
+
buildSinglePostContent(reply, siteUrl, replyPermalink, { inline: true }),
|
|
462
|
+
);
|
|
372
463
|
}
|
|
373
464
|
|
|
374
465
|
return parts.join("\n");
|
|
@@ -421,7 +512,7 @@ export function defaultFeedRenderer(data: FeedData): string {
|
|
|
421
512
|
const titleAttr = m.originalName
|
|
422
513
|
? ` title="${escapeXml(m.originalName)}"`
|
|
423
514
|
: "";
|
|
424
|
-
return `\n <link rel="enclosure" type="${escapeXml(m.mimeType)}" href="${escapeXml(m.url)}"${lengthAttr}${titleAttr}/>`;
|
|
515
|
+
return `\n <link rel="enclosure" type="${escapeXml(m.mimeType)}" href="${escapeXml(toAbsoluteFeedUrl(m.url, siteUrl))}"${lengthAttr}${titleAttr}/>`;
|
|
425
516
|
})
|
|
426
517
|
.join("");
|
|
427
518
|
|
|
@@ -438,7 +529,18 @@ export function defaultFeedRenderer(data: FeedData): string {
|
|
|
438
529
|
})
|
|
439
530
|
.join("");
|
|
440
531
|
|
|
441
|
-
|
|
532
|
+
// The feed's own <updated> is the newest entry timestamp, not the current
|
|
533
|
+
// time — stamping "now" on every render tells every reader the feed changed
|
|
534
|
+
// on every poll, which is both untrue and useless for change detection.
|
|
535
|
+
// ISO 8601 from toISOString() is UTC and zero-padded, so lexical max is
|
|
536
|
+
// chronological max. Empty feeds fall back to now, since Atom requires it.
|
|
537
|
+
const feedUpdated =
|
|
538
|
+
posts
|
|
539
|
+
.map((post) => post.feedUpdatedAt ?? post.updatedAt)
|
|
540
|
+
.reduce<
|
|
541
|
+
string | null
|
|
542
|
+
>((latest, value) => (latest === null || value > latest ? value : latest), null) ??
|
|
543
|
+
new Date().toISOString();
|
|
442
544
|
|
|
443
545
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
444
546
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
@@ -447,7 +549,7 @@ export function defaultFeedRenderer(data: FeedData): string {
|
|
|
447
549
|
<link href="${escapeXml(siteUrl)}" rel="alternate"/>
|
|
448
550
|
<link href="${escapeXml(selfUrl)}" rel="self"/>
|
|
449
551
|
<id>${escapeXml(selfUrl)}</id>
|
|
450
|
-
<updated>${
|
|
552
|
+
<updated>${feedUpdated}</updated>
|
|
451
553
|
${entries}
|
|
452
554
|
</feed>`;
|
|
453
555
|
}
|
package/src/lib/hugo-markdown.ts
CHANGED
|
@@ -111,6 +111,11 @@ export interface HugoFrontMatter {
|
|
|
111
111
|
rating?: number | null;
|
|
112
112
|
featured_at?: string | null;
|
|
113
113
|
pinned_at?: string | null;
|
|
114
|
+
/**
|
|
115
|
+
* Reply bundles only: the reply was published without announcing the Thread
|
|
116
|
+
* on Latest. Round-tripped so a restore rebuilds the same ordering.
|
|
117
|
+
*/
|
|
118
|
+
quiet_reply?: boolean;
|
|
114
119
|
|
|
115
120
|
// Derived Featured Thread projection (root bundles only)
|
|
116
121
|
featured_post_ids?: string[];
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const LINK_PREVIEW_PROVIDER_LABELS: Record<string, string> = {
|
|
2
|
+
youtube: "YouTube",
|
|
3
|
+
vimeo: "Vimeo",
|
|
4
|
+
bilibili: "Bilibili",
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Return the display label for a recognized Link preview provider.
|
|
9
|
+
*
|
|
10
|
+
* @param provider - Stored provider identifier, such as `youtube`
|
|
11
|
+
* @returns A stable display label, or undefined for unknown providers
|
|
12
|
+
* @example
|
|
13
|
+
* ```ts
|
|
14
|
+
* getLinkPreviewProviderLabel("youtube"); // "YouTube"
|
|
15
|
+
* getLinkPreviewProviderLabel("unknown"); // undefined
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export function getLinkPreviewProviderLabel(
|
|
19
|
+
provider?: string,
|
|
20
|
+
): string | undefined {
|
|
21
|
+
const normalizedProvider = provider?.trim().toLowerCase();
|
|
22
|
+
return normalizedProvider
|
|
23
|
+
? LINK_PREVIEW_PROVIDER_LABELS[normalizedProvider]
|
|
24
|
+
: undefined;
|
|
25
|
+
}
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
parseFootnoteDefinition,
|
|
26
26
|
} from "./footnotes.js";
|
|
27
27
|
import { renderMarkdownImage, type RichImageAttrs } from "./rich-image.js";
|
|
28
|
+
import { sanitizeRichTextHref } from "./url.js";
|
|
28
29
|
|
|
29
30
|
export const MARKDOWN_MARKED_OPTIONS = {
|
|
30
31
|
gfm: true,
|
|
@@ -825,7 +826,11 @@ export function createMarkdownContentExtensions(
|
|
|
825
826
|
codeBlock: false,
|
|
826
827
|
trailingNode: { notAfter: ["footnoteDefinition"] },
|
|
827
828
|
}),
|
|
828
|
-
SemanticLink.configure({
|
|
829
|
+
SemanticLink.configure({
|
|
830
|
+
openOnClick: false,
|
|
831
|
+
autolink: false,
|
|
832
|
+
isAllowedUri: (url) => sanitizeRichTextHref(url) !== "",
|
|
833
|
+
}),
|
|
829
834
|
MarkdownCodeBlock,
|
|
830
835
|
Table.configure({
|
|
831
836
|
resizable: false,
|
package/src/lib/post-display.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { Context } from "hono";
|
|
|
9
9
|
import type { Bindings, Post, PostView } from "../types.js";
|
|
10
10
|
import type { AppVariables } from "../types/app-context.js";
|
|
11
11
|
import { buildMediaMap } from "./media-helpers.js";
|
|
12
|
-
import { createMediaContext, toPostView } from "./view.js";
|
|
12
|
+
import { createMediaContext, resolveDraftTailId, toPostView } from "./view.js";
|
|
13
13
|
|
|
14
14
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
15
15
|
|
|
@@ -82,7 +82,12 @@ function resolvePostSocialImage(
|
|
|
82
82
|
interface PostDisplayOptions {
|
|
83
83
|
isAuthenticated?: boolean;
|
|
84
84
|
allowDraft?: boolean;
|
|
85
|
-
/**
|
|
85
|
+
/**
|
|
86
|
+
* Force draft members into the assembled thread. The author already sees
|
|
87
|
+
* them whenever they are signed in; this only covers the draft-preview
|
|
88
|
+
* route, where the request is authenticated but `isAuthenticated` has
|
|
89
|
+
* already been forced to `true` for private-post access.
|
|
90
|
+
*/
|
|
86
91
|
includeDraftThread?: boolean;
|
|
87
92
|
}
|
|
88
93
|
|
|
@@ -120,11 +125,16 @@ export async function assemblePostCardView(
|
|
|
120
125
|
}
|
|
121
126
|
|
|
122
127
|
const mediaCtx = createMediaContext(c.var.appConfig);
|
|
123
|
-
const [rawMediaMap, collectionsMap, lastPostMap, aliasesMap] =
|
|
128
|
+
const [rawMediaMap, collectionsMap, lastPostMap, draftTailMap, aliasesMap] =
|
|
124
129
|
await Promise.all([
|
|
125
130
|
c.var.services.media.getByPostIds([post.id]),
|
|
126
131
|
c.var.services.collections.getCollectionsByPostIds([post.id]),
|
|
127
|
-
c.var.services.posts.
|
|
132
|
+
c.var.services.posts.getThreadTailIds([post.threadId]),
|
|
133
|
+
c.var.isAuthenticated
|
|
134
|
+
? c.var.services.posts.getThreadTailIds([post.threadId], {
|
|
135
|
+
includeDrafts: true,
|
|
136
|
+
})
|
|
137
|
+
: Promise.resolve(new Map<string, string>()),
|
|
128
138
|
c.var.services.paths.getPostAliases([post.id]),
|
|
129
139
|
]);
|
|
130
140
|
|
|
@@ -144,6 +154,13 @@ export async function assemblePostCardView(
|
|
|
144
154
|
aliasesMap.get(post.id)?.[0],
|
|
145
155
|
);
|
|
146
156
|
|
|
157
|
+
const draftTailId = resolveDraftTailId(
|
|
158
|
+
post.threadId,
|
|
159
|
+
lastPostMap,
|
|
160
|
+
draftTailMap,
|
|
161
|
+
);
|
|
162
|
+
if (draftTailId) view.draftTailId = draftTailId;
|
|
163
|
+
|
|
147
164
|
return view;
|
|
148
165
|
}
|
|
149
166
|
|
|
@@ -170,12 +187,19 @@ export async function assemblePostPageDisplay(
|
|
|
170
187
|
}
|
|
171
188
|
|
|
172
189
|
const mediaCtx = createMediaContext(c.var.appConfig);
|
|
190
|
+
// Drafts render for the author so the thread they see ends where the server
|
|
191
|
+
// thinks it ends — otherwise a trailing draft silently blocks replies while
|
|
192
|
+
// staying invisible. Read the real auth state rather than the option, which
|
|
193
|
+
// callers force to `true` to bypass the private-post check.
|
|
194
|
+
const includeDrafts = Boolean(
|
|
195
|
+
options?.includeDraftThread || c.var.isAuthenticated,
|
|
196
|
+
);
|
|
173
197
|
const threadPosts = (
|
|
174
198
|
await c.var.services.posts.getThread(post.threadId)
|
|
175
199
|
).filter(
|
|
176
200
|
(threadPost) =>
|
|
177
201
|
threadPost.status === "published" ||
|
|
178
|
-
(
|
|
202
|
+
(includeDrafts && threadPost.status === "draft"),
|
|
179
203
|
);
|
|
180
204
|
|
|
181
205
|
const allPostIds =
|
|
@@ -219,13 +243,22 @@ export async function assemblePostPageDisplay(
|
|
|
219
243
|
)
|
|
220
244
|
: undefined;
|
|
221
245
|
|
|
222
|
-
|
|
246
|
+
// Page metadata describes what a reader gets, so it never draws on drafts:
|
|
247
|
+
// an unpublished reply must not supply the og:image or bump the modified
|
|
248
|
+
// time. Falls back to the post itself when nothing in the thread is
|
|
249
|
+
// published (the draft-preview route, which is noindex anyway).
|
|
250
|
+
const publishedViews = threadPostViews?.filter(
|
|
251
|
+
(view) => view.status === "published",
|
|
252
|
+
);
|
|
253
|
+
const metaViews = publishedViews?.length ? publishedViews : [postView];
|
|
254
|
+
|
|
255
|
+
const socialImage = resolvePostSocialImage(postView, metaViews);
|
|
223
256
|
|
|
224
|
-
|
|
225
|
-
const
|
|
257
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- metaViews is never empty
|
|
258
|
+
const rootView = metaViews[0]!;
|
|
226
259
|
// ISO 8601 strings from toISOString() are all UTC and zero-padded, so
|
|
227
260
|
// lexical comparison matches chronological order.
|
|
228
|
-
const articleModifiedTime =
|
|
261
|
+
const articleModifiedTime = metaViews
|
|
229
262
|
.map((p) => p.updatedAt)
|
|
230
263
|
.reduce((latest, t) => (t > latest ? t : latest));
|
|
231
264
|
|
package/src/lib/timeline.ts
CHANGED
|
@@ -14,7 +14,7 @@ import type {
|
|
|
14
14
|
} from "../types.js";
|
|
15
15
|
import type { AppVariables } from "../types/app-context.js";
|
|
16
16
|
import { buildMediaMap } from "./media-helpers.js";
|
|
17
|
-
import { createMediaContext, toPostView } from "./view.js";
|
|
17
|
+
import { createMediaContext, resolveDraftTailId, toPostView } from "./view.js";
|
|
18
18
|
|
|
19
19
|
type Env = { Bindings: Bindings; Variables: AppVariables };
|
|
20
20
|
|
|
@@ -51,13 +51,32 @@ async function buildTimelineItems(
|
|
|
51
51
|
// Batch load media, collections, and latest-reply contexts in parallel
|
|
52
52
|
const postIds = posts.map((p) => p.id);
|
|
53
53
|
const mediaCtx = createMediaContext(c.var.appConfig);
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
54
|
+
const threadIds = posts.map((p) => p.threadId);
|
|
55
|
+
const [
|
|
56
|
+
rawMediaMap,
|
|
57
|
+
collectionsMap,
|
|
58
|
+
threadContexts,
|
|
59
|
+
aliasesMap,
|
|
60
|
+
publishedTails,
|
|
61
|
+
draftInclusiveTails,
|
|
62
|
+
] = await Promise.all([
|
|
63
|
+
c.var.services.media.getByPostIds(postIds),
|
|
64
|
+
c.var.services.collections.getCollectionsByPostIds(postIds),
|
|
65
|
+
c.var.services.posts.getThreadTimelineContext(postIds),
|
|
66
|
+
c.var.services.paths.getPostAliases(postIds),
|
|
67
|
+
// The feed never renders drafts — it would turn a reading surface into a
|
|
68
|
+
// workspace. So the author's trailing draft stays hidden here, and the
|
|
69
|
+
// reply affordance carries its ID instead of silently offering a reply
|
|
70
|
+
// the server will refuse.
|
|
71
|
+
c.var.isAuthenticated
|
|
72
|
+
? c.var.services.posts.getThreadTailIds(threadIds)
|
|
73
|
+
: Promise.resolve(new Map<string, string>()),
|
|
74
|
+
c.var.isAuthenticated
|
|
75
|
+
? c.var.services.posts.getThreadTailIds(threadIds, {
|
|
76
|
+
includeDrafts: true,
|
|
77
|
+
})
|
|
78
|
+
: Promise.resolve(new Map<string, string>()),
|
|
79
|
+
]);
|
|
61
80
|
const mediaMap = buildMediaMap(
|
|
62
81
|
rawMediaMap,
|
|
63
82
|
mediaCtx.r2PublicUrl,
|
|
@@ -113,6 +132,12 @@ async function buildTimelineItems(
|
|
|
113
132
|
firstAlias(post.id),
|
|
114
133
|
);
|
|
115
134
|
|
|
135
|
+
const draftTailId = resolveDraftTailId(
|
|
136
|
+
post.threadId,
|
|
137
|
+
publishedTails,
|
|
138
|
+
draftInclusiveTails,
|
|
139
|
+
);
|
|
140
|
+
|
|
116
141
|
const threadCtx = threadContexts.get(post.id);
|
|
117
142
|
|
|
118
143
|
if (threadCtx) {
|
|
@@ -142,9 +167,10 @@ async function buildTimelineItems(
|
|
|
142
167
|
},
|
|
143
168
|
mediaCtx,
|
|
144
169
|
contextCollectionsMap.get(threadCtx.latestReply.id),
|
|
145
|
-
true, // latestReply is the last post in the thread
|
|
170
|
+
true, // latestReply is the last published post in the thread
|
|
146
171
|
firstContextAlias(threadCtx.latestReply.id),
|
|
147
172
|
);
|
|
173
|
+
if (draftTailId) latestReplyView.draftTailId = draftTailId;
|
|
148
174
|
|
|
149
175
|
return {
|
|
150
176
|
post: postView,
|
|
@@ -157,6 +183,10 @@ async function buildTimelineItems(
|
|
|
157
183
|
};
|
|
158
184
|
}
|
|
159
185
|
|
|
186
|
+
// No published replies, so the root carries the reply affordance — and any
|
|
187
|
+
// trailing draft with it.
|
|
188
|
+
if (draftTailId) postView.draftTailId = draftTailId;
|
|
189
|
+
|
|
160
190
|
return { post: postView };
|
|
161
191
|
});
|
|
162
192
|
}
|
package/src/lib/tiptap-render.ts
CHANGED
|
@@ -14,7 +14,7 @@ import {
|
|
|
14
14
|
import { renderEmbedFromAttrs } from "./embed-render.js";
|
|
15
15
|
import { escapeHtml } from "./html.js";
|
|
16
16
|
import { renderPublishedImageFigure } from "./rich-image.js";
|
|
17
|
-
import {
|
|
17
|
+
import { sanitizeRichTextHref } from "./url.js";
|
|
18
18
|
|
|
19
19
|
interface TiptapMark {
|
|
20
20
|
type: string;
|
|
@@ -324,7 +324,9 @@ const MARK_RENDERERS: Record<string, MarkRenderer> = {
|
|
|
324
324
|
strike: (html) => `<s>${html}</s>`,
|
|
325
325
|
code: (html) => `<code>${html}</code>`,
|
|
326
326
|
link: (html, mark) => {
|
|
327
|
-
const href = escapeHtml(
|
|
327
|
+
const href = escapeHtml(
|
|
328
|
+
sanitizeRichTextHref(getStringAttr(mark.attrs, "href")),
|
|
329
|
+
);
|
|
328
330
|
const target = getStringAttr(mark.attrs, "target");
|
|
329
331
|
const targetAttr = target ? ` target="${escapeHtml(target)}"` : "";
|
|
330
332
|
const relAttr = target ? ' rel="noopener noreferrer"' : "";
|