mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
feat(wiki): slug-first wikilink resolution and safe DOM postprocess
This commit is contained in:
parent
ee04340742
commit
66d3d90ea9
@ -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}")
|
||||
|
||||
@ -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 内存)
|
||||
*/
|
||||
|
||||
@ -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": {
|
||||
|
||||
316
mateclaw-ui/pnpm-lock.yaml
generated
316
mateclaw-ui/pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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) =>
|
||||
|
||||
237
mateclaw-ui/src/composables/__tests__/wikilink.test.ts
Normal file
237
mateclaw-ui/src/composables/__tests__/wikilink.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
|
||||
245
mateclaw-ui/src/composables/wikilink.ts
Normal file
245
mateclaw-ui/src/composables/wikilink.ts
Normal 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).
|
||||
*
|
||||
* 0x00–0x1F (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)
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
|
||||
21
mateclaw-ui/vitest.config.ts
Normal file
21
mateclaw-ui/vitest.config.ts
Normal 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'),
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user