Last Updated: 2026-05-06 Status: Active
Ground-truth reference for every public API surface in @vlozi/blog. Generated by reading the source code, not the docs. Target version: @vlozi/blog@2.1.6+, last audited 2026-05-01.
IMPORTANT
Any other doc that contradicts this one is wrong — update this doc first, propagate to the rest.
1. Entry points
The package ships four module entry points. They are not interchangeable — pick based on what you're doing.
| Entry | Purpose | Browser / Node | Imports React? |
|---|---|---|---|
@vlozi/blog |
Headless VloziClient, types, errors, cache, RSS/sitemap. Server-safe. |
Both | No |
@vlozi/blog/react |
VloziProvider, hooks, all client components, skeletons. Client-only ("use client"). |
Browser | Yes |
@vlozi/blog/server |
Async React Server Components. Run on the server, stream HTML. Zero client JS. | Node / Edge / Workers | Yes (RSC) |
@vlozi/blog/next |
Next.js App Router helpers (generateStaticParamsForPosts, generateMetadataForPost). |
Node / Edge | No |
@vlozi/blog/styles.css |
Default prose + component CSS. Imported as a side-effect; targets .vlz-content and .vlz-*. |
(CSS) | — |
Naming collision to know about (will be renamed in a future patch):
@vlozi/blogexportsVloziConfig= the client config{ apiKey, baseUrl }.@vlozi/blog/reactexportsVloziConfig= the provider config{ mermaidTheme, syntaxHighlighting, imageComponent }.
These are different shapes with the same name. TypeScript resolves them by import path. If you need both in the same file: alias one of them at import (import type { VloziConfig as VloziProviderConfig } from "@vlozi/blog/react").
2. @vlozi/blog — headless surface
VloziClient
import { VloziClient } from "@vlozi/blog";
const client = new VloziClient({
apiKey: process.env.VLOZI_API_KEY!, // pk_*; sk_* keys throw VloziSecretKeyError
baseUrl: process.env.VLOZI_BASE_URL!, // e.g. "https://api.vlozi.app"
// Optional knobs:
timeoutMs: 10_000, // request timeout; 0 disables
retries: 2, // retry attempts on network/5xx/429
retryBackoffMs: 300, // initial backoff; doubles each attempt
cache: undefined, // CacheAdapter | false; default is shared InMemoryCache
cacheTtlMs: 60_000, // default TTL for cached GETs
onRequest: (ctx) => {}, // hook fires before each request
onResponse: (ctx) => {}, // hook fires after each response
onError: (ctx) => {}, // hook fires after retries exhausted
});Construction is non-throwing for missing credentials. apiKey: "" and baseUrl: "" both construct cleanly; the typed VloziConfigError is thrown on the FIRST API call. This is intentional — next build imports module-scope clients at static-prerender time, when env vars may not yet be wired up. The build succeeds with placeholder credentials; the error fires at the first real call.
The synchronous sk_* check is the only construction-time throw. Secret keys must never reach a client bundle.
Public properties:
| Property | Type | Notes |
|---|---|---|
client.baseUrl |
string |
Normalized (trailing slash stripped) |
client.cache |
CacheAdapter | null |
null when caching is disabled |
client.blog |
BlogApi |
Memoized lazy getter — same instance every read |
client.invalidate(pattern) |
(string | RegExp) => void |
Drop matching cache entries |
client.mutate(key, value, ttlMs?) |
(key, T, ttlMs?) => void |
Manually write to cache (optimistic updates) |
client.blog — public API
| Method | Signature | Returns | Notes |
|---|---|---|---|
list(params?, opts?) |
(ListParams?, RequestOptions?) |
Promise<PaginatedResponse<Post>> |
List endpoint excludes content |
get(slug, opts?) |
(string, RequestOptions?) |
Promise<Post> |
Single-post endpoint includes content (HTML) |
archive(opts?) |
({ maxPosts?, requestOptions? }?) |
Promise<ArchiveResult> |
Paginates through up to maxPosts (default 1000) and groups by year/month client-side |
related(slug, opts?) |
(string, { limit?, pool?, requestOptions? }?) |
Promise<Post[]> |
Score = +3 per shared category, +1 per shared tag |
neighbors(slug, opts?) |
(string, { pool?, requestOptions? }?) |
Promise<NeighborsResult> |
Default pool: 500 |
categories.list(opts?) |
(RequestOptions?) |
Promise<{ data: Category[] }> |
|
tags.list(opts?) |
(RequestOptions?) |
Promise<{ data: Tag[] }> |
RequestOptions.cache: "default" (read+write) | "no-store" (skip cache) | "reload" (skip read, refresh cache).
Types
Post
interface Post {
title: string;
slug: string; // canonical React key — no `id` field exposed
excerpt: string;
content?: string; // HTML. Only on `client.blog.get()`.
publishedAt: string; // ISO-8601
seoTitle?: string;
seoDescription?: string;
featuredImageUrl?: string | null;
category?: Category | null;
tags?: Tag[];
author?: Author | null;
readingTime?: number; // computed client-side if missing
}Critical: there is no Post.id. Use slug for key= and routing.
Category / Tag
interface Category { name: string; slug: string; postCount?: number; }
interface Tag { name: string; slug: string; postCount?: number; }Author
interface Author {
name: string;
slug?: string; // for /authors/:slug pages
avatarUrl?: string;
bio?: string;
url?: string;
}PaginatedResponse<T> / ListParams / ArchiveResult / NeighborsResult
interface PaginatedResponse<T> {
data: T[];
meta: { page: number; limit: number; total: number; totalPages: number };
}
interface ListParams {
page?: number; // default 1
limit?: number; // default 10
category?: string | string[]; // OR-matched if array
tag?: string | string[];
search?: string;
sort?: 'publishedAt' | 'title' | 'createdAt';
order?: 'asc' | 'desc';
}
interface ArchiveGroup {
year: number; month: number; monthName: string;
posts: Post[]; count: number;
}
interface ArchiveResult { groups: ArchiveGroup[]; totalPosts: number; }
interface NeighborsResult { previous: Post | null; next: Post | null; }Errors — typed hierarchy
VloziError // base — `instanceof VloziError` for catch-all
├── VloziConfigError // missing apiKey/baseUrl on first request; .field, .hint
├── VloziApiError // any non-2xx HTTP response; .status, .url, .requestId, .detail
│ ├── VloziAuthError // 401/403
│ └── VloziRateLimitError // 429; .retryAfter (seconds)
├── VloziNetworkError // DNS, offline, CORS — no response received; .url, .cause
├── VloziTimeoutError // exceeded timeoutMs; .url, .timeoutMs
└── VloziSecretKeyError // sk_* passed as apiKey; thrown at constructionError messages are pre-formatted for log aggregators:
Vlozi API Error (404): Not Found [GET https://api.vlozi.app/blog/public/posts/x] (requestId=req_abc123)Cache adapters
import { InMemoryCache, type CacheAdapter, type CacheEntry } from "@vlozi/blog";
// Defaults: maxEntries: 100, maxAgeMultiplier: 5 (so SWR window = ttlMs * 5)
new InMemoryCache({ maxEntries: 200, maxAgeMultiplier: 10 });
// Or implement your own:
class RedisCache implements CacheAdapter {
get<T>(key: string): CacheEntry<T> | undefined { /* ... */ }
set<T>(key: string, value: T, ttlMs: number): void { /* ... */ }
delete(key: string): void { /* ... */ }
invalidate(pattern: string | RegExp): void { /* ... */ }
clear(): void { /* ... */ }
subscribe(key: string, listener: () => void): () => void { /* ... */ }
}CacheAdapter MUST return stale entries (between staleAt and expiresAt) so callers can use them for SWR. The subscribe method is what powers useSyncExternalStore integration in the hooks.
Feeds — generateRSS / generateSitemap
import { generateRSS, generateSitemap } from "@vlozi/blog";
// RSS 2.0 string
const rssXml = await generateRSS({
client,
siteUrl: "https://example.com",
title: "Example Blog",
description: "Latest posts",
postUrl: (slug) => `https://example.com/blog/${slug}`, // optional
language: "en-us",
limit: 50,
category: undefined,
});
// Sitemap XML
const sitemapXml = await generateSitemap({
client,
siteUrl: "https://example.com",
additionalUrls: [
{ loc: "https://example.com/", priority: 1.0, changefreq: "daily" },
],
});Both helpers paginate through up to maxPosts (default 1000) and build XML in memory. No streaming.
3. @vlozi/blog/react — React surface
VloziProvider
import { VloziClient } from "@vlozi/blog";
import { VloziProvider } from "@vlozi/blog/react";
import "@vlozi/blog/styles.css"; // see "CSS contract" below
const client = new VloziClient({ apiKey: ..., baseUrl: ... });
<VloziProvider
client={client}
config={{
mermaidTheme: "auto", // "default" | "dark" | "auto" | () => "default" | "dark"
syntaxHighlighting: true, // true emits data-vlz-syntax="default"; false omits it
imageComponent: undefined, // default <img>; pass Next.js Image to upgrade
}}
>
{children}
</VloziProvider>The client prop is required. config is optional — defaults are sensible. Per-component prop overrides win over provider config.
Hooks for reading the provider:
| Hook | Returns | Throws? |
|---|---|---|
useVlozi() |
VloziClient |
Throws if no provider |
useOptionalVlozi() |
VloziClient | null |
Never; use for components that work standalone |
useVloziConfig() |
VloziConfig (provider config) |
Never; returns {} if no provider |
Data hooks
All hooks return stable references — they only change identity when their underlying state changes. Safe in dependency lists.
const {
data, // PaginatedResponse<Post> | null
loading, error,
refetch, // () => Promise<void>; bypasses cache
page, totalPages, hasNextPage, hasPrevPage,
} = usePosts(params?: ListParams);
const { data, loading, error } = usePost(slug: string);
const { data, loading, error, refetch } = useCategories();
const { data, loading, error, refetch } = useTags();
const { data, loading, error, refetch } = useArchive({ maxPosts? });
const { data, loading, error } = useRelatedPosts(slug, { limit?, pool? });
const { data, loading, error } = useNeighbors(slug, { pool? });usePost and usePosts both subscribe to cache changes via useSyncExternalStore — calling client.invalidate(/foo/) or client.mutate(key, post) re-renders consumers automatically.
Components — full prop reference
<BlogContent>
Renders post HTML body. Sanitizes, hydrates carousel/mermaid blocks, optionally upgrades inline body images.
<BlogContent
html={post.content!} // required
className?: string // merged with .vlz-content
transformHtml?: (html: string) => string // run BEFORE sanitization
imageComponent?: VloziImageComponent // upgrade inline <img> tags via portal
/>transformHtmlis the consumer-side escape hatch for patching server bugs. Output is still sanitized — not an XSS escape.imageComponenttriggers body-image hydration. Each non-mermaid, non-carousel, non-<picture><img>gets wrapped in a<span data-vlz-img-host>and the consumer's component is portal-mounted over it. Setdata-vlz-skip-hydrateon a specific<img>in the source HTML to opt out per-image.- When
imageComponentis unset AND no provider config has one, image hydration is skipped entirely (no DOM mutation). <BlogContent>always emits<div class="vlz-content">and (unlesssyntaxHighlighting: falsein provider)data-vlz-syntax="default".
<BlogPost>
Full-page post — featured image + header + body via <BlogContent>. Uses usePost internally.
<BlogPost
slug={params.slug} // required
className?: string // applied to <article>
renderTitle?: Slot<[title: string]> // default: <h1>
showFeaturedImage?: boolean // default true
imageComponent?: VloziImageComponent // featured image only
renderLoading?: Slot // default: <BlogPostSkeleton>
renderError?: Slot<[error: Error]> // default: same as renderNotFound
renderNotFound?: Slot // shown when usePost resolves to null
/>renderError fires for fetch failures. renderNotFound fires for 404s. Default they share UI; override either to distinguish.
<BlogList>
Paginated grid + optional search/sort. Uses usePosts internally.
<BlogList
className?: string
limit?: number // default 10
columns?: 1 | 2 | 3 | 4 // default 3
variant?: "grid" | "list" // default "grid"
category?: string | string[] // OR-matched if array
tag?: string | string[]
showPagination?: boolean // default true
renderItem?: Slot<[post: Post]> // replaces <BlogCard>
renderLoading?: Slot // default: <BlogListSkeleton>
renderEmpty?: Slot // "No posts found"
renderError?: Slot<[error: Error]>
imageComponent?: VloziImageComponent // forwarded to each card
prefetchOnHover?: boolean // forwarded to each card
scrollToTopOnPageChange?: boolean // default true
search?: string // controlled query (wins over searchable input)
searchable?: boolean // built-in input with debounce
searchPlaceholder?: string // default "Search posts..."
searchDebounceMs?: number // default 300
sortable?: boolean // built-in sort dropdown
/>- Controlled
searchprop wins oversearchable. Whitespace trimmed. Empty string omits the param. - Page resets to 1 when search/category/tag/sort changes.
<BlogInfiniteList>
Infinite-scroll variant of <BlogList>. Uses usePosts internally and an IntersectionObserver.
<BlogInfiniteList
className?: string
limit?: number // page size (default 10)
columns?: 1 | 2 | 3 | 4 // default 3
category?, tag?, search?, sort?, order? // see ListParams
renderItem?: Slot<[post: Post]>
renderLoading?, renderEmpty?, renderError?
imageComponent?: VloziImageComponent
prefetchOnHover?: boolean
rootMargin?: string // IntersectionObserver; default "400px"
/><BlogCard>
Single post card. Standalone-safe (works without <VloziProvider> if prefetchOnHover is off).
<BlogCard
post={post} // required
className?: string
variant?: "default" | "featured" | "compact" // default "default"
renderMeta?: Slot<[post: Post]>
footer?: ReactNode
onClick?: (post: Post) => void
imageComponent?: VloziImageComponent
prefetchOnHover?: boolean // default false
/><BlogCategoryNav>
<BlogCategoryNav
className?: string
activeCategory?: string // controlled — current slug
onSelect?: (slug: string | null) => void // null = "All"
variant?: "sidebar" | "tabs" | "pills" // default "sidebar"
showCounts?: boolean // default true
renderLoading?, renderEmpty?: Slot
/><BlogTagNav>
<BlogTagNav
className?: string
activeTag?: string // controlled
onSelect?: (slug: string | null) => void // null = "All tags"
variant?: "pills" | "cloud" // default "pills"
showCounts?: boolean // default true
renderLoading?, renderEmpty?: Slot
/>onSelect returns null for the "All" button. Convert to undefined for ListParams: onSelect={(slug) => setTag(slug ?? undefined)}.
<BlogArchive>
<BlogArchive
className?: string
maxPosts?: number // default 1000
postHref?: (post: Post) => string // default `/blog/${slug}`
variant?: "flat" | "grouped" // default "grouped"
prefetchOnHover?: boolean // default false
renderLoading?, renderError?, renderEmpty?: Slot
renderGroup?: Slot<[group: ArchiveGroup]> // replaces default post list
/><RelatedPosts>
Fail-silent on errors. Returns null when no related posts found (unless renderEmpty is set).
<RelatedPosts
slug={post.slug} // required
className?: string
limit?: number // default 3
pool?: number // default 100
heading?: ReactNode // default "Related posts"; pass null to hide
columns?: 1 | 2 | 3 | 4 // default 3
imageComponent?: VloziImageComponent
prefetchOnHover?: boolean
renderItem?: Slot<[post: Post]>
renderLoading?, renderError?, renderEmpty?: Slot
/><PrevNextNav>
<PrevNextNav
slug={post.slug} // required
className?: string
pool?: number // default 500
postHref?: (post: Post) => string
renderLink?: Slot<[post: Post, direction: "previous" | "next"]>
renderLoading?: Slot
hideWhenEmpty?: boolean // default true
/><MermaidBlock>, <Carousel>
Standalone exports of the rich-content components. Used internally by <BlogContent> hydration; usable directly:
<MermaidBlock
source={mermaidSourceString} // required
className?: string
theme?: VloziMermaidTheme // override; falls back to provider, then "auto"
/>
<Carousel
slides={[{ src: "/a.jpg", alt: "...", caption?: "..." }]} // required
className?: string
/>Skeleton primitives
import { BlogPostSkeleton, BlogCardSkeleton, BlogListSkeleton } from "@vlozi/blog/react";
<BlogPostSkeleton className?: string />
<BlogCardSkeleton className?: string />
<BlogListSkeleton
columns?: 1 | 2 | 3 | 4 // default 3
count?: number // default columns * 2
className?: string
/>The internal <BlogPost> and <BlogList> use these primitives — guaranteed parity between SDK default loading state and what consumers can render.
VloziImageComponent contract
interface VloziImageProps {
src: string;
alt: string;
className?: string;
loading?: "lazy" | "eager";
decoding?: "sync" | "async" | "auto";
width?: number; // body-image hydration only — featured paths don't pass these
height?: number;
}
type VloziImageComponent = ComponentType<VloziImageProps>;DefaultVloziImage is exported but is just <img>. Pass <Image> from Next.js, Gatsby Image, or your own component to upgrade.
Slot type
Every render* prop accepts a Slot<Args> — either a ReactNode or a function (...args) => ReactNode. Resolve via:
import { type Slot, resolveSlot } from "@vlozi/blog/react"; // resolveSlot is internal but exportedIn practice, consumers just pass JSX or a function. The SDK handles both.
4. @vlozi/blog/server — async Server Components
import { ServerBlogList, ServerBlogPost, ServerBlogCategoryList } from "@vlozi/blog/server";
import { VloziClient } from "@vlozi/blog";
const client = new VloziClient({ apiKey: ..., baseUrl: ... });
// Drop-in for app/blog/page.tsx — zero client JS, pure SSR HTML
export default async function BlogIndexPage() {
return <ServerBlogList client={client} limit={9} />;
}<ServerBlogList>
<ServerBlogList
client={client} // required
page?: number // default 1
limit?: number // default 10
category?, tag?, search?, sort?, order? // ListParams
postHref?: (post: Post) => string
notFound?: ReactNode // empty-state JSX
className?: string
/><ServerBlogPost>
<ServerBlogPost
client={client} // required
slug={params.slug} // required
notFound?: ReactNode // shown on 404
render?: (post: Post) => ReactNode // custom layout
showFeaturedImage?: boolean // default true
className?: string
/><ServerBlogPost> runs sanitizeHtml on the post body but does NOT do hydration (no React state). Use it for SEO-critical pages where you don't need interactive carousels/mermaid. For interactive features, render <BlogContent> from @vlozi/blog/react inside a client component instead.
<ServerBlogCategoryList>
<ServerBlogCategoryList
client={client} // required
activeCategory?: string
categoryHref?: (cat: Category) => string
showCounts?: boolean
className?: string
/>5. @vlozi/blog/next — Next.js helpers
import { generateStaticParamsForPosts, generateMetadataForPost } from "@vlozi/blog/next";
import type { GenerateStaticParamsOptions, GenerateMetadataOptions, VloziMetadata } from "@vlozi/blog/next";
// app/blog/[slug]/page.tsx
export const generateStaticParams = () =>
generateStaticParamsForPosts({ client, paramKey: "slug", limit: 1000 });
export const generateMetadata = async ({ params }) => {
const { slug } = await params;
return generateMetadataForPost({
client,
slug,
siteUrl: "https://example.com", // optional; used to build canonical + og urls
});
};generateMetadataForPost returns a Metadata object compatible with Next's Metadata type. It builds title, description, OpenGraph, Twitter card, and canonical URL from the post's SEO fields.
generateStaticParamsForPosts paginates sequentially in 100-post batches. For >1000 posts, set limit higher or pass paramKey if your route param isn't slug.
6. CSS contract
The five --vlz-* knobs
Every .vlz-* component class derives its color from one of:
| Token | Default | Used for |
|---|---|---|
--vlz-accent |
#3b82f6 |
Links, syntax keywords, blockquote rule, task checkbox, active states |
--vlz-muted-fg |
color-mix(in oklch, currentColor 55%, transparent) |
Figcaptions, list markers, syntax comments, secondary text |
--vlz-border |
color-mix(in oklch, currentColor 18%, transparent) |
Tables, <hr>, <details>, card borders |
--vlz-surface |
color-mix(in oklch, currentColor 6%, transparent) |
Code blocks, table headers, <summary>, card surfaces |
--vlz-surface-hover |
color-mix(in oklch, currentColor 10%, transparent) |
Hover state on the above |
Override on .vlz-content (or any ancestor). For shadcn-style HSL tokens:
.vlz-content {
--vlz-accent: hsl(var(--primary));
--vlz-muted-fg: hsl(var(--muted-foreground));
--vlz-border: hsl(var(--border));
--vlz-surface: hsl(var(--muted) / 0.6);
--vlz-surface-hover: hsl(var(--muted) / 0.8);
}For Tailwind v4 with @theme: var(--color-primary), var(--color-muted-foreground), etc.
CSS class taxonomy (~249 distinct classes)
Documented in packages/blog-sdk/src/react/styles.css. Grouped by component:
| Component | Class roots |
|---|---|
| Body (BlogContent) | .vlz-content, .vlz-content h1-h6, .vlz-content p/a/strong/em/ul/ol/li/img/table/code/pre/figure/blockquote/hr/details/.callout/.task-list/.task-item/.video-embed |
| Hydrated rich blocks | .vlz-mermaid*, .vlz-carousel* |
| Syntax highlighting | [data-vlz-syntax="default"] .hljs-* |
| Hydration host hooks | [data-vlz-hydrated="mermaid"], [data-vlz-hydrated="carousel"], [data-vlz-img-host] |
| BlogCard | .vlz-card, .vlz-card--{default,featured,compact,clickable}, .vlz-card-{image,thumb,body,meta,title,excerpt,tags,tag,footer} |
| BlogList | .vlz-list, .vlz-grid, .vlz-grid--cols-{1,2,3,4}, .vlz-list-toolbar*, .vlz-pagination* |
| BlogPost | .vlz-post, .vlz-post-{banner,header,title,meta,tags,tag}, .vlz-post-skeleton* |
| BlogArchive | .vlz-archive, .vlz-archive--{grouped,flat}, .vlz-archive-{group,heading,count,list,item,link,date} |
| BlogCategoryNav | .vlz-cat-{nav-row,sidebar,sidebar-item,sidebar-count,tab,tab--active,nav-skeleton,skeleton-row} |
| BlogTagNav | .vlz-tag, .vlz-tag--{active,cloud}, .vlz-tag-{count,skeleton,nav} |
| PrevNextNav | .vlz-prev-next, .vlz-prev-next-{link,direction,title,skeleton}, .vlz-prev-next-link--next |
| RelatedPosts | .vlz-related, .vlz-related-heading |
| Skeletons | .vlz-card-skeleton*, .vlz-post-skeleton*, .vlz-skeleton, .vlz-tag-skeleton, .vlz-cat-skeleton-row, .vlz-archive-skeleton* |
| Form inputs (in BlogList toolbar) | .vlz-input, .vlz-select |
| Utility | .vlz-sr-only (visually-hidden text) |
All classes accept consumer overrides through normal CSS — no !important shipped anywhere in the SDK.
data-vlz-* attribute hooks
| Attribute | Set on | Meaning |
|---|---|---|
data-vlz-hydrated="mermaid" |
<pre> |
This <pre> is a mermaid diagram host — was rendered as <pre><code class="language-mermaid">, now portal-mounts a <MermaidBlock> |
data-vlz-source |
<pre> (mermaid) |
Cached mermaid source (idempotency hook) |
data-vlz-hydrated="carousel" |
<div data-type="carousel"> |
This carousel host has been parsed and is ready for hydration |
data-vlz-slides |
carousel host | Cached parsed slides (idempotency hook) |
data-vlz-img-host |
<span> |
Wraps a body <img> post-scan; React portal mounts the consumer's imageComponent here |
data-vlz-img-{src,alt,width,height,class,loading} |
image host | Captured original <img> attributes |
data-vlz-skip-hydrate |
any <img> |
Per-image opt-out for body image hydration |
data-vlz-syntax="default" |
<BlogContent> root |
Activates the SDK's .hljs-* token palette. Omitted when syntaxHighlighting: false in provider |
Consumer styling rules of thumb:
- Don't style bare
<pre>or<iframe>in your global CSS — both are portal-mounted hosts in some posts. Scope under.vlz-content pre:not([data-vlz-hydrated])for ordinary code blocks. - The wrapper div around YouTube embeds (
<div class="video-embed">) gets stripped by the sanitizer when its iframe is non-allowlisted (prevents phantom 16:9 boxes). - Allowlisted iframe
srcprefixes:https://www.youtube-nocookie.com/,https://youtube-nocookie.com/,https://www.youtube.com/embed/,https://youtube.com/embed/. Everything else is stripped.
7. Server-side rendering pipeline (blog-service)
The blog API runs in apps/blog-service as a Cloudflare Worker. The relevant pipeline:
- Editor saves post as Tiptap JSON (in
apps/seller-dashboard). - On publish: JSON is stored in the database.
- On
GET /blog/public/posts/:slug: blog-service runsgenerateHtmlCustomfromapps/blog-service/src/utils/tiptap-renderer.tsto convert JSON → HTML. - The HTML lands in
Post.contentreturned fromclient.blog.get(). - SDK's
<BlogContent>runssanitizeHtmlon it, then progressively hydrates known rich blocks.
Renderer node-type coverage
| Node type | Rendered as | Notes |
|---|---|---|
paragraph |
<p> |
Empty paragraphs become <p> </p> |
heading (level 1-6) |
<h1> through <h6> |
|
bulletList / orderedList |
<ul> / <ol> |
|
listItem |
<li> |
|
blockquote |
<blockquote> |
|
horizontalRule |
<hr> |
|
hardBreak |
<br> |
|
codeBlock |
<pre><code class="language-X"> with lowlight tokenization |
Mermaid passes through untokenized (skipped by language === "mermaid") |
table / tableRow / tableHeader / tableCell |
<table> / <tr> / <th> / <td> |
colspan/rowspan supported |
taskList / taskItem |
<ul class="task-list"> / <li class="task-item"> with <input type="checkbox" disabled> |
|
image |
<img> (or <figure> if caption set) |
|
youtube |
<div class="video-embed"><iframe src="..."></iframe></div> |
URL normalized to youtube-nocookie.com/embed/${id} regardless of input form |
callout |
<div class="callout callout-{info,warning,tip,danger}"> |
|
toggle |
<details open><summary>...</summary>...</details> |
|
carousel |
NOT IMPLEMENTED — falls through to default case |
🔴 Known bug: posts with carousels render as flat figure stacks. The SDK's hydration scanner expects <div data-type="carousel" data-slides='[...]'>; the renderer never emits it. See "Known bugs" below. |
| anything else | inner (children rendered, no wrapper) |
Graceful degradation |
Marks (inline)
| Mark | Rendered as |
|---|---|
bold |
<strong> |
italic |
<em> |
strike |
<s> |
underline |
<u> |
code |
<code> |
highlight |
<mark> |
link |
<a href="..." target="_blank" rel="noopener noreferrer"> (URLs are sanitized — javascript:/vbscript:/data:text/html all rejected; anchors and mailto:/tel: pass) |
Syntax highlighting (server-side, 2.1.6+)
Code blocks with a language attribute are tokenized by lowlight at render time. Output:
<pre><code class="language-typescript">
<span class="hljs-keyword">const</span> x: <span class="hljs-keyword">string</span> = <span class="hljs-string">"hi"</span>;
</code></pre>Registered languages: lowlight.common (~37 languages) plus explicit html/css/js/ts aliases. Unknown languages fall back to escaped text inside the language-* class.
mermaid blocks are deliberately NOT tokenized — they're hydrated as live SVG diagrams by the SDK's <MermaidBlock> component instead.
8. Known bugs (as of 2.1.6)
| # | Bug | Impact | Status |
|---|---|---|---|
| 1 | VloziConfig name collision between @vlozi/blog (client config) and @vlozi/blog/react (provider config) |
Confusing for consumers using both imports in one file | Tracked for rename in 2.1.7 |
| 2 | apps/blog-service renderer has no case "carousel" — carousels stored in Tiptap JSON lose their data-type="carousel" wrapper, so the SDK's hydration scanner finds nothing |
Posts with carousels render as flat figure stacks. Embla carousel never mounts. | Tracked for next blog-service deploy |
| 3 | apps/blog-service is the only place YouTube URL normalization happens |
Pre-2.1.6 posts in a database may still have raw youtube.com/watch?v= URLs in their stored HTML if they were never re-rendered |
Mitigated client-side by transformHtml escape hatch on <BlogContent> |
| 4 | Sanitizer uses regex over string (5-pass fixed-point), not DOM-parser | Slower on long posts; regex-fragile on nested adversarial input | Defense-in-depth only — primary sanitization is server-side. Refactor tracked as B8 in sdk-backlog.md. |
9. Differences between SDK ground truth and apps/seller-dashboard editor
The dashboard's Tiptap editor extensions support node types the renderer doesn't yet emit. If you see a JSON node type in the editor that isn't in the "Renderer node-type coverage" table above, it's either:
- A node we render correctly (in the table) — fine.
- A node the renderer drops (
carouseltoday) — falls through todefaultand renders only inner content.
When adding new editor extensions, the renderer must be updated in the same change or the new feature ships broken in production.
10. Stable contract for consumer overrides
The following are part of the SDK's PUBLIC contract and won't change without a major version bump:
- All exported types, classes, hooks, components in Entry points
- The five
--vlz-*token names + defaults - The
.vlz-contentclass as the prose wrapper - Per-component
.vlz-*class roots listed in CSS class taxonomy - The
data-vlz-*attribute hooks listed above Post.slugas the canonical React key (no opaqueid)- The four entry points (
@vlozi/blog,/react,/server,/next) - Error class names and shapes
The following are INTERNAL — consumers shouldn't depend on them:
- The exact selector list in
styles.css(some classes are implementation details — only the documented roots are stable) - The internal
findHydrationTargetsfunction signature (underscore-prefixed = internal) - The hydration scanner's exact mutation pattern (only the resulting
data-vlz-*attributes are stable) - Node-type names in the Tiptap JSON (consumers receive HTML, not JSON)
- Internal React state shape of any component (use props, not refs)
11. Where this doc fits
- Authoritative reference: this doc.
- AI integration prompt:
ai-integration-prompt.md— paste-and-go for AI assistants. Should reference this doc for any "what does X actually accept?" question. - Living queue of bugs/improvements:
sdk-backlog.md— slottable items per future release. - Version-specific changelogs:
packages/blog-sdk/CHANGELOG.md. - Future visions:
layouts-vision.md,hosted-blog-vision.md,integration-friction-vision.md.
When a contradicition appears between this doc and another, this doc is the ground truth — fix the other doc, not this one.