diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java index 4aadb203..5f4cde3f 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java @@ -444,6 +444,35 @@ public class WikiController { return R.ok(page); } + /** + * Lightweight wikilink resolution index. + *

+ * The viewer's wikilink resolver needs a {slug, title, archived} list that + * (1) is not constrained by the user's selected raw-material filter, and + * (2) is not paginated. The general page list endpoint above is filtered + * by rawId and may scope down based on UI state, so this is a separate, + * minimal endpoint dedicated to the resolver. + *

+ * Archived pages are excluded by default. Pass {@code includeArchived=true} + * to retrieve archived rows as well (useful when the renderer needs to mark + * existing links to archived targets as such instead of treating them as + * broken links). + */ + @RequireWorkspaceRole("viewer") + @Operation(summary = "获取 Wiki 页面引用索引(slug/title/archived,供 wikilink 解析)") + @GetMapping("/knowledge-bases/{kbId}/pages/refs") + public R> listPageRefs( + @PathVariable Long kbId, + @RequestParam(name = "includeArchived", defaultValue = "false") boolean includeArchived, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + verifyKBWorkspace(kbId, workspaceId); + List items = pageService.listAllRefs(kbId, includeArchived); + Map body = new LinkedHashMap<>(); + body.put("kbId", kbId); + body.put("items", items); + return R.ok(body); + } + @RequireWorkspaceRole("member") @Operation(summary = "手动编辑 Wiki 页面") @PutMapping("/knowledge-bases/{kbId}/pages/{slug}") diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java index c3e3e828..9a3ebacd 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiPageService.java @@ -52,6 +52,18 @@ public class WikiPageService { /** Agent 引用记录 */ public record ReferenceEntry(String slug, String title, int refCount) {} + /** + * Lightweight page reference for client-side wikilink resolution. + *

+ * Carries only {slug, title, archived} — no content, no source, no enrichment + * fields. Designed so the frontend can build a slug/title lookup map without + * dragging full page entities (each of which can be tens of KB once content + * is loaded). The {@code archived} flag lets the renderer pick the correct + * visual state (active link vs archived link vs broken span) without a + * second roundtrip. + */ + public record PageRef(String slug, String title, boolean archived) {} + /** 获取被引用最多的页面 Top N */ public List getTopReferenced(Long kbId, int limit) { String prefix = kbId + ":"; @@ -178,6 +190,44 @@ public class WikiPageService { summaryCache.remove(kbId); } + /** + * List all wikilink resolution refs in a knowledge base. + *

+ * The frontend wikilink resolver needs a complete {slug → page} index that + * is independent of the user's raw-material filter and unaffected by lazy + * pagination. {@link #listByKbId} only returns non-archived rows and is + * filtered by the UI's selected raw, so it cannot back wikilink resolution. + * This method serves the dedicated {@code GET /pages/refs} endpoint and + * returns minimal projections (slug + title + archived flag). + *

+ * When {@code includeArchived} is false (default), reuses the 5-minute + * summary cache for free; archived pages are absent there by construction. + * When true, runs a fresh query selecting only the three projected columns + * — uncached, because archived links appear on a small subset of pages and + * are not worth caching invalidation complexity. + * + * @param kbId knowledge base + * @param includeArchived true to include archived=1 rows; false (default) + * returns only active pages + */ + public List listAllRefs(Long kbId, boolean includeArchived) { + if (!includeArchived) { + return listSummaries(kbId).stream() + .map(p -> new PageRef(p.getSlug(), p.getTitle(), false)) + .toList(); + } + List rows = pageMapper.selectList( + new LambdaQueryWrapper() + .select(WikiPageEntity::getSlug, WikiPageEntity::getTitle, + WikiPageEntity::getArchived) + .eq(WikiPageEntity::getKbId, kbId) + .orderByAsc(WikiPageEntity::getTitle)); + return rows.stream() + .map(p -> new PageRef(p.getSlug(), p.getTitle(), + p.getArchived() != null && p.getArchived() == 1)) + .toList(); + } + /** * DB 级别搜索页面(不加载 content CLOB 到 Java 内存) */ diff --git a/mateclaw-ui/package.json b/mateclaw-ui/package.json index 409db88b..cec79041 100644 --- a/mateclaw-ui/package.json +++ b/mateclaw-ui/package.json @@ -9,7 +9,9 @@ "build": "bash ../scripts/check-snowflake-precision.sh && node --max-old-space-size=6144 ./node_modules/vue-tsc/bin/vue-tsc.js --noEmit && node --max-old-space-size=6144 ./node_modules/vite/bin/vite.js build", "preview": "vite preview", "lint": "eslint src --ext .ts,.vue --fix && bash ../scripts/check-snowflake-precision.sh", - "lint:precision": "bash ../scripts/check-snowflake-precision.sh" + "lint:precision": "bash ../scripts/check-snowflake-precision.sh", + "test": "vitest run", + "test:watch": "vitest" }, "dependencies": { "@element-plus/icons-vue": "^2.3.1", @@ -49,10 +51,12 @@ "autoprefixer": "^10.4.20", "eslint": "^9.18.0", "eslint-plugin-vue": "^9.32.0", + "happy-dom": "^20.9.0", "rollup-plugin-visualizer": "^7.0.1", "tailwindcss": "^4.0.6", "typescript": "~5.7.2", "vite": "^7.3.1", + "vitest": "^4.1.7", "vue-tsc": "^3.2.6" }, "pnpm": { diff --git a/mateclaw-ui/pnpm-lock.yaml b/mateclaw-ui/pnpm-lock.yaml index 57d9e133..dbf6e128 100644 --- a/mateclaw-ui/pnpm-lock.yaml +++ b/mateclaw-ui/pnpm-lock.yaml @@ -92,7 +92,7 @@ importers: devDependencies: '@tailwindcss/vite': specifier: ^4.2.2 - version: 4.2.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0)) + version: 4.2.2(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.32.0)) '@types/dagre': specifier: ^0.7.54 version: 0.7.54 @@ -101,7 +101,7 @@ importers: version: 0.16.8 '@vitejs/plugin-vue': specifier: ^6.0.5 - version: 6.0.5(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))(vue@3.5.31(typescript@5.7.3)) + version: 6.0.5(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.32.0))(vue@3.5.31(typescript@5.7.3)) '@vue/tsconfig': specifier: ^0.7.0 version: 0.7.0(typescript@5.7.3)(vue@3.5.31(typescript@5.7.3)) @@ -114,6 +114,9 @@ importers: eslint-plugin-vue: specifier: ^9.32.0 version: 9.33.0(eslint@9.39.4(jiti@2.6.1)) + happy-dom: + specifier: ^20.9.0 + version: 20.9.0 rollup-plugin-visualizer: specifier: ^7.0.1 version: 7.0.1(rollup@4.60.1) @@ -125,7 +128,10 @@ importers: version: 5.7.3 vite: specifier: ^7.3.1 - version: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) + version: 7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.32.0) + vitest: + specifier: ^4.1.7 + version: 4.1.7(@types/node@25.9.1)(happy-dom@20.9.0)(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.32.0)) vue-tsc: specifier: ^3.2.6 version: 3.2.6(typescript@5.7.3) @@ -606,6 +612,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@sxzz/popperjs-es@2.11.8': resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==} @@ -703,6 +712,9 @@ packages: peerDependencies: vite: ^5.2.0 || ^6 || ^7 || ^8 + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -799,6 +811,9 @@ packages: '@types/dagre@0.7.54': resolution: {integrity: sha512-QjcRY+adGbYvBFS7cwv5txhVIwX1XXIUswWl+kSQTbI6NjgZydrZkEKX/etzVd7i+bCsCb40Z/xlBY5eoFuvWQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -817,12 +832,21 @@ packages: '@types/lodash@4.17.24': resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + '@types/node@25.9.1': + resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} '@types/web-bluetooth@0.0.20': resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} @@ -833,6 +857,35 @@ packages: vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 vue: ^3.2.25 + '@vitest/expect@4.1.7': + resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} + + '@vitest/mocker@4.1.7': + resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.7': + resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} + + '@vitest/runner@4.1.7': + resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} + + '@vitest/snapshot@4.1.7': + resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} + + '@vitest/spy@4.1.7': + resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} + + '@vitest/utils@4.1.7': + resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} + '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -969,6 +1022,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + async-validator@4.2.5: resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} @@ -1022,6 +1079,10 @@ packages: caniuse-lite@1.0.30001784: resolution: {integrity: sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -1064,6 +1125,9 @@ packages: confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + copy-anything@4.0.5: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} @@ -1323,6 +1387,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1399,10 +1466,17 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1501,6 +1575,10 @@ packages: hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + happy-dom@20.9.0: + resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==} + engines: {node: '>=20.0.0'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1809,6 +1887,9 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + open@11.0.0: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} @@ -1971,6 +2052,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1983,9 +2067,15 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + state-local@1.0.7: resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -2019,6 +2109,9 @@ packages: three@0.182.0: resolution: {integrity: sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.1.1: resolution: {integrity: sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg==} engines: {node: '>=18'} @@ -2027,6 +2120,10 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + ts-dedent@2.2.0: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} @@ -2050,6 +2147,9 @@ packages: ufo@1.6.3: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -2106,6 +2206,47 @@ packages: yaml: optional: true + vitest@4.1.7: + resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.7 + '@vitest/browser-preview': 4.1.7 + '@vitest/browser-webdriverio': 4.1.7 + '@vitest/coverage-istanbul': 4.1.7 + '@vitest/coverage-v8': 4.1.7 + '@vitest/ui': 4.1.7 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-jsonrpc@8.2.0: resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} engines: {node: '>=14.0.0'} @@ -2171,11 +2312,20 @@ packages: typescript: optional: true + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -2184,6 +2334,18 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.3.1: resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} engines: {node: '>=20'} @@ -2548,6 +2710,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true + '@standard-schema/spec@1.1.0': {} + '@sxzz/popperjs-es@2.11.8': {} '@tailwindcss/node@4.2.2': @@ -2611,12 +2775,17 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.2.2 '@tailwindcss/oxide-win32-x64-msvc': 4.2.2 - '@tailwindcss/vite@4.2.2(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))': + '@tailwindcss/vite@4.2.2(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.32.0))': dependencies: '@tailwindcss/node': 4.2.2 '@tailwindcss/oxide': 4.2.2 tailwindcss: 4.2.2 - vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) + vite: 7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.32.0) + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 '@types/d3-array@3.2.2': {} @@ -2737,6 +2906,8 @@ snapshots: '@types/dagre@0.7.54': {} + '@types/deep-eql@4.0.2': {} + '@types/estree@1.0.8': {} '@types/geojson@7946.0.16': {} @@ -2751,21 +2922,72 @@ snapshots: '@types/lodash@4.17.24': {} + '@types/node@25.9.1': + dependencies: + undici-types: 7.24.6 + '@types/trusted-types@2.0.7': {} '@types/web-bluetooth@0.0.20': {} + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 25.9.1 + '@upsetjs/venn.js@2.0.0': optionalDependencies: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-vue@6.0.5(vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0))(vue@3.5.31(typescript@5.7.3))': + '@vitejs/plugin-vue@6.0.5(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.32.0))(vue@3.5.31(typescript@5.7.3))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 - vite: 7.3.1(jiti@2.6.1)(lightningcss@1.32.0) + vite: 7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.32.0) vue: 3.5.31(typescript@5.7.3) + '@vitest/expect@4.1.7': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.7(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.32.0))': + dependencies: + '@vitest/spy': 4.1.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.32.0) + + '@vitest/pretty-format@4.1.7': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.7': + dependencies: + '@vitest/utils': 4.1.7 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.7': + dependencies: + '@vitest/pretty-format': 4.1.7 + '@vitest/utils': 4.1.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.7': {} + + '@vitest/utils@4.1.7': + dependencies: + '@vitest/pretty-format': 4.1.7 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@volar/language-core@2.4.28': dependencies: '@volar/source-map': 2.4.28 @@ -2956,6 +3178,8 @@ snapshots: argparse@2.0.1: {} + assertion-error@2.0.1: {} + async-validator@4.2.5: {} asynckit@0.4.0: {} @@ -3011,6 +3235,8 @@ snapshots: caniuse-lite@1.0.30001784: {} + chai@6.2.2: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -3053,6 +3279,8 @@ snapshots: confbox@0.1.8: {} + convert-source-map@2.0.0: {} + copy-anything@4.0.5: dependencies: is-what: 5.5.0 @@ -3344,6 +3572,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.1.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -3481,8 +3711,14 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + esutils@2.0.3: {} + expect-type@1.3.0: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -3568,6 +3804,18 @@ snapshots: hachure-fill@0.5.2: {} + happy-dom@20.9.0: + dependencies: + '@types/node': 25.9.1 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -3834,6 +4082,8 @@ snapshots: dependencies: boolbase: 1.0.0 + obug@2.1.1: {} + open@11.0.0: dependencies: default-browser: 5.5.0 @@ -4001,14 +4251,20 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + source-map-js@1.2.1: {} source-map@0.7.6: {} speakingurl@14.0.1: {} + stackback@0.0.2: {} + state-local@1.0.7: {} + std-env@4.1.0: {} + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -4037,6 +4293,8 @@ snapshots: three@0.182.0: {} + tinybench@2.9.0: {} + tinyexec@1.1.1: {} tinyglobby@0.2.15: @@ -4044,6 +4302,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + ts-dedent@2.2.0: {} tslib@2.3.0: {} @@ -4058,6 +4318,8 @@ snapshots: ufo@1.6.3: {} + undici-types@7.24.6: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: browserslist: 4.28.2 @@ -4072,7 +4334,7 @@ snapshots: uuid@11.1.0: {} - vite@7.3.1(jiti@2.6.1)(lightningcss@1.32.0): + vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.32.0): dependencies: esbuild: 0.27.5 fdir: 6.5.0(picomatch@4.0.4) @@ -4081,10 +4343,39 @@ snapshots: rollup: 4.60.1 tinyglobby: 0.2.15 optionalDependencies: + '@types/node': 25.9.1 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.32.0 + vitest@4.1.7(@types/node@25.9.1)(happy-dom@20.9.0)(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.32.0)): + dependencies: + '@vitest/expect': 4.1.7 + '@vitest/mocker': 4.1.7(vite@7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.32.0)) + '@vitest/pretty-format': 4.1.7 + '@vitest/runner': 4.1.7 + '@vitest/snapshot': 4.1.7 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.1.1 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 7.3.1(@types/node@25.9.1)(jiti@2.6.1)(lightningcss@1.32.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.9.1 + happy-dom: 20.9.0 + transitivePeerDependencies: + - msw + vscode-jsonrpc@8.2.0: {} vscode-languageserver-protocol@3.17.5: @@ -4149,10 +4440,17 @@ snapshots: optionalDependencies: typescript: 5.7.3 + whatwg-mimetype@3.0.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrap-ansi@9.0.2: @@ -4161,6 +4459,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + ws@8.21.0: {} + wsl-utils@0.3.1: dependencies: is-wsl: 3.1.1 diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index d5a73987..cb1d8d48 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -786,6 +786,11 @@ export const wikiApi = { // Wiki Pages listPages: (kbId: number, rawId?: number) => http.get(`/wiki/knowledge-bases/${kbId}/pages`, rawId != null ? { params: { rawId } } : undefined), + // Lightweight {slug, title, archived} list for wikilink resolution. Never + // paginated and not scoped by the current raw-material filter — this is the + // authoritative resolution index used by the viewer's wikilink postprocess. + listPageRefs: (kbId: number, includeArchived = false) => + http.get(`/wiki/knowledge-bases/${kbId}/pages/refs`, { params: { includeArchived } }), getPage: (kbId: number, slug: string) => http.get(`/wiki/knowledge-bases/${kbId}/pages/${encodeURIComponent(slug)}`), updatePage: (kbId: number, slug: string, content: string) => diff --git a/mateclaw-ui/src/composables/__tests__/wikilink.test.ts b/mateclaw-ui/src/composables/__tests__/wikilink.test.ts new file mode 100644 index 00000000..f8398973 --- /dev/null +++ b/mateclaw-ui/src/composables/__tests__/wikilink.test.ts @@ -0,0 +1,237 @@ +// @vitest-environment happy-dom +import { describe, it, expect } from 'vitest' +import { + resolveWikilink, + postprocessWikilinks, + type WikilinkRef, +} from '../wikilink' + +// Compact ref fixture used by most tests. Active refs only — archived cases +// have their own fixtures. +const REFS: WikilinkRef[] = [ + { slug: 'machine-learning-basics', title: '机器学习基础' }, + { slug: 'transformer-architecture', title: 'Transformer Architecture' }, + { slug: 'react-overview', title: 'React Overview' }, +] + +const ARCHIVED_REFS: WikilinkRef[] = [ + { slug: 'deprecated-concept', title: 'Deprecated Concept' }, +] + +// --------------------------------------------------------------------------- +// resolveWikilink — pure resolution semantics (no DOM) +// --------------------------------------------------------------------------- +describe('resolveWikilink — happy path', () => { + it('resolves an exact slug match to a hit', () => { + const r = resolveWikilink('machine-learning-basics', REFS) + expect(r).toEqual({ + kind: 'hit', + slug: 'machine-learning-basics', + display: '机器学习基础', + archived: false, + }) + }) + + it('resolves an exact title match to a hit', () => { + const r = resolveWikilink('Transformer Architecture', REFS) + expect(r.kind).toBe('hit') + if (r.kind === 'hit') expect(r.slug).toBe('transformer-architecture') + }) + + it('honours [[slug|display]] alias form', () => { + const r = resolveWikilink('machine-learning-basics|入门指南', REFS) + expect(r).toEqual({ + kind: 'hit', + slug: 'machine-learning-basics', + display: '入门指南', + archived: false, + }) + }) + + it('resolves an archived target with archived=true', () => { + const r = resolveWikilink('deprecated-concept', REFS, ARCHIVED_REFS) + expect(r.kind).toBe('hit') + if (r.kind === 'hit') { + expect(r.archived).toBe(true) + expect(r.slug).toBe('deprecated-concept') + } + }) + + it('prefers active refs over archived refs on slug clash', () => { + const active: WikilinkRef[] = [{ slug: 'foo', title: 'Active Foo' }] + const archived: WikilinkRef[] = [{ slug: 'foo', title: 'Archived Foo' }] + const r = resolveWikilink('foo', active, archived) + expect(r.kind).toBe('hit') + if (r.kind === 'hit') expect(r.archived).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// 6 safety cases — every one of these MUST degrade to a broken span and MUST +// NOT inject the raw target into any attribute or eval-context. The viewer's +// XSS surface depends on this resolver returning 'broken' for these inputs. +// --------------------------------------------------------------------------- +describe('resolveWikilink — safety cases', () => { + it('safety 1: ', REFS) + expect(r.kind).toBe('broken') + if (r.kind === 'broken') expect(r.reason).toBe('dangerous') + }) + + it('safety 2: double quote in target → broken (dangerous)', () => { + const r = resolveWikilink('foo"onmouseover=alert(1)', REFS) + expect(r.kind).toBe('broken') + if (r.kind === 'broken') expect(r.reason).toBe('dangerous') + }) + + it('safety 3: adjacent [[a]] [[b]] inside one raw → broken target name', () => { + // The resolver only ever sees the content between a single pair of [[ ]]. + // If a malformed source contains `[[a]] [[b`, the regex matches `[[a]]` + // cleanly and the resolver receives 'a'. We instead test the worse case + // where an open `[[` leaks INTO the raw value via a malformed source. + const r = resolveWikilink('foo [[ bar', REFS) + expect(r.kind).toBe('broken') // unknown slug 'foo [[ bar' (no danger char) + }) + + it('safety 4: multiple `|` characters split on first only, no injection', () => { + const r = resolveWikilink('machine-learning-basics|a|b|c', REFS) + expect(r.kind).toBe('hit') + if (r.kind === 'hit') expect(r.display).toBe('a|b|c') + }) + + it('safety 5: empty raw → broken (empty)', () => { + expect(resolveWikilink('', REFS).kind).toBe('broken') + expect(resolveWikilink(' ', REFS).kind).toBe('broken') + expect(resolveWikilink('|display-only', REFS).kind).toBe('broken') + }) + + it('safety 6: oversize slug (>256 chars) → broken (too-long)', () => { + const big = 'a'.repeat(300) + const r = resolveWikilink(big, REFS) + expect(r.kind).toBe('broken') + if (r.kind === 'broken') expect(r.reason).toBe('too-long') + }) + + it('rejects control characters (NUL, US, DEL)', () => { + expect(resolveWikilink('slug\x00ish', REFS).kind).toBe('broken') + expect(resolveWikilink('slug\x1Fish', REFS).kind).toBe('broken') + expect(resolveWikilink('slug\x7Fish', REFS).kind).toBe('broken') + }) + + it('rejects backtick (template-literal escape vector)', () => { + expect(resolveWikilink('slug`evil', REFS).kind).toBe('broken') + }) + + it('rejects newlines (would break attribute serialisation)', () => { + expect(resolveWikilink('slug\nfoo', REFS).kind).toBe('broken') + expect(resolveWikilink('slug\rfoo', REFS).kind).toBe('broken') + }) +}) + +// --------------------------------------------------------------------------- +// postprocessWikilinks — DOM walker behaviour +// --------------------------------------------------------------------------- +describe('postprocessWikilinks — DOM behaviour', () => { + function setup(html: string): HTMLElement { + const root = document.createElement('div') + root.innerHTML = html + return root + } + + it('replaces a hit into ', () => { + const root = setup('

See [[machine-learning-basics]] for more.

') + postprocessWikilinks(root, (raw) => resolveWikilink(raw, REFS, ARCHIVED_REFS)) + const a = root.querySelector('a.wiki-link') as HTMLAnchorElement + expect(a).not.toBeNull() + expect(a.getAttribute('data-slug')).toBe('machine-learning-basics') + expect(a.textContent).toBe('机器学习基础') + expect(a.getAttribute('href')).toBeNull() + }) + + it('replaces an archived hit into ', () => { + const root = setup('

See [[deprecated-concept]].

') + postprocessWikilinks(root, (raw) => resolveWikilink(raw, REFS, ARCHIVED_REFS)) + const a = root.querySelector('a.wiki-link.wiki-link-archived') as HTMLAnchorElement + expect(a).not.toBeNull() + expect(a.getAttribute('data-slug')).toBe('deprecated-concept') + expect(a.getAttribute('title')).toBe('Archived page') + }) + + it('replaces a miss into with no clickable surface', () => { + const root = setup('

See [[unknown-page]] sometime.

') + postprocessWikilinks(root, (raw) => resolveWikilink(raw, REFS, ARCHIVED_REFS)) + const span = root.querySelector('span.wiki-link-broken') as HTMLSpanElement + expect(span).not.toBeNull() + expect(root.querySelector('a')).toBeNull() + // Display falls back to the literal [[...]] so the malformed source is + // visible to the reader. + expect(span.textContent).toBe('[[unknown-page]]') + }) + + it('does not interpolate dangerous raw into attributes', () => { + // The realistic vector: a text node carries the literal `]] stay safe.' + root.appendChild(p) + expect(root.querySelector('script')).toBeNull() // sanity — text-node form + postprocessWikilinks(root, (raw) => resolveWikilink(raw, REFS, ARCHIVED_REFS)) + expect(root.querySelector('script')).toBeNull() + expect(root.querySelector('a')).toBeNull() + const span = root.querySelector('span.wiki-link-broken') as HTMLSpanElement + expect(span).not.toBeNull() + // The attribute carrying user data is the title; verify it's the rejection + // reason, not the raw payload. + expect(span.getAttribute('title')).toMatch(/dangerous/) + // Visible label is the literal [[...]] — readers see the malformed source + // verbatim instead of having it silently swallowed. + expect(span.textContent).toContain('