Skip to main content

forge_doc/
vocs.rs

1//! Vocs site file generation.
2//!
3//! Generates `vocs.config.ts`, `pages/index.mdx`, `package.json`, and `.gitignore`
4//! from the emitted MDX pages.
5
6use 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
15/// Map from a Solidity source file location to its vocs page URL.
16///
17/// Key is `(parent_dir_with_forward_slashes, file_stem)` of the source file.
18/// Value is the root-relative vocs URL (no extension, leading `/`).
19type SourceToUrl = HashMap<(String, String), String>;
20
21/// Write all vocs site scaffolding into `out_dir`.
22///
23/// `pages` is the list of relative MDX paths emitted by the render step (relative to
24/// `out_dir/pages/`). `root` is the project root and `sources` is the Solidity
25/// sources directory; both are searched for a README to use as the homepage.
26pub 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 user-editable scaffold files only on first run so that manual
38    // customisations (tweaked vocs config, added npm deps, custom landing page)
39    // are not silently overwritten on subsequent `forge doc` runs.
40    write_if_absent(&out_dir.join(".gitignore"), "dist\nnode_modules\n")?;
41    write_if_absent(&out_dir.join("package.json"), package_json())?;
42    // The sidebar changes every time pages are added/removed so it lives in its own
43    // file that is always regenerated.  vocs.config.ts imports it via a relative
44    // import, so users can freely edit the main config without losing changes on
45    // the next `forge doc` run.
46    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    // Homepage: config.homepage -> <sources>/README.md -> <root>/README.md -> empty.
50    // Rewrite relative links: `.sol` -> generated vocs page; everything else ->
51    // a `{repo}/blob/{commit}/...` URL when a repository is configured.
52    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    // Always regenerate: unlike user-editable scaffold files, index.mdx is
72    // derived from README and must reflect the latest source on every run.
73    fs::write(&index_path, &homepage_content)?;
74
75    Ok(())
76}
77
78/// Write `content` to `path` only if the file does not already exist.
79fn write_if_absent(path: &Path, content: &str) -> eyre::Result<()> {
80    if !path.exists() {
81        fs::write(path, content)?;
82    }
83    Ok(())
84}
85
86// ── vocs.config.ts ────────────────────────────────────────────────────────────
87
88/// Generate the user-editable `vocs.config.ts`.
89///
90/// The sidebar is imported from the always-regenerated `vocs.sidebar.ts` so
91/// that users can customise this file freely without losing changes on re-runs.
92fn 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        // GitHub `edit/<branch>/<path>` requires a real branch name (it does not
103        // accept `HEAD`). Use the detected current branch when available, else
104        // fall back to `main`.
105        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    // Pin the shiki language bundle. Vocs otherwise scans every MDX page and
113    // eagerly loads any code-fence language it finds, which fails for fences
114    // like ```ml (OCaml, used by some READMEs as an ASCII tree). With an
115    // explicit `langs` list, unknown fences fall back to `plaintext` instead
116    // of crashing the highlighter at startup.
117    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
144/// Build a TypeScript sidebar array string from the list of relative page paths.
145fn build_sidebar(pages: &[PathBuf]) -> String {
146    // Sort pages for deterministic output.
147    let mut sorted = pages.to_vec();
148    sorted.sort();
149
150    // Group by parent directory -> type category -> (name, link).
151    // BTreeMap keeps dirs and type categories in sorted/canonical order.
152    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        // Always emit forward-slash URLs so links work on Windows.
159        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        // Split `type.Name` -> (kind, display_name).
163        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            // Top-level items, emit flat, preserving type prefix in name for clarity.
179            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        // Does this directory have more than one type category?
192        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                // Emit a collapsed sub-group for this type category.
199                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                // Single type, list items directly without a wrapper.
214                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
229/// Canonical sort order for type categories in the sidebar.
230fn 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
247/// Human-readable label for a type category.
248const 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
265// ── homepage ──────────────────────────────────────────────────────────────────
266
267/// Locate the homepage markdown.
268///
269/// Returns `(content, base_dir)` where `base_dir` is the absolute directory the
270/// homepage file lives in (used to resolve its relative links). When no
271/// homepage file is found, returns an empty string and no base dir.
272fn find_homepage(config: &DocConfig, root: &Path, sources: &Path) -> (String, Option<PathBuf>) {
273    // 1. Explicit homepage from config.
274    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    // 2. <sources>/README.md (e.g. `src/README.md`).
282    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    // 3. <root>/README.md
292    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    // 4. Empty fallback.
298    (String::new(), None)
299}
300
301// ── homepage link rewriting ───────────────────────────────────────────────────
302
303/// Build a `(parent_dir, file_stem) -> vocs_url` map from the emitted MDX pages.
304///
305/// Each page is named `<type>.<Name>.mdx`; the key mirrors the source `.sol`
306/// file (parent dir + item name), so a README link to `path/to/Name.sol`
307/// matches the page for the item whose name equals the file stem.
308fn 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
320/// Escape MDX-sensitive characters (`{` and `<`) in plain-text regions of a
321/// Markdown document, leaving fenced code blocks (` ``` ` or `~~~`) untouched.
322///
323/// Without this, README content with template placeholders like `{FOO}` or
324/// HTML-like tokens like `<TOKEN>` would be interpreted as MDX expressions/JSX
325/// and break `vocs dev` / `vocs build`.
326fn 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            // Escape `{` and bare `<` (not already `&lt;` or a known entity).
347            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("&lt;"),
371                        c => out.push(c),
372                    }
373                }
374            }
375        }
376    }
377    out
378}
379
380/// Rewrite inline `[text](url)` markdown links in the homepage:
381/// * `.sol` paths that resolve to a known page → vocs URL.
382/// * Any other relative path under `root` → `{repo}/blob/{commit}/...`.
383/// * Absolute URLs, anchors, and unresolved targets are left untouched.
384fn 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        // Scan the URL, counting parens so we don't split on `(` / `)` inside it.
398        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; // skip escaped character
418                }
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
441/// Resolve `target` against `base_dir` and return either the matching vocs
442/// page URL (`.sol`) or a `{repo}/blob/{commit}/...` URL for any other relative
443/// path under `root`.
444fn 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    // Skip absolute URLs and pure anchors.
453    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    // Split off `#fragment` / `?query`; we'll preserve the fragment on the rewrite.
463    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    // A leading `/` in a README link means "relative to project root", not an
470    // OS-absolute filesystem path. Resolve against `root` so `/src/Foo.sol`
471    // becomes `<root>/src/Foo.sol` rather than failing to strip the root prefix.
472    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    // `.sol` -> vocs page.
481    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    // Fall back to a repo URL for everything else under the project root.
490    // Use raw (download) URLs for image assets so they render inline rather
491    // than pointing at the GitHub blob viewer page.
492    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
512/// Lexically resolve `.` and `..` components without touching the filesystem.
513fn 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
527// ── package.json ──────────────────────────────────────────────────────────────
528
529const 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
546// ── helpers ───────────────────────────────────────────────────────────────────
547
548fn 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        // .sol with known page -> vocs URL.
576        assert!(out.contains("[Morpho](/src/contract.Morpho)"));
577        // .sol with fragment -> vocs URL (fragment dropped, no anchor in MDX).
578        assert!(out.contains("[IMorpho](/src/interfaces/interface.IMorpho)"));
579        // .sol without a known page -> repo blob URL.
580        assert!(out.contains("[Unknown](https://github.com/x/y/blob/abc123/src/Unknown.sol)"));
581        // Other relative paths -> repo blob URL.
582        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        // Absolute URL and pure anchor untouched.
585        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        // Plain-text regions: { → \{ and < → &lt;  (} and > are left alone).
614        assert!(out.contains(r"\{placeholder}"), "{{ in plain text should be escaped");
615        assert!(out.contains("&lt;TOKEN>"), "< in plain text should be escaped");
616        assert!(out.contains(r"\{another}"));
617        assert!(out.contains("&lt;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        // Inside ``` fences: untouched.
623        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        // Inside ~~~ fences: untouched.
630        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}