1use crate::utils::{git_raw_url, git_source_url};
7use foundry_config::DocConfig;
8use path_slash::PathExt;
9use std::{
10 collections::HashMap,
11 fs,
12 path::{Component, Path, PathBuf},
13};
14
15type SourceToUrl = HashMap<(String, String), String>;
20
21pub fn write_site_files(
27 out_dir: &Path,
28 config: &DocConfig,
29 pages: &[PathBuf],
30 root: &Path,
31 sources: &Path,
32 branch: Option<&str>,
33 commit: Option<&str>,
34) -> eyre::Result<()> {
35 fs::create_dir_all(out_dir)?;
36
37 write_if_absent(&out_dir.join(".gitignore"), "dist\nnode_modules\n")?;
41 write_if_absent(&out_dir.join("package.json"), package_json())?;
42 fs::write(out_dir.join("vocs.sidebar.ts"), vocs_sidebar(pages))?;
47 write_if_absent(&out_dir.join("vocs.config.ts"), &vocs_config(config, branch))?;
48
49 let (homepage_content, homepage_dir) = find_homepage(config, root, sources);
53 let src_to_url = build_source_to_url(pages);
54 let homepage_content = if let Some(base_dir) = homepage_dir.as_deref() {
55 rewrite_homepage_links(
56 &homepage_content,
57 base_dir,
58 root,
59 &src_to_url,
60 config.repository.as_deref(),
61 commit,
62 )
63 } else {
64 homepage_content
65 };
66 let homepage_content = escape_mdx_outside_code_fences(&homepage_content);
67 let index_path = out_dir.join("src").join("pages").join("index.mdx");
68 if let Some(parent) = index_path.parent() {
69 fs::create_dir_all(parent)?;
70 }
71 fs::write(&index_path, &homepage_content)?;
74
75 Ok(())
76}
77
78fn write_if_absent(path: &Path, content: &str) -> eyre::Result<()> {
80 if !path.exists() {
81 fs::write(path, content)?;
82 }
83 Ok(())
84}
85
86fn vocs_config(config: &DocConfig, branch: Option<&str>) -> String {
93 let title = if config.title.is_empty() { "Documentation" } else { &config.title };
94
95 let mut ts = String::new();
96 ts.push_str("import { defineConfig } from 'vocs/config'\n");
97 ts.push_str("import { sidebar } from './vocs.sidebar'\n\n");
98 ts.push_str("export default defineConfig({\n");
99 ts.push_str(&format!(" title: {},\n", json_str(title)));
100
101 if let Some(repo) = &config.repository {
102 let edit_branch = branch.unwrap_or("main");
106 ts.push_str(&format!(
107 " editLink: {{ pattern: '{}/edit/{edit_branch}/{{path}}' }},\n",
108 repo.trim_end_matches('/')
109 ));
110 }
111
112 ts.push_str(" codeHighlight: {\n");
118 ts.push_str(" fallbackLanguage: 'plaintext',\n");
119 ts.push_str(" langs: [\n");
120 ts.push_str(
121 " 'ansi', 'bash', 'diff', 'html', 'js', 'json', 'jsx',\n \
122 'markdown', 'md', 'mdx', 'plaintext', 'rust', 'sol', 'solidity',\n \
123 'toml', 'ts', 'tsx', 'yaml', 'zsh',\n",
124 );
125 ts.push_str(" ],\n");
126 ts.push_str(" },\n");
127
128 ts.push_str(" sidebar,\n");
129 ts.push_str("})\n");
130 ts
131}
132
133fn vocs_sidebar(pages: &[PathBuf]) -> String {
134 let sidebar = build_sidebar(pages);
135 let mut ts = String::new();
136 ts.push_str("// This file is generated by forge doc. Do not edit manually.\n");
137 ts.push_str("// Re-run `forge doc` to update.\n\n");
138 ts.push_str("export const sidebar = [\n");
139 ts.push_str(&sidebar);
140 ts.push_str("]\n");
141 ts
142}
143
144fn build_sidebar(pages: &[PathBuf]) -> String {
146 let mut sorted = pages.to_vec();
148 sorted.sort();
149
150 let mut groups: std::collections::BTreeMap<
153 String,
154 std::collections::BTreeMap<u8, Vec<(String, String)>>,
155 > = Default::default();
156
157 for page in &sorted {
158 let link = format!("/{}", page.with_extension("").to_slash_lossy());
160 let stem = page.file_stem().and_then(|s| s.to_str()).unwrap_or("unknown");
161
162 let (kind, name) = stem.split_once('.').unwrap_or(("", stem));
164 let cat = type_category_order(kind);
165
166 let dir = page
167 .parent()
168 .and_then(|p| if p == Path::new("") { None } else { Some(p.to_slash_lossy()) })
169 .map(|s| s.to_string())
170 .unwrap_or_default();
171
172 groups.entry(dir).or_default().entry(cat).or_default().push((name.to_string(), link));
173 }
174
175 let mut out = String::new();
176 for (dir, by_type) in &groups {
177 if dir.is_empty() {
178 for items in by_type.values() {
180 for (name, link) in items {
181 out.push_str(&format!(
182 " {{ text: {}, link: {} }},\n",
183 json_str(name),
184 json_str(link)
185 ));
186 }
187 }
188 continue;
189 }
190
191 let multi_type = by_type.len() > 1;
193
194 out.push_str(&format!(" {{\n text: {},\n items: [\n", json_str(dir)));
195
196 for (cat, items) in by_type {
197 if multi_type {
198 let cat_label = type_category_label(*cat);
200 out.push_str(&format!(
201 " {{\n text: {},\n collapsed: true,\n items: [\n",
202 json_str(cat_label)
203 ));
204 for (name, link) in items {
205 out.push_str(&format!(
206 " {{ text: {}, link: {} }},\n",
207 json_str(name),
208 json_str(link)
209 ));
210 }
211 out.push_str(" ],\n },\n");
212 } else {
213 for (name, link) in items {
215 out.push_str(&format!(
216 " {{ text: {}, link: {} }},\n",
217 json_str(name),
218 json_str(link)
219 ));
220 }
221 }
222 }
223
224 out.push_str(" ],\n },\n");
225 }
226 out
227}
228
229fn type_category_order(kind: &str) -> u8 {
231 match kind {
232 "contract" => 0,
233 "abstract" => 1,
234 "interface" => 2,
235 "library" => 3,
236 "struct" => 4,
237 "enum" => 5,
238 "type" => 6,
239 "error" => 7,
240 "event" => 8,
241 "function" => 9,
242 "constants" => 10,
243 _ => 11,
244 }
245}
246
247const fn type_category_label(cat: u8) -> &'static str {
249 match cat {
250 0 => "Contracts",
251 1 => "Abstract Contracts",
252 2 => "Interfaces",
253 3 => "Libraries",
254 4 => "Structs",
255 5 => "Enums",
256 6 => "Types",
257 7 => "Errors",
258 8 => "Events",
259 9 => "Functions",
260 10 => "Constants",
261 _ => "Other",
262 }
263}
264
265fn find_homepage(config: &DocConfig, root: &Path, sources: &Path) -> (String, Option<PathBuf>) {
273 if let Some(hp) = &config.homepage {
275 let path = if hp.is_absolute() { hp.clone() } else { root.join(hp) };
276 if let Ok(content) = fs::read_to_string(&path) {
277 return (content, path.parent().map(Path::to_path_buf));
278 }
279 }
280
281 let src_readme = if sources.is_absolute() {
283 sources.join("README.md")
284 } else {
285 root.join(sources).join("README.md")
286 };
287 if let Ok(content) = fs::read_to_string(&src_readme) {
288 return (content, src_readme.parent().map(Path::to_path_buf));
289 }
290
291 let readme = root.join("README.md");
293 if let Ok(content) = fs::read_to_string(&readme) {
294 return (content, readme.parent().map(Path::to_path_buf));
295 }
296
297 (String::new(), None)
299}
300
301fn build_source_to_url(pages: &[PathBuf]) -> SourceToUrl {
309 let mut map = SourceToUrl::new();
310 for page in pages {
311 let stem = page.file_stem().and_then(|s| s.to_str()).unwrap_or("");
312 let Some((_kind, name)) = stem.split_once('.') else { continue };
313 let dir = page.parent().map(|p| p.to_slash_lossy().into_owned()).unwrap_or_default();
314 let url = format!("/{}", page.with_extension("").to_slash_lossy());
315 map.insert((dir, name.to_string()), url);
316 }
317 map
318}
319
320fn escape_mdx_outside_code_fences(text: &str) -> String {
327 let mut out = String::with_capacity(text.len());
328 let mut in_fence = false;
329 let mut fence_marker = "";
330 for line in text.split_inclusive('\n') {
331 let trimmed = line.trim_start();
332 if in_fence {
333 out.push_str(line);
334 if trimmed.starts_with(fence_marker) {
335 in_fence = false;
336 }
337 } else if trimmed.starts_with("```") {
338 in_fence = true;
339 fence_marker = "```";
340 out.push_str(line);
341 } else if trimmed.starts_with("~~~") {
342 in_fence = true;
343 fence_marker = "~~~";
344 out.push_str(line);
345 } else {
346 let mut inline_code_ticks = 0usize;
348 let mut pending_ticks = 0usize;
349 for ch in line.chars() {
350 if ch == '`' {
351 pending_ticks += 1;
352 out.push(ch);
353 continue;
354 }
355
356 if pending_ticks > 0 {
357 if inline_code_ticks == 0 {
358 inline_code_ticks = pending_ticks;
359 } else if inline_code_ticks == pending_ticks {
360 inline_code_ticks = 0;
361 }
362 pending_ticks = 0;
363 }
364
365 if inline_code_ticks > 0 {
366 out.push(ch);
367 } else {
368 match ch {
369 '{' => out.push_str(r"\{"),
370 '<' => out.push_str("<"),
371 c => out.push(c),
372 }
373 }
374 }
375 }
376 }
377 out
378}
379
380fn rewrite_homepage_links(
385 text: &str,
386 base_dir: &Path,
387 root: &Path,
388 src_to_url: &SourceToUrl,
389 repo: Option<&str>,
390 commit: Option<&str>,
391) -> String {
392 let mut out = String::with_capacity(text.len());
393 let mut rest = text;
394 while let Some(open) = rest.find("](") {
395 out.push_str(&rest[..open + 2]);
396 rest = &rest[open + 2..];
397 let bytes = rest.as_bytes();
399 let mut i = 0;
400 let mut depth = 1usize;
401 let mut closed = false;
402 while i < bytes.len() {
403 match bytes[i] {
404 b'(' => {
405 depth += 1;
406 i += 1;
407 }
408 b')' => {
409 depth -= 1;
410 if depth == 0 {
411 closed = true;
412 break;
413 }
414 i += 1;
415 }
416 b'\\' => {
417 i += 2; }
419 _ => {
420 i += 1;
421 }
422 }
423 }
424 let target = &rest[..i];
425 match try_rewrite_target(target, base_dir, root, src_to_url, repo, commit) {
426 Some(new) => out.push_str(&new),
427 None => out.push_str(target),
428 }
429 if closed {
430 out.push(')');
431 rest = &rest[i + 1..];
432 } else {
433 rest = &rest[i..];
434 break;
435 }
436 }
437 out.push_str(rest);
438 out
439}
440
441fn try_rewrite_target(
445 target: &str,
446 base_dir: &Path,
447 root: &Path,
448 src_to_url: &SourceToUrl,
449 repo: Option<&str>,
450 commit: Option<&str>,
451) -> Option<String> {
452 if target.is_empty()
454 || target.starts_with('#')
455 || target.starts_with("//")
456 || target.contains("://")
457 || target.starts_with("mailto:")
458 {
459 return None;
460 }
461
462 let (path_part, suffix) = target.find(['#', '?']).map_or((target, ""), |i| target.split_at(i));
464 if path_part.is_empty() {
465 return None;
466 }
467
468 let path = Path::new(path_part);
469 let abs = if path.is_absolute() {
473 let without_root_prefix = path.strip_prefix("/").unwrap_or(path);
474 normalize_path(&root.join(without_root_prefix))
475 } else {
476 normalize_path(&base_dir.join(path))
477 };
478 let rel = abs.strip_prefix(root).ok()?.to_path_buf();
479
480 if path.extension().and_then(|e| e.to_str()) == Some("sol") {
482 let stem = rel.file_stem().and_then(|s| s.to_str())?;
483 let dir = rel.parent().map(|p| p.to_slash_lossy().into_owned()).unwrap_or_default();
484 if let Some(url) = src_to_url.get(&(dir, stem.to_string())) {
485 return Some(url.clone());
486 }
487 }
488
489 let repo = repo?;
493 let is_image = path
494 .extension()
495 .and_then(|e| e.to_str())
496 .map(|ext| {
497 matches!(
498 ext.to_ascii_lowercase().as_str(),
499 "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "ico"
500 )
501 })
502 .unwrap_or(false);
503 let mut url = if is_image {
504 git_raw_url(repo, commit.unwrap_or("HEAD"), root, &abs)?
505 } else {
506 git_source_url(repo, commit.unwrap_or("HEAD"), root, &abs)?
507 };
508 url.push_str(suffix);
509 Some(url)
510}
511
512fn normalize_path(p: &Path) -> PathBuf {
514 let mut out = PathBuf::new();
515 for comp in p.components() {
516 match comp {
517 Component::ParentDir => {
518 out.pop();
519 }
520 Component::CurDir => {}
521 other => out.push(other.as_os_str()),
522 }
523 }
524 out
525}
526
527const fn package_json() -> &'static str {
530 r#"{
531 "scripts": {
532 "dev": "vocs dev",
533 "build": "vocs build",
534 "preview": "vocs preview"
535 },
536 "dependencies": {
537 "react": "^19",
538 "react-dom": "^19",
539 "vocs": "https://pkg.pr.new/wevm/vocs@next",
540 "waku": "1.0.0-alpha.6"
541 }
542}
543"#
544}
545
546fn json_str(s: &str) -> String {
549 serde_json::to_string(s).expect("serializing a string cannot fail")
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555
556 #[test]
557 fn rewrites_links() {
558 let map = build_source_to_url(&[
559 PathBuf::from("src/contract.Morpho.mdx"),
560 PathBuf::from("src/interfaces/interface.IMorpho.mdx"),
561 PathBuf::from("src/libraries/library.MathLib.mdx"),
562 ]);
563 let root = Path::new("/repo");
564 let repo = Some("https://github.com/x/y");
565 let commit = Some("abc123");
566 let input = "[Morpho](./src/Morpho.sol), \
567 [IMorpho](src/interfaces/IMorpho.sol#L10), \
568 [Unknown](src/Unknown.sol), \
569 [Contrib](./CONTRIBUTING.md), \
570 [Logo](./img/logo.png), \
571 [ext](https://x.com), \
572 [anchor](#section)";
573
574 let out = rewrite_homepage_links(input, Path::new("/repo"), root, &map, repo, commit);
575 assert!(out.contains("[Morpho](/src/contract.Morpho)"));
577 assert!(out.contains("[IMorpho](/src/interfaces/interface.IMorpho)"));
579 assert!(out.contains("[Unknown](https://github.com/x/y/blob/abc123/src/Unknown.sol)"));
581 assert!(out.contains("[Contrib](https://github.com/x/y/blob/abc123/CONTRIBUTING.md)"));
583 assert!(out.contains("[Logo](https://github.com/x/y/raw/abc123/img/logo.png)"));
584 assert!(out.contains("[ext](https://x.com)"));
586 assert!(out.contains("[anchor](#section)"));
587 }
588
589 #[test]
590 fn escape_mdx_leaves_code_fences_alone() {
591 let input = "\
592# Title
593
594Plain text with {placeholder} and <TOKEN> here.
595Inline code keeps `forge create <Contract>` and `{OWNER}` unchanged.
596
597```solidity
598contract Foo {
599 mapping(address => uint256) public balances;
600}
601```
602
603More text: {another} and <bar/>.
604
605~~~shell
606echo {not escaped}
607~~~
608
609End {brace}.
610";
611 let out = escape_mdx_outside_code_fences(input);
612
613 assert!(out.contains(r"\{placeholder}"), "{{ in plain text should be escaped");
615 assert!(out.contains("<TOKEN>"), "< in plain text should be escaped");
616 assert!(out.contains(r"\{another}"));
617 assert!(out.contains("<bar/>"));
618 assert!(out.contains(r"\{brace}"));
619 assert!(out.contains("`forge create <Contract>`"), "inline code span must be unchanged");
620 assert!(out.contains("`{OWNER}`"), "inline code span braces must be unchanged");
621
622 assert!(
624 out.contains("mapping(address => uint256)"),
625 "code fence content must be unchanged"
626 );
627 assert!(!out.contains(r"mapping(address => uint256\)"), "no stray escaping inside fence");
628
629 assert!(out.contains("echo {not escaped}"), "~~~ fence content must be unchanged");
631 }
632
633 #[test]
634 fn json_str_emits_valid_typescript_strings() {
635 assert_eq!(json_str(r#"Acme\"#), r#""Acme\\""#);
636 assert_eq!(json_str("Bob's Docs"), r#""Bob's Docs""#);
637 assert_eq!(json_str(r#"Quote " Docs"#), r#""Quote \" Docs""#);
638 }
639}