1use crate::{
7 markdown::{code_regions, neutralize_esm, region_contains},
8 utils::{git_raw_url, git_source_url},
9};
10use foundry_common::fs::normalize_path;
11use foundry_config::DocConfig;
12use markdown::ParseOptions;
13use path_slash::PathExt;
14use std::{
15 collections::HashMap,
16 fs,
17 path::{Path, PathBuf},
18};
19
20type SourceToUrl = HashMap<(String, String), String>;
25
26pub fn write_site_files(
32 out_dir: &Path,
33 config: &DocConfig,
34 pages: &[PathBuf],
35 root: &Path,
36 sources: &Path,
37 branch: Option<&str>,
38 commit: Option<&str>,
39) -> eyre::Result<()> {
40 fs::create_dir_all(out_dir)?;
41
42 write_if_absent(&out_dir.join(".gitignore"), "dist\nnode_modules\n")?;
46 write_if_absent(&out_dir.join("package.json"), package_json())?;
47 fs::write(out_dir.join("vocs.sidebar.ts"), vocs_sidebar(pages))?;
52 write_if_absent(&out_dir.join("vocs.config.ts"), &vocs_config(config, branch))?;
53
54 let (homepage_content, homepage_dir) = find_homepage(config, root, sources);
58 let src_to_url = build_source_to_url(pages);
59 let homepage_content = if let Some(base_dir) = homepage_dir.as_deref() {
60 rewrite_homepage_links(
61 &homepage_content,
62 base_dir,
63 root,
64 &src_to_url,
65 config.repository.as_deref(),
66 commit,
67 )
68 } else {
69 homepage_content
70 };
71 let homepage_content = homepage_content.strip_prefix('\u{feff}').unwrap_or(&homepage_content);
72 let homepage_content = neutralize_esm(&escape_mdx_outside_code_fences(homepage_content));
73 let index_path = out_dir.join("src").join("pages").join("index.mdx");
74 if let Some(parent) = index_path.parent() {
75 fs::create_dir_all(parent)?;
76 }
77 fs::write(&index_path, &homepage_content)?;
80
81 Ok(())
82}
83
84fn write_if_absent(path: &Path, content: &str) -> eyre::Result<()> {
86 if !path.exists() {
87 fs::write(path, content)?;
88 }
89 Ok(())
90}
91
92fn vocs_config(config: &DocConfig, branch: Option<&str>) -> String {
99 let title = if config.title.is_empty() { "Documentation" } else { &config.title };
100
101 let mut ts = String::new();
102 ts.push_str("import { defineConfig } from 'vocs/config'\n");
103 ts.push_str("import { sidebar } from './vocs.sidebar'\n\n");
104 ts.push_str("export default defineConfig({\n");
105 ts.push_str(&format!(" title: {},\n", json_str(title)));
106
107 if let Some(repo) = &config.repository {
108 let edit_branch = branch.unwrap_or("main");
112 ts.push_str(&format!(
113 " editLink: {{ pattern: '{}/edit/{edit_branch}/{{path}}' }},\n",
114 repo.trim_end_matches('/')
115 ));
116 }
117
118 ts.push_str(" codeHighlight: {\n");
124 ts.push_str(" fallbackLanguage: 'plaintext',\n");
125 ts.push_str(" langs: [\n");
126 ts.push_str(
127 " 'ansi', 'bash', 'diff', 'html', 'js', 'json', 'jsx',\n \
128 'markdown', 'md', 'mdx', 'plaintext', 'rust', 'sol', 'solidity',\n \
129 'toml', 'ts', 'tsx', 'yaml', 'zsh',\n",
130 );
131 ts.push_str(" ],\n");
132 ts.push_str(" },\n");
133
134 ts.push_str(" sidebar,\n");
135 ts.push_str("})\n");
136 ts
137}
138
139fn vocs_sidebar(pages: &[PathBuf]) -> String {
140 let sidebar = build_sidebar(pages);
141 let mut ts = String::new();
142 ts.push_str("// This file is generated by forge doc. Do not edit manually.\n");
143 ts.push_str("// Re-run `forge doc` to update.\n\n");
144 ts.push_str("export const sidebar = [\n");
145 ts.push_str(&sidebar);
146 ts.push_str("]\n");
147 ts
148}
149
150fn build_sidebar(pages: &[PathBuf]) -> String {
152 let mut sorted = pages.to_vec();
154 sorted.sort();
155
156 let mut groups: std::collections::BTreeMap<
159 String,
160 std::collections::BTreeMap<u8, Vec<(String, String)>>,
161 > = Default::default();
162
163 for page in &sorted {
164 let link = format!("/{}", page.with_extension("").to_slash_lossy());
166 let stem = page.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown");
167
168 let (kind, name) = stem.split_once('.').unwrap_or(("", stem));
170 let cat = type_category_order(kind);
171
172 let dir = page
173 .parent()
174 .and_then(|p| if p == Path::new("") { None } else { Some(p.to_slash_lossy()) })
175 .map(|s| s.to_string())
176 .unwrap_or_default();
177
178 groups.entry(dir).or_default().entry(cat).or_default().push((name.to_string(), link));
179 }
180
181 let mut out = String::new();
182 for (dir, by_type) in &groups {
183 if dir.is_empty() {
184 for items in by_type.values() {
186 for (name, link) in items {
187 out.push_str(&format!(
188 " {{ text: {}, link: {} }},\n",
189 json_str(name),
190 json_str(link)
191 ));
192 }
193 }
194 continue;
195 }
196
197 let multi_type = by_type.len() > 1;
199
200 out.push_str(&format!(" {{\n text: {},\n items: [\n", json_str(dir)));
201
202 for (cat, items) in by_type {
203 if multi_type {
204 let cat_label = type_category_label(*cat);
206 out.push_str(&format!(
207 " {{\n text: {},\n collapsed: true,\n items: [\n",
208 json_str(cat_label)
209 ));
210 for (name, link) in items {
211 out.push_str(&format!(
212 " {{ text: {}, link: {} }},\n",
213 json_str(name),
214 json_str(link)
215 ));
216 }
217 out.push_str(" ],\n },\n");
218 } else {
219 for (name, link) in items {
221 out.push_str(&format!(
222 " {{ text: {}, link: {} }},\n",
223 json_str(name),
224 json_str(link)
225 ));
226 }
227 }
228 }
229
230 out.push_str(" ],\n },\n");
231 }
232 out
233}
234
235fn type_category_order(kind: &str) -> u8 {
237 match kind {
238 "contract" => 0,
239 "abstract" => 1,
240 "interface" => 2,
241 "library" => 3,
242 "struct" => 4,
243 "enum" => 5,
244 "type" => 6,
245 "error" => 7,
246 "event" => 8,
247 "function" => 9,
248 "constants" => 10,
249 _ => 11,
250 }
251}
252
253const fn type_category_label(cat: u8) -> &'static str {
255 match cat {
256 0 => "Contracts",
257 1 => "Abstract Contracts",
258 2 => "Interfaces",
259 3 => "Libraries",
260 4 => "Structs",
261 5 => "Enums",
262 6 => "Types",
263 7 => "Errors",
264 8 => "Events",
265 9 => "Functions",
266 10 => "Constants",
267 _ => "Other",
268 }
269}
270
271fn find_homepage(config: &DocConfig, root: &Path, sources: &Path) -> (String, Option<PathBuf>) {
279 if let Some(hp) = &config.homepage {
281 let path = if hp.is_absolute() { hp.clone() } else { root.join(hp) };
282 if let Ok(content) = fs::read_to_string(&path) {
283 return (content, path.parent().map(Path::to_path_buf));
284 }
285 }
286
287 let src_readme = if sources.is_absolute() {
289 sources.join("README.md")
290 } else {
291 root.join(sources).join("README.md")
292 };
293 if let Ok(content) = fs::read_to_string(&src_readme) {
294 return (content, src_readme.parent().map(Path::to_path_buf));
295 }
296
297 let readme = root.join("README.md");
299 if let Ok(content) = fs::read_to_string(&readme) {
300 return (content, readme.parent().map(Path::to_path_buf));
301 }
302
303 (String::new(), None)
305}
306
307fn build_source_to_url(pages: &[PathBuf]) -> SourceToUrl {
315 let mut map = SourceToUrl::new();
316 for page in pages {
317 let stem = page.file_stem().and_then(|s| s.to_str()).unwrap_or("");
318 let Some((_kind, name)) = stem.split_once('.') else { continue };
319 let dir = page.parent().map(|p| p.to_slash_lossy().into_owned()).unwrap_or_default();
320 let url = format!("/{}", page.with_extension("").to_slash_lossy());
321 map.insert((dir, name.to_string()), url);
322 }
323 map
324}
325
326fn escape_mdx_outside_code_fences(text: &str) -> String {
333 struct Fence {
334 marker: char,
335 len: usize,
336 }
337
338 let mut out = String::with_capacity(text.len());
339 let mut fence: Option<Fence> = None;
340 for line in text.split_inclusive('\n') {
341 let trimmed = line.trim_start();
342 if let Some(open) = fence.as_ref() {
343 out.push_str(line);
344 let marker_len = trimmed.chars().take_while(|&ch| ch == open.marker).count();
345 let suffix = &trimmed[marker_len..];
347 if marker_len >= open.len && suffix.trim().is_empty() {
348 fence = None;
349 }
350 } else {
351 let opening = trimmed.chars().next().and_then(|marker| {
352 if !matches!(marker, '`' | '~') {
353 return None;
354 }
355 let len = trimmed.chars().take_while(|&ch| ch == marker).count();
356 if len < 3 || marker == '`' && trimmed[len..].contains('`') {
357 return None;
358 }
359 Some(Fence { marker, len })
360 });
361 if let Some(opening) = opening {
362 fence = Some(opening);
363 out.push_str(line);
364 continue;
365 }
366
367 let mut inline_code_ticks = 0usize;
369 let mut pending_ticks = 0usize;
370 for ch in line.chars() {
371 if ch == '`' {
372 pending_ticks += 1;
373 out.push(ch);
374 continue;
375 }
376
377 if pending_ticks > 0 {
378 if inline_code_ticks == 0 {
379 inline_code_ticks = pending_ticks;
380 } else if inline_code_ticks == pending_ticks {
381 inline_code_ticks = 0;
382 }
383 pending_ticks = 0;
384 }
385
386 if inline_code_ticks > 0 {
387 out.push(ch);
388 } else {
389 match ch {
390 '{' => out.push_str(r"\{"),
391 '<' => out.push_str("<"),
392 c => out.push(c),
393 }
394 }
395 }
396 }
397 }
398 out
399}
400
401fn rewrite_homepage_links(
407 text: &str,
408 base_dir: &Path,
409 root: &Path,
410 src_to_url: &SourceToUrl,
411 repo: Option<&str>,
412 commit: Option<&str>,
413) -> String {
414 let code_regions = code_regions(text, &ParseOptions::gfm());
417 let mut region_cursor = 0;
418 let mut out = String::with_capacity(text.len());
419 let mut rest = text;
420 let mut consumed = 0usize;
421 while let Some(open) = rest.find("](") {
422 let abs_open = consumed + open;
423 out.push_str(&rest[..open + 2]);
424 rest = &rest[open + 2..];
425 consumed += open + 2;
426 if region_contains(&code_regions, &mut region_cursor, abs_open) {
428 continue;
429 }
430 let bytes = rest.as_bytes();
432 let mut i = 0;
433 let mut depth = 1usize;
434 let mut closed = false;
435 while i < bytes.len() {
436 match bytes[i] {
437 b'(' => {
438 depth += 1;
439 i += 1;
440 }
441 b')' => {
442 depth -= 1;
443 if depth == 0 {
444 closed = true;
445 break;
446 }
447 i += 1;
448 }
449 b'\\' => {
450 i = (i + 2).min(bytes.len());
453 }
454 _ => {
455 i += 1;
456 }
457 }
458 }
459 let target = &rest[..i];
460 if !closed {
461 out.push_str(target);
462 rest = &rest[i..];
463 break;
464 }
465 match try_rewrite_target(target, base_dir, root, src_to_url, repo, commit) {
466 Some(new) => out.push_str(&new),
467 None => out.push_str(target),
468 }
469 out.push(')');
470 rest = &rest[i + 1..];
471 consumed += i + 1;
472 }
473 out.push_str(rest);
474 out
475}
476
477fn try_rewrite_target(
481 target: &str,
482 base_dir: &Path,
483 root: &Path,
484 src_to_url: &SourceToUrl,
485 repo: Option<&str>,
486 commit: Option<&str>,
487) -> Option<String> {
488 if target.is_empty()
490 || target.starts_with('#')
491 || target.starts_with("//")
492 || target.contains("://")
493 || target.starts_with("mailto:")
494 {
495 return None;
496 }
497
498 let (path_part, suffix) = target.find(['#', '?']).map_or((target, ""), |i| target.split_at(i));
500 if path_part.is_empty() {
501 return None;
502 }
503
504 let path = Path::new(path_part);
505 let abs = if path.is_absolute() {
509 let without_root_prefix = path.strip_prefix("/").unwrap_or(path);
510 normalize_path(&root.join(without_root_prefix))
511 } else {
512 normalize_path(&base_dir.join(path))
513 };
514 let rel = abs.strip_prefix(root).ok()?.to_path_buf();
515
516 if path.extension().and_then(|e| e.to_str()) == Some("sol") {
518 let stem = rel.file_stem().and_then(|s| s.to_str())?;
519 let dir = rel.parent().map(|p| p.to_slash_lossy().into_owned()).unwrap_or_default();
520 if let Some(url) = src_to_url.get(&(dir, stem.to_string())) {
521 return Some(url.clone());
522 }
523 }
524
525 let repo = repo?;
529 let is_image = path.extension().and_then(|e| e.to_str()).is_some_and(|ext| {
530 matches!(
531 ext.to_ascii_lowercase().as_str(),
532 "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "ico"
533 )
534 });
535 let mut url = if is_image {
536 git_raw_url(repo, commit.unwrap_or("HEAD"), root, &abs)?
537 } else {
538 git_source_url(repo, commit.unwrap_or("HEAD"), root, &abs)?
539 };
540 url.push_str(suffix);
541 Some(url)
542}
543
544const fn package_json() -> &'static str {
547 r#"{
548 "scripts": {
549 "dev": "vocs dev",
550 "build": "vocs build",
551 "preview": "vocs preview"
552 },
553 "dependencies": {
554 "react": "^19",
555 "react-dom": "^19",
556 "vocs": "https://pkg.pr.new/wevm/vocs@next",
557 "waku": "1.0.0-alpha.6"
558 }
559}
560"#
561}
562
563fn json_str(s: &str) -> String {
566 serde_json::to_string(s).expect("serializing a string cannot fail")
567}
568
569#[cfg(test)]
570mod tests {
571 use super::*;
572
573 #[test]
574 fn rewrites_links() {
575 let map = build_source_to_url(&[
576 PathBuf::from("src/contract.Morpho.mdx"),
577 PathBuf::from("src/interfaces/interface.IMorpho.mdx"),
578 PathBuf::from("src/libraries/library.MathLib.mdx"),
579 ]);
580 let root = Path::new("/repo");
581 let repo = Some("https://github.com/x/y");
582 let commit = Some("abc123");
583 let input = "[Morpho](./src/Morpho.sol), \
584 [IMorpho](src/interfaces/IMorpho.sol#L10), \
585 [Unknown](src/Unknown.sol), \
586 [Contrib](./CONTRIBUTING.md), \
587 [Logo](./img/logo.png), \
588 [ext](https://x.com), \
589 [anchor](#section)";
590
591 let out = rewrite_homepage_links(input, Path::new("/repo"), root, &map, repo, commit);
592 assert!(out.contains("[Morpho](/src/contract.Morpho)"));
594 assert!(out.contains("[IMorpho](/src/interfaces/interface.IMorpho)"));
596 assert!(out.contains("[Unknown](https://github.com/x/y/blob/abc123/src/Unknown.sol)"));
598 assert!(out.contains("[Contrib](https://github.com/x/y/blob/abc123/CONTRIBUTING.md)"));
600 assert!(out.contains("[Logo](https://github.com/x/y/raw/abc123/img/logo.png)"));
601 assert!(out.contains("[ext](https://x.com)"));
603 assert!(out.contains("[anchor](#section)"));
604 }
605
606 #[test]
607 fn escape_mdx_leaves_code_fences_alone() {
608 let input = "\
609# Title
610
611Plain text with {placeholder} and <TOKEN> here.
612Inline code keeps `forge create <Contract>` and `{OWNER}` unchanged.
613
614```solidity
615contract Foo {
616 mapping(address => uint256) public balances;
617}
618```
619
620More text: {another} and <bar/>.
621
622~~~shell
623echo {not escaped}
624~~~
625
626End {brace}.
627";
628 let out = escape_mdx_outside_code_fences(input);
629
630 assert!(out.contains(r"\{placeholder}"), "{{ in plain text should be escaped");
632 assert!(out.contains("<TOKEN>"), "< in plain text should be escaped");
633 assert!(out.contains(r"\{another}"));
634 assert!(out.contains("<bar/>"));
635 assert!(out.contains(r"\{brace}"));
636 assert!(out.contains("`forge create <Contract>`"), "inline code span must be unchanged");
637 assert!(out.contains("`{OWNER}`"), "inline code span braces must be unchanged");
638
639 assert!(
641 out.contains("mapping(address => uint256)"),
642 "code fence content must be unchanged"
643 );
644 assert!(!out.contains(r"mapping(address => uint256\)"), "no stray escaping inside fence");
645
646 assert!(out.contains("echo {not escaped}"), "~~~ fence content must be unchanged");
648 }
649
650 #[test]
651 fn escape_mdx_supports_longer_backtick_fences() {
652 let input = "````markdown\n```solidity\ncontract Test {\n function value() external returns (uint256) {\n return 1;\n }\n}\n```\n````\n\nOutside {text}.\n";
653 let out = escape_mdx_outside_code_fences(input);
654
655 assert_eq!(out, input.replace("Outside {text}", r"Outside \{text}"));
656 }
657
658 #[test]
659 fn escape_mdx_supports_longer_tilde_fences() {
660 let input = "~~~~markdown\n~~~solidity\ncontract Test {\n function value() external returns (uint256) {\n return 1;\n }\n}\n~~~\n~~~~\n\nOutside {text}.\n";
661 let out = escape_mdx_outside_code_fences(input);
662
663 assert_eq!(out, input.replace("Outside {text}", r"Outside \{text}"));
664 }
665
666 #[test]
667 fn escape_mdx_rejects_backticks_in_fence_info() {
668 let input = "```bad`\n```still-bad`\n{process.exit(42)}\n";
669 let out = escape_mdx_outside_code_fences(input);
670
671 assert_eq!(out, input.replace("{process.exit(42)}", r"\{process.exit(42)}"));
672 }
673
674 #[test]
675 fn json_str_emits_valid_typescript_strings() {
676 assert_eq!(json_str(r#"Acme\"#), r#""Acme\\""#);
677 assert_eq!(json_str("Bob's Docs"), r#""Bob's Docs""#);
678 assert_eq!(json_str(r#"Quote " Docs"#), r#""Quote \" Docs""#);
679 }
680
681 #[test]
682 fn rewrite_homepage_links_leaves_code_alone() {
683 let map = build_source_to_url(&[PathBuf::from("src/contract.Foo.mdx")]);
684 let root = Path::new("/repo");
685 let repo = Some("https://github.com/x/y");
686 let commit = Some("abc123");
687 let input = "\
688See [Foo](./src/Foo.sol) for details.
689
690```solidity
691function deploy() external {
692 address[] memory targets = new address[](2);
693}
694```
695
696Also uses `new address[](2)` inline, then links [Contrib](./CONTRIBUTING.md).
697
698A lone ` backtick is not a code span: [Logo](./img/logo.png).
699
700 address[] memory indented = new address[](2);
701";
702 let expected = "\
703See [Foo](/src/contract.Foo) for details.
704
705```solidity
706function deploy() external {
707 address[] memory targets = new address[](2);
708}
709```
710
711Also uses `new address[](2)` inline, then links [Contrib](https://github.com/x/y/blob/abc123/CONTRIBUTING.md).
712
713A lone ` backtick is not a code span: [Logo](https://github.com/x/y/raw/abc123/img/logo.png).
714
715 address[] memory indented = new address[](2);
716";
717 let out = rewrite_homepage_links(input, root, root, &map, repo, commit);
718 assert_eq!(out, expected);
719 }
720
721 #[test]
722 fn rewrite_homepage_links_handles_trailing_unclosed_backslash() {
723 let map = SourceToUrl::new();
725 let root = Path::new("/repo");
726 let input = "See [Foo](abc\\";
727 let out = rewrite_homepage_links(
728 input,
729 root,
730 root,
731 &map,
732 Some("https://github.com/x/y"),
733 Some("abc123"),
734 );
735 assert_eq!(out, input, "unclosed target should be left untouched, not panic");
736 }
737}