Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 53 additions & 3 deletions src/html/comrak.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,17 +212,26 @@ impl SyntaxHighlighterAdapter for ComrakHighlightWrapperAdapter {
lang: Option<&str>,
code: &str,
) -> std::io::Result<()> {
// Resolve any "hidden" lines (rustdoc style): they are removed from the
// displayed code but kept in the copyable form so copied snippets still
// run. Non-example languages are left untouched.
let processed = crate::util::example_code::process_example_code(lang, code);
let (displayed, copyable) = match &processed {
Some(example) => (example.displayed.as_str(), example.copyable.as_str()),
None => (code, code),
};

if let Some(adapter) = &self.0 {
adapter.write_highlighted(output, lang, code)?;
adapter.write_highlighted(output, lang, displayed)?;
} else {
comrak::html::escape(output, code.as_bytes())?;
comrak::html::escape(output, displayed.as_bytes())?;
}

write!(output, "</code>")?;
write!(
output,
r#"<button class="copyButton" data-copy="{}">{}{}</button>"#,
html_escape::encode_double_quoted_attribute(code),
html_escape::encode_double_quoted_attribute(copyable),
include_str!("./templates/icons/copy.svg"),
include_str!("./templates/icons/check.svg"),
)?;
Expand Down Expand Up @@ -254,3 +263,44 @@ impl SyntaxHighlighterAdapter for ComrakHighlightWrapperAdapter {
}
}
}

#[cfg(test)]
mod tests {
use super::*;

fn render(md: &str) -> String {
let renderer = create_renderer(None, None, None);
let anchorizer: super::super::jsdoc::Anchorizer =
Arc::new(|content: String, _level: u8| content);
renderer(md, false, None, anchorizer).unwrap()
}

#[test]
fn hides_example_code_lines() {
let html = render("```ts\n# const x = 1;\nconsole.log(x);\n```");

// The displayed code (before the closing `</code>`) omits the hidden line.
let displayed = html.split("</code>").next().unwrap();
assert!(!displayed.contains("const x = 1;"));
assert!(displayed.contains("console.log(x);"));

// The copy button retains the full, runnable snippet.
assert!(html.contains("data-copy="));
assert!(html.contains("const x = 1;"));
}

#[test]
fn does_not_hide_non_example_code_lines() {
let html = render("```sh\n# install\ndeno install\n```");
let displayed = html.split("</code>").next().unwrap();
assert!(displayed.contains("# install"));
}

#[test]
fn escapes_double_hash_in_example_code() {
let html = render("```ts\n## shown\n```");
let displayed = html.split("</code>").next().unwrap();
assert!(displayed.contains("# shown"));
assert!(!displayed.contains("## shown"));
}
}
32 changes: 31 additions & 1 deletion src/printer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1024,7 +1024,16 @@ fn render_markdown(
format!("```{}", cb.info)
};
self.push_output(&format!("{}\n", colors::gray(&fence_open)))?;
for line in cb.literal.lines() {
// Hide "hidden" lines (rustdoc style) from example code blocks.
let processed = crate::util::example_code::process_example_code(
Some(&cb.info),
&cb.literal,
);
let displayed = processed
.as_ref()
.map(|example| example.displayed.as_str())
.unwrap_or(cb.literal.as_str());
for line in displayed.lines() {
self.push_output(&format!("{}\n", colors::gray(line)))?;
}
self.push_output(&format!("{}\n", colors::gray("```")))?;
Expand Down Expand Up @@ -1402,6 +1411,27 @@ mod render_markdown_tests {
assert_eq!(output, "```\nplain code\n```\n");
}

#[test]
fn code_block_hidden_lines() {
// Lines beginning with `# ` are hidden from rendered example output.
let output = render("```ts\n# const x = 1;\nconsole.log(x);\n```");
assert_eq!(output, "```ts\nconsole.log(x);\n```\n");
}

#[test]
fn code_block_hidden_lines_escape() {
// `##` escapes to a literal leading `#`.
let output = render("```ts\n## not hidden\n```");
assert_eq!(output, "```ts\n# not hidden\n```\n");
}

#[test]
fn code_block_hidden_lines_only_examples() {
// Non-example languages are left untouched.
let output = render("```sh\n# install\ndeno install\n```");
assert_eq!(output, "```sh\n# install\ndeno install\n```\n");
}

#[test]
fn link_with_text() {
let output = render("[click here](https://example.com)");
Expand Down
196 changes: 196 additions & 0 deletions src/util/example_code.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// Copyright 2020-2023 the Deno authors. All rights reserved. MIT license.

//! Support for "hidden" lines in example code blocks, mirroring the behavior
//! of rustdoc's documentation tests.
//!
//! Within a JavaScript/TypeScript code block, a line that begins with `# `
//! (hash followed by a space) — or that consists solely of `#` — is *hidden*:
//! it is removed from the rendered documentation but is still part of the
//! runnable example. This lets authors keep boilerplate (imports, setup, etc.)
//! runnable without cluttering the rendered output. A line beginning with `##`
//! is an escape for a leading literal `#` and is rendered with a single `#`.
//!
//! See <https://doc.rust-lang.org/rustdoc/write-documentation/documentation-tests.html#hiding-portions-of-the-example>.

/// Whether a fenced code block's language denotes a runnable JavaScript or
/// TypeScript example.
///
/// Hidden-line processing is intentionally limited to these languages so that
/// other code blocks (for instance shell snippets that legitimately begin a
/// line with `#`) are rendered verbatim.
fn is_example_lang(lang: &str) -> bool {
matches!(
lang,
"js"
| "javascript"
| "mjs"
| "cjs"
| "jsx"
| "ts"
| "typescript"
| "mts"
| "cts"
| "tsx"
)
}

/// The result of processing the body of an example code block for hidden lines.
pub struct ExampleCode {
/// The code shown to the reader, with hidden lines removed and `##` escapes
/// resolved to a single `#`.
pub displayed: String,
/// The full, runnable code: hidden-line markers are stripped but every line
/// is retained, so a copied snippet still executes as the author intended.
pub copyable: String,
}

/// Processes the body of a fenced code block, splitting it into the code that
/// should be displayed and the full runnable code.
///
/// Returns `None` when `lang` is not a runnable JavaScript/TypeScript language
/// (see [`is_example_lang`]), in which case the block should be rendered as-is.
pub fn process_example_code(
lang: Option<&str>,
code: &str,
) -> Option<ExampleCode> {
// The info string may carry additional attributes (e.g. `ts ignore`); only
// the first token denotes the language.
let lang = lang.unwrap_or("").split_whitespace().next().unwrap_or("");
if !is_example_lang(lang) {
return None;
}

let mut displayed = String::new();
let mut copyable = String::new();

for line in code.split_inclusive('\n') {
let (content, newline) = match line.strip_suffix('\n') {
Some(content) => (content, "\n"),
None => (line, ""),
};
let trimmed = content.trim_start();
let indent = &content[..content.len() - trimmed.len()];

if let Some(rest) = trimmed.strip_prefix("##") {
// Escaped literal `#`: render and copy with a single leading `#`.
displayed.push_str(indent);
displayed.push('#');
displayed.push_str(rest);
displayed.push_str(newline);

copyable.push_str(indent);
copyable.push('#');
copyable.push_str(rest);
copyable.push_str(newline);
} else if trimmed == "#" {
// Hidden empty line: kept in the runnable code only.
copyable.push_str(indent);
copyable.push_str(newline);
} else if let Some(rest) = trimmed.strip_prefix("# ") {
// Hidden line: kept in the runnable code only.
copyable.push_str(indent);
copyable.push_str(rest);
copyable.push_str(newline);
} else {
displayed.push_str(line);
copyable.push_str(line);
}
}

Some(ExampleCode {
displayed,
copyable,
})
}

#[cfg(test)]
mod tests {
use super::*;

fn displayed(lang: Option<&str>, code: &str) -> Option<String> {
process_example_code(lang, code).map(|c| c.displayed)
}

fn copyable(lang: Option<&str>, code: &str) -> Option<String> {
process_example_code(lang, code).map(|c| c.copyable)
}

#[test]
fn hides_lines() {
let code = "# import { add } from \"./mod.ts\";\nadd(1, 2);\n";
assert_eq!(displayed(Some("ts"), code).unwrap(), "add(1, 2);\n");
assert_eq!(
copyable(Some("ts"), code).unwrap(),
"import { add } from \"./mod.ts\";\nadd(1, 2);\n"
);
}

#[test]
fn hides_bare_hash_line() {
let code = "const x = 1;\n#\nconst y = 2;\n";
assert_eq!(
displayed(Some("js"), code).unwrap(),
"const x = 1;\nconst y = 2;\n"
);
assert_eq!(
copyable(Some("js"), code).unwrap(),
"const x = 1;\n\nconst y = 2;\n"
);
}

#[test]
fn escapes_double_hash() {
let code = "## not hidden\n";
assert_eq!(displayed(Some("ts"), code).unwrap(), "# not hidden\n");
assert_eq!(copyable(Some("ts"), code).unwrap(), "# not hidden\n");
}

#[test]
fn preserves_indentation() {
let code = "function f() {\n # const secret = 1;\n return 2;\n}\n";
assert_eq!(
displayed(Some("ts"), code).unwrap(),
"function f() {\n return 2;\n}\n"
);
assert_eq!(
copyable(Some("ts"), code).unwrap(),
"function f() {\n const secret = 1;\n return 2;\n}\n"
);
}

#[test]
fn leaves_private_fields_and_attributes_alone() {
// `#field` (no following space) is a private field, not a hidden line.
let code = "class C {\n #x = 1;\n}\n";
assert_eq!(displayed(Some("ts"), code).unwrap(), code);
assert_eq!(copyable(Some("ts"), code).unwrap(), code);
}

#[test]
fn preserves_shebang() {
let code = "#!/usr/bin/env -S deno run\nconsole.log(1);\n";
assert_eq!(displayed(Some("ts"), code).unwrap(), code);
}

#[test]
fn ignores_non_example_languages() {
// Shell snippets routinely begin a line with `#`; leave them untouched.
let code = "# install\ndeno install\n";
assert!(process_example_code(Some("sh"), code).is_none());
assert!(process_example_code(Some("bash"), code).is_none());
assert!(process_example_code(None, code).is_none());
}

#[test]
fn handles_attributes_in_info_string() {
let code = "# hidden;\nshown;\n";
assert_eq!(displayed(Some("ts ignore"), code).unwrap(), "shown;\n");
}

#[test]
fn handles_missing_trailing_newline() {
let code = "# hidden;\nshown;";
assert_eq!(displayed(Some("ts"), code).unwrap(), "shown;");
assert_eq!(copyable(Some("ts"), code).unwrap(), "hidden;\nshown;");
}
}
1 change: 1 addition & 0 deletions src/util/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// Copyright 2020-2023 the Deno authors. All rights reserved. MIT license.

pub mod example_code;
pub mod graph;
pub mod swc;
pub mod symbol;
Loading