1#[cfg(feature = "server")]
5use std::sync::LazyLock;
6#[cfg(not(feature = "server"))]
7use std::sync::OnceLock;
8
9use leptos::ev::{KeyboardEvent, MouseEvent};
10use leptos::prelude::*;
11use leptos_meta::Title;
12use leptos_router::hooks::use_params_map;
13use leptos_router::{lazy_route, LazyRoute};
14use serde::{Deserialize, Serialize};
15
16use crate::api::error::ApiError;
17use crate::components::forms::FormInput;
18use crate::components::ui::{
19 button_class, Button, ButtonSize, ButtonVariant, Icon, IconName, SegmentedControl,
20 SegmentedItem,
21};
22use crate::context::ShowcaseFilters;
23use crate::hooks::ssr_resource::{route_data, PageJson, RouteData};
24use crate::pages::not_found::PageLoadFailed;
25use crate::routes::Route;
26
27mod browser_previews;
28mod business_previews;
29mod comment_previews;
30mod layout_article_previews;
31mod live_previews;
32
33#[allow(unused_imports)]
35use live_previews::sample_post;
36
37#[derive(Clone, Serialize, Deserialize)]
38pub(crate) struct ShowcaseData {
39 groups: Vec<[String; 2]>,
40 components: Vec<ComponentSpec>,
41}
42
43#[derive(Clone, Serialize, Deserialize)]
44struct ComponentSpec {
45 name: String,
46 slug: String,
47 label: String,
48 group: String,
49 summary: String,
50 states: String,
51 description: String,
52 props: Vec<[String; 4]>,
53 keys: Vec<[String; 2]>,
54 source: String,
55 code: String,
56 preview_mode: PreviewMode,
57 usage_kind: UsageKind,
58}
59
60#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
61#[serde(rename_all = "snake_case")]
62enum PreviewMode {
63 Interactive,
64 Static,
65}
66
67#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
68#[serde(rename_all = "snake_case")]
69enum UsageKind {
70 Rust,
71 Api,
72 Router,
73 Browser,
74}
75
76#[cfg(feature = "server")]
78static SHOWCASE: LazyLock<ShowcaseData> = LazyLock::new(|| {
79 serde_json::from_str(include_str!("components_showcase_data.json"))
80 .expect("组件图鉴数据必须是有效 JSON")
81});
82
83#[cfg(not(feature = "server"))]
84static CLIENT_SHOWCASE: OnceLock<ShowcaseData> = OnceLock::new();
85
86#[cfg(feature = "server")]
88fn showcase() -> &'static ShowcaseData {
89 &SHOWCASE
90}
91
92#[cfg(not(feature = "server"))]
94fn showcase() -> &'static ShowcaseData {
95 CLIENT_SHOWCASE
96 .get()
97 .expect("组件图鉴数据须先由 ShowcaseDataGate 加载")
98}
99
100#[server(endpoint = "get_showcase_data", input = leptos::server_fn::codec::Json)]
101pub(crate) async fn get_showcase_data() -> Result<ShowcaseData, ApiError> {
102 Ok(SHOWCASE.clone())
103}
104
105#[leptos::lazy]
108async fn fetch_showcase() -> PageJson<ShowcaseData> {
109 #[cfg(not(feature = "server"))]
110 if let Some(data) = CLIENT_SHOWCASE.get() {
111 return PageJson::new(Ok(data.clone()));
112 }
113 PageJson::new(get_showcase_data().await)
114}
115
116#[component]
118fn ShowcaseDataGate(data: RouteData<ShowcaseData>, children: ChildrenFn) -> impl IntoView {
119 view! {
120 <Suspense fallback=|| ()>
121 {move || {
122 let children = children.clone();
123 Suspend::new(async move {
124 match data.await {
125 Ok(_data) => {
126 #[cfg(not(feature = "server"))]
127 if CLIENT_SHOWCASE.get().is_none() {
128 let _ = CLIENT_SHOWCASE.set(_data);
129 }
130 children().into_any()
131 }
132 Err(_) => view! { <PageLoadFailed/> }.into_any(),
133 }
134 })
135 }}
136 </Suspense>
137 }
138}
139
140pub struct ShowcaseRoute {
142 data: RouteData<ShowcaseData>,
143}
144
145#[lazy_route]
146impl LazyRoute for ShowcaseRoute {
147 fn data() -> Self {
148 crate::static_assets::ensure_admin_css();
149 Self {
150 data: route_data(|| (), |_| fetch_showcase()),
151 }
152 }
153
154 fn view(this: Self) -> AnyView {
155 view! {
156 <ShowcaseDataGate data=this.data>
157 <ShowcaseGallery/>
158 </ShowcaseDataGate>
159 }
160 .into_any()
161 }
162}
163
164pub struct ShowcaseDetailRoute {
166 data: RouteData<ShowcaseData>,
167}
168
169#[lazy_route]
170impl LazyRoute for ShowcaseDetailRoute {
171 fn data() -> Self {
172 crate::static_assets::ensure_admin_css();
173 Self {
174 data: route_data(|| (), |_| fetch_showcase()),
175 }
176 }
177
178 fn view(this: Self) -> AnyView {
179 let params = use_params_map();
180 view! {
181 <ShowcaseDataGate data=this.data>
182 {move || {
184 let component = params.read().get("component").unwrap_or_default();
185 view! { <ShowcaseDetail component=component/> }
186 }}
187 </ShowcaseDataGate>
188 }
189 .into_any()
190 }
191}
192
193fn group_label(group: &str) -> &str {
194 showcase()
195 .groups
196 .iter()
197 .find(|item| item[0] == group)
198 .map(|item| item[1].as_str())
199 .unwrap_or("全部组件")
200}
201
202struct CodeToken<'a> {
203 class: Option<&'static str>,
204 text: &'a str,
205}
206
207fn highlight_snippet(code: &str) -> Vec<CodeToken<'_>> {
208 const KEYWORDS: &str = concat!(
209 "as async await const crate else enum export fn for from if impl import in ",
210 "interface let match mod move mut new pub return self static struct super trait ",
211 "type use where while",
212 );
213 let bytes = code.as_bytes();
214 let mut tokens = Vec::new();
215 let mut position = 0;
216 let mut plain_start = 0;
217
218 while position < bytes.len() {
219 let start = position;
220 let class = if bytes[position..].starts_with(b"//") {
221 position = bytes[position..]
222 .iter()
223 .position(|byte| *byte == b'\n')
224 .map_or(bytes.len(), |offset| position + offset);
225 Some("comment")
226 } else if bytes[position..].starts_with(b"/*") {
227 position = bytes[position + 2..]
228 .windows(2)
229 .position(|pair| pair == b"*/")
230 .map_or(bytes.len(), |offset| position + offset + 4);
231 Some("comment")
232 } else if matches!(bytes[position], b'"' | b'`') {
233 let quote = bytes[position];
234 position += 1;
235 while position < bytes.len() {
236 if bytes[position] == b'\\' {
237 position = (position + 2).min(bytes.len());
238 } else if bytes[position] == quote {
239 position += 1;
240 break;
241 } else {
242 position += 1;
243 }
244 }
245 Some("string")
246 } else if bytes[position].is_ascii_digit() {
247 position += 1;
248 while position < bytes.len()
249 && (bytes[position].is_ascii_alphanumeric() || bytes[position] == b'_')
250 {
251 position += 1;
252 }
253 Some("constant numeric")
254 } else if bytes[position].is_ascii_alphabetic() || bytes[position] == b'_' {
255 let raw_identifier = bytes[position..].starts_with(b"r#");
256 position += if raw_identifier { 2 } else { 1 };
257 while position < bytes.len()
258 && (bytes[position].is_ascii_alphanumeric() || bytes[position] == b'_')
259 {
260 position += 1;
261 }
262 let word = &code[start..position];
263 let next = bytes.get(position).copied();
264 if matches!(word, "true" | "false") {
265 Some("constant language boolean")
266 } else if !raw_identifier && KEYWORDS.split_ascii_whitespace().any(|item| item == word)
267 {
268 Some("keyword")
269 } else if next == Some(b':') && bytes.get(position + 1) != Some(&b':') {
270 Some("meta property object")
271 } else if word.starts_with(|character: char| character.is_ascii_uppercase()) {
272 Some("entity name class")
273 } else if matches!(next, Some(b'(' | b'!')) {
274 Some("entity name function")
275 } else if next == Some(b'{') {
276 Some("keyword")
277 } else {
278 None
279 }
280 } else {
281 position += code[position..].chars().next().unwrap().len_utf8();
282 None
283 };
284
285 if let Some(class) = class {
286 if plain_start < start {
287 tokens.push(CodeToken {
288 class: None,
289 text: &code[plain_start..start],
290 });
291 }
292 tokens.push(CodeToken {
293 class: Some(class),
294 text: &code[start..position],
295 });
296 plain_start = position;
297 }
298 }
299
300 if plain_start < code.len() {
301 tokens.push(CodeToken {
302 class: None,
303 text: &code[plain_start..],
304 });
305 }
306 tokens
307}
308
309fn checkbox_snippet(checked: bool, danger: bool) -> String {
310 format!(
311 "let checked = RwSignal::new({checked});\n\nview! {{\n <label>\n <Checkbox\n checked=checked\n onchange=move |value: bool| checked.set(value){}\n />\n \"记住这个小小的选择\"\n </label>\n}}",
312 if danger { "\n danger=true" } else { "" },
313 )
314}
315
316fn alert_box_preset_message(variant: &str) -> &'static str {
317 match variant {
318 "error" => "保存失败,网络连接似乎中断了。",
319 "default" => "草稿已自动保存至本地缓存。",
320 _ => "已保存,新的想法正在生长。",
321 }
322}
323
324fn alert_box_next_variant(variant: &str) -> &'static str {
325 match variant {
326 "success" => "error",
327 "error" => "default",
328 _ => "success",
329 }
330}
331
332fn alert_box_snippet(variant: &str, message: &str) -> String {
333 let escaped_message = message.replace('\\', "\\\\").replace('"', "\\\"");
334 format!(
335 "view! {{\n <AlertBox\n message=\"{escaped_message}\"\n variant=\"{variant}\"\n />\n}}"
336 )
337}
338
339fn highlighted_view(code: &str) -> impl IntoView {
341 highlight_snippet(code)
342 .into_iter()
343 .map(
344 |token| view! { <span class=token.class.unwrap_or("")>{token.text.to_string()}</span> },
345 )
346 .collect_view()
347}
348
349#[component]
350fn AlertBoxDemo(
351 detail: bool,
352 alert_variant: RwSignal<String>,
353 alert_message: RwSignal<String>,
354) -> impl IntoView {
355 let variant_str = Signal::derive(move || match alert_variant.read().as_str() {
356 "error" => "error",
357 "default" => "default",
358 _ => "success",
359 });
360 let cycle = move || {
361 let next = alert_box_next_variant(&alert_variant.read_untracked());
362 alert_variant.set(next.to_string());
363 alert_message.set(alert_box_preset_message(next).to_string());
364 };
365
366 view! {
367 <div class="showcase-alert-demo">
368 {(!detail)
370 .then(|| {
371 view! {
372 <SegmentedControl
373 items=vec![
374 SegmentedItem::new("success".to_string(), "成功"),
375 SegmentedItem::new("error".to_string(), "错误"),
376 SegmentedItem::new("default".to_string(), "普通"),
377 ]
378 value=Signal::derive(move || variant_str.get().to_string())
379 aria_label="切换提示状态"
380 on_change=move |v: String| {
381 alert_message.set(alert_box_preset_message(&v).to_string());
382 alert_variant.set(v);
383 }
384 />
385 }
386 })}
387 <div
388 class="showcase-alert-box-target"
389 role="button"
390 tabindex="0"
391 title="点击循环切换状态"
392 aria-label="点击循环切换状态"
393 on:click=move |_| cycle()
394 on:keydown=move |event: KeyboardEvent| {
395 if event.key() == "Enter" {
396 cycle();
397 }
398 }
399 >
400 <crate::components::forms::AlertBox message=alert_message variant=variant_str/>
401 </div>
402 </div>
403 }
404}
405
406fn inspector_visible() -> bool {
408 #[cfg(target_arch = "wasm32")]
409 {
410 web_sys::window()
411 .and_then(|window| window.match_media("(min-width: 1101px)").ok().flatten())
412 .is_some_and(|query| query.matches())
413 }
414 #[cfg(not(target_arch = "wasm32"))]
415 false
416}
417
418fn click_hits_interactive(event: &MouseEvent) -> bool {
420 #[cfg(target_arch = "wasm32")]
421 {
422 use wasm_bindgen::JsCast;
423 event
424 .target()
425 .and_then(|target| target.dyn_into::<web_sys::Element>().ok())
426 .and_then(|element| {
427 element
428 .closest(
429 "a, button, input, select, textarea, label, summary, [contenteditable], [role=button], [role=tab], .cm-editor, .xterm",
430 )
431 .ok()
432 .flatten()
433 })
434 .is_some()
435 }
436 #[cfg(not(target_arch = "wasm32"))]
437 {
438 let _ = event;
439 false
440 }
441}
442
443const MASONRY_PRELOAD_SCRIPT: &str = r#"
448(function() {
449 var script = document.currentScript;
450 var catalog = script && script.parentNode;
451 if (!catalog || !window.MutationObserver) return;
452 var laid = 0;
453 function layout() {
454 var grid = script.nextElementSibling;
455 if (!grid || !window.matchMedia('(min-width: 901px)').matches) return;
456 grid.classList.add('is-masonry');
457 var parsed = document.readyState !== 'loading';
458 var row = parseFloat(getComputedStyle(grid).gridAutoRows);
459 var cards = grid.children;
460 for (; laid < cards.length; laid++) {
461 var card = cards[laid];
462 if (!parsed && !card.nextElementSibling) break;
463 if (!card.classList.contains('showcase-card')) continue;
464 var height = card.getBoundingClientRect().height + parseFloat(getComputedStyle(card).marginBottom);
465 card.style.gridRowEnd = 'span ' + Math.ceil(height / row);
466 }
467 }
468 if (document.readyState !== 'loading') return layout();
469 var observer = new MutationObserver(layout);
470 observer.observe(catalog, { childList: true, subtree: true });
471 document.addEventListener('DOMContentLoaded', function() {
472 observer.disconnect();
473 layout();
474 }, { once: true });
475})();
476"#;
477
478#[component]
479fn ShowcaseGallery() -> impl IntoView {
480 let filters = expect_context::<RwSignal<ShowcaseFilters>>();
481
482 Effect::new(move |_| {
483 #[cfg(target_arch = "wasm32")]
484 crate::bridges::globals::init_showcase_masonry();
485 });
486 on_cleanup(|| {
487 #[cfg(target_arch = "wasm32")]
488 crate::bridges::globals::dispose_showcase_masonry();
489 });
490
491 let active_group = Memo::new(move |_| {
492 let category = filters.read().category.clone();
493 if category.is_empty() {
494 "all".to_string()
495 } else {
496 category
497 }
498 });
499 let shown = Memo::new(move |_| {
500 let group = active_group.get();
501 let query = filters.read().search.trim().to_lowercase();
502 showcase()
503 .components
504 .iter()
505 .filter(|spec| {
506 (group == "all" || spec.group == group)
507 && (query.is_empty()
508 || spec.name.to_lowercase().contains(&query)
509 || spec.label.to_lowercase().contains(&query)
510 || spec.summary.to_lowercase().contains(&query))
511 })
512 .map(|spec| spec.slug.clone())
513 .collect::<Vec<_>>()
514 });
515 let total = showcase().components.len();
516 let interactive_count = showcase()
517 .components
518 .iter()
519 .filter(|spec| spec.preview_mode == PreviewMode::Interactive)
520 .count();
521 let selected = Memo::new(move |_| {
523 let shown = shown.get();
524 let wanted = filters.read().selected.clone();
525 wanted
526 .filter(|slug| shown.contains(slug))
527 .or_else(|| shown.first().cloned())
528 });
529
530 Effect::new(move |_| {
531 #[cfg(target_arch = "wasm32")]
532 {
533 let return_to = filters.read().return_to.clone();
534 if let Some(slug) = return_to {
535 if let Some(target) = web_sys::window()
536 .and_then(|window| window.document())
537 .and_then(|document| document.get_element_by_id(&format!("showcase-{slug}")))
538 {
539 target.scroll_into_view();
540 filters.update(|state| state.return_to = None);
541 }
542 }
543 }
544 });
545
546 let categories = showcase()
547 .groups
548 .iter()
549 .map(|group| {
550 let id = group[0].clone();
551 let amount = if id == "all" {
552 total
553 } else {
554 showcase()
555 .components
556 .iter()
557 .filter(|spec| spec.group == id)
558 .count()
559 };
560 let label = if id == "all" {
561 "全部".to_string()
562 } else {
563 group[1].clone()
564 };
565 let pressed = {
566 let id = id.clone();
567 Memo::new(move |_| active_group.get() == id)
568 };
569 view! {
570 <button
571 type="button"
572 class=move || if pressed.get() { "chip is-active" } else { "chip" }
573 aria-pressed=move || pressed.get().to_string()
574 on:click=move |_| filters.update(|state| state.category = id.clone())
575 >
576 {label}
577 <span class="chip-count">{amount}</span>
578 </button>
579 }
580 })
581 .collect_view();
582
583 view! {
584 <Title text="组件图鉴 · Yggdrasil"/>
585 <crate::components::enhance_assets::EnhanceScript/>
588 <crate::components::enhance_assets::LightboxAssets/>
589 <article class="showcase showcase-gallery">
590 <a class="showcase-up" href=Route::About.to_string()>
591 <Icon name=IconName::ArrowLeft size=14/>
592 "关于"
593 </a>
594 <header class="showcase-header page-enter">
595 <div>
596 <h1 class="showcase-title">"组件图鉴"</h1>
597 <p class="showcase-intro tabular-nums">
598 {format!("{total} 个组件,都来自正式页面,其中 {interactive_count} 个可以直接操作。")}
599 </p>
600 </div>
601 <label class="showcase-search">
602 <Icon name=IconName::Search size=15/>
603 <FormInput
604 class="showcase-search-input"
605 r#type="search"
606 aria_label="搜索组件"
607 placeholder="组件名或用途"
608 value=Signal::derive(move || filters.read().search.clone())
609 oninput=move |value: String| filters.update(|state| state.search = value)
610 />
611 </label>
612 </header>
613 <nav class="showcase-categories page-enter" style="--enter-index: 1" aria-label="组件分类">
614 {categories}
615 </nav>
616 <div class="showcase-catalog page-enter" style="--enter-index: 2">
617 <script inner_html=MASONRY_PRELOAD_SCRIPT></script>
618 <div class="showcase-grid">
619 <For
620 each=move || shown.get()
621 key=|slug| slug.clone()
622 children=move |slug| {
623 let spec = showcase()
624 .components
625 .iter()
626 .find(|item| item.slug == slug)
627 .expect("目录卡片必须对应图鉴条目");
628 let card_slug = slug.clone();
629 let is_selected = {
630 let slug = slug.clone();
631 move || selected.read().as_deref() == Some(slug.as_str())
632 };
633 view! {
634 <article
635 class=move || {
636 if is_selected() {
637 "showcase-card is-selected"
638 } else {
639 "showcase-card"
640 }
641 }
642 id=format!("showcase-{slug}")
643 on:click=move |event: MouseEvent| {
644 if click_hits_interactive(&event) {
645 return;
646 }
647 if inspector_visible() {
648 filters
649 .update(|state| state.selected = Some(card_slug.clone()));
650 } else {
651 crate::navigation::push(
652 &Route::ComponentDetail {
653 component: card_slug.clone(),
654 }
655 .to_string(),
656 );
657 }
658 }
659 >
660 <div class=format!(
661 "showcase-card-preview showcase-card-preview--{slug}",
662 )>
663 <ComponentPreview slug=slug.clone() detail=false/>
664 </div>
665 <div class="showcase-card-caption">
666 <div class="showcase-card-title">
667 <h3>
668 <a href=Route::ComponentDetail {
669 component: slug.clone(),
670 }
671 .to_string()>{spec.label.clone()}</a>
672 </h3>
673 <code>{spec.name.clone()}</code>
674 </div>
675 <p>{spec.summary.clone()}</p>
676 </div>
677 </article>
678 }
679 }
680 />
681 {move || {
682 shown
683 .read()
684 .is_empty()
685 .then(|| {
686 view! {
687 <p class="showcase-no-results">
688 "没有找到这个组件,换个名字或用途试试。"
689 </p>
690 }
691 })
692 }}
693 </div>
694 {move || selected.get().map(|slug| view! { <ShowcaseInspector slug=slug/> })}
695 </div>
696 </article>
697 }
698}
699
700#[component]
702fn ShowcaseInspector(slug: String) -> impl IntoView {
703 let spec = showcase()
704 .components
705 .iter()
706 .find(|item| item.slug == slug)
707 .expect("详情栏必须对应图鉴条目");
708
709 let props = (!spec.props.is_empty()).then(|| {
710 let rows = spec
711 .props
712 .iter()
713 .map(|prop| {
714 view! {
715 <div>
716 <dt>
717 <code>{prop[0].clone()}</code>
718 <span>{prop[1].clone()}</span>
719 </dt>
720 <dd>{prop[3].clone()}</dd>
721 </div>
722 }
723 })
724 .collect_view();
725 view! {
726 <section class="showcase-inspector-section">
727 <h3>"属性"</h3>
728 <dl class="showcase-inspector-props">{rows}</dl>
729 </section>
730 }
731 });
732
733 view! {
734 <aside class="showcase-inspector" aria-label="组件详情">
735 <div class="showcase-inspector-head">
736 <div class="showcase-inspector-title">
737 <h2>{spec.label.clone()}</h2>
738 <code>{spec.name.clone()}</code>
739 </div>
740 <p>{spec.description.clone()}</p>
741 <p class="showcase-inspector-states">{format!("状态:{}", spec.states)}</p>
742 </div>
743 {props}
744 <section class="showcase-inspector-section">
745 <h3>"用法"</h3>
746 <div class="showcase-code-panel">
747 <pre>
748 <code>{highlighted_view(&spec.code)}</code>
749 </pre>
750 </div>
751 <p class="showcase-inspector-source">{spec.source.clone()}</p>
752 </section>
753 <div class="showcase-inspector-foot">
754 <a
755 class=button_class(ButtonVariant::Secondary, ButtonSize::Sm)
756 href=Route::ComponentDetail {
757 component: spec.slug.clone(),
758 }
759 .to_string()
760 >
761 "打开详情页"
762 <Icon name=IconName::ArrowRight size=14/>
763 </a>
764 </div>
765 </aside>
766 }
767}
768
769const DETAIL_SECTIONS: [(&str, &str); 4] = [
771 ("preview", "预览"),
772 ("props", "属性"),
773 ("keys", "键盘"),
774 ("usage", "用法"),
775];
776
777fn checkbox_state(checked: bool, danger: bool) -> &'static str {
778 if danger {
779 "danger"
780 } else if checked {
781 "checked"
782 } else {
783 "default"
784 }
785}
786
787#[cfg(target_arch = "wasm32")]
789fn update_active_section(active_section: RwSignal<&'static str>) {
790 let Some(window) = web_sys::window() else {
791 return;
792 };
793 let Some(document) = window.document() else {
794 return;
795 };
796 let present: Vec<(&'static str, f64)> = DETAIL_SECTIONS
797 .iter()
798 .filter_map(|(id, _)| {
799 document
800 .get_element_by_id(id)
801 .map(|element| (*id, element.get_bounding_client_rect().top()))
802 })
803 .collect();
804 let viewport = window
805 .inner_height()
806 .ok()
807 .and_then(|value| value.as_f64())
808 .unwrap_or(0.0);
809 let scrolled = window.scroll_y().unwrap_or(0.0);
810 let height = document
811 .document_element()
812 .map(|root| f64::from(root.scroll_height()))
813 .unwrap_or(0.0);
814 let current = if scrolled > 0.0 && viewport + scrolled >= height - 2.0 {
815 present.last().map(|(id, _)| *id)
816 } else {
817 present
818 .iter()
819 .rev()
820 .find(|(_, top)| *top <= 120.0)
821 .map(|(id, _)| *id)
822 }
823 .unwrap_or("preview");
824 if active_section.try_get_untracked() != Some(current) {
825 active_section.try_set(current);
826 }
827}
828
829#[component]
830fn ShowcaseDetail(component: String) -> impl IntoView {
831 let filters = expect_context::<RwSignal<ShowcaseFilters>>();
832 let checked = RwSignal::new(false);
833 let danger = RwSignal::new(false);
834 let alert_variant = RwSignal::new("success".to_string());
835 let alert_message = RwSignal::new(alert_box_preset_message("success").to_string());
836 let reset_generation = RwSignal::new(0_u32);
837 let copied = RwSignal::new(false);
838 let active_section = RwSignal::new("preview");
839
840 #[cfg(target_arch = "wasm32")]
841 crate::hooks::event_listener::use_event_listener(web_sys::window, "scroll", move || {
842 update_active_section(active_section)
843 });
844
845 let Some((index, spec)) = showcase()
846 .components
847 .iter()
848 .enumerate()
849 .find(|(_, spec)| spec.slug == component)
850 else {
851 crate::request_ctx::set_status(404);
852 return view! {
853 <Title text="组件未找到 · Yggdrasil"/>
854 <div class="showcase showcase-missing">
855 <h1>"图鉴里没有这个组件"</h1>
856 <p>"地址可能写错了,或者组件已经改名。"</p>
857 <a
858 class=button_class(ButtonVariant::Secondary, ButtonSize::Md)
859 href=Route::ComponentShowcase.to_string()
860 >
861 "返回组件图鉴"
862 </a>
863 </div>
864 }
865 .into_any();
866 };
867 let is_checkbox = spec.name == "Checkbox";
868 let is_alert = spec.name == "AlertBox";
869 let snippet = {
870 let code = spec.code.clone();
871 move || {
872 if is_checkbox {
873 checkbox_snippet(checked.get(), danger.get())
874 } else if is_alert {
875 alert_box_snippet(&alert_variant.read(), &alert_message.read())
876 } else {
877 code.clone()
878 }
879 }
880 };
881 let interactive = spec.preview_mode == PreviewMode::Interactive;
882 let stage_class = match (interactive, is_wide_preview(&spec.slug)) {
883 (false, true) => "showcase-live-preview is-static is-wide",
884 (false, false) => "showcase-live-preview is-static",
885 (true, true) => "showcase-live-preview is-wide",
886 (true, false) => "showcase-live-preview",
887 };
888 let status = {
889 let local_demo = is_local_demo(&spec.slug);
890 move || {
891 if is_checkbox {
892 Some(format!(
893 "checked: {} · danger: {}",
894 checked.get(),
895 danger.get()
896 ))
897 } else if is_alert {
898 Some(format!(
899 "variant: \"{}\" · message: \"{}\"",
900 alert_variant.get(),
901 alert_message.get()
902 ))
903 } else if local_demo {
904 Some("本地演示,内容不会保存".to_string())
905 } else {
906 None
907 }
908 }
909 };
910 let sections: Vec<(&str, &str)> = DETAIL_SECTIONS
911 .into_iter()
912 .filter(|(id, _)| *id != "keys" || !spec.keys.is_empty())
913 .collect();
914 let previous = index.checked_sub(1).map(|i| &showcase().components[i]);
915 let next = showcase().components.get(index + 1);
916 let gallery_slug = spec.slug.clone();
917 let slug = spec.slug.clone();
918
919 let state_control = if is_checkbox {
920 Some(
921 view! {
922 <SegmentedControl
923 items=vec![
924 SegmentedItem::new("default".to_string(), "默认"),
925 SegmentedItem::new("checked".to_string(), "已选"),
926 SegmentedItem::new("danger".to_string(), "危险态"),
927 ]
928 value=Signal::derive(move || {
929 checkbox_state(checked.get(), danger.get()).to_string()
930 })
931 aria_label="预览状态"
932 on_change=move |state: String| {
933 checked.set(state != "default");
934 danger.set(state == "danger");
935 }
936 />
937 }
938 .into_any(),
939 )
940 } else if is_alert {
941 Some(
942 view! {
943 <SegmentedControl
944 items=vec![
945 SegmentedItem::new("success".to_string(), "成功"),
946 SegmentedItem::new("error".to_string(), "错误"),
947 SegmentedItem::new("default".to_string(), "普通"),
948 ]
949 value=alert_variant
950 aria_label="预览状态"
951 on_change=move |variant: String| {
952 alert_message.set(alert_box_preset_message(&variant).to_string());
953 alert_variant.set(variant);
954 }
955 />
956 }
957 .into_any(),
958 )
959 } else {
960 None
961 };
962 let reset_button = interactive.then(|| {
963 view! {
964 <Button
965 variant=ButtonVariant::Ghost
966 size=ButtonSize::Sm
967 icon=IconName::RotateCcw
968 class="showcase-reset"
969 onclick=move |_: MouseEvent| {
970 checked.set(false);
971 danger.set(false);
972 alert_variant.set("success".to_string());
973 alert_message.set(alert_box_preset_message("success").to_string());
974 reset_generation.update(|generation| *generation = generation.wrapping_add(1));
975 }
976 >
977 "恢复默认"
978 </Button>
979 }
980 });
981 let alert_field = is_alert.then(|| {
982 view! {
983 <label class="showcase-live-field">
984 <span>"message"</span>
985 <FormInput
986 r#type="text"
987 placeholder="输入自定义提示文案…"
988 value=alert_message
989 oninput=move |value: String| alert_message.set(value)
990 />
991 </label>
992 }
993 });
994
995 let props_section = if spec.props.is_empty() {
996 view! { <p class="showcase-detail-note">"这个组件不接收属性。"</p> }.into_any()
997 } else {
998 let rows = spec
999 .props
1000 .iter()
1001 .map(|prop| {
1002 let default = if prop[2] == "必填" {
1003 view! { <span class="showcase-prop-required">"必填"</span> }.into_any()
1004 } else {
1005 view! { <code class="showcase-prop-default">{prop[2].clone()}</code> }
1006 .into_any()
1007 };
1008 view! {
1009 <tr>
1010 <td>
1011 <code>{prop[0].clone()}</code>
1012 </td>
1013 <td>
1014 <code class="showcase-prop-type">{prop[1].clone()}</code>
1015 </td>
1016 <td>{default}</td>
1017 <td>{prop[3].clone()}</td>
1018 </tr>
1019 }
1020 })
1021 .collect_view();
1022 view! {
1023 <div class="showcase-props-wrap">
1024 <table>
1025 <thead>
1026 <tr>
1027 <th>"名称"</th>
1028 <th>"类型"</th>
1029 <th>"默认值"</th>
1030 <th>"说明"</th>
1031 </tr>
1032 </thead>
1033 <tbody>{rows}</tbody>
1034 </table>
1035 </div>
1036 }
1037 .into_any()
1038 };
1039
1040 let keys_section = (!spec.keys.is_empty()).then(|| {
1041 let items = spec
1042 .keys
1043 .iter()
1044 .map(|item| {
1045 view! {
1046 <div>
1047 <dt>
1048 <kbd>{item[0].clone()}</kbd>
1049 </dt>
1050 <dd>{item[1].clone()}</dd>
1051 </div>
1052 }
1053 })
1054 .collect_view();
1055 view! {
1056 <section id="keys" class="showcase-detail-section" aria-labelledby="showcase-keys-title">
1057 <h2 id="showcase-keys-title">"键盘"</h2>
1058 <dl class="showcase-key-list">{items}</dl>
1059 </section>
1060 }
1061 });
1062
1063 let usage_label = match spec.usage_kind {
1064 UsageKind::Rust => "Leptos view!",
1065 UsageKind::Api => "Rust 接口",
1066 UsageKind::Router => "路由配置",
1067 UsageKind::Browser => "TypeScript",
1068 };
1069 let api_note = (spec.usage_kind == UsageKind::Api).then(|| {
1070 view! {
1071 <p class="showcase-detail-note">
1072 "下方列出组件接口;正式调用由所在页面提供数据与回调。"
1073 </p>
1074 }
1075 });
1076 let copy_snippet = snippet.clone();
1077 let on_copy = move |_: MouseEvent| {
1078 #[cfg(target_arch = "wasm32")]
1079 {
1080 let text = copy_snippet();
1081 leptos::task::spawn_local(async move {
1082 if crate::utils::js::copy_text(&text).await {
1083 copied.try_set(true);
1084 crate::utils::time::sleep_ms(1600).await;
1085 copied.try_set(false);
1086 }
1087 });
1088 }
1089 #[cfg(not(target_arch = "wasm32"))]
1090 let _ = ©_snippet;
1091 };
1092
1093 let neighbor = |item: &ComponentSpec, class: &'static str, hint: &'static str| {
1094 view! {
1095 <a
1096 class=class
1097 href=Route::ComponentDetail {
1098 component: item.slug.clone(),
1099 }
1100 .to_string()
1101 >
1102 <span class="showcase-neighbor-hint">{hint}</span>
1103 <span class="showcase-neighbor-name">
1104 {format!("{} ", item.label)} <code>{item.name.clone()}</code>
1105 </span>
1106 </a>
1107 }
1108 };
1109 let previous_link = previous.map(|item| neighbor(item, "", "← 上一个"));
1110 let next_link = next.map(|item| neighbor(item, "is-next", "下一个 →"));
1111
1112 let toc = sections
1113 .into_iter()
1114 .map(|(id, label)| {
1115 view! {
1116 <li>
1117 <a
1118 href=format!("#{id}")
1119 class=move || if active_section.get() == id { "is-active" } else { "" }
1120 aria-current=move || (active_section.get() == id).then_some("location")
1121 >
1122 {label}
1123 </a>
1124 </li>
1125 }
1126 })
1127 .collect_view();
1128
1129 view! {
1130 <Title text=format!("{} {} · 组件图鉴 · Yggdrasil", spec.label, spec.name)/>
1131 <article class="showcase showcase-detail">
1132 <nav class="breadcrumb" aria-label="面包屑">
1133 <a href=Route::About.to_string()>"关于"</a>
1134 <span aria-hidden="true">"/"</span>
1135 <a
1136 href=Route::ComponentShowcase.to_string()
1137 on:click=move |_| {
1138 filters
1139 .update(|state| {
1140 state.return_to = Some(gallery_slug.clone());
1141 state.selected = Some(gallery_slug.clone());
1142 });
1143 }
1144 >
1145 "组件图鉴"
1146 </a>
1147 <span aria-hidden="true">"/"</span>
1148 <span aria-current="page">{spec.label.clone()}</span>
1149 </nav>
1150 <div class="showcase-detail-layout">
1151 <div class="showcase-detail-main">
1152 <header class="showcase-detail-header page-enter">
1153 <div class="showcase-detail-title">
1154 <h1>{spec.label.clone()}</h1>
1155 <code>{spec.name.clone()}</code>
1156 <span class="chip">{group_label(&spec.group)}</span>
1157 </div>
1158 <p class="showcase-detail-lead">{spec.description.clone()}</p>
1159 </header>
1160 <section
1161 id="preview"
1162 class="showcase-live-panel page-enter"
1163 style="--enter-index: 1"
1164 aria-labelledby="showcase-preview-title"
1165 >
1166 <div class="showcase-live-toolbar">
1167 <h2 id="showcase-preview-title">"预览"</h2>
1168 <div class="showcase-live-actions">{state_control} {reset_button}</div>
1169 </div>
1170 <div class=stage_class>
1171 {move || {
1172 reset_generation.track();
1173 view! {
1174 <div class="showcase-preview-instance">
1175 <ComponentPreview
1176 slug=slug.clone()
1177 detail=true
1178 checked=checked
1179 danger=danger
1180 alert_variant=alert_variant
1181 alert_message=alert_message
1182 />
1183 </div>
1184 }
1185 }}
1186 </div>
1187 {alert_field}
1188 {move || {
1189 status()
1190 .map(|status| view! { <div class="showcase-live-status">{status}</div> })
1191 }}
1192 </section>
1193 <section
1194 id="props"
1195 class="showcase-detail-section"
1196 aria-labelledby="showcase-props-title"
1197 >
1198 <h2 id="showcase-props-title">"属性"</h2>
1199 {props_section}
1200 </section>
1201 {keys_section}
1202 <section
1203 id="usage"
1204 class="showcase-detail-section"
1205 aria-labelledby="showcase-usage-title"
1206 >
1207 <div class="showcase-section-head">
1208 <h2 id="showcase-usage-title">"用法"</h2>
1209 <code class="showcase-source">{spec.source.clone()}</code>
1210 </div>
1211 {api_note}
1212 <figure class="showcase-code-panel">
1213 <div class="showcase-code-toolbar">
1214 <span>{usage_label}</span>
1215 <Button
1216 variant=ButtonVariant::Ghost
1217 size=ButtonSize::Sm
1218 icon=Signal::derive(move || {
1219 Some(if copied.get() { IconName::Check } else { IconName::Copy })
1220 })
1221 onclick=on_copy
1222 >
1223 {move || if copied.get() { "已复制" } else { "复制" }}
1224 </Button>
1225 </div>
1226 <pre>
1227 <code>{move || highlighted_view(&snippet())}</code>
1228 </pre>
1229 </figure>
1230 </section>
1231 <nav class="showcase-neighbors" aria-label="相邻组件">
1232 {previous_link} {next_link}
1233 </nav>
1234 </div>
1235 <aside class="showcase-toc" aria-label="本页目录">
1236 <p class="showcase-toc-label">"本页"</p>
1237 <ol>{toc}</ol>
1238 <p class="showcase-toc-states">{format!("状态:{}", spec.states)}</p>
1239 </aside>
1240 </div>
1241 </article>
1242 }
1243 .into_any()
1244}
1245
1246#[component]
1247fn ComponentPreview(
1248 slug: String,
1249 detail: bool,
1250 #[prop(optional)] checked: Option<RwSignal<bool>>,
1251 #[prop(optional)] danger: Option<RwSignal<bool>>,
1252 #[prop(optional)] alert_variant: Option<RwSignal<String>>,
1253 #[prop(optional)] alert_message: Option<RwSignal<String>>,
1254) -> impl IntoView {
1255 let spec = showcase()
1256 .components
1257 .iter()
1258 .find(|item| item.slug == slug)
1259 .expect("预览必须对应图鉴条目");
1260 let name = spec.name.clone();
1261 let preview = match preview_route(&slug).expect("图鉴条目必须有专属预览分派") {
1264 PreviewRoute::Live => match (checked, danger, alert_variant, alert_message) {
1265 (Some(checked), Some(danger), Some(alert_variant), Some(alert_message)) => view! {
1266 <live_previews::LiveComponentPreview
1267 name=name
1268 detail=detail
1269 checked=checked
1270 danger=danger
1271 alert_variant=alert_variant
1272 alert_message=alert_message
1273 />
1274 }
1275 .into_any(),
1276 _ => {
1277 view! { <live_previews::LiveComponentPreview name=name detail=detail/> }.into_any()
1278 }
1279 },
1280 PreviewRoute::Scene => {
1281 view! { <live_previews::SceneComponentPreview name=name/> }.into_any()
1282 }
1283 PreviewRoute::Business => {
1284 business_previews::preview(&slug, detail).expect("业务预览必须实现")
1285 }
1286 PreviewRoute::LayoutArticle => {
1287 layout_article_previews::preview(&slug, detail).expect("布局与文章预览必须实现")
1288 }
1289 PreviewRoute::Comment => {
1290 comment_previews::preview(&slug, detail).expect("评论预览必须实现")
1291 }
1292 PreviewRoute::Browser => {
1293 browser_previews::preview(&slug, detail).expect("浏览器预览必须实现")
1294 }
1295 };
1296 view! {
1297 <crate::components::enhance_assets::EnhanceScript/>
1298 <crate::components::enhance_assets::LightboxAssets/>
1299 {preview}
1300 }
1301}
1302
1303#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1304enum PreviewRoute {
1305 Live,
1306 Scene,
1307 Business,
1308 LayoutArticle,
1309 Comment,
1310 Browser,
1311}
1312
1313fn preview_route(slug: &str) -> Option<PreviewRoute> {
1314 Some(match slug {
1315 "checkbox" | "toggle-switch" | "form-select" | "radio" | "form-input" | "form-textarea"
1316 | "time-picker" | "form-label" | "segmented-control" | "pagination" | "table-pager"
1317 | "tooltip" | "popover" | "modal-shell" | "confirm-dialog" | "alert-box"
1318 | "status-badge" | "status-dot" | "tag-chip" | "user-avatar" | "image-placeholder"
1319 | "icon" | "button" | "icon-button" | "number-input" | "menu" | "toast"
1320 | "bottom-sheet" | "side-sheet" | "breadcrumb" | "search-palette" | "admin-palette"
1321 | "empty-state" | "load-error" | "composer" | "composer-input" | "composer-send" => {
1322 PreviewRoute::Live
1323 }
1324 "post-cover" => PreviewRoute::Scene,
1325 "asset-picker-modal" | "asset-upload-modal" | "code-runner" | "sql-result-table" => {
1326 PreviewRoute::Business
1327 }
1328 "post-header" | "post-content" | "post-footer" | "post-toc" => PreviewRoute::LayoutArticle,
1329 "comment-card-shell"
1330 | "comment-form"
1331 | "comment-item"
1332 | "comment-list"
1333 | "pending-comment-item"
1334 | "comment-section" => PreviewRoute::Comment,
1335 "tiptap-editor" | "code-mirror-editor" | "xterm-terminal" | "lightbox" => {
1336 PreviewRoute::Browser
1337 }
1338 _ => return None,
1339 })
1340}
1341
1342fn is_local_demo(slug: &str) -> bool {
1343 matches!(
1344 preview_route(slug),
1345 Some(PreviewRoute::Business | PreviewRoute::Comment | PreviewRoute::Browser)
1346 )
1347}
1348
1349fn is_wide_preview(slug: &str) -> bool {
1350 matches!(
1351 preview_route(slug),
1352 Some(
1353 PreviewRoute::Business
1354 | PreviewRoute::LayoutArticle
1355 | PreviewRoute::Comment
1356 | PreviewRoute::Browser
1357 )
1358 )
1359}
1360
1361#[cfg(test)]
1362mod tests {
1363 use super::*;
1364 use std::collections::HashSet;
1365 use std::path::Path;
1366
1367 #[test]
1368 fn large_embedded_documents_stay_out_of_wasm() {
1369 for (path, source, embed) in [
1371 (
1372 "components_showcase",
1373 include_str!("components_showcase.rs"),
1374 "static SHOWCASE: LazyLock",
1375 ),
1376 (
1377 "api/writing_guide",
1378 include_str!("../api/writing_guide.rs"),
1379 "pub const SKILL_SOURCE",
1380 ),
1381 ] {
1382 let before = source
1383 .split_once(embed)
1384 .unwrap_or_else(|| panic!("{path} should embed its document"))
1385 .0;
1386 assert!(
1387 before.trim_end().ends_with("#[cfg(feature = \"server\")]"),
1388 "{path} must gate its embedded document behind the server feature"
1389 );
1390 }
1391 }
1392
1393 fn public_component_names(dir: &Path, names: &mut HashSet<String>) {
1394 for entry in std::fs::read_dir(dir).expect("读取组件源码目录") {
1395 let path = entry.expect("组件目录项").path();
1396 if path.is_dir() {
1397 if path.file_name().is_some_and(|name| name == "skeletons") {
1399 continue;
1400 }
1401 public_component_names(&path, names);
1402 } else if path.extension().is_some_and(|extension| extension == "rs") {
1403 let source = std::fs::read_to_string(&path).expect("读取组件源码");
1404 for line in source.lines() {
1405 if let Some(rest) = line.trim_start().strip_prefix("pub fn ") {
1406 let name: String = rest
1407 .chars()
1408 .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
1409 .collect();
1410 if name.starts_with(char::is_uppercase) {
1411 names.insert(name);
1412 }
1413 }
1414 }
1415 }
1416 }
1417 }
1418
1419 const EXCLUDED_LAYOUT_COMPONENTS: [(&str, &str, &str); 5] = [
1420 ("AdminLayout", "admin-layout", "admin_layout.rs"),
1421 ("FrontendLayout", "frontend-layout", "frontend_layout.rs"),
1422 ("Header", "header", "header.rs"),
1423 ("Footer", "footer", "footer.rs"),
1424 ("SearchIconLink", "search-icon-link", "header.rs"),
1425 ];
1426
1427 #[test]
1428 fn catalog_covers_public_components_and_has_unique_routes() {
1429 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/components");
1430 let mut exported = HashSet::new();
1431 public_component_names(&root, &mut exported);
1432 let catalog: HashSet<&str> = SHOWCASE
1433 .components
1434 .iter()
1435 .map(|component| component.name.as_str())
1436 .collect();
1437 let missing: Vec<_> = exported
1438 .iter()
1439 .filter(|name| {
1440 !catalog.contains(name.as_str())
1441 && !EXCLUDED_LAYOUT_COMPONENTS
1442 .iter()
1443 .any(|(excluded, _, _)| name.as_str() == *excluded)
1444 })
1445 .collect();
1446 assert!(missing.is_empty(), "图鉴缺少公开组件: {missing:?}");
1447
1448 let mut slugs = HashSet::new();
1449 let groups: HashSet<&str> = SHOWCASE
1450 .groups
1451 .iter()
1452 .map(|group| group[0].as_str())
1453 .collect();
1454 for component in &SHOWCASE.components {
1455 assert!(
1456 slugs.insert(component.slug.as_str()),
1457 "重复路径: {}",
1458 component.slug
1459 );
1460 assert!(
1461 groups.contains(component.group.as_str()),
1462 "未知分类: {}",
1463 component.group
1464 );
1465 assert!(
1466 !component.code.trim().is_empty(),
1467 "缺少用法: {}",
1468 component.name
1469 );
1470 assert!(
1471 preview_route(&component.slug).is_some(),
1472 "缺少真实预览分派: {}",
1473 component.slug
1474 );
1475 }
1476 }
1477
1478 #[test]
1479 fn catalog_excludes_layouts_but_keeps_production_components() {
1480 assert!(SHOWCASE.groups.iter().all(|group| group[0] != "layout"));
1481 assert!(SHOWCASE
1482 .components
1483 .iter()
1484 .all(|item| item.group != "layout"));
1485 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/components");
1486 for (name, slug, file) in EXCLUDED_LAYOUT_COMPONENTS {
1487 assert!(!SHOWCASE
1488 .components
1489 .iter()
1490 .any(|item| item.name == name || item.slug == slug));
1491 assert_eq!(preview_route(slug), None, "布局预览不应保留: {slug}");
1492 assert!(layout_article_previews::preview(slug, false).is_none());
1493 assert!(layout_article_previews::preview(slug, true).is_none());
1494 let source = std::fs::read_to_string(root.join(file)).expect("正式布局组件必须保留");
1495 assert!(source.contains(&format!("pub fn {name}(")));
1496 }
1497 }
1498
1499 #[test]
1500 fn catalog_excludes_skeletons_and_their_preview_routes() {
1501 let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/components/skeletons");
1502 let mut skeletons = HashSet::new();
1503 public_component_names(&root, &mut skeletons);
1504 assert!(!skeletons.is_empty(), "业务骨架屏组件必须保留");
1505 for component in &SHOWCASE.components {
1506 assert!(!skeletons.contains(&component.name));
1507 assert!(!component.source.starts_with("src/components/skeletons/"));
1508 }
1509 for slug in [
1510 "skeleton-box",
1511 "skeleton-card",
1512 "skeleton-table",
1513 "delayed-skeleton",
1514 "home-skeleton",
1515 "post-detail-body",
1516 "tag-posts-loading",
1517 ] {
1518 assert_eq!(preview_route(slug), None, "骨架屏预览不应保留: {slug}");
1519 }
1520 assert!(SHOWCASE.groups.iter().all(|group| group[0] == "all"
1521 || SHOWCASE
1522 .components
1523 .iter()
1524 .any(|item| item.group == group[0])));
1525 }
1526
1527 #[test]
1528 fn highlighted_usage_preserves_every_snippet() {
1529 let snippets = SHOWCASE
1530 .components
1531 .iter()
1532 .map(|component| component.code.clone())
1533 .chain([true, false].into_iter().flat_map(|checked| {
1534 [true, false].map(move |danger| checkbox_snippet(checked, danger))
1535 }))
1536 .chain(
1537 ["success", "error", "default"]
1538 .into_iter()
1539 .map(|variant| alert_box_snippet(variant, alert_box_preset_message(variant))),
1540 );
1541
1542 for snippet in snippets {
1543 let tokens = highlight_snippet(&snippet);
1544 assert_eq!(
1545 tokens.iter().map(|token| token.text).collect::<String>(),
1546 snippet
1547 );
1548 assert!(tokens.iter().any(|token| token.class.is_some()));
1549 }
1550 }
1551
1552 #[test]
1553 fn highlighted_usage_keeps_strings_and_comments_intact() {
1554 let code = "let text = \"中文 \\\"// still a string\\\"\";\n// 注释\n";
1555 let tokens = highlight_snippet(code);
1556 assert!(tokens
1557 .iter()
1558 .any(|token| token.class == Some("string") && token.text.contains("中文")));
1559 assert!(tokens
1560 .iter()
1561 .any(|token| token.class == Some("comment") && token.text == "// 注释"));
1562 assert_eq!(
1563 tokens.iter().map(|token| token.text).collect::<String>(),
1564 code
1565 );
1566 }
1567}