feat(wiki): slug-first wikilink resolution and safe DOM postprocess

This commit is contained in:
matevip 2026-05-28 08:16:56 +08:00
parent ee04340742
commit 66d3d90ea9
12 changed files with 1062 additions and 35 deletions

View File

@ -444,6 +444,35 @@ public class WikiController {
return R.ok(page);
}
/**
* Lightweight wikilink resolution index.
* <p>
* 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.
* <p>
* 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<Map<String, Object>> listPageRefs(
@PathVariable Long kbId,
@RequestParam(name = "includeArchived", defaultValue = "false") boolean includeArchived,
@RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) {
verifyKBWorkspace(kbId, workspaceId);
List<WikiPageService.PageRef> items = pageService.listAllRefs(kbId, includeArchived);
Map<String, Object> 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}")

View File

@ -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.
* <p>
* 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<ReferenceEntry> 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.
* <p>
* 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).
* <p>
* 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<PageRef> listAllRefs(Long kbId, boolean includeArchived) {
if (!includeArchived) {
return listSummaries(kbId).stream()
.map(p -> new PageRef(p.getSlug(), p.getTitle(), false))
.toList();
}
List<WikiPageEntity> rows = pageMapper.selectList(
new LambdaQueryWrapper<WikiPageEntity>()
.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 内存
*/

View File

@ -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": {

View File

@ -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

View File

@ -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) =>

View File

@ -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: <script> tag in target → broken (dangerous)', () => {
const r = resolveWikilink('<script>alert(1)</script>', 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 <a class="wiki-link" data-slug>', () => {
const root = setup('<p>See [[machine-learning-basics]] for more.</p>')
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 <a class="wiki-link wiki-link-archived">', () => {
const root = setup('<p>See [[deprecated-concept]].</p>')
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 <span class="wiki-link-broken"> with no clickable surface', () => {
const root = setup('<p>See [[unknown-page]] sometime.</p>')
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 `<script>` chars,
// produced by markdown rendering (DOMPurify strips actual <script> tags
// upstream, so what reaches the postprocess is text). Build the DOM with
// textContent rather than innerHTML so the test doesn't accidentally make
// happy-dom parse the literal as a script element before we run.
const root = document.createElement('div')
const p = document.createElement('p')
p.textContent = 'Bad: [[<script>alert(1)</script>]] 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('<script>')
})
it('skips <code> and <pre> subtrees', () => {
const root = setup(
'<p>Outside [[machine-learning-basics]] active.</p>' +
'<pre><code>Inside [[machine-learning-basics]] kept literal.</code></pre>',
)
postprocessWikilinks(root, (raw) => resolveWikilink(raw, REFS, ARCHIVED_REFS))
// Outside text → replaced into <a>
const a = root.querySelector('p a.wiki-link')
expect(a).not.toBeNull()
// Inside <pre><code> → still literal
const code = root.querySelector('pre code') as HTMLElement
expect(code.textContent).toContain('[[machine-learning-basics]]')
expect(code.querySelector('a')).toBeNull()
})
it('skips inline <code>', () => {
const root = setup('<p>This is <code>[[inline]]</code> example.</p>')
postprocessWikilinks(root, (raw) => resolveWikilink(raw, REFS, ARCHIVED_REFS))
const code = root.querySelector('code') as HTMLElement
expect(code.textContent).toBe('[[inline]]')
expect(code.querySelector('a')).toBeNull()
expect(code.querySelector('span')).toBeNull()
})
it('handles multiple wikilinks in one paragraph', () => {
const root = setup(
'<p>See [[machine-learning-basics]] and [[transformer-architecture]] and [[unknown-x]].</p>',
)
postprocessWikilinks(root, (raw) => resolveWikilink(raw, REFS, ARCHIVED_REFS))
expect(root.querySelectorAll('a.wiki-link').length).toBe(2)
expect(root.querySelectorAll('span.wiki-link-broken').length).toBe(1)
})
it('is idempotent — running twice does not double-wrap', () => {
const root = setup('<p>See [[machine-learning-basics]] first.</p>')
const resolver = (raw: string) => resolveWikilink(raw, REFS, ARCHIVED_REFS)
postprocessWikilinks(root, resolver)
const firstHtml = root.innerHTML
postprocessWikilinks(root, resolver)
expect(root.innerHTML).toBe(firstHtml)
expect(root.querySelectorAll('a.wiki-link').length).toBe(1)
})
})

View File

@ -343,20 +343,43 @@ const purifyConfig = {
const RENDER_CACHE = new Map<string, string>()
const RENDER_CACHE_CAP = 200
function cacheKey(text: string): string {
function cacheKey(text: string, wikilink: WikilinkMode): string {
// Compact key — collisions on the order of 10^-6 in single-conversation
// scope, and a false hit only causes a "stale" render of unchanged content
// (no security implication since cached values are sanitized HTML).
return `${text.length}:${text.slice(0, 40)}:${text.slice(-40)}`
// The wikilink mode is part of the key so a 'none' caller cannot read back
// a 'legacy'-substituted cached entry of the same source.
return `${wikilink}:${text.length}:${text.slice(0, 40)}:${text.slice(-40)}`
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Wikilink handling mode for {@link useMarkdownRenderer}.
*
* - `'legacy'` (default): pre-markdown string substitution of `[[Title]]` into
* `<a class="wiki-link" data-wiki-title="...">`, dispatching the global
* `wiki-link-click` event when clicked. Kept for chat / other views that
* already rely on this behaviour.
* - `'none'`: skip wikilink substitution entirely. Use this when the caller
* wants to walk the rendered DOM itself and resolve `[[...]]` against an
* authoritative `{slug, title}` index the dedicated path used by the Wiki
* page viewer, where the legacy "guess slug from title" approach is unsafe.
*/
export type WikilinkMode = 'legacy' | 'none'
export interface RenderMarkdownOptions {
/** How to handle `[[...]]` syntax. Defaults to `'legacy'`. */
wikilink?: WikilinkMode
}
export function useMarkdownRenderer() {
function renderMarkdown(content: string): string {
function renderMarkdown(content: string, opts?: RenderMarkdownOptions): string {
if (!content) return ''
const k = cacheKey(content)
const wikilink: WikilinkMode = opts?.wikilink ?? 'legacy'
const k = cacheKey(content, wikilink)
const cached = RENDER_CACHE.get(k)
if (cached !== undefined) {
// Refresh LRU position — re-insert at the tail.
@ -368,10 +391,14 @@ export function useMarkdownRenderer() {
// 1. LaTeX placeholders (skips fenced/inline code).
const withLatex = preprocessLatex(content)
// 2. Wiki link substitution: [[Title]] → <a class="wiki-link" …>.
const withWikiLinks = withLatex.replace(
/\[\[([^\]]+)\]\]/g,
'<a class="wiki-link" href="#" data-wiki-title="$1" onclick="window.dispatchEvent(new CustomEvent(\'wiki-link-click\',{detail:{title:\'$1\'}}));return false">$1</a>'
)
// Skipped in 'none' mode so the caller can do its own DOM postprocess.
const withWikiLinks =
wikilink === 'none'
? withLatex
: withLatex.replace(
/\[\[([^\]]+)\]\]/g,
'<a class="wiki-link" href="#" data-wiki-title="$1" onclick="window.dispatchEvent(new CustomEvent(\'wiki-link-click\',{detail:{title:\'$1\'}}));return false">$1</a>'
)
// 3. Marked → 4. DOMPurify.
const rawHtml = markedInstance.parse(withWikiLinks) as string
const result = DOMPurify.sanitize(rawHtml, purifyConfig)

View File

@ -0,0 +1,245 @@
// Wiki wikilink postprocess — resolves `[[slug]]` / `[[slug|display]]` markers
// in already-rendered markdown HTML into the three canonical link states the
// Wiki page viewer ships:
//
// <a class="wiki-link" data-slug=...> active page hit
// <a class="wiki-link wiki-link-archived" ...> archived page hit
// <span class="wiki-link-broken" title=...> unresolved / unsafe target
//
// Two reasons this lives outside the viewer .vue:
//
// 1. The previous regex-based substitution was unsafe (interpolated raw
// target into HTML attributes, didn't skip code blocks, guessed slugs by
// lower-casing titles). Putting the new logic in a pure helper lets the
// 6 safety cases get covered by ordinary unit tests instead of mounting
// the whole Vue component.
// 2. The DOM walker has to skip code/pre/kbd/samp subtrees. That's the only
// "code block protection" needed once markdown has already produced
// proper <pre><code> wrappers — no string-level sentinel substitution.
/**
* Lightweight {slug, title, archived} entry. Shape mirrors the backend
* `PageRef` DTO and the store's `WikiPageRef`. Kept local to avoid creating
* a build dependency from this file onto the Pinia store.
*/
export interface WikilinkRef {
slug: string
title: string
archived?: boolean
}
/** Result of resolving a single `[[...]]` target string. */
export type WikilinkResolution =
| { kind: 'hit'; slug: string; display: string; archived: boolean }
| { kind: 'broken'; display: string; reason: 'empty' | 'dangerous' | 'too-long' | 'unknown' }
/**
* Tags whose contents must not be touched. `<pre>` and `<code>` cover fenced
* and inline code blocks emitted by marked; `<kbd>` and `<samp>` are listed
* for completeness so authors can show literal wikilink syntax in docs
* without it being silently rewritten.
*/
const SKIP_TAGS = new Set(['PRE', 'CODE', 'KBD', 'SAMP'])
/**
* Characters that turn a wikilink into an attribute-injection or HTML-context
* escape risk. The list is intentionally narrow slug values can legitimately
* contain CJK and `-`, so we reject only what is unambiguously dangerous (HTML
* delimiters, quote characters, backtick, line breaks, C0 / DEL control bytes).
*
* 0x000x1F (excluding TAB which is rare in slugs anyway) and 0x7F catch the
* NUL / control-char family that broke an earlier draft of this RFC document
* when someone wrote them literally instead of as escape text. If a real slug
* needs a tab character, that is a backend bug worth surfacing.
*/
// eslint-disable-next-line no-control-regex
const DANGEROUS_CHAR_RE = /[<>"'`\n\r\x00-\x1F\x7F]/
/** Slug length cap. Backend `toSlug` produces slugs well under this. */
const MAX_SLUG_LEN = 256
/** `[[...]]` matcher used during text-node walking. Non-greedy. */
const WIKILINK_RE = /\[\[([^\]]+?)\]\]/g
/**
* Resolve a single raw target into a render directive.
*
* The function is pure: no DOM access, no store reads. Tests drive it with
* synthetic `refs` arrays to verify each of the six safety cases enumerated in
* the RFC (script tag, double quote, nested brackets, multi `|`, empty, oversize).
*
* Resolution order:
* 1. Empty / dangerous / oversize broken span, raw never enters output
* attributes. The visible text falls back to the original `[[...]]`
* literal so users can spot the malformed content.
* 2. Exact slug match against active refs.
* 3. Title match (trim + case-insensitive) against active refs.
* 4. Same two passes against archived refs hit renders as archived.
* 5. Otherwise broken.
*
* Title-fallback is kept because the RFC's migration plan allows older content
* that still writes `[[Page Title]]` to keep resolving for six months while
* the slug-first prompt rollout (Phase 3) replaces it. Once that window closes
* the title branch can be deleted without any other code change.
*/
export function resolveWikilink(
raw: string,
refs: WikilinkRef[],
archivedRefs: WikilinkRef[] = [],
): WikilinkResolution {
const rawTrimmed = (raw ?? '').trim()
const literal = `[[${raw ?? ''}]]`
if (!rawTrimmed) {
return { kind: 'broken', display: literal, reason: 'empty' }
}
if (DANGEROUS_CHAR_RE.test(rawTrimmed)) {
return { kind: 'broken', display: literal, reason: 'dangerous' }
}
// Split [[target|display]] form. Only the first `|` is honoured; any extras
// are kept verbatim in the display text and trigger the dangerous-char path
// only if they collide with the rejection set (they don't, `|` is allowed).
//
// `explicitDisplay` is the empty string when the source uses the bare
// `[[target]]` form. In that case the visible label falls back to the
// resolved page's title (more readable than the slug). When the source
// explicitly overrides via `|`, that override always wins.
const pipeIdx = rawTrimmed.indexOf('|')
const target = pipeIdx >= 0 ? rawTrimmed.slice(0, pipeIdx).trim() : rawTrimmed
const explicitDisplay = pipeIdx >= 0 ? rawTrimmed.slice(pipeIdx + 1).trim() : ''
if (!target) {
return { kind: 'broken', display: literal, reason: 'empty' }
}
if (target.length > MAX_SLUG_LEN) {
return { kind: 'broken', display: literal, reason: 'too-long' }
}
const lookupSlug = target.toLowerCase()
const lookupTitle = target.trim().toLowerCase()
for (const ref of refs) {
if (ref.slug.toLowerCase() === lookupSlug) {
return { kind: 'hit', slug: ref.slug, display: explicitDisplay || ref.title, archived: false }
}
}
for (const ref of refs) {
if (ref.title.trim().toLowerCase() === lookupTitle) {
return { kind: 'hit', slug: ref.slug, display: explicitDisplay || ref.title, archived: false }
}
}
for (const ref of archivedRefs) {
if (ref.slug.toLowerCase() === lookupSlug) {
return { kind: 'hit', slug: ref.slug, display: explicitDisplay || ref.title, archived: true }
}
}
for (const ref of archivedRefs) {
if (ref.title.trim().toLowerCase() === lookupTitle) {
return { kind: 'hit', slug: ref.slug, display: explicitDisplay || ref.title, archived: true }
}
}
return { kind: 'broken', display: literal, reason: 'unknown' }
}
/**
* Build the DOM element for a resolution result.
*
* Always uses `document.createElement` + `textContent` + `setAttribute`. No
* `innerHTML` writes anywhere the previous regex-based substitution path
* concatenated raw target strings into HTML and was the original source of
* the bug class this RFC closes.
*/
function buildLinkElement(
doc: Document,
resolution: WikilinkResolution,
): HTMLElement {
if (resolution.kind === 'hit') {
const a = doc.createElement('a')
a.className = resolution.archived ? 'wiki-link wiki-link-archived' : 'wiki-link'
a.setAttribute('data-slug', resolution.slug)
// No href: the page viewer hooks click via the global `wiki-link` listener
// and routes through the Pinia store. Adding a real href would expose the
// app to middle-click "open in new tab" 404s since the route layer is SPA.
a.setAttribute('role', 'link')
a.setAttribute('tabindex', '0')
if (resolution.archived) {
a.setAttribute('title', 'Archived page')
}
a.textContent = resolution.display
return a
}
const span = doc.createElement('span')
span.className = 'wiki-link-broken'
span.setAttribute('title', `Target not found (${resolution.reason})`)
span.textContent = resolution.display
return span
}
/**
* Walk the rendered article DOM and replace every `[[...]]` token inside a
* text node with the appropriate `<a>` or `<span>` element.
*
* The walker uses {@link TreeWalker} with `NodeFilter.SHOW_TEXT` so we only
* ever look at text nodes element nodes and their attributes are not even
* candidates for substitution. The filter additionally rejects any text node
* whose ancestor chain crosses a {@link SKIP_TAGS} element, so code blocks,
* inline code and the other docstring-style tags remain literal.
*
* The function is idempotent: text nodes that no longer match `[[...]]` are
* skipped, and previously-inserted `<a>`/`<span>` elements have no text-node
* children carrying the original syntax (it is consumed by the regex split).
*/
export function postprocessWikilinks(
root: HTMLElement,
resolver: (raw: string) => WikilinkResolution,
doc: Document = root.ownerDocument ?? document,
): void {
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
// Reject text nodes inside any of the skip tags. Walking ancestors is
// cheap because the markdown tree depth is bounded by Marked's grammar.
let parent: Node | null = node.parentNode
while (parent && parent !== root) {
if (parent.nodeType === 1 /* ELEMENT_NODE */) {
const tag = (parent as Element).tagName
if (SKIP_TAGS.has(tag)) return NodeFilter.FILTER_REJECT
}
parent = parent.parentNode
}
return WIKILINK_RE.test(node.nodeValue ?? '')
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT
},
})
// Collect first, mutate second — mutating the tree while walking it would
// skip siblings or revisit nodes.
const targets: Text[] = []
let cur = walker.nextNode()
while (cur) {
targets.push(cur as Text)
cur = walker.nextNode()
}
for (const textNode of targets) {
const original = textNode.nodeValue ?? ''
// Reset regex state — the regex is module-level with /g, so `lastIndex`
// carries over between text nodes if we don't.
WIKILINK_RE.lastIndex = 0
const fragment = doc.createDocumentFragment()
let lastIdx = 0
let match: RegExpExecArray | null
while ((match = WIKILINK_RE.exec(original)) !== null) {
if (match.index > lastIdx) {
fragment.appendChild(doc.createTextNode(original.slice(lastIdx, match.index)))
}
const resolution = resolver(match[1])
fragment.appendChild(buildLinkElement(doc, resolution))
lastIdx = match.index + match[0].length
}
if (lastIdx < original.length) {
fragment.appendChild(doc.createTextNode(original.slice(lastIdx)))
}
textNode.parentNode?.replaceChild(fragment, textNode)
}
}

View File

@ -63,6 +63,19 @@ export function isProtectedPage(page: WikiPage | null | undefined): boolean {
return page.locked === 1
}
/**
* Lightweight {slug, title, archived} entry used to resolve `[[...]]` wikilinks
* in rendered wiki content. Distinct from {@link WikiPage} pageRefs are never
* filtered by the user's raw-material selection and never carry content, so the
* renderer can always trust them as the authoritative resolution index for the
* active knowledge base.
*/
export interface WikiPageRef {
slug: string
title: string
archived: boolean
}
export const useWikiStore = defineStore('wiki', () => {
const knowledgeBases = ref<WikiKB[]>([])
const currentKB = ref<WikiKB | null>(null)
@ -75,6 +88,14 @@ export const useWikiStore = defineStore('wiki', () => {
const selectedRawId = ref<number | null>(null)
const totalPageCount = ref(0)
// Wikilink resolution index — kept separate from `pages` because (a) it must
// survive the raw-material filter, and (b) the viewer's postprocess needs an
// O(1) slug/title lookup over the full KB. `archivedPageRefs` is only loaded
// on demand: most pages don't reference archived targets, and asking for them
// by default would let archived slugs leak into the active resolution map.
const pageRefs = ref<WikiPageRef[]>([])
const archivedPageRefs = ref<WikiPageRef[]>([])
async function fetchKnowledgeBases() {
loading.value = true
try {
@ -90,7 +111,10 @@ export const useWikiStore = defineStore('wiki', () => {
async function selectKB(id: number) {
const res: any = await wikiApi.getKB(id)
currentKB.value = res.data || res
await Promise.all([fetchRawMaterials(id), fetchPages(id)])
// pageRefs refresh in parallel with materials + pages — the viewer needs
// the resolution index ready before it tries to postprocess wikilinks.
archivedPageRefs.value = []
await Promise.all([fetchRawMaterials(id), fetchPages(id), fetchPageRefs(id)])
}
async function createKB(data: { name: string; description?: string; agentId?: number }) {
@ -115,6 +139,8 @@ export const useWikiStore = defineStore('wiki', () => {
currentPage.value = null
rawMaterials.value = []
pages.value = []
pageRefs.value = []
archivedPageRefs.value = []
selectedRawId.value = null
}
@ -129,6 +155,29 @@ export const useWikiStore = defineStore('wiki', () => {
if (!rawId) totalPageCount.value = pages.value.length
}
/**
* Fetch the active (non-archived) wikilink resolution index. Called on KB
* switch and from {@link refreshCurrentKB} so the viewer always has a fresh
* map. `archivedPageRefs` is left alone call {@link fetchArchivedPageRefs}
* lazily if the viewer detects a link pointing at a possibly archived target.
*/
async function fetchPageRefs(kbId: number) {
const res: any = await wikiApi.listPageRefs(kbId, false)
pageRefs.value = (res.data?.items ?? res.items ?? []) as WikiPageRef[]
}
/**
* Lazily fetch archived refs so the viewer can label existing links to
* archived targets without polluting the default resolution map (which would
* otherwise let LLM-generated content keep pointing at retired pages).
*/
async function fetchArchivedPageRefs(kbId: number) {
if (archivedPageRefs.value.length > 0) return
const res: any = await wikiApi.listPageRefs(kbId, true)
const all = (res.data?.items ?? res.items ?? []) as WikiPageRef[]
archivedPageRefs.value = all.filter((p) => p.archived)
}
// Background refreshes (job completion, SSE events, fallback polling) must
// not drop the user's active raw-material filter. Re-fetch the page list
// scoped to selectedRawId whenever a filter is applied — otherwise the list
@ -141,6 +190,9 @@ export const useWikiStore = defineStore('wiki', () => {
wikiApi.getKB(kbId),
fetchRawMaterials(kbId),
fetchPages(kbId, selectedRawId.value ?? undefined),
// Keep refs in lockstep with the rest of the KB state so a freshly
// created page is immediately resolvable by the viewer.
fetchPageRefs(kbId),
])
const nextKB = (kbRes as any).data || kbRes
currentKB.value = nextKB
@ -209,6 +261,8 @@ export const useWikiStore = defineStore('wiki', () => {
loading,
selectedRawId,
totalPageCount,
pageRefs,
archivedPageRefs,
fetchKnowledgeBases,
selectKB,
createKB,
@ -216,6 +270,8 @@ export const useWikiStore = defineStore('wiki', () => {
backToLibrary,
fetchRawMaterials,
fetchPages,
fetchPageRefs,
fetchArchivedPageRefs,
refreshCurrentKB,
filterPagesByRaw,
clearRawFilter,

View File

@ -786,7 +786,9 @@ const defaultForm = (): Partial<Agent> & { name: string; defaultThinkingLevel: s
tags: '',
enabled: true,
defaultThinkingLevel: null,
workspaceBasePath: null,
// Agent type declares this as `string | undefined`; using `undefined` keeps
// the Partial<Agent> shape happy without widening the type to allow null.
workspaceBasePath: undefined,
// Issue #184 explicit opt-out flags. Default false matches the legacy
// "zero rows = inherit global default" contract for newly-created agents.
skillsDisabled: false,
@ -978,7 +980,7 @@ async function openEditModal(agent: Agent) {
tags: agent.tags || '',
enabled: agent.enabled,
defaultThinkingLevel: (agent as any).defaultThinkingLevel || null,
workspaceBasePath: agent.workspaceBasePath || null,
workspaceBasePath: agent.workspaceBasePath || undefined,
skillsDisabled: agent.skillsDisabled === true,
toolsDisabled: agent.toolsDisabled === true,
}

View File

@ -102,6 +102,7 @@ import { useWikiStore, isProtectedPage, type WikiPage } from '@/stores/useWikiSt
import { useWorkspaceStore } from '@/stores/useWorkspaceStore'
import { wikiApi } from '@/api/index'
import { useMarkdownRenderer } from '@/composables/useMarkdownRenderer'
import { postprocessWikilinks, resolveWikilink, type WikilinkRef } from '@/composables/wikilink'
import { Link, SetUp } from '@element-plus/icons-vue'
import PageHeader from './PageHeader.vue'
import RelatedPagesPanel from './RelatedPagesPanel.vue'
@ -132,33 +133,56 @@ const isSystem = computed(() => store.currentPage?.pageType === 'system')
const isProtected = computed(() => isProtectedPage(store.currentPage))
const isLockedNotSystem = computed(() => isProtected.value && !isSystem.value)
// Render the markdown WITHOUT the renderer's built-in wikilink substitution.
// `wikilink: 'none'` keeps the raw `[[...]]` tokens intact so the DOM
// postprocess below can resolve them against the authoritative pageRefs
// index rather than against `store.pages` (which is filtered by rawId and
// would silently break cross-material links). See RFC 55 §1.3 / Phase 1.
const renderedContent = computed(() => {
if (!store.currentPage?.content) return ''
// Build a lookup map: title (normalized) slug, for resolving [[Title]] links
const titleToSlug = new Map<string, string>()
for (const p of store.pages) {
if (p.title && p.slug) {
titleToSlug.set(p.title.trim().toLowerCase(), p.slug)
}
}
const content = store.currentPage.content.replace(/\[\[([^\]]+)\]\]/g, (_match, raw) => {
const title = raw.trim()
// Prefer exact title match; fall back to slug-style guess
const slug = titleToSlug.get(title.toLowerCase()) ?? title.toLowerCase().replace(/\s+/g, '-')
return `<a class="wiki-link" data-slug="${slug}">${title}</a>`
})
return renderMarkdown(content)
return renderMarkdown(store.currentPage.content, { wikilink: 'none' })
})
// Project the store's pageRefs into the resolver's lightweight shape. Pulling
// `archived: false` explicitly (the store's WikiPageRef already carries it,
// but the active list is guaranteed non-archived by the backend filter) keeps
// the resolver's TypeScript types simple.
const activeRefs = computed<WikilinkRef[]>(() =>
store.pageRefs.map((p) => ({ slug: p.slug, title: p.title, archived: false })),
)
const archivedRefs = computed<WikilinkRef[]>(() =>
store.archivedPageRefs.map((p) => ({ slug: p.slug, title: p.title, archived: true })),
)
// Postprocess wikilinks after v-html settles. Runs on every content swap and
// also whenever the resolution index changes (e.g. user navigated away, a
// page was created in the background, archived refs finally loaded), so links
// that started life as `wiki-link-broken` upgrade to active hits on a re-walk.
async function runWikilinkPostprocess() {
await nextTick()
const root = articleRef.value
if (!root) return
const refs = activeRefs.value
const archived = archivedRefs.value
postprocessWikilinks(root, (raw) => resolveWikilink(raw, refs, archived))
}
// Bind the image lightbox to the rendered article on every content swap.
// Awaits a microtask so v-html has a chance to repopulate the DOM, then
// asks the lightbox to walk <img> tags and attach click handlers. Already-
// bound elements are skipped by the lightbox itself.
watch(renderedContent, async () => {
await nextTick()
await runWikilinkPostprocess()
lightboxRef.value?.attach(articleRef.value)
})
// Re-run wikilink resolution when the refs index changes a sibling page
// created or restored from archive should upgrade existing broken spans on
// the open page to active links without forcing the user to re-navigate.
watch([activeRefs, archivedRefs], async () => {
await runWikilinkPostprocess()
})
watch(() => store.currentPage, async (page) => {
if (page && store.currentKB) {
editing.value = false
@ -226,6 +250,15 @@ async function openPage(slug: string) {
}
onMounted(() => {
// Lazily load archived refs once per KB so the postprocess can label any
// existing links to archived targets as such instead of treating them as
// broken. The store dedupes repeated calls, so this is cheap on revisits.
if (store.currentKB) {
store.fetchArchivedPageRefs(store.currentKB.id)
}
// Global click delegation both `wiki-link` and `wiki-link wiki-link-archived`
// share the same data-slug contract and the same routing through openPage.
// `wiki-link-broken` lacks the class entirely so its clicks are no-ops.
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement
if (target.classList.contains('wiki-link')) {
@ -300,6 +333,24 @@ onMounted(() => {
.page-content :deep(img) { max-width: 100%; border-radius: 10px; }
.page-content :deep(.wiki-link) { color: var(--mc-primary); text-decoration: none; cursor: pointer; border-bottom: 1px dashed var(--mc-primary); }
.page-content :deep(.wiki-link:hover) { text-decoration: underline; }
/* Archived target still clickable to view/restore, but visually de-emphasised
to match the archived state semantics from elsewhere in the wiki UI. */
.page-content :deep(.wiki-link.wiki-link-archived) {
color: var(--mc-text-tertiary);
border-bottom-color: var(--mc-text-tertiary);
border-bottom-style: dotted;
font-style: italic;
}
.page-content :deep(.wiki-link.wiki-link-archived:hover) { color: var(--mc-text-secondary); }
/* Broken target no click, no href, no request. Dashed underline + muted tone
tells the reader the wikilink couldn't be resolved without committing to
navigation that would 404. Tooltip shows the rejection reason. */
.page-content :deep(.wiki-link-broken) {
color: var(--mc-text-tertiary);
text-decoration: underline dashed var(--mc-text-tertiary);
text-underline-offset: 3px;
cursor: help;
}
/* Editor */
.page-editor { width: 100%; min-height: 60vh; padding: 16px; border: 1px solid var(--mc-border); border-radius: 14px; font-family: 'JetBrains Mono', monospace; font-size: 14px; line-height: 1.65; resize: vertical; background: var(--mc-bg-elevated); color: var(--mc-text-primary); outline: none; }

View File

@ -0,0 +1,21 @@
import { defineConfig } from 'vitest/config'
import path from 'node:path'
// Two test conventions coexist in this repo:
// - `test/**/*.test.ts` — pre-existing files using the Node `node:test`
// runner (run via `node --test test/<file>.test.ts`).
// - `src/**/__tests__/*.test.ts` — vitest tests for new code.
//
// Scope vitest to `src/**` so it never picks up the node:test files (which
// don't export describe/it/expect and would otherwise fail discovery).
export default defineConfig({
test: {
include: ['src/**/*.{test,spec}.{ts,tsx}'],
environment: 'happy-dom',
},
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
})