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