Oussama Metatla

79 papers A* 25A 3Misc 10Journal 16Unranked 24
YearRankTypeTitle / Venue / Authors
2026 A* conf
CHI
Siyu Zhang, Yelu Gu, Kirsten Cater, Oussama Metatla
2026 conf
CHI Extended Abstracts
Oussama Metatla, Min Susan Li, Feng Feng, Cameron Steer, Michael J. Proulx, Meike Scheller, Tegan Joy Roberts-Morgan
2026 A* conf
CHI
Priscilla Y. Lo, Tegan Roberts-Morgan, Matthew Horton, Janet C. Read, Oussama Metatla
2026 A* conf
CHI
Elaine Czech, Dan Bennett, Grace Jane Stangroome, Vanessa Aisyahsari Hanschke, Amy Ingold, Paul Marshall, Oussama Metatla
2026 A* conf
CHI
Min Susan Li, Zhuzhi Fan, Tegan Joy Roberts-Morgan, Amy Ingold, Oussama Metatla
2026 A* conf
CHI
Mamoru Watanabe, Oussama Metatla, Atau Tanaka
2026 J jnl
CoRR
Tegan Joy Roberts-Morgan, Min Susan Li, Priscilla Lo, Zhuzhi Fan, Dan Bennett, Oussama Metatla
2026 A* conf
CHI
Tegan Roberts-Morgan, Min Susan Li, Priscilla Y. Lo, Zhuzhi Fan, Dan Bennett, Oussama Metatla
2026 A* conf
CHI
Zhuzhi Fan, Min Susan Li, Jialin Deng, Amy Ingold, Oussama Metatla
2025 Misc conf
IDC
Tegan Joy Roberts-Morgan, Brooke Morris, Min Susan Li, Amy Ingold, Matthew Horton, Janet C. Read, Dan Bennett, Oussama Metatla
2025 conf
CHI Extended Abstracts
Mamoru Watanabe, Oussama Metatla, Atau Tanaka
2025 A* conf
CHI
Brooke Morris, Hayati Havlucu, Alison Oldfield, Oussama Metatla
2025 A* conf
CHI
Katherine Mary Jones, Martin Grayson, Cecily Morrison, Ute Leonards, Oussama Metatla
2025 Misc conf
IDC
Brooke Morris, Alison Oldfield, Oussama Metatla
2025 conf
CHI Extended Abstracts
Eszter Vigh, Ellen Weir, Nathalie Alexandra Penglin Tcherdakoff, Grace Jane Stangroome, Yelu Gu, Oussama Metatla, Mamoru Watanabe, René Schäfer, Sophie Hahn, Konrad Mikolaj Krawczyk, Marcela Godoy, Rodolfo Cossovich, Randy Morin, Kristine Dreaver-Charles, Marguerite Koole, Frank B. W. Lewis
2025 J jnl
Int. J. Hum. Comput. Stud.
Dan Bennett, Anne Roudaut, Oussama Metatla
2025 A* conf
CHI
Richard Grafton, Oussama Metatla, Anne Roudaut
2024 Misc conf
IDC
Zoë Clark, Brooke Morris, Elaine Czech, Hayati Havlucu, Oussama Metatla
2024 A* conf
CHI
Katherine Mary Jones, Ute Leonards, Oussama Metatla
2024 J jnl
Proc. ACM Hum. Comput. Interact.
Leyla Benhamida, Slimane Larabi, Oussama Metatla
2024 Misc conf
IDC
Tegan Joy Roberts-Morgan, Brooke Ayers Morris, Elaine Czech, Suhan Neema, Abigale Stangl, Kyle Michael Keane, Matthew Horton, Janet C. Read, Oussama Metatla
2024 A* conf
CHI
Brooke Ayers Morris, Hayati Havlucu, Alison Oldfield, Oussama Metatla
2023 conf
CSCW Companion
Oritsetimeyin Arueyingho, Nicola J. Bidwell, Anicia Peters, Jacki O'Neill, Oussama Metatla, Amid Ayobi, Makuochi Samuel Nkwo, Damiete Onyema Lawrence, Rockefeller Zimba, Hadiza Ismaila, Hilda Owii, Abiola Saka
2023 conf
INTERACT (4)
David Gollasch, Meinhardt Branig, Kathrin Gerling, Jan Gulliksen, Oussama Metatla, Katta Spiel, Gerhard Weber
2023 conf
CHI Extended Abstracts
Brooke Ayers Morris, Hayati Havlucu, Alison Oldfield, Oussama Metatla
2023 A* conf
CHI
Dan Bennett, Oussama Metatla, Anne Roudaut, Elisa D. Mekler
2023 J jnl
CoRR
Dan Bennett, Oussama Metatla, Anne Roudaut, Elisa D. Mekler
2023 A* conf
CHI
Luluah Albarrak, Oussama Metatla, Anne Roudaut
2022 A conf
ASSETS
Elaine Czech, Paul Marshall, Oussama Metatla
2022 Misc conf
IDC
Ana Cristina Pires, Isabel Neto, Emeline Brulé, Laura Malinverni, Oussama Metatla, Juan Pablo Hourcade
2022 A* conf
CHI
Feng Feng, Dan Bennett, Zhijun Fan, Oussama Metatla
2022 A* conf
CHI
Daniel Bennett, Anne Roudaut, Oussama Metatla
2022 J jnl
Sensors
Ki-Young Shin, Ryan McConville, Oussama Metatla, Min Hye Chang, Chiyoung Han, Junhaeng Lee, Anne Roudaut
2022 Misc conf
IDC
Janet C. Read, Oussama Metatla, Matthew Horton, Elisa Rubegni, Jason C. Yip
2021 J jnl
Proc. ACM Hum. Comput. Interact.
Luluah Albarrak, Oussama Metatla, Anne Roudaut
2021 J jnl
Int. J. Hum. Comput. Stud.
Luluah Albarrak, Oussama Metatla, Anne Roudaut
2021 A* conf
CHI
Anan Lin, Meike Scheller, Feng Feng, Michael J. Proulx, Oussama Metatla
2021 conf
CHI Extended Abstracts
Maryam Bandukda, Aneesha Singh, Catherine Holloway, Nadia Berthouze, Emeline Brulé, Ana Tajadura-Jiménez, Oussama Metatla, Ana Javornik, Anja Thieme
2020 Misc conf
IDC
Oussama Metatla, Janet C. Read, Matthew Horton
2020 A conf
Conference on Designing Interactive Systems
Luluah Albarrak, Oussama Metatla, Anne Roudaut
2020 A* conf
CHI
David R. Glowacki, Mark D. Wonnacott, Rachel Freire, Becca Rose Glowacki, Ella M. Gale, James E. Pike, Tiu de Haan, Mike Chatziapostolou, Oussama Metatla
2020 J jnl
CoRR
David R. Glowacki, Mark D. Wonnacott, Rachel Freire, Becca Rose Glowacki, Ella M. Gale, James E. Pike, Tiu de Haan, Mike Chatziapostolou, Oussama Metatla
2020 Misc conf
IDC
Stephanie Robinson, Sion L. Hannuna, Oussama Metatla
2020 A* conf
CHI
Emeline Brulé, Brianna J. Tomlinson, Oussama Metatla, Christophe Jouffrais, Marcos Serrano
2020 A* conf
CHI
Oussama Metatla, Sandra Bardot, Clare Cullen, Marcos Serrano, Christophe Jouffrais
2020 conf
CSCW Companion
Clare Cullen, Oussama Metatla
2020 conf
IDC (Extended Abstracts)
Seray B. Ibrahim, Emeline Brulé, Laura Benton, Anthony J. Hornof, Erin Beneteau, Oussama Metatla, Nikoleta Yiannoutsou, Katta Spiel
2019 A* conf
CHI
Oussama Metatla, Emanuela Maggioni, Clare Cullen, Marianna Obrist
2019 conf
CHI Extended Abstracts
Luluah Albarrak, Oussama Metatla, Anne Roudaut
2019 Misc conf
IDC
Clare Cullen, Oussama Metatla
2019 conf
CHI Extended Abstracts
James Edmeads, Oussama Metatla
2019 conf
CHI Extended Abstracts
Emeline Brulé, Oussama Metatla, Katta Spiel, Ahmed Kharrufa, Charlotte Robinson
2019 J jnl
CoRR
Lisa May Thomas, Helen M. Deeks, Alex J. Jones, Oussama Metatla, David R. Glowacki
2019 J jnl
Interactions
Oussama Metatla, Anja Thieme, Emeline Brulé, Cynthia L. Bennett, Marcos Serrano, Christophe Jouffrais
2019 A* conf
CHI
Oussama Metatla, Alison Oldfield, Taimur Ahmed, Antonis Vafeas, Sunny Miglani
2019 J jnl
Interactions
Max Krüger, Ana Maria Bustamante Duarte, Anne Weibert, Konstantin Aal, Reem Talhouk, Oussama Metatla
2018 A* conf
CHI
Oussama Metatla, Clare Cullen
2018 A* conf
CHI
Oussama Metatla, Nick Bryan-Kinns, Tony Stockman
2018 conf
CHI Extended Abstracts
Joe Hutchinson, Oussama Metatla
2018 conf
CHI Extended Abstracts
Oussama Metatla, Marcos Serrano, Christophe Jouffrais, Anja Thieme, Shaun K. Kane, Stacy M. Branham, Émeline Brulé, Cynthia L. Bennett
2018 Misc conf
IDC
Clare Cullen, Oussama Metatla
2018 J jnl
CoRR
Michael B. O'Connor, Helen M. Deeks, Edward Dawn, Oussama Metatla, Anne Roudaut, Matthew Sutton, Becca Rose Glowacki, Rebecca Sage, Philip Tew, Mark Wonnacott, Phil Bates, Adrian J. Mulholland, David R. Glowacki
2017 conf
BCS HCI
Oussama Metatla
2016 J jnl
J. Multimodal User Interfaces
Oussama Metatla, Fiore Martin, Adam Parkinson, Nick Bryan-Kinns, Tony Stockman, Atau Tanaka
2016 J jnl
PeerJ Comput. Sci.
Oussama Metatla, Nick Bryan-Kinns, Tony Stockman, Fiore Martin
2016 A* conf
CHI
Oussama Metatla, Nuno N. Correia, Fiore Martin, Nick Bryan-Kinns, Tony Stockman
2016 conf
AfriCHI
Oussama Metatla
2015 J jnl
PeerJ Prepr.
Oussama Metatla, Nick Bryan-Kinns, Tony Stockman, Fiore Martin
2015 conf
ICAD
Oussama Metatla, Nick Bryan-Kinns, Tony Stockman, Fiore Martin
2014 conf
BCS HCI
Oussama Metatla, Fiore Martin, Tony Stockman, Nick Bryan-Kinns
2013 conf
INTERACT (3)
Oussama Metatla, Nick Bryan-Kinns, Tony Stockman, Fiore Martin
2013 conf
INTERACT (1)
Nuzhah Gooda Sahib, Tony Stockman, Anastasios Tombros, Oussama Metatla
2012 J jnl
J. Multimodal User Interfaces
Oussama Metatla, Nick Bryan-Kinns, Tony Stockman
2012 conf
BCS HCI
Oussama Metatla, Nick Bryan-Kinns, Tony Stockman, Fiore Martin
2010
Oussama Metatla
2010 conf
NIME
Nick Bryan-Kinns, Robin Fencott, Oussama Metatla, Shahin Nabavian, Jennifer G. Sheridan
2008 conf
BCS HCI (2)
Oussama Metatla, Nick Bryan-Kinns, Tony Stockman
2008 A conf
ASSETS
Oussama Metatla, Nick Bryan-Kinns, Tony Stockman
2007 conf
BCS HCI (1)
Oussama Metatla, Nick Bryan-Kinns, Tony Stockman
src/main.rs
← Index src/main.rs rust
mod highlight;

use std::collections::BTreeMap;
use std::fs;
use std::io::Read;
use std::path::{Path, PathBuf};
use std::time::Instant;

use walkdir::WalkDir;

use highlight::highlight_code;

/// Above this threshold the file is read in full and classified
/// (text or binary) based on its complete content.
const FULL_READ_LIMIT: u64 = 4_000_000; // 4 MB
/// For files larger than the threshold above, we only read a preview
/// of this size (so we never load huge files fully into memory).
const PREVIEW_BYTES: usize = 16_384;
/// How many bytes of binary content to show in the hex dump.
const HEXDUMP_BYTES: usize = 4096;

struct FileResult {
    rel_path: PathBuf, // relative path of the original file
    out_rel: PathBuf,  // relative path of the generated .html file (inside out/)
    label: String,     // label shown in the badge (language, or "binary")
}

enum Unreadable {
    IoError(PathBuf),
}

enum Content {
    /// Successfully decoded text. `truncated` indicates whether the file
    /// is larger than what was actually read (very large files: preview only).
    Text { content: String, truncated: bool, total_size: u64 },
    /// Content that isn't valid UTF-8 text: we show a hex dump instead
    /// of trying to decode it.
    Binary { preview: Vec<u8>, total_size: u64, truncated: bool },
}

/// Reads a file safely: for small files, reads everything and classifies
/// text/binary based on the full content; for large files, reads only a
/// prefix, to avoid loading huge files into memory.
fn read_content(path: &Path, total_size: u64) -> std::io::Result<Content> {
    if total_size <= FULL_READ_LIMIT {
        let bytes = fs::read(path)?;
        match String::from_utf8(bytes) {
            Ok(s) => Ok(Content::Text {
                content: s,
                truncated: false,
                total_size,
            }),
            Err(e) => Ok(Content::Binary {
                preview: e.into_bytes(),
                total_size,
                truncated: false,
            }),
        }
    } else {
        let mut f = fs::File::open(path)?;
        let mut buf = vec![0u8; PREVIEW_BYTES];
        let n = f.read(&mut buf)?;
        buf.truncate(n);

        match std::str::from_utf8(&buf) {
            Ok(s) => Ok(Content::Text {
                content: s.to_string(),
                truncated: true,
                total_size,
            }),
            Err(e) => {
                let valid_up_to = e.valid_up_to();
                // If the error is only due to cutting a multi-byte
                // character in half (common when reading an arbitrary
                // prefix of a text file), still treat it as text.
                if valid_up_to > 0 && buf.len() - valid_up_to <= 4 {
                    let s = std::str::from_utf8(&buf[..valid_up_to]).unwrap().to_string();
                    Ok(Content::Text {
                        content: s,
                        truncated: true,
                        total_size,
                    })
                } else {
                    Ok(Content::Binary {
                        preview: buf,
                        total_size,
                        truncated: true,
                    })
                }
            }
        }
    }
}

/// Tree node used to build the index (index.html)
#[derive(Default)]
struct Node {
    dirs: BTreeMap<String, Node>,
    files: Vec<(String, String, PathBuf)>, // (display_name, label, href relative to the index)
}

impl Node {
    fn insert_file(&mut self, components: &[String], display_name: String, label: String, href: PathBuf) {
        if components.is_empty() {
            self.files.push((display_name, label, href));
        } else {
            let head = &components[0];
            let child = self.dirs.entry(head.clone()).or_default();
            child.insert_file(&components[1..], display_name, label, href);
        }
    }

    fn render(&self, out: &mut String) {
        out.push_str("<ul class=\"tree\">\n");
        for (name, node) in &self.dirs {
            out.push_str(&format!(
                "<li class=\"dir\"><span class=\"dir-name\">\u{1F4C1} {}</span>\n",
                escape_html(name)
            ));
            node.render(out);
            out.push_str("</li>\n");
        }
        for (name, label, href) in &self.files {
            out.push_str(&format!(
                "<li class=\"file\"><a href=\"{}\">\u{1F4C4} {}</a> <span class=\"badge\">{}</span></li>\n",
                href.to_string_lossy().replace('\\', "/"),
                escape_html(name),
                escape_html(label)
            ));
        }
        out.push_str("</ul>\n");
    }
}

fn main() {
    let args: Vec<String> = std::env::args().collect();
    let input_dir = args.get(1).cloned().unwrap_or_else(|| ".".to_string());
    let output_dir = args.get(2).cloned().unwrap_or_else(|| "out".to_string());
    // Extra directory names to exclude, optional, comma-separated
    // (e.g. "node_modules,.git"). Nothing is excluded by default:
    // the generator includes everything it finds, including
    // __pycache__, compiled files, binaries, etc.
    let extra_excludes: Vec<String> = args
        .get(3)
        .map(|s| s.split(',').map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect())
        .unwrap_or_default();

    let input = PathBuf::from(&input_dir);
    let output = PathBuf::from(&output_dir);

    if !input.is_dir() {
        eprintln!("Error: '{}' is not a valid directory.", input.display());
        std::process::exit(1);
    }

    if let Err(e) = fs::create_dir_all(&output) {
        eprintln!("Error creating the output directory: {e}");
        std::process::exit(1);
    }

    // Absolute path of the output directory, so we can exclude it from the
    // scan in case it's nested inside the input directory (e.g. input=".",
    // output="out"). This is the only automatic exclusion and it can't be
    // turned off: it only prevents the program from processing the files
    // it is currently writing.
    let output_abs = fs::canonicalize(&output).unwrap_or_else(|_| output.clone());

    println!("Scanning '{}' -> '{}'", input.display(), output.display());
    if !extra_excludes.is_empty() {
        println!("Directories excluded on request: {}", extra_excludes.join(", "));
    }
    let start = Instant::now();

    let mut results: Vec<FileResult> = Vec::new();
    let mut unreadable: Vec<Unreadable> = Vec::new();
    let mut n_binary = 0usize;
    let mut n_truncated = 0usize;

    for entry in WalkDir::new(&input)
        .into_iter()
        .filter_entry(|e| {
            if e.file_type().is_dir() {
                if let Some(name) = e.file_name().to_str() {
                    if extra_excludes.iter().any(|x| x == name) {
                        return false;
                    }
                }
                if let Ok(abs) = fs::canonicalize(e.path()) {
                    if abs == output_abs {
                        return false;
                    }
                }
            }
            true
        })
    {
        let entry = match entry {
            Ok(e) => e,
            Err(_) => continue,
        };

        if !entry.file_type().is_file() {
            continue;
        }

        let path = entry.path();
        let rel_path = match path.strip_prefix(&input) {
            Ok(p) => p.to_path_buf(),
            Err(_) => path.to_path_buf(),
        };

        let meta = match fs::metadata(path) {
            Ok(m) => m,
            Err(_) => {
                unreadable.push(Unreadable::IoError(rel_path));
                continue;
            }
        };

        let content = match read_content(path, meta.len()) {
            Ok(c) => c,
            Err(_) => {
                unreadable.push(Unreadable::IoError(rel_path));
                continue;
            }
        };

        let syntax_lang = detect_language(path);

        let (body_html, label, note): (String, String, Option<String>) = match &content {
            Content::Text { content, truncated, total_size } => {
                let body = highlight_code(content, syntax_lang);
                let note = if *truncated {
                    n_truncated += 1;
                    Some(format!(
                        "Truncated preview: showing the first {} bytes out of {} total.",
                        content.len(),
                        total_size
                    ))
                } else {
                    None
                };
                (body, syntax_lang.to_string(), note)
            }
            Content::Binary { preview, total_size, truncated } => {
                n_binary += 1;
                let dump = hex_dump(preview, HEXDUMP_BYTES);
                let shown = preview.len().min(HEXDUMP_BYTES);
                let note = Some(format!(
                    "Binary file: {} bytes total. Showing the first {} bytes as hex.{}",
                    total_size,
                    shown,
                    if *truncated { " (only the first bytes of the file were read)" } else { "" }
                ));
                (escape_html(&dump), "binary".to_string(), note)
            }
        };

        let mut out_rel = rel_path.clone();
        let new_name = format!(
            "{}.html",
            out_rel.file_name().unwrap_or_default().to_string_lossy()
        );
        out_rel.set_file_name(new_name);

        let out_path = output.join(&out_rel);
        if let Some(parent) = out_path.parent() {
            let _ = fs::create_dir_all(parent);
        }

        let depth = out_rel.components().count().saturating_sub(1);
        let back_to_index = "../".repeat(depth);

        let html = render_file_html(
            &rel_path.to_string_lossy(),
            &label,
            &body_html,
            note.as_deref(),
            &back_to_index,
        );

        if fs::write(&out_path, html).is_ok() {
            results.push(FileResult {
                rel_path,
                out_rel,
                label,
            });
        }
    }

    // Build the tree for the index
    let mut root = Node::default();
    for r in &results {
        let components: Vec<String> = r
            .rel_path
            .parent()
            .map(|p| {
                p.components()
                    .map(|c| c.as_os_str().to_string_lossy().to_string())
                    .filter(|s| !s.is_empty())
                    .collect()
            })
            .unwrap_or_default();
        let display_name = r
            .rel_path
            .file_name()
            .unwrap_or_default()
            .to_string_lossy()
            .to_string();
        root.insert_file(
            &components,
            display_name,
            r.label.clone(),
            r.out_rel.clone(),
        );
    }

    let mut tree_html = String::new();
    root.render(&mut tree_html);

    let index_html = render_index_html(&tree_html, results.len(), n_binary, n_truncated);
    let _ = fs::write(output.join("index.html"), index_html);

    let elapsed = start.elapsed();
    println!(
        "Done in {:.2?}: {} files included ({} binary, {} with truncated preview), {} unreadable.",
        elapsed,
        results.len(),
        n_binary,
        n_truncated,
        unreadable.len()
    );
    for u in &unreadable {
        match u {
            Unreadable::IoError(p) => println!("  unreadable (I/O error): {}", p.display()),
        }
    }
    println!("Open {}/index.html in your browser.", output.display());
}

/// Produces a hex dump (in the style of `xxd -C`) of the first `limit` bytes.
fn hex_dump(bytes: &[u8], limit: usize) -> String {
    let show = &bytes[..bytes.len().min(limit)];
    let mut out = String::with_capacity(show.len() * 4);
    for (i, chunk) in show.chunks(16).enumerate() {
        let offset = i * 16;
        out.push_str(&format!("{:08x}  ", offset));
        for (j, b) in chunk.iter().enumerate() {
            out.push_str(&format!("{:02x} ", b));
            if j == 7 {
                out.push(' ');
            }
        }
        let missing = 16usize.saturating_sub(chunk.len());
        for _ in 0..missing {
            out.push_str("   ");
        }
        out.push_str(" |");
        for b in chunk {
            let c = *b;
            if (0x20..0x7f).contains(&c) {
                out.push(c as char);
            } else {
                out.push('.');
            }
        }
        out.push_str("|\n");
    }
    out
}

/// Determines the language (used to pick syntax-highlighting rules)
/// from the file's name/extension. This is only used when the content
/// turns out to actually be text; binary files are classified as such
/// regardless by read_content.
fn detect_language(path: &Path) -> &'static str {
    let file_name = path
        .file_name()
        .map(|s| s.to_string_lossy().to_lowercase())
        .unwrap_or_default();

    match file_name.as_str() {
        "dockerfile" => return "dockerfile",
        "makefile" | "gnumakefile" => return "makefile",
        "cmakelists.txt" => return "cmake",
        ".gitignore" | ".dockerignore" | ".npmignore" => return "plaintext",
        "cargo.toml" | "cargo.lock" => return "ini",
        _ => {}
    }

    let ext = path
        .extension()
        .map(|s| s.to_string_lossy().to_lowercase())
        .unwrap_or_default();

    match ext.as_str() {
        "py" | "pyw" | "pyi" => "python",
        "rs" => "rust",
        "js" | "mjs" | "cjs" => "javascript",
        "jsx" => "javascript",
        "ts" => "typescript",
        "tsx" => "typescript",
        "java" => "java",
        "c" => "c",
        "h" => "c",
        "hpp" | "hh" | "hxx" => "cpp",
        "cpp" | "cc" | "cxx" => "cpp",
        "cs" => "csharp",
        "go" => "go",
        "rb" => "ruby",
        "php" => "php",
        "html" | "htm" => "xml",
        "css" => "css",
        "scss" | "sass" => "scss",
        "less" => "less",
        "json" => "json",
        "xml" => "xml",
        "yaml" | "yml" => "yaml",
        "toml" | "ini" | "cfg" | "conf" => "ini",
        "sh" | "bash" | "zsh" => "bash",
        "sql" => "sql",
        "md" | "markdown" => "markdown",
        "kt" | "kts" => "kotlin",
        "swift" => "swift",
        "lua" => "lua",
        "r" => "r",
        "pl" | "pm" => "perl",
        "groovy" | "gradle" => "groovy",
        "vb" => "vbnet",
        "graphql" | "gql" => "graphql",
        "diff" | "patch" => "diff",
        "bat" | "cmd" => "dos",
        "ps1" => "powershell",
        "vue" => "xml",
        "txt" => "plaintext",
        _ => "plaintext",
    }
}

fn escape_html(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for c in s.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&#39;"),
            _ => out.push(c),
        }
    }
    out
}

const STYLE: &str = r#"
:root {
  --bg: #0d1117;
  --panel: #161b22;
  --text: #c9d1d9;
  --muted: #8b949e;
  --accent: #58a6ff;
  --border: #30363d;
}
* { box-sizing: border-box; }
body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
}
header {
  padding: 14px 20px;
  background: var(--panel);
  border-bottom: 1px solid var(--border);
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
}
header a { color: var(--accent); text-decoration: none; font-weight: 600; }
header a:hover { text-decoration: underline; }
.path { color: var(--muted); font-size: 0.9em; word-break: break-all; }
.note {
  padding: 10px 20px;
  background: #21262d;
  border-bottom: 1px solid var(--border);
  color: var(--muted);
  font-size: 0.85em;
}
main { padding: 0; }
pre {
  margin: 0;
  padding: 16px 20px 40px 20px;
  overflow-x: auto;
  font-size: 13px;
  line-height: 1.5;
}
code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace; }
.container { max-width: 1000px; margin: 30px auto; padding: 0 20px 60px; }
h1 { font-size: 1.6em; }
.meta { color: var(--muted); font-size: 0.9em; margin-bottom: 20px; }
ul.tree { list-style: none; padding-left: 18px; margin: 6px 0; }
ul.tree > li { margin: 4px 0; }
.dir-name { font-weight: 600; color: var(--text); }
.file a { color: var(--accent); text-decoration: none; }
.file a:hover { text-decoration: underline; }
.badge {
  display: inline-block;
  font-size: 0.72em;
  color: var(--muted);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 1px 8px;
  margin-left: 6px;
}
.skipped { color: var(--muted); font-size: 0.85em; margin-top: 30px; }

/* Syntax highlighting: generated in Rust, no JavaScript involved. */
.c { color: #8b949e; font-style: italic; }
.s { color: #a5d6ff; }
.n { color: #79c0ff; }
.k { color: #ff7b72; font-weight: 600; }
"#;

fn render_file_html(rel_path: &str, label: &str, body_html: &str, note: Option<&str>, back_to_index: &str) -> String {
    let title = escape_html(rel_path);
    let note_html = match note {
        Some(n) => format!("<div class=\"note\">{}</div>\n", escape_html(n)),
        None => String::new(),
    };
    format!(
        r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{title}</title>
<style>{STYLE}</style>
</head>
<body>
<header>
  <a href="{back_to_index}index.html">&larr; Index</a>
  <span class="path">{title}</span>
  <span class="badge">{label}</span>
</header>
{note_html}<main>
<pre><code>{body_html}</code></pre>
</main>
</body>
</html>
"#
    )
}

fn render_index_html(tree_html: &str, n_ok: usize, n_binary: usize, n_truncated: usize) -> String {
    let mut extra = String::new();
    if n_binary > 0 {
        extra.push_str(&format!(
            "<p class=\"skipped\">{} binary files included with a hex-dump preview.</p>",
            n_binary
        ));
    }
    if n_truncated > 0 {
        extra.push_str(&format!(
            "<p class=\"skipped\">{} very large files shown as a truncated preview only.</p>",
            n_truncated
        ));
    }

    let folder_emoji = '\u{1F4C2}';
    format!(
        r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Source index</title>
<style>{STYLE}</style>
</head>
<body>
<div class="container">
<h1>{folder_emoji} Source index</h1>
<p class="meta">{n_ok} files included in total.</p>
{tree_html}
{extra}
</div>
</body>
</html>
"#
    )
}