use serde::{Deserialize, Serialize};
mod block_provenance;
mod evaluate;
mod render;
mod transclude;
pub use evaluate::{
BlockEvaluator, BlockEvaluators, EvalOutcome, EvalOutput, EvaluationPolicy, evaluate_blocks,
parse_eval,
};
pub use render::GophermapContext;
pub use transclude::{
Fetched, TranscludeOutcome, TransclusionPolicy, parse_include, resolve_transclusions,
};
pub use block_provenance::{BlockProvenance, BlockProvenanceMap, ResolvedProvenance};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct EngineDocument {
pub address: String,
pub title: Option<String>,
pub content_type: String,
#[serde(default)]
pub lang: Option<String>,
#[serde(default)]
pub provenance: DocumentProvenance,
#[serde(default)]
pub trust: DocumentTrustState,
#[serde(default)]
pub diagnostics: Vec<DocumentDiagnostic>,
pub blocks: Vec<Block>,
}
impl EngineDocument {
pub fn walk_inline_spans(&self) -> Vec<&InlineSpan> {
let mut out = Vec::new();
for block in &self.blocks {
collect_block_spans(block, &mut out);
}
out
}
pub fn outgoing_links(&self) -> Vec<&str> {
let mut links = Vec::new();
for block in &self.blocks {
collect_block_link_urls(block, &mut links);
}
links
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct DocumentProvenance {
#[serde(default)]
pub source_kind: Option<String>,
#[serde(default)]
pub canonical_uri: Option<String>,
#[serde(default)]
pub fetched_at: Option<String>,
#[serde(default)]
pub source_label: Option<String>,
}
impl DocumentProvenance {
pub fn for_engine(engine_id: &str, address: &str) -> Self {
Self {
source_kind: Some(engine_id.to_string()),
canonical_uri: Some(address.to_string()),
fetched_at: None,
source_label: None,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum DocumentTrustState {
Trusted,
Tofu,
Insecure,
Broken,
#[default]
Unknown,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum DocumentDiagnostic {
UnsupportedConstruct(String),
DegradedRendering(String),
ParseWarning(String),
RawSourceFallback,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Block {
Heading { level: u8, spans: Vec<InlineSpan> },
Paragraph { spans: Vec<InlineSpan> },
CodeBlock {
language: Option<String>,
text: String,
},
Quote { blocks: Vec<Block> },
List {
ordered: bool,
items: Vec<Vec<Block>>,
},
Image { url: String, alt: String },
Preformatted { text: String },
Rule,
FeedHeader {
title: String,
subtitle: Option<String>,
summary: Option<String>,
source_url: Option<String>,
},
FeedEntry {
title: String,
date: Option<String>,
summary: Option<String>,
article_url: Option<String>,
source_url: Option<String>,
},
MetadataRow { label: String, value: String },
Badge { text: String },
Table {
alignments: Vec<TableAlignment>,
header: Vec<Vec<InlineSpan>>,
rows: Vec<Vec<Vec<InlineSpan>>>,
},
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum TableAlignment {
#[default]
None,
Left,
Center,
Right,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum InlineSpan {
Text(String),
Code(String),
Emphasis(Vec<InlineSpan>),
Strong(Vec<InlineSpan>),
Link {
url: String,
title: Option<String>,
spans: Vec<InlineSpan>,
#[serde(default)]
predicate: Option<String>,
},
LineBreak,
SoftBreak,
}
pub fn inline_text(spans: &[InlineSpan]) -> String {
let mut out = String::new();
for span in spans {
append_inline_text(span, &mut out);
}
out
}
fn append_inline_text(span: &InlineSpan, out: &mut String) {
match span {
InlineSpan::Text(text) | InlineSpan::Code(text) => out.push_str(text),
InlineSpan::Emphasis(spans) | InlineSpan::Strong(spans) => {
for inner in spans {
append_inline_text(inner, out);
}
}
InlineSpan::Link { spans, .. } => {
for inner in spans {
append_inline_text(inner, out);
}
}
InlineSpan::SoftBreak => out.push(' '),
InlineSpan::LineBreak => out.push('\n'),
}
}
fn collect_block_spans<'a>(block: &'a Block, out: &mut Vec<&'a InlineSpan>) {
match block {
Block::Heading { spans, .. } | Block::Paragraph { spans } => {
for span in spans {
out.push(span);
}
}
Block::Quote { blocks } => {
for inner in blocks {
collect_block_spans(inner, out);
}
}
Block::List { items, .. } => {
for item in items {
for inner in item {
collect_block_spans(inner, out);
}
}
}
Block::Table { header, rows, .. } => {
for cell in header.iter().chain(rows.iter().flatten()) {
for span in cell {
out.push(span);
}
}
}
Block::CodeBlock { .. }
| Block::Image { .. }
| Block::Preformatted { .. }
| Block::Rule
| Block::FeedHeader { .. }
| Block::FeedEntry { .. }
| Block::MetadataRow { .. }
| Block::Badge { .. } => {}
}
}
fn collect_block_link_urls<'a>(block: &'a Block, out: &mut Vec<&'a str>) {
match block {
Block::Heading { spans, .. } | Block::Paragraph { spans } => {
for span in spans {
collect_link_urls(span, out);
}
}
Block::Quote { blocks } => {
for inner in blocks {
collect_block_link_urls(inner, out);
}
}
Block::List { items, .. } => {
for item in items {
for inner in item {
collect_block_link_urls(inner, out);
}
}
}
Block::FeedHeader { source_url, .. } => {
if let Some(url) = source_url {
out.push(url.as_str());
}
}
Block::FeedEntry {
article_url,
source_url,
..
} => {
if let Some(url) = article_url {
out.push(url.as_str());
}
if let Some(url) = source_url {
out.push(url.as_str());
}
}
Block::Table { header, rows, .. } => {
for cell in header.iter().chain(rows.iter().flatten()) {
for span in cell {
collect_link_urls(span, out);
}
}
}
Block::CodeBlock { .. }
| Block::Image { .. }
| Block::Preformatted { .. }
| Block::Rule
| Block::MetadataRow { .. }
| Block::Badge { .. } => {}
}
}
fn collect_link_urls<'a>(span: &'a InlineSpan, out: &mut Vec<&'a str>) {
match span {
InlineSpan::Link { url, spans, .. } => {
out.push(url.as_str());
for inner in spans {
collect_link_urls(inner, out);
}
}
InlineSpan::Emphasis(spans) | InlineSpan::Strong(spans) => {
for inner in spans {
collect_link_urls(inner, out);
}
}
InlineSpan::Text(_)
| InlineSpan::Code(_)
| InlineSpan::LineBreak
| InlineSpan::SoftBreak => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn doc(blocks: Vec<Block>) -> EngineDocument {
EngineDocument {
address: "doc:1".into(),
title: None,
content_type: "text/plain".into(),
lang: None,
provenance: DocumentProvenance::default(),
trust: DocumentTrustState::Unknown,
diagnostics: Vec::new(),
blocks,
}
}
#[test]
fn provenance_for_engine_records_kind_and_uri() {
let p = DocumentProvenance::for_engine("nematic.markdown", "file:///x.md");
assert_eq!(p.source_kind.as_deref(), Some("nematic.markdown"));
assert_eq!(p.canonical_uri.as_deref(), Some("file:///x.md"));
assert!(p.fetched_at.is_none());
}
#[test]
fn trust_state_default_is_unknown() {
let t: DocumentTrustState = Default::default();
assert_eq!(t, DocumentTrustState::Unknown);
}
#[test]
fn outgoing_links_walks_feed_entry_urls() {
let document = doc(vec![
Block::FeedHeader {
title: "Feed".into(),
subtitle: None,
summary: None,
source_url: Some("https://feed.test/".into()),
},
Block::FeedEntry {
title: "Entry".into(),
date: None,
summary: None,
article_url: Some("https://feed.test/post-1".into()),
source_url: Some("https://feed.test/".into()),
},
]);
assert_eq!(
document.outgoing_links(),
vec![
"https://feed.test/",
"https://feed.test/post-1",
"https://feed.test/",
]
);
}
}