Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ path = "./crates/quarto-error-reporting"
[workspace.dependencies.quarto-source-map]
path = "./crates/quarto-source-map"

[workspace.dependencies.quarto-doctemplate]
path = "./crates/quarto-doctemplate"

[workspace.dependencies.quarto-treesitter-ast]
path = "./crates/quarto-treesitter-ast"

[workspace.lints.clippy]
assigning_clones = "warn"
Expand Down
6 changes: 6 additions & 0 deletions crates/pico-quarto-render/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,15 @@ repository.workspace = true

[dependencies]
quarto-markdown-pandoc = { workspace = true }
quarto-doctemplate = { workspace = true }
anyhow.workspace = true
clap = { version = "4.0", features = ["derive"] }
walkdir = "2.5"
include_dir = "0.7"
rayon = "1.10"

[dev-dependencies]
quarto-source-map = { workspace = true }

[lints]
workspace = true
27 changes: 27 additions & 0 deletions crates/pico-quarto-render/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# pico-quarto-render

Experimental batch renderer for QMD files to HTML.

This crate exists for prototyping and experimentation with Quarto's rendering pipeline. It is not intended for production use.

## Usage

```bash
pico-quarto-render <INPUT_DIR> <OUTPUT_DIR> [-v]
```

## Parallelism

Files are processed in parallel using [Rayon](https://docs.rs/rayon). To control the number of threads, set the `RAYON_NUM_THREADS` environment variable:

```bash
# Use 4 threads
RAYON_NUM_THREADS=4 pico-quarto-render input/ output/

# Use single thread (sequential processing, no rayon overhead)
RAYON_NUM_THREADS=1 pico-quarto-render input/ output/
```

If not set, Rayon defaults to the number of logical CPUs.

When `RAYON_NUM_THREADS=1`, the code bypasses Rayon entirely and uses a simple sequential loop. This produces cleaner stack traces for profiling.
Binary file added crates/pico-quarto-render/profile.json.gz
Binary file not shown.
88 changes: 88 additions & 0 deletions crates/pico-quarto-render/src/embedded_resolver.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/*
* embedded_resolver.rs
* Copyright (c) 2025 Posit, PBC
*/

//! Embedded template resolver for pico-quarto-render.
//!
//! This module provides a `PartialResolver` implementation that loads templates
//! from resources compiled into the binary via `include_dir`.

use include_dir::{Dir, include_dir};
use quarto_doctemplate::resolver::{PartialResolver, resolve_partial_path};
use std::path::Path;

/// Embedded HTML templates directory.
static HTML_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/src/resources/html-template");

/// Resolver that loads templates from embedded resources.
///
/// Templates are compiled into the binary at build time using `include_dir`.
/// This resolver implements `PartialResolver` to support partial template loading.
pub struct EmbeddedResolver;

impl PartialResolver for EmbeddedResolver {
fn get_partial(&self, name: &str, base_path: &Path) -> Option<String> {
// Resolve the partial path following Pandoc rules
let partial_path = resolve_partial_path(name, base_path);

// Get the filename portion for embedded lookup
// (templates are flat in our structure, so we just need the filename)
let filename = partial_path.file_name()?.to_str()?;

HTML_TEMPLATES
.get_file(filename)
.and_then(|f| f.contents_utf8())
.map(|s| s.to_string())
}
}

/// Get the main template source.
///
/// Returns the content of `template.html` from the embedded resources.
pub fn get_main_template() -> Option<&'static str> {
HTML_TEMPLATES
.get_file("template.html")
.and_then(|f| f.contents_utf8())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_get_main_template() {
let template = get_main_template();
assert!(template.is_some());
let content = template.unwrap();
assert!(content.contains("<!DOCTYPE html>"));
assert!(content.contains("$body$"));
}

#[test]
fn test_embedded_resolver_finds_partials() {
let resolver = EmbeddedResolver;
let base_path = Path::new("template.html");

// Should find metadata.html partial
let metadata = resolver.get_partial("metadata", base_path);
assert!(metadata.is_some());

// Should find title-block.html partial
let title_block = resolver.get_partial("title-block", base_path);
assert!(title_block.is_some());

// Should find styles.html partial
let styles = resolver.get_partial("styles", base_path);
assert!(styles.is_some());
}

#[test]
fn test_embedded_resolver_missing_partial() {
let resolver = EmbeddedResolver;
let base_path = Path::new("template.html");

let missing = resolver.get_partial("nonexistent", base_path);
assert!(missing.is_none());
}
}
110 changes: 110 additions & 0 deletions crates/pico-quarto-render/src/format_writers.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* format_writers.rs
* Copyright (c) 2025 Posit, PBC
*/

//! Format-specific writers for template context building.
//!
//! This module provides a trait for format-specific AST-to-string conversion,
//! and implementations for HTML output.

use anyhow::Result;
use quarto_markdown_pandoc::pandoc::block::Block;
use quarto_markdown_pandoc::pandoc::inline::Inlines;

/// Format-specific writers for converting Pandoc AST to strings.
///
/// Implementations of this trait provide the format-specific rendering
/// needed when converting document metadata to template values.
pub trait FormatWriters {
/// Write blocks to a string.
fn write_blocks(&self, blocks: &[Block]) -> Result<String>;

/// Write inlines to a string.
fn write_inlines(&self, inlines: &Inlines) -> Result<String>;
}

/// HTML format writers.
///
/// Uses the HTML writer from quarto-markdown-pandoc to convert
/// Pandoc AST nodes to HTML strings.
pub struct HtmlWriters;

impl FormatWriters for HtmlWriters {
fn write_blocks(&self, blocks: &[Block]) -> Result<String> {
let mut buf = Vec::new();
quarto_markdown_pandoc::writers::html::write_blocks(blocks, &mut buf)?;
Ok(String::from_utf8_lossy(&buf).into_owned())
}

fn write_inlines(&self, inlines: &Inlines) -> Result<String> {
let mut buf = Vec::new();
quarto_markdown_pandoc::writers::html::write_inlines(inlines, &mut buf)?;
Ok(String::from_utf8_lossy(&buf).into_owned())
}
}

#[cfg(test)]
mod tests {
use super::*;
use quarto_markdown_pandoc::pandoc::Inline;
use quarto_markdown_pandoc::pandoc::block::Paragraph;
use quarto_markdown_pandoc::pandoc::inline::{Emph, Space, Str};

fn dummy_source_info() -> quarto_source_map::SourceInfo {
quarto_source_map::SourceInfo::from_range(
quarto_source_map::FileId(0),
quarto_source_map::Range {
start: quarto_source_map::Location {
offset: 0,
row: 0,
column: 0,
},
end: quarto_source_map::Location {
offset: 0,
row: 0,
column: 0,
},
},
)
}

#[test]
fn test_html_writers_inlines() {
let writers = HtmlWriters;
let inlines = vec![
Inline::Str(Str {
text: "Hello".to_string(),
source_info: dummy_source_info(),
}),
Inline::Space(Space {
source_info: dummy_source_info(),
}),
Inline::Emph(Emph {
content: vec![Inline::Str(Str {
text: "world".to_string(),
source_info: dummy_source_info(),
})],
source_info: dummy_source_info(),
}),
];

let result = writers.write_inlines(&inlines).unwrap();
assert_eq!(result, "Hello <em>world</em>");
}

#[test]
fn test_html_writers_blocks() {
let writers = HtmlWriters;
let blocks = vec![Block::Paragraph(Paragraph {
content: vec![Inline::Str(Str {
text: "A paragraph.".to_string(),
source_info: dummy_source_info(),
})],
source_info: dummy_source_info(),
})];

let result = writers.write_blocks(&blocks).unwrap();
assert_eq!(result, "<p>A paragraph.</p>\n");
}
}
Loading