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