Public Site

Content Rendering

Overview

Vextro ships a vextro/content subpath with utilities and Astro components for rendering CMS data in your public site. This is separate from the admin module -- you use these utilities in your Astro pages, not in the admin dashboard.

The module covers three concerns:

  • Rich text -- converting TipTap JSON documents to semantic HTML and plain text
  • Images -- building srcset/sizes attributes, selecting variants by size, and computing focal-point CSS
  • Blocks -- mapping block references to renderer components

Installation

import {
  renderTipTapToHtml,
  extractPlainText,
  buildSrcSet,
  buildImageSizes,
  focalPointToObjectPosition,
} from 'vextro/content';

Astro components are available as direct file imports:

---
import VextroImage from 'vextro/content/VextroImage.astro';
import VextroRichText from 'vextro/content/VextroRichText.astro';
import VextroBlocks from 'vextro/content/VextroBlocks.astro';
import VextroContent from 'vextro/content/VextroContent.astro';
---

Rich Text

renderTipTapToHtml

Converts a TipTap JSON document to semantic HTML. Handles all standard TipTap node types (paragraphs, headings h1–h6, bullet lists, ordered lists, blockquotes, tables, code blocks, hard breaks, horizontal rules) plus custom vextroBlock nodes embedded in rich text. Returns an empty string for null or undefined input.

import { renderTipTapToHtml } from 'vextro/content';

const html = renderTipTapToHtml(post.body);
---
import { renderTipTapToHtml } from 'vextro/content';
const html = renderTipTapToHtml(post.body);
---
<article set:html={html} />

extractPlainText

Strips all markup from a TipTap JSON document or an HTML string and returns plain text. Block elements are followed by newlines. Useful for generating excerpts and search index entries.

import { extractPlainText } from 'vextro/content';

const excerpt = extractPlainText(post.body).slice(0, 160);

VextroRichText Component

Wraps TipTap JSON or an HTML string in a semantic element. Renders nothing if content is empty.

---
import VextroRichText from 'vextro/content/VextroRichText.astro';
---
<VextroRichText content={post.body} class="prose" as="article" />

Props

PropTypeDefaultDescription
contentRichTextContent—TipTap JSON doc or HTML string
classstring—CSS class on the wrapper element
as"div" | "article" | "section" | "main" | "aside""div"Wrapper element tag

Images

Vextro's upload system generates multiple image variants (WebP, AVIF, original format) at different widths. The content utilities help you build the HTML attributes for <picture> elements.

buildSrcSet

Generates an srcset attribute value from an array of image variants. Pass { format: "webp" } to filter to a single format.

import { buildSrcSet } from 'vextro/content';

const webpSrcSet = buildSrcSet(file.sizes, { format: 'webp' });
// "image-800.webp 800w, image-1200.webp 1200w, ..."

buildImageSizes

Builds an HTML sizes attribute from a breakpoint map. The default key provides the fallback value.

import { buildImageSizes } from 'vextro/content';

const sizes = buildImageSizes({
  '(min-width: 1024px)': '50vw',
  '(min-width: 768px)': '75vw',
  default: '100vw',
});
// "(min-width: 1024px) 50vw, (min-width: 768px) 75vw, 100vw"

focalPointToObjectPosition

Converts focal point coordinates (0–1 range) stored on a file record to a CSS object-position value.

import { focalPointToObjectPosition } from 'vextro/content';

const position = focalPointToObjectPosition(file.focalX, file.focalY);
// "35% 60%"

getLargestVariant / getSmallestVariant

Return the URL of the largest or smallest generated variant by width. Falls back to the original url if no variants exist.

import { getLargestVariant, getSmallestVariant } from 'vextro/content';

const ogImageUrl = getLargestVariant(post.coverImage);
const thumbnailUrl = getSmallestVariant(post.coverImage);

hasModernFormat

Returns true if the variant array includes WebP or AVIF files. Useful for conditionally rendering <source> elements.

import { hasModernFormat } from 'vextro/content';

if (hasModernFormat(file.sizes)) {
  // render <picture> with modern format sources
}

VextroImage Component

Renders a responsive <picture> with modern format <source> elements (AVIF, WebP) and a fallback <img>. Applies focal-point object-position automatically.

---
import VextroImage from 'vextro/content/VextroImage.astro';
---
<VextroImage
  source={post.coverImage}
  alt="Cover image"
  sizes="(min-width: 1024px) 50vw, 100vw"
  loading="lazy"
  class="rounded-xl"
/>

Props

PropTypeDefaultDescription
sourceVextroImageSource—File record from Convex (has url, sizes, focalX, focalY)
altstringrequiredAlt text for the <img>
sizesstring—HTML sizes attribute
width / heightnumber—Explicit dimensions
loading"lazy" | "eager"—Browser loading hint
fetchpriority"high" | "low" | "auto"—Resource priority hint
classstring—Class on the <picture> wrapper
imgClassstring—Class on the <img> element
focalX / focalYnumberfrom sourceOverride focal point (0–1 range)

Blocks

When a document uses a blocks field, each entry is stored as a BlockReference (just blockType and blockId). Before rendering you must resolve references to populate the data field. See Blocks: Component Architecture for the data fetching pattern.

VextroBlocks Component

Maps resolved blocks to renderer components by blockType.

---
import VextroBlocks from 'vextro/content/VextroBlocks.astro';
import HeroBlock from '../blocks/HeroBlock.astro';
import TextBlock from '../blocks/TextBlock.astro';

const components = {
  hero: HeroBlock,
  text: TextBlock,
};
---
<VextroBlocks blocks={resolvedBlocks} {components} />

Props

PropTypeDefaultDescription
blocksResolvedBlock[]—Resolved block array with data populated
componentsRecord<string, Component>—Object mapping blockType to Astro component
classstring—Class on the wrapper element
aselement tag"div"Wrapper element tag

Unknown block types render a dev-only placeholder. They are invisible in production builds.

VextroContent Component

A unified wrapper for pages that mix rich text sections with embedded block sections. Each section in the array is either { type: "richText", value } or { type: "blocks", value }.

---
import VextroContent from 'vextro/content/VextroContent.astro';
---
<VextroContent
  sections={post.content}
  blockComponents={components}
  richTextClass="prose"
/>

Props

PropTypeDefaultDescription
sectionsContentField[]—Ordered array of { type, value } sections (richText, blocks, or html)
blockComponentsRecord<string, Component>{}Object mapping blockType to Astro component
classstring—Class on the outer wrapper
richTextClassstring—Class applied to each rich text section
blocksClassstring—Class applied to each blocks section
aselement tag"div"Wrapper element tag

Types

// TipTap document tree
type TipTapDoc = { type: 'doc'; content: TipTapNode[] };
type TipTapNode = { type: string; attrs?: Record<string, unknown>; content?: TipTapNode[]; marks?: TipTapMark[]; text?: string };
type TipTapMark = { type: string; attrs?: Record<string, unknown> };

// Input accepted by renderTipTapToHtml / extractPlainText
type RichTextContent = TipTapDoc | string | null | undefined;

// Image source shape (compatible with VextroFileRecord from vextro/storage)
type VextroImageSource = {
  url: string;
  focalX?: number;
  focalY?: number;
  sizes?: ImageVariant[];
};

type ImageVariant = {
  name: string;
  url: string;
  width: number;
  height: number;
  mimeType: string;
  filesize: number;
};

// Block types
type BlockReference = { blockType: string; blockId: string; order?: number };
type ResolvedBlock = BlockReference & { data: Record<string, unknown> };
Previous
Persistence Model