Skip to main content

yggdrasil/pages/
components_showcase.rs

1//! 项目自写组件的公开图鉴与独立用法页。
2//!
3//! 目录、文案和属性说明来自本模块的静态数据;预览直接渲染项目里的组件。
4#[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// 供各预览子模块通过 `super::sample_post` 共享示例文章,主文件自身不使用。
34#[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// 图鉴数据只嵌入服务端二进制;浏览器从水合数据或 server function 取得,不随 WASM 下发。
77#[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/// 当前图鉴数据。
87#[cfg(feature = "server")]
88fn showcase() -> &'static ShowcaseData {
89    &SHOWCASE
90}
91
92/// 当前图鉴数据。客户端须先经 `ShowcaseDataGate` 加载。
93#[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/// 客户端会话内只请求一次;首屏直接复用 SSR 水合数据。取数函数随分包加载,
106/// 见 `hooks::ssr_resource`。
107#[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/// 加载图鉴数据后再渲染子页面,使目录、详情与预览可同步读取 `showcase()`。
117#[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
140/// 组件图鉴目录路由,对应 `/about/components`。
141pub 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
164/// 单枚组件详情路由,对应 `/about/components/:component`。
165pub 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                // 按组件重建详情,切换相邻组件时预览状态、复制提示与目录高亮都回到初始值。
183                {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
339/// 高亮后的用法片段。
340fn 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            // 详情页由预览工具栏切换状态,这里只在目录卡片里给出切换控件。
369            {(!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
406/// 宽屏时目录右侧有详情栏,点卡片只切换选中;窄屏没有详情栏,点卡片直接进入详情页。
407fn 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
418/// 点击是否落在卡片内的交互控件上(链接、按钮、编辑器等),这类点击不触发卡片选中。
419fn 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
443/// 首屏边解析边排瀑布流:卡片一闭合就按自然高度写好 `grid-row-end`,浏览器每次中途绘制
444/// 看到的都是最终位置;之后由 `showcase-masonry.ts` 接管(公式、断点与它一致,算出的跨度
445/// 相同,不再挪动)。卡片的位置只取决于它前面卡片的跨度,所以还在解析的最后一张不用等。
446/// 客户端导航时脚本在目录挂上文档时执行,此时卡片已齐,一次排完。
447const 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    // 选中项被筛掉时,详情栏落到当前结果的第一项。
522    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        // P5 §8.2:图鉴 masonry 增强与灯箱预览按需注入(SSR 直达带 defer,先于
586        // hydration;SPA 首跳由增强调用队列兜底)。阅读器客户端不含图鉴路由。
587        <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/// 目录右侧的详情栏:说明、状态、属性与用法,并给出详情页入口。
701#[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
769/// 详情页的章节,与右侧目录一一对应。
770const 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/// 目录高亮最后一个越过页头的章节;滚到底时高亮最后一节,短章节也能被选中。
788#[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 _ = &copy_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    // P5 §8.2:预览分派的公共入口统一声明增强/灯箱资源——detail 详情页不渲染
1262    // ShowcaseGallery,全局注入移除后这里必须自带(按 src 去重,画廊页幂等)。
1263    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        // 两份文档只供服务端渲染与 server function 下发,不能随 WASM 发给每位访客。
1370        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                // 骨架屏保留在业务页面中,不收录到公开图鉴。
1398                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}