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
339pub 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 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 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 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
974fn 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('&', "&")
2105 .replace('<', "<")
2106 .replace('>', ">")
2107}
2108
2109fn escape_html_attribute(value: &str) -> String {
2110 escape_html_text(value).replace('"', """)
2111}
2112
2113#[derive(Debug, Clone, Copy)]
2114struct SrcAttributeSpan {
2115 value_start: usize,
2116 value_end: usize,
2117 is_quoted: bool,
2118}