From ae4100536cd897464a1257b5c033eedcc08516df Mon Sep 17 00:00:00 2001 From: Machillka Date: Fri, 28 Aug 2026 10:36:08 +0800 Subject: [PATCH] fix(share): add stable short links for Chinese routes - generate and validate stable short IDs during build - use the configured base URL for shared links - add a VitePress-native copy dropdown on every page - publish and verify short-link artifacts in CI - cover collisions, redirects, fallbacks, and UI states --- .github/workflows/deploey.yml | 24 +- .gitignore | 1 + .vitepress/config.mts | 7 +- .vitepress/data/share-links.json | 765 ++++++++++++++++++ .../scripts/check-share-link-artifacts.ts | 28 + .vitepress/scripts/check-share-links.ts | 15 + .../scripts/health-check-short-links.ts | 51 ++ .vitepress/scripts/prepare-share-links.ts | 24 + .vitepress/scripts/publish-short-links.ts | 51 ++ .vitepress/shared/share-link-contract.ts | 97 +++ .vitepress/shared/site-config.ts | 1 + .vitepress/theme/components/ShareButton.vue | 228 ++++++ .../theme/components/share-button-state.ts | 69 ++ .vitepress/theme/index.ts | 2 + .../services/default-page-share-service.ts | 11 + .../theme/services/page-share-context.ts | 19 + .../theme/services/page-share-service.ts | 146 ++++ .vitepress/theme/services/share-link-query.ts | 109 +++ .../utilities/share-link-artifact-check.ts | 76 ++ .../utilities/share-link-manifest-plugin.ts | 59 ++ .../utilities/share-link-registry-files.ts | 262 ++++++ .vitepress/utilities/share-links.ts | 300 +++++++ .vitepress/utilities/short-link-pages.ts | 275 +++++++ package.json | 12 +- .../C++/Exception.md" | 424 ++++++++++ tests/unit/page-share-service.test.ts | 71 ++ tests/unit/share-button-state.test.ts | 71 ++ tests/unit/share-link-artifact-check.test.ts | 18 + tests/unit/share-link-contract.test.ts | 45 ++ tests/unit/share-link-manifest-plugin.test.ts | 24 + tests/unit/share-link-query.test.ts | 69 ++ tests/unit/share-link-registry-files.test.ts | 199 +++++ tests/unit/share-links.test.ts | 163 ++++ tests/unit/short-link-pages.test.ts | 72 ++ 34 files changed, 3782 insertions(+), 6 deletions(-) create mode 100644 .vitepress/data/share-links.json create mode 100644 .vitepress/scripts/check-share-link-artifacts.ts create mode 100644 .vitepress/scripts/check-share-links.ts create mode 100644 .vitepress/scripts/health-check-short-links.ts create mode 100644 .vitepress/scripts/prepare-share-links.ts create mode 100644 .vitepress/scripts/publish-short-links.ts create mode 100644 .vitepress/shared/share-link-contract.ts create mode 100644 .vitepress/shared/site-config.ts create mode 100644 .vitepress/theme/components/ShareButton.vue create mode 100644 .vitepress/theme/components/share-button-state.ts create mode 100644 .vitepress/theme/services/default-page-share-service.ts create mode 100644 .vitepress/theme/services/page-share-context.ts create mode 100644 .vitepress/theme/services/page-share-service.ts create mode 100644 .vitepress/theme/services/share-link-query.ts create mode 100644 .vitepress/utilities/share-link-artifact-check.ts create mode 100644 .vitepress/utilities/share-link-manifest-plugin.ts create mode 100644 .vitepress/utilities/share-link-registry-files.ts create mode 100644 .vitepress/utilities/share-links.ts create mode 100644 .vitepress/utilities/short-link-pages.ts create mode 100644 "posts/\347\274\226\347\250\213\350\257\255\350\250\200/C++/Exception.md" create mode 100644 tests/unit/page-share-service.test.ts create mode 100644 tests/unit/share-button-state.test.ts create mode 100644 tests/unit/share-link-artifact-check.test.ts create mode 100644 tests/unit/share-link-contract.test.ts create mode 100644 tests/unit/share-link-manifest-plugin.test.ts create mode 100644 tests/unit/share-link-query.test.ts create mode 100644 tests/unit/share-link-registry-files.test.ts create mode 100644 tests/unit/share-links.test.ts create mode 100644 tests/unit/short-link-pages.test.ts diff --git a/.github/workflows/deploey.yml b/.github/workflows/deploey.yml index dae9b6e..0b3e35f 100644 --- a/.github/workflows/deploey.yml +++ b/.github/workflows/deploey.yml @@ -40,6 +40,12 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Prepare stable share IDs + run: pnpm share-links:prepare + + - name: Require generated IDs to be committed + run: git diff --exit-code -- .vitepress/data/share-links.json + - name: Check committed secrets run: pnpm check:secrets @@ -53,7 +59,7 @@ jobs: run: pnpm test build: - name: Build VitePress site + name: Build YuuFrag and short-link artifacts if: github.event_name != 'pull_request' needs: quality runs-on: ubuntu-latest @@ -84,7 +90,21 @@ jobs: - name: Build with VitePress run: pnpm docs:build - - name: Upload artifact + - name: Verify stable registry remains clean + run: git diff --exit-code -- .vitepress/data/share-links.json + + - name: Verify both share-link artifacts + run: pnpm share-links:artifacts:check + + - name: Upload standalone short-link archive + uses: actions/upload-artifact@v4 + with: + name: yuufrag-shortlinks + path: .shortlink-dist + if-no-files-found: error + retention-days: 30 + + - name: Upload YuuFrag Pages artifact uses: actions/upload-pages-artifact@v4 with: path: .vitepress/dist diff --git a/.gitignore b/.gitignore index d03dfe3..0024efa 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,7 @@ .vitepress/dist .vitepress/.temp .vitepress/generated/ +.shortlink-dist/ public/contributors/ dist cache diff --git a/.vitepress/config.mts b/.vitepress/config.mts index 514c3d2..196e8dd 100644 --- a/.vitepress/config.mts +++ b/.vitepress/config.mts @@ -20,8 +20,9 @@ import { writeLegacyPageRedirects, } from "./utilities/page-route-plugin"; import { createPageRouteRewriter } from "./utilities/route-paths"; +import { ShareLinkManifestPlugin } from "./utilities/share-link-manifest-plugin"; +import { baseUrl } from "./shared/site-config"; -const baseUrl = "https://yuufrag.machillka.com"; const feedUrl = `${baseUrl}/feed.rss`; const contributorIndex = await prepareGithubContributors(contributorsConfig); @@ -124,6 +125,7 @@ export default defineConfig({ text: "分类", items: ScanCurrentDir("../../posts/", "posts"), }, + { component: "ShareButton" }, ], sidebar: { "/guide/": [ @@ -151,6 +153,9 @@ export default defineConfig({ vite: { plugins: [ PageRoutePlugin(), + ShareLinkManifestPlugin({ + manifestFile: ".vitepress/generated/share-links-manifest.json", + }), RssPlugin(RSS), RssAssetPlugin({ baseUrl, filename: "feed.rss" }), ], diff --git a/.vitepress/data/share-links.json b/.vitepress/data/share-links.json new file mode 100644 index 0000000..3fad173 --- /dev/null +++ b/.vitepress/data/share-links.json @@ -0,0 +1,765 @@ +{ + "version": 1, + "records": { + "22btgecyrm": { + "pageId": "posts/游戏开发/ChikaEngine/Profiler/Profiler01-宏定义.md", + "status": "active" + }, + "294erjc4vc": { + "pageId": "posts/编程语言/Golang/Benchmark.md", + "status": "active" + }, + "2b4uvjgaag": { + "pageId": "posts/软件工程/后端工程/幂等.md", + "status": "active" + }, + "2k3qcr5mz5": { + "pageId": "posts/数据结构与算法/Manacher.md", + "status": "active" + }, + "2p3rcad5rx": { + "pageId": "posts/编程语言/Golang/WorkerPool.md", + "status": "active" + }, + "2pysfnhn38": { + "pageId": "posts/人工智能/物语系列 GPT-SoVITS 模型训练记录.md", + "status": "active" + }, + "2ufq576k5m": { + "pageId": "posts/美术/板绘入手/图形概括.md", + "status": "active" + }, + "3ewcmvf2da": { + "pageId": "posts/数据结构与算法/KMP.md", + "status": "active" + }, + "3p4z7jntvr": { + "pageId": "posts/游戏开发/ChikaEngine/Job System/Job System.md", + "status": "active" + }, + "3q3k3ndvvz": { + "pageId": "posts/随笔/关于什么是游戏引擎的乱想.md", + "status": "active" + }, + "3r7gapu6ym": { + "pageId": "posts/软件工程/项目实践/web-chatroom/数据库基础实现.md", + "status": "active" + }, + "3u5wcdtqhr": { + "pageId": "posts/计算机图形学/课程笔记-GAMES101/Depth testing.md", + "status": "active" + }, + "3wyq7rd64z": { + "pageId": "posts/游戏开发/ECS.md", + "status": "active" + }, + "43nzuyeyp5": { + "pageId": "posts/数据结构与算法/树形结构/LCA.md", + "status": "active" + }, + "46shdeyueh": { + "pageId": "posts/数据结构与算法/图论/拓扑排序.md", + "status": "active" + }, + "49zx3eqejn": { + "pageId": "posts/软件工程/架构与设计/MVVM.md", + "status": "active" + }, + "4hedh6guzy": { + "pageId": "posts/编程语言/Rust/Rust 异常处理.md", + "status": "active" + }, + "4mbzanac4y": { + "pageId": "posts/编程语言/C++/Effective C++ Item 11.md", + "status": "active" + }, + "4mnscu6cj9": { + "pageId": "posts/软件工程/设计模式/工厂模式.md", + "status": "active" + }, + "4skec73bnn": { + "pageId": "posts/游戏开发/引擎设计/Asset Hot Reload.md", + "status": "active" + }, + "4tye8r57hs": { + "pageId": "posts/计算机图形学/课程笔记-GAMES101/OpenGL逻辑简述.md", + "status": "active" + }, + "4xuwz3dy4e": { + "pageId": "posts/数学/蒙特卡罗方法.md", + "status": "active" + }, + "54f5vm8292": { + "pageId": "posts/编程语言/C++/Type Traits.md", + "status": "active" + }, + "58adtdrqtx": { + "pageId": "posts/计算机图形学/index.md", + "status": "active" + }, + "5aaf6vt325": { + "pageId": "posts/游戏开发/Unreal/UE输入系统.md", + "status": "active" + }, + "5amk4xm54b": { + "pageId": "posts/数据结构与算法/Trie 前缀树.md", + "status": "active" + }, + "5jdyw8a3mz": { + "pageId": "posts/软件工程/Handle.md", + "status": "active" + }, + "5szzjhyvnp": { + "pageId": "posts/编程语言/Golang/G-P-M调度模型.md", + "status": "active" + }, + "67y3y5msu3": { + "pageId": "posts/数据结构与算法/题解/LeetCode 214.md", + "status": "active" + }, + "6f46brqgn3": { + "pageId": "posts/数学/傅里叶级数.md", + "status": "active" + }, + "6g75wtq72w": { + "pageId": "posts/计算机图形学/课程笔记-GAMES101/渲染.md", + "status": "active" + }, + "6pve9vktba": { + "pageId": "posts/游戏开发/Unreal/Third Person Controller/03 锁定系统.md", + "status": "active" + }, + "6pvq5z9kmf": { + "pageId": "posts/编程语言/C++/PImpl.md", + "status": "active" + }, + "6rcfbgknqc": { + "pageId": "posts/编程语言/C++/并发模型/Thread 基础语法.md", + "status": "active" + }, + "6y4qxp6tuf": { + "pageId": "posts/编程语言/C++/span.md", + "status": "active" + }, + "6yuv3mnxpe": { + "pageId": "posts/软件工程/index.md", + "status": "active" + }, + "74fw5gjajx": { + "pageId": "posts/计算机系统/编译原理/编译基本流程.md", + "status": "active" + }, + "7jj9myt78n": { + "pageId": "posts/计算机系统/index.md", + "status": "active" + }, + "7mf7tkx7az": { + "pageId": "posts/游戏开发/ChikaEngine/Profiler/Profiler07-ThreadRegistry.md", + "status": "active" + }, + "7x9hqnhaq6": { + "pageId": "posts/编程语言/index.md", + "status": "active" + }, + "7yxjymb2p5": { + "pageId": "posts/计算机系统/操作系统与并发/临界区和临界资源.md", + "status": "active" + }, + "86q8exjxuw": { + "pageId": "posts/游戏开发/ChikaEngine/Render Dependency Graph.md", + "status": "active" + }, + "8bkm4hffu8": { + "pageId": "posts/编程语言/Golang/Test.md", + "status": "active" + }, + "8dy95qb7np": { + "pageId": "posts/游戏开发/Unreal/Rider踩坑.md", + "status": "active" + }, + "8gpg3rm9sn": { + "pageId": "posts/计算机图形学/课程笔记-GAMES101/几何.md", + "status": "active" + }, + "8vmpmqwy67": { + "pageId": "posts/软件工程/Generation 理解.md", + "status": "active" + }, + "8watwpex39": { + "pageId": "posts/编程语言/Golang/Golang 结构体.md", + "status": "active" + }, + "8xygjc8nwy": { + "pageId": "posts/编程语言/Golang/select 语句.md", + "status": "active" + }, + "95xmp9kkdj": { + "pageId": "posts/计算机系统/操作系统与并发/多级缓存和MESI.md", + "status": "active" + }, + "96hs58euum": { + "pageId": "posts/编程语言/Golang/GORM.md", + "status": "active" + }, + "97su4g9pbv": { + "pageId": "posts/随笔/无法驻留的冬天.md", + "status": "active" + }, + "9ac7xyb4ka": { + "pageId": "posts/软件工程/开发工具/LibclangPython.md", + "status": "active" + }, + "9fdnt7t29q": { + "pageId": "posts/软件工程/编程范式/简述函数式编程.md", + "status": "active" + }, + "9m4wh5pdd9": { + "pageId": "posts/编程语言/C++/并发模型/锁.md", + "status": "active" + }, + "9u3jcra4qz": { + "pageId": "posts/计算机图形学/课程笔记-GAMES101/Math/Linear.md", + "status": "active" + }, + "9vf5npjkc9": { + "pageId": "posts/软件工程/后端工程/HTTP基础.md", + "status": "active" + }, + "aa2k7spt4e": { + "pageId": "posts/软件工程/项目实践/web-chatroom/WebChatRoom 总述.md", + "status": "active" + }, + "ad4e56s47s": { + "pageId": "posts/计算机图形学/BRDF.md", + "status": "active" + }, + "agy9y85h7p": { + "pageId": "posts/编程语言/CSharp/反射简述.md", + "status": "active" + }, + "apvk8d77gc": { + "pageId": "posts/编程语言/C++/Lambda.md", + "status": "active" + }, + "au9m7be2ma": { + "pageId": "posts/游戏开发/ChikaEngine/Profiler/Profiler03-NameRegistry.md", + "status": "active" + }, + "aukuuy5czv": { + "pageId": "posts/计算机图形学/延迟渲染.md", + "status": "active" + }, + "axhhss6u4n": { + "pageId": "posts/编程语言/C++/RAII.md", + "status": "active" + }, + "axrdp7u42x": { + "pageId": "posts/计算机系统/操作系统与并发/异步.md", + "status": "active" + }, + "b9657k9e4t": { + "pageId": "posts/游戏开发/Unreal/Third Person Controller/第三人称控制器总目标总览.md", + "status": "active" + }, + "bffb64nrms": { + "pageId": "posts/游戏开发/ChikaEngine/资产管理.md", + "status": "active" + }, + "bjdv9w93cr": { + "pageId": "posts/计算机系统/计算机体系结构/二进制负数表达.md", + "status": "active" + }, + "byrjajn9u7": { + "pageId": "posts/编程语言/Rust/Rust Lifetime.md", + "status": "active" + }, + "cc2jwrf5pj": { + "pageId": "posts/游戏开发/Unity/Unity Framework/Core-服务管理和广播系统.md", + "status": "active" + }, + "cc65q2huuy": { + "pageId": "posts/编程语言/Golang/Gin 基础使用.md", + "status": "active" + }, + "cmd49w9v28": { + "pageId": "posts/计算机图形学/Mipmap.md", + "status": "active" + }, + "cmpg4c4hhk": { + "pageId": "posts/游戏开发/ChikaEngine/总览.md", + "status": "active" + }, + "cq9guhx27z": { + "pageId": "posts/编程语言/Golang/defer 关键字.md", + "status": "active" + }, + "cwj9zku8n9": { + "pageId": "posts/计算机图形学/Vulkan/Vulkan渲染流程.md", + "status": "active" + }, + "ddpch2c5dv": { + "pageId": "posts/游戏开发/ChikaEngine/Profiler/Benchmark 场景数据和 Seed.md", + "status": "active" + }, + "demt2zvrpe": { + "pageId": "posts/美术/index.md", + "status": "active" + }, + "dena4tjtd8": { + "pageId": "posts/数据结构与算法/题解/LeetCode 136 和 XOR.md", + "status": "active" + }, + "dn4593mh2c": { + "pageId": "posts/计算机系统/操作系统与并发/锁.md", + "status": "active" + }, + "dxg9aqv7fe": { + "pageId": "posts/软件工程/SDD.md", + "status": "active" + }, + "dzshj6emn4": { + "pageId": "posts/计算机图形学/课程笔记-GAMES101/材质.md", + "status": "active" + }, + "e2pa6bs5au": { + "pageId": "posts/游戏开发/ChikaEngine/Profiler/Profiler05-Session 字段定义.md", + "status": "active" + }, + "e6vpu2abx7": { + "pageId": "posts/编程语言/C++/资源传递 移动和转发.md", + "status": "active" + }, + "e9uwrzgbnz": { + "pageId": "posts/游戏开发/Unreal/UE接口.md", + "status": "active" + }, + "eqqsmuf9uv": { + "pageId": "posts/编程语言/Golang/map.md", + "status": "active" + }, + "erqvedfy7u": { + "pageId": "posts/数据结构与算法/前缀和哈希.md", + "status": "active" + }, + "errb4suw6a": { + "pageId": "posts/编程语言/C++/const.md", + "status": "active" + }, + "f54mx3cwr6": { + "pageId": "posts/数据结构与算法/树形结构/二叉搜索树.md", + "status": "active" + }, + "fd89cbwmnc": { + "pageId": "posts/游戏开发/Unreal/Render/UE 材质蓝图.md", + "status": "active" + }, + "fpewq39ggd": { + "pageId": "posts/人工智能/index.md", + "status": "active" + }, + "fx4up2wuad": { + "pageId": "posts/编程语言/CSharp/值类型和引用类型.md", + "status": "active" + }, + "fyypkgrt7n": { + "pageId": "posts/游戏开发/ChikaEngine/Renderer.md", + "status": "active" + }, + "g7jk45enw5": { + "pageId": "posts/游戏开发/Unreal/Render/Toon Shader.md", + "status": "active" + }, + "g9vzwxqupq": { + "pageId": "posts/随笔/index.md", + "status": "active" + }, + "gbthsjkz73": { + "pageId": "posts/编程语言/Golang/Golang 异常处理.md", + "status": "active" + }, + "gcf3ww364f": { + "pageId": "posts/游戏开发/ChikaEngine/Profiler/Profiler06-Session Event.md", + "status": "active" + }, + "gg9eyj4gfv": { + "pageId": "posts/游戏开发/ChikaEngine/Job System/SmallJobFunction.md", + "status": "active" + }, + "gn4h764sfs": { + "pageId": "posts/计算机图形学/Vulkan/PhysicalDevice.md", + "status": "active" + }, + "gtm43f6adp": { + "pageId": "posts/编程语言/C++/模板元编程.md", + "status": "active" + }, + "h4ans48agr": { + "pageId": "posts/计算机系统/操作系统与并发/线程和进程.md", + "status": "active" + }, + "h8y8qxu35s": { + "pageId": "posts/编程语言/C++/function.md", + "status": "active" + }, + "hb6t8cfyyc": { + "pageId": "posts/软件工程/架构与设计/Clean Architecture.md", + "status": "active" + }, + "hkum37bmcf": { + "pageId": "posts/计算机图形学/课程笔记-GAMES101/Math/傅里叶.md", + "status": "active" + }, + "hrcf46vkjd": { + "pageId": "posts/游戏开发/Unreal/CDO.md", + "status": "active" + }, + "ht4wbhhgk2": { + "pageId": "posts/软件工程/开发工具/lldb.md", + "status": "active" + }, + "j3e2egzeh7": { + "pageId": "posts/人工智能/Agent 综述.md", + "status": "active" + }, + "j9ejfg2y3u": { + "pageId": "posts/计算机系统/操作系统与并发/线程状态.md", + "status": "active" + }, + "jcvf99586t": { + "pageId": "posts/编程语言/C++/vector.md", + "status": "active" + }, + "jerahty3hp": { + "pageId": "posts/软件工程/ACID.md", + "status": "active" + }, + "jwsze3tgcn": { + "pageId": "posts/人工智能/记录 Hermes 配置踩坑.md", + "status": "active" + }, + "k22ebqp5cz": { + "pageId": "posts/游戏开发/Unity/Unity Framework/Movement.md", + "status": "active" + }, + "kx3g6hnfam": { + "pageId": "posts/编程语言/Golang/context 语句.md", + "status": "active" + }, + "m7tu3axxdj": { + "pageId": "posts/计算机图形学/UV 坐标.md", + "status": "active" + }, + "mdtfky8qd4": { + "pageId": "posts/软件工程/编程范式/面向对象/抽象和接口的思考.md", + "status": "active" + }, + "mkdrcbs6ch": { + "pageId": "posts/随笔/我们,被模型绑架了.md", + "status": "active" + }, + "mqs9q93vyz": { + "pageId": "posts/游戏开发/Unity/Unity Framework/Core-State Machine.md", + "status": "active" + }, + "mrs2s4qhqb": { + "pageId": "posts/编程语言/C++/Exception.md", + "status": "active" + }, + "mza5m47fyf": { + "pageId": "posts/软件工程/设计模式/观察者模式.md", + "status": "active" + }, + "n7ghh5xrt8": { + "pageId": "posts/计算机图形学/课程笔记-GAMES101/Rasterization Triangle.md", + "status": "active" + }, + "nc5xgwdec3": { + "pageId": "posts/游戏开发/ChikaEngine/Profiler/Profiler04-Event 数据.md", + "status": "active" + }, + "nv4amvqdt6": { + "pageId": "posts/美术/板绘入手/抓型 01.md", + "status": "active" + }, + "ny4ypfq54a": { + "pageId": "posts/编程语言/C++/类的内存布局.md", + "status": "active" + }, + "p5gp8gtv8j": { + "pageId": "posts/编程语言/CSharp/引用和值传递.md", + "status": "active" + }, + "pbqj52uder": { + "pageId": "posts/计算机图形学/Vulkan/Why Vulkan.md", + "status": "active" + }, + "pf9yv7dq3e": { + "pageId": "posts/编程语言/Golang/WaitGroup.md", + "status": "active" + }, + "pkbun2sx77": { + "pageId": "posts/数学/欧拉公式.md", + "status": "active" + }, + "pqdkjdzd2n": { + "pageId": "posts/游戏开发/Unity/Unity Framework/项目管理.md", + "status": "active" + }, + "qav5rhfq4g": { + "pageId": "posts/软件工程/设计模式/状态机.md", + "status": "active" + }, + "qaxkwe5mts": { + "pageId": "posts/计算机图形学/课程笔记-GAMES101/Shadow Map.md", + "status": "active" + }, + "qbkxu3q7ka": { + "pageId": "posts/游戏开发/ChikaEngine/Shader 反射.md", + "status": "active" + }, + "qh3k9c565s": { + "pageId": "posts/游戏开发/ChikaEngine/Archive.md", + "status": "active" + }, + "qhmhqq6rps": { + "pageId": "posts/游戏开发/Games104反射机制.md", + "status": "active" + }, + "r3u8bmk4h5": { + "pageId": "posts/编程语言/Golang/Golang 协程通道.md", + "status": "active" + }, + "rb6d77887v": { + "pageId": "posts/人工智能/KV Cache.md", + "status": "active" + }, + "rccf2g2mg6": { + "pageId": "posts/计算机系统/操作系统与并发/生产者-消费者模型.md", + "status": "active" + }, + "rqpm9dnrcf": { + "pageId": "posts/计算机系统/操作系统与并发/条件变量.md", + "status": "active" + }, + "rrxdgg8nj4": { + "pageId": "posts/软件工程/架构与设计/DOD.md", + "status": "active" + }, + "rxct2kwrnd": { + "pageId": "posts/软件工程/设计模式/组合模式.md", + "status": "active" + }, + "s8wzrx8swd": { + "pageId": "posts/游戏开发/Unity/Unity Framework/Utilities-Audio Manager.md", + "status": "active" + }, + "swpt7g3vzc": { + "pageId": "posts/数据结构与算法/双指针.md", + "status": "active" + }, + "sy2ahzhrcz": { + "pageId": "posts/软件工程/设计模式/状态模式.md", + "status": "active" + }, + "t4qnzyzfm8": { + "pageId": "posts/软件工程/后端工程/限流算法/令牌桶.md", + "status": "active" + }, + "t6v8mat8jw": { + "pageId": "posts/计算机图形学/视锥体剔除.md", + "status": "active" + }, + "t92snh5j93": { + "pageId": "posts/游戏开发/Unity/Unity Framework/对象池.md", + "status": "active" + }, + "tjyudhczgr": { + "pageId": "posts/软件工程/编程范式/面向对象/鸭子类型.md", + "status": "active" + }, + "tnrk8qrhrh": { + "pageId": "posts/游戏开发/ChikaEngine/窗口Resize解决方案.md", + "status": "active" + }, + "ts7h3db9u5": { + "pageId": "posts/计算机系统/操作系统与并发/并发活性问题.md", + "status": "active" + }, + "ttshn7meta": { + "pageId": "posts/计算机图形学/Vulkan/Descriptor相关.md", + "status": "active" + }, + "u95x2rasuy": { + "pageId": "posts/编程语言/C++/左值右值.md", + "status": "active" + }, + "uennjfqzbw": { + "pageId": "posts/软件工程/后端工程/雪花算法.md", + "status": "active" + }, + "uj4sw43he8": { + "pageId": "posts/游戏开发/Unreal/UE反射.md", + "status": "active" + }, + "ukpqasaws6": { + "pageId": "posts/游戏开发/Unreal/Third Person Controller/01 基础控制.md", + "status": "active" + }, + "unez4cvbf8": { + "pageId": "posts/计算机图形学/课程笔记-GAMES101/Math/Transformation.md", + "status": "active" + }, + "ux2dqsgxgr": { + "pageId": "posts/计算机系统/操作系统与并发/线程行为.md", + "status": "active" + }, + "uxy7kbgs7r": { + "pageId": "posts/游戏开发/index.md", + "status": "active" + }, + "v5peywj2dt": { + "pageId": "posts/游戏开发/ChikaEngine/Shader Interface Stable Hash.md", + "status": "active" + }, + "v83hx6du48": { + "pageId": "posts/编程语言/C++/指针.md", + "status": "active" + }, + "v8vfpuycej": { + "pageId": "posts/随笔/凉宫春日黑客松.md", + "status": "active" + }, + "v8y6vkqq87": { + "pageId": "posts/计算机图形学/课程笔记-GAMES101/路径追踪.md", + "status": "active" + }, + "v97axxmr2c": { + "pageId": "posts/计算机图形学/Shadow Map.md", + "status": "active" + }, + "vv39r5k3dq": { + "pageId": "posts/数学/傅里叶变换.md", + "status": "active" + }, + "w29sxsmagv": { + "pageId": "posts/计算机图形学/CPU和GPU加速计算.md", + "status": "active" + }, + "w2mve52dnq": { + "pageId": "posts/计算机系统/操作系统与并发/原子操作.md", + "status": "active" + }, + "w8pu6q22vh": { + "pageId": "posts/软件工程/开发工具/Cmake基础用法.md", + "status": "active" + }, + "wkze88bvr2": { + "pageId": "posts/随笔/魔卡少女樱.md", + "status": "active" + }, + "wpzbj2sm7b": { + "pageId": "posts/编程语言/Rust/Rust Ownership.md", + "status": "active" + }, + "ws5sexmj4y": { + "pageId": "posts/数据结构与算法/快速选择.md", + "status": "active" + }, + "wvb3eumhc9": { + "pageId": "posts/人工智能/RAG.md", + "status": "active" + }, + "wwu8etd4jr": { + "pageId": "posts/计算机图形学/Vulkan/Attachment.md", + "status": "active" + }, + "x2dyhsu7f3": { + "pageId": "posts/游戏开发/ChikaEngine/Profiler/Profiler02-RAII.md", + "status": "active" + }, + "x3murksztj": { + "pageId": "posts/软件工程/开发工具/Google Benchmark.md", + "status": "active" + }, + "x5n7875qt4": { + "pageId": "posts/数据结构与算法/树形结构/红黑树.md", + "status": "active" + }, + "xdwzrpbgan": { + "pageId": "posts/编程语言/Rust/Rust 中的闭包.md", + "status": "active" + }, + "xh4qajvjrm": { + "pageId": "posts/编程语言/Python/Python DTO 与 dataclass.md", + "status": "active" + }, + "xm52ks23eh": { + "pageId": "posts/人工智能/YokoDevAgent/YokoDevAgent.md", + "status": "active" + }, + "xw7zc8ry5r": { + "pageId": "posts/游戏开发/Unreal/Render/初探UE材质系统.md", + "status": "active" + }, + "y2bgmcu7fy": { + "pageId": "posts/数学/index.md", + "status": "active" + }, + "y35ngx7w58": { + "pageId": "posts/数据结构与算法/index.md", + "status": "active" + }, + "y3ybkzkpne": { + "pageId": "posts/游戏开发/Unreal/Third Person Controller/02 摄像机优化.md", + "status": "active" + }, + "y7fs8qhfju": { + "pageId": "posts/软件工程/架构与设计/MVP.md", + "status": "active" + }, + "y9eekfcu5v": { + "pageId": "posts/数据结构与算法/单调栈.md", + "status": "active" + }, + "ymj9fj7tdn": { + "pageId": "posts/计算机图形学/Vulkan/Pipeline.md", + "status": "active" + }, + "z2amzqz6xt": { + "pageId": "posts/计算机图形学/课程笔记-GAMES101/Ray Tracing.md", + "status": "active" + }, + "z7ktx6a7kh": { + "pageId": "posts/游戏开发/Unity/环境安装踩坑.md", + "status": "active" + }, + "z8vaccpcry": { + "pageId": "posts/计算机系统/计算机体系结构/硬件相关.md", + "status": "active" + }, + "zfyurdpfz2": { + "pageId": "posts/游戏开发/ChikaEngine/Profiler/Benchmark.md", + "status": "active" + }, + "zgxguwkcpc": { + "pageId": "posts/数据结构与算法/字符串前缀和哈希.md", + "status": "active" + }, + "zmdfxru3ea": { + "pageId": "posts/计算机图形学/Shader 和 Material.md", + "status": "active" + }, + "ztd7r38pmq": { + "pageId": "posts/软件工程/设计模式/装饰器模式.md", + "status": "active" + }, + "zu9dktfjvf": { + "pageId": "posts/软件工程/TDD.md", + "status": "active" + }, + "zxzaca3396": { + "pageId": "posts/游戏开发/ChikaEngine/EventBus.md", + "status": "active" + } + } +} diff --git a/.vitepress/scripts/check-share-link-artifacts.ts b/.vitepress/scripts/check-share-link-artifacts.ts new file mode 100644 index 0000000..ea698bd --- /dev/null +++ b/.vitepress/scripts/check-share-link-artifacts.ts @@ -0,0 +1,28 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import fg from "fast-glob"; +import { + assertNoPublishedShortLinks, + checkShareLinkArtifacts, +} from "../utilities/share-link-artifact-check.ts"; + +const projectRoot = path.resolve(import.meta.dirname, "../.."); +const contentDistDir = path.join(projectRoot, ".vitepress/dist"); +const shortDistDir = path.join(projectRoot, ".shortlink-dist"); +const result = await checkShareLinkArtifacts({ + browserManifestFile: path.join(contentDistDir, "share-links/manifest.json"), + shortManifestFile: path.join(shortDistDir, "manifest.json"), + shortDistDir, +}); +const indexFiles = await fg( + ["sitemap*.xml", "feed.rss", "**/phaseshard*.json", "**/*search*index*.json"], + { cwd: contentDistDir, onlyFiles: true }, +); +assertNoPublishedShortLinks(await Promise.all(indexFiles.map(async (file) => ({ + file, + content: await fs.readFile(path.join(contentDistDir, file), "utf8"), +})))); + +console.info( + `[share-links] artifacts match registry ${result.registryHash}; ${result.activeCount} active, ${result.goneCount} gone`, +); diff --git a/.vitepress/scripts/check-share-links.ts b/.vitepress/scripts/check-share-links.ts new file mode 100644 index 0000000..f2e16bd --- /dev/null +++ b/.vitepress/scripts/check-share-links.ts @@ -0,0 +1,15 @@ +import fg from "fast-glob"; +import path from "node:path"; +import { checkShareLinkFiles } from "../utilities/share-link-registry-files.ts"; + +const projectRoot = path.resolve(import.meta.dirname, "../.."); +const registryFile = path.join(projectRoot, ".vitepress/data/share-links.json"); +const pageIds = await fg("posts/**/*.md", { + cwd: projectRoot, + onlyFiles: true, +}); +const result = await checkShareLinkFiles({ registryFile, pageIds }); + +console.info( + `[share-links] ${result.activeCount} active, ${result.goneCount} gone; registry check passed`, +); diff --git a/.vitepress/scripts/health-check-short-links.ts b/.vitepress/scripts/health-check-short-links.ts new file mode 100644 index 0000000..88915e0 --- /dev/null +++ b/.vitepress/scripts/health-check-short-links.ts @@ -0,0 +1,51 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { parseShortLinkDeploymentManifest } from "../utilities/short-link-pages.ts"; + +const projectRoot = path.resolve(import.meta.dirname, "../.."); +const expected = parseShortLinkDeploymentManifest( + JSON.parse(await fs.readFile(path.join(projectRoot, ".shortlink-dist/manifest.json"), "utf8")) as unknown, +); +const rawOrigin = process.env.SHORTLINK_HEALTH_ORIGIN; + +if (rawOrigin === undefined) { + throw new Error("必须设置 SHORTLINK_HEALTH_ORIGIN 才能执行生产短链健康检查"); +} + +const origin = new URL(rawOrigin); +if (origin.protocol !== "https:") throw new Error("短链健康检查只允许 HTTPS origin"); + +const deployedResponse = await fetch(new URL("/manifest.json", origin)); +if (!deployedResponse.ok) throw new Error(`生产 manifest 响应异常:${deployedResponse.status}`); +const deployed = parseShortLinkDeploymentManifest(await deployedResponse.json()); +if (deployed.registryHash !== expected.registryHash) { + throw new Error(`生产 registry hash 不一致:${deployed.registryHash} != ${expected.registryHash}`); +} + +for (const [id, record] of Object.entries(expected.records)) { + const response = await fetch(new URL(`/s/${id}/`, origin), { redirect: "manual" }); + if (record.status === "gone") { + if (response.status !== 200 && response.status !== 410) { + throw new Error(`gone 短链响应异常:${id} -> ${response.status}`); + } + continue; + } + const location = response.headers.get("location"); + if (response.status >= 300 && response.status < 400) { + if (location === null || new URL(location, origin).href !== record.target) { + throw new Error(`短链重定向目标异常:${id}`); + } + } else if (response.status === 200) { + const html = await response.text(); + if (!html.includes(record.target)) throw new Error(`静态短链页面目标异常:${id}`); + } else { + throw new Error(`active 短链响应异常:${id} -> ${response.status}`); + } +} + +for (const invalidPath of ["/s/invalid/", "/s/%2e%2e%2fmanifest.json/", "/s/not-found-id/"]) { + const response = await fetch(new URL(invalidPath, origin), { redirect: "manual" }); + if (response.status !== 404) throw new Error(`非法短链应返回 404:${invalidPath}`); +} + +console.info(`[share-links] production health check passed for ${origin.origin}`); diff --git a/.vitepress/scripts/prepare-share-links.ts b/.vitepress/scripts/prepare-share-links.ts new file mode 100644 index 0000000..ecbdc62 --- /dev/null +++ b/.vitepress/scripts/prepare-share-links.ts @@ -0,0 +1,24 @@ +import fg from "fast-glob"; +import path from "node:path"; +import { prepareShareLinkFiles } from "../utilities/share-link-registry-files.ts"; + +const projectRoot = path.resolve(import.meta.dirname, "../.."); +const registryFile = path.join(projectRoot, ".vitepress/data/share-links.json"); +const generatedManifestFile = path.join( + projectRoot, + ".vitepress/generated/share-links-manifest.json", +); + +const pageIds = await fg("posts/**/*.md", { + cwd: projectRoot, + onlyFiles: true, +}); +const result = await prepareShareLinkFiles({ + registryFile, + generatedManifestFile, + pageIds, +}); + +console.info( + `[share-links] ${result.added.length} added, ${result.unchangedCount} retained, ${result.registryChanged ? "registry updated" : "registry unchanged"}, ${result.manifestChanged ? "manifest updated" : "manifest unchanged"}`, +); diff --git a/.vitepress/scripts/publish-short-links.ts b/.vitepress/scripts/publish-short-links.ts new file mode 100644 index 0000000..71dd330 --- /dev/null +++ b/.vitepress/scripts/publish-short-links.ts @@ -0,0 +1,51 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import matter from "gray-matter"; +import { parseShareLinkManifest } from "../shared/share-link-contract.ts"; +import { loadShareLinkRegistry } from "../utilities/share-link-registry-files.ts"; +import { + publishShortLinkSite, + type ShortLinkPageMetadata, +} from "../utilities/short-link-pages.ts"; + +const projectRoot = path.resolve(import.meta.dirname, "../.."); +const registryFile = path.join(projectRoot, ".vitepress/data/share-links.json"); +const manifestFile = path.join(projectRoot, ".vitepress/generated/share-links-manifest.json"); +const contentDistDir = path.join(projectRoot, ".vitepress/dist"); +const outputDir = path.join(projectRoot, ".shortlink-dist"); +const registry = await loadShareLinkRegistry(registryFile); +const manifest = parseShareLinkManifest( + JSON.parse(await fs.readFile(manifestFile, "utf8")) as unknown, +); + +async function metadataForPageId(pageId: string): Promise { + const source = await fs.readFile(path.join(projectRoot, pageId), "utf8"); + const parsed = matter(source); + const heading = parsed.content.match(/^#\s+(.+)$/m)?.[1]?.trim(); + const fallbackTitle = path.basename(pageId, path.extname(pageId)); + + return { + title: typeof parsed.data.title === "string" + ? parsed.data.title + : heading ?? fallbackTitle, + description: typeof parsed.data.description === "string" + ? parsed.data.description + : "Machillka 的学习记录与共享笔记库", + }; +} + +const result = await publishShortLinkSite({ + registry, + registryHash: manifest.registryHash, + outputDir, + contentDistDir, + metadataForPageId, +}); + +const contentShortLinkDir = path.join(contentDistDir, "s"); +await fs.rm(contentShortLinkDir, { recursive: true, force: true }); +await fs.cp(path.join(outputDir, "s"), contentShortLinkDir, { recursive: true }); + +console.info( + `[share-links] published ${result.activeCount} active and ${result.goneCount} gone short-link pages to ${outputDir} and ${contentShortLinkDir}`, +); diff --git a/.vitepress/shared/share-link-contract.ts b/.vitepress/shared/share-link-contract.ts new file mode 100644 index 0000000..baab304 --- /dev/null +++ b/.vitepress/shared/share-link-contract.ts @@ -0,0 +1,97 @@ +import { baseUrl } from "./site-config.ts"; + +export const SHARE_ID_ALPHABET = "23456789abcdefghjkmnpqrstuvwxyz"; +export const SHARE_ID_LENGTH = 10; +export const SHARE_LINK_SHORT_ORIGIN = baseUrl; +export const SHARE_LINK_MANIFEST_PATH = "/share-links/manifest.json"; +export const YUUFRAG_CONTENT_ORIGIN = baseUrl; + +const SHARE_ID_PATTERN = new RegExp( + `^[${SHARE_ID_ALPHABET}]{${SHARE_ID_LENGTH}}$`, +); +const REGISTRY_HASH_PATTERN = /^[0-9a-f]{64}$/; + +export type ShareId = string; + +export interface ShareLinkManifest { + version: 1; + registryHash: string; + shortOrigin: typeof SHARE_LINK_SHORT_ORIGIN; + byCanonicalPath: Record; +} + +export function isShareId(value: string): value is ShareId { + return SHARE_ID_PATTERN.test(value); +} + +export function normalizeCanonicalPath(rawPath: string): string { + if (typeof rawPath !== "string" || rawPath.length === 0) { + throw new Error("canonical path 必须是非空字符串"); + } + + const withoutQueryOrHash = rawPath.split(/[?#]/, 1)[0]; + let decoded = withoutQueryOrHash; + + try { + decoded = decodeURI(withoutQueryOrHash); + } catch { + throw new Error(`canonical path 编码非法:${rawPath}`); + } + + const withLeadingSlash = decoded.startsWith("/") ? decoded : `/${decoded}`; + const withoutHtml = withLeadingSlash + .replace(/\/index\.html$/, "/") + .replace(/\.html$/, ""); + + return withoutHtml.normalize("NFC"); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function parseShareLinkManifest(value: unknown): ShareLinkManifest { + if (!isRecord(value) || value.version !== 1) { + throw new Error("分享 manifest 版本非法"); + } + + if ( + value.shortOrigin !== SHARE_LINK_SHORT_ORIGIN || + typeof value.registryHash !== "string" || + !REGISTRY_HASH_PATTERN.test(value.registryHash) || + !isRecord(value.byCanonicalPath) + ) { + throw new Error("分享 manifest 顶层字段非法"); + } + + const byCanonicalPath: Record = {}; + const canonicalPathById = new Map(); + + for (const [rawPath, rawId] of Object.entries(value.byCanonicalPath)) { + const canonicalPath = normalizeCanonicalPath(rawPath); + + if (canonicalPath !== rawPath || !isShareId(String(rawId))) { + throw new Error(`分享 manifest 映射非法:${rawPath}`); + } + + const existingPath = canonicalPathById.get(rawId as ShareId); + if (existingPath !== undefined) { + throw new Error(`分享 manifest ID 重复:${rawId} -> ${existingPath}, ${rawPath}`); + } + + byCanonicalPath[canonicalPath] = rawId as ShareId; + canonicalPathById.set(rawId as ShareId, canonicalPath); + } + + return { + version: 1, + registryHash: value.registryHash, + shortOrigin: SHARE_LINK_SHORT_ORIGIN, + byCanonicalPath, + }; +} + +export function shareIdToShortUrl(id: ShareId): string { + if (!isShareId(id)) throw new Error(`非法分享 ID:${id}`); + return `${SHARE_LINK_SHORT_ORIGIN}/s/${id}`; +} diff --git a/.vitepress/shared/site-config.ts b/.vitepress/shared/site-config.ts new file mode 100644 index 0000000..fe70abc --- /dev/null +++ b/.vitepress/shared/site-config.ts @@ -0,0 +1 @@ +export const baseUrl = "https://yuufrag.machillka.com"; diff --git a/.vitepress/theme/components/ShareButton.vue b/.vitepress/theme/components/ShareButton.vue new file mode 100644 index 0000000..a258964 --- /dev/null +++ b/.vitepress/theme/components/ShareButton.vue @@ -0,0 +1,228 @@ + + + + + diff --git a/.vitepress/theme/components/share-button-state.ts b/.vitepress/theme/components/share-button-state.ts new file mode 100644 index 0000000..a9cb8d7 --- /dev/null +++ b/.vitepress/theme/components/share-button-state.ts @@ -0,0 +1,69 @@ +import { ref, type Ref } from "vue"; +import type { + PageShareInput, + PageShareService, + SharePhase, +} from "../services/page-share-service.ts"; + +export function createShareButtonController( + service: PageShareService, + createInput: () => PageShareInput, +): { + message: Ref; + displayUrl: Ref; + busy: Ref; + share: () => Promise; + reset: () => void; +} { + const message = ref(""); + const displayUrl = ref(""); + const busy = ref(false); + + const onPhase = (phase: SharePhase, preserveMessage: boolean) => { + if (preserveMessage) return; + + message.value = phase === "querying" + ? "正在查找分享短链…" + : "正在复制链接…"; + }; + + const share = async () => { + if (busy.value) return; + const preserveMessage = displayUrl.value.length > 0; + busy.value = true; + + try { + const result = await service.share( + createInput(), + (phase) => onPhase(phase, preserveMessage), + ); + + if (result.status === "cancelled") { + message.value = ""; + return; + } + + displayUrl.value = result.url; + const fallbackText = result.fallback ? "(短链不可用,已使用原始链接)" : ""; + + if (result.status === "shared") { + message.value = `分享面板已打开${fallbackText}`; + } else if (result.status === "copied") { + message.value = `链接已复制${fallbackText}`; + } else { + message.value = `自动复制失败,请手工复制${fallbackText}`; + } + } catch { + message.value = "暂时无法分享,请稍后重试"; + } finally { + busy.value = false; + } + }; + + const reset = () => { + message.value = ""; + displayUrl.value = ""; + }; + + return { message, displayUrl, busy, share, reset }; +} diff --git a/.vitepress/theme/index.ts b/.vitepress/theme/index.ts index 187a505..b32f157 100644 --- a/.vitepress/theme/index.ts +++ b/.vitepress/theme/index.ts @@ -15,6 +15,7 @@ import { useRoute } from "vitepress"; import TagList from "./components/TagList.vue"; import CategoryCards from "./components/CategoryCards.vue"; import RecentPosts from "./components/RecentPosts.vue"; +import ShareButton from "./components/ShareButton.vue"; export default { Layout: () => { return h(DefaultTheme.Layout, null, { @@ -86,5 +87,6 @@ export default { app.component("ArticleMetadata", ArticleMetadata); app.component("Contributors", Contributors); app.component("AboutTeam", AboutTeam); + app.component("ShareButton", ShareButton); }, } satisfies Theme; diff --git a/.vitepress/theme/services/default-page-share-service.ts b/.vitepress/theme/services/default-page-share-service.ts new file mode 100644 index 0000000..82c2126 --- /dev/null +++ b/.vitepress/theme/services/default-page-share-service.ts @@ -0,0 +1,11 @@ +import { StaticManifestShareLinkQuery } from "./share-link-query.ts"; +import { + BrowserClipboardAdapter, + DefaultPageShareService, +} from "./page-share-service.ts"; + +export const pageShareService = new DefaultPageShareService( + new StaticManifestShareLinkQuery(), + { canShare: () => false, share: async () => undefined }, + new BrowserClipboardAdapter(), +); diff --git a/.vitepress/theme/services/page-share-context.ts b/.vitepress/theme/services/page-share-context.ts new file mode 100644 index 0000000..0704be3 --- /dev/null +++ b/.vitepress/theme/services/page-share-context.ts @@ -0,0 +1,19 @@ +import { + normalizeCanonicalPath, +} from "../../shared/share-link-contract.ts"; +import { baseUrl } from "../../shared/site-config.ts"; +import type { PageShareInput } from "./page-share-service.ts"; + +export function createPageShareInput(input: { + routePath: string; + title: string; + text?: string; +}): PageShareInput { + const canonicalPath = normalizeCanonicalPath(input.routePath); + return { + canonicalPath, + canonicalUrl: new URL(canonicalPath, `${baseUrl}/`).href, + title: input.title, + text: input.text, + }; +} diff --git a/.vitepress/theme/services/page-share-service.ts b/.vitepress/theme/services/page-share-service.ts new file mode 100644 index 0000000..a45ba5b --- /dev/null +++ b/.vitepress/theme/services/page-share-service.ts @@ -0,0 +1,146 @@ +import type { ShareLinkQuery } from "./share-link-query.ts"; + +export type SharePhase = "querying" | "sharing" | "copying"; + +export interface PageShareInput { + canonicalPath: string; + canonicalUrl: string; + title: string; + text?: string; +} + +export interface SharePayload { + title: string; + text?: string; + url: string; +} + +export interface NativeShareAdapter { + canShare(payload: SharePayload): boolean; + share(payload: SharePayload): Promise; +} + +export interface ClipboardAdapter { + writeText(value: string): Promise; +} + +export type PageShareResult = + | { status: "shared" | "copied"; url: string; fallback: boolean } + | { status: "cancelled"; url: string; fallback: boolean } + | { status: "manual"; url: string; fallback: boolean; error?: unknown }; + +export interface PageShareService { + share( + input: PageShareInput, + onPhase?: (phase: SharePhase) => void, + ): Promise; +} + +function isAbortError(error: unknown): boolean { + return ( + error !== null && + typeof error === "object" && + "name" in error && + error.name === "AbortError" + ); +} + +export class BrowserNativeShareAdapter implements NativeShareAdapter { + canShare(payload: SharePayload): boolean { + if (typeof navigator === "undefined" || typeof navigator.share !== "function") { + return false; + } + + return typeof navigator.canShare !== "function" || navigator.canShare(payload); + } + + async share(payload: SharePayload): Promise { + if (typeof navigator === "undefined" || typeof navigator.share !== "function") { + throw new Error("当前环境不支持原生分享"); + } + + await navigator.share(payload); + } +} + +export class BrowserClipboardAdapter implements ClipboardAdapter { + async writeText(value: string): Promise { + if ( + typeof navigator === "undefined" || + navigator.clipboard === undefined || + typeof navigator.clipboard.writeText !== "function" + ) { + throw new Error("当前环境不支持 Clipboard API"); + } + + await navigator.clipboard.writeText(value); + } +} + +export class DefaultPageShareService implements PageShareService { + private readonly query: ShareLinkQuery; + private readonly nativeShare: NativeShareAdapter; + private readonly clipboard: ClipboardAdapter; + + constructor( + query: ShareLinkQuery, + nativeShare: NativeShareAdapter, + clipboard: ClipboardAdapter, + ) { + this.query = query; + this.nativeShare = nativeShare; + this.clipboard = clipboard; + } + + async share( + input: PageShareInput, + onPhase?: (phase: SharePhase) => void, + ): Promise { + onPhase?.("querying"); + let url = input.canonicalUrl; + let fallback = false; + + try { + const shortLink = await this.query.findByCanonicalPath(input.canonicalPath); + + if (shortLink === undefined) { + fallback = true; + } else { + url = shortLink.url; + } + } catch { + fallback = true; + } + + const payload: SharePayload = { title: input.title, text: input.text, url }; + + let canUseNativeShare = false; + try { + canUseNativeShare = this.nativeShare.canShare(payload); + } catch { + canUseNativeShare = false; + } + + if (canUseNativeShare) { + onPhase?.("sharing"); + + try { + await this.nativeShare.share(payload); + return { status: "shared", url, fallback }; + } catch (error) { + if (isAbortError(error)) { + return { status: "cancelled", url, fallback }; + } + } + } + + onPhase?.("copying"); + + try { + await this.clipboard.writeText(url); + return { status: "copied", url, fallback }; + } catch (error) { + return { status: "manual", url, fallback, error }; + } + } +} diff --git a/.vitepress/theme/services/share-link-query.ts b/.vitepress/theme/services/share-link-query.ts new file mode 100644 index 0000000..892a056 --- /dev/null +++ b/.vitepress/theme/services/share-link-query.ts @@ -0,0 +1,109 @@ +import { + SHARE_LINK_MANIFEST_PATH, + normalizeCanonicalPath, + parseShareLinkManifest, + shareIdToShortUrl, + type ShareId, + type ShareLinkManifest, +} from "../../shared/share-link-contract.ts"; + +export interface ShareLinkValue { + id: ShareId; + url: string; +} + +export interface ShareLinkQuery { + findByCanonicalPath(path: string): Promise; +} + +export type ShareLinkQueryErrorCode = "fetch" | "http" | "schema"; + +export class ShareLinkQueryError extends Error { + public readonly code: ShareLinkQueryErrorCode; + + constructor( + code: ShareLinkQueryErrorCode, + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.code = code; + this.name = "ShareLinkQueryError"; + } +} + +export type ShareLinkFetch = ( + input: string, + init?: { headers?: Record }, +) => Promise<{ ok: boolean; status: number; json(): Promise }>; + +export class StaticManifestShareLinkQuery implements ShareLinkQuery { + private manifestPromise: Promise | undefined; + private readonly manifestUrl: string; + private readonly fetchImpl: ShareLinkFetch; + + constructor(options: { + manifestUrl?: string; + fetchImpl?: ShareLinkFetch; + } = {}) { + this.manifestUrl = options.manifestUrl ?? SHARE_LINK_MANIFEST_PATH; + const injected = options.fetchImpl; + + this.fetchImpl = injected ?? (async (input, init) => { + if (typeof globalThis.fetch !== "function") { + throw new Error("当前环境不支持 fetch"); + } + + return globalThis.fetch(input, init); + }); + } + + async findByCanonicalPath(path: string): Promise { + const canonicalPath = normalizeCanonicalPath(path); + const manifest = await this.loadManifest(); + const id = manifest.byCanonicalPath[canonicalPath]; + + return id === undefined ? undefined : { id, url: shareIdToShortUrl(id) }; + } + + private loadManifest(): Promise { + if (this.manifestPromise === undefined) { + const pending = this.fetchManifest().catch((error: unknown) => { + if (this.manifestPromise === pending) this.manifestPromise = undefined; + throw error; + }); + this.manifestPromise = pending; + } + + return this.manifestPromise; + } + + private async fetchManifest(): Promise { + let response: Awaited>; + + try { + response = await this.fetchImpl(this.manifestUrl, { + headers: { Accept: "application/json" }, + }); + } catch (error) { + throw new ShareLinkQueryError("fetch", "无法加载分享链接清单", { + cause: error, + }); + } + + if (!response.ok) { + throw new ShareLinkQueryError( + "http", + `分享链接清单响应异常:HTTP ${response.status}`, + ); + } + + try { + return parseShareLinkManifest(await response.json()); + } catch (error) { + throw new ShareLinkQueryError("schema", "分享链接清单格式非法", { + cause: error, + }); + } + } +} diff --git a/.vitepress/utilities/share-link-artifact-check.ts b/.vitepress/utilities/share-link-artifact-check.ts new file mode 100644 index 0000000..df578c8 --- /dev/null +++ b/.vitepress/utilities/share-link-artifact-check.ts @@ -0,0 +1,76 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { + SHARE_LINK_SHORT_ORIGIN, + parseShareLinkManifest, +} from "../shared/share-link-contract.ts"; +import { + parseShortLinkDeploymentManifest, + type ShortLinkDeploymentManifest, +} from "./short-link-pages.ts"; + +async function readJson(file: string): Promise { + return JSON.parse(await fs.readFile(file, "utf8")) as unknown; +} + +export async function checkShareLinkArtifacts(input: { + browserManifestFile: string; + shortManifestFile: string; + shortDistDir: string; +}): Promise<{ registryHash: string; activeCount: number; goneCount: number }> { + const browser = parseShareLinkManifest(await readJson(input.browserManifestFile)); + const short = parseShortLinkDeploymentManifest(await readJson(input.shortManifestFile)); + + if (browser.registryHash !== short.registryHash) { + throw new Error( + `双产物 registry hash 不一致:${browser.registryHash} != ${short.registryHash}`, + ); + } + + const browserPathById = new Map(); + for (const [canonicalPath, id] of Object.entries(browser.byCanonicalPath)) { + const existingPath = browserPathById.get(id); + if (existingPath !== undefined) { + throw new Error(`浏览器 manifest 中 ID 重复:${id} -> ${existingPath}, ${canonicalPath}`); + } + browserPathById.set(id, canonicalPath); + } + + let activeCount = 0; + let goneCount = 0; + for (const [id, record] of Object.entries(short.records)) { + await fs.access(path.join(input.shortDistDir, "s", id, "index.html")); + if (record.status === "gone") { + if (browserPathById.has(id)) throw new Error(`gone ID 出现在浏览器 manifest:${id}`); + goneCount += 1; + continue; + } + + const canonicalPath = browserPathById.get(id); + if (canonicalPath === undefined) throw new Error(`active ID 缺少浏览器映射:${id}`); + if (new URL(record.target).pathname !== new URL(canonicalPath, short.contentOrigin).pathname) { + throw new Error(`active ID 双产物目标不一致:${id}`); + } + activeCount += 1; + } + + if (activeCount !== browserPathById.size) { + throw new Error("浏览器 manifest 存在未发布的 active ID"); + } + + await fs.access(path.join(input.shortDistDir, "404.html")); + return { registryHash: browser.registryHash, activeCount, goneCount }; +} + +export function assertNoPublishedShortLinks( + files: ReadonlyArray<{ file: string; content: string }>, +): void { + const shortLinkPrefix = `${SHARE_LINK_SHORT_ORIGIN}/s/`; + const offenders = files + .filter(({ content }) => content.includes(shortLinkPrefix)) + .map(({ file }) => file); + + if (offenders.length > 0) { + throw new Error(`内容索引不得发布短链:\n${offenders.map((file) => `- ${file}`).join("\n")}`); + } +} diff --git a/.vitepress/utilities/share-link-manifest-plugin.ts b/.vitepress/utilities/share-link-manifest-plugin.ts new file mode 100644 index 0000000..f8c6716 --- /dev/null +++ b/.vitepress/utilities/share-link-manifest-plugin.ts @@ -0,0 +1,59 @@ +import { promises as fs } from "node:fs"; +import type { Plugin } from "vite"; +import { + SHARE_LINK_MANIFEST_PATH, + parseShareLinkManifest, +} from "../shared/share-link-contract.ts"; + +export async function loadShareLinkManifestSource(file: string): Promise { + const source = await fs.readFile(file, "utf8"); + parseShareLinkManifest(JSON.parse(source) as unknown); + return source.endsWith("\n") ? source : `${source}\n`; +} + +export function ShareLinkManifestPlugin(options: { + manifestFile: string; +}): Plugin { + return { + name: "yuufrag-share-link-manifest", + configureServer(server) { + server.middlewares.use(async (request, response, next) => { + const pathname = request.url?.split(/[?#]/, 1)[0]; + + if (pathname !== SHARE_LINK_MANIFEST_PATH) { + next(); + return; + } + + if (request.method !== "GET" && request.method !== "HEAD") { + response.statusCode = 405; + response.setHeader("Allow", "GET, HEAD"); + response.end(); + return; + } + + try { + const source = await loadShareLinkManifestSource( + options.manifestFile, + ); + response.statusCode = 200; + response.setHeader("Content-Type", "application/json; charset=utf-8"); + response.setHeader("Cache-Control", "no-store"); + response.end(request.method === "HEAD" ? undefined : source); + } catch (error) { + response.statusCode = 500; + response.setHeader("Content-Type", "text/plain; charset=utf-8"); + response.end(`分享 manifest 无法读取:${(error as Error).message}`); + } + }); + }, + async generateBundle() { + const source = await loadShareLinkManifestSource(options.manifestFile); + this.emitFile({ + type: "asset", + fileName: SHARE_LINK_MANIFEST_PATH.slice(1), + source, + }); + }, + }; +} diff --git a/.vitepress/utilities/share-link-registry-files.ts b/.vitepress/utilities/share-link-registry-files.ts new file mode 100644 index 0000000..879607a --- /dev/null +++ b/.vitepress/utilities/share-link-registry-files.ts @@ -0,0 +1,262 @@ +import { createHash } from "node:crypto"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { + SHARE_LINK_SHORT_ORIGIN, + type ShareLinkManifest, +} from "../shared/share-link-contract.ts"; +import { + createShareLinkIndex, + isShareId, + prepareShareLinks, + resolveCanonicalHref, + validateShareLinkRegistry, + type PrepareShareLinksResult, + type ShareId, + type ShareLinkRegistry, +} from "./share-links.ts"; + +export { + SHARE_LINK_SHORT_ORIGIN, + type ShareLinkManifest, +} from "../shared/share-link-contract.ts"; + +export interface ShareLinkFilePaths { + registryFile: string; + generatedManifestFile: string; +} + +export interface PrepareShareLinkFilesInput extends ShareLinkFilePaths { + pageIds: Iterable; + readTextIfExists?: (file: string) => Promise; +} + +export interface PrepareShareLinkFilesResult extends PrepareShareLinksResult { + registryChanged: boolean; + manifestChanged: boolean; + manifest: ShareLinkManifest; +} + +export interface CheckShareLinkFilesInput { + registryFile: string; + pageIds: Iterable; +} + +export interface CheckShareLinkFilesResult { + activeCount: number; + goneCount: number; +} + +function defaultRegistry(): ShareLinkRegistry { + return { version: 1, records: {} }; +} + +function serializeJson(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function hashContent(content: string): string { + return createHash("sha256").update(content, "utf8").digest("hex"); +} + +function lockFileFor(registryFile: string): string { + return `${registryFile}.lock`; +} + +function tempFileFor(file: string): string { + return `${file}.${process.pid}.${Date.now()}.tmp`; +} + +async function readTextIfExists(file: string): Promise { + try { + return await fs.readFile(file, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return undefined; + } + + throw error; + } +} + +export async function loadShareLinkRegistry( + registryFile: string, +): Promise { + return loadShareLinkRegistryFromReader(registryFile, readTextIfExists); +} + +async function loadShareLinkRegistryFromReader( + registryFile: string, + readText: (file: string) => Promise, +): Promise { + const content = await readText(registryFile); + + if (content === undefined) return defaultRegistry(); + + try { + return JSON.parse(content) as ShareLinkRegistry; + } catch (error) { + throw new Error( + `无法解析分享注册表 ${registryFile}: ${(error as Error).message}`, + ); + } +} + +async function acquireRegistryLock(registryFile: string): Promise<() => Promise> { + const lockFile = lockFileFor(registryFile); + await fs.mkdir(path.dirname(lockFile), { recursive: true }); + + let handle: Awaited>; + + try { + handle = await fs.open(lockFile, "wx"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + throw new Error(`分享注册表正被另一个进程更新:${lockFile}`); + } + + throw error; + } + + try { + await handle.writeFile(`${process.pid}\n`, "utf8"); + } catch (error) { + await handle.close(); + await fs.unlink(lockFile).catch(() => undefined); + throw error; + } + + return async () => { + await handle.close(); + await fs.unlink(lockFile).catch((error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + }); + }; +} + +async function writeTextAtomicallyIfChanged( + file: string, + content: string, +): Promise { + const current = await readTextIfExists(file); + + if (current === content) return false; + + await fs.mkdir(path.dirname(file), { recursive: true }); + + const tempFile = tempFileFor(file); + + try { + await fs.writeFile(tempFile, content, "utf8"); + await fs.rename(tempFile, file); + } catch (error) { + await fs.unlink(tempFile).catch(() => undefined); + throw error; + } + + return true; +} + +function createManifest(registry: ShareLinkRegistry, registryHash: string): ShareLinkManifest { + const index = createShareLinkIndex(registry); + const byCanonicalPath: Record = {}; + + for (const [pageId, id] of [...index.byPageId.entries()].sort( + ([left], [right]) => (left < right ? -1 : left > right ? 1 : 0), + )) { + const canonicalPath = resolveCanonicalHref(id, index); + + if (canonicalPath === undefined) { + throw new Error(`active 分享 ID 无法解析 canonical 路由:${id} -> ${pageId}`); + } + + const existingId = byCanonicalPath[canonicalPath]; + + if (existingId !== undefined && existingId !== id) { + throw new Error( + `canonical 路由存在多个分享 ID:${canonicalPath}\n- ${existingId}\n- ${id}`, + ); + } + + byCanonicalPath[canonicalPath] = id; + } + + return { + version: 1, + registryHash, + shortOrigin: SHARE_LINK_SHORT_ORIGIN, + byCanonicalPath, + }; +} + +export async function prepareShareLinkFiles( + input: PrepareShareLinkFilesInput, +): Promise { + const releaseLock = await acquireRegistryLock(input.registryFile); + + try { + const readText = input.readTextIfExists ?? readTextIfExists; + const initialRegistryContent = await readText(input.registryFile); + const initialRegistryHash = hashContent(initialRegistryContent ?? ""); + const existingRegistry = await loadShareLinkRegistryFromReader( + input.registryFile, + readText, + ); + const prepared = prepareShareLinks({ + registry: existingRegistry, + pageIds: input.pageIds, + }); + const registryContent = serializeJson(prepared.registry); + + const beforeWriteRegistryContent = await readText(input.registryFile); + + if (hashContent(beforeWriteRegistryContent ?? "") !== initialRegistryHash) { + throw new Error( + `分享注册表在 prepare 期间已被修改,拒绝覆盖:${input.registryFile}`, + ); + } + + const registryChanged = await writeTextAtomicallyIfChanged( + input.registryFile, + registryContent, + ); + const manifest = createManifest( + prepared.registry, + hashContent(registryContent), + ); + const manifestChanged = await writeTextAtomicallyIfChanged( + input.generatedManifestFile, + serializeJson(manifest), + ); + + return { + ...prepared, + registryChanged, + manifestChanged, + manifest, + }; + } finally { + await releaseLock(); + } +} + +export async function checkShareLinkFiles( + input: CheckShareLinkFilesInput, +): Promise { + const registry = await loadShareLinkRegistry(input.registryFile); + validateShareLinkRegistry(registry, input.pageIds); + + let activeCount = 0; + let goneCount = 0; + + for (const [id, record] of Object.entries(registry.records)) { + if (!isShareId(id)) { + throw new Error(`非法分享 ID:${id}`); + } + + if (record.status === "active") activeCount += 1; + if (record.status === "gone") goneCount += 1; + } + + return { activeCount, goneCount }; +} diff --git a/.vitepress/utilities/share-links.ts b/.vitepress/utilities/share-links.ts new file mode 100644 index 0000000..e1ddea0 --- /dev/null +++ b/.vitepress/utilities/share-links.ts @@ -0,0 +1,300 @@ +import { createHash } from "node:crypto"; +import { normalizePageId, pageIdToPublicHref } from "./route-paths.ts"; +import { + SHARE_ID_ALPHABET, + SHARE_ID_LENGTH, + isShareId, + type ShareId, +} from "../shared/share-link-contract.ts"; + +export { + SHARE_ID_ALPHABET, + SHARE_ID_LENGTH, + isShareId, + type ShareId, +} from "../shared/share-link-contract.ts"; +export const SHARE_ID_MAX_ATTEMPTS = 1024; + +const SHARE_ID_SPACE = BigInt(SHARE_ID_ALPHABET.length) ** BigInt(SHARE_ID_LENGTH); +export type ShareLinkStatus = "active" | "gone"; + +export interface ShareLinkRecord { + pageId: string; + status: ShareLinkStatus; +} + +export interface ShareLinkRegistry { + version: 1; + records: Record; +} + +export interface ShareLinkIndex { + byId: ReadonlyMap; + byPageId: ReadonlyMap; +} + +export interface PrepareShareLinksResult { + registry: ShareLinkRegistry; + added: ReadonlyArray<{ id: ShareId; pageId: string }>; + unchangedCount: number; +} + +export function normalizeSharePageId(pageId: string): string { + if (typeof pageId !== "string") { + throw new Error("页面 ID 必须是字符串"); + } + + const normalized = normalizePageId(pageId).normalize("NFC"); + + if (normalized.length === 0) { + throw new Error("页面 ID 不能为空"); + } + + return normalized; +} + +function compareStrings(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function assertSafeAttempt(attempt: number): void { + if (!Number.isSafeInteger(attempt) || attempt < 0) { + throw new Error(`短 ID attempt 必须是非负安全整数:${attempt}`); + } +} + +function encodeFixedBase31(value: bigint): ShareId { + const radix = BigInt(SHARE_ID_ALPHABET.length); + const output = new Array(SHARE_ID_LENGTH); + let remainder = value; + + for (let index = SHARE_ID_LENGTH - 1; index >= 0; index -= 1) { + output[index] = SHARE_ID_ALPHABET[Number(remainder % radix)]; + remainder /= radix; + } + + if (remainder !== 0n) { + throw new Error("短 ID 超出固定编码空间"); + } + + return output.join(""); +} + +export function generateStaticShareId(input: { + pageId: string; + attempt: number; +}): ShareId { + const pageId = normalizeSharePageId(input.pageId); + assertSafeAttempt(input.attempt); + + const digest = createHash("sha256") + .update(`yuufrag-share-v1\0${pageId}\0${input.attempt}`, "utf8") + .digest("hex"); + const value = BigInt(`0x${digest}`) % SHARE_ID_SPACE; + + return encodeFixedBase31(value); +} + +function assertRegistryShape(registry: ShareLinkRegistry): void { + if (registry === null || typeof registry !== "object") { + throw new Error("分享注册表必须是对象"); + } + + if (registry.version !== 1) { + throw new Error(`不支持的分享注册表版本:${String(registry.version)}`); + } + + if (registry.records === null || typeof registry.records !== "object") { + throw new Error("分享注册表 records 必须是对象"); + } +} + +function normalizeCurrentPageIds(pageIds: Iterable): string[] { + const originalByNormalized = new Map(); + + for (const rawPageId of pageIds) { + const normalizedPageId = normalizeSharePageId(rawPageId); + const existing = originalByNormalized.get(normalizedPageId); + + if (existing !== undefined && existing !== rawPageId) { + throw new Error( + `规范化后页面 ID 冲突:${normalizedPageId}\n- ${existing}\n- ${rawPageId}`, + ); + } + + originalByNormalized.set(normalizedPageId, rawPageId); + } + + return [...originalByNormalized.keys()].sort(compareStrings); +} + +function validateRecord(id: string, record: ShareLinkRecord): ShareLinkRecord { + if (!isShareId(id)) { + throw new Error(`非法分享 ID:${id}`); + } + + if (record === null || typeof record !== "object") { + throw new Error(`分享 ID ${id} 的记录必须是对象`); + } + + if (record.status !== "active" && record.status !== "gone") { + throw new Error(`分享 ID ${id} 的状态非法:${String(record.status)}`); + } + + if (typeof record.pageId !== "string") { + throw new Error(`分享 ID ${id} 的 pageId 必须是字符串`); + } + + const normalizedPageId = normalizeSharePageId(record.pageId); + + if (record.pageId !== normalizedPageId) { + throw new Error( + `分享 ID ${id} 的 pageId 必须预先归一化:${record.pageId}`, + ); + } + + return { pageId: normalizedPageId, status: record.status }; +} + +export function createShareLinkIndex(registry: ShareLinkRegistry): ShareLinkIndex { + assertRegistryShape(registry); + + const byId = new Map(); + const byPageId = new Map(); + + for (const [id, rawRecord] of Object.entries(registry.records).sort( + ([left], [right]) => compareStrings(left, right), + )) { + const record = validateRecord(id, rawRecord); + const shareId = id as ShareId; + + byId.set(shareId, record); + + if (record.status !== "active") continue; + + const existingId = byPageId.get(record.pageId); + + if (existingId !== undefined) { + throw new Error( + `页面存在多个 active 分享 ID:${record.pageId}\n- ${existingId}\n- ${shareId}`, + ); + } + + byPageId.set(record.pageId, shareId); + } + + return { byId, byPageId }; +} + +export function validateShareLinkRegistry( + registry: ShareLinkRegistry, + pageIds: Iterable, +): void { + const currentPageIds = normalizeCurrentPageIds(pageIds); + const currentPageIdSet = new Set(currentPageIds); + const index = createShareLinkIndex(registry); + + for (const [pageId, shareId] of index.byPageId) { + if (!currentPageIdSet.has(pageId)) { + throw new Error( + `active 分享 ID 指向不存在页面:${shareId} -> ${pageId}`, + ); + } + } + + const missingPageIds = currentPageIds.filter( + (pageId) => !index.byPageId.has(pageId), + ); + + if (missingPageIds.length > 0) { + throw new Error( + [ + "以下页面缺少 active 分享 ID:", + ...missingPageIds.map((pageId) => `- ${pageId}`), + ].join("\n"), + ); + } +} + +function createRegistryCopy(registry: ShareLinkRegistry): ShareLinkRegistry { + const records = Object.fromEntries( + Object.entries(registry.records) + .sort(([left], [right]) => compareStrings(left, right)) + .map(([id, record]) => [id, { ...record }]), + ) as Record; + + return { version: 1, records }; +} + +export function prepareShareLinks(input: { + registry: ShareLinkRegistry; + pageIds: Iterable; +}): PrepareShareLinksResult { + const currentPageIds = normalizeCurrentPageIds(input.pageIds); + const existingIndex = createShareLinkIndex(input.registry); + const currentPageIdSet = new Set(currentPageIds); + + for (const [pageId, shareId] of existingIndex.byPageId) { + if (!currentPageIdSet.has(pageId)) { + throw new Error( + `active 分享 ID 指向不存在页面:${shareId} -> ${pageId}`, + ); + } + } + + const registry = createRegistryCopy(input.registry); + const claimedIds = new Set(existingIndex.byId.keys()); + const assignedPageIds = new Set(existingIndex.byPageId.keys()); + const added: Array<{ id: ShareId; pageId: string }> = []; + + for (const pageId of currentPageIds) { + if (assignedPageIds.has(pageId)) continue; + + let shareId: ShareId | undefined; + + for (let attempt = 0; attempt < SHARE_ID_MAX_ATTEMPTS; attempt += 1) { + const candidate = generateStaticShareId({ pageId, attempt }); + + if (claimedIds.has(candidate)) continue; + + shareId = candidate; + break; + } + + if (shareId === undefined) { + throw new Error( + `短 ID 分配超过最大尝试次数:${pageId}(${SHARE_ID_MAX_ATTEMPTS})`, + ); + } + + claimedIds.add(shareId); + assignedPageIds.add(pageId); + registry.records[shareId] = { pageId, status: "active" }; + added.push({ id: shareId, pageId }); + } + + const sortedRegistry = createRegistryCopy(registry); + validateShareLinkRegistry(sortedRegistry, currentPageIds); + + return { + registry: sortedRegistry, + added, + unchangedCount: currentPageIds.length - added.length, + }; +} + +export function resolveCanonicalHref( + id: ShareId, + index: ShareLinkIndex, + cleanUrls = true, +): string | undefined { + if (!isShareId(id)) return undefined; + + const record = index.byId.get(id); + + if (record === undefined || record.status !== "active") return undefined; + + return pageIdToPublicHref(record.pageId, cleanUrls); +} diff --git a/.vitepress/utilities/short-link-pages.ts b/.vitepress/utilities/short-link-pages.ts new file mode 100644 index 0000000..e0c1af5 --- /dev/null +++ b/.vitepress/utilities/short-link-pages.ts @@ -0,0 +1,275 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { + YUUFRAG_CONTENT_ORIGIN, + isShareId, + type ShareId, +} from "../shared/share-link-contract.ts"; +import { + createShareLinkIndex, + resolveCanonicalHref, + type ShareLinkRegistry, +} from "./share-links.ts"; +import { pageIdToOutputFile, rewritePageId } from "./route-paths.ts"; + +export { YUUFRAG_CONTENT_ORIGIN } from "../shared/share-link-contract.ts"; + +export interface ShortLinkPageMetadata { + title: string; + description: string; +} + +export interface ShortLinkDeploymentManifest { + version: 1; + registryHash: string; + contentOrigin: typeof YUUFRAG_CONTENT_ORIGIN; + records: Record< + ShareId, + { status: "active"; target: string } | { status: "gone" } + >; +} + +export function parseShortLinkDeploymentManifest( + value: unknown, +): ShortLinkDeploymentManifest { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error("短链部署 manifest 必须是对象"); + } + + const candidate = value as Partial; + if ( + candidate.version !== 1 || + candidate.contentOrigin !== YUUFRAG_CONTENT_ORIGIN || + typeof candidate.registryHash !== "string" || + !/^[0-9a-f]{64}$/.test(candidate.registryHash) || + candidate.records === null || + typeof candidate.records !== "object" || + Array.isArray(candidate.records) + ) { + throw new Error("短链部署 manifest 顶层字段非法"); + } + + const records: ShortLinkDeploymentManifest["records"] = {}; + for (const [id, rawRecord] of Object.entries(candidate.records)) { + if (!isShareId(id) || rawRecord === null || typeof rawRecord !== "object") { + throw new Error(`短链部署记录非法:${id}`); + } + const record = rawRecord as { status?: unknown; target?: unknown }; + if (record.status === "gone" && record.target === undefined) { + records[id] = { status: "gone" }; + continue; + } + if (record.status !== "active" || typeof record.target !== "string") { + throw new Error(`短链部署记录非法:${id}`); + } + const target = new URL(record.target); + if ( + target.origin !== YUUFRAG_CONTENT_ORIGIN || + target.username || + target.password || + target.search || + target.hash + ) { + throw new Error(`短链部署目标非法:${id}`); + } + records[id] = { status: "active", target: target.href }; + } + + return { + version: 1, + registryHash: candidate.registryHash, + contentOrigin: YUUFRAG_CONTENT_ORIGIN, + records, + }; +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function serializeScriptString(value: string): string { + return JSON.stringify(value) + .replace(//g, "\\u003e") + .replace(/&/g, "\\u0026") + .replace(/\u2028/g, "\\u2028") + .replace(/\u2029/g, "\\u2029"); +} + +function pageShell(input: { + title: string; + description: string; + head: string; + body: string; +}): string { + return ` + + + + + + ${escapeHtml(input.title)} + + ${input.head} + + +
${input.body}
+ + +`; +} + +export function renderActiveShortLinkPage(input: { + target: string; + metadata: ShortLinkPageMetadata; +}): string { + const target = new URL(input.target); + + if ( + target.origin !== YUUFRAG_CONTENT_ORIGIN || + target.username || + target.password || + target.search || + target.hash + ) { + throw new Error(`短链目标非法:${input.target}`); + } + + const href = target.href; + const escapedHref = escapeHtml(href); + const title = input.metadata.title || "YuuFrag 分享链接"; + const description = input.metadata.description || "正在前往 YuuFrag 笔记页面"; + + return pageShell({ + title, + description, + head: [ + ``, + ``, + ``, + ``, + ``, + ``, + ].join("\n "), + body: `

正在前往 ${escapeHtml(title)}

`, + }); +} + +export function renderGoneShortLinkPage(): string { + return pageShell({ + title: "链接已下线 · YuuFrag", + description: "这个 YuuFrag 分享链接已下线。", + head: '', + body: "

链接已下线

这个分享链接不再指向任何页面。

", + }).replace('\n ', ""); +} + +export function renderUnknownShortLinkPage(): string { + return pageShell({ + title: "未找到分享链接 · YuuFrag", + description: "无法找到这个 YuuFrag 分享链接。", + head: '', + body: "

404

无法找到这个分享链接。

", + }).replace('\n ', ""); +} + +async function pathExists(file: string): Promise { + try { + await fs.access(file); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +} + +export async function publishShortLinkSite(input: { + registry: ShareLinkRegistry; + registryHash: string; + outputDir: string; + contentDistDir: string; + metadataForPageId: (pageId: string) => Promise; +}): Promise<{ activeCount: number; goneCount: number; manifest: ShortLinkDeploymentManifest }> { + if (!/^[0-9a-f]{64}$/.test(input.registryHash)) { + throw new Error("registry hash 非法"); + } + + const index = createShareLinkIndex(input.registry); + const records: ShortLinkDeploymentManifest["records"] = {}; + const tempDir = `${input.outputDir}.tmp-${process.pid}-${Date.now()}`; + const backupDir = `${input.outputDir}.backup-${process.pid}-${Date.now()}`; + let activeCount = 0; + let goneCount = 0; + + await fs.mkdir(path.join(tempDir, "s"), { recursive: true }); + + try { + for (const [id, record] of [...index.byId.entries()].sort(([a], [b]) => a.localeCompare(b))) { + if (!isShareId(id)) throw new Error(`非法分享 ID:${id}`); + const pageDir = path.join(tempDir, "s", id); + await fs.mkdir(pageDir, { recursive: true }); + + if (record.status === "gone") { + await fs.writeFile(path.join(pageDir, "index.html"), renderGoneShortLinkPage(), "utf8"); + records[id] = { status: "gone" }; + goneCount += 1; + continue; + } + + const canonicalPath = resolveCanonicalHref(id, index); + if (canonicalPath === undefined) throw new Error(`无法解析 active 分享 ID:${id}`); + const contentFile = path.join( + input.contentDistDir, + pageIdToOutputFile(rewritePageId(record.pageId)), + ); + + if (!(await pathExists(contentFile))) { + throw new Error(`短链目标内容产物不存在:${id} -> ${contentFile}`); + } + + const target = new URL(canonicalPath, `${YUUFRAG_CONTENT_ORIGIN}/`).href; + const metadata = await input.metadataForPageId(record.pageId); + await fs.writeFile( + path.join(pageDir, "index.html"), + renderActiveShortLinkPage({ target, metadata }), + "utf8", + ); + records[id] = { status: "active", target }; + activeCount += 1; + } + + const manifest: ShortLinkDeploymentManifest = { + version: 1, + registryHash: input.registryHash, + contentOrigin: YUUFRAG_CONTENT_ORIGIN, + records, + }; + await fs.writeFile( + path.join(tempDir, "manifest.json"), + `${JSON.stringify(manifest, null, 2)}\n`, + "utf8", + ); + await fs.writeFile(path.join(tempDir, "404.html"), renderUnknownShortLinkPage(), "utf8"); + + const hadOutput = await pathExists(input.outputDir); + if (hadOutput) await fs.rename(input.outputDir, backupDir); + + try { + await fs.rename(tempDir, input.outputDir); + } catch (error) { + if (hadOutput) await fs.rename(backupDir, input.outputDir); + throw error; + } + + if (hadOutput) await fs.rm(backupDir, { recursive: true, force: true }); + return { activeCount, goneCount, manifest }; + } catch (error) { + await fs.rm(tempDir, { recursive: true, force: true }); + throw error; + } +} diff --git a/package.json b/package.json index c47a9b5..8c0e175 100644 --- a/package.json +++ b/package.json @@ -33,13 +33,19 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "typecheck:vue": "vue-tsc -p tsconfig.vue.json --noEmit", "test": "pnpm test:unit", - "test:unit": "pnpm test:routes && pnpm test:rss && pnpm test:tags", + "test:unit": "pnpm test:routes && pnpm test:rss && pnpm test:tags && pnpm test:share-links", "test:rss": "node --test --experimental-strip-types tests/unit/rss-assets.test.ts", "test:routes": "node --test --experimental-strip-types tests/unit/route-paths.test.ts", + "test:share-links": "node --test --experimental-strip-types tests/unit/share-*.test.ts tests/unit/page-share-service.test.ts tests/unit/short-link-pages.test.ts", + "share-links:prepare": "node --experimental-strip-types .vitepress/scripts/prepare-share-links.ts", + "share-links:check": "node --experimental-strip-types .vitepress/scripts/check-share-links.ts", + "share-links:publish": "node --experimental-strip-types .vitepress/scripts/publish-short-links.ts", + "share-links:artifacts:check": "node --experimental-strip-types .vitepress/scripts/check-share-link-artifacts.ts", + "share-links:health": "node --experimental-strip-types .vitepress/scripts/health-check-short-links.ts", "test:tags": "node --test --experimental-strip-types tests/unit/tag-catalog.test.ts", "tags:generate": "node --experimental-strip-types .vitepress/scripts/generate-tags.ts", - "docs:dev": "pnpm tags:generate && vitepress dev", - "docs:build": "pnpm tags:generate && vitepress build", + "docs:dev": "pnpm tags:generate && pnpm share-links:prepare && vitepress dev", + "docs:build": "pnpm tags:generate && pnpm share-links:prepare && vitepress build && pnpm share-links:publish && pnpm share-links:artifacts:check", "docs:preview": "vitepress preview" }, "dependencies": { diff --git "a/posts/\347\274\226\347\250\213\350\257\255\350\250\200/C++/Exception.md" "b/posts/\347\274\226\347\250\213\350\257\255\350\250\200/C++/Exception.md" new file mode 100644 index 0000000..f13febb --- /dev/null +++ "b/posts/\347\274\226\347\250\213\350\257\255\350\250\200/C++/Exception.md" @@ -0,0 +1,424 @@ +# Exception + +Error 是问题 (~~废话~~), 那么一般来说可以分为编译期发现的和运行期间发现的. 此处简单说下 C++ 提供的一套在 Runtime 的时候对于 Error 的捕获,传播,处理等的机制 —— 也就是 Exception Handling + +简单来说,这是一套可以主动报告 Failure,并把 Failure 沿调用栈传递给某个 Handler. + +(关于 runtime error 的其他处理机制, 比如 std::option, std::expected 等,等开其他笔记再说, 此处聚焦 try-catch, throw 以及 exception 这套机制) + +## 简单例子 + +```C++ +void Load() +{ + throw std::runtime_error("load failed"); + std::cout << "Load End" << '\n'; +} + +int main() +{ + try + { + Load(); + } + catch (const std::exception& e) + { + std::cerr << e.what() << '\n'; + } + + std::cout << "continue\n"; +} +``` + +按照顺序执行, 先进入 `Try` 语句, 尝试执行 `Load`, 如果 `Load` 中出现了 throw 的 exception, 则会被 catch 匹配, 然后执行 catch 中的代码块, 执行之后继续按照顺序执行(**直接执行 try-catch 后的语句**). 所以上述代码的输出结果为 —— + +``` +load failed +continue +``` + +## 解决问题 + +那么这解决了一个什么问题呢 —— 当前层级的 Err, 自己可能不能处理, 要交付给上层进行处理 + +实际例子 —— + +``` +Application::LoadScene + ↓ +SceneLoader::Load + ↓ +TextureLoader::Load + ↓ +FileSystem::Read +``` + +然后 `FileSystem::Read` 挂了, 读取文件失败 —— 但是自己可能不能解决这个 Err, 因为文件损坏 / 没有读取权限等之类的有可能有多个问题导致文件失败,这不是 Read 方法可以自己解决的事情, 无奈, 只能把 Err 报告给上层,看看上面怎么处理. + +此处有两种处理方式 —— + +一是使用 Error Value 进行传播 + +```C++ +Result ReadFile() +{ + if (failed) + return Error; +} + +Result LoadTexture() +{ + auto result = ReadFile(); + + if (!result) + return result.error(); + + ... +} + +... // 再上层也同理 +``` + +`std::expected`、LLVM `Expected`、Abseil `StatusOr` 都属于这种实现, 非常的不赖, 也挺优雅的. + +那么另一种就是使用 Exception 来进行捕获 + +```C++ +File ReadFile() +{ + if (failed) + throw FileError(...); +} + +Texture LoadTexture() +{ + File file = ReadFile(); + + ... + // 就是正常编写逻辑 +} +``` + +然后只需要在有能力处理这个 Error 的调用层写 —— + +```C++ +try +{ + LoadScene(); +} +catch (const FileError& error) +{ + ... +} +``` + +也就是在 “调用者” 处进行错误的捕获和处理. + +那么这样实现的好处是可以让 Err 越过若干层级, 直接跳到可以处理它的调用层进行处理 + +## Exception Object + +当程序执行到 + +```C++ +throw std::runtime_error("load failed"); +``` + +的时候, 是实例化一个 `Exception Object` 对象, 并且记录信息, 让后面捕获这个 Exception 的地方可以进行一些处理 + +首先 `std::exception` 这个基类 —— + +```C++ +class exception +{ + public: + virtual ~exception() noexcept; + virtual const char* what() const noexcept; +}; +``` + +(被删的差不多的 exception) + +也就是说, 所有实现了这个接口的 exception 都有一个 `what` 接口,可以用于自己 override 记录错误的基础信息等 + +那么上面的 `std::runtime_error` 就是一个实现了 `std::exception` 的 exception, 其核心依旧是这个 `what`, 主要保存一段描述问题的信息 + +不过值得注意的是, throw 不是被限制要 throw 一个 `exception` 的接口, 而是可以 throw 其他什么, 比如 —— + +```C++ +throw 114514; +throw SomeClass{}; +``` + +## Throw 之后 + +(以下不是 C++ Standard 规定的具体实现方式,而是 Itanium C++ ABI 下 GCC/Clang 等常见实现使用的一套典型机制) + +最需要关注的点在于 —— 在遇到 Throw 语句之后, 如何向上传播. + +可以简单查阅一下 [Itanium ABI 文档](https://itanium-cxx-abi.github.io/cxx-abi/abi-eh.html) —— + +```C++ +temp = __cxa_allocate_exception(sizeof(X)); + +// construct X into temp + +__cxa_throw( + temp, + type_info, + destructor +); +``` + +这里就定义了几件事情要做 —— + +1. 准备一块能够保存 Exception Object 的空间 +2. 在这块空间中真正构造 `X` +3. 把这个 Exception Object 交给 C++ Exception Runtime +4. Runtime 同时记录 + 1. 它是什么类型 + 2. 最后应该怎么销毁它 +5. 开始沿调用栈寻找能够处理它的 Handler + +首先建立独立 Exception Object 的原因是 —— 如果 throw 来一个局部变量, 则会的当前的 Stack Frame 结束之后被销毁, 其声明周期无法保证它可以被传播到可以处理它的地方 + +接着记录 type_info 是为了在 `catch` 中可以进行类型匹配 —— + +```C++ +catch (const std::runtime_error&) +catch (const FileError&) +... +``` + +这样的代码可以让 throw 出来的对象正确进入分支 + +同时 `destructor` 是为了保证其声明周期 —— 在 catch 之后可以被正常释放掉 + +我们准备好了所有东西, 接下来就是如何传播的事情了 —— + +假设还是“解决问题”那个部分中的读取文件的例子, 我们会有一个调用链 —— + +``` +Caller + ↓ +LoadScene + ↓ +LoadTexture + ↓ +ReadFile +``` + +此时必须让 exception 随着 `ReadFile` 沿着这个路径一直找到 `Caller` 身上. + +显然, 此时涉及到栈帧的切换,所以需要涉及`Stack Unwinder`的事情, 简单来说, 就是让 Exception 向上传播的时候, 因为会离开一个栈帧, 所以需要对其作用域内的已经构造的局部对象进行释放, 然后继续向上传播.最后找到 Catch 为止. 看 catch 如何处理, 处理之后从 `try-catch` 块之后继续执行 + +不过实际上在 Itanium C++ ABI 的典型实现模型中其实有两个阶段 —— + +1. 先做查找, 仅查找 catch 在的栈帧, 而不做销毁, `_UA_SEARCH_PHASE` +2. 进行 Stack Unwinding, 并执行沿途需要的 Cleanup / Destructor, 一路走到 catch 的位置, `_UA_CLEANUP_PHASE` + +(其中实际上比较复杂的编译原理, 未来可期一下, 此处重点还是 C++ 的理解, 所以先极度化简下) + +此处为了保证资源可以被正常的销毁, 也是依赖 RAII 来进行管理, 保证没有资源泄露. + +不过哪怕利用 RAII 保证资源的释放正常, 也无法保证资源的逻辑正确. 比如 —— + +```C++ +void Config::Update(const NewConfig& newConfig) +{ + name_ = newConfig.name; + Validate(newConfig); // throw + path_ = newConfig.path; +} +``` + +那么此时 `config` 的状态就是名字是新的,但是路径是旧的, 所以工程上还会提出 `Exception Safety Guarantee` 这样的概念. + +感觉继续写会导致文章职责膨胀, 下次一定. 简单来说就是保证 Throw 之后我们最好依旧可以保证变量逻辑正确 (比如先做所有有可能 throw 的操作, 最后再统一 commit) + +```C++ +void Config::Update(const NewConfig& newConfig) +{ + Config temp = *this; + + temp.name_ = newConfig.name; + temp.path_ = newConfig.path; + + Validate(temp); + + std::swap(*this, temp); // commit +} +``` + +## noexcept + +`noexcept` 用于声明一个函数**不允许 Exception 从该函数边界逃逸**. 如果 Exception 实际试图逃出一个 `noexcept` 函数, 程序会调用 `std::terminate()`, 尤其是 `destructor` `move` `swap` 之类的方法非常的有意义 —— + +简单来个例子, 假设析构函数可以参与exception的传播链条 —— + +```C++ +class File +{ + public: + ~File() noexcept(false) { throw CloseError{}; } +} + +void Foo() +{ + File file; + MaybeThrow(); +} +``` + +系统在执行 `MaybeThrow` 的时候开始处理异常, Stack Unwinding 自动调用了 `~File()`, 而 Destructor 在执行过程中又抛出了第二个 Exception, 并让它逃出了 Destructor. 那么就会直接调用 `std::terminate()` 终止程序 + +原因是一个异常正在被尝试处理, 但是同时另一个异常又被系统自动掉其被抛出, 那么不管是继续处理第一个异常, 还是放弃第一个异常去处理第二个异常, 都会有很多问题(资源管理就是一大难题), 所以会直接调用 `std::terminate()` + +因此, 对于可能在 Stack Unwinding 中承担 Cleanup 的操作,尤其是 Destructor,应当保证 Exception 不会逃逸 + +与此同时, 对于承担 commit / resource transfer 职责, 并且实现本身确实能够保证不抛异常的 Move / Swap, 应该尽可能提供 `noexcept`. 这样 generic code 才能够利用这一性质建立或维护 Strong Exception Guarantee —— 还是举例子 —— 标准库里的 `vector` 在 realloc 的时候 + +``` +old storage +[A][B][C] + ↓ +new storage +``` + +假设在 move A, B 的时候正常, 但是在 move C 的时候抛出异常, 那么此时很难保证 Strong Guarantee, 于是 `std::move_if_noexcept` 的策略是 —— 如果可以 move 是 noexcept 就 move, 如果不是, 则使用 copy + +## 好的写法 + +在明确可以处理 exception 的时候再写, 而不是 catch 语句到处写. 同时设计类型的时候最好考虑针对不同的策略 —— 每种策略对应一种类型, 而不是写一堆类型. + +### Exception Hierarchy + +假设我们全部使用 `std::runtime_error(message)` 那么在 catch 之后做字符串匹配, 就显得非常愚蠢. 而是可以通过实现 `exception` 的接口, 然后做类型匹配 + +```C++ +class AssetError : public std::runtime_error +{ +public: + using std::runtime_error::runtime_error; +}; + +class AssetNotFoundError : public AssetError +{ +public: + AssetNotFoundError(Path path) + : AssetError("asset not found"), + path_(std::move(path)) + { + } + + const Path& PathValue() const noexcept + { + return path_; + } + +private: + Path path_; +}; + +class AssetDecodeError : public AssetError +{ +public: + using AssetError::AssetError; +}; +``` + +这种利用继承树的实现也可以让调用处可以自己选择处理粒度 —— + +```C++ +try +{ + LoadAsset(path); +} +catch (const AssetNotFoundError& e) +{ + UseFallbackAsset(); +} +catch (const AssetDecodeError& e) +{ + ReportCorruptedAsset(e); +} +catch (const AssetError& e) +{ + ReportAssetFailure(e); +} +``` + +借助同一个 `try` 对应的 handlers 会按照源码出现顺序依次进行 matching 的特性, 所以写的顺序也要从细粒度到粗粒度,最后“兜底”这样来写 + +### Catch-All Boundary + +最好还是先匹配类型, 然后通过 `...` 来进行兜底 —— + +```C++ +try +{ + RunApplication(); +} +catch (const std::exception& e) +{ + LogFatal(e.what()); + + return EXIT_FAILURE; +} +catch (...) +{ + LogFatal("unknown exception"); + + return EXIT_FAILURE; +} +``` + +### Rethrow + +当前层有时不能真正处理 Exception,但可能需要完成某些必要的附加工作,之后继续传播原始 Exception,此时可以使用 `throw;` + +```C++ +try +{ + Load(); +} +catch (const AssetError& e) +{ + AddTrace(e); + + throw; +} +``` + +这样的语义是 —— 当前层不能处理,只进行必要的附加工作,然后继续传播原异常 + +不过值得注意的是, 此处如果写成`throw e;`的话, 语义又完全不同,而是类似下一个文段说的内容. + +于此同时, 可能会出现 `object slicing` 的问题, 即根据当前 e 的静态类型进行创建 throw 对象之后再进行传播, 可能会只剩下基类那部分数据 + +### Exception Translation + +此处的语义是把当前匹配到的 throw 做一次重新的打包, 成为自己系统中的 Error 传播对象 + +```C++ +try +{ + ReadFile(path); +} +catch (const std::filesystem::filesystem_error& e) +{ + throw AssetLoadError( + AssetErrorCode::IOError, + path, + e.what() + ); +} +``` + +这样会重新开启一个新的 throw exception 的传播链条 + +## 总结 + +> [!note] Don’t try to catch every exception in every function. + +不要在各个函数中都写 catch, 而是在明确可以处理某种 exception 的时候再写; \ No newline at end of file diff --git a/tests/unit/page-share-service.test.ts b/tests/unit/page-share-service.test.ts new file mode 100644 index 0000000..0f1d8d8 --- /dev/null +++ b/tests/unit/page-share-service.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + DefaultPageShareService, + type ClipboardAdapter, + type NativeShareAdapter, + type PageShareInput, +} from "../../.vitepress/theme/services/page-share-service.ts"; + +const input: PageShareInput = { + canonicalPath: "/posts/中文", + canonicalUrl: "https://yuufrag.machillka.com/posts/%E4%B8%AD%E6%96%87", + title: "中文", +}; + +function adapters(options: { native?: "ok" | "abort" | "fail" | "off"; clipboard?: "ok" | "fail" } = {}) { + let copied: string | undefined; + const native: NativeShareAdapter = { + canShare: () => options.native !== "off", + share: async () => { + if (options.native === "abort") throw Object.assign(new Error(), { name: "AbortError" }); + if (options.native === "fail") throw new Error("share failed"); + }, + }; + const clipboard: ClipboardAdapter = { + writeText: async (value) => { + copied = value; + if (options.clipboard === "fail") throw new Error("copy failed"); + }, + }; + return { native, clipboard, copied: () => copied }; +} + +test("native 成功与取消不会触发 Clipboard", async () => { + for (const nativeResult of ["ok", "abort"] as const) { + const a = adapters({ native: nativeResult }); + const service = new DefaultPageShareService( + { findByCanonicalPath: async () => ({ id: "23456789ab", url: "https://yuufrag.machillka.com/s/23456789ab" }) }, + a.native, + a.clipboard, + ); + const result = await service.share(input); + assert.equal(result.status, nativeResult === "ok" ? "shared" : "cancelled"); + assert.equal(a.copied(), undefined); + } +}); + +test("native 技术失败回退复制,查询失败回退 canonical", async () => { + const a = adapters({ native: "fail" }); + const service = new DefaultPageShareService( + { findByCanonicalPath: async () => { throw new Error("manifest unavailable"); } }, + a.native, + a.clipboard, + ); + const result = await service.share(input); + assert.deepEqual(result, { status: "copied", url: input.canonicalUrl, fallback: true }); + assert.equal(a.copied(), input.canonicalUrl); +}); + +test("未命中使用 canonical,Clipboard 失败返回 manual URL", async () => { + const a = adapters({ native: "off", clipboard: "fail" }); + const service = new DefaultPageShareService( + { findByCanonicalPath: async () => undefined }, + a.native, + a.clipboard, + ); + const result = await service.share(input); + assert.equal(result.status, "manual"); + assert.equal(result.url, input.canonicalUrl); + assert.equal(result.fallback, true); +}); diff --git a/tests/unit/share-button-state.test.ts b/tests/unit/share-button-state.test.ts new file mode 100644 index 0000000..a3e14ce --- /dev/null +++ b/tests/unit/share-button-state.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + createShareButtonController, +} from "../../.vitepress/theme/components/share-button-state.ts"; + +test("fake service 驱动 copied、fallback 和 manual 状态", async () => { + const input = { + canonicalPath: "/posts/中文", + canonicalUrl: "https://yuufrag.machillka.com/posts/中文", + title: "中文", + }; + const copied = createShareButtonController( + { share: async (_input, phase) => { + phase?.("querying"); + phase?.("copying"); + return { status: "copied", url: input.canonicalUrl, fallback: true }; + } }, + () => input, + ); + await copied.share(); + assert.match(copied.message.value, /原始链接/); + assert.equal(copied.displayUrl.value, input.canonicalUrl); + + const manual = createShareButtonController( + { share: async () => ({ status: "manual", url: input.canonicalUrl, fallback: false }) }, + () => input, + ); + await manual.share(); + assert.equal(manual.displayUrl.value, input.canonicalUrl); + + manual.reset(); + assert.equal(manual.displayUrl.value, ""); +}); + +test("用户取消安静回到 idle", async () => { + const controller = createShareButtonController( + { share: async () => ({ status: "cancelled", url: "https://x.test", fallback: false }) }, + () => ({ canonicalPath: "/posts/x", canonicalUrl: "https://x.test", title: "x" }), + ); + await controller.share(); + assert.equal(controller.message.value, ""); + assert.equal(controller.busy.value, false); +}); + +test("下拉框内再次复制时保留成功反馈,避免文本闪烁", async () => { + let calls = 0; + let controller!: ReturnType; + const messagesDuringSecondCopy: string[] = []; + const url = "https://yuufrag.machillka.com/s/23456789ab"; + const service = { + share: async (_input: unknown, phase?: (value: "querying" | "sharing" | "copying") => void) => { + calls += 1; + phase?.("querying"); + if (calls === 2) messagesDuringSecondCopy.push(controller.message.value); + phase?.("copying"); + if (calls === 2) messagesDuringSecondCopy.push(controller.message.value); + return { status: "copied" as const, url, fallback: false }; + }, + }; + controller = createShareButtonController( + service, + () => ({ canonicalPath: "/", canonicalUrl: url, title: "首页" }), + ); + + await controller.share(); + assert.equal(controller.message.value, "链接已复制"); + await controller.share(); + assert.deepEqual(messagesDuringSecondCopy, ["链接已复制", "链接已复制"]); + assert.equal(controller.message.value, "链接已复制"); +}); diff --git a/tests/unit/share-link-artifact-check.test.ts b/tests/unit/share-link-artifact-check.test.ts new file mode 100644 index 0000000..3fcb0b5 --- /dev/null +++ b/tests/unit/share-link-artifact-check.test.ts @@ -0,0 +1,18 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { assertNoPublishedShortLinks } from "../../.vitepress/utilities/share-link-artifact-check.ts"; +import { parseShortLinkDeploymentManifest } from "../../.vitepress/utilities/short-link-pages.ts"; + +test("部署 manifest 拒绝外域 target", () => { + assert.throws(() => parseShortLinkDeploymentManifest({ + version: 1, + registryHash: "a".repeat(64), + contentOrigin: "https://yuufrag.machillka.com", + records: { "23456789ab": { status: "active", target: "https://evil.test" } }, + })); +}); + +test("sitemap、RSS、搜索和图谱内容不得含短链", () => { + assert.doesNotThrow(() => assertNoPublishedShortLinks([{ file: "sitemap.xml", content: "https://yuufrag.machillka.com/posts/x" }])); + assert.throws(() => assertNoPublishedShortLinks([{ file: "feed.rss", content: "https://yuufrag.machillka.com/s/23456789ab" }])); +}); diff --git a/tests/unit/share-link-contract.test.ts b/tests/unit/share-link-contract.test.ts new file mode 100644 index 0000000..44d92c6 --- /dev/null +++ b/tests/unit/share-link-contract.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + normalizeCanonicalPath, + parseShareLinkManifest, + shareIdToShortUrl, +} from "../../.vitepress/shared/share-link-contract.ts"; +import { baseUrl } from "../../.vitepress/shared/site-config.ts"; +import { createPageShareInput } from "../../.vitepress/theme/services/page-share-context.ts"; + +test("canonical path 归一化中文、编码、query、hash 与 html", () => { + assert.equal( + normalizeCanonicalPath("/posts/%E4%B8%AD%E6%96%87.html?q=1#x"), + "/posts/中文", + ); + assert.equal(normalizeCanonicalPath("/posts/目录/index.html"), "/posts/目录/"); +}); + +test("manifest schema 拒绝错误域名和短 ID", () => { + const base = { + version: 1, + registryHash: "a".repeat(64), + shortOrigin: baseUrl, + byCanonicalPath: { "/posts/中文": "23456789ab" }, + }; + + assert.deepEqual(parseShareLinkManifest(base), base); + assert.throws(() => parseShareLinkManifest({ ...base, shortOrigin: "https://bad.test" })); + assert.throws(() => parseShareLinkManifest({ ...base, byCanonicalPath: { "/x": "invalid" } })); + assert.throws(() => parseShareLinkManifest({ + ...base, + byCanonicalPath: { "/x": "23456789ab", "/y": "23456789ab" }, + })); + assert.equal(shareIdToShortUrl("23456789ab"), `${baseUrl}/s/23456789ab`); +}); + +test("文章原始链接与短 ID 链接都使用 baseUrl 前缀", () => { + const page = createPageShareInput({ + routePath: "/posts/中文?q=1#section", + title: "中文", + }); + + assert.equal(page.canonicalUrl, `${baseUrl}/posts/%E4%B8%AD%E6%96%87`); + assert.ok(shareIdToShortUrl("23456789ab").startsWith(`${baseUrl}/`)); +}); diff --git a/tests/unit/share-link-manifest-plugin.test.ts b/tests/unit/share-link-manifest-plugin.test.ts new file mode 100644 index 0000000..7ac0b3b --- /dev/null +++ b/tests/unit/share-link-manifest-plugin.test.ts @@ -0,0 +1,24 @@ +import assert from "node:assert/strict"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { loadShareLinkManifestSource } from "../../.vitepress/utilities/share-link-manifest-plugin.ts"; + +test("manifest 插件只接受有效的中间 manifest", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "yuufrag-manifest-plugin-")); + const file = path.join(root, "manifest.json"); + try { + await fs.writeFile(file, JSON.stringify({ + version: 1, + registryHash: "a".repeat(64), + shortOrigin: "https://yuufrag.machillka.com", + byCanonicalPath: { "/posts/中文": "23456789ab" }, + })); + assert.match(await loadShareLinkManifestSource(file), /中文/); + await fs.writeFile(file, JSON.stringify({ version: 2 })); + await assert.rejects(loadShareLinkManifestSource(file)); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/share-link-query.test.ts b/tests/unit/share-link-query.test.ts new file mode 100644 index 0000000..aae2476 --- /dev/null +++ b/tests/unit/share-link-query.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + ShareLinkQueryError, + StaticManifestShareLinkQuery, +} from "../../.vitepress/theme/services/share-link-query.ts"; + +const manifest = { + version: 1 as const, + registryHash: "a".repeat(64), + shortOrigin: "https://yuufrag.machillka.com" as const, + byCanonicalPath: { "/posts/中文 页面": "23456789ab" }, +}; + +test("查询是惰性的,并发和后续调用只 fetch 一次", async () => { + let calls = 0; + let release!: () => void; + const wait = new Promise((resolve) => { release = resolve; }); + const query = new StaticManifestShareLinkQuery({ + fetchImpl: async () => { + calls += 1; + await wait; + return { ok: true, status: 200, json: async () => manifest }; + }, + }); + + assert.equal(calls, 0); + const first = query.findByCanonicalPath("/posts/%E4%B8%AD%E6%96%87%20%E9%A1%B5%E9%9D%A2?q=1"); + const second = query.findByCanonicalPath("/posts/中文 页面#x"); + assert.equal(calls, 1); + release(); + assert.deepEqual(await first, { + id: "23456789ab", + url: "https://yuufrag.machillka.com/s/23456789ab", + }); + assert.deepEqual(await second, await first); + assert.equal(await query.findByCanonicalPath("/missing"), undefined); + assert.equal(calls, 1); +}); + +test("网络、HTTP 和 schema 错误可区分", async () => { + const fixtures = [ + { expected: "fetch", fetchImpl: async () => { throw new Error("offline"); } }, + { expected: "http", fetchImpl: async () => ({ ok: false, status: 503, json: async () => ({}) }) }, + { expected: "schema", fetchImpl: async () => ({ ok: true, status: 200, json: async () => ({ version: 2 }) }) }, + ]; + + for (const fixture of fixtures) { + const query = new StaticManifestShareLinkQuery({ fetchImpl: fixture.fetchImpl }); + await assert.rejects( + () => query.findByCanonicalPath("/posts/中文"), + (error: unknown) => error instanceof ShareLinkQueryError && error.code === fixture.expected, + ); + } +}); + +test("失败不永久缓存,下一次点击可以重试", async () => { + let calls = 0; + const query = new StaticManifestShareLinkQuery({ + fetchImpl: async () => { + calls += 1; + if (calls === 1) throw new Error("temporary offline"); + return { ok: true, status: 200, json: async () => manifest }; + }, + }); + await assert.rejects(query.findByCanonicalPath("/posts/中文 页面")); + assert.notEqual(await query.findByCanonicalPath("/posts/中文 页面"), undefined); + assert.equal(calls, 2); +}); diff --git a/tests/unit/share-link-registry-files.test.ts b/tests/unit/share-link-registry-files.test.ts new file mode 100644 index 0000000..7491df2 --- /dev/null +++ b/tests/unit/share-link-registry-files.test.ts @@ -0,0 +1,199 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + checkShareLinkFiles, + loadShareLinkRegistry, + prepareShareLinkFiles, +} from "../../.vitepress/utilities/share-link-registry-files.ts"; + +async function withTemporaryFiles( + callback: (input: { + root: string; + registryFile: string; + manifestFile: string; + }) => Promise, +): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "yuufrag-share-links-")); + + try { + await callback({ + root, + registryFile: path.join(root, "data/share-links.json"), + manifestFile: path.join(root, "generated/share-links-manifest.json"), + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +function hash(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +test("prepare writes only missing ids and a query manifest", async () => { + await withTemporaryFiles(async ({ registryFile, manifestFile }) => { + const pageIds = ["posts/B.md", "posts/中文.md", "posts/A.md"]; + const first = await prepareShareLinkFiles({ + registryFile, + generatedManifestFile: manifestFile, + pageIds, + }); + const registry = await loadShareLinkRegistry(registryFile); + const manifest = JSON.parse(await fs.readFile(manifestFile, "utf8")) as { + byCanonicalPath: Record; + shortOrigin: string; + }; + + assert.equal(first.added.length, 3); + assert.equal(first.registryChanged, true); + assert.equal(first.manifestChanged, true); + assert.equal(Object.keys(registry.records).length, 3); + assert.equal(Object.keys(manifest.byCanonicalPath).length, 3); + assert.equal(manifest.shortOrigin, "https://yuufrag.machillka.com"); + assert.ok(manifest.byCanonicalPath["/posts/中文"]); + }); +}); + +test("repeated prepare is stable and does not rewrite unchanged files", async () => { + await withTemporaryFiles(async ({ registryFile, manifestFile }) => { + const pageIds = ["posts/A.md"]; + await prepareShareLinkFiles({ + registryFile, + generatedManifestFile: manifestFile, + pageIds, + }); + const registryBefore = await fs.readFile(registryFile, "utf8"); + const manifestBefore = await fs.readFile(manifestFile, "utf8"); + const second = await prepareShareLinkFiles({ + registryFile, + generatedManifestFile: manifestFile, + pageIds, + }); + + assert.equal(second.added.length, 0); + assert.equal(second.registryChanged, false); + assert.equal(second.manifestChanged, false); + assert.equal(await fs.readFile(registryFile, "utf8"), registryBefore); + assert.equal(await fs.readFile(manifestFile, "utf8"), manifestBefore); + }); +}); + +test("check is read-only", async () => { + await withTemporaryFiles(async ({ registryFile, manifestFile }) => { + const pageIds = ["posts/A.md"]; + await prepareShareLinkFiles({ + registryFile, + generatedManifestFile: manifestFile, + pageIds, + }); + const before = await fs.readFile(registryFile, "utf8"); + + const result = await checkShareLinkFiles({ registryFile, pageIds }); + + assert.equal(result.activeCount, 1); + assert.equal(result.goneCount, 0); + assert.equal(await fs.readFile(registryFile, "utf8"), before); + }); +}); + +test("prepare refuses to run while the registry lock exists", async () => { + await withTemporaryFiles(async ({ registryFile, manifestFile }) => { + await fs.mkdir(path.dirname(registryFile), { recursive: true }); + await fs.writeFile(`${registryFile}.lock`, "other-process\n", "utf8"); + + await assert.rejects( + () => + prepareShareLinkFiles({ + registryFile, + generatedManifestFile: manifestFile, + pageIds: ["posts/A.md"], + }), + /分享注册表正被另一个进程更新/, + ); + await assert.rejects(() => fs.access(registryFile)); + }); +}); + +test("prepare refuses to replace a registry changed outside its lock", async () => { + await withTemporaryFiles(async ({ registryFile, manifestFile }) => { + await fs.mkdir(path.dirname(registryFile), { recursive: true }); + await fs.writeFile( + registryFile, + JSON.stringify({ version: 1, records: {} }), + "utf8", + ); + + const changedContent = JSON.stringify({ + version: 1, + records: { + k7m2p9x4qd: { pageId: "posts/A.md", status: "active" }, + }, + }); + const originalReadFile = fs.readFile.bind(fs); + let registryReadCount = 0; + + const readTextIfExists = async (file: string): Promise => { + if (file === registryFile) { + registryReadCount += 1; + if (registryReadCount === 3) { + await fs.writeFile(registryFile, changedContent, "utf8"); + } + } + + try { + return await originalReadFile(file, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return undefined; + } + + throw error; + } + }; + + await assert.rejects( + () => + prepareShareLinkFiles({ + registryFile, + generatedManifestFile: manifestFile, + pageIds: ["posts/A.md"], + readTextIfExists, + }), + /分享注册表在 prepare 期间已被修改/, + ); + assert.equal(hash(await fs.readFile(registryFile, "utf8")), hash(changedContent)); + }); +}); + +test("active records that no longer point to a page fail before writing", async () => { + await withTemporaryFiles(async ({ registryFile, manifestFile }) => { + await fs.mkdir(path.dirname(registryFile), { recursive: true }); + await fs.writeFile( + registryFile, + JSON.stringify({ + version: 1, + records: { + k7m2p9x4qd: { pageId: "posts/旧页面.md", status: "active" }, + }, + }), + "utf8", + ); + const before = await fs.readFile(registryFile, "utf8"); + + await assert.rejects( + () => + prepareShareLinkFiles({ + registryFile, + generatedManifestFile: manifestFile, + pageIds: ["posts/新页面.md"], + }), + /active 分享 ID 指向不存在页面:k7m2p9x4qd -> posts\/旧页面\.md/, + ); + assert.equal(await fs.readFile(registryFile, "utf8"), before); + }); +}); diff --git a/tests/unit/share-links.test.ts b/tests/unit/share-links.test.ts new file mode 100644 index 0000000..acbef04 --- /dev/null +++ b/tests/unit/share-links.test.ts @@ -0,0 +1,163 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import fg from "fast-glob"; +import path from "node:path"; + +import { + SHARE_ID_ALPHABET, + SHARE_ID_LENGTH, + createShareLinkIndex, + generateStaticShareId, + isShareId, + normalizeSharePageId, + prepareShareLinks, + resolveCanonicalHref, + validateShareLinkRegistry, + type ShareLinkRegistry, +} from "../../.vitepress/utilities/share-links.ts"; + +const projectRoot = path.resolve(import.meta.dirname, "../.."); + +function registry( + records: ShareLinkRegistry["records"], +): ShareLinkRegistry { + return { version: 1, records }; +} + +test("normalizes page ids as POSIX NFC paths", () => { + assert.equal( + normalizeSharePageId("\\posts\\A\u030A.md"), + "posts/Å.md", + ); + assert.equal(normalizeSharePageId("/posts/中文.md"), "posts/中文.md"); +}); + +test("accepts only the fixed share id alphabet and length", () => { + const validId = SHARE_ID_ALPHABET.slice(0, 1).repeat(SHARE_ID_LENGTH); + + assert.equal(isShareId(validId), true); + assert.equal(isShareId("k7m2p9x4q"), false); + assert.equal(isShareId("k7m2p9x4qdz"), false); + assert.equal(isShareId("K7m2p9x4qd"), false); + assert.equal(isShareId("k7m2p9x4o0"), false); + assert.equal(isShareId("k7m2p9x4/2"), false); +}); + +test("generates deterministic fixed-width share ids", () => { + const input = { pageId: "posts/软件工程/设计.md", attempt: 0 }; + const first = generateStaticShareId(input); + const second = generateStaticShareId(input); + + assert.equal(first, second); + assert.equal(first.length, SHARE_ID_LENGTH); + assert.equal(isShareId(first), true); + assert.notEqual( + first, + generateStaticShareId({ ...input, attempt: 1 }), + ); +}); + +test("prepares missing pages in stable order without mutating input", () => { + const input = registry({}); + const pageIds = ["posts/B.md", "posts/中文.md", "posts/A.md"]; + const first = prepareShareLinks({ registry: input, pageIds }); + const second = prepareShareLinks({ + registry: input, + pageIds: [...pageIds].reverse(), + }); + + assert.deepEqual(first.registry, second.registry); + assert.deepEqual(input, registry({})); + assert.equal(first.added.length, 3); + assert.equal(first.unchangedCount, 0); + assert.doesNotThrow(() => validateShareLinkRegistry(first.registry, pageIds)); +}); + +test("retries a claimed candidate instead of overwriting it", () => { + const pageId = "posts/碰撞.md"; + const firstCandidate = generateStaticShareId({ pageId, attempt: 0 }); + const nextCandidate = generateStaticShareId({ pageId, attempt: 1 }); + const input = registry({ + [firstCandidate]: { pageId: "posts/已有.md", status: "active" }, + }); + + const result = prepareShareLinks({ + registry: input, + pageIds: ["posts/已有.md", pageId], + }); + + assert.equal(result.registry.records[firstCandidate].pageId, "posts/已有.md"); + assert.equal(result.registry.records[nextCandidate].pageId, pageId); + assert.deepEqual(result.added, [{ id: nextCandidate, pageId }]); +}); + +test("rejects multiple active ids for one page", () => { + assert.throws( + () => + createShareLinkIndex( + registry({ + "k7m2p9x4qd": { pageId: "posts/A.md", status: "active" }, + "m7n2p9x4qd": { pageId: "posts/A.md", status: "active" }, + }), + ), + /页面存在多个 active 分享 ID:posts\/A\.md/, + ); +}); + +test("rejects an active id that targets a missing page", () => { + assert.throws( + () => + validateShareLinkRegistry( + registry({ + "k7m2p9x4qd": { pageId: "posts/A.md", status: "active" }, + }), + ["posts/B.md"], + ), + /active 分享 ID 指向不存在页面:k7m2p9x4qd -> posts\/A\.md/, + ); +}); + +test("allows gone ids without allowing them to resolve", () => { + const shareId = "k7m2p9x4qd"; + const input = registry({ + [shareId]: { pageId: "posts/已删除.md", status: "gone" }, + }); + const result = prepareShareLinks({ + registry: input, + pageIds: ["posts/当前.md"], + }); + const index = createShareLinkIndex(result.registry); + + assert.equal(resolveCanonicalHref(shareId, index), undefined); + assert.equal(index.byId.get(shareId)?.status, "gone"); + assert.equal(result.added.length, 1); +}); + +test("resolves active ids through the canonical route utility", () => { + const shareId = "k7m2p9x4qd"; + const index = createShareLinkIndex( + registry({ + [shareId]: { + pageId: "posts/软件工程/一个 页面.md", + status: "active", + }, + }), + ); + + assert.equal( + resolveCanonicalHref(shareId, index), + "/posts/软件工程/一个-页面", + ); + assert.equal(resolveCanonicalHref("not-an-id", index), undefined); +}); + +test("current posts inventory can be assigned unique active ids", async () => { + const pageIds = await fg("posts/**/*.md", { + cwd: projectRoot, + onlyFiles: true, + }); + const result = prepareShareLinks({ registry: registry({}), pageIds }); + + assert.equal(result.added.length, pageIds.length); + assert.doesNotThrow(() => validateShareLinkRegistry(result.registry, pageIds)); +}); diff --git a/tests/unit/short-link-pages.test.ts b/tests/unit/short-link-pages.test.ts new file mode 100644 index 0000000..87dd062 --- /dev/null +++ b/tests/unit/short-link-pages.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { + publishShortLinkSite, + renderActiveShortLinkPage, + renderGoneShortLinkPage, +} from "../../.vitepress/utilities/short-link-pages.ts"; + +test("active HTML 安全跳转并提供 canonical、refresh、JS 和无 JS 链接", () => { + const html = renderActiveShortLinkPage({ + target: "https://yuufrag.machillka.com/posts/%E4%B8%AD%E6%96%87", + metadata: { title: "标题 ", description: "描述 & 更多" }, + }); + assert.match(html, /noindex,follow/); + assert.match(html, /rel="canonical"/); + assert.match(html, /http-equiv="refresh"/); + assert.match(html, /location\.replace/); + assert.match(html, //); +}); + +test("active HTML 拒绝开放重定向、query 与 hash", () => { + for (const target of [ + "https://evil.test/x", + "https://yuufrag.machillka.com/x?target=https://evil.test", + "https://yuufrag.machillka.com/x#evil", + ]) { + assert.throws(() => renderActiveShortLinkPage({ + target, + metadata: { title: "x", description: "x" }, + })); + } + assert.match(renderGoneShortLinkPage(), /noindex,nofollow/); +}); + +test("发布器生成 active、gone、404 和同 hash manifest,并原子替换旧产物", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "yuufrag-shortlinks-")); + const outputDir = path.join(root, "out"); + const contentDistDir = path.join(root, "content"); + await fs.mkdir(path.join(contentDistDir, "posts"), { recursive: true }); + await fs.writeFile(path.join(contentDistDir, "posts", "中文.html"), "ok"); + await fs.mkdir(outputDir); + await fs.writeFile(path.join(outputDir, "old.txt"), "old"); + + try { + const result = await publishShortLinkSite({ + registry: { + version: 1, + records: { + "23456789ab": { pageId: "posts/中文.md", status: "active" }, + "23456789ac": { pageId: "posts/旧文.md", status: "gone" }, + }, + }, + registryHash: "b".repeat(64), + outputDir, + contentDistDir, + metadataForPageId: async () => ({ title: "中文", description: "描述" }), + }); + assert.equal(result.manifest.registryHash, "b".repeat(64)); + assert.equal(result.activeCount, 1); + assert.equal(result.goneCount, 1); + await fs.access(path.join(outputDir, "s", "23456789ab", "index.html")); + await fs.access(path.join(outputDir, "s", "23456789ac", "index.html")); + await fs.access(path.join(outputDir, "404.html")); + await assert.rejects(fs.access(path.join(outputDir, "old.txt"))); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +});