React VAST formatter + validator guide
If you already print formatted VAST back to users as HTML, keep that renderer. The smallest useful integration is to add vastlint.validate(xml) , line-aware highlighting, and an issue rail beside your existing formatter. The full page-shaped validator is still here lower down, but it is now the second step instead of the first one.
What this guide gives you
- A small formatter-first recipe for Publica-style and other HTML-rendered XML views
- A full React validator page when you do want the whole workbench
- A hosted preview that mirrors the production vastlint workbench
- A concrete source file you can adapt instead of a vague component sketch
Quick reference
| Framework | React |
| Package dependency | vastlint |
| Works in | Vite, Webpack 5, Rollup, Next.js client components |
| Includes | Formatter-first mix-in, issue rail, line highlights, and full-page reference |
| Best fit | Existing formatter UIs, internal QA pages, support consoles, creative review tools |
Install
The take-home page is intentionally self-contained. If your app already uses React, the only runtime package you need for this example is vastlint.
npm install vastlintSimple mix-in for existing HTML formatters
This is the path for teams that already have a syntax-highlighted XML block, a Publica-style HTML renderer, or any other formatter that prints VAST back to the user. Do not replace that view. Keep it, run validate(xml), group issues by line, tint the matching rows, and render the errors and warnings in a side rail.
- Keep your current XML-to-HTML formatter and only swap the line renderer hook.
- Call
validate(xml)every time the displayed XML changes. - Use
issue.lineto tint rows andissue.idplusissue.messageto build the issue list.
"use client";
import { useMemo } from "react";
import { validate, type Issue } from "vastlint";
function escapeHtml(value: string) {
return value
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
// Replace this with your existing Publica-style formatter.
function formatLineAsHtml(line: string) {
return escapeHtml(line);
}
function buildIssuesByLine(issues: Issue[]) {
const byLine = new Map<number, Issue[]>();
for (const issue of issues) {
if (issue.line == null) continue;
const existing = byLine.get(issue.line) ?? [];
existing.push(issue);
byLine.set(issue.line, existing);
}
return byLine;
}
function renderXmlHtml(xml: string, issuesByLine: Map<number, Issue[]>) {
return xml
.split("\n")
.map((line, index) => {
const lineNumber = index + 1;
const issues = issuesByLine.get(lineNumber) ?? [];
const hasError = issues.some((issue) => issue.severity === "error");
const hasWarning = issues.some((issue) => issue.severity === "warning");
const markerColor = hasError ? "#dc2626" : hasWarning ? "#f59e0b" : "#94a3b8";
const background = hasError
? "background: rgba(220, 38, 38, 0.10);"
: hasWarning
? "background: rgba(245, 158, 11, 0.12);"
: "";
const marker =
'<span style="display:inline-block;width:18px;color:' +
markerColor +
';font-size:11px;">●</span>';
return (
'<div data-line="' +
lineNumber +
'" style="white-space: pre; border-radius: 4px; padding-right: 8px; ' +
background +
'">' +
marker +
formatLineAsHtml(line) +
"</div>"
);
})
.join("");
}
export function VastHtmlWithLint({ xml }: { xml: string }) {
const result = useMemo(() => validate(xml), [xml]);
const issuesByLine = useMemo(() => buildIssuesByLine(result.issues), [result.issues]);
const html = useMemo(() => renderXmlHtml(xml, issuesByLine), [xml, issuesByLine]);
return (
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
<section className="space-y-3">
<div className="flex flex-wrap gap-2 text-xs">
<span className="rounded border border-red-500/20 bg-red-500/10 px-2 py-1">
{result.summary.errors} errors
</span>
<span className="rounded border border-amber-500/20 bg-amber-500/10 px-2 py-1">
{result.summary.warnings} warnings
</span>
<span className="rounded border border-slate-300 bg-slate-100 px-2 py-1">
{result.summary.infos} advisories
</span>
</div>
<div
className="rounded-lg border bg-surface p-3 font-mono text-xs leading-6"
dangerouslySetInnerHTML={{ __html: html }}
/>
</section>
<aside className="space-y-2">
{result.issues.map((issue) => (
<button
key={[issue.id, issue.line ?? "doc", issue.col ?? 0].join(":")}
type="button"
onClick={() => {
if (issue.line == null) return;
document
.querySelector('[data-line="' + issue.line + '"]')
?.scrollIntoView({ block: "center", behavior: "smooth" });
}}
className="block w-full rounded-lg border border-border bg-surface-1 p-3 text-left"
>
<div className="flex items-center justify-between gap-3 text-xs font-semibold">
<span>{issue.id}</span>
<span>{issue.line == null ? "Document" : "Line " + issue.line}</span>
</div>
<p className="mt-1 text-xs text-text-secondary">{issue.message}</p>
</button>
))}
{result.issues.length === 0 ? (
<div className="rounded-lg border border-emerald-500/20 bg-emerald-500/10 p-3 text-sm">
No errors, warnings, or advisories.
</div>
) : null}
</aside>
</div>
);
}The one line you are expected to replace is formatLineAsHtml(line). Everything else is the generic vastlint wiring that surfaces severity counts, row-level highlighting, and clickable error or warning cards.
Full-page reference implementation
If you want more than a formatter overlay, the hosted preview below shows the heavier page-shaped version: editor on the left, results on the right, plus preview and auto-fix affordances. Use this when you need a dedicated QA workbench instead of just surfacing errors inside an existing formatter view.
Drop in the full page instead
Once you copy the component file, render it from a route or top-level page. If you decide the formatter-first recipe is too small for your use case, this is the next step.
import VastLintTakeHomePage from "./VastLintTakeHomePage";
export default function App() {
return <VastLintTakeHomePage />;
}Full-page source
This is the complete hosted implementation used for the isolated preview above. It is intentionally verbose because it is a page-shaped baseline, not the small formatter overlay recipe shown earlier.
"use client";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ClipboardEvent,
type DragEvent,
} from "react";
import type { Issue as VastlintIssue } from "vastlint";
import { ResultsPanel } from "@/components/ResultsPanel";
import { VastPreview } from "@/components/VastPreview";
import { XmlEditor } from "@/components/XmlEditor";
import { resolveLines } from "@/lib/resolveLines";
import { formatVersion, isValidationError, type ValidationResult } from "@/lib/types";
import { validateXml } from "@/lib/validate";
const SAMPLE_BROKEN_XML = [
"\x3c?xml version=\"1.0\" encoding=\"UTF-8\"?>",
"\x3cVAST version=\"4.2\">",
" \x3cAd id=\"1\">",
" \x3cInLine>",
" \x3cAdSystem>Acme\x3c/AdSystem>",
" \x3cAdTitle>Bad Values Ad\x3c/AdTitle>",
" \x3cAdServingId>SERVING-001\x3c/AdServingId>",
" \x3cImpression>\x3c![CDATA[https://track.example.com/impression]]>\x3c/Impression>",
" \x3cCreatives>",
" \x3cCreative>",
" \x3cUniversalAdId idRegistry=\"ad-id.org\">TEST-001\x3c/UniversalAdId>",
" \x3cLinear>",
" \x3cDuration>90 seconds\x3c/Duration>",
" \x3cMediaFiles>",
" \x3cMediaFile delivery=\"download\" type=\"video/mp4\" width=\"1280\" height=\"720\">",
" \x3c![CDATA[https://cdn.example.com/video.mp4]]>",
" \x3c/MediaFile>",
" \x3c/MediaFiles>",
" \x3c/Linear>",
" \x3c/Creative>",
" \x3c/Creatives>",
" \x3c/InLine>",
" \x3c/Ad>",
"\x3c/VAST>",
].join("\n");
const SAMPLE_CLEAN_XML = [
"\x3c?xml version=\"1.0\" encoding=\"UTF-8\"?>",
"\x3cVAST version=\"4.2\">",
" \x3cAd id=\"1\">",
" \x3cInLine>",
" \x3cAdSystem>Acme\x3c/AdSystem>",
" \x3cAdTitle>Clean Example Ad\x3c/AdTitle>",
" \x3cAdServingId>SERVING-001\x3c/AdServingId>",
" \x3cImpression>\x3c![CDATA[https://track.example.com/impression]]>\x3c/Impression>",
" \x3cCreatives>",
" \x3cCreative>",
" \x3cUniversalAdId idRegistry=\"ad-id.org\">TEST-001\x3c/UniversalAdId>",
" \x3cLinear>",
" \x3cDuration>00:00:30\x3c/Duration>",
" \x3cMediaFiles>",
" \x3cMediaFile delivery=\"progressive\" type=\"video/mp4\" width=\"1280\" height=\"720\">",
" \x3c![CDATA[https://cdn.example.com/video.mp4]]>",
" \x3c/MediaFile>",
" \x3c/MediaFiles>",
" \x3c/Linear>",
" \x3c/Creative>",
" \x3c/Creatives>",
" \x3c/InLine>",
" \x3c/Ad>",
"\x3c/VAST>",
].join("\n");
type DisplayIssue = {
id: string;
severity: "error" | "warning" | "info";
message: string;
path: string | null;
spec_ref: string;
line: number | null;
col: number | null;
};
type AppliedFix = {
rule_id: string;
description: string;
path: string;
};
type FixPreviewResult = {
xml: string;
applied: AppliedFix[];
remaining: DisplayIssue[];
};
type FixPreviewState = {
sourceXml: string;
result: FixPreviewResult;
} | null;
type VastlintModuleWithFix = typeof import("vastlint") & {
fix?: (xml: string) => {
xml: string;
applied: AppliedFix[];
remaining: VastlintIssue[];
};
};
let vastlintReady: Promise<VastlintModuleWithFix> | null = null;
function loadVastlint() {
if (!vastlintReady) {
vastlintReady = import("vastlint") as Promise<VastlintModuleWithFix>;
}
return vastlintReady;
}
type State =
| { status: "idle" }
| { status: "validating" }
| { status: "result"; result: ValidationResult }
| { status: "error"; message: string };
const SEVERITY_ORDER: DisplayIssue["severity"][] = ["error", "warning", "info"];
const primaryButtonClass =
"rounded-md bg-brand px-3.5 py-2 text-xs font-semibold text-white transition-colors hover:bg-brand-hover disabled:cursor-not-allowed disabled:opacity-40";
const secondaryButtonClass =
"rounded-md border border-border bg-surface px-3.5 py-2 text-xs font-semibold text-text-primary transition-colors hover:border-text-muted hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-40";
function createRuntimeIssue(message: string): DisplayIssue {
return {
id: "APP-runtime-error",
severity: "error",
message,
path: null,
spec_ref: "vastlint runtime",
line: null,
col: null,
};
}
function formatXml(xml: string): string {
try {
const trimmed = xml.trim();
if (!trimmed) {
return xml;
}
let result = "";
let indent = 0;
const tab = " ";
const nodes = trimmed.replace(/>\s*</g, ">\n<").split("\n");
for (const raw of nodes) {
const node = raw.trim();
if (!node) {
continue;
}
if (/^<\//.test(node)) {
indent = Math.max(0, indent - 1);
}
result += `${tab.repeat(indent)}${node}\n`;
if (/^<[^/?!]/.test(node) && !/\/>$/.test(node) && !/<\//.test(node)) {
indent += 1;
}
}
return result.trimEnd();
} catch {
return xml;
}
}
async function fetchVastUrl(url: string): Promise<string> {
try {
const proxyUrl = `/api/vast-proxy?url=${encodeURIComponent(url)}`;
const response = await fetch(proxyUrl);
if (response.ok) {
const text = await response.text();
if (text.trim().startsWith("<")) {
return text.trim();
}
}
if (response.headers.get("content-type")?.includes("json")) {
const json = await response.json().catch(() => null);
if (json?.error) {
throw new Error(json.error);
}
}
} catch (proxyError) {
if (!(proxyError instanceof TypeError)) {
throw proxyError;
}
}
let response: Response;
try {
response = await fetch(url, { mode: "cors" });
} catch {
throw new Error(
"Could not fetch URL (likely CORS or network error). Try opening the URL directly and copying the XML."
);
}
if (!response.ok) {
throw new Error(`Server returned ${response.status} ${response.statusText} for that URL.`);
}
const text = await response.text();
const trimmed = text.trim();
if (!trimmed.startsWith("<")) {
throw new Error("The URL did not return XML. Make sure it points directly to a VAST tag.");
}
return trimmed;
}
function isUrl(value: string): boolean {
const trimmed = value.trim();
return trimmed.startsWith("http://") || trimmed.startsWith("https://");
}
function sortIssues(issues: DisplayIssue[]): DisplayIssue[] {
return [...issues].sort((left, right) => {
const severityDelta = SEVERITY_ORDER.indexOf(left.severity) - SEVERITY_ORDER.indexOf(right.severity);
if (severityDelta !== 0) {
return severityDelta;
}
const lineDelta = (left.line ?? Number.MAX_SAFE_INTEGER) - (right.line ?? Number.MAX_SAFE_INTEGER);
if (lineDelta !== 0) {
return lineDelta;
}
const colDelta = (left.col ?? 0) - (right.col ?? 0);
if (colDelta !== 0) {
return colDelta;
}
return left.id.localeCompare(right.id);
});
}
async function safeFix(xml: string): Promise<FixPreviewResult> {
try {
const maybeFix = (await loadVastlint()).fix;
if (typeof maybeFix !== "function") {
return {
xml,
applied: [],
remaining: [
createRuntimeIssue(
"Auto-fix preview requires a newer vastlint browser package. Validation still works on this page."
),
],
};
}
const result = maybeFix(xml);
return {
xml: result.xml,
applied: result.applied,
remaining: result.remaining,
};
} catch (error) {
return {
xml,
applied: [],
remaining: [createRuntimeIssue(error instanceof Error ? error.message : "Unexpected fix failure")],
};
}
}
function severityBadgeClass(severity: DisplayIssue["severity"]): string {
if (severity === "error") {
return "border border-severity-error/30 bg-severity-error-bg text-severity-error";
}
if (severity === "warning") {
return "border border-severity-warning/30 bg-severity-warning-bg text-severity-warning";
}
return "border border-severity-info/30 bg-severity-info-bg text-severity-info";
}
export default function VastLintTakeHomePage() {
const [state, setState] = useState<State>({ status: "idle" });
const [xml, setXml] = useState(() => formatXml(SAMPLE_BROKEN_XML));
const [isDragging, setIsDragging] = useState(false);
const [fixPreview, setFixPreview] = useState<FixPreviewState>(null);
const [copiedLabel, setCopiedLabel] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const copyResetRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const showResults = state.status === "result";
const showError = state.status === "error";
const isLoading = state.status === "validating";
const fixSourceXml = fixPreview?.sourceXml ?? null;
const submit = useCallback(async (value: string) => {
const trimmed = value.trim();
if (!trimmed) {
return;
}
setState({ status: "validating" });
try {
const result = await validateXml(trimmed);
if (isValidationError(result)) {
setState({ status: "error", message: result.error });
} else {
setState({ status: "result", result });
}
} catch (error) {
setState({
status: "error",
message: error instanceof Error ? error.message : "Unexpected error during validation.",
});
}
}, []);
const annotations = useMemo(() => {
if (state.status !== "result") {
return [];
}
return resolveLines(xml, state.result.issues);
}, [state, xml]);
const loadSample = useCallback(
(sample: string) => {
const formatted = formatXml(sample);
setXml(formatted);
setFixPreview(null);
submit(formatted);
},
[submit]
);
const handleReset = useCallback(() => {
setState({ status: "idle" });
setXml("");
setFixPreview(null);
setTimeout(() => textareaRef.current?.focus(), 0);
}, []);
const loadUrl = useCallback(
async (url: string) => {
setState({ status: "validating" });
try {
const raw = await fetchVastUrl(url);
const formatted = formatXml(raw);
setXml(formatted);
setFixPreview(null);
await submit(formatted);
} catch (error) {
setState({
status: "error",
message: error instanceof Error ? error.message : "Failed to fetch URL.",
});
}
},
[submit]
);
const handleCopy = useCallback(async (value: string, label: string) => {
if (typeof navigator === "undefined" || !navigator.clipboard) {
return;
}
try {
await navigator.clipboard.writeText(value);
setCopiedLabel(label);
if (copyResetRef.current) {
clearTimeout(copyResetRef.current);
}
copyResetRef.current = setTimeout(() => setCopiedLabel(""), 1500);
} catch {
setCopiedLabel("Copy failed");
if (copyResetRef.current) {
clearTimeout(copyResetRef.current);
}
copyResetRef.current = setTimeout(() => setCopiedLabel(""), 1500);
}
}, []);
const runAutoFix = useCallback(() => {
if (!xml.trim()) {
return;
}
void safeFix(xml).then((result) => {
setFixPreview({ sourceXml: xml, result });
});
}, [xml]);
const applyFixPreview = useCallback(() => {
if (!fixPreview) {
return;
}
setXml(fixPreview.result.xml);
setFixPreview(null);
submit(fixPreview.result.xml);
}, [fixPreview, submit]);
const onDragOver = useCallback((event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsDragging(true);
}, []);
const onDragLeave = useCallback(() => {
setIsDragging(false);
}, []);
const onDrop = useCallback(
(event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsDragging(false);
const droppedText = event.dataTransfer.getData("text");
if (droppedText && isUrl(droppedText)) {
loadUrl(droppedText);
return;
}
const file = event.dataTransfer.files?.[0];
if (!file) {
return;
}
if (file.size > 512 * 1024) {
setState({
status: "error",
message: `File too large (${Math.round(file.size / 1024)} KB). Max 512 KB.`,
});
return;
}
const reader = new FileReader();
reader.onload = (loadEvent) => {
const text = loadEvent.target?.result;
if (typeof text === "string") {
const formatted = formatXml(text);
setXml(formatted);
setFixPreview(null);
submit(formatted);
}
};
reader.onerror = () => {
setState({
status: "error",
message: "Could not read the file. Make sure it's a valid UTF-8 XML file.",
});
};
reader.readAsText(file);
},
[loadUrl, submit]
);
const onPaste = useCallback(
(event: ClipboardEvent<HTMLTextAreaElement>) => {
const raw = event.clipboardData.getData("text");
if (isUrl(raw.trim())) {
event.preventDefault();
loadUrl(raw.trim());
return;
}
if (raw.trimStart().startsWith("<")) {
event.preventDefault();
const formatted = formatXml(raw);
setXml(formatted);
setFixPreview(null);
submit(formatted);
}
},
[loadUrl, submit]
);
useEffect(() => {
const trimmed = xml.trim();
if (!trimmed) {
return;
}
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
debounceRef.current = setTimeout(() => {
submit(trimmed);
}, 700);
return () => {
if (debounceRef.current) {
clearTimeout(debounceRef.current);
}
};
}, [xml, submit]);
useEffect(() => {
if (fixSourceXml !== null && fixSourceXml !== xml) {
setFixPreview(null);
}
}, [fixSourceXml, xml]);
useEffect(() => {
return () => {
if (copyResetRef.current) {
clearTimeout(copyResetRef.current);
}
};
}, []);
return (
<div className="mx-auto max-w-7xl px-6 py-6 sm:py-10">
<div className="mb-5 sm:mb-8">
<div className="inline-flex rounded-full border border-border bg-surface-1 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-text-muted">
Production-style React example
</div>
<h1 className="mt-4 text-2xl font-bold text-text-primary sm:text-3xl">React VAST validator page</h1>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-text-secondary">
This isolated preview borrows the same editor, issue rail, and asset preview rhythm as the real
vastlint validator. The example-only layer on top is the sample loader and auto-fix preview,
which makes it easier to hand to DSP, SSP, ad-server, and QA teams.
</p>
</div>
<div className="mb-6 rounded-xl border border-border bg-surface-1 p-4">
<div className="flex flex-wrap gap-2">
<button type="button" className={primaryButtonClass} onClick={() => loadSample(SAMPLE_BROKEN_XML)}>
Load broken sample
</button>
<button type="button" className={secondaryButtonClass} onClick={() => loadSample(SAMPLE_CLEAN_XML)}>
Load clean sample
</button>
<button
type="button"
className={secondaryButtonClass}
onClick={runAutoFix}
disabled={xml.trim().length === 0}
>
Auto-fix preview
</button>
<button
type="button"
className={secondaryButtonClass}
onClick={() => handleCopy(xml, "Current XML copied")}
disabled={xml.trim().length === 0}
>
Copy current XML
</button>
<button type="button" className={secondaryButtonClass} onClick={handleReset}>
Clear
</button>
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-text-muted">
<span>Paste XML or a VAST URL</span>
<span>Drop a .xml file</span>
<span>Auto-validates as you type</span>
<span className="text-brand">{copiedLabel || "Nothing stored"}</span>
</div>
</div>
<div className="rounded-2xl border border-border bg-surface-1 p-4 sm:p-5">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-widest text-text-muted">Validator workbench</h2>
<p className="mt-1 text-sm text-text-secondary">
Same split-screen flow as the real validator: XML editor on the left, issue summary on the right.
</p>
</div>
{showResults && state.status === "result" ? (
<div className="flex flex-wrap gap-2 text-[11px]">
<span className="rounded border border-border bg-surface px-2 py-1 font-mono text-text-secondary">
{formatVersion(state.result.version)}
</span>
<span className="rounded border border-severity-error/30 bg-severity-error-bg px-2 py-1 font-semibold text-severity-error">
{state.result.summary.errors} error{state.result.summary.errors !== 1 ? "s" : ""}
</span>
<span className="rounded border border-severity-warning/30 bg-severity-warning-bg px-2 py-1 font-semibold text-severity-warning">
{state.result.summary.warnings} warning{state.result.summary.warnings !== 1 ? "s" : ""}
</span>
<span className="rounded border border-severity-info/30 bg-severity-info-bg px-2 py-1 font-semibold text-severity-info">
{state.result.summary.infos} info
</span>
</div>
) : null}
</div>
<div className={`hidden items-start gap-6 sm:flex ${showResults || showError ? "flex-row" : "flex-col"}`}>
<div className={showResults || showError ? "min-w-0 flex-1" : "w-full"}>
{!isLoading ? (
<XmlEditor
value={xml}
onChange={setXml}
onSubmit={submit}
onPaste={onPaste}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
isDragging={isDragging}
isLoading={isLoading}
showResults={showResults}
annotations={annotations}
textareaRef={textareaRef}
/>
) : (
<div className="flex items-center justify-center gap-3 rounded border border-border bg-surface py-10">
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-border border-t-brand" />
<span className="text-sm text-text-secondary">Validating…</span>
</div>
)}
</div>
{showResults || showError ? (
<div className="w-[340px] shrink-0">
{showError && state.status === "error" ? (
<div className="rounded border border-severity-error/30 bg-severity-error-bg px-4 py-3">
<p className="mb-0.5 text-sm font-semibold text-severity-error">Validation failed</p>
<p className="text-xs text-text-secondary">{state.message}</p>
<button
type="button"
onClick={handleReset}
className="mt-2 text-xs text-text-secondary underline transition-colors hover:text-text-primary"
>
Try again
</button>
</div>
) : null}
{showResults && state.status === "result" ? (
<ResultsPanel result={state.result} onReset={handleReset} />
) : null}
</div>
) : null}
</div>
<div className="sm:hidden">
<div className="relative flex flex-col gap-4">
<div>
{!isLoading ? (
<XmlEditor
value={xml}
onChange={setXml}
onSubmit={submit}
onPaste={onPaste}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
isDragging={isDragging}
isLoading={isLoading}
showResults={showResults}
annotations={annotations}
textareaRef={textareaRef}
/>
) : (
<div className="flex items-center justify-center gap-3 rounded border border-border bg-surface py-10">
<div className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-border border-t-brand" />
<span className="text-sm text-text-secondary">Validating…</span>
</div>
)}
</div>
{showResults || showError ? (
<div className="pointer-events-auto absolute right-0 top-0 z-20 flex max-h-[80%] max-w-[62%] flex-col gap-2 overflow-y-auto">
{showError && state.status === "error" ? (
<div className="rounded-lg border border-severity-error/30 bg-severity-error-bg/95 px-3 py-2 shadow-xl backdrop-blur-sm">
<p className="mb-0.5 text-xs font-semibold text-severity-error">Validation failed</p>
<p className="text-[10px] leading-snug text-text-secondary">{state.message}</p>
<button type="button" onClick={handleReset} className="mt-1.5 text-[10px] text-text-secondary underline">
Try again
</button>
</div>
) : null}
{showResults && state.status === "result" ? (
<div className="overflow-hidden rounded-lg border border-border bg-surface-2/95 shadow-xl backdrop-blur-sm">
<ResultsPanel result={state.result} onReset={handleReset} compact />
</div>
) : null}
</div>
) : null}
</div>
</div>
</div>
{fixPreview ? (
<section className="mt-6 rounded-xl border border-border bg-surface-1 p-4 sm:p-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-widest text-text-muted">Auto-fix preview</h2>
<p className="mt-1 text-sm text-text-secondary">
{fixPreview.result.applied.length > 0
? `${fixPreview.result.applied.length} automatic change(s) available before you replace the editor content.`
: "No automatic fixes were available for the current XML."}
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
className={secondaryButtonClass}
onClick={() => handleCopy(fixPreview.result.xml, "Fixed XML copied")}
>
Copy fixed XML
</button>
<button
type="button"
className={primaryButtonClass}
onClick={applyFixPreview}
disabled={fixPreview.result.applied.length === 0}
>
Replace editor with fixed XML
</button>
</div>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<div className="rounded-lg border border-border bg-surface p-4">
<h3 className="text-[11px] font-semibold uppercase tracking-widest text-text-muted">Applied fixes</h3>
{fixPreview.result.applied.length === 0 ? (
<p className="mt-3 text-sm text-text-secondary">Nothing to apply automatically.</p>
) : (
<div className="mt-3 grid gap-2">
{fixPreview.result.applied.map((applied, index) => (
<div key={`${applied.rule_id}-${index}`} className="rounded-lg border border-border bg-surface-1 px-3 py-2">
<p className="font-mono text-[11px] text-text-secondary">{applied.rule_id}</p>
<p className="mt-1 text-sm text-text-primary">{applied.description}</p>
<p className="mt-1 font-mono text-[11px] text-text-muted">{applied.path}</p>
</div>
))}
</div>
)}
</div>
<div className="rounded-lg border border-border bg-surface p-4">
<h3 className="text-[11px] font-semibold uppercase tracking-widest text-text-muted">Remaining issues</h3>
{fixPreview.result.remaining.length === 0 ? (
<p className="mt-3 text-sm text-text-secondary">No remaining issues after auto-fix.</p>
) : (
<div className="mt-3 grid gap-2">
{sortIssues(fixPreview.result.remaining).map((issue, index) => (
<div key={`${issue.id}-${index}`} className="rounded-lg border border-border bg-surface-1 px-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider ${severityBadgeClass(issue.severity)}`}>
{issue.severity}
</span>
<span className="font-mono text-[11px] text-text-secondary">{issue.id}</span>
</div>
<p className="mt-2 text-sm text-text-primary">{issue.message}</p>
<p className="mt-1 font-mono text-[11px] text-text-muted">{issue.path ?? "No XPath location"}</p>
</div>
))}
</div>
)}
</div>
</div>
<div className="mt-4 overflow-hidden rounded-lg border border-border bg-surface">
<pre className="max-h-[320px] overflow-auto px-4 py-3 font-mono text-xs leading-6 text-text-secondary">
{fixPreview.result.xml}
</pre>
</div>
</section>
) : null}
{showResults ? (
<div className="mt-6">
<VastPreview xml={xml} />
</div>
) : null}
<div className="mt-8 flex items-center justify-center gap-6 text-center sm:gap-10">
{[
{ value: "118", label: "rules" },
{ value: "6", label: "VAST versions" },
{ value: "<1ms", label: "validation" },
{ value: "0", label: "data stored" },
].map(({ value, label }) => (
<div key={label}>
<p className="tabular-nums text-lg font-bold text-text-primary sm:text-xl">{value}</p>
<p className="text-[10px] uppercase tracking-wider text-text-muted sm:text-xs">{label}</p>
</div>
))}
</div>
</div>
);
}What to customize after copying
- In the simple recipe, replace only the formatter hook and keep the validation wiring intact.
- In the full page, replace the sample XML loaders with your own fetch, file-upload, or pasted-tag flow.
- Swap the example classes or inline styles for your design system once the behavior is proven.
- Move from a full-page route to smaller components only after the first integration works end to end.
If you want a more product-shaped version next, the natural follow-up is a DSP or ad-server debugger layout with the existing formatter view, original XML, repaired XML, wrapper chain, and issue panes side by side.