use super::{Block, DocumentTrustState, EngineDocument, InlineSpan, TableAlignment, inline_text};
impl EngineDocument {
pub fn to_markdown(&self) -> String {
let mut out = String::new();
for block in &self.blocks {
block.write_markdown(&mut out, 0);
}
out
}
pub fn to_gemini(&self) -> String {
let mut out = String::new();
for block in &self.blocks {
block.write_gemini(&mut out);
}
out
}
pub fn to_knot(&self) -> String {
let mut out = String::new();
if self.has_frontmatter_content() {
self.write_knot_frontmatter(&mut out);
}
self.write_knot_body(&mut out);
out
}
pub fn write_knot_body(&self, out: &mut String) {
for block in &self.blocks {
block.write_knot(out);
}
}
fn has_frontmatter_content(&self) -> bool {
self.title.is_some()
|| self.provenance.canonical_uri.is_some()
|| self.provenance.fetched_at.is_some()
|| self.provenance.source_label.is_some()
|| !matches!(self.trust, DocumentTrustState::Unknown)
}
fn write_knot_frontmatter(&self, out: &mut String) {
out.push_str("---\n");
if let Some(title) = &self.title {
out.push_str(&format!("title: {title}\n"));
}
if let Some(source) = &self.provenance.canonical_uri {
out.push_str(&format!("source: {source}\n"));
}
if let Some(captured) = &self.provenance.fetched_at {
out.push_str(&format!("captured: {captured}\n"));
}
if let Some(label) = &self.provenance.source_label {
out.push_str(&format!("source_label: {label}\n"));
}
let trust_str = match self.trust {
DocumentTrustState::Trusted => Some("trusted"),
DocumentTrustState::Tofu => Some("tofu"),
DocumentTrustState::Insecure => Some("insecure"),
DocumentTrustState::Broken => Some("broken"),
DocumentTrustState::Unknown => None,
};
if let Some(s) = trust_str {
out.push_str(&format!("trust: {s}\n"));
}
out.push_str("---\n\n");
}
}
pub(super) fn table_lines(
header: &[Vec<InlineSpan>],
rows: &[Vec<Vec<InlineSpan>>],
) -> Vec<String> {
let row_line = |cells: &[Vec<InlineSpan>]| {
cells
.iter()
.map(|c| inline_text(c))
.collect::<Vec<_>>()
.join(" | ")
};
let mut lines = Vec::new();
if !header.is_empty() {
lines.push(row_line(header));
}
lines.extend(rows.iter().map(|r| row_line(r)));
lines
}
fn write_markdown_table(
alignments: &[TableAlignment],
header: &[Vec<InlineSpan>],
rows: &[Vec<Vec<InlineSpan>>],
out: &mut String,
pad: &str,
) {
let cols = header
.len()
.max(rows.iter().map(Vec::len).max().unwrap_or(0));
if cols == 0 {
return;
}
let write_row = |cells: &[Vec<InlineSpan>], out: &mut String| {
out.push_str(pad);
out.push('|');
for i in 0..cols {
out.push(' ');
if let Some(cell) = cells.get(i) {
write_inline_markdown(cell, out);
}
out.push_str(" |");
}
out.push('\n');
};
write_row(header, out);
out.push_str(pad);
out.push('|');
for i in 0..cols {
out.push_str(match alignments.get(i).copied().unwrap_or_default() {
TableAlignment::None => " --- |",
TableAlignment::Left => " :--- |",
TableAlignment::Center => " :---: |",
TableAlignment::Right => " ---: |",
});
}
out.push('\n');
for row in rows {
write_row(row, out);
}
out.push('\n');
}
impl Block {
fn write_markdown(&self, out: &mut String, indent: usize) {
let pad = " ".repeat(indent);
match self {
Self::Heading { level, spans } => {
let level = (*level).clamp(1, 6) as usize;
out.push_str(&pad);
out.push_str(&"#".repeat(level));
out.push(' ');
write_inline_markdown(spans, out);
out.push_str("\n\n");
}
Self::Paragraph { spans } => {
out.push_str(&pad);
write_inline_markdown(spans, out);
out.push_str("\n\n");
}
Self::CodeBlock { language, text } => {
out.push_str(&pad);
out.push_str("```");
if let Some(lang) = language {
out.push_str(lang);
}
out.push('\n');
out.push_str(text);
if !text.ends_with('\n') {
out.push('\n');
}
out.push_str(&pad);
out.push_str("```\n\n");
}
Self::Quote { blocks } => {
let mut inner = String::new();
for block in blocks {
block.write_markdown(&mut inner, 0);
}
for line in inner.lines() {
out.push_str(&pad);
out.push_str("> ");
out.push_str(line);
out.push('\n');
}
out.push('\n');
}
Self::List { ordered, items } => {
for (i, item) in items.iter().enumerate() {
out.push_str(&pad);
if *ordered {
out.push_str(&format!("{}. ", i + 1));
} else {
out.push_str("- ");
}
let mut first = true;
for block in item {
if first {
let mut piece = String::new();
block.write_markdown(&mut piece, 0);
out.push_str(piece.trim_end());
out.push('\n');
first = false;
} else {
block.write_markdown(out, indent + 1);
}
}
}
out.push('\n');
}
Self::Image { url, alt } => {
out.push_str(&pad);
out.push_str(&format!("\n\n"));
}
Self::Preformatted { text } => {
out.push_str(&pad);
out.push_str("```\n");
out.push_str(text);
if !text.ends_with('\n') {
out.push('\n');
}
out.push_str(&pad);
out.push_str("```\n\n");
}
Self::Rule => out.push_str("---\n\n"),
Self::FeedHeader {
title,
subtitle,
summary,
source_url,
} => {
out.push_str(&format!("# {title}\n\n"));
if let Some(subtitle) = subtitle {
out.push_str(&format!("## {subtitle}\n\n"));
}
if let Some(summary) = summary {
out.push_str(summary);
out.push_str("\n\n");
}
if let Some(url) = source_url {
out.push_str(&format!("[Open source]({url})\n\n"));
}
}
Self::FeedEntry {
title,
date,
summary,
article_url,
source_url,
} => {
out.push_str(&format!("## {title}\n\n"));
if let Some(date) = date {
out.push_str(&format!("*{date}*\n\n"));
}
if let Some(summary) = summary {
out.push_str(summary);
out.push_str("\n\n");
}
if let Some(url) = article_url {
out.push_str(&format!("[Open article]({url})\n\n"));
}
if let Some(url) = source_url {
out.push_str(&format!("[Open source]({url})\n\n"));
}
}
Self::MetadataRow { label, value } => {
out.push_str(&format!("**{label}:** {value}\n\n"));
}
Self::Badge { text } => {
out.push_str(&format!("> *{text}*\n\n"));
}
Self::Table {
alignments,
header,
rows,
} => {
write_markdown_table(alignments, header, rows, out, &pad);
}
}
}
fn write_gemini(&self, out: &mut String) {
match self {
Self::Table { header, rows, .. } => {
out.push_str("```\n");
for line in table_lines(header, rows) {
out.push_str(&line);
out.push('\n');
}
out.push_str("```\n");
}
Self::Heading { level, spans } => {
let prefix = match level {
1 => "# ",
2 => "## ",
_ => "### ",
};
out.push_str(prefix);
out.push_str(&inline_text(spans));
out.push('\n');
}
Self::Paragraph { spans } => {
let text = inline_text(spans);
if !text.is_empty() && !is_link_only(spans) {
out.push_str(&text);
out.push('\n');
}
let mut links = Vec::new();
for span in spans {
collect_link_targets(span, &mut links);
}
for (url, label) in links {
out.push_str("=> ");
out.push_str(&url);
if !label.is_empty() && label != url {
out.push(' ');
out.push_str(&label);
}
out.push('\n');
}
}
Self::CodeBlock { language, text } => {
out.push_str("```");
if let Some(lang) = language {
out.push_str(lang);
}
out.push('\n');
out.push_str(text);
if !text.ends_with('\n') {
out.push('\n');
}
out.push_str("```\n");
}
Self::Quote { blocks } => {
let mut inner = String::new();
for block in blocks {
block.write_gemini(&mut inner);
}
for line in inner.lines() {
out.push_str("> ");
out.push_str(line);
out.push('\n');
}
}
Self::List { items, .. } => {
for item in items {
let mut inner = String::new();
for block in item {
block.write_gemini(&mut inner);
}
let trimmed = inner.trim();
out.push_str("* ");
out.push_str(trimmed);
out.push('\n');
}
}
Self::Image { url, alt } => {
out.push_str("=> ");
out.push_str(url);
if !alt.is_empty() {
out.push(' ');
out.push_str(alt);
}
out.push('\n');
}
Self::Preformatted { text } => {
out.push_str("```\n");
out.push_str(text);
if !text.ends_with('\n') {
out.push('\n');
}
out.push_str("```\n");
}
Self::Rule => out.push('\n'),
Self::FeedHeader {
title,
subtitle,
summary,
source_url,
} => {
out.push_str(&format!("# {title}\n"));
if let Some(subtitle) = subtitle {
out.push_str(&format!("## {subtitle}\n"));
}
if let Some(summary) = summary {
out.push_str(summary);
out.push('\n');
}
if let Some(url) = source_url {
out.push_str(&format!("=> {url} Open source\n"));
}
}
Self::FeedEntry {
title,
date,
summary,
article_url,
source_url,
} => {
out.push_str(&format!("## {title}\n"));
if let Some(date) = date {
out.push_str(&format!("> {date}\n"));
}
if let Some(summary) = summary {
out.push_str(summary);
out.push('\n');
}
if let Some(url) = article_url {
out.push_str(&format!("=> {url} Open article\n"));
}
if let Some(url) = source_url {
out.push_str(&format!("=> {url} Open source\n"));
}
}
Self::MetadataRow { label, value } => {
out.push_str(&format!("{label}: {value}\n"));
}
Self::Badge { text } => {
out.push_str(&format!("> {text}\n"));
}
}
}
}
impl Block {
fn write_knot(&self, out: &mut String) {
match self {
Self::FeedHeader {
title,
subtitle,
summary,
source_url,
} => {
out.push_str("```feed-header\n");
out.push_str(&format!("title: {title}\n"));
if let Some(s) = subtitle {
out.push_str(&format!("subtitle: {s}\n"));
}
if let Some(s) = summary {
out.push_str(&format!("summary: {s}\n"));
}
if let Some(url) = source_url {
out.push_str(&format!("source: {url}\n"));
}
out.push_str("```\n\n");
}
Self::FeedEntry {
title,
date,
summary,
article_url,
source_url,
} => {
out.push_str("```feed-entry\n");
out.push_str(&format!("title: {title}\n"));
if let Some(d) = date {
out.push_str(&format!("date: {d}\n"));
}
if let Some(s) = summary {
out.push_str(&format!("summary: {s}\n"));
}
if let Some(url) = article_url {
out.push_str(&format!("url: {url}\n"));
}
if let Some(url) = source_url {
out.push_str(&format!("source: {url}\n"));
}
out.push_str("```\n\n");
}
Self::MetadataRow { label, value } => {
out.push_str("```metadata-row\n");
out.push_str(&format!("{label}: {value}\n"));
out.push_str("```\n\n");
}
Self::Badge { text } => {
out.push_str("```badge\n");
out.push_str(text);
if !text.ends_with('\n') {
out.push('\n');
}
out.push_str("```\n\n");
}
Self::Quote { blocks } => {
let mut inner = String::new();
for block in blocks {
block.write_knot(&mut inner);
}
for line in inner.lines() {
out.push_str("> ");
out.push_str(line);
out.push('\n');
}
out.push('\n');
}
Self::List { ordered, items } => {
for (i, item) in items.iter().enumerate() {
if *ordered {
out.push_str(&format!("{}. ", i + 1));
} else {
out.push_str("- ");
}
let mut inner = String::new();
for block in item {
block.write_knot(&mut inner);
}
out.push_str(inner.trim_end());
out.push('\n');
}
out.push('\n');
}
other => other.write_markdown(out, 0),
}
}
}
fn write_inline_markdown(spans: &[InlineSpan], out: &mut String) {
for span in spans {
match span {
InlineSpan::Text(t) => out.push_str(t),
InlineSpan::Code(t) => {
out.push('`');
out.push_str(t);
out.push('`');
}
InlineSpan::Emphasis(s) => {
out.push('*');
write_inline_markdown(s, out);
out.push('*');
}
InlineSpan::Strong(s) => {
out.push_str("**");
write_inline_markdown(s, out);
out.push_str("**");
}
InlineSpan::Link { url, spans, .. } => {
out.push('[');
write_inline_markdown(spans, out);
out.push_str("](");
out.push_str(url);
out.push(')');
}
InlineSpan::SoftBreak => out.push('\n'),
InlineSpan::LineBreak => out.push_str(" \n"),
}
}
}
fn is_link_only(spans: &[InlineSpan]) -> bool {
let mut saw_link = false;
for span in spans {
match span {
InlineSpan::Link { .. } => saw_link = true,
InlineSpan::Text(text) if text.trim().is_empty() => {}
InlineSpan::SoftBreak | InlineSpan::LineBreak => {}
_ => return false,
}
}
saw_link
}
fn collect_link_targets(span: &InlineSpan, out: &mut Vec<(String, String)>) {
match span {
InlineSpan::Link { url, spans, .. } => {
out.push((url.clone(), inline_text(spans)));
for inner in spans {
collect_link_targets(inner, out);
}
}
InlineSpan::Emphasis(spans) | InlineSpan::Strong(spans) => {
for inner in spans {
collect_link_targets(inner, out);
}
}
_ => {}
}
}
mod export;
pub use export::GophermapContext;
#[cfg(test)]
mod tests;