Changeset 2d6a9c3 in Klonkt


Ignore:
Timestamp:
07/16/2026 12:20:51 PM (8 weeks ago)
Author:
Robin <roboburr@…>
Branches:
main
Children:
af21002
Parents:
8878814
git-author:
Robin <roboburr@…> (07/13/2026 05:21:50 AM)
git-committer:
Robin <roboburr@…> (07/16/2026 12:20:51 PM)
Message:

Spoor A step 1: bake post display HTML at save (ActivityPub source model)

Introduce the source/rendered split: posts.content stays the raw source (what
the editor round-trips), a new content_rendered column holds the baked display
HTML. bakePostContent() runs the linkify pipeline once at create/edit and
caches it; the render route serves content_rendered (falling back to an
on-the-fly bake for not-yet-baked posts) and no longer re-linkifies every view.
Verified: baked and fallback paths render byte-identically and a baked lone URL
still becomes an embed. This is the seam for step 2 (resolve @mentions once at
save instead of per page view) and removes the per-render linkify cost.

Co-Authored-By: Claude Opus 4.8 <noreply@…>

Location:
src
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • src/config/database.js

    r8878814 r2d6a9c3  
    387387  ensureColumn('ap_followers', 'last_delivery_at', 'DATETIME'); // last SUCCESSFUL delivery to this follower's inbox
    388388  ensureColumn('ap_followers', 'last_error_at', 'DATETIME');    // last time a delivery to it gave up (max retries)
     389
     390  // ActivityPub `source` model: content_rendered = baked display HTML (#hashtags / URLs /
     391  // @mentions linkified once at save). `content` stays the raw source used for editing and
     392  // re-rendering. NULL on old posts → the render route bakes on the fly as a fallback.
     393  ensureColumn('posts', 'content_rendered', 'TEXT');
    389394}
    390395
  • src/routes/posts.js

    r8878814 r2d6a9c3  
    238238}
    239239
     240// Bake + cache a post's display HTML (ActivityPub `source` model): `content` stays the raw
     241// source (used by the editor + re-rendering), content_rendered holds the linkified render the
     242// page serves. Called after every create/edit. Non-fatal: the render route falls back to
     243// baking on the fly if this ever fails.
     244function cacheRenderedContent(postId, rawContent) {
     245  try {
     246    db.prepare('UPDATE posts SET content_rendered = ? WHERE id = ?')
     247      .run(ActivityPubService.bakePostContent(rawContent || ''), postId);
     248  } catch (e) { /* fallback bake in the render route keeps display correct */ }
     249}
     250
    240251router.post('/posts/create', requireAuth, (req, res) => {
    241252  const site = res.locals.site;
     
    299310    now, now, publishedAt
    300311  );
     312  cacheRenderedContent(postId, cleanContent); // bake display HTML (ActivityPub `source` model)
    301313
    302314  // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks
     
    439451    finalSlug, publishedAt, now, post.id
    440452  );
     453  cacheRenderedContent(post.id, cleanContent); // re-bake display HTML on edit (ActivityPub `source` model)
    441454
    442455  // Per-post "share audio on the fediverse" → set fedi_open on this post's hosted tracks
     
    9941007  if (post.status === 'published') recordPostView(post, req);
    9951008
    996   // Render content. Content is now user-authored HTML (already sanitized on
    997   // save). The pipeline still adds autoembed iframes and shortcode embeds:
    998   //   stored HTML → autoembed → [[track]]/[[album]]/[[playlist]] → response
    999   let html = post.content || '';
     1009  // Render content. Base = the pre-rendered ("baked") display HTML: #hashtags/URLs (and, later,
     1010  // @mentions) linkified once at SAVE and cached in content_rendered — the ActivityPub `source`
     1011  // model (content = raw source, kept for editing). Old posts with no baked copy fall back to
     1012  // baking on the fly (cheap, no network). The dynamic layer (autoembed + [[track/album/
     1013  // playlist]] + signed audio URLs) stays per-render on top, since it can't be cached.
     1014  let html = (post.content_rendered != null && post.content_rendered !== '')
     1015    ? post.content_rendered
     1016    : ActivityPubService.bakePostContent(post.content || '');
    10001017  if (audioEnabled()) {
    10011018  if (site.enable_audio_player !== 0) {
     
    10921109    html = html.replace(/\[\[(track|album|playlist):[^\]]+\]\]/gi, '');
    10931110  }
    1094   // Linkify inline #hashtags and bare URLs for on-site display — same rules as the
    1095   // federated copy (buildNote). Was: only the Mastodon copy got links; the website
    1096   // showed raw "#tag"/URL text. Idempotent, so editor links + embeds are untouched.
    1097   html = ActivityPubService.linkifyBody('', html);
     1111  // (linkify is baked into content_rendered at save now, not re-run here.)
    10981112  post.content_html = html;
    10991113
  • src/services/ActivityPubService.js

    r8878814 r2d6a9c3  
    14641464}
    14651465
     1466// Bake a post's raw source into its display HTML (the ActivityPub `source` model): done ONCE
     1467// at save and cached in posts.content_rendered, so page views serve it statically instead of
     1468// re-linkifying every render. Step 1 = #hashtags + bare URLs (cheap, no network). Step 2 will
     1469// resolve @mentions here too (webfinger once at save instead of per page view).
     1470export function bakePostContent(source) {
     1471  return linkifyBody('', source || '');
     1472}
     1473
    14661474// Extract the AP Hashtag tag objects from already-linked reply content.
    14671475function hashtagTags(base, content) {
     
    24632471  deliverWithRetry, enqueueDelivery, processDeliveryQueue, startDeliveryWorker,
    24642472  getReplyUris, markNotificationsSeen, countUnseenNotifications, hasPlayableAudio,
    2465   linkifyBody, listFollowers, removeFollower,
     2473  linkifyBody, bakePostContent, listFollowers, removeFollower,
    24662474};
Note: See TracChangeset for help on using the changeset viewer.