Skip to main content

typub_html/
builders.rs

1//! Helper constructors for typub HTML IR v2.
2//!
3//! These are intended for tests and small fixture construction.
4
5use std::collections::BTreeMap;
6
7use typub_ir::{
8    AdmonitionKind, Asset, AssetId, AssetSource, AssetVariant, AttrMap, Block, BlockAttrs,
9    Document, FlowListItem, FlowListItemMarker, FootnoteDef, HeadingLevel, ImageAsset, Inline,
10    List, ListKind, MathSource, RelativePath, RenderPayload, RenderedArtifact, TableCell,
11    TableCellKind, TableRow, TableSection, TableSectionKind, TaskListItem, Url,
12};
13
14/// Empty passthrough map helper.
15pub fn empty_attrs() -> AttrMap {
16    AttrMap::new()
17}
18
19/// Build a document with default metadata and no footnotes.
20pub fn document(blocks: Vec<Block>) -> Document {
21    Document {
22        blocks,
23        footnotes: BTreeMap::new(),
24        assets: BTreeMap::new(),
25        meta: Default::default(),
26    }
27}
28
29/// Build a document with explicit assets.
30pub fn document_with_assets(
31    blocks: Vec<Block>,
32    assets: impl IntoIterator<Item = (AssetId, Asset)>,
33) -> Document {
34    Document {
35        blocks,
36        footnotes: BTreeMap::new(),
37        assets: assets.into_iter().collect(),
38        meta: Default::default(),
39    }
40}
41
42/// Build a document with explicit footnotes and assets.
43pub fn document_full(
44    blocks: Vec<Block>,
45    footnotes: impl IntoIterator<Item = (typub_ir::FootnoteId, FootnoteDef)>,
46    assets: impl IntoIterator<Item = (AssetId, Asset)>,
47) -> Document {
48    Document {
49        blocks,
50        footnotes: footnotes.into_iter().collect(),
51        assets: assets.into_iter().collect(),
52        meta: Default::default(),
53    }
54}
55
56/// Create a paragraph block with plain text.
57pub fn paragraph_text(text: &str) -> Block {
58    Block::Paragraph {
59        content: vec![Inline::Text(text.to_string())],
60        attrs: BlockAttrs::default(),
61    }
62}
63
64/// Create a paragraph block from inline content.
65pub fn paragraph(content: Vec<Inline>) -> Block {
66    Block::Paragraph {
67        content,
68        attrs: BlockAttrs::default(),
69    }
70}
71
72/// Create a heading block from text.
73///
74/// Out-of-range levels are clamped to 1..=6 for convenience in tests.
75pub fn heading_text(level: u8, text: &str) -> Block {
76    let normalized = level.clamp(1, 6);
77    let heading_level = match HeadingLevel::new(normalized) {
78        Ok(v) => v,
79        Err(_) => {
80            // unreachable due clamp, keep graceful fallback
81            return paragraph_text(text);
82        }
83    };
84
85    Block::Heading {
86        level: heading_level,
87        id: None,
88        content: vec![Inline::Text(text.to_string())],
89        attrs: BlockAttrs::default(),
90    }
91}
92
93/// Create a quote block with a single paragraph child.
94pub fn quote_text(text: &str) -> Block {
95    Block::Quote {
96        blocks: vec![paragraph_text(text)],
97        cite: None,
98        attrs: BlockAttrs::default(),
99    }
100}
101
102/// Create a code block with optional language.
103pub fn code_block(code: &str, language: &str) -> Block {
104    Block::CodeBlock {
105        code: code.to_string(),
106        language: if language.is_empty() {
107            None
108        } else {
109            Some(language.to_string())
110        },
111        filename: None,
112        highlight_lines: Vec::new(),
113        highlighted_html: None,
114        attrs: BlockAttrs::default(),
115    }
116}
117
118/// Create a code block with pre-rendered highlighted HTML.
119pub fn code_block_highlighted(code: &str, highlighted: &str, language: &str) -> Block {
120    Block::CodeBlock {
121        code: code.to_string(),
122        language: if language.is_empty() {
123            None
124        } else {
125            Some(language.to_string())
126        },
127        filename: None,
128        highlight_lines: Vec::new(),
129        highlighted_html: Some(highlighted.to_string()),
130        attrs: BlockAttrs::default(),
131    }
132}
133
134/// Build an image block and its backing remote image asset.
135///
136/// Returns `(block, (asset_id, asset))` so callers can place the asset into `Document.assets`.
137pub fn image(asset_id: &str, src: &str, alt: &str) -> (Block, (AssetId, Asset)) {
138    let id = AssetId(asset_id.to_string());
139    let asset = Asset::Image(ImageAsset {
140        source: AssetSource::RemoteUrl {
141            url: Url(src.to_string()),
142        },
143        meta: None,
144        variants: Vec::new(),
145    });
146
147    let block = Block::Paragraph {
148        content: vec![Inline::Image {
149            asset: typub_ir::AssetRef(id.clone()),
150            alt: alt.to_string(),
151            title: None,
152            attrs: Default::default(),
153        }],
154        attrs: BlockAttrs::default(),
155    };
156
157    (block, (id, asset))
158}
159
160/// Build an image block + local-path image asset (legacy ImageMarker-equivalent fixture).
161pub fn image_marker(
162    asset_id: &str,
163    path: &str,
164    alt: &str,
165) -> Result<(Block, (AssetId, Asset)), String> {
166    let id = AssetId(asset_id.to_string());
167    let rel = RelativePath::new(path.to_string())?;
168    let asset = Asset::Image(ImageAsset {
169        source: AssetSource::LocalPath { path: rel },
170        meta: None,
171        variants: Vec::new(),
172    });
173
174    let block = Block::Paragraph {
175        content: vec![Inline::Image {
176            asset: typub_ir::AssetRef(id.clone()),
177            alt: alt.to_string(),
178            title: None,
179            attrs: Default::default(),
180        }],
181        attrs: BlockAttrs::default(),
182    };
183
184    Ok((block, (id, asset)))
185}
186
187/// Create a divider block.
188pub fn divider() -> Block {
189    Block::Divider {
190        attrs: BlockAttrs::default(),
191    }
192}
193
194/// Create a flow list item from inline content.
195pub fn list_item(content: Vec<Inline>) -> FlowListItem {
196    FlowListItem {
197        marker: None,
198        blocks: vec![paragraph(content)],
199    }
200}
201
202/// Create a bullet list from plain text items.
203pub fn bullet_list_text(items: &[&str]) -> Block {
204    let items = items
205        .iter()
206        .map(|s| FlowListItem {
207            marker: Some(FlowListItemMarker::Bullet),
208            blocks: vec![paragraph_text(s)],
209        })
210        .collect();
211
212    Block::List {
213        list: List {
214            kind: ListKind::Bullet { items },
215        },
216        attrs: BlockAttrs::default(),
217    }
218}
219
220/// Create a numbered list from plain text items.
221pub fn numbered_list_text(items: &[&str]) -> Block {
222    let items = items
223        .iter()
224        .enumerate()
225        .map(|(i, s)| FlowListItem {
226            marker: Some(FlowListItemMarker::Number((i + 1) as u32)),
227            blocks: vec![paragraph_text(s)],
228        })
229        .collect();
230
231    Block::List {
232        list: List {
233            kind: ListKind::Numbered {
234                start: 1,
235                reversed: false,
236                marker: None,
237                items,
238            },
239        },
240        attrs: BlockAttrs::default(),
241    }
242}
243
244/// Create a task list item with paragraph content.
245pub fn task_item(text: &str, checked: bool) -> TaskListItem {
246    TaskListItem {
247        checked,
248        blocks: vec![paragraph_text(text)],
249    }
250}
251
252/// Create a task list block.
253pub fn task_list(items: Vec<TaskListItem>) -> Block {
254    Block::List {
255        list: List {
256            kind: ListKind::Task { items },
257        },
258        attrs: BlockAttrs::default(),
259    }
260}
261
262/// Create a simple table data cell from inline content.
263pub fn table_cell(content: Vec<Inline>) -> TableCell {
264    TableCell {
265        kind: TableCellKind::Data,
266        blocks: vec![paragraph(content)],
267        colspan: 1,
268        rowspan: 1,
269        scope: None,
270        align: None,
271        attrs: BlockAttrs::default(),
272    }
273}
274
275/// Create a simple table from text headers and rows.
276pub fn table_text(headers: &[&str], rows: &[Vec<&str>]) -> Block {
277    let head_rows = if headers.is_empty() {
278        Vec::new()
279    } else {
280        vec![TableRow {
281            cells: headers
282                .iter()
283                .map(|h| TableCell {
284                    kind: TableCellKind::Header,
285                    blocks: vec![paragraph_text(h)],
286                    colspan: 1,
287                    rowspan: 1,
288                    scope: None,
289                    align: None,
290                    attrs: BlockAttrs::default(),
291                })
292                .collect(),
293            attrs: BlockAttrs::default(),
294        }]
295    };
296
297    let body_rows = rows
298        .iter()
299        .map(|r| TableRow {
300            cells: r
301                .iter()
302                .map(|c| TableCell {
303                    kind: TableCellKind::Data,
304                    blocks: vec![paragraph_text(c)],
305                    colspan: 1,
306                    rowspan: 1,
307                    scope: None,
308                    align: None,
309                    attrs: BlockAttrs::default(),
310                })
311                .collect(),
312            attrs: BlockAttrs::default(),
313        })
314        .collect();
315
316    let mut sections = Vec::new();
317    if !head_rows.is_empty() {
318        sections.push(TableSection {
319            kind: TableSectionKind::Head,
320            rows: head_rows,
321            attrs: BlockAttrs::default(),
322        });
323    }
324    sections.push(TableSection {
325        kind: TableSectionKind::Body,
326        rows: body_rows,
327        attrs: BlockAttrs::default(),
328    });
329
330    Block::Table {
331        caption: None,
332        sections,
333        attrs: BlockAttrs::default(),
334    }
335}
336
337/// Create a basic admonition block with paragraph content.
338pub fn admonition_text(kind: AdmonitionKind, text: &str) -> Block {
339    Block::Admonition {
340        kind,
341        title: None,
342        blocks: vec![paragraph_text(text)],
343        attrs: BlockAttrs::default(),
344    }
345}
346
347/// Create a math inline fragment with latex source.
348pub fn math_inline_latex(src: &str) -> Inline {
349    Inline::MathInline {
350        math: RenderPayload {
351            src: Some(MathSource::Latex(src.to_string())),
352            rendered: None,
353            id: None,
354        },
355        attrs: Default::default(),
356    }
357}
358
359/// Create a math block with latex source.
360pub fn math_block_latex(src: &str) -> Block {
361    Block::MathBlock {
362        math: RenderPayload {
363            src: Some(MathSource::Latex(src.to_string())),
364            rendered: None,
365            id: None,
366        },
367        attrs: BlockAttrs::default(),
368    }
369}
370
371/// Create an inline SVG payload.
372pub fn svg_inline(svg: &str) -> Inline {
373    Inline::SvgInline {
374        svg: RenderPayload {
375            src: None,
376            rendered: Some(RenderedArtifact::Svg(svg.to_string())),
377            id: None,
378        },
379        attrs: Default::default(),
380    }
381}
382
383/// Create a block SVG payload.
384pub fn svg_block(svg: &str) -> Block {
385    Block::SvgBlock {
386        svg: RenderPayload {
387            src: None,
388            rendered: Some(RenderedArtifact::Svg(svg.to_string())),
389            id: None,
390        },
391        attrs: BlockAttrs::default(),
392    }
393}
394
395/// Create an image asset variant helper.
396pub fn asset_variant(
397    name: &str,
398    publish_url: &str,
399    width: Option<u32>,
400    height: Option<u32>,
401) -> AssetVariant {
402    AssetVariant {
403        name: name.to_string(),
404        publish_url: Url(publish_url.to_string()),
405        width,
406        height,
407    }
408}