Skip to main content

marknest_core/
lib.rs

1use std::collections::HashSet;
2use std::fs;
3use std::io::{Cursor, Read};
4use std::path::{Component, Path, PathBuf};
5
6use ammonia::{Builder as HtmlSanitizerBuilder, UrlRelative};
7use pulldown_cmark::{
8    CowStr, Event, HeadingLevel, Options as MarkdownOptions, Parser, Tag, TagEnd, html,
9};
10use serde::{Deserialize, Serialize};
11
12const MAX_ZIP_FILE_COUNT: usize = 4_096;
13const MAX_ZIP_UNCOMPRESSED_BYTES: u64 = 256 * 1024 * 1024;
14pub const RUNTIME_ASSET_MODE: &str = "bundled_local";
15pub const DEFAULT_MERMAID_TIMEOUT_MS: u32 = 5_000;
16pub const DEFAULT_MATH_TIMEOUT_MS: u32 = 3_000;
17pub const MERMAID_VERSION: &str = "11.11.0";
18pub const MATHJAX_VERSION: &str = "3.2.2";
19pub const RUNTIME_ASSET_BASE_PATH: &str = "./runtime-assets/";
20pub const MERMAID_SCRIPT_URL: &str = "./runtime-assets/mermaid/mermaid.min.js";
21pub const MATHJAX_SCRIPT_URL: &str = "./runtime-assets/mathjax/es5/tex-svg.js";
22
23const MERMAID_RUNTIME_ASSET_RELATIVE_PATH: &str = "mermaid/mermaid.min.js";
24const MATHJAX_RUNTIME_ASSET_RELATIVE_PATH: &str = "mathjax/es5/tex-svg.js";
25
26#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
27#[serde(rename_all = "snake_case")]
28pub enum ProjectSourceKind {
29    Workspace,
30    Zip,
31    SingleMarkdown,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
35#[serde(rename_all = "snake_case")]
36pub enum EntrySelectionReason {
37    Readme,
38    Index,
39    SingleMarkdownFile,
40    MultipleCandidates,
41    NoMarkdownFiles,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
45pub struct EntryCandidate {
46    pub path: String,
47}
48
49#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
50#[serde(rename_all = "snake_case")]
51pub enum AssetReferenceKind {
52    MarkdownImage,
53    RawHtmlImage,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
57#[serde(rename_all = "snake_case")]
58pub enum AssetStatus {
59    Resolved,
60    Missing,
61    External,
62    UnsupportedScheme,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
66pub struct AssetRef {
67    pub entry_path: String,
68    pub original_reference: String,
69    pub resolved_path: Option<String>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub fetch_url: Option<String>,
72    pub kind: AssetReferenceKind,
73    pub status: AssetStatus,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Default)]
77pub struct Diagnostic {
78    pub missing_assets: Vec<String>,
79    pub ignored_files: Vec<String>,
80    pub warnings: Vec<String>,
81    pub path_errors: Vec<String>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
85pub struct ProjectIndex {
86    pub source_kind: ProjectSourceKind,
87    pub selected_entry: Option<String>,
88    pub entry_selection_reason: EntrySelectionReason,
89    pub entry_candidates: Vec<EntryCandidate>,
90    pub assets: Vec<AssetRef>,
91    pub diagnostic: Diagnostic,
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
95#[serde(rename_all = "snake_case")]
96pub enum ThemePreset {
97    #[default]
98    Default,
99    Github,
100    Docs,
101    Plain,
102}
103
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
105#[serde(rename_all = "snake_case")]
106pub enum MermaidMode {
107    Off,
108    #[default]
109    Auto,
110    On,
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
114#[serde(rename_all = "snake_case")]
115pub enum MathMode {
116    Off,
117    #[default]
118    Auto,
119    On,
120}
121
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
123pub struct PdfMetadata {
124    pub title: Option<String>,
125    pub author: Option<String>,
126    pub subject: Option<String>,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130pub struct RenderOptions {
131    pub theme: ThemePreset,
132    pub metadata: PdfMetadata,
133    pub custom_css: Option<String>,
134    pub enable_toc: bool,
135    pub sanitize_html: bool,
136    pub mermaid_mode: MermaidMode,
137    pub math_mode: MathMode,
138    pub mermaid_timeout_ms: u32,
139    pub math_timeout_ms: u32,
140    pub runtime_assets_base_url: Option<String>,
141}
142
143impl Default for RenderOptions {
144    fn default() -> Self {
145        Self {
146            theme: ThemePreset::Default,
147            metadata: PdfMetadata::default(),
148            custom_css: None,
149            enable_toc: false,
150            sanitize_html: true,
151            mermaid_mode: MermaidMode::Off,
152            math_mode: MathMode::Off,
153            mermaid_timeout_ms: DEFAULT_MERMAID_TIMEOUT_MS,
154            math_timeout_ms: DEFAULT_MATH_TIMEOUT_MS,
155            runtime_assets_base_url: None,
156        }
157    }
158}
159
160#[derive(Debug, Clone, PartialEq, Eq)]
161pub struct RuntimeAssetScriptUrls {
162    pub mermaid_script_url: String,
163    pub mathjax_script_url: String,
164}
165
166pub fn runtime_asset_script_urls(options: &RenderOptions) -> RuntimeAssetScriptUrls {
167    let base_url = normalized_runtime_assets_base_url(
168        options
169            .runtime_assets_base_url
170            .as_deref()
171            .unwrap_or(RUNTIME_ASSET_BASE_PATH),
172    );
173
174    RuntimeAssetScriptUrls {
175        mermaid_script_url: format!("{base_url}{MERMAID_RUNTIME_ASSET_RELATIVE_PATH}"),
176        mathjax_script_url: format!("{base_url}{MATHJAX_RUNTIME_ASSET_RELATIVE_PATH}"),
177    }
178}
179
180fn normalized_runtime_assets_base_url(base_url: &str) -> String {
181    let trimmed = base_url.trim();
182    if trimmed.is_empty() {
183        return RUNTIME_ASSET_BASE_PATH.to_string();
184    }
185
186    if trimmed.ends_with('/') {
187        trimmed.to_string()
188    } else {
189        format!("{trimmed}/")
190    }
191}
192
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub enum AnalyzeError {
195    UnsafePath { path: String },
196    ZipArchive(String),
197    Io(String),
198    ZipLimitsExceeded(String),
199}
200
201impl std::fmt::Display for AnalyzeError {
202    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
203        match self {
204            Self::UnsafePath { path } => write!(f, "unsafe path detected: {path}"),
205            Self::ZipArchive(message) => write!(f, "zip archive error: {message}"),
206            Self::Io(message) => write!(f, "i/o error: {message}"),
207            Self::ZipLimitsExceeded(message) => write!(f, "zip limits exceeded: {message}"),
208        }
209    }
210}
211
212impl std::error::Error for AnalyzeError {}
213
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub struct RenderedHtmlDocument {
216    pub title: String,
217    pub html: String,
218}
219
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub enum RenderHtmlError {
222    EntryNotFound { entry_path: String },
223    InvalidEntryPath { entry_path: String },
224    Analyze(AnalyzeError),
225    Io(String),
226    InvalidUtf8 { entry_path: String },
227}
228
229impl std::fmt::Display for RenderHtmlError {
230    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231        match self {
232            Self::EntryNotFound { entry_path } => {
233                write!(f, "entry markdown file could not be found: {entry_path}")
234            }
235            Self::InvalidEntryPath { entry_path } => {
236                write!(f, "entry markdown file path is invalid: {entry_path}")
237            }
238            Self::Analyze(error) => write!(f, "{error}"),
239            Self::Io(message) => write!(f, "i/o error: {message}"),
240            Self::InvalidUtf8 { entry_path } => {
241                write!(f, "entry markdown file is not valid UTF-8: {entry_path}")
242            }
243        }
244    }
245}
246
247impl std::error::Error for RenderHtmlError {}
248
249pub fn render_workspace_entry(
250    root: &Path,
251    entry_path: &str,
252) -> Result<RenderedHtmlDocument, RenderHtmlError> {
253    render_workspace_entry_with_options(root, entry_path, &RenderOptions::default())
254}
255
256pub fn render_zip_entry(
257    bytes: &[u8],
258    entry_path: &str,
259) -> Result<RenderedHtmlDocument, RenderHtmlError> {
260    render_zip_entry_with_options(bytes, entry_path, &RenderOptions::default())
261}
262
263pub fn render_workspace_entry_with_options(
264    root: &Path,
265    entry_path: &str,
266    options: &RenderOptions,
267) -> Result<RenderedHtmlDocument, RenderHtmlError> {
268    let workspace_file_system = WorkspaceFileSystem::new(root).map_err(RenderHtmlError::Analyze)?;
269    render_entry_with_options(&workspace_file_system, entry_path, options)
270}
271
272pub fn render_zip_entry_with_options(
273    bytes: &[u8],
274    entry_path: &str,
275    options: &RenderOptions,
276) -> Result<RenderedHtmlDocument, RenderHtmlError> {
277    let zip_file_system = ZipMemoryFileSystem::new(bytes).map_err(RenderHtmlError::Analyze)?;
278    render_entry_with_options(&zip_file_system, entry_path, options)
279}
280
281pub fn render_zip_entry_with_options_strip_prefix(
282    bytes: &[u8],
283    entry_path: &str,
284    options: &RenderOptions,
285) -> Result<RenderedHtmlDocument, RenderHtmlError> {
286    let mut zip_file_system = ZipMemoryFileSystem::new(bytes).map_err(RenderHtmlError::Analyze)?;
287    zip_file_system.strip_common_prefix();
288    render_entry_with_options(&zip_file_system, entry_path, options)
289}
290
291pub fn render_markdown_entry(
292    bytes: &[u8],
293    filename: &str,
294) -> Result<RenderedHtmlDocument, RenderHtmlError> {
295    render_markdown_entry_with_options(bytes, filename, &RenderOptions::default())
296}
297
298pub fn render_markdown_entry_with_options(
299    bytes: &[u8],
300    filename: &str,
301    options: &RenderOptions,
302) -> Result<RenderedHtmlDocument, RenderHtmlError> {
303    let file_system = SingleMarkdownFileSystem::new(bytes, filename)?;
304    let entry_path: &str = &file_system.files[0].normalized_path;
305    render_entry_with_options(&file_system, entry_path, options)
306}
307
308#[derive(Debug, Clone, PartialEq, Eq)]
309struct RenderedHeading {
310    level: HeadingLevel,
311    id: String,
312    title: String,
313}
314
315#[derive(Debug, Clone, PartialEq, Eq)]
316struct RenderedMarkdownBody {
317    html: String,
318    headings: Vec<RenderedHeading>,
319}
320
321#[derive(Debug)]
322struct PendingHeading<'a> {
323    level: HeadingLevel,
324    id: Option<CowStr<'a>>,
325    classes: Vec<CowStr<'a>>,
326    attrs: Vec<(CowStr<'a>, Option<CowStr<'a>>)>,
327    events: Vec<Event<'a>>,
328    title: String,
329}
330
331pub fn analyze_workspace(root: &Path) -> Result<ProjectIndex, AnalyzeError> {
332    analyze_project(&WorkspaceFileSystem::new(root)?)
333}
334
335pub fn analyze_zip(bytes: &[u8]) -> Result<ProjectIndex, AnalyzeError> {
336    analyze_project(&ZipMemoryFileSystem::new(bytes)?)
337}
338
339/// Analyze a ZIP archive, stripping the common top-level directory prefix
340/// from all paths before analysis. Use this for GitHub-style archives where
341/// files are nested under a single `{repo}-{ref}/` directory.
342pub fn analyze_zip_strip_prefix(bytes: &[u8]) -> Result<ProjectIndex, AnalyzeError> {
343    let mut fs = ZipMemoryFileSystem::new(bytes)?;
344    fs.strip_common_prefix();
345    analyze_project(&fs)
346}
347
348fn remote_fetch_url(reference: &str) -> Option<String> {
349    if !is_http_reference(reference) {
350        return None;
351    }
352
353    Some(normalize_remote_fetch_url(reference))
354}
355
356fn normalize_remote_fetch_url(reference: &str) -> String {
357    normalize_github_repository_image_url(reference).unwrap_or_else(|| reference.trim().to_string())
358}
359
360fn normalize_github_repository_image_url(reference: &str) -> Option<String> {
361    let trimmed_reference: &str = reference.trim();
362    let (scheme, remainder) = if let Some(value) = trimmed_reference.strip_prefix("https://") {
363        ("https://", value)
364    } else if let Some(value) = trimmed_reference.strip_prefix("http://") {
365        ("http://", value)
366    } else {
367        return None;
368    };
369
370    let host_path_separator: usize = remainder.find('/')?;
371    let host: &str = &remainder[..host_path_separator];
372    if !host.eq_ignore_ascii_case("github.com") {
373        return None;
374    }
375
376    let path: &str = strip_reference_query_and_fragment(&remainder[host_path_separator..]);
377    let segments: Vec<&str> = path.trim_start_matches('/').split('/').collect();
378    if segments.len() < 5 {
379        return None;
380    }
381
382    if segments[2] == "blob" || segments[2] == "raw" {
383        return Some(format!(
384            "{scheme}raw.githubusercontent.com/{}/{}/{}",
385            segments[0],
386            segments[1],
387            segments[3..].join("/")
388        ));
389    }
390
391    None
392}
393
394trait IndexedFileSystem {
395    fn source_kind(&self) -> ProjectSourceKind;
396    fn files(&self) -> &[IndexedFile];
397
398    fn file_contents(&self, normalized_path: &str) -> Option<&[u8]> {
399        self.files()
400            .iter()
401            .find(|file| file.normalized_path == normalized_path)
402            .map(|file| file.contents.as_slice())
403    }
404}
405
406#[derive(Debug, Clone)]
407struct IndexedFile {
408    normalized_path: String,
409    contents: Vec<u8>,
410}
411
412struct WorkspaceFileSystem {
413    files: Vec<IndexedFile>,
414}
415
416impl WorkspaceFileSystem {
417    fn new(root: &Path) -> Result<Self, AnalyzeError> {
418        if !root.exists() {
419            return Err(AnalyzeError::Io(format!(
420                "workspace root does not exist: {}",
421                root.display()
422            )));
423        }
424
425        let canonical_root: PathBuf = root
426            .canonicalize()
427            .map_err(|error| AnalyzeError::Io(error.to_string()))?;
428        let mut files: Vec<IndexedFile> = Vec::new();
429        collect_workspace_files(&canonical_root, &canonical_root, &mut files)?;
430        files.sort_by(|left, right| left.normalized_path.cmp(&right.normalized_path));
431        Ok(Self { files })
432    }
433}
434
435impl IndexedFileSystem for WorkspaceFileSystem {
436    fn source_kind(&self) -> ProjectSourceKind {
437        ProjectSourceKind::Workspace
438    }
439
440    fn files(&self) -> &[IndexedFile] {
441        &self.files
442    }
443}
444
445struct ZipMemoryFileSystem {
446    files: Vec<IndexedFile>,
447}
448
449impl ZipMemoryFileSystem {
450    fn new(bytes: &[u8]) -> Result<Self, AnalyzeError> {
451        let reader: Cursor<&[u8]> = Cursor::new(bytes);
452        let mut archive = zip::ZipArchive::new(reader)
453            .map_err(|error| AnalyzeError::ZipArchive(error.to_string()))?;
454        let mut files: Vec<IndexedFile> = Vec::new();
455        let mut total_uncompressed_bytes: u64 = 0;
456
457        if archive.len() > MAX_ZIP_FILE_COUNT {
458            return Err(AnalyzeError::ZipLimitsExceeded(format!(
459                "archive contains {} files, limit is {}",
460                archive.len(),
461                MAX_ZIP_FILE_COUNT
462            )));
463        }
464
465        for index in 0..archive.len() {
466            let mut entry = archive
467                .by_index(index)
468                .map_err(|error| AnalyzeError::ZipArchive(error.to_string()))?;
469
470            if entry.is_dir() {
471                continue;
472            }
473
474            let raw_path: String = entry.name().to_string();
475            let normalized_path: String =
476                normalize_relative_string(&raw_path).map_err(|_| AnalyzeError::UnsafePath {
477                    path: raw_path.clone(),
478                })?;
479
480            total_uncompressed_bytes = total_uncompressed_bytes
481                .checked_add(entry.size())
482                .ok_or_else(|| {
483                    AnalyzeError::ZipLimitsExceeded(
484                        "archive uncompressed size overflowed the configured limit".to_string(),
485                    )
486                })?;
487
488            if total_uncompressed_bytes > MAX_ZIP_UNCOMPRESSED_BYTES {
489                return Err(AnalyzeError::ZipLimitsExceeded(format!(
490                    "archive expands to {} bytes, limit is {} bytes",
491                    total_uncompressed_bytes, MAX_ZIP_UNCOMPRESSED_BYTES
492                )));
493            }
494
495            let mut contents: Vec<u8> = Vec::new();
496            entry
497                .read_to_end(&mut contents)
498                .map_err(|error| AnalyzeError::ZipArchive(error.to_string()))?;
499
500            files.push(IndexedFile {
501                normalized_path,
502                contents,
503            });
504        }
505
506        files.sort_by(|left, right| left.normalized_path.cmp(&right.normalized_path));
507        Ok(Self { files })
508    }
509
510    /// Strip the common first path segment from all files if every file shares
511    /// the same top-level directory. Used for GitHub-style archives that nest
512    /// everything under `{repo}-{ref}/`.
513    fn strip_common_prefix(&mut self) {
514        if self.files.is_empty() {
515            return;
516        }
517
518        let common: String = match self.files[0].normalized_path.split('/').next() {
519            Some(segment) => segment.to_string(),
520            None => return,
521        };
522
523        let all_share_prefix = self.files.iter().all(|file| {
524            file.normalized_path.starts_with(&common)
525                && file.normalized_path.len() > common.len()
526                && file.normalized_path.as_bytes()[common.len()] == b'/'
527        });
528
529        if !all_share_prefix {
530            return;
531        }
532
533        let strip_len: usize = common.len() + 1;
534        for file in self.files.iter_mut() {
535            file.normalized_path = file.normalized_path[strip_len..].to_string();
536        }
537        self.files
538            .sort_by(|left, right| left.normalized_path.cmp(&right.normalized_path));
539    }
540}
541
542impl IndexedFileSystem for ZipMemoryFileSystem {
543    fn source_kind(&self) -> ProjectSourceKind {
544        ProjectSourceKind::Zip
545    }
546
547    fn files(&self) -> &[IndexedFile] {
548        &self.files
549    }
550}
551
552struct SingleMarkdownFileSystem {
553    files: Vec<IndexedFile>,
554}
555
556impl SingleMarkdownFileSystem {
557    fn new(contents: &[u8], filename: &str) -> Result<Self, RenderHtmlError> {
558        let normalized_path: String =
559            normalize_relative_string(filename).map_err(|_| RenderHtmlError::InvalidEntryPath {
560                entry_path: filename.to_string(),
561            })?;
562
563        Ok(Self {
564            files: vec![IndexedFile {
565                normalized_path,
566                contents: contents.to_vec(),
567            }],
568        })
569    }
570}
571
572impl IndexedFileSystem for SingleMarkdownFileSystem {
573    fn source_kind(&self) -> ProjectSourceKind {
574        ProjectSourceKind::SingleMarkdown
575    }
576
577    fn files(&self) -> &[IndexedFile] {
578        &self.files
579    }
580}
581
582fn analyze_project(file_system: &dyn IndexedFileSystem) -> Result<ProjectIndex, AnalyzeError> {
583    let mut diagnostic: Diagnostic = Diagnostic::default();
584    let mut markdown_files: Vec<&IndexedFile> = Vec::new();
585    let mut known_paths: Vec<&str> = Vec::new();
586
587    for indexed_file in file_system.files() {
588        known_paths.push(indexed_file.normalized_path.as_str());
589
590        if is_markdown_path(&indexed_file.normalized_path) {
591            markdown_files.push(indexed_file);
592        } else if !is_supported_image_path(&indexed_file.normalized_path) {
593            diagnostic
594                .ignored_files
595                .push(indexed_file.normalized_path.clone());
596        }
597    }
598
599    known_paths.sort_unstable();
600    diagnostic.ignored_files.sort_unstable();
601
602    let entry_candidates: Vec<EntryCandidate> = markdown_files
603        .iter()
604        .map(|file| EntryCandidate {
605            path: file.normalized_path.clone(),
606        })
607        .collect();
608
609    let (selected_entry, entry_selection_reason) = select_entry(&entry_candidates);
610    let assets: Vec<AssetRef> = collect_assets(&markdown_files, &known_paths, &mut diagnostic);
611
612    Ok(ProjectIndex {
613        source_kind: file_system.source_kind(),
614        selected_entry,
615        entry_selection_reason,
616        entry_candidates,
617        assets,
618        diagnostic,
619    })
620}
621
622fn collect_workspace_files(
623    root: &Path,
624    directory: &Path,
625    files: &mut Vec<IndexedFile>,
626) -> Result<(), AnalyzeError> {
627    let mut entries: Vec<fs::DirEntry> = fs::read_dir(directory)
628        .map_err(|error| AnalyzeError::Io(error.to_string()))?
629        .collect::<Result<Vec<_>, _>>()
630        .map_err(|error| AnalyzeError::Io(error.to_string()))?;
631
632    entries.sort_by_key(|entry| entry.file_name().to_string_lossy().to_string());
633
634    for entry in entries {
635        let file_type: fs::FileType = entry
636            .file_type()
637            .map_err(|error| AnalyzeError::Io(error.to_string()))?;
638
639        if file_type.is_dir() {
640            let name: String = entry.file_name().to_string_lossy().to_string();
641            if should_skip_directory(&name) {
642                continue;
643            }
644
645            collect_workspace_files(root, &entry.path(), files)?;
646            continue;
647        }
648
649        if !file_type.is_file() {
650            continue;
651        }
652
653        let path: PathBuf = entry.path();
654        let relative_path: &Path = path.strip_prefix(root).map_err(|error| {
655            AnalyzeError::Io(format!(
656                "failed to compute relative path for {}: {error}",
657                path.display()
658            ))
659        })?;
660        let normalized_path: String =
661            normalize_path(relative_path).map_err(|_| AnalyzeError::UnsafePath {
662                path: relative_path.display().to_string(),
663            })?;
664        let contents: Vec<u8> =
665            fs::read(&path).map_err(|error| AnalyzeError::Io(error.to_string()))?;
666
667        files.push(IndexedFile {
668            normalized_path,
669            contents,
670        });
671    }
672
673    Ok(())
674}
675
676fn should_skip_directory(name: &str) -> bool {
677    matches!(name, ".git" | "target" | "node_modules" | "__MACOSX")
678}
679
680fn select_entry(entry_candidates: &[EntryCandidate]) -> (Option<String>, EntrySelectionReason) {
681    if entry_candidates.is_empty() {
682        return (None, EntrySelectionReason::NoMarkdownFiles);
683    }
684
685    if let Some(root_readme) = entry_candidates
686        .iter()
687        .find(|candidate| candidate.path.eq_ignore_ascii_case("README.md"))
688    {
689        return (Some(root_readme.path.clone()), EntrySelectionReason::Readme);
690    }
691
692    let readme_candidates: Vec<&EntryCandidate> = entry_candidates
693        .iter()
694        .filter(|candidate| file_name(&candidate.path).eq_ignore_ascii_case("README.md"))
695        .collect();
696    if readme_candidates.len() == 1 {
697        return (
698            Some(readme_candidates[0].path.clone()),
699            EntrySelectionReason::Readme,
700        );
701    }
702    if readme_candidates.len() > 1 {
703        return (None, EntrySelectionReason::MultipleCandidates);
704    }
705
706    if let Some(root_index) = entry_candidates
707        .iter()
708        .find(|candidate| candidate.path.eq_ignore_ascii_case("index.md"))
709    {
710        return (Some(root_index.path.clone()), EntrySelectionReason::Index);
711    }
712
713    let index_candidates: Vec<&EntryCandidate> = entry_candidates
714        .iter()
715        .filter(|candidate| file_name(&candidate.path).eq_ignore_ascii_case("index.md"))
716        .collect();
717    if index_candidates.len() == 1 {
718        return (
719            Some(index_candidates[0].path.clone()),
720            EntrySelectionReason::Index,
721        );
722    }
723    if index_candidates.len() > 1 {
724        return (None, EntrySelectionReason::MultipleCandidates);
725    }
726
727    if entry_candidates.len() == 1 {
728        return (
729            Some(entry_candidates[0].path.clone()),
730            EntrySelectionReason::SingleMarkdownFile,
731        );
732    }
733
734    (None, EntrySelectionReason::MultipleCandidates)
735}
736
737fn collect_assets(
738    markdown_files: &[&IndexedFile],
739    known_paths: &[&str],
740    diagnostic: &mut Diagnostic,
741) -> Vec<AssetRef> {
742    let mut assets: Vec<AssetRef> = Vec::new();
743
744    for markdown_file in markdown_files {
745        let contents: String = String::from_utf8_lossy(&markdown_file.contents).into_owned();
746
747        for reference in extract_markdown_image_destinations(&contents) {
748            assets.push(resolve_asset_reference(
749                &markdown_file.normalized_path,
750                &reference,
751                AssetReferenceKind::MarkdownImage,
752                known_paths,
753                diagnostic,
754            ));
755        }
756
757        for reference in extract_raw_html_img_sources(&contents) {
758            assets.push(resolve_asset_reference(
759                &markdown_file.normalized_path,
760                &reference,
761                AssetReferenceKind::RawHtmlImage,
762                known_paths,
763                diagnostic,
764            ));
765        }
766    }
767
768    assets
769}
770
771fn resolve_asset_reference(
772    entry_path: &str,
773    original_reference: &str,
774    kind: AssetReferenceKind,
775    known_paths: &[&str],
776    diagnostic: &mut Diagnostic,
777) -> AssetRef {
778    let trimmed_reference: &str = original_reference.trim();
779
780    if has_windows_drive_prefix(trimmed_reference) {
781        diagnostic
782            .path_errors
783            .push(format!("{entry_path} -> {trimmed_reference}"));
784        return AssetRef {
785            entry_path: entry_path.to_string(),
786            original_reference: trimmed_reference.to_string(),
787            resolved_path: None,
788            fetch_url: None,
789            kind,
790            status: AssetStatus::UnsupportedScheme,
791        };
792    }
793
794    if is_external_reference(trimmed_reference) {
795        return AssetRef {
796            entry_path: entry_path.to_string(),
797            original_reference: trimmed_reference.to_string(),
798            resolved_path: None,
799            fetch_url: remote_fetch_url(trimmed_reference),
800            kind,
801            status: AssetStatus::External,
802        };
803    }
804
805    if has_uri_scheme(trimmed_reference) {
806        diagnostic.warnings.push(format!(
807            "unsupported asset scheme: {entry_path} -> {trimmed_reference}"
808        ));
809        return AssetRef {
810            entry_path: entry_path.to_string(),
811            original_reference: trimmed_reference.to_string(),
812            resolved_path: None,
813            fetch_url: None,
814            kind,
815            status: AssetStatus::UnsupportedScheme,
816        };
817    }
818
819    let local_reference: &str = strip_reference_query_and_fragment(trimmed_reference);
820    let normalized_path: String = match resolve_local_asset_path(entry_path, local_reference) {
821        Ok(path) => path,
822        Err(()) => {
823            diagnostic
824                .path_errors
825                .push(format!("{entry_path} -> {trimmed_reference}"));
826            return AssetRef {
827                entry_path: entry_path.to_string(),
828                original_reference: trimmed_reference.to_string(),
829                resolved_path: None,
830                fetch_url: None,
831                kind,
832                status: AssetStatus::UnsupportedScheme,
833            };
834        }
835    };
836
837    if known_paths.binary_search(&normalized_path.as_str()).is_ok() {
838        return AssetRef {
839            entry_path: entry_path.to_string(),
840            original_reference: trimmed_reference.to_string(),
841            resolved_path: Some(normalized_path),
842            fetch_url: None,
843            kind,
844            status: AssetStatus::Resolved,
845        };
846    }
847
848    diagnostic
849        .missing_assets
850        .push(format!("{entry_path} -> {trimmed_reference}"));
851
852    AssetRef {
853        entry_path: entry_path.to_string(),
854        original_reference: trimmed_reference.to_string(),
855        resolved_path: Some(normalized_path),
856        fetch_url: None,
857        kind,
858        status: AssetStatus::Missing,
859    }
860}
861
862fn resolve_local_asset_path(entry_path: &str, reference: &str) -> Result<String, ()> {
863    if reference.starts_with('/') || reference.starts_with('\\') {
864        let root_relative_reference: &str = reference.trim_start_matches(['/', '\\']);
865        return normalize_relative_string(root_relative_reference);
866    }
867
868    let combined_reference: String = join_with_entry_directory(entry_path, reference);
869    normalize_relative_string(&combined_reference)
870}
871
872fn normalize_path(path: &Path) -> Result<String, ()> {
873    let mut segments: Vec<String> = Vec::new();
874
875    for component in path.components() {
876        match component {
877            Component::Normal(value) => {
878                segments.push(value.to_string_lossy().replace('\\', "/"));
879            }
880            Component::CurDir => {}
881            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return Err(()),
882        }
883    }
884
885    if segments.is_empty() {
886        return Err(());
887    }
888
889    Ok(segments.join("/"))
890}
891
892fn normalize_relative_string(path: &str) -> Result<String, ()> {
893    let trimmed_path: &str = path.trim();
894    if trimmed_path.is_empty() {
895        return Err(());
896    }
897
898    if trimmed_path.starts_with('/') || trimmed_path.starts_with('\\') {
899        return Err(());
900    }
901
902    if has_windows_drive_prefix(trimmed_path) {
903        return Err(());
904    }
905
906    let normalized_input: String = trimmed_path.replace('\\', "/");
907    let mut segments: Vec<&str> = Vec::new();
908    for segment in normalized_input.split('/') {
909        match segment {
910            "" | "." => {}
911            ".." => {
912                if segments.pop().is_none() {
913                    return Err(());
914                }
915            }
916            _ => segments.push(segment),
917        }
918    }
919
920    if segments.is_empty() {
921        return Err(());
922    }
923
924    Ok(segments.join("/"))
925}
926
927fn strip_reference_query_and_fragment(reference: &str) -> &str {
928    let query_start: usize = reference.find('?').unwrap_or(reference.len());
929    let fragment_start: usize = reference.find('#').unwrap_or(reference.len());
930    let suffix_start: usize = query_start.min(fragment_start);
931    reference[..suffix_start].trim()
932}
933
934fn extract_markdown_image_destinations(markdown: &str) -> Vec<String> {
935    let mut destinations: Vec<String> = Vec::new();
936    let mut offset: usize = 0;
937
938    while let Some(marker_index) = markdown[offset..].find("![") {
939        let alt_start: usize = offset + marker_index + 2;
940
941        // Find the closing `]` of the alt text, tracking bracket depth so
942        // nested brackets (e.g. `[![inner]][ref]`) are handled correctly.
943        let Some(alt_close) = find_closing_bracket(&markdown[alt_start..]) else {
944            offset = alt_start;
945            continue;
946        };
947        let after_alt: usize = alt_start + alt_close + 1;
948
949        // Only inline-style images `![alt](url)` have `(` right after the `]`.
950        // Reference-style images like `![alt][ref]` or `![alt]` must be skipped.
951        if after_alt >= markdown.len() || markdown.as_bytes()[after_alt] != b'(' {
952            offset = after_alt;
953            continue;
954        }
955
956        let destination_start: usize = after_alt + 1;
957        let Some(destination_end) = find_closing_parenthesis(&markdown[destination_start..]) else {
958            break;
959        };
960
961        let raw_destination: &str =
962            &markdown[destination_start..destination_start + destination_end];
963        let cleaned_destination: String = clean_markdown_destination(raw_destination);
964        if !cleaned_destination.is_empty() {
965            destinations.push(cleaned_destination);
966        }
967
968        offset = destination_start + destination_end + 1;
969    }
970
971    destinations
972}
973
974/// Finds the position of the closing `]` that matches the bracket depth,
975/// accounting for nested brackets and backslash escapes.
976fn find_closing_bracket(input: &str) -> Option<usize> {
977    let mut depth: usize = 0;
978    let mut previous_was_escape: bool = false;
979
980    for (index, character) in input.char_indices() {
981        if previous_was_escape {
982            previous_was_escape = false;
983            continue;
984        }
985
986        if character == '\\' {
987            previous_was_escape = true;
988            continue;
989        }
990
991        match character {
992            '[' => depth += 1,
993            ']' => {
994                if depth == 0 {
995                    return Some(index);
996                }
997                depth -= 1;
998            }
999            _ => {}
1000        }
1001    }
1002
1003    None
1004}
1005
1006fn find_closing_parenthesis(input: &str) -> Option<usize> {
1007    let mut depth: usize = 0;
1008    let mut previous_was_escape: bool = false;
1009
1010    for (index, character) in input.char_indices() {
1011        if previous_was_escape {
1012            previous_was_escape = false;
1013            continue;
1014        }
1015
1016        if character == '\\' {
1017            previous_was_escape = true;
1018            continue;
1019        }
1020
1021        match character {
1022            '(' => depth += 1,
1023            ')' => {
1024                if depth == 0 {
1025                    return Some(index);
1026                }
1027                depth -= 1;
1028            }
1029            _ => {}
1030        }
1031    }
1032
1033    None
1034}
1035
1036fn clean_markdown_destination(raw_destination: &str) -> String {
1037    let trimmed: &str = raw_destination.trim();
1038    if trimmed.starts_with('<') && trimmed.ends_with('>') && trimmed.len() >= 2 {
1039        return trimmed[1..trimmed.len() - 1].trim().to_string();
1040    }
1041
1042    trimmed
1043        .split_ascii_whitespace()
1044        .next()
1045        .unwrap_or_default()
1046        .to_string()
1047}
1048
1049fn extract_raw_html_img_sources(markdown: &str) -> Vec<String> {
1050    let lower_markdown: String = markdown.to_ascii_lowercase();
1051    let mut sources: Vec<String> = Vec::new();
1052    let mut offset: usize = 0;
1053
1054    while let Some(tag_index) = lower_markdown[offset..].find("<img") {
1055        let tag_start: usize = offset + tag_index;
1056        let Some(tag_end_offset) = lower_markdown[tag_start..].find('>') else {
1057            break;
1058        };
1059        let tag_end: usize = tag_start + tag_end_offset + 1;
1060        let tag_text: &str = &markdown[tag_start..tag_end];
1061
1062        if let Some(source) = extract_src_attribute(tag_text) {
1063            sources.push(source);
1064        }
1065
1066        offset = tag_end;
1067    }
1068
1069    sources
1070}
1071
1072fn extract_src_attribute(tag: &str) -> Option<String> {
1073    let lower_tag: String = tag.to_ascii_lowercase();
1074    let mut offset: usize = 0;
1075
1076    while let Some(relative_index) = lower_tag[offset..].find("src") {
1077        let name_start: usize = offset + relative_index;
1078        let Some(previous) = tag[..name_start].chars().last() else {
1079            offset = name_start + 3;
1080            continue;
1081        };
1082
1083        if !previous.is_ascii_whitespace() && previous != '<' {
1084            offset = name_start + 3;
1085            continue;
1086        }
1087
1088        let mut cursor: usize = name_start + 3;
1089        cursor = skip_ascii_whitespace(tag, cursor);
1090
1091        if !tag[cursor..].starts_with('=') {
1092            offset = name_start + 3;
1093            continue;
1094        }
1095        cursor += 1;
1096
1097        cursor = skip_ascii_whitespace(tag, cursor);
1098
1099        let first_char: char = tag[cursor..].chars().next()?;
1100        if matches!(first_char, '"' | '\'') {
1101            let quote: char = first_char;
1102            cursor += quote.len_utf8();
1103            let end_offset: usize = tag[cursor..].find(quote)?;
1104            return Some(tag[cursor..cursor + end_offset].to_string());
1105        }
1106
1107        let end_offset: usize = tag[cursor..]
1108            .find(|character: char| character.is_ascii_whitespace() || character == '>')
1109            .unwrap_or(tag[cursor..].len());
1110        return Some(tag[cursor..cursor + end_offset].to_string());
1111    }
1112
1113    None
1114}
1115
1116fn skip_ascii_whitespace(input: &str, mut cursor: usize) -> usize {
1117    while let Some(character) = input[cursor..].chars().next() {
1118        if !character.is_ascii_whitespace() {
1119            break;
1120        }
1121        cursor += character.len_utf8();
1122    }
1123
1124    cursor
1125}
1126
1127fn join_with_entry_directory(entry_path: &str, reference: &str) -> String {
1128    let Some((directory, _)) = entry_path.rsplit_once('/') else {
1129        return reference.to_string();
1130    };
1131
1132    format!("{directory}/{reference}")
1133}
1134
1135fn file_name(path: &str) -> &str {
1136    path.rsplit('/').next().unwrap_or(path)
1137}
1138
1139fn is_markdown_path(path: &str) -> bool {
1140    let lowercase_path: String = path.to_ascii_lowercase();
1141    lowercase_path.ends_with(".md") || lowercase_path.ends_with(".markdown")
1142}
1143
1144fn is_supported_image_path(path: &str) -> bool {
1145    let lowercase_path: String = path.to_ascii_lowercase();
1146    [
1147        ".png", ".jpg", ".jpeg", ".gif", ".svg", ".webp", ".bmp", ".avif",
1148    ]
1149    .iter()
1150    .any(|extension| lowercase_path.ends_with(extension))
1151}
1152
1153fn is_http_reference(reference: &str) -> bool {
1154    let lowercase_reference: String = reference.to_ascii_lowercase();
1155    lowercase_reference.starts_with("http://") || lowercase_reference.starts_with("https://")
1156}
1157
1158fn is_external_reference(reference: &str) -> bool {
1159    let lowercase_reference: String = reference.to_ascii_lowercase();
1160    is_http_reference(reference) || lowercase_reference.starts_with("data:")
1161}
1162
1163fn has_uri_scheme(reference: &str) -> bool {
1164    let Some(colon_index) = reference.find(':') else {
1165        return false;
1166    };
1167
1168    if reference[..colon_index]
1169        .chars()
1170        .all(|character| character.is_ascii_alphanumeric() || matches!(character, '+' | '-' | '.'))
1171    {
1172        return true;
1173    }
1174
1175    false
1176}
1177
1178fn has_windows_drive_prefix(path: &str) -> bool {
1179    let bytes: &[u8] = path.as_bytes();
1180    bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'
1181}
1182
1183fn render_markdown_to_html(markdown: &str, render_options: &RenderOptions) -> RenderedMarkdownBody {
1184    let mut markdown_options: MarkdownOptions = MarkdownOptions::empty();
1185    markdown_options.insert(MarkdownOptions::ENABLE_STRIKETHROUGH);
1186    markdown_options.insert(MarkdownOptions::ENABLE_TABLES);
1187    markdown_options.insert(MarkdownOptions::ENABLE_TASKLISTS);
1188    markdown_options.insert(MarkdownOptions::ENABLE_FOOTNOTES);
1189    markdown_options.insert(MarkdownOptions::ENABLE_HEADING_ATTRIBUTES);
1190    if !matches!(render_options.math_mode, MathMode::Off) {
1191        markdown_options.insert(MarkdownOptions::ENABLE_MATH);
1192    }
1193
1194    let parser: Parser<'_> = Parser::new_ext(markdown, markdown_options);
1195    let mut events: Vec<Event<'_>> = Vec::new();
1196    let mut headings: Vec<RenderedHeading> = Vec::new();
1197    let mut pending_heading: Option<PendingHeading<'_>> = None;
1198    let mut used_heading_ids: HashSet<String> = HashSet::new();
1199    let mut in_code_block: bool = false;
1200
1201    for event in parser {
1202        match event {
1203            Event::Start(Tag::Heading {
1204                level,
1205                id,
1206                classes,
1207                attrs,
1208            }) => {
1209                pending_heading = Some(PendingHeading {
1210                    level,
1211                    id,
1212                    classes,
1213                    attrs,
1214                    events: Vec::new(),
1215                    title: String::new(),
1216                });
1217            }
1218            Event::End(TagEnd::Heading(_)) => {
1219                let Some(mut heading) = pending_heading.take() else {
1220                    continue;
1221                };
1222                let heading_title: String = normalize_heading_title(&heading.title, headings.len());
1223                let heading_id: String = unique_heading_id(
1224                    heading
1225                        .id
1226                        .as_deref()
1227                        .map(normalize_heading_id)
1228                        .unwrap_or_else(|| slugify_heading_text(&heading_title)),
1229                    &mut used_heading_ids,
1230                );
1231                headings.push(RenderedHeading {
1232                    level: heading.level,
1233                    id: heading_id.clone(),
1234                    title: heading_title,
1235                });
1236                events.push(Event::Start(Tag::Heading {
1237                    level: heading.level,
1238                    id: Some(heading_id.into()),
1239                    classes: std::mem::take(&mut heading.classes),
1240                    attrs: std::mem::take(&mut heading.attrs),
1241                }));
1242                events.extend(std::mem::take(&mut heading.events));
1243                events.push(Event::End(TagEnd::Heading(heading.level)));
1244            }
1245            Event::Start(Tag::CodeBlock(kind)) => {
1246                in_code_block = true;
1247                push_heading_or_document_event(
1248                    &mut pending_heading,
1249                    &mut events,
1250                    Event::Start(Tag::CodeBlock(kind)),
1251                );
1252            }
1253            Event::End(TagEnd::CodeBlock) => {
1254                in_code_block = false;
1255                push_heading_or_document_event(
1256                    &mut pending_heading,
1257                    &mut events,
1258                    Event::End(TagEnd::CodeBlock),
1259                );
1260            }
1261            Event::Text(text) if !in_code_block => {
1262                let replaced_text: String = replace_github_emoji_shortcodes(text.as_ref());
1263                if let Some(heading) = pending_heading.as_mut() {
1264                    heading.title.push_str(&replaced_text);
1265                    heading.events.push(Event::Text(replaced_text.into()));
1266                } else {
1267                    events.push(Event::Text(replaced_text.into()));
1268                }
1269            }
1270            Event::Text(text) => {
1271                if let Some(heading) = pending_heading.as_mut() {
1272                    heading.title.push_str(text.as_ref());
1273                    heading.events.push(Event::Text(text));
1274                } else {
1275                    events.push(Event::Text(text));
1276                }
1277            }
1278            Event::Code(text) => {
1279                if let Some(heading) = pending_heading.as_mut() {
1280                    heading.title.push_str(text.as_ref());
1281                    heading.events.push(Event::Code(text));
1282                } else {
1283                    events.push(Event::Code(text));
1284                }
1285            }
1286            Event::SoftBreak => {
1287                if let Some(heading) = pending_heading.as_mut() {
1288                    heading.title.push(' ');
1289                    heading.events.push(Event::SoftBreak);
1290                } else {
1291                    events.push(Event::SoftBreak);
1292                }
1293            }
1294            Event::HardBreak => {
1295                if let Some(heading) = pending_heading.as_mut() {
1296                    heading.title.push(' ');
1297                    heading.events.push(Event::HardBreak);
1298                } else {
1299                    events.push(Event::HardBreak);
1300                }
1301            }
1302            other_event => {
1303                push_heading_or_document_event(&mut pending_heading, &mut events, other_event);
1304            }
1305        }
1306    }
1307
1308    let mut html_output: String = String::new();
1309    html::push_html(&mut html_output, events.into_iter());
1310    if render_options.enable_toc && headings.len() > 1 {
1311        html_output = format!("{}{}", build_toc_html(&headings), html_output);
1312    }
1313
1314    RenderedMarkdownBody {
1315        html: html_output,
1316        headings,
1317    }
1318}
1319
1320fn replace_github_emoji_shortcodes(mut text: &str) -> String {
1321    let mut replaced: String = String::with_capacity(text.len());
1322
1323    while let Some((token_start, shortcode_start, shortcode_end, token_end)) = text
1324        .find(':')
1325        .map(|index| (index, index + 1))
1326        .and_then(|(token_start, shortcode_start)| {
1327            text[shortcode_start..].find(':').map(|closing_offset| {
1328                (
1329                    token_start,
1330                    shortcode_start,
1331                    shortcode_start + closing_offset,
1332                    shortcode_start + closing_offset + 1,
1333                )
1334            })
1335        })
1336    {
1337        if let Some(emoji) = emojis::get_by_shortcode(&text[shortcode_start..shortcode_end]) {
1338            replaced.push_str(&text[..token_start]);
1339            replaced.push_str(emoji.as_str());
1340            text = &text[token_end..];
1341            continue;
1342        }
1343
1344        replaced.push_str(&text[..shortcode_end]);
1345        text = &text[shortcode_end..];
1346    }
1347
1348    replaced.push_str(text);
1349    replaced
1350}
1351
1352fn push_heading_or_document_event<'a>(
1353    pending_heading: &mut Option<PendingHeading<'a>>,
1354    events: &mut Vec<Event<'a>>,
1355    event: Event<'a>,
1356) {
1357    if let Some(heading) = pending_heading.as_mut() {
1358        heading.events.push(event);
1359    } else {
1360        events.push(event);
1361    }
1362}
1363
1364fn normalize_heading_title(title: &str, heading_index: usize) -> String {
1365    let trimmed = title.trim();
1366    if trimmed.is_empty() {
1367        format!("Section {}", heading_index + 1)
1368    } else {
1369        trimmed.to_string()
1370    }
1371}
1372
1373fn normalize_heading_id(id: &str) -> String {
1374    let trimmed = id.trim().trim_start_matches('#');
1375    if trimmed.is_empty() {
1376        "section".to_string()
1377    } else {
1378        trimmed.to_string()
1379    }
1380}
1381
1382fn slugify_heading_text(text: &str) -> String {
1383    let mut slug = String::new();
1384    let mut previous_was_separator = false;
1385
1386    for character in text.chars().flat_map(char::to_lowercase) {
1387        if character.is_alphanumeric() {
1388            slug.push(character);
1389            previous_was_separator = false;
1390        } else if (character.is_whitespace() || matches!(character, '-' | '_'))
1391            && !previous_was_separator
1392        {
1393            slug.push('-');
1394            previous_was_separator = true;
1395        }
1396    }
1397
1398    let trimmed = slug.trim_matches('-');
1399    if trimmed.is_empty() {
1400        "section".to_string()
1401    } else {
1402        trimmed.to_string()
1403    }
1404}
1405
1406fn unique_heading_id(base_id: String, used_heading_ids: &mut HashSet<String>) -> String {
1407    if used_heading_ids.insert(base_id.clone()) {
1408        return base_id;
1409    }
1410
1411    let mut index: usize = 2;
1412    loop {
1413        let candidate = format!("{base_id}-{index}");
1414        if used_heading_ids.insert(candidate.clone()) {
1415            return candidate;
1416        }
1417        index += 1;
1418    }
1419}
1420
1421fn build_toc_html(headings: &[RenderedHeading]) -> String {
1422    let mut toc_html = String::from(
1423        "<nav class=\"marknest-toc\" aria-label=\"Table of contents\"><p class=\"marknest-toc-title\">Contents</p><ol class=\"marknest-toc-list\">",
1424    );
1425
1426    for heading in headings {
1427        toc_html.push_str("<li class=\"marknest-toc-level-");
1428        toc_html.push_str(&heading_level_number(heading.level).to_string());
1429        toc_html.push_str("\"><a href=\"#");
1430        toc_html.push_str(&escape_html_attribute(&heading.id));
1431        toc_html.push_str("\">");
1432        toc_html.push_str(&escape_html_text(&heading.title));
1433        toc_html.push_str("</a></li>");
1434    }
1435
1436    toc_html.push_str("</ol></nav>");
1437    toc_html
1438}
1439
1440fn heading_level_number(level: HeadingLevel) -> u8 {
1441    match level {
1442        HeadingLevel::H1 => 1,
1443        HeadingLevel::H2 => 2,
1444        HeadingLevel::H3 => 3,
1445        HeadingLevel::H4 => 4,
1446        HeadingLevel::H5 => 5,
1447        HeadingLevel::H6 => 6,
1448    }
1449}
1450
1451fn render_entry_with_options(
1452    file_system: &dyn IndexedFileSystem,
1453    entry_path: &str,
1454    options: &RenderOptions,
1455) -> Result<RenderedHtmlDocument, RenderHtmlError> {
1456    let normalized_entry_path: String =
1457        normalize_relative_string(entry_path).map_err(|_| RenderHtmlError::InvalidEntryPath {
1458            entry_path: entry_path.to_string(),
1459        })?;
1460    let project_index = analyze_project(file_system).map_err(RenderHtmlError::Analyze)?;
1461
1462    if !project_index
1463        .entry_candidates
1464        .iter()
1465        .any(|candidate| candidate.path == normalized_entry_path)
1466    {
1467        return Err(RenderHtmlError::EntryNotFound {
1468            entry_path: normalized_entry_path,
1469        });
1470    }
1471
1472    let entry_bytes: Vec<u8> = file_system
1473        .file_contents(&normalized_entry_path)
1474        .ok_or_else(|| RenderHtmlError::EntryNotFound {
1475            entry_path: normalized_entry_path.clone(),
1476        })?
1477        .to_vec();
1478    let markdown: String =
1479        String::from_utf8(entry_bytes).map_err(|_| RenderHtmlError::InvalidUtf8 {
1480            entry_path: normalized_entry_path.clone(),
1481        })?;
1482
1483    let rendered_markdown: RenderedMarkdownBody = render_markdown_to_html(&markdown, options);
1484    let body_html: String = if options.sanitize_html {
1485        sanitize_html_fragment(&rendered_markdown.html)
1486    } else {
1487        rendered_markdown.html
1488    };
1489    let body_html: String = expand_collapsed_details(&body_html);
1490    let body_html: String = inline_entry_assets(
1491        file_system,
1492        &body_html,
1493        &project_index.assets,
1494        &normalized_entry_path,
1495    )
1496    .map_err(RenderHtmlError::Io)?;
1497    let title: String = options
1498        .metadata
1499        .title
1500        .clone()
1501        .unwrap_or_else(|| title_from_entry_path(&normalized_entry_path));
1502
1503    Ok(RenderedHtmlDocument {
1504        title: title.clone(),
1505        html: build_html_document(&title, &body_html, options),
1506    })
1507}
1508
1509fn inline_entry_assets(
1510    file_system: &dyn IndexedFileSystem,
1511    html_document: &str,
1512    assets: &[AssetRef],
1513    entry_path: &str,
1514) -> Result<String, String> {
1515    let replacements: Vec<(String, String)> = assets
1516        .iter()
1517        .filter(|asset| asset.entry_path == entry_path && asset.status == AssetStatus::Resolved)
1518        .filter_map(|asset| {
1519            asset.resolved_path.as_ref().map(|resolved_path| {
1520                let bytes: Vec<u8> = file_system
1521                    .file_contents(resolved_path)
1522                    .ok_or_else(|| format!("Failed to read asset {resolved_path}: not found"))?
1523                    .to_vec();
1524                let mime_type: &'static str = infer_mime_type(resolved_path);
1525                Ok::<(String, String), String>((
1526                    asset.original_reference.clone(),
1527                    format!("data:{mime_type};base64,{}", encode_base64(&bytes)),
1528                ))
1529            })
1530        })
1531        .collect::<Result<Vec<_>, _>>()?;
1532
1533    Ok(rewrite_html_img_sources(html_document, &replacements))
1534}
1535
1536pub fn rewrite_html_img_sources(html_document: &str, replacements: &[(String, String)]) -> String {
1537    let lower_html_document: String = html_document.to_ascii_lowercase();
1538    let mut rewritten_html: String = String::new();
1539    let mut cursor: usize = 0;
1540
1541    while let Some(relative_tag_index) = lower_html_document[cursor..].find("<img") {
1542        let tag_start: usize = cursor + relative_tag_index;
1543        let Some(relative_tag_end) = lower_html_document[tag_start..].find('>') else {
1544            break;
1545        };
1546        let tag_end: usize = tag_start + relative_tag_end + 1;
1547
1548        rewritten_html.push_str(&html_document[cursor..tag_start]);
1549        rewritten_html.push_str(&rewrite_img_tag(
1550            &html_document[tag_start..tag_end],
1551            replacements,
1552        ));
1553        cursor = tag_end;
1554    }
1555
1556    rewritten_html.push_str(&html_document[cursor..]);
1557    rewritten_html
1558}
1559
1560fn expand_collapsed_details(html_document: &str) -> String {
1561    let lower_html_document: String = html_document.to_ascii_lowercase();
1562    let mut rewritten_html: String = String::new();
1563    let mut cursor: usize = 0;
1564
1565    while let Some(relative_tag_index) = lower_html_document[cursor..].find("<details") {
1566        let tag_start: usize = cursor + relative_tag_index;
1567        let Some(relative_tag_end) = lower_html_document[tag_start..].find('>') else {
1568            break;
1569        };
1570        let tag_end: usize = tag_start + relative_tag_end + 1;
1571
1572        rewritten_html.push_str(&html_document[cursor..tag_start]);
1573        rewritten_html.push_str(&expand_details_tag(&html_document[tag_start..tag_end]));
1574        cursor = tag_end;
1575    }
1576
1577    rewritten_html.push_str(&html_document[cursor..]);
1578    rewritten_html
1579}
1580
1581fn expand_details_tag(tag: &str) -> String {
1582    if tag_has_boolean_attribute(tag, "details", "open") {
1583        return tag.to_string();
1584    }
1585
1586    let Some(tag_end) = tag.rfind('>') else {
1587        return tag.to_string();
1588    };
1589    let insertion_prefix: &str = if tag[..tag_end]
1590        .chars()
1591        .last()
1592        .is_some_and(|character| character.is_ascii_whitespace())
1593    {
1594        ""
1595    } else {
1596        " "
1597    };
1598
1599    let mut expanded_tag: String = String::new();
1600    expanded_tag.push_str(&tag[..tag_end]);
1601    expanded_tag.push_str(insertion_prefix);
1602    expanded_tag.push_str("open");
1603    expanded_tag.push_str(&tag[tag_end..]);
1604    expanded_tag
1605}
1606
1607fn rewrite_img_tag(tag: &str, replacements: &[(String, String)]) -> String {
1608    let Some(src_span) = find_src_attribute_span(tag) else {
1609        return tag.to_string();
1610    };
1611
1612    let original_reference: &str = &tag[src_span.value_start..src_span.value_end];
1613    let Some((_, replacement)) = replacements
1614        .iter()
1615        .find(|(candidate, _)| candidate == original_reference)
1616    else {
1617        return tag.to_string();
1618    };
1619
1620    if src_span.is_quoted {
1621        let mut rewritten_tag: String = String::new();
1622        rewritten_tag.push_str(&tag[..src_span.value_start]);
1623        rewritten_tag.push_str(&escape_html_attribute(replacement));
1624        rewritten_tag.push_str(&tag[src_span.value_end..]);
1625        return rewritten_tag;
1626    }
1627
1628    let mut rewritten_tag: String = String::new();
1629    rewritten_tag.push_str(&tag[..src_span.value_start]);
1630    rewritten_tag.push('"');
1631    rewritten_tag.push_str(&escape_html_attribute(replacement));
1632    rewritten_tag.push('"');
1633    rewritten_tag.push_str(&tag[src_span.value_end..]);
1634    rewritten_tag
1635}
1636
1637fn find_src_attribute_span(tag: &str) -> Option<SrcAttributeSpan> {
1638    let lower_tag: String = tag.to_ascii_lowercase();
1639    let mut offset: usize = 0;
1640
1641    while let Some(relative_index) = lower_tag[offset..].find("src") {
1642        let name_start: usize = offset + relative_index;
1643        let previous_is_boundary: bool = match tag[..name_start].chars().last() {
1644            Some(character) => character.is_ascii_whitespace() || character == '<',
1645            None => false,
1646        };
1647
1648        if !previous_is_boundary {
1649            offset = name_start + 3;
1650            continue;
1651        }
1652
1653        let mut cursor: usize = name_start + 3;
1654        cursor = skip_ascii_whitespace(tag, cursor);
1655
1656        if !tag[cursor..].starts_with('=') {
1657            offset = name_start + 3;
1658            continue;
1659        }
1660        cursor += 1;
1661        cursor = skip_ascii_whitespace(tag, cursor);
1662
1663        let first_character: char = tag[cursor..].chars().next()?;
1664        if matches!(first_character, '"' | '\'') {
1665            let quote_character: char = first_character;
1666            let value_start: usize = cursor + quote_character.len_utf8();
1667            let value_end_offset: usize = tag[value_start..].find(quote_character)?;
1668            return Some(SrcAttributeSpan {
1669                value_start,
1670                value_end: value_start + value_end_offset,
1671                is_quoted: true,
1672            });
1673        }
1674
1675        let value_end_offset: usize = tag[cursor..]
1676            .find(|character: char| character.is_ascii_whitespace() || character == '>')
1677            .unwrap_or(tag[cursor..].len());
1678        return Some(SrcAttributeSpan {
1679            value_start: cursor,
1680            value_end: cursor + value_end_offset,
1681            is_quoted: false,
1682        });
1683    }
1684
1685    None
1686}
1687
1688fn tag_has_boolean_attribute(tag: &str, expected_tag_name: &str, attribute_name: &str) -> bool {
1689    let lower_tag: String = tag.to_ascii_lowercase();
1690    if !lower_tag.starts_with(&format!("<{expected_tag_name}")) {
1691        return false;
1692    }
1693
1694    let mut cursor: usize = expected_tag_name.len() + 1;
1695    while cursor < tag.len() {
1696        cursor = skip_ascii_whitespace(tag, cursor);
1697        if cursor >= tag.len() {
1698            break;
1699        }
1700
1701        let Some(character) = tag[cursor..].chars().next() else {
1702            break;
1703        };
1704        if matches!(character, '>' | '/') {
1705            break;
1706        }
1707
1708        let attribute_start: usize = cursor;
1709        while let Some(attribute_character) = tag[cursor..].chars().next() {
1710            if attribute_character.is_ascii_whitespace()
1711                || matches!(attribute_character, '=' | '>' | '/')
1712            {
1713                break;
1714            }
1715            cursor += attribute_character.len_utf8();
1716        }
1717
1718        if tag[attribute_start..cursor].eq_ignore_ascii_case(attribute_name) {
1719            return true;
1720        }
1721
1722        cursor = skip_ascii_whitespace(tag, cursor);
1723        if !tag[cursor..].starts_with('=') {
1724            continue;
1725        }
1726        cursor += 1;
1727        cursor = skip_ascii_whitespace(tag, cursor);
1728
1729        let Some(value_start_character) = tag[cursor..].chars().next() else {
1730            break;
1731        };
1732        if matches!(value_start_character, '"' | '\'') {
1733            let quote_character: char = value_start_character;
1734            cursor += quote_character.len_utf8();
1735            let Some(value_end_offset) = tag[cursor..].find(quote_character) else {
1736                break;
1737            };
1738            cursor += value_end_offset + quote_character.len_utf8();
1739            continue;
1740        }
1741
1742        while let Some(value_character) = tag[cursor..].chars().next() {
1743            if value_character.is_ascii_whitespace() || matches!(value_character, '>' | '/') {
1744                break;
1745            }
1746            cursor += value_character.len_utf8();
1747        }
1748    }
1749
1750    false
1751}
1752
1753fn build_html_document(title: &str, body_html: &str, options: &RenderOptions) -> String {
1754    let metadata_tags: String = build_metadata_tags(&options.metadata);
1755    let runtime_script: String = build_runtime_script(options);
1756    let custom_css: &str = options.custom_css.as_deref().unwrap_or("");
1757    format!(
1758        "<!doctype html><html><head><meta charset=\"utf-8\"><title>{}</title>{}<style>{}{}{}{}{}</style></head><body class=\"{}\">{}{}</body></html>",
1759        escape_html_text(title),
1760        metadata_tags,
1761        base_stylesheet(),
1762        theme_stylesheet(options.theme),
1763        custom_css,
1764        runtime_stylesheet(),
1765        if runtime_script.is_empty() {
1766            ""
1767        } else {
1768            ".marknest-mermaid svg, .math-rendered svg { max-width: 100%; height: auto; }"
1769        },
1770        theme_class_name(options.theme),
1771        body_html,
1772        runtime_script
1773    )
1774}
1775
1776fn sanitize_html_fragment(html_fragment: &str) -> String {
1777    let mut sanitizer = HtmlSanitizerBuilder::default();
1778    sanitizer.url_relative(UrlRelative::PassThrough);
1779    sanitizer.add_generic_attributes(["align", "aria-label", "class", "id", "title"]);
1780    sanitizer.add_tags(["details", "figure", "figcaption", "input", "nav", "summary"]);
1781    sanitizer.add_tag_attributes("img", ["width", "height", "loading"]);
1782    sanitizer.add_tag_attributes("input", ["type", "checked", "disabled"]);
1783    sanitizer.add_tag_attributes("details", ["open"]);
1784    sanitizer.clean(html_fragment).to_string()
1785}
1786
1787fn base_stylesheet() -> &'static str {
1788    "body { background: #ffffff; color: #111827; font-family: \"Segoe UI\", Arial, sans-serif; font-size: 12pt; line-height: 1.6; margin: 0; } h1, h2, h3, h4, h5, h6 { line-height: 1.25; margin: 1.2em 0 0.5em; } p, ul, ol, pre, table, blockquote, figure, details { margin: 0 0 1em; } details > summary { cursor: default; } details:not([open]) > :not(summary) { display: block; } img { max-width: 100%; vertical-align: middle; } p > img:only-child, p > a:only-child > img, body > img, body > a > img { display: block; margin: 0 0 1em; } pre { background: #f3f4f6; border-radius: 8px; padding: 12px; white-space: pre-wrap; overflow-wrap: anywhere; word-break: break-word; } pre code { white-space: inherit; overflow-wrap: inherit; word-break: inherit; } code { font-family: Consolas, \"Cascadia Code\", monospace; } table { border-collapse: collapse; width: 100%; } th, td { border: 1px solid #d1d5db; padding: 6px 10px; text-align: left; } blockquote { border-left: 4px solid #d1d5db; color: #4b5563; padding-left: 12px; } .marknest-toc { border: 1px solid #d1d5db; border-radius: 10px; background: #f8fafc; padding: 16px 18px; margin: 0 0 1.5em; } .marknest-toc-title { font-weight: 700; letter-spacing: 0.01em; margin: 0 0 0.75em; } .marknest-toc-list { margin: 0; padding-left: 1.25em; } .marknest-toc-list li { margin: 0.2em 0; } .marknest-toc-level-2 { margin-left: 1rem; } .marknest-toc-level-3 { margin-left: 2rem; } .marknest-toc-level-4 { margin-left: 3rem; } .marknest-toc-level-5 { margin-left: 4rem; } .marknest-toc-level-6 { margin-left: 5rem; } @media print { h1 { break-before: page; page-break-before: always; } h1:first-of-type { break-before: auto; page-break-before: auto; } pre, table, blockquote, figure, img, tr, .marknest-toc { break-inside: avoid; page-break-inside: avoid; } thead { display: table-header-group; } }"
1789}
1790
1791fn theme_stylesheet(theme: ThemePreset) -> &'static str {
1792    match theme {
1793        ThemePreset::Default => "",
1794        ThemePreset::Github => {
1795            ".theme-github { color: #1f2328; font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Helvetica, Arial, sans-serif; } .theme-github pre { background: #f6f8fa; border: 1px solid #d0d7de; } .theme-github blockquote { border-left-color: #d0d7de; color: #57606a; } .theme-github table th, .theme-github table td { border-color: #d0d7de; }"
1796        }
1797        ThemePreset::Docs => {
1798            ".theme-docs { color: #102a43; font-family: Georgia, \"Times New Roman\", serif; } .theme-docs h1, .theme-docs h2, .theme-docs h3 { color: #0b7285; } .theme-docs pre { background: #f8fafc; border: 1px solid #cbd5e1; } .theme-docs blockquote { border-left-color: #0b7285; color: #334e68; }"
1799        }
1800        ThemePreset::Plain => {
1801            ".theme-plain { color: #111827; font-family: \"Segoe UI\", Arial, sans-serif; } .theme-plain pre, .theme-plain blockquote { background: transparent; border-radius: 0; border-left-color: #9ca3af; } .theme-plain table th, .theme-plain table td { border-color: #9ca3af; }"
1802        }
1803    }
1804}
1805
1806fn runtime_stylesheet() -> &'static str {
1807    ".math.math-inline { font-style: italic; white-space: nowrap; } .math.math-display { display: block; margin: 1.2em 0; text-align: center; } .marknest-mermaid { display: block; margin: 1.2em 0; }"
1808}
1809
1810fn theme_class_name(theme: ThemePreset) -> &'static str {
1811    match theme {
1812        ThemePreset::Default => "theme-default",
1813        ThemePreset::Github => "theme-github",
1814        ThemePreset::Docs => "theme-docs",
1815        ThemePreset::Plain => "theme-plain",
1816    }
1817}
1818
1819fn build_metadata_tags(metadata: &PdfMetadata) -> String {
1820    let mut tags: String = String::new();
1821
1822    if let Some(author) = &metadata.author {
1823        tags.push_str("<meta name=\"author\" content=\"");
1824        tags.push_str(&escape_html_attribute(author));
1825        tags.push_str("\">");
1826    }
1827
1828    if let Some(subject) = &metadata.subject {
1829        tags.push_str("<meta name=\"subject\" content=\"");
1830        tags.push_str(&escape_html_attribute(subject));
1831        tags.push_str("\">");
1832    }
1833
1834    tags
1835}
1836
1837fn build_runtime_script(options: &RenderOptions) -> String {
1838    if matches!(options.mermaid_mode, MermaidMode::Off)
1839        && matches!(options.math_mode, MathMode::Off)
1840    {
1841        return String::new();
1842    }
1843
1844    let runtime_urls = runtime_asset_script_urls(options);
1845
1846    format!(
1847        r#"<script>(function () {{
1848const config = {{"mermaidMode":"{}","mathMode":"{}","mermaidTheme":"{}","mermaidTimeoutMs":{},"mathTimeoutMs":{},"mermaidScript":"{}","mathScript":"{}"}};
1849const status = {{ready:false,warnings:[],errors:[]}};
1850window.__MARKNEST_RENDER_CONFIG__ = config;
1851window.__MARKNEST_RENDER_STATUS__ = status;
1852const addMessage = (kind, message) => {{
1853  if (kind === "error") {{
1854    status.errors.push(message);
1855  }} else {{
1856    status.warnings.push(message);
1857  }}
1858}};
1859const handleFailure = (mode, message) => addMessage(mode === "on" ? "error" : "warning", message);
1860const withTimeout = async (promiseFactory, timeoutMs, message) => {{
1861  const normalizedTimeoutMs = Math.max(1, Number(timeoutMs) || 0);
1862  let timerId = null;
1863  try {{
1864    return await Promise.race([
1865      promiseFactory(),
1866      new Promise((_, reject) => {{
1867        timerId = window.setTimeout(() => reject(new Error(message)), normalizedTimeoutMs);
1868      }}),
1869    ]);
1870  }} finally {{
1871    if (timerId !== null) {{
1872      window.clearTimeout(timerId);
1873    }}
1874  }}
1875}};
1876const loadScript = (src) => new Promise((resolve, reject) => {{
1877  const existing = document.querySelector(`script[data-marknest-src="${{src}}"]`);
1878  if (existing) {{
1879    if (existing.dataset.loaded === "true") {{
1880      resolve();
1881      return;
1882    }}
1883    existing.addEventListener("load", () => resolve(), {{ once: true }});
1884    existing.addEventListener("error", () => reject(new Error(`Failed to load ${{src}}.`)), {{ once: true }});
1885    return;
1886  }}
1887  const script = document.createElement("script");
1888  script.src = src;
1889  script.async = true;
1890  script.dataset.marknestSrc = src;
1891  script.addEventListener("load", () => {{
1892    script.dataset.loaded = "true";
1893    resolve();
1894  }}, {{ once: true }});
1895  script.addEventListener("error", () => reject(new Error(`Failed to load ${{src}}.`)), {{ once: true }});
1896  document.head.appendChild(script);
1897}});
1898const renderMermaid = async () => {{
1899  if (config.mermaidMode === "off") {{
1900    return;
1901  }}
1902  const blocks = Array.from(document.querySelectorAll("pre > code.language-mermaid"));
1903  if (blocks.length === 0) {{
1904    return;
1905  }}
1906  try {{
1907    await loadScript(config.mermaidScript);
1908    if (!window.mermaid) {{
1909      throw new Error("Mermaid did not initialize.");
1910    }}
1911    window.mermaid.initialize({{ startOnLoad: false, securityLevel: "strict", theme: config.mermaidTheme }});
1912    for (let index = 0; index < blocks.length; index += 1) {{
1913      const code = blocks[index];
1914      const source = code.textContent ? code.textContent.trim() : "";
1915      if (!source) {{
1916        handleFailure(config.mermaidMode, `Mermaid rendering failed: diagram ${{index + 1}} is empty.`);
1917        continue;
1918      }}
1919      try {{
1920        const rendered = await withTimeout(
1921          () => window.mermaid.render(`marknest-mermaid-${{index}}`, source),
1922          config.mermaidTimeoutMs,
1923          `Mermaid rendering timed out: diagram ${{index + 1}}.`,
1924        );
1925        const wrapper = document.createElement("figure");
1926        wrapper.className = "marknest-mermaid";
1927        wrapper.innerHTML = rendered.svg;
1928        const pre = code.parentElement;
1929        if (pre) {{
1930          pre.replaceWith(wrapper);
1931        }}
1932      }} catch (error) {{
1933        handleFailure(
1934          config.mermaidMode,
1935          error instanceof Error && error.message
1936            ? error.message
1937            : `Mermaid rendering failed: diagram ${{index + 1}}.`,
1938        );
1939      }}
1940    }}
1941  }} catch (error) {{
1942    handleFailure(config.mermaidMode, `Mermaid renderer could not be loaded: ${{error.message}}`);
1943  }}
1944}};
1945const renderMath = async () => {{
1946  if (config.mathMode === "off") {{
1947    return;
1948  }}
1949  const nodes = Array.from(document.querySelectorAll(".math-inline, .math-display"));
1950  if (nodes.length === 0) {{
1951    return;
1952  }}
1953  try {{
1954    window.MathJax = {{ startup: {{ typeset: false }}, svg: {{ fontCache: "none" }} }};
1955    await loadScript(config.mathScript);
1956    if (!window.MathJax || typeof window.MathJax.tex2svgPromise !== "function") {{
1957      throw new Error("MathJax did not initialize.");
1958    }}
1959    for (let index = 0; index < nodes.length; index += 1) {{
1960      const node = nodes[index];
1961      const tex = node.textContent ? node.textContent.trim() : "";
1962      if (!tex) {{
1963        continue;
1964      }}
1965      try {{
1966        const display = node.classList.contains("math-display");
1967        const rendered = await withTimeout(
1968          () => window.MathJax.tex2svgPromise(tex, {{ display }}),
1969          config.mathTimeoutMs,
1970          `Math rendering timed out: expression ${{index + 1}}.`,
1971        );
1972        node.replaceChildren(rendered);
1973        node.classList.add("math-rendered");
1974      }} catch (error) {{
1975        handleFailure(
1976          config.mathMode,
1977          error instanceof Error && error.message
1978            ? error.message
1979            : `Math rendering failed: expression ${{index + 1}}.`,
1980        );
1981      }}
1982    }}
1983  }} catch (error) {{
1984    handleFailure(config.mathMode, `Math renderer could not be loaded: ${{error.message}}`);
1985  }}
1986}};
1987const finalizeRendering = async () => {{
1988  try {{
1989    await renderMermaid();
1990    await renderMath();
1991  }} finally {{
1992    status.ready = true;
1993  }}
1994}};
1995if (document.readyState === "loading") {{
1996  document.addEventListener("DOMContentLoaded", () => {{
1997    void finalizeRendering();
1998  }}, {{ once: true }});
1999}} else {{
2000  void finalizeRendering();
2001}}
2002}})();</script>"#,
2003        mermaid_mode_name(options.mermaid_mode),
2004        math_mode_name(options.math_mode),
2005        mermaid_theme_name(options.theme),
2006        options.mermaid_timeout_ms,
2007        options.math_timeout_ms,
2008        runtime_urls.mermaid_script_url,
2009        runtime_urls.mathjax_script_url
2010    )
2011}
2012
2013fn mermaid_mode_name(mode: MermaidMode) -> &'static str {
2014    match mode {
2015        MermaidMode::Off => "off",
2016        MermaidMode::Auto => "auto",
2017        MermaidMode::On => "on",
2018    }
2019}
2020
2021fn math_mode_name(mode: MathMode) -> &'static str {
2022    match mode {
2023        MathMode::Off => "off",
2024        MathMode::Auto => "auto",
2025        MathMode::On => "on",
2026    }
2027}
2028
2029fn mermaid_theme_name(theme: ThemePreset) -> &'static str {
2030    match theme {
2031        ThemePreset::Plain => "neutral",
2032        ThemePreset::Default | ThemePreset::Github | ThemePreset::Docs => "default",
2033    }
2034}
2035
2036fn title_from_entry_path(entry_path: &str) -> String {
2037    let file_name: &str = file_name(entry_path);
2038    Path::new(file_name)
2039        .file_stem()
2040        .and_then(|stem| stem.to_str())
2041        .unwrap_or("document")
2042        .to_string()
2043}
2044
2045fn infer_mime_type(path: &str) -> &'static str {
2046    let lowercase_path: String = path.to_ascii_lowercase();
2047    if lowercase_path.ends_with(".png") {
2048        "image/png"
2049    } else if lowercase_path.ends_with(".jpg") || lowercase_path.ends_with(".jpeg") {
2050        "image/jpeg"
2051    } else if lowercase_path.ends_with(".gif") {
2052        "image/gif"
2053    } else if lowercase_path.ends_with(".svg") {
2054        "image/svg+xml"
2055    } else if lowercase_path.ends_with(".webp") {
2056        "image/webp"
2057    } else if lowercase_path.ends_with(".bmp") {
2058        "image/bmp"
2059    } else if lowercase_path.ends_with(".avif") {
2060        "image/avif"
2061    } else {
2062        "application/octet-stream"
2063    }
2064}
2065
2066fn encode_base64(bytes: &[u8]) -> String {
2067    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2068    let mut encoded: String = String::with_capacity(bytes.len().div_ceil(3) * 4);
2069    let mut index: usize = 0;
2070
2071    while index + 3 <= bytes.len() {
2072        let chunk: &[u8] = &bytes[index..index + 3];
2073        encoded.push(TABLE[(chunk[0] >> 2) as usize] as char);
2074        encoded.push(TABLE[((chunk[0] & 0b0000_0011) << 4 | (chunk[1] >> 4)) as usize] as char);
2075        encoded.push(TABLE[((chunk[1] & 0b0000_1111) << 2 | (chunk[2] >> 6)) as usize] as char);
2076        encoded.push(TABLE[(chunk[2] & 0b0011_1111) as usize] as char);
2077        index += 3;
2078    }
2079
2080    match bytes.len() - index {
2081        1 => {
2082            let byte: u8 = bytes[index];
2083            encoded.push(TABLE[(byte >> 2) as usize] as char);
2084            encoded.push(TABLE[((byte & 0b0000_0011) << 4) as usize] as char);
2085            encoded.push('=');
2086            encoded.push('=');
2087        }
2088        2 => {
2089            let first: u8 = bytes[index];
2090            let second: u8 = bytes[index + 1];
2091            encoded.push(TABLE[(first >> 2) as usize] as char);
2092            encoded.push(TABLE[((first & 0b0000_0011) << 4 | (second >> 4)) as usize] as char);
2093            encoded.push(TABLE[((second & 0b0000_1111) << 2) as usize] as char);
2094            encoded.push('=');
2095        }
2096        _ => {}
2097    }
2098
2099    encoded
2100}
2101
2102fn escape_html_text(value: &str) -> String {
2103    value
2104        .replace('&', "&amp;")
2105        .replace('<', "&lt;")
2106        .replace('>', "&gt;")
2107}
2108
2109fn escape_html_attribute(value: &str) -> String {
2110    escape_html_text(value).replace('"', "&quot;")
2111}
2112
2113#[derive(Debug, Clone, Copy)]
2114struct SrcAttributeSpan {
2115    value_start: usize,
2116    value_end: usize,
2117    is_quoted: bool,
2118}