Render helper
Render Hive post Markdown into safe HTML, extract cover images and summaries and build proxied image URLs with @ecency/render-helper.
@ecency/render-helper is the library Ecency uses to turn a Hive post body (Markdown mixed with HTML) into sanitized HTML. It also finds a post’s cover image, builds a plain text summary and creates proxied, resized image URLs. The web app, the mobile app and the self-hosted blog all use it, so your app renders posts the same way Ecency does.
It is open source (MIT) and lives in the vision-web monorepo. It works in the browser, in Node.js (SSR) and in React Native.
Install
Section titled “Install”npm install @ecency/render-helper# oryarn add @ecency/render-helper# orpnpm add @ecency/render-helperThe package ships a browser ESM build, a Node ESM build and a Node CommonJS build. Your bundler picks the right one through the package exports.
Quick start
Section titled “Quick start”import { renderPostBody, catchPostImage, postBodySummary,} from "@ecency/render-helper";
// A post as returned by Hive (bridge or condenser API)const post = { author: "alice", permlink: "hello-hive", last_update: "2026-10-19T05:47:42", body: "Hello **Hive**!\n\n", json_metadata: { image: ["https://example.com/cover.jpg"] },};
const html = renderPostBody(post, false); // sanitized HTML for a websiteconst cover = catchPostImage(post, 600, 0); // proxied cover URL, 600px wideconst summary = postBodySummary(post, 160); // plain text, about 160 chars (whole words)Always insert the output of renderPostBody as HTML (for example with dangerouslySetInnerHTML in React). It is already sanitized, so don’t run it through Markdown again.
The Entry type
Section titled “The Entry type”Most functions accept either a raw string or an Entry object. Passing an Entry lets the library cache the result per post version.
interface Entry { author?: string permlink?: string last_update?: string body: any json_metadata?: any}json_metadata may be an object or a JSON string. Both work.
renderPostBody
Section titled “renderPostBody”function renderPostBody( obj: Entry | string, forApp?: boolean, // default true _webp?: boolean, // deprecated, ignored parentDomain?: string, // default "ecency.com" seoContext?: SeoContext, renderOptions?: RenderOptions): stringConverts Markdown and HTML into sanitized HTML. Along the way it:
- strips scripts, styles, event handlers and unsafe URL schemes,
- proxies and resizes images (adding
srcsetplus<picture>sources with AVIF and WebP for static images), - turns links to known Hive frontends into internal post, profile and tag links,
- turns supported video, audio and social links into embeds (see supported embeds).
forApp picks the output style:
true(the default) is for native or app hosts that handle link taps themselves. Post, profile and tag links lose theirhrefand carrydata-author,data-permlink,data-taganddata-hrefattributes instead.falseis for websites. Internal links become normal relative links such as/@alice/hello-hive.
parentDomain is passed to embeds that need it (Twitch requires a parent parameter). Set it to your own domain.
seoContext controls rel on external links. By default every external link gets rel="nofollow ugc noopener". When you pass the author’s reputation and the post payout, high quality content can earn followed links.
interface SeoContext { authorReputation?: number // human readable reputation score postPayout?: number // total post payout in USD}renderOptions fine-tunes the website output (forApp: false):
interface RenderOptions { // Render video embeds as iframes right away, with no play button overlay embedVideosDirectly?: boolean // Render @user and #tag chips as inert <span> instead of links // (useful when your site has no profile or tag pages) inertAuthorAndTagChips?: boolean // Absolute origin for profile section links you can't serve yourself, // for example "https://ecency.com" for /@user/wallet externalProfileBase?: string // Keep the author's pixel width/height on <img> to reserve layout space preserveImageDimensions?: boolean}Example for a single-author blog that only serves its own posts:
const html = renderPostBody(post, false, false, "blog.example.com", undefined, { inertAuthorAndTagChips: true, externalProfileBase: "https://ecency.com",});simpleMarkdownToHTML
Section titled “simpleMarkdownToHTML”function simpleMarkdownToHTML(input: string): stringA lightweight Markdown to HTML conversion with the same sanitizer, but without the Hive specific transforms (no image proxying, link rewriting or embeds). Use it for short user text such as chat messages, comments in an editor preview or bios.
postBodySummary
Section titled “postBodySummary”function postBodySummary( obj: Entry | string, length?: number, // default 200, 0 = no truncation platform?: "ios" | "android" | "web" // default "web"): stringReturns a plain text summary of the body with Markdown, HTML and links removed. Use it for card previews and meta descriptions. Use "ios" or "android" in React Native.
The summary never cuts a word in half. It keeps the last word whole, so the result can run a few characters (up to 9) past length. If you need a hard limit, trim the result yourself.
postBodySummary("## Title\n\nSome **bold** text and a [link](https://example.com).", 40);// "Title Some bold text and a link."catchPostImage
Section titled “catchPostImage”function catchPostImage( obj: Entry | string, width?: number, // default 0 (original size) height?: number, // default 0 format?: string, // default "match" options?: { fast?: boolean }): string | nullFinds the image a post card should show and returns it as a proxied URL, or null. The lookup order is:
json_metadata.thumbnailsjson_metadata.image- the first image in the body (including a YouTube poster when a video comes first)
Set options.fast to true on servers that render long feeds. It skips the last, expensive step (a full render of the body) and returns null in the rare cases only that step could resolve.
catchPostImage(post, 600, 500);// "https://i.ecency.com/p/<hash>?format=match&mode=fit&width=600&height=500"Raw image URLs
Section titled “Raw image URLs”When you need the original URL before proxying (for example to check a file extension or build a preload tag):
function getEntryCardImageRawUrl(obj: Entry | string): string | nullfunction getEntryImageRawUrl(obj: Entry | string): string | nullgetEntryCardImageRawUrlfollows the same order ascatchPostImage, so it returns the file the card will actually load.getEntryImageRawUrlskipsthumbnailsand looks atjson_metadata.image, then the body. It answers “what is the first image of the post itself”.
Image proxy helpers
Section titled “Image proxy helpers”Images are served through Ecency’s image proxy. The default base is https://i.ecency.com. See Images API for the URL format.
setProxyBase
Section titled “setProxyBase”function setProxyBase(p: string): voidPoints every generated image URL at another proxy base, for example your own imagehoster instance. Call it once at startup.
setProxyBase("https://images.example.com");proxifyImageSrc
Section titled “proxifyImageSrc”function proxifyImageSrc( url?: string, width?: number, // default 0 height?: number, // default 0 _format?: string, // deprecated, always "match" opts?: ProxifyOptions): string
interface ProxifyOptions { blur?: boolean // tiny blurred placeholder (LQIP) forceProxy?: boolean // go through /p/ even without a size, to get WebP/AVIF}Returns a proxied URL, or an empty string for invalid input. The proxy picks WebP or AVIF from the browser’s Accept header. Without a size, blur or forceProxy, images already on Ecency’s host keep their original bytes and format, which is the safest choice for Open Graph and social images.
proxifyImageSrc("https://example.com/photo.jpg", 800);// "https://i.ecency.com/p/<hash>?format=match&mode=fit&width=800"
proxifyImageSrc("https://example.com/photo.jpg", 0, 0, "match", { blur: true });// "https://i.ecency.com/p/<hash>?format=match&mode=fit&blur=1"Responsive images
Section titled “Responsive images”function buildSrcSet(url?: string): stringfunction buildSrcSetForFormat(url?: string, format?: "avif" | "webp" | "match"): stringfunction buildPictureSources(rawUrl?: string): { avif: string; webp: string } | nullfunction isPictureEligibleRawUrl(rawUrl?: string): booleanfunction isLegacySizedProxyUrl(url?: string): booleanconst IMAGE_SIZES: string // "(max-width: 768px) 100vw, 700px"buildSrcSetreturns asrcsetwith widths 320, 600, 800, 1024 and 1280.buildSrcSetForFormatdoes the same but pins the format in the URL. It returns an empty string when the format can’t be honored.buildPictureSourcesreturns AVIF and WebPsrcsetstrings for a<picture>element, ornullwhen the image isn’t a static JPEG, PNG or WebP (animated and vector images keep a plain<img>).isPictureEligibleRawUrlis the checkbuildPictureSourcesuses.isLegacySizedProxyUrldetects old sized proxy URLs such ashttps://images.hive.blog/0x0/<url>.
const raw = "https://example.com/photo.jpg";const sources = buildPictureSources(raw);
<picture> {sources && <source type="image/avif" srcSet={sources.avif} sizes={IMAGE_SIZES} />} {sources && <source type="image/webp" srcSet={sources.webp} sizes={IMAGE_SIZES} />} <img src={proxifyImageSrc(raw, 800)} srcSet={buildSrcSet(raw)} sizes={IMAGE_SIZES} alt="" /></picture>Embeds and safety helpers
Section titled “Embeds and safety helpers”isAllowedEmbedSrc
Section titled “isAllowedEmbedSrc”function isAllowedEmbedSrc(value?: string | null): booleanReturns true only for an https:// URL whose host is on the embed allowlist and, for providers with a known embed path, whose path matches it. Use it before you put any URL into an <iframe src>. It never throws.
isAllowedEmbedSrc("https://www.youtube.com/embed/dQw4w9WgXcQ"); // trueisAllowedEmbedSrc("https://www.youtube.com/watch?v=dQw4w9WgXcQ"); // false, not an embed pathisAllowedEmbedSrc("https://play.3speak.tv.evil.com/watch"); // falseisValidPermlink
Section titled “isValidPermlink”function isValidPermlink(permlink: string): booleantrue for a standard Hive permlink (lowercase letters, digits and hyphens, not ending in an image extension). Query strings and fragments are ignored.
SECTION_LIST
Section titled “SECTION_LIST”const SECTION_LIST: string[]The profile sections the renderer treats as profile pages rather than posts: wallet, feed, followers, following, points, communities, posts, blog, comments, replies, settings, engine, permissions, referrals, payout, activities, spk, trail. A link such as /@alice/wallet is a profile link, not a post with permlink wallet.
Supported embeds
Section titled “Supported embeds”renderPostBody turns links and pasted iframes from these providers into embeds:
| Type | Providers |
|---|---|
| Video | YouTube (including Shorts), Vimeo, Twitch, 3Speak, DTube, Rumble, BitChute, Brighteon, Odysee and LBRY, VIMM, Loom, BrandNewTube, archive.org, Dapplr, Truvvl, Skatehive, Skatehype |
| Audio | 3Speak Audio, Spotify, SoundCloud, Aureal, Liketu audio files (as an <audio> player) |
| Social | X / Twitter posts (as a twitter-tweet blockquote; load X’s widget script if you want the full card) |
Iframes from any other host are removed.
Tuning
Section titled “Tuning”function setCacheSize(size: number): voidfunction setSlowRenderThresholdMs(ms: number): void- Rendered HTML, summaries and images are kept in an in-memory LRU cache keyed by post and version. The default holds 500 entries. Raise it on a busy server, lower it on memory constrained devices.
- On Node.js, renders slower than 500 ms log a warning. Change the threshold with
setSlowRenderThresholdMs, or pass0to turn the warning off.
Related
Section titled “Related”- Ecency SDK: fetch posts and accounts to render.
- Images API: the image proxy URL format.
- Self-hosted blog: a full app built on these helpers.