syntaxai/tdd.md · main · src / a31_sxdoc.ts
// c31 — types for sx-doc: tdd.md's typed rich-content format.
//
// Why a typed tree instead of HTML strings:
// • Editor saves a structured shape, not a string blob — block-level
// ops (move, transform, AI-edit) operate on typed nodes, not regex.
// • Round-trippable: htmlToSx(sxToHtml(doc)) ≈ doc (whitespace modulo).
// • Compact JSON: single-letter keys (`t`, `c`, `v`, `m`) keep the
// SQLite + git-sidecar payloads small.
//
// SAMA placement: c31 because this file is pure types/registry — no I/O,
// no logic. Parser/renderer live in c32_sxdoc_parse + c32_sxdoc_render
// where the deterministic transforms (and their sibling tests) belong.
//
// Scope-omission: podman's typed marketing blocks (hero, feature-card,
// feature-grid, stats-row, steps-grid, use-case-card, cta-band) are
// deliberately skipped — tdd.md content has no marketing-landing-page
// shape; skipping saves ~600 LOC across server + client.
export const SX_DOC_VERSION = 1;
export interface SxDocument {
v: typeof SX_DOC_VERSION;
blocks: SxBlock[];
}
export type SxBlock =
| SxParagraph
| SxHeading
| SxList
| SxListItem
| SxQuote
| SxCodeBlock
| SxImage
| SxDivider
| SxHtml
| SxShortcode;
export interface SxParagraph {
t: "p";
c: SxInline[];
}
export interface SxHeading {
t: "h";
level: 1 | 2 | 3 | 4 | 5 | 6;
c: SxInline[];
}
export interface SxList {
t: "ul" | "ol";
// Each item is an array of blocks so a list item can hold paragraphs,
// nested lists, etc.
items: SxBlock[][];
}
// Separate type so renderers can special-case loose list-items. Lists
// store items as SxBlock[][] directly; SxListItem only appears when an
// isolated <li> reaches the parser without a parent list.
export interface SxListItem {
t: "li";
c: SxBlock[];
}
export interface SxQuote {
t: "quote";
c: SxBlock[];
}
export interface SxCodeBlock {
t: "code";
// Language hint — e.g. "ts", "py". May be empty.
lang?: string;
// Raw source code. Newlines preserved verbatim.
src: string;
}
export interface SxImage {
t: "img";
src: string;
alt?: string;
caption?: string;
// Intrinsic dimensions if known — used for layout-shift prevention.
w?: number;
h?: number;
}
export interface SxDivider {
t: "hr";
}
// Escape hatch for HTML we don't (yet) model — preserves the source
// verbatim so round-tripping is lossless. New element kinds should land
// as proper SxBlock variants over time, not as `html` blobs.
export interface SxHtml {
t: "html";
src: string;
}
// `[[sx:name arg=value ...]]` shortcode lifted out of source. We store
// the name + args structurally so renderers and queries don't need to
// understand the wire syntax.
export interface SxShortcode {
t: "shortcode";
name: string;
args: Record<string, string>;
}
// ─── inline ──────────────────────────────────────────────────────────────
export type SxInline = SxText | SxLink;
// Text run with optional marks. Marks are single-character flags:
// b=bold i=italic u=underline s=strikethrough c=inline-code
// Storage order doesn't matter; renderers nest them deterministically
// (see MARK_ORDER in c32_sxdoc_render).
export interface SxText {
t: "text";
v: string;
m?: SxMark[];
}
export type SxMark = "b" | "i" | "u" | "s" | "c";
export interface SxLink {
t: "a";
href: string;
c: SxInline[];
}
// ─── helpers ─────────────────────────────────────────────────────────────
// Type guard — useful at renderer and storage boundaries.
export const isBlock = (node: unknown): node is SxBlock => {
if (!node || typeof node !== "object") return false;
return "t" in node && typeof (node as { t: unknown }).t === "string";
};
// Sentinel for new posts that haven't been parsed yet.
export const emptyDocument = (): SxDocument => ({
v: SX_DOC_VERSION,
blocks: [],
});
// Row-shape returned by c13_database.listDocuments. Defined here in
// Layer 0 (Pure) per SAMA v2 §1.1 so c51 render code can reference
// the type without importing from Layer 2 (Adapter). The Adapter
// (c13_database) imports this type to type its own return value.
export interface SxDocumentSummary {
id: number;
slug: string;
type: "page" | "post";
title: string;
status: "published" | "draft";
primaryTag: string | null;
updatedAt: number;
}