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/sizesattributes, 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
| Prop | Type | Default | Description |
|---|---|---|---|
content | RichTextContent | — | TipTap JSON doc or HTML string |
class | string | — | 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
| Prop | Type | Default | Description |
|---|---|---|---|
source | VextroImageSource | — | File record from Convex (has url, sizes, focalX, focalY) |
alt | string | required | Alt text for the <img> |
sizes | string | — | HTML sizes attribute |
width / height | number | — | Explicit dimensions |
loading | "lazy" | "eager" | — | Browser loading hint |
fetchpriority | "high" | "low" | "auto" | — | Resource priority hint |
class | string | — | Class on the <picture> wrapper |
imgClass | string | — | Class on the <img> element |
focalX / focalY | number | from source | Override 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
| Prop | Type | Default | Description |
|---|---|---|---|
blocks | ResolvedBlock[] | — | Resolved block array with data populated |
components | Record<string, Component> | — | Object mapping blockType to Astro component |
class | string | — | Class on the wrapper element |
as | element 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
| Prop | Type | Default | Description |
|---|---|---|---|
sections | ContentField[] | — | Ordered array of { type, value } sections (richText, blocks, or html) |
blockComponents | Record<string, Component> | {} | Object mapping blockType to Astro component |
class | string | — | Class on the outer wrapper |
richTextClass | string | — | Class applied to each rich text section |
blocksClass | string | — | Class applied to each blocks section |
as | element 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> };