Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
d03b384
render-helper: fast thumbnail lookup, thumbnails first, memoized nulls
feruzm Aug 21, 2026
88a26ba
review: match the renderer's bare-URL rules, keep the entry preload o…
feruzm Aug 21, 2026
faa6aa9
review: strip HTML comments with an index scan, rebuild dist
feruzm Aug 21, 2026
9c97b7e
review: mark tag interiors, walk every YouTube link, compare anchors …
feruzm Aug 21, 2026
c43173b
review: pin the script-text case as a documented divergence, rebuild …
feruzm Aug 21, 2026
da8a37b
review: strip hidden regions with index scans throughout, rebuild dist
feruzm Aug 21, 2026
b73356c
review: a markdown autolink is not a tag, rebuild dist
feruzm Aug 21, 2026
85f469f
review: case-insensitive autolinks, tag boundaries as the renderer re…
feruzm Aug 21, 2026
a55f841
review: read the opening tag the renderer's way, rebuild dist
feruzm Aug 21, 2026
d63ded4
review: image anchors by first text child, typed fixtures, memo spec …
feruzm Aug 21, 2026
4f07981
review: hide <pre> only where the parser keeps it raw, rebuild dist
feruzm Aug 21, 2026
f56ebd2
review: decide block context on the original lines, rebuild dist
feruzm Aug 21, 2026
e23e74b
review: blank spans in one pass, rebuild dist
feruzm Aug 21, 2026
eff25a3
review: container prefixes in the block model, linear image-href chec…
feruzm Aug 21, 2026
08ab1ae
review: container prefixes in any alternation, rebuild dist
feruzm Aug 21, 2026
e9c3d78
review: nested list markers, indented code in context, rebuild dist
feruzm Aug 21, 2026
d31cd01
review: an open HTML block is raw until its blank line, rebuild dist
feruzm Aug 21, 2026
5927907
review: a bare ! is prose, anchors matched quote-aware with bare href…
feruzm Aug 21, 2026
1925087
review: join anchor blanking once, rebuild dist
feruzm Aug 21, 2026
f097a15
review: data-href is not the href, rebuild dist
feruzm Aug 21, 2026
31515ba
chore: apply changeset versioning for PR #1611
github-actions[bot] Aug 21, 2026
ac40c7b
review: classify URL tokens in code, rebuild dist
feruzm Aug 21, 2026
e208f7d
review: a tag with a glued attribute is text, rebuild dist
feruzm Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import { getAccountFullQueryOptions, QueryKeys } from "@ecency/sdk";
import {
buildPictureSources,
buildSrcSet,
catchPostImage,
getEntryImageRawUrl,
IMAGE_SIZES
} from "@ecency/render-helper";
import { entryLcpMatch } from "@/app/(dynamicPages)/entry/_helpers/entry-lcp-match";
import { EcencyEntriesCacheManagement } from "@/core/caches";
import { EntryPageContentClient } from "@/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-content-client";
import { EntryPageContentSSR } from "@/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-content-ssr";
Expand Down Expand Up @@ -201,7 +201,7 @@ export default async function EntryPage({ params, searchParams }: Props) {
// fetchpriority="high" <img> still prioritizes the actual fetch.
const rawCover = getEntryImageRawUrl(entry);
const coverPicture = rawCover ? buildPictureSources(rawCover) : null;
const lcpMatch = catchPostImage(entry, 600, 500, "match");
const lcpMatch = entryLcpMatch(rawCover);
const lcpMatchSrcSet = lcpMatch ? buildSrcSet(lcpMatch) : "";

// Structured data: only top-level posts get Article + breadcrumb. Comments
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/app/(dynamicPages)/entry/_helpers/entry-lcp-match.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { proxifyImageSrc } from "@ecency/render-helper";

/**
* The format=match preload URL for a cover the entry page cannot preload as a
* <picture> (gif, svg, extensionless, already-proxified). It is derived from
* the SAME raw cover the page renders (getEntryImageRawUrl: metadata image,
* then the first body image), never from catchPostImage, which prefers an
* explicit json_metadata.thumbnails entry. A thumbnail is a card concern; the
* post body does not render it, so preloading it would be a wasted request
* and would cost the real cover its head start.
*
* Sizing follows what the body renders for that cover: a gif stays unsized so
* the proxy does not flatten the animation, everything else is requested at
* the thumbnail size the body's <img> uses.
*/
export function entryLcpMatch(rawCover: string | null | undefined): string | null {
if (!rawCover) {
return null;
}
const proxied = /\.gif$/i.test(rawCover)
? proxifyImageSrc(rawCover, 0, 0, "match")
: proxifyImageSrc(rawCover, 600, 500, "match");
return proxied || null;
}
31 changes: 19 additions & 12 deletions apps/web/src/core/entries/slim-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,19 +66,26 @@ function pickThumbnail(entry: Entry): string | undefined {
// entry, while the body is still here to look at.
//
// Two steps, because they find different things. getEntryImageRawUrl is the
// regex fast path over raw markdown. catchPostImage falls back to a full
// markdown2Html plus DOM parse, and THAT is where a video post's poster
// (3Speak/YouTube render as <img class="no-replace video-thumbnail">) and
// <center>-wrapped bare image URLs are discovered. Stopping at the fast path
// dropped those cards to /assets/noimage.png: measured on live posts, 4 of 29
// rows that carry no metadata image, concentrated in the video communities.
// regex fast path over raw markdown. catchPostImage in fast mode adds the
// cases the regex alone missed, a YouTube poster and a <center>-wrapped bare
// URL, still without rendering markdown. Measured on live posts, those two
// were 4 of 29 rows that carry no metadata image, concentrated in the video
// communities, and stopping at the regex dropped them to /assets/noimage.png.
//
// The second call only runs when the fast path found nothing, and it is work
// the card itself already did before this step existed. catchPostImage(0, 0)
// returns the proxied /p/ URL, and re-proxying it at card size reuses the same
// hash rather than nesting, so the card src stays byte-identical to what it was
// before slimming.
return getEntryImageRawUrl(entry) ?? catchPostImage(entry, 0, 0, "match") ?? undefined;
// Fast mode matters here because this runs on the server for every row of
// every feed. The full lookup ends in markdown2Html plus a DOM parse, and on
// a long body with no image at all that is hundreds of milliseconds of
// synchronous CPU per row: one feed of such rows held a server's event loop
// for five seconds, stalling every other request on that process. The one
// class fast mode gives up is an ambiguous markdown image URL (one containing
// a parenthesis), which the card then shows without a thumbnail.
//
// catchPostImage(0, 0) returns the proxied /p/ URL, and re-proxying it at card
// size reuses the same hash rather than nesting, so the card src stays
// byte-identical to what it was before slimming.
return (
getEntryImageRawUrl(entry) ?? catchPostImage(entry, 0, 0, "match", { fast: true }) ?? undefined
);
}

function pickDescription(entry: Entry): string {
Expand Down
47 changes: 47 additions & 0 deletions apps/web/src/specs/app/entry-lcp-match.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { catchPostImage, getEntryImageRawUrl, proxifyImageSrc } from "@ecency/render-helper";
import { entryLcpMatch } from "@/app/(dynamicPages)/entry/_helpers/entry-lcp-match";
import { mockEntry } from "@/specs/test-utils";

describe("entryLcpMatch", () => {
it("is null without a raw cover, so the page emits no preload", () => {
expect(entryLcpMatch(null)).toBeNull();
expect(entryLcpMatch(undefined)).toBeNull();
expect(entryLcpMatch("")).toBeNull();
});

it("requests a non-gif cover at the thumbnail size the body renders", () => {
const raw = "https://files.peakd.com/x/cover.svg";
expect(entryLcpMatch(raw)).toBe(proxifyImageSrc(raw, 600, 500, "match"));
});

it("keeps a gif cover unsized, as the body does", () => {
const raw = "https://files.peakd.com/x/anim.gif";
expect(entryLcpMatch(raw)).toBe(proxifyImageSrc(raw, 0, 0, "match"));
});

it("follows the rendered cover, not the card thumbnail, when the two differ", () => {
// A publisher sets a dedicated poster for cards and a gif as the post's
// cover image. Cards show the poster; the body renders the gif; the
// preload must fetch the gif.
const entry = mockEntry({
author: "lcp",
permlink: "poster-vs-cover",
body: "text only",
json_metadata: {
thumbnails: ["https://files.peakd.com/x/poster.png"],
image: ["https://files.peakd.com/x/cover.gif"]
}
});
const raw = getEntryImageRawUrl(entry);
expect(raw).toBe("https://files.peakd.com/x/cover.gif");
expect(entryLcpMatch(raw)).toBe(proxifyImageSrc("https://files.peakd.com/x/cover.gif", 0, 0, "match"));
expect(catchPostImage(entry, 600, 500, "match")).toBe(
proxifyImageSrc("https://files.peakd.com/x/poster.png", 600, 500, "match")
);
});

it("returns null rather than an empty string for a cover the proxy refuses", () => {
expect(entryLcpMatch("not a url")).toBeNull();
});
});
38 changes: 14 additions & 24 deletions apps/web/src/specs/core/entries/slim-entry.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,23 +68,15 @@ describe("slimEntry", () => {
expect(slimEntry(e).json_metadata?.image).toEqual(["https://images.hive.blog/in-body.png"]);
});

it("shows the cover unslimmed and the poster slimmed, which is deliberate", () => {
// catchPostImage never looks at `thumbnails`: getImage() in render-helper
// reads json_metadata.image as a string, then as an array, then falls back
// to the body. Slimming puts the thumbnail first, so a post that sets the
// two fields to DIFFERENT urls renders its cover on an unslimmed card and
// its poster on a slim one.
it("shows the poster on both the unslimmed and the slimmed card", () => {
// `thumbnails` is published for exactly this purpose by 3Speak, Liketu and
// the editor's thumbnail picker, and a publisher who sets a dedicated poster
// means it. render-helper's catchPostImage reads it ahead of `image` now, the
// same order slimming uses, so a post that sets the two fields to DIFFERENT
// urls renders the poster whether or not the row was slimmed.
//
// That divergence is chosen. `thumbnails` is published for exactly this
// purpose by 3Speak and Liketu, and a publisher who sets a dedicated poster
// means it. It is also unobservable in practice: across 461 live rows from
// trending, hot, created, promoted, tags and communities, 70 carried both
// fields and 0 of them disagreed.
//
// BOTH halves are asserted on purpose. Pinning only the slim side would let
// the divergence disappear unnoticed if render-helper ever started honouring
// `thumbnails`, and this test exists to make that a decision rather than a
// surprise.
// BOTH halves are asserted on purpose: if either side ever changed its order
// the two cards would silently disagree again.
const meta = {
thumbnails: ["https://images.hive.blog/poster.png"],
image: ["https://images.hive.blog/cover.png"]
Expand All @@ -96,12 +88,9 @@ describe("slimEntry", () => {
const card = (e: Entry) => catchPostImage(e, 320, 180, "match");

expect(card(unslimmed)).toBe(
proxifyImageSrc("https://images.hive.blog/cover.png", 320, 180, "match")
);
expect(card(slimmed)).toBe(
proxifyImageSrc("https://images.hive.blog/poster.png", 320, 180, "match")
);
expect(card(unslimmed)).not.toBe(card(slimmed));
expect(card(slimmed)).toBe(card(unslimmed));
});

it("survives thumbnails that are not an array", () => {
Expand Down Expand Up @@ -134,18 +123,19 @@ describe("slimEntry", () => {
expect(slimEntry(e).json_metadata?.image).toEqual(["https://images.hive.blog/real.png"]);
});

it("keeps a video post's poster, which only the full render finds", () => {
it("keeps a video post's poster, which the raw-markdown regex alone misses", () => {
// A bare YouTube URL becomes an <img class="no-replace video-thumbnail"> in
// the rendered post, so the raw-markdown fast path sees no image at all.
// Stopping there dropped these cards to the noimage placeholder.
// the rendered post, so getEntryImageRawUrl sees no image at all. Stopping
// there dropped these cards to the noimage placeholder; catchPostImage's
// fast mode derives the same poster without rendering.
const e = entry({
json_metadata: {},
body: "Check this out\n\nhttps://www.youtube.com/watch?v=dQw4w9WgXcQ\n\nthanks"
});
expect(slimEntry(e).json_metadata?.image?.[0]).toBeTruthy();
});

it("keeps a <center>-wrapped bare image URL, also full-render only", () => {
it("keeps a <center>-wrapped bare image URL, which the regex also missed before", () => {
const e = entry({
json_metadata: {},
body: "<center>https://images.hive.blog/DQmb59qYM1czWSDDw2dRmUHJ7s97L6S6Rk3uZLyA5vCxAEr/pic.jpg</center>"
Expand Down
13 changes: 12 additions & 1 deletion packages/render-helper/dist/browser/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,18 @@ declare function markdown2Html(obj: Entry | string, forApp?: boolean, _webp?: bo
* no unambiguous image (the caller can fall back to catchPostImage).
*/
declare function getEntryImageRawUrl(obj: Entry | string): string | null;
declare function catchPostImage(obj: Entry | string, width?: number, height?: number, format?: string): string | null;
interface CatchPostImageOptions {
/**
* Stop after the metadata and regex tiers. The last tier is a full
* markdown2Html + DOM parse, which on a long body with no image at all costs
* hundreds of milliseconds of synchronous CPU; a feed of such rows can hold a
* server's event loop for seconds. Callers that can live without the rare
* markdown-only finds (video embed posters, for instance) set this and get
* null back instead. Default false keeps every existing caller byte-identical.
*/
fast?: boolean;
}
declare function catchPostImage(obj: Entry | string, width?: number, height?: number, format?: string, options?: CatchPostImageOptions): string | null;

/**
* Generate a text summary from an Entry object or raw string
Expand Down
Loading
Loading