From 671b574d0aec7f4457a7eea332a47e0dc45fa9c2 Mon Sep 17 00:00:00 2001 From: zengchuang Date: Fri, 30 Oct 2026 16:17:54 +0800 Subject: [PATCH 1/2] feat: add curated project article library --- README.md | 17 +- SHA256SUMS | 10 +- assets/article-resources.json | 275 ++++++++++++++++++++++++++++++++ index.html | 45 +++++- qa-resources.cjs | 290 +++++++++++++++++++++++++++++++++ resource-library.js | 291 ++++++++++++++++++++++++++++++++++ styles.css | 15 +- video-resources.js | 251 ----------------------------- 8 files changed, 926 insertions(+), 268 deletions(-) create mode 100644 assets/article-resources.json create mode 100644 qa-resources.cjs create mode 100644 resource-library.js delete mode 100644 video-resources.js diff --git a/README.md b/README.md index bde21af..45c0ed4 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ A bilingual, buildless website showcasing seven open-source inference projects: python3 -m http.server 8000 --bind 127.0.0.1 ``` -Open [http://localhost:8000](http://localhost:8000). The deployable files are already at the repository root: `index.html`, `styles.css`, `assistant.css`, `app.js`, `showcase.js`, `inferrsi.js`, `assistant.js`, `video-resources.js`, `robots.txt`, `sitemap.xml`, and `assets/`. +Open [http://localhost:8000](http://localhost:8000). The deployable files are already at the repository root: `index.html`, `styles.css`, `assistant.css`, `app.js`, `showcase.js`, `inferrsi.js`, `assistant.js`, `resource-library.js`, `robots.txt`, `sitemap.xml`, and `assets/`. The stylesheet requests DM Sans and Manrope from Google Fonts. System-font fallbacks remain available when the external font service cannot be reached. @@ -26,10 +26,11 @@ Optional maintenance checks use Node.js 18+ built-in modules only: ```sh node --check app.js -node --check video-resources.js +node --check resource-library.js node --check prepare-initial-content.cjs node --check qa-interactions.cjs node qa-interactions.cjs +node qa-resources.cjs ``` The interaction suite reads the current `index.html` and runs `app.js` in a simulated DOM using Node's VM. It covers filtering, bilingual content, keyboard navigation, project/poster dialogs, focus return, motion preferences, blocked storage, and all six architecture viewers. This is a logic regression check; it does not replace real-browser visual or accessibility testing. @@ -57,6 +58,8 @@ Bind each custom domain in its Pages project. The registrar must delegate `infer ## Latest additions +- A bilingual Articles column with 14 verified technical articles and practical guides covering all seven projects, separate filters and search, and six entries per page. + - A bilingual Video resources column for external learning tutorials and project talks, with project/type filters and search. Eleven links cover vLLM-Omni, afd-plugin and vLLM Router, with available publisher information and verification sources retained. - A repo-grouped video showcase with five vLLM-Omni videos and two InferRSI English demos, local poster previews, click-to-play and fullscreen controls. - InferRSI as a seventh project and an evidence-driven optimization direction, with a brand-styled, scalable architecture SVG. @@ -105,10 +108,18 @@ for the optional dependency-free Node preview and interaction checklist. 每条已收录资源还保留 `sourceUrl`、`verifiedAt` 和 `sourceNote`,供维护者核对来源、核实日期及证据范围;有依据时记录 `eventDate`。这些维护字段不显示在视频卡片上。`publishedAt` 只填写已确认的视频上传日期,不能用会议日期或配套文章日期代替。中英文标题是便于浏览的编辑文案;原始标题与信息请以来源页面为准。首批资料来源包括 vLLM 官方 Office Hours、Conf42、Red Hat Developer、B 站原视频页面以及 vLLM 小课堂的原视频与课件索引。B 站的部分页面/API 会对自动抓取返回 412;其链接由原始页面索引或配套资料交叉核对,未逐条验证实际播放。 -The library is rendered by `video-resources.js`. Project counts reflect the full catalog; results combine project, content-type, and text filters. Results are paginated at 6 videos per page, with bilingual previous/next controls and the current visible range. Changing a filter or search resets to page one; changing language preserves the current page and filters. Pagination is hidden for a single page, empty results, loading and errors. Search covers both languages, project names, platforms, publishers, and speakers. Empty catalogs, no matches, and failed requests have distinct states; failed requests can be retried. +The library is rendered by `resource-library.js`. Project counts reflect the full catalog; results combine project, content-type, and text filters. Results are paginated at 6 videos per page, with bilingual previous/next controls and the current visible range. Changing a filter or search resets to page one; changing language preserves the current page and filters. Pagination is hidden for a single page, empty results, loading and errors. Search covers both languages, project names, platforms, publishers, and speakers. Empty catalogs, no matches, and failed requests have distinct states; failed requests can be retried. The catalog is fetched with `cache: 'no-store'` and a fresh request parameter so edits to the JSON appear on the next page reload, even when the document or previous JSON response was cached. Changing only the page URL's query string does not invalidate a separately fetched resource. The script entry also carries a version parameter to refresh older copies of the loader. +## 文章专栏 / Articles + +首页导航的「文章专栏」指向 `#article-resources`。首批收录 14 篇官方博客、社区技术解读与项目实践指南,覆盖七个项目。点击「阅读原文」前往原发布平台;中英文标题和简介为本站编辑文案,正文以原文为准。 + +文章清单位于 `assets/article-resources.json`,与视频分开维护。字段沿用视频资源结构;类型为 `tutorial`(学习教程)或 `technical`(技术解读),可选作者字段为 `author`,无需填写 `speaker` 或 `duration`。每条记录保留 `sourceUrl`、`sourceNote` 和 `verifiedAt`,核实日期使用 `YYYY-MM-DD`。只有来源明确提供发布日期时才填写 `publishedAt`;持续更新的项目文档不使用仓库修改时间代替发布日期。 + +`resource-library.js` initializes the video and article libraries independently using the same rendering and filtering logic. Each keeps its own project/type filters, search, page, loading and retry state. Both show at most six entries per page and preserve state on language changes. Article search includes authors; links and text use the same HTTPS validation and escaping as videos. `qa-resources.cjs` exercises both libraries together, including isolated fetch failures, invalid entries, pagination and language changes. + ## 素材与许可 / Assets and licensing See [ATTRIBUTION.md](ATTRIBUTION.md) for asset-specific sources and [licenses/](licenses/) for the retained upstream licenses. The two official upstream diagrams retain their Apache-2.0 terms. The logo, poster, and poster-based vector redraws are not assigned an open-source license by this repository. No blanket license is asserted for all website code and branding. diff --git a/SHA256SUMS b/SHA256SUMS index 7c2e72d..59007e8 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -5,7 +5,7 @@ dc2b68dea37313b64e7a5e585f68be9164e3ebfae84ed5614a28519159a68454 ATTRIBUTION.md fc44df75947d9534405fc9f5401de083dd2fab3e474a5f781348137ad306e10c BENCHMARK_EVIDENCE.json 00fe86be6eb9b9d39857f1833be8ab6d9ae6d39a9117a01ccfcdf3dc46a9f956 INFERRSI.md 9b37f8f61e35ca9998499716bd299a03f05d81208acae3bb35900807a0fc55ad LOCAL_PREVIEW.md -0c48cee1c5e4e1f0b193f87fa49e1b8647ba876cb422360273569225f5b74c4e README.md +a8c3a134ad353fecb4656438c34d11b9109a18aba73acd0011122a12f7ba8442 README.md 5655fa3532ac07d339e7c44de9118a08d2a83bf1cfeadef7395f08e440480902 SHOWCASE.md 0bc3d5f7bd34ac53892f39ab97de48a9d771ddbfc35676255fa46292e4dcae81 app.js 53cf5937ee480102fbabd8e26a52adf1e936a49c2c24d0fc1948465982466017 assets/architecture/afd.svg @@ -20,6 +20,7 @@ ae0a52e430db46ca989b63eb679b13db0a6b90516fc70f3269172bd1c5fc2ff6 assets/archite 2972f105042a61cb960d2923dd2916d3c6e0644f27cfe73f55fedf42eeed781b assets/architecture/rlt.png 5c35d3277b107940d1cf501b5344d5e3a06f68f60cda6b34dfa274b0defdd731 assets/architecture/router-poster.svg cc3545981d0ec428efe6dd61d35901b0fc3f8beb50f696616f5eb30a1689faae assets/architecture/router.svg +94643191d8af94adca001ac35dac5766c065805fed1c911fdd195bd9e5b608ab assets/article-resources.json 1e43c9c8b4dc78086790a41fd46369141802e12e592349f08a804107a35fa1a3 assets/favicon.svg 7b1b2d9403e7a8b83cd695adc6d5a7c9ff5b697225de3149057c0f708c1fddc5 assets/infermatrix-logo.png 79d4c4265b2d7c5635056ab49064469ab48c98dfb326e2e1fcbfbb1c3effe5ad assets/infermatrix-poster.png @@ -46,7 +47,7 @@ f1e5d4f2cc41c9c1da69dfb3523c5659012ac8169d06c267b165af5ecd2aa698 assets/showcas 30e9b46a6d06ab864bbc7ca0289143822d1192392074dc965b0683559f6911e0 assistant.css 71ba38902b5a33b28d7bb66f5a90d83a73cbc4ca9b0d037289b7abce221a30c2 assistant.js 1c13002720c8d65a3fc98084c708b9058827ab66740ae52105991cfebc60eb8e dom-fixture.json -cc9f9f5815ecfb81c9df0ea4de1f40088fdd0ab17e336502c5cd21d3fa73e413 index.html +feefe8709e65c5b74a5b1e761d66093d4d253f99b71a38426d7f6dfe44da3112 index.html 9ec768282a4d526da0c76048ae1e037db9103084bd8e0d5662b22930f433660d inferrsi.js ae0fe01b8c81c20e4a607f8608b84ac0df3b42f9e38bcaaefde92cda5c63a782 initial-data.json 0e8566ea362276878e1f38f97da49964dbea301c15d4aa9d727f97a4c39fd943 licenses/afd-plugin-LICENSE.txt @@ -54,8 +55,9 @@ c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4 licenses/vllm- 19cc4e8b177647501dc4c8fa02ce38e90f0c1f3efe70fe2aa3409930bde7aae1 local-preview.cjs 250f9ee919523549bbabe67fa4585a2772a405f8364b2a370b4020e61aedb8b6 prepare-initial-content.cjs 5462843e0ed8edf7fbc2fcdca220ff496cbd0533a914ddd9c91d1e26b40d8f08 qa-interactions.cjs +a680892e61cd4ef0628cd907acdaba74e1d847e1be051987ed88118984952540 qa-resources.cjs +61cd2df6a4ae984b045a7c9d2063ef29b27e19f87cc1d2f95cc15795e4055366 resource-library.js 51a3881b90c67476605580f3a859becf174b8bf67d37c5c1200324613f2ab42d robots.txt 79c5f0e08b8dc1cfcae28c9e95851c2ee4f3ab7e7eb9dce09ee5303522e17d22 showcase.js 741e991c7d0bc12c49acc061ba697dcc5e57946380c708cc823f7fadcb3d6485 sitemap.xml -1e7d9d7df6e610ec11f441989b64bc51fed2770d674f5f1b6a04f718240e259d styles.css -cc80a9dffa71fd10669b5385dfa0771f9f64052d8e1bb9bc6b311bdadf0a77ad video-resources.js +de33071c1766e07fe866050c3cc3e192bb9c7a4bf9278fa7ffb64a2f292695c7 styles.css diff --git a/assets/article-resources.json b/assets/article-resources.json new file mode 100644 index 0000000..5b5fd59 --- /dev/null +++ b/assets/article-resources.json @@ -0,0 +1,275 @@ +[ + { + "id": "omni-announcement", + "project": "omni", + "type": "technical", + "title": { + "zh": "vLLM-Omni 发布:简单、高效的全模态模型服务", + "en": "Announcing vLLM-Omni: Easy, Fast, and Cheap Omni-Modality Model Serving" + }, + "description": { + "zh": "官方介绍全模态服务的目标、异构模型流水线和分阶段资源配置,并提供入门入口。", + "en": "The official introduction to omni-modal serving, heterogeneous model pipelines and stage-level resource allocation, with links to get started." + }, + "url": "https://vllm.ai/blog/2025-11-30-vllm-omni", + "platform": "vLLM Blog", + "author": "vLLM-Omni Team", + "publishedAt": "2025-11-30", + "sourceUrl": "https://vllm.ai/blog/2025-11-30-vllm-omni", + "sourceNote": "Title, byline, publication date and technical scope verified on the original official article. Covers motivation, architecture, key features and getting started; roadmap items are not asserted as completed features.", + "verifiedAt": "2026-10-30" + }, + { + "id": "omni-redhat-qwen3-architecture", + "project": "omni", + "type": "technical", + "title": { + "zh": "走进 vLLM-Omni 架构:服务 Qwen3-Omni", + "en": "Inside the vLLM-Omni Architecture: Serving Qwen3-Omni" + }, + "description": { + "zh": "通过保险理赔演示理解 Thinker、Talker、Code2Wav 三阶段服务、OmniConnector 和流式流水线。", + "en": "An insurance-claim demo explains the Thinker, Talker and Code2Wav stages, OmniConnector transport and streaming pipeline execution." + }, + "url": "https://developers.redhat.com/articles/2026/07/01/inside-vllm-omni-architecture-serving-qwen3-omni", + "platform": "Red Hat Developer", + "author": "Isaac Tigges", + "publishedAt": "2026-07-01", + "sourceUrl": "https://developers.redhat.com/articles/2026/07/01/inside-vllm-omni-architecture-serving-qwen3-omni", + "sourceNote": "Title, byline, publication date and technical scope verified on the original Red Hat Developer article. This is a complete written architecture walkthrough with a companion video; the article date and byline describe the article.", + "verifiedAt": "2026-10-30" + }, + { + "id": "omni-qwen3-multistage-optimization", + "project": "omni", + "type": "technical", + "title": { + "zh": "Qwen3-Omni 多阶段服务:实践经验与性能优化", + "en": "Experience and Lessons Learned from Serving Multi-Stage Qwen3-Omni in vLLM-Omni" + }, + "description": { + "zh": "逐阶段讲解批处理、CUDA Graph、异步分块、异步输出和副本扩容,并提供受控测试结果。", + "en": "A stage-by-stage walkthrough of batching, CUDA Graphs, async chunking, async output and replica scaling, supported by controlled benchmarks." + }, + "url": "https://vllm.ai/blog/2026-07-01-qwen3-omni-optimization", + "platform": "vLLM Blog", + "author": "vLLM-Omni Team and Ant Group SCT Team", + "publishedAt": "2026-07-01", + "sourceUrl": "https://vllm.ai/blog/2026-07-01-qwen3-omni-optimization", + "sourceNote": "Title, byline, publication date and optimization chapters verified on the original official article. The summary describes mechanisms and controlled validation without generalizing measured speedups across configurations.", + "verifiedAt": "2026-10-30" + }, + { + "id": "omni-official-quickstart", + "project": "omni", + "type": "tutorial", + "title": { + "zh": "vLLM-Omni 快速入门:离线推理与在线服务", + "en": "vLLM-Omni Quickstart: Offline Inference and Online Serving" + }, + "description": { + "zh": "从环境与安装开始,运行离线图像生成,并通过 OpenAI 兼容接口启动在线服务。", + "en": "Set up the environment, run offline image generation and launch online serving through an OpenAI-compatible API." + }, + "url": "https://docs.vllm.ai/projects/vllm-omni/en/latest/getting_started/quickstart/", + "platform": "vLLM-Omni Docs", + "originalTitle": "Quickstart", + "sourceUrl": "https://docs.vllm.ai/projects/vllm-omni/en/latest/getting_started/quickstart/", + "sourceNote": "Topic, installation steps, offline image-generation examples and online-serving examples verified on the project-maintained guide. This is a continuously updated latest document; no publication date or individual author is asserted.", + "verifiedAt": "2026-10-30" + }, + { + "id": "afd-plugin-announcement", + "project": "afd", + "type": "technical", + "title": { + "zh": "vLLM AFD Plugin 发布:分离 Attention 与 FFN", + "en": "Announcing vLLM AFD Plugin: Disaggregating Attention and FFN for Flexible MoE Serving" + }, + "description": { + "zh": "介绍实验性 AFD 插件的 Attention / FFN 服务划分、Connector 接口、GPU / NPU 支持与部署方式。", + "en": "An introduction to the experimental AFD plugin, its Attention and FFN services, connector interface, GPU/NPU support and deployment recipes." + }, + "url": "https://vllm.ai/blog/2026-07-23-vllm-afd-plugin", + "platform": "vLLM Blog", + "author": "AFD Plugin Contributors", + "publishedAt": "2026-07-23", + "sourceUrl": "https://vllm.ai/blog/2026-07-23-vllm-afd-plugin", + "sourceNote": "Title, byline, publication date, architecture and deployment sections verified on the original official article. The article explicitly calls the plugin experimental and limits support claims to validated configurations.", + "verifiedAt": "2026-10-30" + }, + { + "id": "afd-fastafd-technical-walkthrough", + "project": "afd", + "type": "technical", + "title": { + "zh": "大模型推理的内存与计算解耦:AFD 与 FastAFD", + "en": "Memory–Compute Disaggregation with AFD and FastAFD" + }, + "description": { + "zh": "社区技术解读,拆解 Attention / FFN 分离、服务角色、双微批流水线和 FastAFD 优化及其适用边界。", + "en": "A community walkthrough of Attention/FFN disaggregation, runtime roles, dual-microbatch pipelines and FastAFD optimizations and their limits." + }, + "url": "https://princepride.github.io/live-streaming-tools/fastafd_vllm_plugin/final/blog/", + "platform": "vLLM 小课堂技术博客", + "originalTitle": "大模型推理的内存与计算解耦:AFD 架构解析与性能验证", + "sourceUrl": "https://princepride.github.io/live-streaming-tools/fastafd_vllm_plugin/final/blog/", + "sourceNote": "Verified as a complete community technical article with companion lesson materials. The display title follows the article's navigation title; originalTitle retains its H1. No individual author or publication date is asserted, and it is not labeled as a project-official announcement.", + "verifiedAt": "2026-10-30" + }, + { + "id": "router-official-announcement", + "project": "router", + "type": "technical", + "title": { + "zh": "vLLM Router:面向大规模服务的高性能 P/D 感知负载均衡", + "en": "vLLM Router: A High-Performance and Prefill/Decode Aware Load Balancer for Large-scale Serving" + }, + "description": { + "zh": "官方介绍请求路由、会话亲和、Prefill / Decode 分离,以及服务发现、容错和监控能力。", + "en": "The official introduction to request routing, session affinity, prefill/decode disaggregation, service discovery, reliability and observability." + }, + "url": "https://vllm.ai/blog/2025-12-13-vllm-router-release", + "platform": "vLLM Blog", + "author": "vLLM Team", + "publishedAt": "2025-12-13", + "sourceUrl": "https://vllm.ai/blog/2025-12-13-vllm-router-release", + "sourceNote": "Title, byline, publication date and technical scope verified on the original official article. Its repository link identifies vllm-project/router, distinct from vLLM Semantic Router. No benchmark numbers are generalized in the catalog summary.", + "verifiedAt": "2026-10-30" + }, + { + "id": "router-load-balancing-guide", + "project": "router", + "type": "tutorial", + "title": { + "zh": "vLLM Router 负载均衡策略指南", + "en": "vLLM Router Load Balancing Policies" + }, + "description": { + "zh": "比较一致性哈希、二选一、缓存感知和 KV 感知等策略,学习会话亲和与路由配置。", + "en": "Compare consistent hashing, power-of-two, cache-aware and KV-aware policies, with examples for session affinity and routing configuration." + }, + "url": "https://github.com/vllm-project/router/blob/main/docs/load_balancing/README.md", + "platform": "vLLM Router Docs", + "originalTitle": "Load Balancing Policies", + "sourceUrl": "https://github.com/vllm-project/router/blob/main/docs/load_balancing/README.md", + "sourceNote": "Verified as the complete project-maintained load-balancing guide, including six routing policies, session-affinity key priorities and configuration examples. This README is a dedicated docs/load_balancing topic, not the repository homepage or a directory listing. No publication date or individual author is asserted.", + "verifiedAt": "2026-10-30" + }, + { + "id": "omni-diffusion-continuous-batching-pid-article", + "project": "omni", + "type": "technical", + "title": { + "zh": "vLLM-Omni:Diffusion 动态批处理与 PiD", + "en": "vLLM-Omni: Diffusion Continuous Batching and PiD" + }, + "description": { + "zh": "社区技术解读,学习按降噪步调度、动态组批与异构分辨率处理,理解 PiD 的机制及性能边界。", + "en": "A community walkthrough of step-level scheduling, dynamic batching, mixed-resolution execution and the mechanisms and limits of PiD." + }, + "url": "https://princepride.github.io/live-streaming-tools/vllm_omni_diffusion_continuous_batching/final/blog/", + "platform": "vLLM 小课堂技术博客", + "originalTitle": "vLLM-Omni 核心解析:Diffusion 异步批处理与 PiD 架构实践", + "sourceUrl": "https://princepride.github.io/live-streaming-tools/vllm_omni_diffusion_continuous_batching/final/blog/", + "sourceNote": "Verified as a complete community technical article covering step-level scheduling, dynamic batching, heterogeneous resolutions and PiD, with companion lesson materials. No individual author or publication date is asserted, and it is not labeled as a project-official announcement.", + "verifiedAt": "2026-10-30" + }, + { + "id": "agentinfer-quick-start", + "project": "agent", + "type": "tutorial", + "title": { + "zh": "AgentInfer 快速入门:安装与启用智能体感知调度", + "en": "AgentInfer Quick Start: Installation and Agent-Aware Scheduling" + }, + "description": { + "zh": "从准备 vLLM 环境、安装 AgentInfer 到启用调度器和验证服务,完成第一次部署。", + "en": "Prepare vLLM, install AgentInfer, enable its scheduler, and verify the running service." + }, + "url": "https://github.com/openJiuwen-ai/agent-infer/blob/main/docs/en/tutorial/01-quick-start.md", + "platform": "GitHub Docs", + "publisher": "openJiuwen / AgentInfer", + "sourceUrl": "https://github.com/openJiuwen-ai/agent-infer/blob/main/docs/en/tutorial/01-quick-start.md", + "sourceNote": "Verified as the official quick-start guide covering the vLLM environment, installation, --agentinfer startup and models-endpoint validation. The API modification timestamp is not used as a publication date; no individual author is asserted.", + "verifiedAt": "2026-10-30" + }, + { + "id": "rlt-installation-first-request", + "project": "rlt", + "type": "tutorial", + "title": { + "zh": "vllm-rlt 用户指南:从安装到第一次推理请求", + "en": "vllm-rlt User Guide: From Installation to the First Request" + }, + "description": { + "zh": "按照逐步指南在 NVIDIA GPU 上启动 Ouro-1.4B,发送普通和流式请求,并了解 CLI 与 Python API。", + "en": "Serve Ouro-1.4B on an NVIDIA GPU, send regular and streaming requests, and explore the CLI and Python API." + }, + "url": "https://github.com/InferMatrixLab/vllm-rlt/blob/main/docs/launching.md", + "platform": "GitHub Docs", + "publisher": "InferMatrixLab", + "sourceUrl": "https://github.com/InferMatrixLab/vllm-rlt/blob/main/docs/launching.md", + "sourceNote": "Verified as the official step-by-step user guide covering prerequisites, model download, serving, generation requests and shutdown. Uses the canonical InferMatrixLab repository after its migration from ThinkFlowLab. The API modification timestamp is not used as a publication date; no individual author is asserted.", + "verifiedAt": "2026-10-30" + }, + { + "id": "mlx-speech-tts-guide", + "project": "mlx", + "type": "tutorial", + "title": { + "zh": "vllm-omni-mlx 语音合成指南:音色、流式输出与声音克隆", + "en": "vllm-omni-mlx Speech Guide: Voices, Streaming, and Voice Cloning" + }, + "description": { + "zh": "学习 TTS 接口的音色选择、风格指令、声音克隆和流式参数,并了解不同模型的使用差异。", + "en": "Learn TTS voice selection, style instructions, cloning, and streaming controls, with differences between model checkpoints." + }, + "url": "https://github.com/InferMatrixLab/vllm-omni-mlx/blob/main/docs/speech.md", + "platform": "GitHub Docs", + "publisher": "InferMatrixLab", + "originalTitle": "Speech (TTS) Usage", + "sourceUrl": "https://github.com/InferMatrixLab/vllm-omni-mlx/blob/main/docs/speech.md", + "sourceNote": "Verified as the official Speech (TTS) Usage guide with API examples and model-specific differences. Uses the canonical InferMatrixLab repository after its migration from ThinkFlowLab. The API modification timestamp is not used as a publication date; no individual author is asserted.", + "verifiedAt": "2026-10-30" + }, + { + "id": "inferrsi-quickstart", + "project": "inferrsi", + "type": "tutorial", + "title": { + "zh": "InferRSI 快速入门:基准对比、质量验证与证据报告", + "en": "InferRSI Quickstart: Benchmarks, Quality Checks, and Evidence Reports" + }, + "description": { + "zh": "了解安装与环境检查、基准和质量命令约定,以及如何读取实验报告和导出引用上下文。", + "en": "Install and inspect the workbench, define benchmark and quality commands, read experiment reports, and export cited context." + }, + "url": "https://github.com/InferMatrixLab/InferRSI/blob/main/docs/quickstart.md", + "platform": "GitHub Docs", + "publisher": "InferMatrixLab", + "originalTitle": "Quickstart", + "sourceUrl": "https://github.com/InferMatrixLab/InferRSI/blob/main/docs/quickstart.md", + "sourceNote": "Verified as the official quickstart for a workbench environment with the target engine already prepared; it does not install the target model or GPU environment. The API modification timestamp is not used as a publication date; no individual author is asserted.", + "verifiedAt": "2026-10-30" + }, + { + "id": "inferrsi-qwen-image-b300-case", + "project": "inferrsi", + "type": "technical", + "title": { + "zh": "InferRSI 优化案例:Qwen-Image-2.1-Turbo 在 B300 上的验证过程", + "en": "InferRSI Case Study: Validating Qwen-Image-2.1-Turbo Optimizations on B300" + }, + "description": { + "zh": "阅读固定工作负载下的优化记录、交替 A/B 实验、图像质量验证与复现步骤,理解结果的适用范围。", + "en": "Follow the optimization ledger, alternating A/B trials, image validation, and reproduction steps for a fixed workload." + }, + "url": "https://github.com/InferMatrixLab/InferRSI/blob/main/docs/qwen-image-2.1-turbo-b300.md", + "platform": "GitHub Docs", + "publisher": "InferMatrixLab", + "sourceUrl": "https://github.com/InferMatrixLab/InferRSI/blob/main/docs/qwen-image-2.1-turbo-b300.md", + "sourceNote": "Verified as an official experiment report with a fixed workload, optimization ledger, alternating A/B trials, image validation and reproduction instructions. Its historical acceptance limits are retained; the summary does not generalize its results into universal performance claims. The API modification timestamp is not used as a publication date; no individual author is asserted.", + "verifiedAt": "2026-10-30" + } +] diff --git a/index.html b/index.html index a44b035..a682618 100644 --- a/index.html +++ b/index.html @@ -14,11 +14,11 @@ - + - + @@ -34,6 +34,7 @@ 开源项目 演示展示 视频资源 + 文章专栏 InferRSI Recipes ↗ 一起构建 @@ -132,9 +133,10 @@

Open to Infer.
Built to Evolve.

视频由项目素材库提供;展示内容与适用条件以原始说明为准。点击后才加载视频,不自动播放。

-
+
LEARN /学习与分享

从了解项目,到动手实践。

汇集 InferMatrix 各项目的学习教程、技术分享与宣讲回放。找到感兴趣的项目,前往原平台观看。

+ 阅读项目文章 ↗
INFERMATRIX / VIDEO LIBRARY

已收录 0 个视频

@@ -164,7 +166,42 @@

Open to Infer.
Built to Evolve.

视频链接将前往原发布平台;内容持续整理更新。

- + +
+
+
READ /阅读与实践
+

读懂技术,走近实现。

从官方博客、技术解读到实践指南,按项目探索 InferMatrix 的文章与文档。

+ 观看学习视频 ↗ +
+
INFERMATRIX / ARTICLE LIBRARY

已收录 0 篇文章

+
+ + + + + + + + +
+
+
+
+ +
+
+ +
+ +

第一批文章,正在整理中。

各项目的技术解读与实践指南将在这里持续汇集,收录后可按项目和内容类型查找。

+
+
+

文章链接将前往原发布平台;文档内容以项目当前版本为准。

+
03 /连接更多可能
diff --git a/qa-resources.cjs b/qa-resources.cjs new file mode 100644 index 0000000..3515578 --- /dev/null +++ b/qa-resources.cjs @@ -0,0 +1,290 @@ +'use strict'; +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const assert = require('node:assert/strict'); + +// Reuse the existing small DOM implementation without executing app.js or its tests. +// Explicit declaration boundaries fail clearly if that helper is reorganized. +const interactionSource = fs.readFileSync(path.join(__dirname, 'qa-interactions.cjs'), 'utf8').replace(/\r\n/g, '\n'); +const helperStart = interactionSource.indexOf('let doc;\nclass Element'); +const helperEnd = interactionSource.indexOf('\nfunction run(', helperStart); +assert(helperStart >= 0 && helperEnd > helperStart, 'Existing DOM helper declarations are available'); +const domHelpers = interactionSource.slice(helperStart, helperEnd); +const markup = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf8'); +const librarySource = fs.readFileSync(path.join(__dirname, 'resource-library.js'), 'utf8'); +const projects = ['all', 'omni', 'afd', 'router', 'agent', 'rlt', 'mlx', 'inferrsi']; +const flush = () => new Promise(resolve => setImmediate(resolve)); + +function record(kind, index, overrides = {}) { + return { + id: `${kind}-${index}`, + project: index < 8 ? 'omni' : 'afd', + type: index % 2 ? 'tutorial' : (kind === 'video' ? 'talk' : 'technical'), + title: {zh: `${kind === 'video' ? '视频' : '文章'} ${index}`, en: `${kind} title ${index}`}, + description: {zh: `学习资料 ${index}`, en: `Learning resource ${index}`}, + url: `https://example.com/${kind}/${index}`, + platform: 'Example', + publisher: 'Project team', + ...(kind === 'video' ? {speaker: `Speaker ${index}`} : {author: `Author ${index}`}), + ...overrides + }; +} + +function harness({onlySection} = {}) { + const helperContext = vm.createContext({}); + const helpers = vm.runInContext(`${domHelpers}\n({parseMarkup, setDocument(value) {doc = value;}})`, helperContext); + const root = helpers.parseMarkup(markup).find(element => element.tagName === 'html'); + assert(root, 'Current HTML is parsed rather than an old DOM fixture'); + root.lang = 'zh-CN'; + if (onlySection) { + const excluded = root.querySelector(onlySection === 'article-resources' ? '#video-resources' : '#article-resources'); + excluded.parentNode.children = excluded.parentNode.children.filter(child => child !== excluded); + } + for (const input of root.querySelectorAll('input')) input.value = ''; + for (const select of root.querySelectorAll('select')) select.value = 'all'; + const document = { + documentElement: root, + activeElement: null, + querySelector: selector => root.querySelector(selector), + querySelectorAll: selector => root.querySelectorAll(selector), + getElementById: id => root.querySelector(`#${id}`) + }; + helpers.setDocument(document); + const requests = [], observers = []; + const context = vm.createContext({ + document, URL, + MutationObserver: class { + constructor(callback) {observers.push(callback);} + observe() {} + }, + fetch(url, options) { + return new Promise((resolve, reject) => requests.push({url, options, resolve, reject, handled: false})); + } + }); + vm.runInContext(librarySource, context, {filename: 'resource-library.js'}); + const q = selector => { + const result = root.querySelector(selector); + assert(result, `Element exists: ${selector}`); + return result; + }; + const control = (kind, name) => q(`#${kind === 'video' ? 'resource' : 'article'}-${name}`); + const cards = kind => control(kind, 'results').querySelectorAll('.resource-card'); + const ids = kind => cards(kind).map(card => card.getAttribute('data-resource-id')); + const click = (kind, name) => control(kind, name).dispatch('click'); + const project = (kind, name) => control(kind, 'project-filters').querySelector(`[data-resource-project="${name}"]`).dispatch('click'); + const search = (kind, query) => {control(kind, 'search').value = query; control(kind, 'search').dispatch('input');}; + const type = (kind, value) => {control(kind, 'type').value = value; control(kind, 'type').dispatch('change');}; + const language = lang => {root.lang = lang; observers.forEach(callback => callback());}; + async function respond(kind, records, {status = 200, reject = false, jsonError = false} = {}) { + const request = requests.find(item => !item.handled && item.url.startsWith(`assets/${kind}-resources.json?`)); + assert(request, `Pending ${kind} request exists`); + request.handled = true; + if (reject) request.reject(new Error('Network unavailable')); + else request.resolve({ok: status >= 200 && status < 300, json: async () => { + if (jsonError) throw new SyntaxError('Invalid JSON'); + return records; + }}); + await flush(); + } + return {root, document, requests, q, control, cards, ids, click, project, search, type, language, respond}; +} + +async function testIndependentLibraries() { + const h = harness(); + assert.equal(h.requests.length, 2, 'Both catalogs start independently'); + for (const kind of ['video', 'article']) { + assert.equal(h.control(kind, 'results').getAttribute('aria-busy'), 'true'); + assert.equal(h.control(kind, 'pagination').hidden, true); + } + for (const request of h.requests) { + assert.equal(request.options.cache, 'no-store'); + assert.match(request.url, /\?v=\d+$/); + } + await h.respond('video', Array.from({length: 11}, (_, index) => record('video', index))); + assert.equal(h.cards('video').length, 6); + assert.equal(h.control('article', 'results').getAttribute('aria-busy'), 'true', 'Other pending request remains loading'); + await h.respond('article', Array.from({length: 14}, (_, index) => record('article', index))); + assert.equal(h.cards('article').length, 6); + assert.match(h.control('video', 'count').textContent, /1–6 \/ 11/); + assert.match(h.control('article', 'count').textContent, /1–6 \/ 14/); + for (const kind of ['video', 'article']) { + assert.equal(h.control(kind, 'previous-page').disabled, true); + assert.equal(h.control(kind, 'next-page').disabled, false); + assert.equal(h.control(kind, 'results').getAttribute('aria-busy'), 'false'); + const counters = h.control(kind, 'project-filters'); + assert.equal(counters.querySelector('[data-resource-count="omni"]').textContent, '8', 'Badges count the complete catalog'); + } + h.click('video', 'next-page'); + assert.equal(h.cards('video').length, 5); + assert.equal(h.control('video', 'next-page').disabled, true); + assert.equal(h.cards('article').length, 6); + assert.match(h.control('article', 'page-status').textContent, /第 1 \/ 3/); + assert.equal(h.document.activeElement, h.control('video', 'results')); + assert.equal(h.control('video', 'results').scrolled, true); + + const articleIds = [...h.ids('article')]; + h.click('article', 'next-page'); + articleIds.push(...h.ids('article')); + assert.equal(h.cards('article').length, 6); + h.click('article', 'next-page'); + articleIds.push(...h.ids('article')); + assert.equal(h.cards('article').length, 2); + assert.equal(new Set(articleIds).size, 14, 'Pagination visits every article exactly once'); + assert.equal(h.control('article', 'next-page').disabled, true); + assert.match(h.control('video', 'page-status').textContent, /第 2 \/ 2/, 'Article pagination leaves video page intact'); + + h.language('en'); + assert.equal(h.q('[data-resource-i18n="nav"]').textContent, 'Videos'); + assert.equal(h.q('[data-article-i18n="nav"]').textContent, 'Articles'); + assert.equal(h.control('video', 'search').placeholder, 'Search titles, speakers or keywords'); + assert.equal(h.control('article', 'search').placeholder, 'Search titles, authors or keywords'); + assert.match(h.control('video', 'page-status').textContent, /Page 2 of 2 · 6 per page/); + assert.match(h.control('article', 'page-status').textContent, /Page 3 of 3 · 6 per page/); + assert.equal(h.control('article', 'results').querySelector('.resource-link').textContent.trim(), 'Read article'); + assert.match(h.control('video', 'results').innerHTML, /Watch video/); + + h.project('video', 'afd'); + assert.equal(h.cards('video').length, 3); + assert.equal(h.control('video', 'pagination').hidden, true); + assert.match(h.control('video', 'page-status').textContent, /Page 1 of 1/); + assert.match(h.control('article', 'page-status').textContent, /Page 3 of 3/); + h.project('article', 'omni'); + h.click('article', 'next-page'); + assert.equal(h.cards('article').length, 2); + h.type('article', 'technical'); + assert.equal(h.cards('article').length, 4); + assert.match(h.control('article', 'page-status').textContent, /Page 1 of 1/); + h.search('article', 'AUTHOR 2'); + assert.deepEqual([...h.ids('article')], ['article-2'], 'Search normalizes width and case and includes article authors'); + h.language('zh-CN'); + assert.deepEqual([...h.ids('article')], ['article-2'], 'Language changes preserve project/type/search'); + assert.equal(h.control('article', 'type').value, 'technical'); + assert.equal(h.control('article', 'search').value, 'AUTHOR 2'); + h.search('article', '不存在的文章'); + assert.equal(h.cards('article').length, 0); + assert.equal(h.control('article', 'empty').hidden, false); + assert.equal(h.control('article', 'pagination').hidden, true); + assert.equal(h.control('article', 'reset').hidden, false); + assert.match(h.control('article', 'empty-title').textContent, /没有找到相关文章/); + h.click('article', 'reset'); + assert.equal(h.cards('article').length, 6); + assert.equal(h.control('article', 'search').value, ''); + assert.equal(h.control('article', 'type').value, 'all'); + assert.equal(h.control('article', 'reset').hidden, true); + assert.equal(h.document.activeElement, h.control('article', 'search')); + assert.equal(h.cards('video').length, 3, 'Resetting articles leaves video filters intact'); + h.search('article', '文章 3'); + assert.deepEqual([...h.ids('article')], ['article-3'], 'Search covers the other title language'); + h.click('article', 'reset'); + h.project('article', 'inferrsi'); + assert.match(h.control('article', 'empty-description').textContent, /InferRSI/); + assert.equal(h.control('article', 'project-filters').querySelector('[data-resource-count="all"]').textContent, '14'); + for (const name of projects) assert(h.control('article', 'project-filters').querySelector(`[data-resource-project="${name}"]`)); +} + +async function testIndependentRecovery() { + const h = harness(); + await h.respond('video', Array.from({length: 7}, (_, index) => record('video', index))); + h.click('video', 'next-page'); + await h.respond('article', null, {status: 503}); + assert.equal(h.cards('article').length, 0); + assert.equal(h.control('article', 'retry').hidden, false); + assert.match(h.control('article', 'empty-title').textContent, /暂时无法加载/); + assert.equal(h.control('article', 'pagination').hidden, true); + assert.equal(h.cards('video').length, 1); + h.click('article', 'retry'); + assert.equal(h.control('article', 'retry').disabled, true); + h.click('article', 'retry'); + assert.equal(h.requests.length, 3, 'Repeated retry during loading does not duplicate requests'); + await h.respond('article', [record('article', 0)]); + assert.equal(h.cards('article').length, 1); + assert.equal(h.control('article', 'retry').hidden, true); + assert.equal(h.control('article', 'empty').hidden, true); + assert.match(h.control('video', 'page-status').textContent, /第 2 \/ 2/); + + for (const failure of [{reject: true}, {jsonError: true}, {}]) { + const invalid = harness(); + await invalid.respond('video', []); + await invalid.respond('article', {unexpected: 'object'}, failure); + assert.equal(invalid.control('article', 'retry').hidden, false, 'Network, malformed JSON, and non-array catalogs are retryable errors'); + assert.equal(invalid.control('video', 'retry').hidden, true); + assert.match(invalid.control('video', 'empty-title').textContent, /第一批视频/); + assert.equal(invalid.control('video', 'pagination').hidden, true); + } +} + +async function testValidationAndEscaping() { + const h = harness(); + const dangerous = ' & \'quoted\''; + const safeArticle = record('article', 0, { + id: dangerous, + title: {zh: dangerous, en: dangerous}, + description: {zh: dangerous, en: dangerous}, + platform: dangerous, + author: dangerous, + publisher: dangerous, + url: 'https://example.com/?q="&value=1', + publishedAt: '2026-02-30' + }); + const invalid = [ + null, + record('article', 1, {id: ' '}), + record('article', 2, {project: 'constructor'}), + record('article', 3, {type: 'talk'}), + record('article', 4, {title: {zh: '中文'}}), + record('article', 5, {description: {zh: '', en: 42}}), + record('article', 6, {platform: ' '}), + ...['http://example.com', 'javascript:alert(1)', 'https://user:password@example.com', '/relative', 'not a URL'] + .map((url, index) => record('article', 7 + index, {url})) + ]; + const leapDate = record('article', 20, {publishedAt: '2024-02-29'}); + await h.respond('article', [safeArticle, ...invalid, {...safeArticle}, leapDate]); + await h.respond('video', [record('video', 0, {id: dangerous}), record('video', 1, {type: 'technical'})]); + assert.equal(h.cards('article').length, 2, 'Malformed entries and duplicate IDs are omitted individually'); + assert.equal(h.cards('video').length, 1, 'Valid IDs can independently appear in both catalogs'); + const html = h.control('article', 'results').innerHTML; + assert(html.includes('<img src=x onerror="alert(1)"> & 'quoted'')); + assert.equal(h.control('article', 'results').querySelectorAll('img').length, 0, 'Untrusted markup is text'); + assert.equal(h.control('article', 'results').querySelectorAll('script').length, 0); + const links = h.control('article', 'results').querySelectorAll('a'); + assert.equal(links.length, 2, 'Untrusted metadata cannot inject extra links'); + for (const link of links) { + assert.equal(new URL(link.getAttribute('href').replace(/&/g, '&')).protocol, 'https:'); + assert.equal(link.getAttribute('target'), '_blank'); + assert.equal(link.getAttribute('rel'), 'noopener noreferrer'); + } + assert(!html.includes('datetime="2026-02-30"'), 'Impossible optional dates are omitted'); + assert(html.includes('datetime="2024-02-29"')); + assert.equal(h.control('article', 'project-filters').querySelector('[data-resource-count="all"]').textContent, '2'); +} + +async function testMarkupAndSingleLibrary() { + const h = harness(); + const ids = h.root.querySelectorAll('[id]').map(element => element.id); + assert.equal(ids.length, new Set(ids).size, 'HTML IDs remain unique across both columns'); + for (const kind of ['video', 'article']) { + const resultId = h.control(kind, 'results').id; + for (const element of ['search', 'type', 'previous-page', 'next-page']) { + assert.equal(h.control(kind, element).getAttribute('aria-controls'), resultId); + } + const label = h.q(`label[for="${h.control(kind, 'search').id}"]`); + assert(label.textContent); + } + for (const onlySection of ['video-resources', 'article-resources']) { + const single = harness({onlySection}); + const kind = onlySection.startsWith('article') ? 'article' : 'video'; + assert.equal(single.requests.length, 1, 'A missing sibling section is optional'); + await single.respond(kind, [record(kind, 0)]); + assert.equal(single.cards(kind).length, 1); + } +} + +(async () => { + await testIndependentLibraries(); + await testIndependentRecovery(); + await testValidationAndEscaping(); + await testMarkupAndSingleLibrary(); + console.log('PASS: independent video/article catalogs, six-item pagination, bilingual state, combined filters and author search, reset/focus, loading/error/retry isolation, invalid records, HTTPS/XSS escaping, and unique accessible controls.'); + console.log('Verification uses the existing VM DOM helper and current HTML; rendered layout remains a browser check.'); +})().catch(error => {console.error(error); process.exitCode = 1;}); diff --git a/resource-library.js b/resource-library.js new file mode 100644 index 0000000..dc02c80 --- /dev/null +++ b/resource-library.js @@ -0,0 +1,291 @@ +'use strict'; +(() => { + const projects = { + omni: 'vLLM-Omni', afd: 'afd-plugin', router: 'vLLM Router', + agent: 'agent-infer', rlt: 'vllm-rlt', mlx: 'vllm-omni-mlx', inferrsi: 'InferRSI' + }; + const videoCopy = { + zh: { + nav: '视频资源', otherLibrary: '阅读项目文章 ↗', noun: '个视频', kicker: '学习与分享', title: '从了解项目,到动手实践。', + intro: '汇集 InferMatrix 各项目的学习教程、技术分享与宣讲回放。找到感兴趣的项目,前往原平台观看。', + searchLabel: '搜索视频', searchPlaceholder: '搜索标题、讲者或关键词', typeLabel: '内容类型', + allTypes: '全部类型', tutorial: '学习教程', talk: '技术分享 / 宣讲', allProjects: '全部项目', + projectLabel: '按项目筛选', reset: '重置筛选', retry: '重新加载', + note: '视频链接将前往原发布平台;内容持续整理更新。', + loading: '正在加载视频资源…', loadingDescription: '正在读取学习教程与技术分享列表。', + emptyTitle: '第一批视频,正在整理中。', + emptyDescription: '这里将持续收录各项目的学习教程、技术分享与宣讲回放。欢迎稍后回来看看。', + noResults: '没有找到相关视频。', + noResultsDescription: '试试其他项目或内容类型,或调整搜索关键词。', + errorTitle: '视频资源暂时无法加载。', + errorDescription: '请稍后重新加载,获取最新的视频列表。', + action: '前往观看', newWindow: '在新窗口打开', speaker: '讲者', author: '作者', duration: '时长', + previousPage: '上一页', nextPage: '下一页', paginationLabel: '视频资源分页', resultsLabel: '视频资源列表' + }, + en: { + nav: 'Videos', otherLibrary: 'Read project articles ↗', noun: 'videos', kicker: 'LEARN & SHARE', title: 'Get to know a project. Start building.', + intro: 'Explore tutorials, technical talks and presentation recordings from the InferMatrix ecosystem. Find your project and watch on the original platform.', + searchLabel: 'Search videos', searchPlaceholder: 'Search titles, speakers or keywords', typeLabel: 'Content type', + allTypes: 'All types', tutorial: 'Tutorial', talk: 'Talk / Presentation', allProjects: 'All projects', + projectLabel: 'Filter by project', reset: 'Reset filters', retry: 'Try again', + note: 'Video links open on their original platforms. More resources will be added over time.', + loading: 'Loading video resources…', loadingDescription: 'Fetching tutorials and technical talks.', + emptyTitle: 'The first videos are on their way.', + emptyDescription: 'Project tutorials, technical talks and presentation recordings will be collected here. Check back soon.', + noResults: 'No matching videos.', + noResultsDescription: 'Try another project or content type, or change your search keywords.', + errorTitle: 'Video resources could not be loaded.', + errorDescription: 'Please try again shortly to load the latest video list.', + action: 'Watch video', newWindow: 'opens in a new window', speaker: 'Speaker', author: 'Author', duration: 'Duration', + previousPage: 'Previous', nextPage: 'Next', paginationLabel: 'Video resource pagination', resultsLabel: 'Video resources' + } + }; + const articleCopy = { + zh: { + ...videoCopy.zh, + nav: '文章专栏', otherLibrary: '观看学习视频 ↗', noun: '篇文章', kicker: '阅读与实践', + title: '读懂技术,走近实现。', + intro: '从官方博客、技术解读到实践指南,按项目探索 InferMatrix 的文章与文档。', + searchLabel: '搜索文章', searchPlaceholder: '搜索标题、作者或关键词', + technical: '技术解读', note: '文章链接将前往原发布平台;文档内容以项目当前版本为准。', + loading: '正在加载文章…', loadingDescription: '正在读取技术文章与实践指南。', + emptyTitle: '这个项目的文章,正在整理中。', + emptyDescription: '这里将持续收录各项目的技术解读与实践指南。', + noResults: '没有找到相关文章。', + errorTitle: '文章暂时无法加载。', errorDescription: '请稍后重新加载,获取最新的文章列表。', + action: '阅读原文', author: '作者', paginationLabel: '文章专栏分页', resultsLabel: '文章列表' + }, + en: { + ...videoCopy.en, + nav: 'Articles', otherLibrary: 'Watch learning videos ↗', noun: 'articles', kicker: 'READ & BUILD', + title: 'Understand the ideas. Explore the implementation.', + intro: 'Explore official blogs, technical explainers and practical guides from across the InferMatrix projects.', + searchLabel: 'Search articles', searchPlaceholder: 'Search titles, authors or keywords', + technical: 'Technical article', note: 'Articles open on their original platforms. Check project documentation for the current version.', + loading: 'Loading articles…', loadingDescription: 'Fetching technical articles and practical guides.', + emptyTitle: 'Articles for this project are on their way.', + emptyDescription: 'Technical explainers and practical project guides will be collected here.', + noResults: 'No matching articles.', + errorTitle: 'Articles could not be loaded.', errorDescription: 'Please try again shortly to load the latest articles.', + action: 'Read article', author: 'Author', paginationLabel: 'Article pagination', resultsLabel: 'Articles' + } + }; + + // Each library owns its controls, request state, filters and current page. + function initLibrary({sectionId, prefix, copy, types, catalog}) { + const section = document.getElementById(sectionId); + if (!section) return; + const control = name => section.querySelector(`#${prefix}-${name}`); + const language = () => document.documentElement.lang.startsWith('zh') ? 'zh' : 'en'; + const esc = value => String(value).replace(/[&<>"']/g, character => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' + }[character])); + const filters = control('project-filters'); + const search = control('search'); + const type = control('type'); + const count = control('count'); + const results = control('results'); + const empty = control('empty'); + const emptyTitle = control('empty-title'); + const emptyDescription = control('empty-description'); + const reset = control('reset'); + const retry = control('retry'); + const pagination = control('pagination'); + const previousPage = control('previous-page'); + const nextPage = control('next-page'); + const pageStatus = control('page-status'); + const pageSize = 6; + let items = []; + let selectedProject = 'all'; + let currentPage = 1; + let loading = false; + let failed = false; + + function bilingual(value, allowEmpty = false) { + return value && typeof value === 'object' && ['zh', 'en'].every(lang => + typeof value[lang] === 'string' && (allowEmpty || value[lang].trim().length > 0)); + } + + // Invalid records are omitted individually so one broken entry cannot hide the catalog. + function validate(record) { + if (!record || typeof record !== 'object' || + typeof record.id !== 'string' || !record.id.trim() || + typeof record.project !== 'string' || !Object.hasOwn(projects, record.project) || + !types.includes(record.type) || + !bilingual(record.title) || !bilingual(record.description, true) || + typeof record.platform !== 'string' || !record.platform.trim() || + typeof record.url !== 'string') return null; + let url; + try { + url = new URL(record.url); + if (url.protocol !== 'https:' || url.username || url.password) return null; + } catch { return null; } + const item = { + id: record.id.trim(), project: record.project, type: record.type, + title: {zh: record.title.zh.trim(), en: record.title.en.trim()}, + description: {zh: record.description.zh.trim(), en: record.description.en.trim()}, + platform: record.platform.trim(), url: url.href + }; + for (const field of ['speaker', 'duration', 'publisher', 'author']) { + if (typeof record[field] === 'string' && record[field].trim()) item[field] = record[field].trim(); + } + if (typeof record.publishedAt === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(record.publishedAt)) { + const date = new Date(`${record.publishedAt}T00:00:00Z`); + if (!Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === record.publishedAt) { + item.publishedAt = record.publishedAt; + } + } + return item; + } + + function renderCard(item, lang, t) { + const metadata = [`${esc(item.platform)}`]; + if (item.publisher) metadata.push(`${esc(item.publisher)}`); + if (item.author) metadata.push(`${esc(t.author)} · ${esc(item.author)}`); + if (item.speaker) metadata.push(`${esc(t.speaker)} · ${esc(item.speaker)}`); + if (item.publishedAt) metadata.push(``); + if (item.duration) metadata.push(`${esc(t.duration)} · ${esc(item.duration)}`); + return `
+
${esc(projects[item.project])}${esc(t[item.type])}
+

${esc(item.title[lang])}

+

${esc(item.description[lang])}

+
${metadata.join('')}
+ ${esc(t.action)} +
`; + } + + function render() { + const lang = language(), t = copy[lang]; + const query = search.value.trim().normalize('NFKC').toLocaleLowerCase(); + const hasFilters = selectedProject !== 'all' || type.value !== 'all' || query.length > 0; + const matching = items.filter(item => { + if (selectedProject !== 'all' && item.project !== selectedProject) return false; + if (type.value !== 'all' && item.type !== type.value) return false; + const haystack = [item.title.zh, item.title.en, item.description.zh, item.description.en, + item.speaker || '', item.author || '', item.publisher || '', item.platform, projects[item.project]].join(' ').normalize('NFKC').toLocaleLowerCase(); + return !query || haystack.includes(query); + }); + const pageCount = Math.max(1, Math.ceil(matching.length / pageSize)); + currentPage = Math.max(1, Math.min(currentPage, pageCount)); + const start = (currentPage - 1) * pageSize; + const end = Math.min(start + pageSize, matching.length); + section.querySelectorAll('[data-resource-count]').forEach(element => { + const project = element.dataset.resourceCount; + element.textContent = String(project === 'all' ? items.length : items.filter(item => item.project === project).length); + }); + filters.querySelectorAll('[data-resource-project]').forEach(button => { + const active = button.dataset.resourceProject === selectedProject; + button.classList.toggle('active', active); + button.setAttribute('aria-pressed', String(active)); + }); + reset.hidden = !hasFilters; + retry.hidden = !failed; + retry.disabled = loading; + results.setAttribute('aria-busy', String(loading)); + results.innerHTML = loading || failed ? '' : matching.slice(start, end).map(item => renderCard(item, lang, t)).join(''); + pagination.hidden = loading || failed || pageCount <= 1; + previousPage.disabled = currentPage === 1; + nextPage.disabled = currentPage === pageCount; + pageStatus.textContent = lang === 'zh' + ? `第 ${currentPage} / ${pageCount} 页 · 每页 ${pageSize} 条` + : `Page ${currentPage} of ${pageCount} · ${pageSize} per page`; + empty.hidden = !loading && !failed && matching.length > 0; + if (loading) { + count.textContent = t.loading; + emptyTitle.textContent = t.loading; + emptyDescription.textContent = t.loadingDescription; + } else if (failed) { + count.textContent = t.errorTitle; + emptyTitle.textContent = t.errorTitle; + emptyDescription.textContent = t.errorDescription; + } else { + const range = matching.length ? `${start + 1}–${end}` : '0'; + count.textContent = lang === 'zh' + ? `显示 ${range} / ${matching.length} ${t.noun}${hasFilters ? `(全部 ${items.length} ${t.noun})` : ''}` + : `Showing ${range} of ${matching.length} ${t.noun}${hasFilters ? ` (${items.length} total)` : ''}`; + emptyTitle.textContent = hasFilters ? t.noResults : t.emptyTitle; + const projectContext = selectedProject === 'all' ? '' : (lang === 'zh' + ? `${projects[selectedProject]} 暂未收录符合条件的资源。` + : `There are no matching resources for ${projects[selectedProject]}. `); + emptyDescription.textContent = hasFilters ? projectContext + t.noResultsDescription : t.emptyDescription; + } + } + + function translate() { + const t = copy[language()]; + document.querySelectorAll(`[data-${prefix}-i18n]`).forEach(element => { + const value = t[element.getAttribute(`data-${prefix}-i18n`)]; + if (value !== undefined) element.textContent = value; + }); + filters.setAttribute('aria-label', t.projectLabel); + pagination.setAttribute('aria-label', t.paginationLabel); + results.setAttribute('aria-label', t.resultsLabel); + search.placeholder = t.searchPlaceholder; + render(); + } + + async function load() { + if (loading) return; + loading = true; + failed = false; + render(); + try { + // The catalog changes independently of the page and must not reuse an old response. + const response = await fetch(`${catalog}?v=${Date.now()}`, {cache: 'no-store'}); + if (!response.ok) throw new Error('Resources unavailable'); + const records = await response.json(); + if (!Array.isArray(records)) throw new Error('Invalid resource catalog'); + const seen = new Set(); + items = records.map(validate).filter(item => { + if (!item || seen.has(item.id)) return false; + seen.add(item.id); + return true; + }); + } catch { + items = []; + failed = true; + } finally { + loading = false; + render(); + } + } + + filters.querySelectorAll('[data-resource-project]').forEach(button => { + button.addEventListener('click', () => { + const project = button.dataset.resourceProject; + if (project !== 'all' && !Object.hasOwn(projects, project)) return; + selectedProject = project; + currentPage = 1; + render(); + }); + }); + const resetPage = () => {currentPage = 1; render();}; + search.addEventListener('input', resetPage); + type.addEventListener('change', resetPage); + reset.addEventListener('click', () => { + selectedProject = 'all'; + search.value = ''; + type.value = 'all'; + currentPage = 1; + render(); + search.focus({preventScroll: true}); + }); + function changePage(step) { + currentPage += step; + render(); + results.focus({preventScroll: true}); + results.scrollIntoView({block: 'start', behavior: 'instant'}); + } + previousPage.addEventListener('click', () => changePage(-1)); + nextPage.addEventListener('click', () => changePage(1)); + retry.addEventListener('click', load); + new MutationObserver(translate).observe(document.documentElement, {attributes: true, attributeFilter: ['lang']}); + translate(); + load(); + } + + initLibrary({sectionId: 'video-resources', prefix: 'resource', copy: videoCopy, + types: ['tutorial', 'talk'], catalog: 'assets/video-resources.json'}); + initLibrary({sectionId: 'article-resources', prefix: 'article', copy: articleCopy, + types: ['tutorial', 'technical'], catalog: 'assets/article-resources.json'}); +})(); diff --git a/styles.css b/styles.css index dfbb1cb..42bc35a 100644 --- a/styles.css +++ b/styles.css @@ -272,10 +272,10 @@ html[lang=zh-CN] .hero-description{font-size:20px;max-width:550px;line-height:1. /* Four dedicated-engine examples retain balanced two-column cards on desktop. */ /* Curated links for learning and project talks. */ -.video-resources{padding-top:96px;padding-bottom:100px;border-top:1px solid var(--line);scroll-margin-top:24px} -.video-resources .section-heading{align-items:flex-start} -.video-resources .section-heading h2{max-width:650px;font-size:clamp(34px,4vw,52px);line-height:1.3;letter-spacing:-.04em;text-wrap:balance} -.video-resources .section-heading>p{max-width:340px;margin-top:5px} +.learning-resources{padding-top:96px;padding-bottom:100px;border-top:1px solid var(--line);scroll-margin-top:24px} +.learning-resources .section-heading{align-items:flex-start} +.learning-resources .section-heading h2{max-width:650px;font-size:clamp(34px,4vw,52px);line-height:1.3;letter-spacing:-.04em;text-wrap:balance} +.learning-resources .section-heading>p{max-width:340px;margin-top:5px} .resource-library{border:1px solid #29415b;border-radius:14px;background:linear-gradient(135deg,#0f1d32,#091426);overflow:hidden} .resource-library-heading{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:23px 28px;border-bottom:1px solid var(--line)} .resource-library-heading>span{font:12px/1.6 var(--mono);letter-spacing:.09em;color:#7db8d7} @@ -319,6 +319,9 @@ html[lang=zh-CN] .hero-description{font-size:20px;max-width:550px;line-height:1. .resource-tools>.resource-reset{min-height:48px;flex:none} .resource-reset:hover{background:#1b405f}.resource-reset[hidden],.resource-empty[hidden]{display:none} .resource-note{color:#8fa6c1;font-size:14px;line-height:1.8;margin:22px 0 0}.resource-note a{color:#9ad8ff;text-decoration:underline;text-underline-offset:4px} -@media(max-width:900px){.video-resources .section-heading{flex-direction:column;gap:22px}.video-resources .section-heading>p{max-width:620px}.resource-results{grid-template-columns:1fr}} -@media(max-width:680px){.video-resources{padding-top:64px;padding-bottom:64px}.video-resources .section-heading h2{font-size:35px;max-width:430px;line-height:1.35}.resource-library-heading{padding:19px 20px;flex-wrap:wrap;gap:8px}.resource-library-heading>span{font-size:11px}.resource-library-heading p{font-size:13px}.resource-project-filters{padding:20px 18px 0;gap:8px}.resource-project-button{font-size:13px;padding:9px 11px}.resource-tools{padding:20px 18px;flex-direction:column;align-items:stretch;gap:16px}.resource-type-field{width:100%}.resource-empty{padding:36px 22px;gap:22px;flex-direction:column;text-align:center}.resource-empty-art{width:78px;height:78px;border-radius:20px}.resource-empty-art svg{width:52px;height:52px}.resource-empty-copy h3{font-size:21px}.resource-empty-copy p{font-size:15px}.resource-empty-actions{justify-content:center}.resource-results{padding:18px;gap:16px}.resource-card{padding:22px}.resource-card h3{font-size:21px}} +@media(max-width:900px){.learning-resources .section-heading{flex-direction:column;gap:22px}.learning-resources .section-heading>p{max-width:620px}.resource-results{grid-template-columns:1fr}} +@media(max-width:680px){.learning-resources{padding-top:64px;padding-bottom:64px}.learning-resources .section-heading h2{font-size:35px;max-width:430px;line-height:1.35}.resource-library-heading{padding:19px 20px;flex-wrap:wrap;gap:8px}.resource-library-heading>span{font-size:11px}.resource-library-heading p{font-size:13px}.resource-project-filters{padding:20px 18px 0;gap:8px}.resource-project-button{font-size:13px;padding:9px 11px}.resource-tools{padding:20px 18px;flex-direction:column;align-items:stretch;gap:16px}.resource-type-field{width:100%}.resource-empty{padding:36px 22px;gap:22px;flex-direction:column;text-align:center}.resource-empty-art{width:78px;height:78px;border-radius:20px}.resource-empty-art svg{width:52px;height:52px}.resource-empty-copy h3{font-size:21px}.resource-empty-copy p{font-size:15px}.resource-empty-actions{justify-content:center}.resource-results{padding:18px;gap:16px}.resource-card{padding:22px}.resource-card h3{font-size:21px}} @media(prefers-reduced-motion:reduce){.resource-project-button{transition:none}} + +.resource-other-library{display:inline-flex;align-items:center;min-height:44px;margin:-10px 0 24px;color:#96d6ff;font-size:14px;text-decoration:underline;text-underline-offset:5px} +.resource-other-library:hover{color:#d1f4ff} diff --git a/video-resources.js b/video-resources.js deleted file mode 100644 index 13d9a03..0000000 --- a/video-resources.js +++ /dev/null @@ -1,251 +0,0 @@ -'use strict'; -(() => { - const section = document.querySelector('#video-resources'); - if (!section) return; - - const projects = { - omni: 'vLLM-Omni', afd: 'afd-plugin', router: 'vLLM Router', - agent: 'agent-infer', rlt: 'vllm-rlt', mlx: 'vllm-omni-mlx', inferrsi: 'InferRSI' - }; - const copy = { - zh: { - nav: '视频资源', kicker: '学习与分享', title: '从了解项目,到动手实践。', - intro: '汇集 InferMatrix 各项目的学习教程、技术分享与宣讲回放。找到感兴趣的项目,前往原平台观看。', - searchLabel: '搜索视频', searchPlaceholder: '搜索标题、讲者或关键词', typeLabel: '内容类型', - allTypes: '全部类型', tutorial: '学习教程', talk: '技术分享 / 宣讲', allProjects: '全部项目', - projectLabel: '按项目筛选', reset: '重置筛选', retry: '重新加载', - note: '视频链接将前往原发布平台;内容持续整理更新。', - loading: '正在加载视频资源…', loadingDescription: '正在读取学习教程与技术分享列表。', - emptyTitle: '第一批视频,正在整理中。', - emptyDescription: '这里将持续收录各项目的学习教程、技术分享与宣讲回放。欢迎稍后回来看看。', - noResults: '没有找到相关视频。', - noResultsDescription: '试试其他项目或内容类型,或调整搜索关键词。', - errorTitle: '视频资源暂时无法加载。', - errorDescription: '请稍后重新加载,获取最新的视频列表。', - watch: '前往观看', newWindow: '在新窗口打开', speaker: '讲者', duration: '时长', - previousPage: '上一页', nextPage: '下一页', paginationLabel: '视频资源分页', resultsLabel: '视频资源列表' - }, - en: { - nav: 'Videos', kicker: 'LEARN & SHARE', title: 'Get to know a project. Start building.', - intro: 'Explore tutorials, technical talks and presentation recordings from the InferMatrix ecosystem. Find your project and watch on the original platform.', - searchLabel: 'Search videos', searchPlaceholder: 'Search titles, speakers or keywords', typeLabel: 'Content type', - allTypes: 'All types', tutorial: 'Tutorial', talk: 'Talk / Presentation', allProjects: 'All projects', - projectLabel: 'Filter by project', reset: 'Reset filters', retry: 'Try again', - note: 'Video links open on their original platforms. More resources will be added over time.', - loading: 'Loading video resources…', loadingDescription: 'Fetching tutorials and technical talks.', - emptyTitle: 'The first videos are on their way.', - emptyDescription: 'Project tutorials, technical talks and presentation recordings will be collected here. Check back soon.', - noResults: 'No matching videos.', - noResultsDescription: 'Try another project or content type, or change your search keywords.', - errorTitle: 'Video resources could not be loaded.', - errorDescription: 'Please try again shortly to load the latest video list.', - watch: 'Watch video', newWindow: 'opens in a new window', speaker: 'Speaker', duration: 'Duration', - previousPage: 'Previous', nextPage: 'Next', paginationLabel: 'Video resource pagination', resultsLabel: 'Video resources' - } - }; - const language = () => document.documentElement.lang.startsWith('zh') ? 'zh' : 'en'; - const esc = value => String(value).replace(/[&<>"']/g, character => ({ - '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' - }[character])); - const filters = section.querySelector('#resource-project-filters'); - const search = section.querySelector('#resource-search'); - const type = section.querySelector('#resource-type'); - const count = section.querySelector('#resource-count'); - const results = section.querySelector('#resource-results'); - const empty = section.querySelector('#resource-empty'); - const emptyTitle = section.querySelector('#resource-empty-title'); - const emptyDescription = section.querySelector('#resource-empty-description'); - const reset = section.querySelector('#resource-reset'); - const retry = section.querySelector('#resource-retry'); - const pagination = section.querySelector('#resource-pagination'); - const previousPage = section.querySelector('#resource-previous-page'); - const nextPage = section.querySelector('#resource-next-page'); - const pageStatus = section.querySelector('#resource-page-status'); - const pageSize = 6; - let items = []; - let selectedProject = 'all'; - let currentPage = 1; - let loading = false; - let failed = false; - - function bilingual(value, allowEmpty = false) { - return value && typeof value === 'object' && ['zh', 'en'].every(lang => - typeof value[lang] === 'string' && (allowEmpty || value[lang].trim().length > 0)); - } - - // Invalid records are omitted individually so one broken entry cannot hide the catalog. - function validate(record) { - if (!record || typeof record !== 'object' || - typeof record.id !== 'string' || !record.id.trim() || - typeof record.project !== 'string' || !Object.hasOwn(projects, record.project) || - !['tutorial', 'talk'].includes(record.type) || - !bilingual(record.title) || !bilingual(record.description, true) || - typeof record.platform !== 'string' || !record.platform.trim() || - typeof record.url !== 'string') return null; - let url; - try { - url = new URL(record.url); - if (url.protocol !== 'https:' || url.username || url.password) return null; - } catch { return null; } - const item = { - id: record.id.trim(), project: record.project, type: record.type, - title: {zh: record.title.zh.trim(), en: record.title.en.trim()}, - description: {zh: record.description.zh.trim(), en: record.description.en.trim()}, - platform: record.platform.trim(), url: url.href - }; - for (const field of ['speaker', 'duration', 'publisher']) { - if (typeof record[field] === 'string' && record[field].trim()) item[field] = record[field].trim(); - } - if (typeof record.publishedAt === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(record.publishedAt)) { - const date = new Date(`${record.publishedAt}T00:00:00Z`); - if (!Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === record.publishedAt) { - item.publishedAt = record.publishedAt; - } - } - return item; - } - - function renderCard(item, lang, t) { - const metadata = [`${esc(item.platform)}`]; - if (item.publisher) metadata.push(`${esc(item.publisher)}`); - if (item.speaker) metadata.push(`${esc(t.speaker)} · ${esc(item.speaker)}`); - if (item.publishedAt) metadata.push(``); - if (item.duration) metadata.push(`${esc(t.duration)} · ${esc(item.duration)}`); - return `
-
${esc(projects[item.project])}${esc(t[item.type])}
-

${esc(item.title[lang])}

-

${esc(item.description[lang])}

-
${metadata.join('')}
- ${esc(t.watch)} -
`; - } - - function render() { - const lang = language(), t = copy[lang]; - const query = search.value.trim().normalize('NFKC').toLocaleLowerCase(); - const hasFilters = selectedProject !== 'all' || type.value !== 'all' || query.length > 0; - const matching = items.filter(item => { - if (selectedProject !== 'all' && item.project !== selectedProject) return false; - if (type.value !== 'all' && item.type !== type.value) return false; - const haystack = [item.title.zh, item.title.en, item.description.zh, item.description.en, - item.speaker || '', item.publisher || '', item.platform, projects[item.project]].join(' ').normalize('NFKC').toLocaleLowerCase(); - return !query || haystack.includes(query); - }); - const pageCount = Math.max(1, Math.ceil(matching.length / pageSize)); - currentPage = Math.max(1, Math.min(currentPage, pageCount)); - const start = (currentPage - 1) * pageSize; - const end = Math.min(start + pageSize, matching.length); - section.querySelectorAll('[data-resource-count]').forEach(element => { - const project = element.dataset.resourceCount; - element.textContent = String(project === 'all' ? items.length : items.filter(item => item.project === project).length); - }); - filters.querySelectorAll('[data-resource-project]').forEach(button => { - const active = button.dataset.resourceProject === selectedProject; - button.classList.toggle('active', active); - button.setAttribute('aria-pressed', String(active)); - }); - reset.hidden = !hasFilters; - retry.hidden = !failed; - retry.disabled = loading; - results.setAttribute('aria-busy', String(loading)); - results.innerHTML = loading || failed ? '' : matching.slice(start, end).map(item => renderCard(item, lang, t)).join(''); - pagination.hidden = loading || failed || pageCount <= 1; - previousPage.disabled = currentPage === 1; - nextPage.disabled = currentPage === pageCount; - pageStatus.textContent = lang === 'zh' - ? `第 ${currentPage} / ${pageCount} 页 · 每页 ${pageSize} 条` - : `Page ${currentPage} of ${pageCount} · ${pageSize} per page`; - empty.hidden = !loading && !failed && matching.length > 0; - if (loading) { - count.textContent = t.loading; - emptyTitle.textContent = t.loading; - emptyDescription.textContent = t.loadingDescription; - } else if (failed) { - count.textContent = t.errorTitle; - emptyTitle.textContent = t.errorTitle; - emptyDescription.textContent = t.errorDescription; - } else { - const range = matching.length ? `${start + 1}–${end}` : '0'; - count.textContent = lang === 'zh' - ? `显示 ${range} / ${matching.length} 个视频${hasFilters ? `(全部 ${items.length} 个)` : ''}` - : `Showing ${range} of ${matching.length} videos${hasFilters ? ` (${items.length} total)` : ''}`; - emptyTitle.textContent = hasFilters ? t.noResults : t.emptyTitle; - const projectContext = selectedProject === 'all' ? '' : (lang === 'zh' - ? `${projects[selectedProject]} 暂未收录符合条件的视频。` - : `There are no matching videos for ${projects[selectedProject]}. `); - emptyDescription.textContent = hasFilters ? projectContext + t.noResultsDescription : t.emptyDescription; - } - } - - function translate() { - const t = copy[language()]; - document.querySelectorAll('[data-resource-i18n]').forEach(element => { - const value = t[element.dataset.resourceI18n]; - if (value !== undefined) element.textContent = value; - }); - filters.setAttribute('aria-label', t.projectLabel); - pagination.setAttribute('aria-label', t.paginationLabel); - results.setAttribute('aria-label', t.resultsLabel); - search.placeholder = t.searchPlaceholder; - render(); - } - - async function load() { - if (loading) return; - loading = true; - failed = false; - render(); - try { - // The catalog changes independently of the page and must not reuse an old response. - const response = await fetch(`assets/video-resources.json?v=${Date.now()}`, {cache: 'no-store'}); - if (!response.ok) throw new Error('Video resources unavailable'); - const records = await response.json(); - if (!Array.isArray(records)) throw new Error('Invalid video resource catalog'); - const seen = new Set(); - items = records.map(validate).filter(item => { - if (!item || seen.has(item.id)) return false; - seen.add(item.id); - return true; - }); - } catch { - items = []; - failed = true; - } finally { - loading = false; - render(); - } - } - - filters.querySelectorAll('[data-resource-project]').forEach(button => { - button.addEventListener('click', () => { - const project = button.dataset.resourceProject; - if (project !== 'all' && !Object.hasOwn(projects, project)) return; - selectedProject = project; - currentPage = 1; - render(); - }); - }); - const resetPage = () => {currentPage = 1; render();}; - search.addEventListener('input', resetPage); - type.addEventListener('change', resetPage); - reset.addEventListener('click', () => { - selectedProject = 'all'; - search.value = ''; - type.value = 'all'; - currentPage = 1; - render(); - search.focus({preventScroll: true}); - }); - function changePage(step) { - currentPage += step; - render(); - results.focus({preventScroll: true}); - results.scrollIntoView({block: 'start', behavior: 'instant'}); - } - previousPage.addEventListener('click', () => changePage(-1)); - nextPage.addEventListener('click', () => changePage(1)); - retry.addEventListener('click', load); - new MutationObserver(translate).observe(document.documentElement, {attributes: true, attributeFilter: ['lang']}); - translate(); - load(); -})(); From a7646a451c7bbeeab81f47c970f1863cc2c9a01b Mon Sep 17 00:00:00 2001 From: zengchuang Date: Fri, 30 Oct 2026 16:25:36 +0800 Subject: [PATCH 2/2] feat: switch learning resources with tabs --- README.md | 6 +- SHA256SUMS | 10 +-- index.html | 26 ++++---- qa-resources.cjs | 157 ++++++++++++++++++++++++++++++++++++++++++-- resource-library.js | 85 +++++++++++++++++++++--- styles.css | 11 +++- 6 files changed, 259 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 45c0ed4..5f24fac 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ for the optional dependency-free Node preview and interaction checklist. ## 视频资源 / Video resources -首页导航的「视频资源」指向 `#video-resources`,与「演示展示」分别维护。专栏收录学习教程、技术分享和宣讲回放的外部链接,点击后在新窗口打开原发布平台,不嵌入或下载视频。 +首页导航的「视频专栏」指向 `#video-resources`,与「演示展示」分别维护。专栏收录学习教程、技术分享和宣讲回放的外部链接,点击后在新窗口打开原发布平台,不嵌入或下载视频。 资源清单集中在 `assets/video-resources.json`。当前收录 11 条:vLLM-Omni 8 条、afd-plugin 2 条、vLLM Router 1 条;其他四个项目暂未找到可核实的教程或宣讲回放。用户指定合集分集时保留 `p` 参数,并移除分享链接中的跟踪参数;可用 B 站官方分页元数据核实分集标题、讲者(标题明确标注时)与时长。每添加一条资源,填写以下格式;示例仅说明字段,不是已收录的视频: @@ -118,7 +118,9 @@ The catalog is fetched with `cache: 'no-store'` and a fresh request parameter so 文章清单位于 `assets/article-resources.json`,与视频分开维护。字段沿用视频资源结构;类型为 `tutorial`(学习教程)或 `technical`(技术解读),可选作者字段为 `author`,无需填写 `speaker` 或 `duration`。每条记录保留 `sourceUrl`、`sourceNote` 和 `verifiedAt`,核实日期使用 `YYYY-MM-DD`。只有来源明确提供发布日期时才填写 `publishedAt`;持续更新的项目文档不使用仓库修改时间代替发布日期。 -`resource-library.js` initializes the video and article libraries independently using the same rendering and filtering logic. Each keeps its own project/type filters, search, page, loading and retry state. Both show at most six entries per page and preserve state on language changes. Article search includes authors; links and text use the same HTTPS validation and escaping as videos. `qa-resources.cjs` exercises both libraries together, including isolated fetch failures, invalid entries, pagination and language changes. +视频与文章在同一个 `#learning-resources` 区域,通过「视频专栏 / 文章专栏」页签切换,一次显示一个栏目。普通访问默认选择视频;`#video-resources` 与 `#article-resources` 深链接、首页对应导航仍可直达指定栏目。页签支持左右方向键循环切换及 Home / End;无 JavaScript 时保留两个资源清单的访问入口。 + +`resource-library.js` initializes the video and article libraries independently using the same rendering and filtering logic. Each keeps its own project/type filters, search, page, loading and retry state across tab and language changes. Both show at most six entries per page. Article search includes authors; links and text use the same HTTPS validation and escaping as videos. `qa-resources.cjs` exercises both libraries together, including accessible tab navigation, deep links, isolated fetch failures, invalid entries, pagination and language changes. ## 素材与许可 / Assets and licensing diff --git a/SHA256SUMS b/SHA256SUMS index 59007e8..269e250 100644 --- a/SHA256SUMS +++ b/SHA256SUMS @@ -5,7 +5,7 @@ dc2b68dea37313b64e7a5e585f68be9164e3ebfae84ed5614a28519159a68454 ATTRIBUTION.md fc44df75947d9534405fc9f5401de083dd2fab3e474a5f781348137ad306e10c BENCHMARK_EVIDENCE.json 00fe86be6eb9b9d39857f1833be8ab6d9ae6d39a9117a01ccfcdf3dc46a9f956 INFERRSI.md 9b37f8f61e35ca9998499716bd299a03f05d81208acae3bb35900807a0fc55ad LOCAL_PREVIEW.md -a8c3a134ad353fecb4656438c34d11b9109a18aba73acd0011122a12f7ba8442 README.md +cd924672b113b8d4b8cdc236a372beaf20641e2a04450a72a94db4eab511122a README.md 5655fa3532ac07d339e7c44de9118a08d2a83bf1cfeadef7395f08e440480902 SHOWCASE.md 0bc3d5f7bd34ac53892f39ab97de48a9d771ddbfc35676255fa46292e4dcae81 app.js 53cf5937ee480102fbabd8e26a52adf1e936a49c2c24d0fc1948465982466017 assets/architecture/afd.svg @@ -47,7 +47,7 @@ f1e5d4f2cc41c9c1da69dfb3523c5659012ac8169d06c267b165af5ecd2aa698 assets/showcas 30e9b46a6d06ab864bbc7ca0289143822d1192392074dc965b0683559f6911e0 assistant.css 71ba38902b5a33b28d7bb66f5a90d83a73cbc4ca9b0d037289b7abce221a30c2 assistant.js 1c13002720c8d65a3fc98084c708b9058827ab66740ae52105991cfebc60eb8e dom-fixture.json -feefe8709e65c5b74a5b1e761d66093d4d253f99b71a38426d7f6dfe44da3112 index.html +14f43be31694e4e0a5b416917ee02fa826a225fb13386ea6ecc8a1602474d878 index.html 9ec768282a4d526da0c76048ae1e037db9103084bd8e0d5662b22930f433660d inferrsi.js ae0fe01b8c81c20e4a607f8608b84ac0df3b42f9e38bcaaefde92cda5c63a782 initial-data.json 0e8566ea362276878e1f38f97da49964dbea301c15d4aa9d727f97a4c39fd943 licenses/afd-plugin-LICENSE.txt @@ -55,9 +55,9 @@ c71d239df91726fc519c6eb72d318ec65820627232b2f796219e87dcf35d0ab4 licenses/vllm- 19cc4e8b177647501dc4c8fa02ce38e90f0c1f3efe70fe2aa3409930bde7aae1 local-preview.cjs 250f9ee919523549bbabe67fa4585a2772a405f8364b2a370b4020e61aedb8b6 prepare-initial-content.cjs 5462843e0ed8edf7fbc2fcdca220ff496cbd0533a914ddd9c91d1e26b40d8f08 qa-interactions.cjs -a680892e61cd4ef0628cd907acdaba74e1d847e1be051987ed88118984952540 qa-resources.cjs -61cd2df6a4ae984b045a7c9d2063ef29b27e19f87cc1d2f95cc15795e4055366 resource-library.js +587d81c6a85f504e6df43ce8ae59b70aec771b521e1bdafa15e6c0d63e25619c qa-resources.cjs +071cfc59cbb253dbaec6a544db376be8c466c35ab6590ea9302e151cf4850b09 resource-library.js 51a3881b90c67476605580f3a859becf174b8bf67d37c5c1200324613f2ab42d robots.txt 79c5f0e08b8dc1cfcae28c9e95851c2ee4f3ab7e7eb9dce09ee5303522e17d22 showcase.js 741e991c7d0bc12c49acc061ba697dcc5e57946380c708cc823f7fadcb3d6485 sitemap.xml -de33071c1766e07fe866050c3cc3e192bb9c7a4bf9278fa7ffb64a2f292695c7 styles.css +71599431c0c1bc0bc73f7105ac319bb1ba6393e4532189aaad524729477d14e5 styles.css diff --git a/index.html b/index.html index a682618..093b024 100644 --- a/index.html +++ b/index.html @@ -14,11 +14,11 @@ - + - + @@ -33,7 +33,7 @@ 品牌愿景 开源项目 演示展示 - 视频资源 + 视频专栏 文章专栏 InferRSI Recipes ↗ @@ -133,10 +133,14 @@

Open to Infer.
Built to Evolve.

视频由项目素材库提供;展示内容与适用条件以原始说明为准。点击后才加载视频,不自动播放。

-
-
LEARN /学习与分享
-

从了解项目,到动手实践。

汇集 InferMatrix 各项目的学习教程、技术分享与宣讲回放。找到感兴趣的项目,前往原平台观看。

- 阅读项目文章 ↗ +
+
LEARN /学习与分享
+

从了解项目,到动手实践。

汇集各项目的学习视频、技术文章与实践指南。按项目查找,选择适合你的学习方式。

+ +
INFERMATRIX / VIDEO LIBRARY

已收录 0 个视频

@@ -167,11 +171,8 @@

Open to Infer.
Built to Evolve.

视频链接将前往原发布平台;内容持续整理更新。

-
-
-
READ /阅读与实践
-

读懂技术,走近实现。

从官方博客、技术解读到实践指南,按项目探索 InferMatrix 的文章与文档。

- 观看学习视频 ↗ + +
INFERMATRIX / ARTICLE LIBRARY

已收录 0 篇文章

@@ -202,6 +203,7 @@

Open to Infer.
Built to Evolve.

文章链接将前往原发布平台;文档内容以项目当前版本为准。

+
03 /连接更多可能
diff --git a/qa-resources.cjs b/qa-resources.cjs index 3515578..b1e92d7 100644 --- a/qa-resources.cjs +++ b/qa-resources.cjs @@ -31,7 +31,7 @@ function record(kind, index, overrides = {}) { }; } -function harness({onlySection} = {}) { +function harness({onlySection, hash = ''} = {}) { const helperContext = vm.createContext({}); const helpers = vm.runInContext(`${domHelpers}\n({parseMarkup, setDocument(value) {doc = value;}})`, helperContext); const root = helpers.parseMarkup(markup).find(element => element.tagName === 'html'); @@ -43,17 +43,33 @@ function harness({onlySection} = {}) { } for (const input of root.querySelectorAll('input')) input.value = ''; for (const select of root.querySelectorAll('select')) select.value = 'all'; + const eventListeners = new Map(); + const location = {hash, pathname: '/', search: ''}; + const window = { + location, + addEventListener(name, callback) { + if (!eventListeners.has(name)) eventListeners.set(name, []); + eventListeners.get(name).push(callback); + } + }; + const history = { + replaceState(_state, _unused, url) {location.hash = new URL(url, 'http://127.0.0.1:8080/').hash;}, + pushState(_state, _unused, url) {location.hash = new URL(url, 'http://127.0.0.1:8080/').hash;} + }; + window.history = history; const document = { documentElement: root, + location, activeElement: null, querySelector: selector => root.querySelector(selector), querySelectorAll: selector => root.querySelectorAll(selector), - getElementById: id => root.querySelector(`#${id}`) + getElementById: id => root.querySelector(`#${id}`), + addEventListener(name, callback) {window.addEventListener(`document:${name}`, callback);} }; helpers.setDocument(document); const requests = [], observers = []; const context = vm.createContext({ - document, URL, + document, window, location, history, URL, MutationObserver: class { constructor(callback) {observers.push(callback);} observe() {} @@ -76,6 +92,23 @@ function harness({onlySection} = {}) { const search = (kind, query) => {control(kind, 'search').value = query; control(kind, 'search').dispatch('input');}; const type = (kind, value) => {control(kind, 'type').value = value; control(kind, 'type').dispatch('change');}; const language = lang => {root.lang = lang; observers.forEach(callback => callback());}; + const setHash = hash => { + location.hash = hash; + for (const callback of eventListeners.get('hashchange') || []) callback({type: 'hashchange'}); + }; + const key = (element, name) => { + const event = {target: element, key: name, defaultPrevented: false, + preventDefault() {this.defaultPrevented = true;}}; + for (let current = element; current; current = current.parentNode) current.dispatch('keydown', event); + return event; + }; + const activate = element => { + const event = {target: element, defaultPrevented: false, + preventDefault() {this.defaultPrevented = true;}}; + for (let current = element; current; current = current.parentNode) current.dispatch('click', event); + for (const callback of eventListeners.get('document:click') || []) callback(event); + return event; + }; async function respond(kind, records, {status = 200, reject = false, jsonError = false} = {}) { const request = requests.find(item => !item.handled && item.url.startsWith(`assets/${kind}-resources.json?`)); assert(request, `Pending ${kind} request exists`); @@ -87,7 +120,8 @@ function harness({onlySection} = {}) { }}); await flush(); } - return {root, document, requests, q, control, cards, ids, click, project, search, type, language, respond}; + return {root, document, location, requests, q, control, cards, ids, click, project, search, type, + language, respond, setHash, key, activate}; } async function testIndependentLibraries() { @@ -280,11 +314,124 @@ async function testMarkupAndSingleLibrary() { } } +async function testResourceTabs() { + const h = harness(); + const videoTab = h.q('#resource-tab-video'); + const articleTab = h.q('#resource-tab-article'); + const videoPanel = h.q('#video-resources'); + const articlePanel = h.q('#article-resources'); + assert.equal(h.q('#resource-tabs').hidden, false, 'JavaScript enables the tab controls'); + const tabIndex = element => element.tabIndex ?? Number(element.getAttribute('tabindex') || 0); + function selected(kind, {focused = false} = {}) { + const video = kind === 'video'; + assert.equal(videoPanel.hidden, !video); + assert.equal(articlePanel.hidden, video); + assert.equal(videoTab.getAttribute('aria-selected'), String(video)); + assert.equal(articleTab.getAttribute('aria-selected'), String(!video)); + assert.equal(tabIndex(videoTab), video ? 0 : -1); + assert.equal(tabIndex(articleTab), video ? -1 : 0); + if (focused) assert.equal(h.document.activeElement, video ? videoTab : articleTab); + } + selected('video'); + const outer = h.q('#learning-resources'); + assert(outer.contains(videoPanel) && outer.contains(articlePanel), 'Both libraries live in one resource section'); + assert.equal(videoTab.getAttribute('role'), 'tab'); + assert.equal(articleTab.getAttribute('role'), 'tab'); + for (const [tab, panel] of [[videoTab, videoPanel], [articleTab, articlePanel]]) { + assert.equal(tab.getAttribute('aria-controls'), panel.id); + assert.equal(panel.getAttribute('role'), 'tabpanel'); + assert.equal(panel.getAttribute('aria-labelledby'), tab.id); + assert.equal(tab.closest('[role="tablist"]'), videoTab.closest('[role="tablist"]')); + } + + h.activate(articleTab); + selected('article'); + h.activate(videoTab); + selected('video'); + assert.equal(h.key(videoTab, 'ArrowRight').defaultPrevented, true); + selected('article', {focused: true}); + h.key(articleTab, 'ArrowRight'); + selected('video', {focused: true}); + h.key(videoTab, 'ArrowLeft'); + selected('article', {focused: true}); + h.key(articleTab, 'Home'); + selected('video', {focused: true}); + h.key(videoTab, 'End'); + selected('article', {focused: true}); + assert.equal(h.key(articleTab, 'Escape').defaultPrevented, false, 'Unrelated keys are left to the browser'); + selected('article'); + + await h.respond('video', Array.from({length: 11}, (_, index) => record('video', index))); + await h.respond('article', Array.from({length: 14}, (_, index) => record('article', index))); + h.activate(videoTab); + h.search('video', 'Speaker'); + h.project('video', 'omni'); + h.click('video', 'next-page'); + const videoIds = [...h.ids('video')]; + h.activate(articleTab); + h.search('article', 'Author'); + h.project('article', 'omni'); + h.click('article', 'next-page'); + const articleIds = [...h.ids('article')]; + h.activate(videoTab); + selected('video'); + assert.equal(h.control('video', 'search').value, 'Speaker'); + assert.deepEqual([...h.ids('video')], videoIds); + assert.match(h.control('video', 'page-status').textContent, /第 2 \/ 2/); + h.activate(articleTab); + selected('article'); + assert.equal(h.control('article', 'search').value, 'Author'); + assert.deepEqual([...h.ids('article')], articleIds); + assert.match(h.control('article', 'page-status').textContent, /第 2 \/ 2/); + h.language('en'); + selected('article'); + assert.match(h.control('article', 'page-status').textContent, /Page 2 of 2/); + assert.equal(articleTab.querySelector('[data-learning-i18n="articles"]').textContent, 'Articles'); + assert.equal(videoTab.querySelector('[data-learning-i18n="videos"]').textContent, 'Videos'); + + h.setHash('#video-resources'); + selected('video'); + h.setHash('#article-resources'); + selected('article'); + h.setHash('#projects'); + selected('article'); + h.setHash('#learning-resources'); + selected('video'); + h.activate(videoTab); + h.activate(h.q('#main-nav a[href="#article-resources"]')); + selected('article'); + h.activate(h.q('#main-nav a[href="#video-resources"]')); + selected('video'); + + const direct = harness({hash: '#article-resources'}); + assert.equal(direct.q('#article-resources').hidden, false, 'An article deep link opens the article tab on first load'); + assert.equal(direct.q('#video-resources').hidden, true); + const unrelated = harness({hash: '#projects'}); + assert.equal(unrelated.q('#video-resources').hidden, false, 'Ordinary navigation starts with videos'); + assert.equal(unrelated.q('#article-resources').hidden, true); +} + +function testNoScriptFallback() { + const helperContext = vm.createContext({}); + const parse = vm.runInContext(`${domHelpers}\nparseMarkup`, helperContext); + const staticRoot = parse(markup).find(element => element.tagName === 'html'); + assert.equal(staticRoot.querySelector('#resource-tabs').hidden, true, 'Nonfunctional tab buttons are hidden without JavaScript'); + for (const kind of ['video', 'article']) { + const panel = staticRoot.querySelector(`#${kind}-resources`); + assert(panel, `${kind} resources exist in static HTML`); + assert.equal(panel.hidden, false, `${kind} fallback is not hidden before JavaScript initializes tabs`); + const fallback = panel.querySelector('noscript'); + assert(fallback?.querySelector(`a[href="assets/${kind}-resources.json"]`), `${kind} catalog remains reachable without JavaScript`); + } +} + (async () => { await testIndependentLibraries(); await testIndependentRecovery(); await testValidationAndEscaping(); await testMarkupAndSingleLibrary(); - console.log('PASS: independent video/article catalogs, six-item pagination, bilingual state, combined filters and author search, reset/focus, loading/error/retry isolation, invalid records, HTTPS/XSS escaping, and unique accessible controls.'); + await testResourceTabs(); + testNoScriptFallback(); + console.log('PASS: independent video/article catalogs, six-item pagination, bilingual state, combined filters and author search, reset/focus, loading/error/retry isolation, invalid records, HTTPS/XSS escaping, accessible tabs and keyboard switching, deep links, preserved library state, and no-JavaScript catalog fallbacks.'); console.log('Verification uses the existing VM DOM helper and current HTML; rendered layout remains a browser check.'); })().catch(error => {console.error(error); process.exitCode = 1;}); diff --git a/resource-library.js b/resource-library.js index dc02c80..108ed2f 100644 --- a/resource-library.js +++ b/resource-library.js @@ -6,8 +6,7 @@ }; const videoCopy = { zh: { - nav: '视频资源', otherLibrary: '阅读项目文章 ↗', noun: '个视频', kicker: '学习与分享', title: '从了解项目,到动手实践。', - intro: '汇集 InferMatrix 各项目的学习教程、技术分享与宣讲回放。找到感兴趣的项目,前往原平台观看。', + nav: '视频专栏', noun: '个视频', searchLabel: '搜索视频', searchPlaceholder: '搜索标题、讲者或关键词', typeLabel: '内容类型', allTypes: '全部类型', tutorial: '学习教程', talk: '技术分享 / 宣讲', allProjects: '全部项目', projectLabel: '按项目筛选', reset: '重置筛选', retry: '重新加载', @@ -23,8 +22,7 @@ previousPage: '上一页', nextPage: '下一页', paginationLabel: '视频资源分页', resultsLabel: '视频资源列表' }, en: { - nav: 'Videos', otherLibrary: 'Read project articles ↗', noun: 'videos', kicker: 'LEARN & SHARE', title: 'Get to know a project. Start building.', - intro: 'Explore tutorials, technical talks and presentation recordings from the InferMatrix ecosystem. Find your project and watch on the original platform.', + nav: 'Videos', noun: 'videos', searchLabel: 'Search videos', searchPlaceholder: 'Search titles, speakers or keywords', typeLabel: 'Content type', allTypes: 'All types', tutorial: 'Tutorial', talk: 'Talk / Presentation', allProjects: 'All projects', projectLabel: 'Filter by project', reset: 'Reset filters', retry: 'Try again', @@ -43,9 +41,7 @@ const articleCopy = { zh: { ...videoCopy.zh, - nav: '文章专栏', otherLibrary: '观看学习视频 ↗', noun: '篇文章', kicker: '阅读与实践', - title: '读懂技术,走近实现。', - intro: '从官方博客、技术解读到实践指南,按项目探索 InferMatrix 的文章与文档。', + nav: '文章专栏', noun: '篇文章', searchLabel: '搜索文章', searchPlaceholder: '搜索标题、作者或关键词', technical: '技术解读', note: '文章链接将前往原发布平台;文档内容以项目当前版本为准。', loading: '正在加载文章…', loadingDescription: '正在读取技术文章与实践指南。', @@ -57,9 +53,7 @@ }, en: { ...videoCopy.en, - nav: 'Articles', otherLibrary: 'Watch learning videos ↗', noun: 'articles', kicker: 'READ & BUILD', - title: 'Understand the ideas. Explore the implementation.', - intro: 'Explore official blogs, technical explainers and practical guides from across the InferMatrix projects.', + nav: 'Articles', noun: 'articles', searchLabel: 'Search articles', searchPlaceholder: 'Search titles, authors or keywords', technical: 'Technical article', note: 'Articles open on their original platforms. Check project documentation for the current version.', loading: 'Loading articles…', loadingDescription: 'Fetching technical articles and practical guides.', @@ -284,6 +278,77 @@ load(); } + function initLibraryTabs() { + const container = document.getElementById('learning-resources'); + const tablist = document.getElementById('resource-tabs'); + const tabs = [document.getElementById('resource-tab-video'), document.getElementById('resource-tab-article')]; + const panels = [document.getElementById('video-resources'), document.getElementById('article-resources')]; + if (!container || !tablist || tabs.some(tab => !tab) || panels.some(panel => !panel)) return; + const copy = { + zh: {kicker: '学习与分享', title: '从了解项目,到动手实践。', + intro: '汇集各项目的学习视频、技术文章与实践指南。按项目查找,选择适合你的学习方式。', + videos: '视频专栏', articles: '文章专栏', label: '学习资源栏目'}, + en: {kicker: 'LEARN & SHARE', title: 'Get to know a project. Start building.', + intro: 'Explore project videos, technical articles and practical guides. Find your project and choose how you learn.', + videos: 'Videos', articles: 'Articles', label: 'Learning resource libraries'} + }; + const indexFromHash = () => panels.findIndex(panel => `#${panel.id}` === window.location.hash); + function activate(index, {focus = false, updateHash = false} = {}) { + tabs.forEach((tab, i) => { + tab.setAttribute('aria-selected', String(i === index)); + tab.setAttribute('tabindex', i === index ? '0' : '-1'); + panels[i].hidden = i !== index; + }); + if (focus) tabs[index].focus({preventScroll: true}); + if (updateHash) window.history.replaceState(null, '', `#${panels[index].id}`); + } + function translateTabs() { + const t = copy[document.documentElement.lang.startsWith('zh') ? 'zh' : 'en']; + container.querySelectorAll('[data-learning-i18n]').forEach(element => { + element.textContent = t[element.dataset.learningI18n]; + }); + tablist.setAttribute('aria-label', t.label); + } + panels.forEach((panel, i) => { + panel.setAttribute('role', 'tabpanel'); + panel.setAttribute('aria-labelledby', tabs[i].id); + panel.setAttribute('tabindex', '0'); + }); + tabs.forEach((tab, index) => { + tab.addEventListener('click', () => activate(index, {updateHash: true})); + tab.addEventListener('keydown', event => { + const keys = {ArrowRight: (index + 1) % tabs.length, ArrowLeft: (index + tabs.length - 1) % tabs.length, + Home: 0, End: tabs.length - 1}; + if (!Object.hasOwn(keys, event.key)) return; + event.preventDefault(); + activate(keys[event.key], {focus: true, updateHash: true}); + }); + }); + // Keep existing video/article links useful, including repeated clicks on the current hash. + panels.forEach((panel, index) => { + document.querySelectorAll(`a[href="#${panel.id}"]`).forEach(link => { + link.addEventListener('click', event => { + event.preventDefault(); + activate(index, {focus: true, updateHash: true}); + container.scrollIntoView({block: 'start', behavior: 'instant'}); + }); + }); + }); + window.addEventListener('hashchange', () => { + const index = indexFromHash(); + if (index < 0 && window.location.hash !== '#learning-resources') return; + activate(Math.max(0, index)); + container.scrollIntoView({block: 'start', behavior: 'instant'}); + }); + const initialIndex = indexFromHash(); + activate(Math.max(0, initialIndex)); + tablist.hidden = false; + translateTabs(); + new MutationObserver(translateTabs).observe(document.documentElement, {attributes: true, attributeFilter: ['lang']}); + if (initialIndex >= 0) container.scrollIntoView({block: 'start', behavior: 'instant'}); + } + + initLibraryTabs(); initLibrary({sectionId: 'video-resources', prefix: 'resource', copy: videoCopy, types: ['tutorial', 'talk'], catalog: 'assets/video-resources.json'}); initLibrary({sectionId: 'article-resources', prefix: 'article', copy: articleCopy, diff --git a/styles.css b/styles.css index 42bc35a..d75a6ef 100644 --- a/styles.css +++ b/styles.css @@ -323,5 +323,12 @@ html[lang=zh-CN] .hero-description{font-size:20px;max-width:550px;line-height:1. @media(max-width:680px){.learning-resources{padding-top:64px;padding-bottom:64px}.learning-resources .section-heading h2{font-size:35px;max-width:430px;line-height:1.35}.resource-library-heading{padding:19px 20px;flex-wrap:wrap;gap:8px}.resource-library-heading>span{font-size:11px}.resource-library-heading p{font-size:13px}.resource-project-filters{padding:20px 18px 0;gap:8px}.resource-project-button{font-size:13px;padding:9px 11px}.resource-tools{padding:20px 18px;flex-direction:column;align-items:stretch;gap:16px}.resource-type-field{width:100%}.resource-empty{padding:36px 22px;gap:22px;flex-direction:column;text-align:center}.resource-empty-art{width:78px;height:78px;border-radius:20px}.resource-empty-art svg{width:52px;height:52px}.resource-empty-copy h3{font-size:21px}.resource-empty-copy p{font-size:15px}.resource-empty-actions{justify-content:center}.resource-results{padding:18px;gap:16px}.resource-card{padding:22px}.resource-card h3{font-size:21px}} @media(prefers-reduced-motion:reduce){.resource-project-button{transition:none}} -.resource-other-library{display:inline-flex;align-items:center;min-height:44px;margin:-10px 0 24px;color:#96d6ff;font-size:14px;text-decoration:underline;text-underline-offset:5px} -.resource-other-library:hover{color:#d1f4ff} +.resource-tabs{display:flex;gap:8px;margin:0 0 28px;padding:6px;width:fit-content;max-width:100%;border:1px solid #2a4562;border-radius:10px;background:#0a172a} +.resource-tabs[hidden],.resource-panel[hidden]{display:none} +.resource-tabs button{display:inline-flex;align-items:center;justify-content:center;gap:12px;min-height:48px;min-width:170px;padding:12px 24px;border:1px solid transparent;border-radius:6px;background:transparent;color:#98afca;font-size:16px;line-height:1.4;transition:background .2s,color .2s,border-color .2s} +.resource-tabs button>span:first-child{font-size:14px;color:#759bbb} +.resource-tabs button:hover{color:#e2f3ff;background:#142b44} +.resource-tabs button[aria-selected=true]{border-color:#3b8d9e;background:#123541;color:#c2f2f7} +.resource-tabs button[aria-selected=true]>span:first-child{color:#82d7e4} +@media(max-width:680px){.resource-tabs{width:100%;gap:4px;margin-bottom:22px}.resource-tabs button{flex:1;min-width:0;padding:12px 14px;font-size:15px;gap:9px}} +@media(prefers-reduced-motion:reduce){.resource-tabs button{transition:none}}