logicspike/docs

Blog Engine

`@vlozi/blog` — SDK Reference

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/blog exports VloziConfig = the client config { apiKey, baseUrl }.
  • @vlozi/blog/react exports VloziConfig = 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 construction

Error 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
/>
  • transformHtml is the consumer-side escape hatch for patching server bugs. Output is still sanitized — not an XSS escape.
  • imageComponent triggers 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. Set data-vlz-skip-hydrate on a specific <img> in the source HTML to opt out per-image.
  • When imageComponent is unset AND no provider config has one, image hydration is skipped entirely (no DOM mutation).
  • <BlogContent> always emits <div class="vlz-content"> and (unless syntaxHighlighting: false in 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 search prop wins over searchable. 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
/>

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
/>

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 exported

In 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 src prefixes: 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:

  1. Editor saves post as Tiptap JSON (in apps/seller-dashboard).
  2. On publish: JSON is stored in the database.
  3. On GET /blog/public/posts/:slug: blog-service runs generateHtmlCustom from apps/blog-service/src/utils/tiptap-renderer.ts to convert JSON → HTML.
  4. The HTML lands in Post.content returned from client.blog.get().
  5. SDK's <BlogContent> runs sanitizeHtml on it, then progressively hydrates known rich blocks.

Renderer node-type coverage

Node type Rendered as Notes
paragraph <p> Empty paragraphs become <p>&nbsp;</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 (carousel today) — falls through to default and 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-content class as the prose wrapper
  • Per-component .vlz-* class roots listed in CSS class taxonomy
  • The data-vlz-* attribute hooks listed above
  • Post.slug as the canonical React key (no opaque id)
  • 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 findHydrationTargets function 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

When a contradicition appears between this doc and another, this doc is the ground truth — fix the other doc, not this one.

Blog Engine