From 0c9db28bd1ba99aa98f54f22839088428e601a34 Mon Sep 17 00:00:00 2001 From: Owais Jamil Date: Sat, 28 Feb 2026 00:11:55 -0600 Subject: [PATCH] feat: plaintext export --- crates/core/src/nlp.rs | 1 - crates/markdown/src/assets/export.css | 129 ++ crates/markdown/src/diagnostics.rs | 184 +++ crates/markdown/src/lib.rs | 1039 ++++++----------- crates/markdown/src/parser.rs | 189 +++ crates/markdown/src/transformer.rs | 294 +++++ crates/markdown/src/utils.rs | 21 + docs/roadmap.md | 2 +- src-tauri/src/commands.rs | 43 +- src-tauri/src/lib.rs | 1 + src/App.tsx | 13 +- src/__tests__/Editor.test.tsx | 1 - src/__tests__/ExportDialog.test.tsx | 10 +- src/__tests__/useTextExport.test.tsx | 185 +++ src/components/Toaster.tsx | 88 ++ src/components/export/TextPreview.tsx | 124 ++ src/components/icons.tsx | 18 + .../pdf/ExportDialog/ExportDialog.tsx | 230 +++- .../pdf/ExportDialog/ExportFooter.tsx | 48 +- .../pdf/ExportDialog/ExportHeader.tsx | 4 +- .../controllers/useWorkspaceViewController.ts | 2 + src/hooks/useTextExport.tsx | 119 ++ src/ports/commands.ts | 23 + src/ports/types.ts | 1 + src/state/selectors.ts | 16 + src/state/stores/app.ts | 16 +- src/state/stores/text-export.ts | 19 + src/state/stores/toasts.ts | 69 ++ src/state/types.ts | 11 + src/types.ts | 2 + 30 files changed, 2128 insertions(+), 774 deletions(-) create mode 100644 crates/markdown/src/assets/export.css create mode 100644 crates/markdown/src/diagnostics.rs create mode 100644 crates/markdown/src/parser.rs create mode 100644 crates/markdown/src/transformer.rs create mode 100644 crates/markdown/src/utils.rs create mode 100644 src/__tests__/useTextExport.test.tsx create mode 100644 src/components/Toaster.tsx create mode 100644 src/components/export/TextPreview.tsx create mode 100644 src/hooks/useTextExport.tsx create mode 100644 src/state/stores/text-export.ts create mode 100644 src/state/stores/toasts.ts diff --git a/crates/core/src/nlp.rs b/crates/core/src/nlp.rs index 60150b5..9e92391 100644 --- a/crates/core/src/nlp.rs +++ b/crates/core/src/nlp.rs @@ -407,7 +407,6 @@ mod tests { fn reports_utf16_ranges_for_multibyte_characters() { let matcher = matcher(&[("just", PatternCategory::Filler)]); - // "πŸ™‚" is two UTF-16 code units, so "just" starts at 3 in UTF-16 offsets. let matches = matcher.scan("aπŸ™‚just"); assert_eq!(matches.len(), 1); diff --git a/crates/markdown/src/assets/export.css b/crates/markdown/src/assets/export.css new file mode 100644 index 0000000..cd697fb --- /dev/null +++ b/crates/markdown/src/assets/export.css @@ -0,0 +1,129 @@ +body { + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, + sans-serif; + line-height: 1.6; + max-width: 800px; + margin: 0 auto; + padding: 2rem; + color: #333; + background: #fff; +} + +header { + border-bottom: 2px solid #eee; + margin-bottom: 2rem; + padding-bottom: 1rem; +} + +header h1 { + margin: 0 0 0.5rem 0; + color: #222; +} + +.metadata { + color: #666; + font-size: 0.9rem; +} + +.metadata span { + margin-right: 1rem; +} + +main h1, +main h2, +main h3, +main h4, +main h5, +main h6 { + margin-top: 2rem; + margin-bottom: 1rem; + color: #222; +} + +main p { + margin-bottom: 1rem; +} + +main a { + color: #0066cc; + text-decoration: none; +} + +main a:hover { + text-decoration: underline; +} + +main code { + background: #f4f4f4; + padding: 0.2rem 0.4rem; + border-radius: 3px; + font-family: "SF Mono", Monaco, Inconsolata, "Fira Code", monospace; + font-size: 0.9em; +} + +main pre { + background: #f4f4f4; + padding: 1rem; + border-radius: 5px; + overflow-x: auto; + margin-bottom: 1rem; +} + +main pre code { + background: none; + padding: 0; +} + +main blockquote { + border-left: 4px solid #ddd; + padding-left: 1rem; + margin-left: 0; + color: #666; +} + +main table { + border-collapse: collapse; + width: 100%; + margin-bottom: 1rem; +} + +main th, +main td { + border: 1px solid #ddd; + padding: 0.5rem; + text-align: left; +} + +main th { + background: #f8f8f8; + font-weight: 600; +} + +main ul, +main ol { + margin-bottom: 1rem; + padding-left: 2rem; +} + +main li { + margin-bottom: 0.25rem; +} + +main input[type="checkbox"] { + margin-right: 0.5rem; +} + +main del { + text-decoration: line-through; + color: #666; +} + +footer { + margin-top: 3rem; + padding-top: 1rem; + border-top: 1px solid #eee; + color: #666; + font-size: 0.9rem; + text-align: center; +} diff --git a/crates/markdown/src/diagnostics.rs b/crates/markdown/src/diagnostics.rs new file mode 100644 index 0000000..1603354 --- /dev/null +++ b/crates/markdown/src/diagnostics.rs @@ -0,0 +1,184 @@ +use super::DocumentMetadata; +use serde::{Deserialize, Serialize}; + +/// Severity level for diagnostics +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum DiagnosticSeverity { + Error, + Warning, + Info, +} + +impl std::fmt::Display for DiagnosticSeverity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DiagnosticSeverity::Error => write!(f, "error"), + DiagnosticSeverity::Warning => write!(f, "warning"), + DiagnosticSeverity::Info => write!(f, "info"), + } + } +} + +/// A single diagnostic message (lint-like warning or error) +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Diagnostic { + pub severity: DiagnosticSeverity, + pub code: String, + pub message: String, + /// Line number (1-indexed) where the issue occurs + pub line: Option, + /// Column number (1-indexed) where the issue occurs + pub column: Option, + /// Source text that triggered the diagnostic + pub source: Option, +} + +impl Diagnostic { + /// Creates a new diagnostic + pub fn new(severity: DiagnosticSeverity, code: impl Into, message: impl Into) -> Self { + Self { severity, code: code.into(), message: message.into(), line: None, column: None, source: None } + } + + /// Adds position information to the diagnostic + pub fn at_position(mut self, line: usize, column: usize) -> Self { + self.line = Some(line); + self.column = Some(column); + self + } + + /// Adds source text to the diagnostic + pub fn with_source(mut self, source: impl Into) -> Self { + self.source = Some(source.into()); + self + } + + /// Creates an error diagnostic + pub fn error(code: impl Into, message: impl Into) -> Self { + Self::new(DiagnosticSeverity::Error, code, message) + } + + /// Creates a warning diagnostic + pub fn warning(code: impl Into, message: impl Into) -> Self { + Self::new(DiagnosticSeverity::Warning, code, message) + } + + /// Creates an info diagnostic + pub fn info(code: impl Into, message: impl Into) -> Self { + Self::new(DiagnosticSeverity::Info, code, message) + } +} + +/// Collection of all diagnostics for a document +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Diagnostics { + pub items: Vec, +} + +impl Diagnostics { + /// Creates an empty diagnostics collection + pub fn new() -> Self { + Self::default() + } + + /// Adds a diagnostic to the collection + pub fn push(&mut self, diagnostic: Diagnostic) { + self.items.push(diagnostic); + } + + /// Returns true if there are no diagnostics + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + /// Returns the number of diagnostics + pub fn len(&self) -> usize { + self.items.len() + } + + /// Returns all errors + pub fn errors(&self) -> Vec<&Diagnostic> { + self.items + .iter() + .filter(|d| matches!(d.severity, DiagnosticSeverity::Error)) + .collect() + } + + /// Returns all warnings + pub fn warnings(&self) -> Vec<&Diagnostic> { + self.items + .iter() + .filter(|d| matches!(d.severity, DiagnosticSeverity::Warning)) + .collect() + } + + /// Returns diagnostics filtered by severity + pub fn by_severity(&self, severity: DiagnosticSeverity) -> Vec<&Diagnostic> { + self.items.iter().filter(|d| d.severity == severity).collect() + } + + /// Runs all diagnostic checks on the document + pub fn run(text: &str, metadata: &DocumentMetadata) -> Self { + let mut diagnostics = Self::new(); + + diagnostics.check_duplicate_heading_ids(metadata); + diagnostics.check_malformed_links(metadata); + diagnostics.check_mixed_line_endings(text); + + diagnostics + } + + /// Checks for duplicate heading IDs + fn check_duplicate_heading_ids(&mut self, metadata: &DocumentMetadata) { + let mut seen_anchors: std::collections::HashMap> = std::collections::HashMap::new(); + + for (idx, heading) in metadata.outline.iter().enumerate() { + if let Some(anchor) = &heading.anchor { + seen_anchors.entry(anchor.clone()).or_default().push(idx); + } + } + + for (anchor, indices) in seen_anchors { + if indices.len() > 1 { + for idx in &indices { + if let Some(heading) = metadata.outline.get(*idx) { + self.push( + Diagnostic::warning("dup-heading-id", format!("Duplicate heading ID: {}", anchor)) + .at_position(*idx + 1, 1) + .with_source(format!("{} {}", "#".repeat(heading.level as usize), heading.text)), + ); + } + } + } + } + } + + /// Checks for malformed links (empty URLs, invalid protocols) + fn check_malformed_links(&mut self, metadata: &DocumentMetadata) { + for link in &metadata.links { + if link.url.is_empty() { + self.push( + Diagnostic::warning("empty-link-url", "Link has empty URL") + .with_source(format!("[{}]", link.title.as_deref().unwrap_or("text"))), + ); + } else if link.url.starts_with("javascript:") { + self.push( + Diagnostic::error("javascript-link", format!("JavaScript URL detected: {}", link.url)) + .with_source(link.url.clone()), + ); + } + } + } + + /// Checks for mixed line endings (CRLF and LF) + fn check_mixed_line_endings(&mut self, text: &str) { + let has_crlf = text.contains("\r\n"); + let has_lf = text.contains('\n') && text.replace("\r\n", "").contains('\n'); + + if has_crlf && has_lf { + self.push(Diagnostic::warning( + "mixed-line-endings", + "Document contains mixed line endings (CRLF and LF)", + )); + } + } +} diff --git a/crates/markdown/src/lib.rs b/crates/markdown/src/lib.rs index 5765938..8404ea3 100644 --- a/crates/markdown/src/lib.rs +++ b/crates/markdown/src/lib.rs @@ -1,7 +1,14 @@ -use comrak::nodes::NodeValue; use comrak::{Arena, Options, parse_document}; +use diagnostics::Diagnostics; +use parser::MarkdownParser; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use transformer::MarkdownTransformer; + +mod diagnostics; +mod parser; +mod transformer; +mod utils; /// Front matter format for documents #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] @@ -106,122 +113,6 @@ impl MarkdownProfile { } } -/// Severity level for diagnostics -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum DiagnosticSeverity { - Error, - Warning, - Info, -} - -impl std::fmt::Display for DiagnosticSeverity { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - DiagnosticSeverity::Error => write!(f, "error"), - DiagnosticSeverity::Warning => write!(f, "warning"), - DiagnosticSeverity::Info => write!(f, "info"), - } - } -} - -/// A single diagnostic message (lint-like warning or error) -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct Diagnostic { - pub severity: DiagnosticSeverity, - pub code: String, - pub message: String, - /// Line number (1-indexed) where the issue occurs - pub line: Option, - /// Column number (1-indexed) where the issue occurs - pub column: Option, - /// Source text that triggered the diagnostic - pub source: Option, -} - -impl Diagnostic { - /// Creates a new diagnostic - pub fn new(severity: DiagnosticSeverity, code: impl Into, message: impl Into) -> Self { - Self { severity, code: code.into(), message: message.into(), line: None, column: None, source: None } - } - - /// Adds position information to the diagnostic - pub fn at_position(mut self, line: usize, column: usize) -> Self { - self.line = Some(line); - self.column = Some(column); - self - } - - /// Adds source text to the diagnostic - pub fn with_source(mut self, source: impl Into) -> Self { - self.source = Some(source.into()); - self - } - - /// Creates an error diagnostic - pub fn error(code: impl Into, message: impl Into) -> Self { - Self::new(DiagnosticSeverity::Error, code, message) - } - - /// Creates a warning diagnostic - pub fn warning(code: impl Into, message: impl Into) -> Self { - Self::new(DiagnosticSeverity::Warning, code, message) - } - - /// Creates an info diagnostic - pub fn info(code: impl Into, message: impl Into) -> Self { - Self::new(DiagnosticSeverity::Info, code, message) - } -} - -/// Collection of all diagnostics for a document -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] -pub struct Diagnostics { - pub items: Vec, -} - -impl Diagnostics { - /// Creates an empty diagnostics collection - pub fn new() -> Self { - Self::default() - } - - /// Adds a diagnostic to the collection - pub fn push(&mut self, diagnostic: Diagnostic) { - self.items.push(diagnostic); - } - - /// Returns true if there are no diagnostics - pub fn is_empty(&self) -> bool { - self.items.is_empty() - } - - /// Returns the number of diagnostics - pub fn len(&self) -> usize { - self.items.len() - } - - /// Returns all errors - pub fn errors(&self) -> Vec<&Diagnostic> { - self.items - .iter() - .filter(|d| matches!(d.severity, DiagnosticSeverity::Error)) - .collect() - } - - /// Returns all warnings - pub fn warnings(&self) -> Vec<&Diagnostic> { - self.items - .iter() - .filter(|d| matches!(d.severity, DiagnosticSeverity::Warning)) - .collect() - } - - /// Returns diagnostics filtered by severity - pub fn by_severity(&self, severity: DiagnosticSeverity) -> Vec<&Diagnostic> { - self.items.iter().filter(|d| d.severity == severity).collect() - } -} - /// A heading in the document outline #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Heading { @@ -287,6 +178,53 @@ pub enum MarkdownError { ParseError(String), } +/// PDF node types for structured PDF export +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum PdfNode { + /// Heading with level and content + Heading { level: u8, content: String }, + /// Paragraph text + Paragraph { content: String }, + /// Code block with optional language + Code { content: String, language: Option }, + /// List with items and ordering flag + List { items: Vec, ordered: bool }, + /// Blockquote content + Blockquote { content: String }, + /// Footnote with id and content + Footnote { id: String, content: String }, +} + +/// Result of rendering Markdown for PDF export +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PdfRenderResult { + /// The PDF AST nodes + pub nodes: Vec, + /// Document title from metadata + pub title: Option, + /// Word count + pub word_count: usize, +} + +/// Result of rendering Markdown for plaintext export +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TextExportResult { + /// The plaintext content + pub text: String, + /// Document title from metadata + pub title: Option, + /// Word count + pub word_count: usize, +} + +/// A list item for PDF export +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PdfListItem { + /// Content of the list item (typically a paragraph) + pub content: String, +} + /// Options for HTML export #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExportOptions { @@ -353,142 +291,15 @@ impl ExportOptions { } } -/// Default CSS styles for HTML export -const DEFAULT_EXPORT_CSS: &str = r#" - body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - line-height: 1.6; - max-width: 800px; - margin: 0 auto; - padding: 2rem; - color: #333; - background: #fff; - } - - header { - border-bottom: 2px solid #eee; - margin-bottom: 2rem; - padding-bottom: 1rem; - } - - header h1 { - margin: 0 0 0.5rem 0; - color: #222; - } - - .metadata { - color: #666; - font-size: 0.9rem; - } - - .metadata span { - margin-right: 1rem; - } - - main h1, main h2, main h3, main h4, main h5, main h6 { - margin-top: 2rem; - margin-bottom: 1rem; - color: #222; - } - - main p { - margin-bottom: 1rem; - } - - main a { - color: #0066cc; - text-decoration: none; - } - - main a:hover { - text-decoration: underline; - } - - main code { - background: #f4f4f4; - padding: 0.2rem 0.4rem; - border-radius: 3px; - font-family: "SF Mono", Monaco, Inconsolata, "Fira Code", monospace; - font-size: 0.9em; - } - - main pre { - background: #f4f4f4; - padding: 1rem; - border-radius: 5px; - overflow-x: auto; - margin-bottom: 1rem; - } - - main pre code { - background: none; - padding: 0; - } - - main blockquote { - border-left: 4px solid #ddd; - padding-left: 1rem; - margin-left: 0; - color: #666; - } - - main table { - border-collapse: collapse; - width: 100%; - margin-bottom: 1rem; - } - - main th, main td { - border: 1px solid #ddd; - padding: 0.5rem; - text-align: left; - } - - main th { - background: #f8f8f8; - font-weight: 600; - } - - main ul, main ol { - margin-bottom: 1rem; - padding-left: 2rem; - } - - main li { - margin-bottom: 0.25rem; - } - - main input[type="checkbox"] { - margin-right: 0.5rem; - } - - main del { - text-decoration: line-through; - color: #666; - } +/// The main Markdown engine for parsing and rendering +pub struct MarkdownEngine; - footer { - margin-top: 3rem; - padding-top: 1rem; - border-top: 1px solid #eee; - color: #666; - font-size: 0.9rem; - text-align: center; +impl Default for MarkdownEngine { + fn default() -> Self { + Self::new() } -"#; - -/// Escapes HTML special characters -fn html_escape(text: &str) -> String { - text.replace('&', "&") - .replace('<', "<") - .replace('>', ">") - .replace('"', """) - .replace('\'', "'") } -/// The main Markdown engine for parsing and rendering -pub struct MarkdownEngine; - impl MarkdownEngine { /// Creates a new Markdown engine pub fn new() -> Self { @@ -501,13 +312,13 @@ impl MarkdownEngine { let options = profile.to_options(); let (body_text, front_matter) = if profile.supports_front_matter() { - Self::extract_front_matter(text) + MarkdownParser::extract_front_matter(text) } else { (text, FrontMatter::default()) }; let root = parse_document(&arena, body_text, &options); - Ok(Self::build_metadata(root, body_text, front_matter)) + Ok(MarkdownParser::build_metadata(root, body_text, front_matter)) } /// Renders Markdown text to HTML using the specified profile @@ -516,289 +327,26 @@ impl MarkdownEngine { let options = profile.to_options(); let (body_text, front_matter) = if profile.supports_front_matter() { - Self::extract_front_matter(text) + MarkdownParser::extract_front_matter(text) } else { (text, FrontMatter::default()) }; let root = parse_document(&arena, body_text, &options); - - let metadata = Self::build_metadata(root, body_text, front_matter); + let metadata = MarkdownParser::build_metadata(root, body_text, front_matter); let mut html_output = String::new(); comrak::format_html(root, &options, &mut html_output).map_err(|e| MarkdownError::ParseError(e.to_string()))?; - let diagnostics = Self::run_diagnostics(text, &metadata); + let diagnostics = Diagnostics::run(text, &metadata); Ok(RenderResult { html: html_output, metadata, diagnostics }) } - fn build_metadata<'a>( - root: &'a comrak::nodes::AstNode<'a>, body_text: &str, front_matter: FrontMatter, - ) -> DocumentMetadata { - let mut metadata = DocumentMetadata { - title: None, - outline: Vec::new(), - links: Vec::new(), - task_items: TaskStats::default(), - word_count: 0, - front_matter, - }; - - Self::extract_metadata_from_node(root, &mut metadata, &mut true); - - if let Some(title) = metadata.front_matter.fields.get("title") { - metadata.title = Some(title.clone()); - } - - metadata.word_count = Self::estimate_word_count(body_text); - metadata - } - - /// Extracts front matter from the beginning of the document - /// - /// Supports YAML (---) and TOML (+++) front matter delimiters - fn extract_front_matter(text: &str) -> (&str, FrontMatter) { - let trimmed = text.trim_start(); - - if let Some(rest) = trimmed.strip_prefix("---") - && let Some(end_pos) = rest.find("\n---") - { - let fm_content = &rest[..end_pos]; - let delimiter_end = end_pos + "\n---".len(); - let body = rest[delimiter_end..] - .strip_prefix('\n') - .map_or(&rest[delimiter_end..], |value| value); - - let fields = Self::parse_yaml_like_front_matter(fm_content); - - return ( - body, - FrontMatter { raw: Some(fm_content.to_string()), format: Some(FrontMatterFormat::Yaml), fields }, - ); - } - - if let Some(rest) = trimmed.strip_prefix("+++") - && let Some(end_pos) = rest.find("\n+++") - { - let fm_content = &rest[..end_pos]; - let delimiter_end = end_pos + "\n+++".len(); - let body = rest[delimiter_end..] - .strip_prefix('\n') - .map_or(&rest[delimiter_end..], |value| value); - - let fields = Self::parse_toml_like_front_matter(fm_content); - - return ( - body, - FrontMatter { raw: Some(fm_content.to_string()), format: Some(FrontMatterFormat::Toml), fields }, - ); - } - - (text, FrontMatter::default()) - } - - /// Parses YAML-like front matter into key-value pairs - /// - /// This is a simple parser that handles basic "key: value" pairs. - /// For complex YAML, a full YAML parser would be needed. - fn parse_yaml_like_front_matter(content: &str) -> HashMap { - let mut fields = HashMap::new(); - - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - - if let Some(pos) = trimmed.find(':') { - let key = trimmed[..pos].trim().to_string(); - let value = trimmed[pos + 1..] - .trim() - .trim_matches('"') - .trim_matches('\'') - .to_string(); - if !key.is_empty() { - fields.insert(key, value); - } - } - } - - fields - } - - /// Parses TOML-like front matter into key-value pairs - /// - /// This is a simple parser that handles basic "key = value" pairs. - /// For complex TOML, a full TOML parser would be needed. - fn parse_toml_like_front_matter(content: &str) -> HashMap { - let mut fields = HashMap::new(); - - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() || trimmed.starts_with('#') { - continue; - } - - if let Some(pos) = trimmed.find('=') { - let key = trimmed[..pos].trim().to_string(); - let value = trimmed[pos + 1..] - .trim() - .trim_matches('"') - .trim_matches('\'') - .to_string(); - if !key.is_empty() { - fields.insert(key, value); - } - } - } - - fields - } - - /// Runs all diagnostic checks on the document - fn run_diagnostics(text: &str, metadata: &DocumentMetadata) -> Diagnostics { - let mut diagnostics = Diagnostics::new(); - - Self::check_duplicate_heading_ids(&mut diagnostics, metadata); - Self::check_malformed_links(&mut diagnostics, metadata); - Self::check_mixed_line_endings(&mut diagnostics, text); - - diagnostics - } - - /// Checks for duplicate heading IDs - fn check_duplicate_heading_ids(diagnostics: &mut Diagnostics, metadata: &DocumentMetadata) { - let mut seen_anchors: std::collections::HashMap> = std::collections::HashMap::new(); - - for (idx, heading) in metadata.outline.iter().enumerate() { - if let Some(anchor) = &heading.anchor { - seen_anchors.entry(anchor.clone()).or_default().push(idx); - } - } - - for (anchor, indices) in seen_anchors { - if indices.len() > 1 { - for idx in &indices { - if let Some(heading) = metadata.outline.get(*idx) { - diagnostics.push( - Diagnostic::warning("dup-heading-id", format!("Duplicate heading ID: {}", anchor)) - .at_position(*idx + 1, 1) - .with_source(format!("{} {}", "#".repeat(heading.level as usize), heading.text)), - ); - } - } - } - } - } - - /// Checks for malformed links (empty URLs, invalid protocols) - fn check_malformed_links(diagnostics: &mut Diagnostics, metadata: &DocumentMetadata) { - for link in &metadata.links { - if link.url.is_empty() { - diagnostics.push( - Diagnostic::warning("empty-link-url", "Link has empty URL") - .with_source(format!("[{}]", link.title.as_deref().unwrap_or("text"))), - ); - } else if link.url.starts_with("javascript:") { - diagnostics.push( - Diagnostic::error("javascript-link", format!("JavaScript URL detected: {}", link.url)) - .with_source(link.url.clone()), - ); - } - } - } - - /// Checks for mixed line endings (CRLF and LF) - fn check_mixed_line_endings(diagnostics: &mut Diagnostics, text: &str) { - let has_crlf = text.contains("\r\n"); - let has_lf = text.contains('\n') && text.replace("\r\n", "").contains('\n'); - - if has_crlf && has_lf { - diagnostics.push(Diagnostic::warning( - "mixed-line-endings", - "Document contains mixed line endings (CRLF and LF)", - )); - } - } - /// Renders Markdown using the default GfmSafe profile pub fn render_default(&self, text: &str) -> Result { self.render(text, MarkdownProfile::default()) } - /// Extracts metadata by traversing the AST - fn extract_metadata_from_node<'a>( - node: &'a comrak::nodes::AstNode<'a>, metadata: &mut DocumentMetadata, first_h1: &mut bool, - ) { - match &node.data.borrow().value { - NodeValue::Heading(heading) => { - let level = heading.level; - let text = Self::extract_text_from_node(node); - - if level == 1 && *first_h1 { - metadata.title = Some(text.clone()); - *first_h1 = false; - } - - metadata.outline.push(Heading { level, text, anchor: None }); - } - NodeValue::Link(link) => { - metadata.links.push(LinkRef { - url: link.url.clone(), - title: if link.title.is_empty() { None } else { Some(link.title.clone()) }, - }); - } - NodeValue::TaskItem(task_item) => { - metadata.task_items.total += 1; - if let Some(symbol) = task_item.symbol - && (symbol == 'x' || symbol == 'X') - { - metadata.task_items.completed += 1; - } - } - _ => {} - } - - for child in node.children() { - Self::extract_metadata_from_node(child, metadata, first_h1); - } - } - - /// Extracts plain text from a node and its children - fn extract_text_from_node<'a>(node: &'a comrak::nodes::AstNode<'a>) -> String { - let mut text = String::new(); - - match &node.data.borrow().value { - NodeValue::Text(t) => { - text.push_str(t); - } - NodeValue::Code(code) => { - text.push_str(&code.literal); - } - _ => { - for child in node.children() { - text.push_str(&Self::extract_text_from_node(child)); - } - } - } - - text - } - - /// Estimates word count from Markdown text - /// - /// This is a simple estimation that counts whitespace-separated tokens - fn estimate_word_count(text: &str) -> usize { - text.split_whitespace().filter(|s| !s.is_empty()).count() - } - - /// Validates that HTML output contains source position attributes - /// - /// This is useful for testing that sourcepos is enabled - pub fn has_sourcepos(html: &str) -> bool { - html.contains("data-sourcepos") - } - /// Exports Markdown to a complete HTML document pub fn export_html( &self, text: &str, profile: MarkdownProfile, options: &ExportOptions, @@ -821,11 +369,11 @@ impl MarkdownEngine { .clone() .or_else(|| render_result.metadata.title.clone()) .unwrap_or_else(|| "Exported Document".to_string()); - output.push_str(&format!(" {}\n", html_escape(&title))); + output.push_str(&format!(" {}\n", utils::html_escape(&title))); if options.include_default_styles { output.push_str(" \n"); } @@ -838,7 +386,7 @@ impl MarkdownEngine { for css_url in &options.external_css_urls { output.push_str(&format!( " \n", - html_escape(css_url) + utils::html_escape(css_url) )); } @@ -848,7 +396,7 @@ impl MarkdownEngine { if options.include_header && !title.is_empty() { output.push_str("
\n"); - output.push_str(&format!("

{}

\n", html_escape(&title))); + output.push_str(&format!("

{}

\n", utils::html_escape(&title))); if options.include_metadata { output.push_str("
\n"); @@ -856,12 +404,15 @@ impl MarkdownEngine { if let Some(author) = render_result.metadata.front_matter.fields.get("author") { output.push_str(&format!( " {}\n", - html_escape(author) + utils::html_escape(author) )); } if let Some(date) = render_result.metadata.front_matter.fields.get("date") { - output.push_str(&format!(" {}\n", html_escape(date))); + output.push_str(&format!( + " {}\n", + utils::html_escape(date) + )); } if render_result.metadata.word_count > 0 { @@ -900,51 +451,7 @@ impl MarkdownEngine { let render_result = self.render(text, profile)?; Ok(render_result.html) } -} - -impl Default for MarkdownEngine { - fn default() -> Self { - Self::new() - } -} - -/// PDF node types for structured PDF export -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum PdfNode { - /// Heading with level and content - Heading { level: u8, content: String }, - /// Paragraph text - Paragraph { content: String }, - /// Code block with optional language - Code { content: String, language: Option }, - /// List with items and ordering flag - List { items: Vec, ordered: bool }, - /// Blockquote content - Blockquote { content: String }, - /// Footnote with id and content - Footnote { id: String, content: String }, -} - -/// Result of rendering Markdown for PDF export -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PdfRenderResult { - /// The PDF AST nodes - pub nodes: Vec, - /// Document title from metadata - pub title: Option, - /// Word count - pub word_count: usize, -} - -/// A list item for PDF export -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct PdfListItem { - /// Content of the list item (typically a paragraph) - pub content: String, -} -impl MarkdownEngine { /// Renders Markdown text to a PDF-compatible AST /// /// Parses the markdown and transforms it into a structured format @@ -954,110 +461,38 @@ impl MarkdownEngine { let options = profile.to_options(); let (body_text, front_matter) = if profile.supports_front_matter() { - Self::extract_front_matter(text) + MarkdownParser::extract_front_matter(text) } else { (text, FrontMatter::default()) }; let root = parse_document(&arena, body_text, &options); - let metadata = Self::build_metadata(root, body_text, front_matter); - - let nodes = Self::transform_to_pdf_nodes(root); + let metadata = MarkdownParser::build_metadata(root, body_text, front_matter); + let nodes = MarkdownTransformer::transform_to_pdf_nodes(root); Ok(PdfRenderResult { nodes, title: metadata.title, word_count: metadata.word_count }) } - /// Transforms a Comrak AST node into PDF nodes - fn transform_to_pdf_nodes<'a>(node: &'a comrak::nodes::AstNode<'a>) -> Vec { - use comrak::nodes::NodeValue; - - let mut nodes = Vec::new(); - - for child in node.children() { - match &child.data.borrow().value { - NodeValue::Document => nodes.extend(Self::transform_to_pdf_nodes(child)), - NodeValue::Heading(heading) => { - let content = Self::extract_text_content(child); - nodes.push(PdfNode::Heading { level: heading.level, content }); - } - NodeValue::Paragraph => { - let content = Self::extract_text_content(child); - if !content.is_empty() { - nodes.push(PdfNode::Paragraph { content }); - } - } - NodeValue::CodeBlock(code_block) => { - let content = code_block.literal.clone(); - let language = if code_block.info.is_empty() { None } else { Some(code_block.info.clone()) }; - nodes.push(PdfNode::Code { content, language }); - } - NodeValue::List(list) => { - let items = Self::transform_list_items(child, list.list_type == comrak::nodes::ListType::Ordered); - if !items.is_empty() { - nodes - .push(PdfNode::List { items, ordered: list.list_type == comrak::nodes::ListType::Ordered }); - } - } - NodeValue::BlockQuote => { - let content = Self::extract_text_content(child); - nodes.push(PdfNode::Blockquote { content }); - } - NodeValue::FootnoteDefinition(footnote) => { - let content = Self::extract_text_content(child); - nodes.push(PdfNode::Footnote { id: footnote.name.clone(), content }); - } - _ => nodes.extend(Self::transform_to_pdf_nodes(child)), - } - } - - nodes - } - - /// Transforms list items from a list node - fn transform_list_items<'a>(list_node: &'a comrak::nodes::AstNode<'a>, _ordered: bool) -> Vec { - let mut items = Vec::new(); - - for child in list_node.children() { - match &child.data.borrow().value { - comrak::nodes::NodeValue::Item(_) => { - let content = Self::extract_text_content(child); - if !content.is_empty() { - items.push(PdfNode::Paragraph { content }); - } - } - _ => items.extend(Self::transform_list_items(child, _ordered)), - } - } - - items - } + /// Renders Markdown text to plaintext format + /// + /// Parses the markdown and transforms it into plain text with preserved + /// logical structure (paragraph breaks, list indentation, horizontal rules). + pub fn render_for_text(&self, text: &str, profile: MarkdownProfile) -> Result { + let arena = Arena::new(); + let options = profile.to_options(); - /// Extracts plain text content from a node and its children - fn extract_text_content<'a>(node: &'a comrak::nodes::AstNode<'a>) -> String { - use comrak::nodes::NodeValue; + let (body_text, front_matter) = if profile.supports_front_matter() { + MarkdownParser::extract_front_matter(text) + } else { + (text, FrontMatter::default()) + }; - let mut text = String::new(); + let root = parse_document(&arena, body_text, &options); + let metadata = MarkdownParser::build_metadata(root, body_text, front_matter); - for child in node.children() { - match &child.data.borrow().value { - NodeValue::Text(t) => text.push_str(t), - NodeValue::SoftBreak | NodeValue::LineBreak => text.push(' '), - NodeValue::Code(code) => text.push_str(&code.literal), - NodeValue::Emph | NodeValue::Strong => text.push_str(&Self::extract_text_content(child)), - NodeValue::Link(link) => { - let link_text = Self::extract_text_content(child); - if link_text.is_empty() { - text.push_str(&link.url); - } else { - text.push_str(&link_text); - } - } - NodeValue::Strikethrough => text.push_str(&Self::extract_text_content(child)), - _ => text.push_str(&Self::extract_text_content(child)), - } - } + let plain_text = MarkdownTransformer::transform_to_plaintext(root); - text.trim().to_string() + Ok(TextExportResult { text: plain_text, title: metadata.title, word_count: metadata.word_count }) } } @@ -1113,7 +548,7 @@ mod tests { let markdown = "# Hello\n\nParagraph here."; let result = engine.render(markdown, MarkdownProfile::GfmSafe).unwrap(); - assert!(MarkdownEngine::has_sourcepos(&result.html)); + assert!(utils::has_sourcepos(&result.html)); assert!(result.html.contains("data-sourcepos")); } @@ -1227,7 +662,7 @@ mod tests { } #[test] - fn test_golden_basic_markdown() { + fn test_basic_markdown() { let engine = MarkdownEngine::new(); let fixtures = fixtures_dir(); let markdown = fs::read_to_string(fixtures.join("basic.md")).expect("Failed to read basic.md"); @@ -1242,7 +677,7 @@ mod tests { } #[test] - fn test_golden_basic_outline() { + fn test_basic_outline() { let engine = MarkdownEngine::new(); let fixtures = fixtures_dir(); let markdown = fs::read_to_string(fixtures.join("basic.md")).expect("Failed to read basic.md"); @@ -1260,7 +695,7 @@ mod tests { } #[test] - fn test_golden_basic_task_stats() { + fn test_basic_task_stats() { let engine = MarkdownEngine::new(); let fixtures = fixtures_dir(); let markdown = fs::read_to_string(fixtures.join("basic.md")).expect("Failed to read basic.md"); @@ -1270,7 +705,7 @@ mod tests { } #[test] - fn test_golden_sourcepos_present_in_fixture() { + fn test_sourcepos_present_in_fixture() { let engine = MarkdownEngine::new(); let fixtures = fixtures_dir(); let markdown = fs::read_to_string(fixtures.join("basic.md")).expect("Failed to read basic.md"); @@ -1294,7 +729,7 @@ mod tests { } #[test] - fn test_golden_xss_safety() { + fn test_xss_safety() { let engine = MarkdownEngine::new(); let fixtures = render_fixtures_dir(); @@ -1419,7 +854,7 @@ mod tests { } #[test] - fn test_golden_frontmatter_yaml() { + fn test_frontmatter_yaml() { let engine = MarkdownEngine::new(); let fixtures = fixtures_dir(); @@ -1435,7 +870,7 @@ mod tests { } #[test] - fn test_golden_frontmatter_toml() { + fn test_frontmatter_toml() { let engine = MarkdownEngine::new(); let fixtures = fixtures_dir(); @@ -1623,10 +1058,272 @@ mod tests { #[test] fn test_html_escape_function() { - assert_eq!(html_escape("