diff --git a/Cargo.lock b/Cargo.lock index 3f2ac480170..82ffc88dc16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3902,7 +3902,9 @@ dependencies = [ "derive_more", "glob", "gloo", + "gloo-net 0.6.0", "js-sys", + "serde", "tokio", "wasm-bindgen", "wasm-bindgen-futures", diff --git a/tools/website-test/Cargo.toml b/tools/website-test/Cargo.toml index 7a24e4ef911..bbab4fa4343 100644 --- a/tools/website-test/Cargo.toml +++ b/tools/website-test/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" build = "build.rs" publish = false -rust-version = "1.62" +rust-version = "1.81" [dependencies] yew-agent = { path = "../../packages/yew-agent/" } @@ -12,7 +12,9 @@ yew-agent = { path = "../../packages/yew-agent/" } [dev-dependencies] derive_more = { version = "2.0", features = ["from"] } gloo = "0.11" +gloo-net = "0.6" js-sys = "0.3" +serde = { version = "1.0", features = ["derive"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4" weblog = "0.3.0" diff --git a/tools/website-test/build.rs b/tools/website-test/build.rs index 8620d1100c1..0d41d07b367 100644 --- a/tools/website-test/build.rs +++ b/tools/website-test/build.rs @@ -1,46 +1,141 @@ use std::collections::HashMap; -use std::fmt::{self, Write}; +use std::error::Error; +use std::fmt::Write; +use std::fs::File; +use std::io::{self, BufRead, BufReader, ErrorKind, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; +use std::process::ExitCode; use std::{env, fs}; use glob::glob; +type Result = core::result::Result>; + +macro_rules! e { + ($($fmt:tt),* $(,)?) => { + return Err(format!($($fmt),*).into()) + }; +} + +macro_rules! assert { + ($condition:expr, $($fmt:tt),* $(,)?) => { + if !$condition { e!($($fmt),*) } + }; +} + #[derive(Debug, Default)] struct Level { nested: HashMap, files: Vec, } -fn main() { - let home = env::var("CARGO_MANIFEST_DIR").unwrap(); - let pattern = format!("{home}/../../website/docs/**/*.md*"); - let base = format!("{home}/../../website"); - let base = Path::new(&base).canonicalize().unwrap(); - let dir_pattern = format!("{home}/../../website/docs/**"); - for dir in glob(&dir_pattern).unwrap() { - println!("cargo:rerun-if-changed={}", dir.unwrap().display()); - } - - let mut level = Level::default(); +fn should_combine_code_blocks(path: &Path) -> io::Result { + const FLAG: &[u8] = b""; - for entry in glob(&pattern).unwrap() { - let path = entry.unwrap(); - let path = Path::new(&path).canonicalize().unwrap(); - println!("cargo:rerun-if-changed={}", path.display()); - let rel = path.strip_prefix(&base).unwrap(); + let mut file = File::open(path)?; + match file.seek(SeekFrom::End(-32)) { + Ok(_) => (), + Err(e) if e.kind() == ErrorKind::InvalidInput => return Ok(false), + Err(e) => return Err(e), + } + let mut buf = [0u8; 32]; + file.read_exact(&mut buf)?; + Ok(buf.trim_ascii_end().ends_with(FLAG)) +} - let mut parts = vec![]; +fn apply_diff(src: &mut String, preamble: &str, added: &str, removed: &str) -> Result { + assert!( + !preamble.is_empty() || !removed.is_empty(), + "Failure on applying a diff: \nNo preamble or text to remove provided, unable to find \ + location to insert:\n{added}\nIn the following text:\n{src}", + ); + + let mut matches = src.match_indices(if preamble.is_empty() { + removed + } else { + preamble + }); + let Some((preamble_start, _)) = matches.next() else { + e!( + "Failure on applying a diff: \ncouldn't find the following text:\n{preamble}\n\nIn \ + the following text:\n{src}" + ) + }; + + assert!( + matches.next().is_none(), + "Failure on applying a diff: \nAmbiguous preamble:\n{preamble}\nIn the following \ + text:\n{src}\nWhile trying to remove the following text:\n{removed}\nAnd add the \ + following:\n{added}\n" + ); + + let preamble_end = preamble_start + preamble.len(); + assert!( + src.get(preamble_end..preamble_end + removed.len()) == Some(removed), + "Failure on applying a diff: \nText to remove not found:\n{removed}\n\nIn the following \ + text:\n{src}", + ); + + src.replace_range(preamble_end..preamble_end + removed.len(), added); + Ok(()) +} - for part in rel { - parts.push(part.to_str().unwrap()); +fn combined_code_blocks(path: &Path) -> Result { + let file = BufReader::new(File::open(path)?); + let mut res = String::new(); + + let mut err = Ok(()); + let mut lines = file + .lines() + .filter_map(|i| i.map_err(|e| err = Err(e)).ok()); + while let Some(line) = lines.next() { + if !line.starts_with("```rust") { + continue; } - level.insert(path.clone(), &parts[..]); + let mut preamble = String::new(); + let mut added = String::new(); + let mut removed = String::new(); + let mut diff_applied = false; + for line in &mut lines { + if line.starts_with("```") { + if !added.is_empty() || !removed.is_empty() { + apply_diff(&mut res, &preamble, &added, &removed)?; + } else if !diff_applied { + // if no diff markers were found, just add the contents + res += &preamble; + } + break; + } else if let Some(line) = line.strip_prefix('+') { + if line.starts_with(char::is_whitespace) { + added += " "; + } + added += line; + added += "\n"; + } else if let Some(line) = line.strip_prefix('-') { + if line.starts_with(char::is_whitespace) { + removed += " "; + } + removed += line; + removed += "\n"; + } else if line.trim_ascii() == "// ..." { + // disregard the preamble + preamble.clear(); + } else { + if !added.is_empty() || !removed.is_empty() { + diff_applied = true; + apply_diff(&mut res, &preamble, &added, &removed)?; + preamble += &added; + added.clear(); + removed.clear(); + } + preamble += &line; + preamble += "\n"; + } + } } - let out = format!("{}/website_tests.rs", env::var("OUT_DIR").unwrap()); - - fs::write(out, level.to_contents()).unwrap(); + Ok(res) } impl Level { @@ -53,14 +148,14 @@ impl Level { } } - fn to_contents(&self) -> String { + fn to_contents(&self) -> Result { let mut dst = String::new(); - self.write_inner(&mut dst, 0).unwrap(); - dst + self.write_inner(&mut dst, 0)?; + Ok(dst) } - fn write_into(&self, dst: &mut String, name: &str, level: usize) -> fmt::Result { + fn write_into(&self, dst: &mut String, name: &str, level: usize) -> Result { self.write_space(dst, level); let name = name.replace(['-', '.'], "_"); writeln!(dst, "pub mod {name} {{")?; @@ -73,24 +168,33 @@ impl Level { Ok(()) } - fn write_inner(&self, dst: &mut String, level: usize) -> fmt::Result { + fn write_inner(&self, dst: &mut String, level: usize) -> Result { for (name, nested) in &self.nested { nested.write_into(dst, name, level)?; } - self.write_space(dst, level); - for file in &self.files { - let stem = Path::new(file) + let stem = file .file_stem() - .unwrap() + .ok_or_else(|| format!("no filename in path {file:?}"))? .to_str() - .unwrap() + .ok_or_else(|| format!("non-UTF8 path: {file:?}"))? .replace('-', "_"); - self.write_space(dst, level); - - writeln!(dst, "#[doc = include_str!(r\"{}\")]", file.display())?; + if should_combine_code_blocks(file)? { + let res = combined_code_blocks(file)?; + self.write_space(dst, level); + writeln!(dst, "/// ```rust, no_run")?; + for line in res.lines() { + self.write_space(dst, level); + writeln!(dst, "/// {line}")?; + } + self.write_space(dst, level); + writeln!(dst, "/// ```")?; + } else { + self.write_space(dst, level); + writeln!(dst, "#[doc = include_str!(r\"{}\")]", file.display())?; + } self.write_space(dst, level); writeln!(dst, "pub fn {stem}_md() {{}}")?; } @@ -104,3 +208,48 @@ impl Level { } } } + +fn inner_main() -> Result { + let home = env::var("CARGO_MANIFEST_DIR")?; + let pattern = format!("{home}/../../website/docs/**/*.md*"); + let base = format!("{home}/../../website"); + let base = Path::new(&base).canonicalize()?; + let dir_pattern = format!("{home}/../../website/docs/**"); + for dir in glob(&dir_pattern)? { + println!("cargo:rerun-if-changed={}", dir?.display()); + } + + let mut level = Level::default(); + + for entry in glob(&pattern)? { + let path = entry?.canonicalize()?; + println!("cargo:rerun-if-changed={}", path.display()); + let rel = path.strip_prefix(&base)?; + + let mut parts = vec![]; + + for part in rel { + parts.push( + part.to_str() + .ok_or_else(|| format!("Non-UTF8 path: {rel:?}"))?, + ); + } + + level.insert(path.clone(), &parts[..]); + } + + let out = format!("{}/website_tests.rs", env::var("OUT_DIR")?); + + fs::write(out, level.to_contents()?)?; + Ok(()) +} + +fn main() -> ExitCode { + match inner_main() { + Ok(_) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("{e}"); + ExitCode::FAILURE + } + } +} diff --git a/tools/website-test/src/lib.rs b/tools/website-test/src/lib.rs index f814b314878..9c62ba37b3a 100644 --- a/tools/website-test/src/lib.rs +++ b/tools/website-test/src/lib.rs @@ -1,4 +1 @@ -#![allow(clippy::needless_doctest_main)] -pub mod tutorial; - include!(concat!(env!("OUT_DIR"), "/website_tests.rs")); diff --git a/tools/website-test/src/tutorial.rs b/tools/website-test/src/tutorial.rs deleted file mode 100644 index 86a04e262a3..00000000000 --- a/tools/website-test/src/tutorial.rs +++ /dev/null @@ -1,7 +0,0 @@ -#[derive(Clone, PartialEq, Eq)] -pub struct Video { - pub id: usize, - pub title: String, - pub speaker: String, - pub url: String, -} diff --git a/website/README.md b/website/README.md index bda43a8b075..979cacd34ca 100644 --- a/website/README.md +++ b/website/README.md @@ -24,6 +24,55 @@ Note this only builds for English locale unlike a production build. > Documentation is written in `mdx`, a superset of markdown empowered with jsx. > JetBrains and VSCode both provide MDX plugins. +## Testing + +```console +cargo make website-test +``` + +[`website-test`](../tools/website-test) is a tool to test all code blocks in the docs as Rust doctests. +It gathers the Rust code blocks automatically, but by default they're all tested separate. In case of a +walkthrough, it makes more sense to combine the changes described in the blocks & test the code as one. +For this end `website-test` scans all doc files for a special flag: + +```html + +``` +If a file ends with this specific comment (and an optional newline after it), all code blocks will be +sown together, with respect to the diff markers in them. For example: + +```md +\`\`\`rust +fn main() { + println!("Hello, World"); +} +\`\`\` + +\`\`\`rust +fn main() { +- println!("Hello, World"); ++ println!("Goodbye, World"); +} +\`\`\` + + +``` + +Will be tested as: +```rust +fn main() { + println!("Goodbye, World"); +} +``` + +:::warning +The current implementation only uses the code before the diff or the code to remove as context, +so make sure there's enough of it. The test assembler will tell you if there isn't. +::: + +While assembling the code blocks, the test assembler will put special meaning into a code +line `// ...`. This line tells the test assembler to disregard any previous context for applying a diff + ## Production Build ```console diff --git a/website/docs/concepts/function-components/properties.mdx b/website/docs/concepts/function-components/properties.mdx index 02c3f469b56..4a6c64f540b 100644 --- a/website/docs/concepts/function-components/properties.mdx +++ b/website/docs/concepts/function-components/properties.mdx @@ -318,14 +318,12 @@ Props are evaluated in the order they're specified, as shown by the following ex #[derive(yew::Properties, PartialEq)] struct Props { first: usize, second: usize, last: usize } -fn main() { - let mut g = 1..=3; - let props = yew::props!(Props { first: g.next().unwrap(), second: g.next().unwrap(), last: g.next().unwrap() }); +let mut g = 1..=3; +let props = yew::props!(Props { first: g.next().unwrap(), second: g.next().unwrap(), last: g.next().unwrap() }); - assert_eq!(props.first, 1); - assert_eq!(props.second, 2); - assert_eq!(props.last, 3); -} +assert_eq!(props.first, 1); +assert_eq!(props.second, 2); +assert_eq!(props.last, 3); ``` ## Anti Patterns diff --git a/website/docs/concepts/html/introduction.mdx b/website/docs/concepts/html/introduction.mdx index 115b9b2d25e..3506b332786 100644 --- a/website/docs/concepts/html/introduction.mdx +++ b/website/docs/concepts/html/introduction.mdx @@ -164,7 +164,7 @@ html! {
}; Properties are specified with `~` before the element name: -```rust +```rust , ignore use yew::prelude::*; html! { }; diff --git a/website/docs/tutorial/index.mdx b/website/docs/tutorial/index.mdx index 5002c48e4a9..7059dbbf987 100644 --- a/website/docs/tutorial/index.mdx +++ b/website/docs/tutorial/index.mdx @@ -185,22 +185,28 @@ We want to build a layout that looks something like this in raw HTML: Now, let's convert this HTML into `html!`. Type (or copy/paste) the following snippet into the body of `app` function such that the value of `html!` is returned by the function -```rust ,ignore -html! { - <> -

{ "RustConf Explorer" }

-
-

{"Videos to watch"}

-

{ "John Doe: Building and breaking things" }

-

{ "Jane Smith: The development process" }

-

{ "Matt Miller: The Web 7.0" }

-

{ "Tom Jerry: Mouseless development" }

-
-
-

{ "John Doe: Building and breaking things" }

- video thumbnail -
- +```rust {3-21} +#[function_component(App)] +fn app() -> Html { +- html! { +-

{ "Hello World" }

+- } ++ html! { ++ <> ++

{ "RustConf Explorer" }

++
++

{ "Videos to watch" }

++

{ "John Doe: Building and breaking things" }

++

{ "Jane Smith: The development process" }

++

{ "Matt Miller: The Web 7.0" }

++

{ "Tom Jerry: Mouseless development" }

++
++
++

{ "John Doe: Building and breaking things" }

++ video thumbnail ++
++ ++ } } ``` @@ -215,6 +221,7 @@ Now, instead of hardcoding the list of videos in the HTML, let's define them as We create a simple `struct` (in `main.rs` or any file of our choice) that will hold our data. ```rust +#[derive(Clone, PartialEq)] struct Video { id: usize, title: String, @@ -225,44 +232,49 @@ struct Video { Next, we will create instances of this struct in our `app` function and use those instead of hardcoding the data: -```rust -use website_test::tutorial::Video; // replace with your own path - -let videos = vec![ - Video { - id: 1, - title: "Building and breaking things".to_string(), - speaker: "John Doe".to_string(), - url: "https://youtu.be/PsaFVLr8t4E".to_string(), - }, - Video { - id: 2, - title: "The development process".to_string(), - speaker: "Jane Smith".to_string(), - url: "https://youtu.be/PsaFVLr8t4E".to_string(), - }, - Video { - id: 3, - title: "The Web 7.0".to_string(), - speaker: "Matt Miller".to_string(), - url: "https://youtu.be/PsaFVLr8t4E".to_string(), - }, - Video { - id: 4, - title: "Mouseless development".to_string(), - speaker: "Tom Jerry".to_string(), - url: "https://youtu.be/PsaFVLr8t4E".to_string(), - }, -]; +```rust {3-29} +#[function_component(App)] +fn app() -> Html { ++ let videos = vec![ ++ Video { ++ id: 1, ++ title: "Building and breaking things".to_string(), ++ speaker: "John Doe".to_string(), ++ url: "https://youtu.be/PsaFVLr8t4E".to_string(), ++ }, ++ Video { ++ id: 2, ++ title: "The development process".to_string(), ++ speaker: "Jane Smith".to_string(), ++ url: "https://youtu.be/PsaFVLr8t4E".to_string(), ++ }, ++ Video { ++ id: 3, ++ title: "The Web 7.0".to_string(), ++ speaker: "Matt Miller".to_string(), ++ url: "https://youtu.be/PsaFVLr8t4E".to_string(), ++ }, ++ Video { ++ id: 4, ++ title: "Mouseless development".to_string(), ++ speaker: "Tom Jerry".to_string(), ++ url: "https://youtu.be/PsaFVLr8t4E".to_string(), ++ }, ++ ]; ++ ``` To display them, we need to convert the `Vec` into `Html`. We can do that by creating an iterator, mapping it to `html!` and collecting it as `Html`: -```rust ,ignore -let videos = videos.iter().map(|video| html! { -

{format!("{}: {}", video.speaker, video.title)}

-}).collect::(); +```rust {4-7} + }, + ]; + ++ let videos = videos.iter().map(|video| html! { ++

{format!("{}: {}", video.speaker, video.title)}

++ }).collect::(); ++ ``` :::tip @@ -271,21 +283,21 @@ Keys on list items help Yew keep track of which items have changed in the list, And finally, we need to replace the hardcoded list of videos with the `Html` we created from the data: -```rust ,ignore {6-10} -html! { - <> -

{ "RustConf Explorer" }

-
-

{ "Videos to watch" }

--

{ "John Doe: Building and breaking things" }

--

{ "Jane Smith: The development process" }

--

{ "Matt Miller: The Web 7.0" }

--

{ "Tom Jerry: Mouseless development" }

-+ { videos } -
- // ... - -} +```rust {6-10} + html! { + <> +

{ "RustConf Explorer" }

+
+

{ "Videos to watch" }

+-

{ "John Doe: Building and breaking things" }

+-

{ "Jane Smith: The development process" }

+-

{ "Matt Miller: The Web 7.0" }

+-

{ "Tom Jerry: Mouseless development" }

++ { videos } +
+ // ... + + } ``` ## Components @@ -305,16 +317,7 @@ In this tutorial, we will be using function components. Now, let's split up our `App` component into smaller components. We begin by extracting the videos list into its own component. -```rust ,compile_fail -use yew::prelude::*; - -struct Video { - id: usize, - title: String, - speaker: String, - url: String, -} - +```rust #[derive(Properties, PartialEq)] struct VideosListProps { videos: Vec