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::{
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
20/// Map from a Solidity source file location to its vocs page URL.
21///
22/// Key is `(parent_dir_with_forward_slashes, file_stem)` of the source file.
23/// Value is the root-relative vocs URL (no extension, leading `/`).
24type SourceToUrl = HashMap<(String, String), String>;
25
26/// Write all vocs site scaffolding into `out_dir`.
27///
28/// `pages` is the list of relative MDX paths emitted by the render step (relative to
29/// `out_dir/pages/`). `root` is the project root and `sources` is the Solidity
30/// sources directory; both are searched for a README to use as the homepage.
31pub 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 user-editable scaffold files only on first run so that manual
43    // customisations (tweaked vocs config, added npm deps, custom landing page)
44    // are not silently overwritten on subsequent `forge doc` runs.
45    write_if_absent(&out_dir.join(".gitignore"), "dist\nnode_modules\n")?;
46    write_if_absent(&out_dir.join("package.json"), package_json())?;
47    // The sidebar changes every time pages are added/removed so it lives in its own
48    // file that is always regenerated.  vocs.config.ts imports it via a relative
49    // import, so users can freely edit the main config without losing changes on
50    // the next `forge doc` run.
51    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    // Homepage: config.homepage -> <sources>/README.md -> <root>/README.md -> empty.
55    // Rewrite relative links: `.sol` -> generated vocs page; everything else ->
56    // a `{repo}/blob/{commit}/...` URL when a repository is configured.
57    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    // Always regenerate: unlike user-editable scaffold files, index.mdx is
78    // derived from README and must reflect the latest source on every run.
79    fs::write(&index_path, &homepage_content)?;
80
81    Ok(())
82}
83
84/// Write `content` to `path` only if the file does not already exist.
85fn write_if_absent(path: &Path, content: &str) -> eyre::Result<()> {
86    if !path.exists() {
87        fs::write(path, content)?;
88    }
89    Ok(())
90}
91
92// ── vocs.config.ts ────────────────────────────────────────────────────────────
93
94/// Generate the user-editable `vocs.config.ts`.
95///
96/// The sidebar is imported from the always-regenerated `vocs.sidebar.ts` so
97/// that users can customise this file freely without losing changes on re-runs.
98fn 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        // GitHub `edit/<branch>/<path>` requires a real branch name (it does not
109        // accept `HEAD`). Use the detected current branch when available, else
110        // fall back to `main`.
111        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    // Pin the shiki language bundle. Vocs otherwise scans every MDX page and
119    // eagerly loads any code-fence language it finds, which fails for fences
120    // like ```ml (OCaml, used by some READMEs as an ASCII tree). With an
121    // explicit `langs` list, unknown fences fall back to `plaintext` instead
122    // of crashing the highlighter at startup.
123    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
150/// Build a TypeScript sidebar array string from the list of relative page paths.
151fn build_sidebar(pages: &[PathBuf]) -> String {
152    // Sort pages for deterministic output.
153    let mut sorted = pages.to_vec();
154    sorted.sort();
155
156    // Group by parent directory -> type category -> (name, link).
157    // BTreeMap keeps dirs and type categories in sorted/canonical order.
158    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        // Always emit forward-slash URLs so links work on Windows.
165        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        // Split `type.Name` -> (kind, display_name).
169        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            // Top-level items, emit flat, preserving type prefix in name for clarity.
185            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        // Does this directory have more than one type category?
198        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                // Emit a collapsed sub-group for this type category.
205                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                // Single type, list items directly without a wrapper.
220                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
235/// Canonical sort order for type categories in the sidebar.
236fn 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
253/// Human-readable label for a type category.
254const 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
271// ── homepage ──────────────────────────────────────────────────────────────────
272
273/// Locate the homepage markdown.
274///
275/// Returns `(content, base_dir)` where `base_dir` is the absolute directory the
276/// homepage file lives in (used to resolve its relative links). When no
277/// homepage file is found, returns an empty string and no base dir.
278fn find_homepage(config: &DocConfig, root: &Path, sources: &Path) -> (String, Option<PathBuf>) {
279    // 1. Explicit homepage from config.
280    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    // 2. <sources>/README.md (e.g. `src/README.md`).
288    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    // 3. <root>/README.md
298    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    // 4. Empty fallback.
304    (String::new(), None)
305}
306
307// ── homepage link rewriting ───────────────────────────────────────────────────
308
309/// Build a `(parent_dir, file_stem) -> vocs_url` map from the emitted MDX pages.
310///
311/// Each page is named `<type>.<Name>.mdx`; the key mirrors the source `.sol`
312/// file (parent dir + item name), so a README link to `path/to/Name.sol`
313/// matches the page for the item whose name equals the file stem.
314fn 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
326/// Escape MDX-sensitive characters (`{` and `<`) in plain-text regions of a
327/// Markdown document, leaving fenced code blocks (` ``` ` or `~~~`) untouched.
328///
329/// Without this, README content with template placeholders like `{FOO}` or
330/// HTML-like tokens like `<TOKEN>` would be interpreted as MDX expressions/JSX
331/// and break `vocs dev` / `vocs build`.
332fn 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            // Fence markers are ASCII, so their character count is also the byte index.
346            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            // Escape `{` and bare `<` (not already `&lt;` or a known entity).
368            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("&lt;"),
392                        c => out.push(c),
393                    }
394                }
395            }
396        }
397    }
398    out
399}
400
401/// Rewrite inline `[text](url)` markdown links in the homepage:
402/// * `.sol` paths that resolve to a known page → vocs URL.
403/// * Any other relative path under `root` → `{repo}/blob/{commit}/...`.
404/// * Absolute URLs, anchors, and unresolved targets are left untouched.
405/// * Code fences and inline code spans are left untouched.
406fn 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    // The README is plain GitHub-flavored Markdown at this point, so parse it as such rather than
415    // as MDX, which may fail on unescaped `{`/`<` and would leave every region unprotected.
416    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        // Solidity syntax like `new address[](2)` inside code is not a link.
427        if region_contains(&code_regions, &mut region_cursor, abs_open) {
428            continue;
429        }
430        // Scan the URL, counting parens so we don't split on `(` / `)` inside it.
431        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                    // Skip the escaped character; clamp so a trailing backslash
451                    // can't index past the end of the text.
452                    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
477/// Resolve `target` against `base_dir` and return either the matching vocs
478/// page URL (`.sol`) or a `{repo}/blob/{commit}/...` URL for any other relative
479/// path under `root`.
480fn 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    // Skip absolute URLs and pure anchors.
489    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    // Split off `#fragment` / `?query`; we'll preserve the fragment on the rewrite.
499    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    // A leading `/` in a README link means "relative to project root", not an
506    // OS-absolute filesystem path. Resolve against `root` so `/src/Foo.sol`
507    // becomes `<root>/src/Foo.sol` rather than failing to strip the root prefix.
508    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    // `.sol` -> vocs page.
517    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    // Fall back to a repo URL for everything else under the project root.
526    // Use raw (download) URLs for image assets so they render inline rather
527    // than pointing at the GitHub blob viewer page.
528    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
544// ── package.json ──────────────────────────────────────────────────────────────
545
546const 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
563// ── helpers ───────────────────────────────────────────────────────────────────
564
565fn 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        // .sol with known page -> vocs URL.
593        assert!(out.contains("[Morpho](/src/contract.Morpho)"));
594        // .sol with fragment -> vocs URL (fragment dropped, no anchor in MDX).
595        assert!(out.contains("[IMorpho](/src/interfaces/interface.IMorpho)"));
596        // .sol without a known page -> repo blob URL.
597        assert!(out.contains("[Unknown](https://github.com/x/y/blob/abc123/src/Unknown.sol)"));
598        // Other relative paths -> repo blob URL.
599        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        // Absolute URL and pure anchor untouched.
602        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        // Plain-text regions: { → \{ and < → &lt;  (} and > are left alone).
631        assert!(out.contains(r"\{placeholder}"), "{{ in plain text should be escaped");
632        assert!(out.contains("&lt;TOKEN>"), "< in plain text should be escaped");
633        assert!(out.contains(r"\{another}"));
634        assert!(out.contains("&lt;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        // Inside ``` fences: untouched.
640        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        // Inside ~~~ fences: untouched.
647        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        // A dangling `](` whose target ends in a backslash must not panic.
724        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}