@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.
Files changed (112) hide show
  1. package/bin/commands/import-site.js +7 -0
  2. package/dist/app-C75Zc8dD.js +6 -0
  3. package/dist/{app-C-oi_t8W.js → app-C7_4oab9.js} +480 -196
  4. package/dist/client/.vite/manifest.json +8 -8
  5. package/dist/client/_assets/chunks/{url-pLre2DM_.js → url-CU9pEbk5.js} +1 -1
  6. package/dist/client/_assets/{client-DxY5BFe8.js → client-Bq0vre8Y.js} +3 -3
  7. package/dist/client/_assets/client-D3VZyG4L.css +2 -0
  8. package/dist/client/_assets/{client-auth-CakPESv9.js → client-auth-F2rb9WfF.js} +1015 -977
  9. package/dist/{env-OHRKGcMj.js → env-BPYViDJJ.js} +1 -1
  10. package/dist/{export-CsFx6F_M.js → export-Dl5KCiEs.js} +93 -7
  11. package/dist/{github-api-BgSiE71w.js → github-api-BNF35Lkx.js} +1 -1
  12. package/dist/{github-app-BbklkFmU.js → github-app-DdiD-bC4.js} +1 -1
  13. package/dist/{github-sync-ppWXN3jb.js → github-sync-Bqg62IvV.js} +3 -3
  14. package/dist/{github-sync-Cq1pzzOI.js → github-sync-HdK2EgvJ.js} +3 -3
  15. package/dist/index.js +5 -5
  16. package/dist/node.js +6 -6
  17. package/dist/{url-BMYO-Zlt.js → url-CbLAtlZe.js} +517 -12
  18. package/package.json +1 -1
  19. package/src/__tests__/mise-config.test.ts +22 -0
  20. package/src/client/__tests__/compose-bridge.test.ts +252 -2
  21. package/src/client/__tests__/compose-shortcuts.test.ts +20 -0
  22. package/src/client/__tests__/toast.test.ts +65 -1
  23. package/src/client/components/__tests__/compose-format-convert.test.ts +0 -19
  24. package/src/client/components/__tests__/jant-command-palette.test.ts +51 -0
  25. package/src/client/components/__tests__/jant-compose-dialog.test.ts +2015 -318
  26. package/src/client/components/__tests__/jant-compose-editor.test.ts +53 -62
  27. package/src/client/components/__tests__/jant-compose-fullscreen.test.ts +35 -89
  28. package/src/client/components/compose-format-convert.ts +5 -1
  29. package/src/client/components/compose-types.ts +47 -3
  30. package/src/client/components/jant-command-palette.ts +12 -6
  31. package/src/client/components/jant-compose-dialog.ts +1464 -680
  32. package/src/client/components/jant-compose-editor.ts +225 -93
  33. package/src/client/components/jant-compose-fullscreen.ts +3 -5
  34. package/src/client/components/jant-post-menu.ts +91 -21
  35. package/src/client/compose-bridge.ts +143 -122
  36. package/src/client/compose-launch.ts +13 -0
  37. package/src/client/compose-shortcuts.ts +5 -1
  38. package/src/client/post-refresh.ts +135 -0
  39. package/src/client/tiptap/__tests__/link-input-rules.test.ts +121 -0
  40. package/src/client/tiptap/link-input-rules.ts +4 -4
  41. package/src/client/toast.ts +130 -38
  42. package/src/db/__tests__/backfill-thread-activity.test.ts +234 -0
  43. package/src/db/__tests__/thread-activity.test.ts +113 -0
  44. package/src/db/backfills/0005_split_thread_activity_from_quiet_replies.sql +68 -0
  45. package/src/db/migrations/0031_many_ego.sql +3 -0
  46. package/src/db/migrations/meta/0031_snapshot.json +2562 -0
  47. package/src/db/migrations/meta/_journal.json +7 -0
  48. package/src/db/migrations/pg/0029_rare_hairball.sql +3 -0
  49. package/src/db/migrations/pg/meta/0029_snapshot.json +3289 -0
  50. package/src/db/migrations/pg/meta/_journal.json +7 -0
  51. package/src/db/pg/schema.ts +18 -0
  52. package/src/db/schema.ts +20 -0
  53. package/src/db/thread-activity.ts +101 -0
  54. package/src/i18n/locales/public/en.po +89 -21
  55. package/src/i18n/locales/public/en.ts +1 -1
  56. package/src/i18n/locales/public/zh-Hans.po +88 -20
  57. package/src/i18n/locales/public/zh-Hans.ts +1 -1
  58. package/src/i18n/locales/public/zh-Hant.po +88 -20
  59. package/src/i18n/locales/public/zh-Hant.ts +1 -1
  60. package/src/lib/__tests__/feed.test.ts +168 -0
  61. package/src/lib/__tests__/markdown.test.ts +16 -0
  62. package/src/lib/__tests__/slug.test.ts +18 -0
  63. package/src/lib/__tests__/translit.test.ts +87 -0
  64. package/src/lib/__tests__/url.test.ts +53 -0
  65. package/src/lib/feed.ts +113 -11
  66. package/src/lib/hugo-markdown.ts +5 -0
  67. package/src/lib/link-preview.ts +25 -0
  68. package/src/lib/markdown-manager.ts +6 -1
  69. package/src/lib/post-display.ts +42 -9
  70. package/src/lib/timeline.ts +39 -9
  71. package/src/lib/tiptap-render.ts +4 -2
  72. package/src/lib/translit.ts +288 -0
  73. package/src/lib/url.ts +123 -12
  74. package/src/lib/view.ts +28 -0
  75. package/src/routes/api/__tests__/palette.test.ts +44 -0
  76. package/src/routes/api/posts.ts +13 -9
  77. package/src/routes/api/public/__tests__/posts.test.ts +38 -0
  78. package/src/routes/api/public/posts.ts +7 -0
  79. package/src/routes/compose.tsx +6 -2
  80. package/src/routes/feed/__tests__/sitemap.test.ts +68 -1
  81. package/src/routes/pages/__tests__/archive-params.test.ts +292 -0
  82. package/src/routes/pages/__tests__/featured.test.ts +6 -2
  83. package/src/routes/pages/__tests__/preview.test.ts +3 -1
  84. package/src/routes/pages/archive.tsx +65 -17
  85. package/src/routes/pages/page.tsx +5 -2
  86. package/src/services/__tests__/collection.test.ts +32 -1
  87. package/src/services/__tests__/post-timeline.test.ts +142 -11
  88. package/src/services/__tests__/post.test.ts +277 -0
  89. package/src/services/collection.ts +8 -10
  90. package/src/services/export-theme/assets/client-site.css +1 -1
  91. package/src/services/export.ts +3 -5
  92. package/src/services/path.ts +4 -1
  93. package/src/services/post.ts +297 -100
  94. package/src/services/search.ts +9 -0
  95. package/src/styles/components.css +0 -14
  96. package/src/styles/site-media.css +4 -4
  97. package/src/styles/tokens.css +62 -1
  98. package/src/styles/ui.css +1363 -1283
  99. package/src/types/entities.ts +5 -0
  100. package/src/types/props.ts +12 -0
  101. package/src/types/views.ts +6 -0
  102. package/src/ui/compose/ComposeDialog.tsx +17 -17
  103. package/src/ui/feed/LinkPreview.tsx +2 -7
  104. package/src/ui/feed/PostStatusBadges.tsx +42 -3
  105. package/src/ui/pages/ArchivePage.tsx +131 -24
  106. package/src/ui/pages/__tests__/ArchivePage.test.tsx +63 -0
  107. package/src/ui/shared/DraftPreviewBar.tsx +25 -9
  108. package/src/ui/shared/PostFooter.tsx +35 -12
  109. package/src/ui/shared/custom-icons.ts +5 -0
  110. package/src/ui/shared/post-article-attributes.ts +6 -0
  111. package/dist/app-BxCOR3Uc.js +0 -6
  112. 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(item: MediaView, postPermalinkUrl?: string): string {
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 = item.posterUrl || item.thumbnailUrl;
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(stripUnsafeFeedHtml(post.bodyHtml));
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(buildSinglePostContent(reply, replyPermalink, { inline: true }));
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
- const now = new Date().toISOString();
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>${now}</updated>
552
+ <updated>${feedUpdated}</updated>
451
553
  ${entries}
452
554
  </feed>`;
453
555
  }
@@ -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({ openOnClick: false, autolink: false }),
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,
@@ -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
- /** Include draft members when assembling a thread for an authenticated preview. */
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.getLastPostIdsByThread([post.threadId]),
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
- (options?.includeDraftThread && threadPost.status === "draft"),
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
- const socialImage = resolvePostSocialImage(postView, threadPostViews);
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
- const rootView = threadPostViews?.[0] ?? postView;
225
- const allViews = threadPostViews ?? [postView];
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 = allViews
261
+ const articleModifiedTime = metaViews
229
262
  .map((p) => p.updatedAt)
230
263
  .reduce((latest, t) => (t > latest ? t : latest));
231
264
 
@@ -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 [rawMediaMap, collectionsMap, threadContexts, aliasesMap] =
55
- await Promise.all([
56
- c.var.services.media.getByPostIds(postIds),
57
- c.var.services.collections.getCollectionsByPostIds(postIds),
58
- c.var.services.posts.getThreadTimelineContext(postIds),
59
- c.var.services.paths.getPostAliases(postIds),
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
  }
@@ -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 { sanitizeUrl } from "./url.js";
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(sanitizeUrl(getStringAttr(mark.attrs, "href")));
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"' : "";