diff --git a/docs-src/0.7/src/essentials/router/index.md b/docs-src/0.7/src/essentials/router/index.md index 8e96cbdb0..c84ae0c68 100644 --- a/docs-src/0.7/src/essentials/router/index.md +++ b/docs-src/0.7/src/essentials/router/index.md @@ -25,6 +25,17 @@ To create a `Routable` enum, you will need to derive the `Routable` with a `#[ro {{#include ../docs-router/src/doc_examples/router_introduction.rs:first_router}} ``` +
+Using a different component name + +By default, each variant renders a component with the same name. You can specify a different component as the second argument to `#[route]`: + +```rust +{{#include ../docs-router/src/doc_examples/route_customization.rs:custom_component}} +``` + +
+ ## Rendering the router Now that you have defined your routes, you can use the `Router` component to render them. The `Router` component takes your `Routable` enum as a generic argument to define handle parsing, and rendering routes. diff --git a/docs-src/0.7/src/essentials/router/routes.md b/docs-src/0.7/src/essentials/router/routes.md index 66325c34b..ff680c815 100644 --- a/docs-src/0.7/src/essentials/router/routes.md +++ b/docs-src/0.7/src/essentials/router/routes.md @@ -39,6 +39,21 @@ The segment can be of any type that implements `FromStr`. {{#include ../docs-router/src/doc_examples/dynamic_segments.rs:route}} ``` +
+Parsing your own dynamic segment types + +Any type that implements `FromStr` + `Display` can be used as a dynamic segment. If parsing fails, the route won't match and the router moves on to the next candidate. This lets you restrict which URLs match a route — for example, only accepting known locales: + +```rust +{{#include ../docs-router/src/doc_examples/route_customization.rs:custom_dynamic_segment}} +``` + +With this route, `/en/about` and `/fr/about` will match, but `/xyz/about` will not. + +See [`FromRouteSegment`](https://docs.rs/dioxus-router/latest/dioxus_router/routable/trait.FromRouteSegment.html) on docs.rs for the full trait definition. + +
+ ## Catch All Segments Catch All segments are in the form of `:..name` where `name` is the name of the field in the route variant. If the segments are parsed successfully then the route matches, otherwise the matching continues. @@ -51,6 +66,17 @@ Catch All segments must be the _last route segment_ in the path (query segments {{#include ../docs-router/src/doc_examples/catch_all_segments.rs:route}} ``` +
+Parsing your own catch-all segment types + +By default, `Vec` collects catch-all segments. You can implement [`FromRouteSegments`](https://docs.rs/dioxus-router/latest/dioxus_router/routable/trait.FromRouteSegments.html) and [`ToRouteSegments`](https://docs.rs/dioxus-router/latest/dioxus_router/routable/trait.ToRouteSegments.html) directly to parse the segments into a structured type and serialize them back into a URL: + +```rust +{{#include ../docs-router/src/doc_examples/route_customization.rs:custom_catch_all}} +``` + +
+ ## Query Segments Query segments are in the form of `?:name&:othername` where `name` and `othername` are the names of fields in the route variant. @@ -65,6 +91,25 @@ Query segments must be the _after all route segments_ and cannot be included in {{#include ../docs-router/src/doc_examples/query_segments.rs:route}} ``` +
+Parsing your own query parameter types + +Individual query parameters use the [`FromQueryArgument`](https://docs.rs/dioxus-router/latest/dioxus_router/routable/trait.FromQueryArgument.html) trait, which is auto-implemented for any `FromStr + Default` type. If the parameter is missing or fails to parse, `Default::default()` is used instead of failing the route. + +You can use your own types as query parameters by implementing `FromStr`, `Default`, and `Display`: + +```rust +{{#include ../docs-router/src/doc_examples/route_customization.rs:custom_single_query}} +``` + +If you need full control over the entire query string — for example, to handle dynamic keys or custom serialization — you can capture it into one type using the spread syntax `?:..field`. The type must implement [`From<&str>`](https://docs.rs/dioxus-router/latest/dioxus_router/routable/trait.FromQuery.html) and `Display`: + +```rust +{{#include ../docs-router/src/doc_examples/route_customization.rs:custom_spread_query}} +``` + +
+ ## Hash Segments Hash segments are in the form of `#:field` where `field` is a field in the route variant. @@ -79,6 +124,18 @@ Hash fragments must be the _after all route segments and any query segments_ and {{#include ../docs-router/src/doc_examples/hash_fragments.rs:route}} ``` +
+Parsing your own hash fragment types + +The [`FromHashFragment`](https://docs.rs/dioxus-router/latest/dioxus_router/routable/trait.FromHashFragment.html) trait is auto-implemented for any `FromStr + Default` type. Parsing failures return `Default::default()` instead of causing the route to fail. + +You can use a custom type to parse structured data from the hash fragment: + +```rust +{{#include ../docs-router/src/doc_examples/route_customization.rs:custom_hash}} +``` + +
## Nested Routes diff --git a/packages/docs-router/src/doc_examples/route_customization.rs b/packages/docs-router/src/doc_examples/route_customization.rs new file mode 100644 index 000000000..e9b529e17 --- /dev/null +++ b/packages/docs-router/src/doc_examples/route_customization.rs @@ -0,0 +1,311 @@ +#![allow(non_snake_case, unused)] +use dioxus::prelude::*; + +// ANCHOR: custom_component +#[derive(Routable, Clone)] +#[rustfmt::skip] +enum Route { + // By default, the component rendered for a route matches the variant name. + // You can specify a different component as the second argument: + #[route("/", HomePage)] + Index {}, +} + +// This component will be rendered for the Index route +#[component] +fn HomePage() -> Element { + rsx! { "Welcome home!" } +} +// ANCHOR_END: custom_component + +fn main() {} + +mod custom_segment { + use super::*; + use std::fmt; + use std::str::FromStr; + + // ANCHOR: custom_dynamic_segment + /// A locale like "en", "fr", or "es" parsed from a URL segment. + #[derive(Clone, PartialEq, Debug)] + struct Locale { + language: String, + } + + /// Display is required so the router can serialize the type back into a URL. + impl fmt::Display for Locale { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.language) + } + } + + /// Any type that implements FromStr can be used as a dynamic segment. + /// If parsing fails, the route won't match and the router moves on. + impl FromStr for Locale { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "en" | "fr" | "es" | "de" | "ja" => Ok(Locale { + language: s.to_string(), + }), + other => Err(format!("Unknown locale: {other}")), + } + } + } + + #[derive(Routable, Clone)] + #[rustfmt::skip] + enum Route { + // With this route, /en/about and /fr/about will match, + // but /xyz/about will not. + #[route("/:locale/about")] + About { locale: Locale }, + } + + #[component] + fn About(locale: Locale) -> Element { + rsx! { "Viewing the about page in {locale}" } + } + // ANCHOR_END: custom_dynamic_segment +} + +mod custom_catch_all { + use super::*; + use std::fmt; + + // ANCHOR: custom_catch_all + /// A path like /docs/en/guide/intro parsed into structured data. + #[derive(Clone, PartialEq, Debug)] + struct DocPath { + locale: String, + sections: Vec, + } + + impl fmt::Display for DocPath { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.locale)?; + for section in &self.sections { + write!(f, "/{section}")?; + } + Ok(()) + } + } + + /// For custom catch-all types, implement both FromRouteSegments (for parsing + /// URLs into your type) and ToRouteSegments (for serializing back to a URL). + impl dioxus::router::routable::FromRouteSegments for DocPath { + type Err = String; + + fn from_route_segments(segments: &[&str]) -> Result { + let mut iter = segments.iter(); + let locale = iter + .next() + .ok_or("Missing locale segment")? + .to_string(); + let sections = iter.map(|s| s.to_string()).collect(); + Ok(DocPath { locale, sections }) + } + } + + impl dioxus::router::routable::ToRouteSegments for DocPath { + fn display_route_segments( + &self, + f: &mut std::fmt::Formatter<'_>, + ) -> std::fmt::Result { + write!(f, "/{}", self.locale)?; + for section in &self.sections { + write!(f, "/{section}")?; + } + Ok(()) + } + } + + #[derive(Routable, Clone)] + #[rustfmt::skip] + enum Route { + #[route("/docs/:..path")] + Docs { path: DocPath }, + } + + #[component] + fn Docs(path: DocPath) -> Element { + rsx! { + div { "Locale: {path.locale}" } + div { "Sections: {path.sections:?}" } + } + } + // ANCHOR_END: custom_catch_all +} + +mod custom_single_query { + use super::*; + use std::fmt; + use std::str::FromStr; + + // ANCHOR: custom_single_query + /// A sort order parsed from a query parameter like ?sort=asc or ?sort=desc. + #[derive(Clone, Default, PartialEq, Debug)] + enum SortOrder { + #[default] + Asc, + Desc, + } + + impl fmt::Display for SortOrder { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SortOrder::Asc => write!(f, "asc"), + SortOrder::Desc => write!(f, "desc"), + } + } + } + + /// Any type that implements FromStr + Default can be used as a query parameter. + /// If the parameter is missing or fails to parse, Default::default() is used. + impl FromStr for SortOrder { + type Err = String; + + fn from_str(s: &str) -> Result { + match s { + "asc" => Ok(SortOrder::Asc), + "desc" => Ok(SortOrder::Desc), + other => Err(format!("Unknown sort order: {other}")), + } + } + } + + #[derive(Routable, Clone)] + #[rustfmt::skip] + enum Route { + #[route("/search?:query&:sort")] + Search { + query: String, + sort: SortOrder, + }, + } + + #[component] + fn Search(query: String, sort: SortOrder) -> Element { + rsx! { + div { "Searching for: {query}" } + div { "Sort order: {sort}" } + } + } + // ANCHOR_END: custom_single_query +} + +mod custom_spread_query { + use super::*; + use std::fmt; + + // ANCHOR: custom_spread_query + /// A custom type that parses the entire query string at once. + /// This is useful when you need full control over query parameter handling. + #[derive(Clone, Default, PartialEq, Debug)] + struct SearchParams { + query: String, + page: usize, + sort: String, + } + + impl fmt::Display for SearchParams { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "query={}&page={}&sort={}", + self.query, self.page, self.sort + ) + } + } + + /// Implementing From<&str> gives you FromQuery automatically. + impl From<&str> for SearchParams { + fn from(query: &str) -> Self { + let mut params = SearchParams::default(); + for pair in query.split('&') { + if let Some((key, value)) = pair.split_once('=') { + match key { + "query" => params.query = value.to_string(), + "page" => params.page = value.parse().unwrap_or(0), + "sort" => params.sort = value.to_string(), + _ => {} + } + } + } + params + } + } + + #[derive(Routable, Clone)] + #[rustfmt::skip] + enum Route { + // Use ?:..field to capture the entire query string into a single type. + #[route("/search?:..params")] + Search { params: SearchParams }, + } + + #[component] + fn Search(params: SearchParams) -> Element { + rsx! { + div { "Query: {params.query}" } + div { "Page: {params.page}" } + div { "Sort: {params.sort}" } + } + } + // ANCHOR_END: custom_spread_query +} + +mod custom_hash { + use super::*; + use std::str::FromStr; + + // ANCHOR: custom_hash + /// A section anchor parsed from a hash fragment like #section-intro. + #[derive(Clone, Default, PartialEq, Debug)] + struct SectionAnchor { + section: String, + subsection: String, + } + + impl std::fmt::Display for SectionAnchor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}-{}", self.section, self.subsection) + } + } + + /// Any type that implements FromStr + Default gets FromHashFragment + /// automatically. Parsing failures return Default::default(). + impl FromStr for SectionAnchor { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.split_once('-') { + Some((section, sub)) => Ok(SectionAnchor { + section: section.to_string(), + subsection: sub.to_string(), + }), + None => Ok(SectionAnchor { + section: s.to_string(), + subsection: String::new(), + }), + } + } + } + + #[derive(Routable, Clone)] + #[rustfmt::skip] + enum Route { + #[route("/page#:anchor")] + Page { anchor: SectionAnchor }, + } + + #[component] + fn Page(anchor: SectionAnchor) -> Element { + rsx! { + div { "Section: {anchor.section}" } + div { "Subsection: {anchor.subsection}" } + } + } + // ANCHOR_END: custom_hash +} diff --git a/packages/docsite/assets/githubmarkdown.css b/packages/docsite/assets/githubmarkdown.css index d5c293882..4f0fa611b 100644 --- a/packages/docsite/assets/githubmarkdown.css +++ b/packages/docsite/assets/githubmarkdown.css @@ -336,8 +336,24 @@ padding: 0; } +.markdown-body details { + border-left: 2px solid var(--color-border-default); + padding: 0.25em 1em; + transition: border-color 0.15s ease; +} + +.markdown-body details:hover { + border-left-color: var(--color-fg-muted); +} + .markdown-body details summary { cursor: pointer; + font-weight: 500; + padding: 0.25em 0; +} + +.markdown-body details[open] summary { + margin-bottom: 0.5em; } .markdown-body details:not([open]) > :not(summary) { diff --git a/packages/docsite/assets/tailwind.css b/packages/docsite/assets/tailwind.css index a702ad651..70a024fbc 100644 --- a/packages/docsite/assets/tailwind.css +++ b/packages/docsite/assets/tailwind.css @@ -316,6 +316,12 @@ .row-span-3 { grid-row: span 3 / span 3; } + .float-left { + float: left; + } + .float-right { + float: right; + } .container { width: 100%; @media (width >= 40rem) { @@ -448,6 +454,9 @@ .inline-flex { display: inline-flex; } + .table { + display: table; + } .h-2 { height: calc(var(--spacing) * 2); } @@ -622,6 +631,9 @@ .basis-0 { flex-basis: calc(var(--spacing) * 0); } + .border-collapse { + border-collapse: collapse; + } .translate-x-px { --tw-translate-x: 1px; translate: var(--tw-translate-x) var(--tw-translate-y); @@ -1288,6 +1300,9 @@ .uppercase { text-transform: uppercase; } + .underline { + text-decoration-line: underline; + } .placeholder-gray-400 { &::placeholder { color: var(--color-gray-400); @@ -1311,10 +1326,17 @@ --tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); } + .outline { + outline-style: var(--tw-outline-style); + outline-width: 1px; + } .invert { --tw-invert: invert(100%); filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } + .filter { + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } .backdrop-blur-sm { --tw-backdrop-blur: blur(var(--blur-sm)); -webkit-backdrop-filter: var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,); @@ -2309,6 +2331,11 @@ inherits: false; initial-value: 0 0 #0000; } +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} @property --tw-blur { syntax: "*"; inherits: false; @@ -2446,6 +2473,7 @@ --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; + --tw-outline-style: solid; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; diff --git a/packages/include_mdbook/packages/mdbook-gen/src/rsx.rs b/packages/include_mdbook/packages/mdbook-gen/src/rsx.rs index 5cd61a46d..916fba72d 100644 --- a/packages/include_mdbook/packages/mdbook-gen/src/rsx.rs +++ b/packages/include_mdbook/packages/mdbook-gen/src/rsx.rs @@ -158,7 +158,13 @@ impl<'a, I: Iterator>> RsxMarkdownParser<'a, I> { pulldown_cmark::Event::Start(start) => { self.start_element(start)?; } - pulldown_cmark::Event::End(_) => self.end_node(), + pulldown_cmark::Event::End(tag_end) => { + // HtmlBlock Start doesn't push a node, so don't pop for its End either. + // Our
handling pushes via Html events instead. + if !matches!(tag_end, pulldown_cmark::TagEnd::HtmlBlock) { + self.end_node(); + } + } pulldown_cmark::Event::Text(text) => { let text = escape_text(&text); self.create_node(BodyNode::Text(parse_quote!(#text))); @@ -172,13 +178,28 @@ impl<'a, I: Iterator>> RsxMarkdownParser<'a, I> { }) } pulldown_cmark::Event::Html(node) | pulldown_cmark::Event::InlineHtml(node) => { - let code = escape_text(&node); - self.create_node(parse_quote! { - p { - class: "inline-html-block", - dangerous_inner_html: #code, - } - }) + let trimmed = node.trim(); + if trimmed == "
" { + self.start_node(parse_quote! { + details {} + }); + } else if trimmed == "
" { + self.end_node(); + } else if trimmed.starts_with("") && trimmed.ends_with("") { + let inner = &trimmed["".len()..trimmed.len() - "".len()]; + let inner = escape_text(inner); + self.create_node(parse_quote! { + summary { #inner } + }); + } else { + let code = escape_text(&node); + self.create_node(parse_quote! { + p { + class: "inline-html-block", + dangerous_inner_html: #code, + } + }) + } } pulldown_cmark::Event::FootnoteReference(_) => {} pulldown_cmark::Event::SoftBreak => {}