diff --git a/README.md b/README.md
index fb58918..43304e6 100644
--- a/README.md
+++ b/README.md
@@ -35,3 +35,10 @@ VireoWiki 当前版本采用 [Mozilla Public License 2.0](LICENSE)(MPL-2.0)
统计功能在未配置 Analytics Engine 或其查询凭据时使用 D1 后备实现,成功的文档 API 读取(包括单页导航)记录访问量,编辑器读取和私有/删除页面不计入公开热门。后台统计同样可直接使用;小时汇总保留约 90 天,文档累计访问量单独保留。后备性能百分位使用响应时间直方图上界估算。历史上没有记录的访问不能补算。
生产配置默认启用 MCP(`MCP_MODE = "open"`):公共阅读工具可连接 `/api/mcp`,编辑操作仍需有效身份及相应权限。可通过配置设为 `disabled` 关闭。
+
+
+### 消歧义页面
+
+在编辑器中勾选“消歧义页面”,或点击“生成消歧义模板”列出同名条目的不同含义。使用 `[[条目标题|显示名称]]` 添加链接与简短说明。也可从普通条目手动链接到 `[[名称(消歧义)]]`。
+
+页面顶部会显示消歧义提示和类型徽标。类型保存在正文开头的 `` 标记中,Git、MCP 和修订历史均能保留;取消勾选只移除标记,保留正文。消歧义页不能同时设置重定向,编辑仍遵循普通文档权限与 ACL。
diff --git a/packages/wiki-shared/src/i18n/en.json b/packages/wiki-shared/src/i18n/en.json
index d5bd31e..b13300c 100644
--- a/packages/wiki-shared/src/i18n/en.json
+++ b/packages/wiki-shared/src/i18n/en.json
@@ -3496,5 +3496,13 @@
"tokens.never": "Never expires",
"tokens.apiHint": "Use the same token as your Git password or send Authorization: Bearer TOKEN to the Wiki API and MCP. Keep it private.",
"tokens.invalidExpiry": "Choose a future expiration time.",
- "tokens.sessionRequired": "Sign in through the browser to manage tokens."
+ "tokens.sessionRequired": "Sign in through the browser to manage tokens.",
+ "disambiguation.type": "Disambiguation page",
+ "disambiguation.hint": "List different meanings of the same name using Wiki links. Do not set a page redirect.",
+ "disambiguation.notice": "This page lists different meanings of the same name. Choose the article you want to read.",
+ "disambiguation.insertTemplate": "Insert disambiguation template",
+ "disambiguation.replaceConfirm": "This replaces the current content with a template. Continue?",
+ "disambiguation.exampleTitle": "Name",
+ "disambiguation.template": "“__VIREO_SLOT_0__” may refer to:\n\n- [[__VIREO_SLOT_0__ (meaning one)|Meaning one]]: Brief description.\n- [[__VIREO_SLOT_0__ (meaning two)|Meaning two]]: Brief description.\n",
+ "disambiguation.redirectConflict": "A disambiguation page cannot also redirect to another page."
}
diff --git a/packages/wiki-shared/src/i18n/source.json b/packages/wiki-shared/src/i18n/source.json
index f22d8f8..c21ec7c 100644
--- a/packages/wiki-shared/src/i18n/source.json
+++ b/packages/wiki-shared/src/i18n/source.json
@@ -3496,5 +3496,13 @@
"tokens.never": "만료 없음",
"tokens.apiHint": "같은 토큰을 Git 비밀번호로 사용하거나 Authorization: Bearer TOKEN 헤더로 Wiki API와 MCP를 호출하세요. 안전하게 보관하세요.",
"tokens.invalidExpiry": "미래 만료 시간을 선택하세요.",
- "tokens.sessionRequired": "토큰 관리는 브라우저 로그인이 필요합니다."
+ "tokens.sessionRequired": "토큰 관리는 브라우저 로그인이 필요합니다.",
+ "disambiguation.type": "동음이의어 문서",
+ "disambiguation.hint": "같은 이름의 여러 의미를 위키 링크로 나열합니다. 문서 넘겨주기는 설정하지 마세요.",
+ "disambiguation.notice": "같은 이름의 여러 의미를 나열한 문서입니다. 읽으려는 항목을 선택하세요.",
+ "disambiguation.insertTemplate": "동음이의어 템플릿 생성",
+ "disambiguation.replaceConfirm": "현재 본문을 템플릿으로 바꿉니다. 계속할까요?",
+ "disambiguation.exampleTitle": "이름",
+ "disambiguation.template": "“__VIREO_SLOT_0__”은 다음을 가리킬 수 있습니다:\n\n- [[__VIREO_SLOT_0__ (의미 하나)|의미 하나]]: 간단한 설명.\n- [[__VIREO_SLOT_0__ (의미 둘)|의미 둘]]: 간단한 설명.\n",
+ "disambiguation.redirectConflict": "동음이의어 문서는 넘겨주기를 함께 설정할 수 없습니다."
}
diff --git a/packages/wiki-shared/src/i18n/zh-CN.json b/packages/wiki-shared/src/i18n/zh-CN.json
index 08408b9..ab6596d 100644
--- a/packages/wiki-shared/src/i18n/zh-CN.json
+++ b/packages/wiki-shared/src/i18n/zh-CN.json
@@ -3496,5 +3496,13 @@
"tokens.never": "永不过期",
"tokens.apiHint": "同一令牌可用作 Git 密码,也可通过 Authorization: Bearer TOKEN 调用 Wiki API 和 MCP。请妥善保管。",
"tokens.invalidExpiry": "请选择未来的到期时间。",
- "tokens.sessionRequired": "请通过网页登录管理令牌。"
+ "tokens.sessionRequired": "请通过网页登录管理令牌。",
+ "disambiguation.type": "消歧义页面",
+ "disambiguation.hint": "用于列出同一名称的不同含义。使用 Wiki 链接指向各条目;不要设置页面重定向。",
+ "disambiguation.notice": "本页列出同一名称的不同含义,请选择您要查阅的条目。",
+ "disambiguation.insertTemplate": "生成消歧义模板",
+ "disambiguation.replaceConfirm": "生成模板会替换当前正文,是否继续?",
+ "disambiguation.exampleTitle": "名称",
+ "disambiguation.template": "“__VIREO_SLOT_0__”可能指:\n\n- [[__VIREO_SLOT_0__(含义一)|含义一]]:简要说明。\n- [[__VIREO_SLOT_0__(含义二)|含义二]]:简要说明。\n",
+ "disambiguation.redirectConflict": "消歧义页面不能同时设置页面重定向。"
}
diff --git a/packages/wiki-shared/src/markup/disambiguation.ts b/packages/wiki-shared/src/markup/disambiguation.ts
new file mode 100644
index 0000000..1fdacf8
--- /dev/null
+++ b/packages/wiki-shared/src/markup/disambiguation.ts
@@ -0,0 +1,13 @@
+/** Kept in the document so revisions, Git and MCP preserve the page type together. */
+export const DISAMBIGUATION_MARKER = '';
+const prefix = /^(?:\uFEFF)?[ \t\r\n]*[ \t]*(?:\r?\n|$)/;
+export function isDisambiguation(content: unknown): boolean {
+ return typeof content === 'string' && prefix.test(content);
+}
+export function stripDisambiguation(content: unknown): string {
+ return typeof content === 'string' ? content.replace(prefix, '') : '';
+}
+export function setDisambiguation(content: string, enabled: boolean): string {
+ const body = stripDisambiguation(content);
+ return enabled ? DISAMBIGUATION_MARKER + '\n' + body : body;
+}
diff --git a/packages/wiki-shared/src/render/render.ts b/packages/wiki-shared/src/render/render.ts
index 92d7dac..9789c8b 100644
--- a/packages/wiki-shared/src/render/render.ts
+++ b/packages/wiki-shared/src/render/render.ts
@@ -18,6 +18,7 @@
* - DOMContentLoaded 시점 처리(_setupArticleTitleCopy 등) 는 module top-level 에서
* 동기적으로 호출되며, 모듈은 deferred 이므로 DOM 이 이미 준비된 상태이다.
*/
+import {isDisambiguation,stripDisambiguation} from '../markup/disambiguation';
import { ui, getLocale } from '../i18n/client';
import { escapeHtml } from '../ui/html';
import { isSafeUrl } from '../ui/url';
@@ -5029,6 +5030,8 @@ function _sortWikiTableByColumn(table, headRow, colIdx) {
async function renderWikiContent(content, slug, containerId, options = {}) {
const containerEl = document.getElementById(containerId);
if (!containerEl) return;
+ const disambiguation = isDisambiguation(content);
+ content = stripDisambiguation(content);
// 이 렌더 호출 동안 _resolvePaletteTokens 가 참조할 팔레트 맵을 옵션으로 받는다.
// SPA 네비게이션 / 블로그 동적 로드처럼 페이지마다 다른 used_palettes 집합을 받는
@@ -5256,6 +5259,8 @@ async function renderWikiContent(content, slug, containerId, options = {}) {
});
let html = (typeof DOMPurify !== 'undefined') ? DOMPurify.sanitize(rawHtml, { ADD_TAGS: ['i', 'span', 'details', 'summary', 'div', 'canvas'], ADD_ATTR: ['class', 'style', 'data-bg', 'data-color', 'data-size', 'data-align', 'data-caption', 'data-unix', 'data-temporal-ms', 'data-temporal-mode', 'hidden', 'data-ext-name', 'data-ext-idx', 'data-state-key', 'data-fn-html', 'data-fn-name', 'data-fn-ref', 'data-progress-auto', 'colspan', 'rowspan', 'title'] }) : escapeHtml(rawHtml);
+ if (disambiguation) html = `
${escapeHtml(ui('disambiguation.type'))}
${escapeHtml(ui('disambiguation.notice'))}
` + html;
+
if (options.showCategory && slug) {
// index.html 의 route() 가 decodeURIComponent 실패 시 원본 slug 를 그대로
// 넘기므로 (`100%news` 같은 잘못된 인코딩 케이스), 여기서 다시 디코드를
diff --git a/src/client/edit/main.ts b/src/client/edit/main.ts
index 1cce8da..7e27478 100644
--- a/src/client/edit/main.ts
+++ b/src/client/edit/main.ts
@@ -20,6 +20,7 @@
* - 다른 모듈이 read/write 하는 state(slug, editor, sectionMode 등)는 types.ts 에
* 선언된 window 프로퍼티를 직접 read/write 한다 — 모듈 내부 로컬 미러는 두지 않는다.
*/
+import {isDisambiguation,setDisambiguation,stripDisambiguation} from '../../../packages/wiki-shared/src/markup/disambiguation';
import { ui } from '../../../packages/wiki-shared/src/i18n/client';
import './types';
import { escapeHtml } from '../utils/html';
@@ -1258,6 +1259,8 @@ document.addEventListener('DOMContentLoaded', async () => {
// 문서 변경 감지 리스너
const updateListener = EditorView.updateListener.of((update) => {
if (update.docChanged) {
+ const typeInput = document.getElementById('disambiguationType') as HTMLInputElement | null;
+ if (typeInput) typeInput.checked = isDisambiguation(update.state.doc.toString());
editorEventHandlers.change.forEach(cb => cb());
window.updateEditorTextCounterFromDoc(update.state.doc);
if (_findFeatureOnDocChange) _findFeatureOnDocChange(update);
@@ -2748,6 +2751,28 @@ document.addEventListener('DOMContentLoaded', async () => {
// 넘겨주기(redirect) 변경 시 편집 요약 자동 갱신
// input: 매 키 입력마다 디바운스로 갱신 (입력 중간 prefix 가 길어졌다 짧아졌다 반복하는 것 완화)
// change: blur 직후 즉시 확정
+ const redirectField = document.getElementById('redirectInput')?.closest('.mb-3');
+ if (redirectField && !document.getElementById('disambiguationControls')) {
+ const controls = document.createElement('div'); controls.id = 'disambiguationControls'; controls.className = 'mb-3';
+ const label = document.createElement('label'); label.className = 'form-check-label fw-bold';
+ const checkbox = document.createElement('input'); checkbox.id = 'disambiguationType'; checkbox.type = 'checkbox'; checkbox.className = 'form-check-input me-2';
+ label.append(checkbox, document.createTextNode(ui('disambiguation.type')));
+ const hint = document.createElement('div'); hint.className = 'form-text text-muted'; hint.textContent = ui('disambiguation.hint');
+ const template = document.createElement('button'); template.type = 'button'; template.className = 'btn btn-sm btn-wiki-outline mt-2'; template.textContent = ui('disambiguation.insertTemplate');
+ checkbox.onchange = () => {
+ if (!editor) return;
+ editor.setMarkdown(setDisambiguation(editor.getMarkdown(), checkbox.checked));
+ };
+ template.onclick = async () => {
+ if (!editor) return;
+ if (stripDisambiguation(editor.getMarkdown()).trim() && !(await window.Swal.fire({text:ui('disambiguation.replaceConfirm'),showCancelButton:true})).isConfirmed) return;
+ const name = (document.getElementById('titleInput') as HTMLInputElement)?.value.trim().replace(/[\[\]|\r\n]/g,'') || ui('disambiguation.exampleTitle');
+ editor.setMarkdown(setDisambiguation(ui('disambiguation.template',[name]),true));
+ };
+ checkbox.checked = !!editor && isDisambiguation(editor.getMarkdown());
+ controls.style.display = sectionMode ? 'none' : '';
+ controls.append(label,hint,template); redirectField.after(controls);
+ }
const redirectInputEl = document.getElementById('redirectInput');
if (redirectInputEl) {
let redirectDebounce = null;
@@ -3251,6 +3276,7 @@ function applySectionEditModeUI(range: SectionRange, fullContent: string): void
document.getElementById('alternateTitleInput'),
document.getElementById('categoryInput'),
document.getElementById('redirectInput'),
+ document.getElementById('disambiguationType'),
];
lockedContainers.forEach(el => {
if (el) {
diff --git a/src/client/pages/index.ts b/src/client/pages/index.ts
index f0e47bb..9c1ecd1 100644
--- a/src/client/pages/index.ts
+++ b/src/client/pages/index.ts
@@ -16,6 +16,7 @@
// (원본 classic 스크립트에서도 모듈 함수라 false 로 평가되어 fallback 이 돌던 동작 보존).
// - HTML 의 on* 속성에서 호출되는, 이 블록이 정의한 함수들은 파일 끝에서 window.* 로 노출한다.
+import {isDisambiguation} from '../../../packages/wiki-shared/src/markup/disambiguation';
import { ui, getLocale } from '../../../packages/wiki-shared/src/i18n/client';
import { createBreadcrumbNav } from '../article/breadcrumb';
import { createStructureModal } from '../article/structure';
@@ -755,7 +756,7 @@ import { createTocController } from '../article/toc';
// 메타 정보
const updatedDate = new Date(page.updated_at * 1000).toLocaleString(getLocale());
- let badgesHtml = '';
+ let badgesHtml = (page.is_disambiguation || isDisambiguation(page.content)) ? `${window.escapeHtml(ui('disambiguation.type'))}` : '';
if (page.category) {
const cats = page.category.split(',').map(c => c.trim()).filter(c => c);
cats.forEach(cat => {
diff --git a/src/client/render.ts b/src/client/render.ts
index 3f98788..df28a0a 100644
--- a/src/client/render.ts
+++ b/src/client/render.ts
@@ -18,6 +18,7 @@
* - DOMContentLoaded 시점 처리(_setupArticleTitleCopy 등) 는 module top-level 에서
* 동기적으로 호출되며, 모듈은 deferred 이므로 DOM 이 이미 준비된 상태이다.
*/
+import {isDisambiguation,stripDisambiguation} from '../../packages/wiki-shared/src/markup/disambiguation';
import { ui, getLocale } from '../../packages/wiki-shared/src/i18n/client';
import { escapeHtml } from './utils/html';
import { isSafeUrl } from './utils/url';
@@ -5029,6 +5030,8 @@ function _sortWikiTableByColumn(table, headRow, colIdx) {
async function renderWikiContent(content, slug, containerId, options = {}) {
const containerEl = document.getElementById(containerId);
if (!containerEl) return;
+ const disambiguation = isDisambiguation(content);
+ content = stripDisambiguation(content);
// 이 렌더 호출 동안 _resolvePaletteTokens 가 참조할 팔레트 맵을 옵션으로 받는다.
// SPA 네비게이션 / 블로그 동적 로드처럼 페이지마다 다른 used_palettes 집합을 받는
@@ -5256,6 +5259,8 @@ async function renderWikiContent(content, slug, containerId, options = {}) {
});
let html = (typeof DOMPurify !== 'undefined') ? DOMPurify.sanitize(rawHtml, { ADD_TAGS: ['i', 'span', 'details', 'summary', 'div', 'canvas'], ADD_ATTR: ['class', 'style', 'data-bg', 'data-color', 'data-size', 'data-align', 'data-caption', 'data-unix', 'data-temporal-ms', 'data-temporal-mode', 'hidden', 'data-ext-name', 'data-ext-idx', 'data-state-key', 'data-fn-html', 'data-fn-name', 'data-fn-ref', 'data-progress-auto', 'colspan', 'rowspan', 'title'] }) : escapeHtml(rawHtml);
+ if (disambiguation) html = `${escapeHtml(ui('disambiguation.type'))}
${escapeHtml(ui('disambiguation.notice'))}
` + html;
+
if (options.showCategory && slug) {
// index.html 의 route() 가 decodeURIComponent 실패 시 원본 slug 를 그대로
// 넘기므로 (`100%news` 같은 잘못된 인코딩 케이스), 여기서 다시 디코드를
diff --git a/src/routes/wiki.ts b/src/routes/wiki.ts
index dfa2d2d..bc74e09 100644
--- a/src/routes/wiki.ts
+++ b/src/routes/wiki.ts
@@ -1,3 +1,4 @@
+import {isDisambiguation} from '../../packages/wiki-shared/src/markup/disambiguation';
import { trackPageView } from '../utils/analytics';
import { ui } from '../i18n/server';
import { Hono, type Context } from 'hono';
@@ -1742,7 +1743,7 @@ wiki.get('/w/:slug', async (c, next) => {
console.error('loadPalettesForPage failed:', e);
}
- const result = safeJSON({ ...page, redirected_from: redirectedFrom, used_palettes: usedPalettes });
+ const result = safeJSON({ ...page, is_disambiguation: isDisambiguation(page.content), redirected_from: redirectedFrom, used_palettes: usedPalettes });
// 편집 메모(editor_note)는 편집기 로딩(for_edit=true) 시에만 wiki:edit 권한자에게 노출한다.
// 일반 열람·SPA 네비게이션·검색 등에서는 응답에서 제거한다.
@@ -2173,6 +2174,8 @@ wiki.put('/w/:slug', requireAuth, async (c) => {
return c.json({ error: ui("m_8dd4e38868d9e2ba") }, 400);
}
+ if (isDisambiguation(body.content) && body.redirect_to) return c.json({error:ui('disambiguation.redirectConflict')},400);
+
// CRLF/CR → LF 정규화. 클라이언트 환경(Windows 클립보드, 외부 임포트 등)에서
// \r 가 섞여 들어오면 렌더 파이프라인의 펜스/`:::`/폴드 정규식이 깨진다.
// 저장 시점에 한 번만 정규화하면 이후 모든 읽기 경로가 안전해진다.
diff --git a/src/utils/aiParser.ts b/src/utils/aiParser.ts
index 5e2a066..e2bc50d 100644
--- a/src/utils/aiParser.ts
+++ b/src/utils/aiParser.ts
@@ -1,3 +1,4 @@
+import {isDisambiguation,stripDisambiguation} from '../../packages/wiki-shared/src/markup/disambiguation';
import { ui } from '../i18n/server';
import type { D1Database } from '@cloudflare/workers-types';
import { normalizeSlug } from './slug';
@@ -334,7 +335,8 @@ function substituteParams(templateContent: string, args: Record,
export async function renderForAI(content: string, db: D1Database, depth = 0, currentSlug?: string): Promise {
if (!content) return '';
- let processed = content;
+ const disambiguation = isDisambiguation(content);
+ let processed = stripDisambiguation(content);
const placeholders = new Map();
let placeholderIndex = 0;
@@ -480,7 +482,7 @@ export async function renderForAI(content: string, db: D1Database, depth = 0, cu
processed = processed.split(key).join(value);
}
- return processed;
+ return (disambiguation ? ui('disambiguation.notice') + '\n\n' : '') + processed;
}
/**
diff --git a/tests/services.test.ts b/tests/services.test.ts
index df0133e..668da45 100644
--- a/tests/services.test.ts
+++ b/tests/services.test.ts
@@ -78,7 +78,8 @@ test('personal tokens authenticate API and MCP, honor current roles and expire/r
const sql=new DatabaseSync(':memory:');sql.exec(await readFile('migrations/schema.sql','utf8'));
const db:any={prepare(query:string){let params:any[]=[];const s={bind(...args:any[]){params=args;return s},async first(){return sql.prepare(query).get(...params)||null},async all(){return{results:sql.prepare(query).all(...params)}},async run(){const r=sql.prepare(query).run(...params);return{meta:{changes:Number(r.changes),last_row_id:Number(r.lastInsertRowid)}}}};return s},async batch(items:any[]){sql.exec('BEGIN');try{const result=[];for(const item of items)result.push(await item.run());sql.exec('COMMIT');return result}catch(error){sql.exec('ROLLBACK');throw error}}};
sql.exec("INSERT INTO users(id,provider,uid,email,name,role) VALUES(1,'nodeloc','1','token@example.com','API editor','user');INSERT INTO pages(id,slug,content,version) VALUES(1,'Public','hello',1)");
- const env:any={DB:db,KV:{get:async()=>null,put:async()=>{},delete:async()=>{}},MEDIA:{put:async()=>{},get:async()=>null},WIKI_NAME:'Test Wiki',WIKI_VISIBILITY:'open',MCP_MODE:'open',SUPER_ADMIN_EMAILS:'',ENABLED_EXTENSIONS:'',EDIT_REQUEST_ENABLED:'false',ASSETS:{fetch:async()=>new Response('',{status:404})}};
+ const objects=new Map();
+ const env:any={DB:db,KV:{get:async()=>null,put:async()=>{},delete:async()=>{}},MEDIA:{put:async(key:string,value:string)=>{objects.set(key,value);return{}},get:async(key:string)=>objects.has(key)?{text:async()=>objects.get(key),arrayBuffer:async()=>new TextEncoder().encode(objects.get(key)).buffer}:null},WIKI_NAME:'Test Wiki',WIKI_VISIBILITY:'open',MCP_MODE:'open',SUPER_ADMIN_EMAILS:'',ENABLED_EXTENSIONS:'',EDIT_REQUEST_ENABLED:'false',ASSETS:{fetch:async()=>new Response('',{status:404})}};
const waits:Promise[]=[];const ctx:any={waitUntil:(p:Promise)=>waits.push(p),passThroughOnException(){}};
(globalThis as any).caches={default:{match:async()=>undefined,put:async()=>{},delete:async()=>true}};
const now=Math.floor(Date.now()/1000);
@@ -100,6 +101,13 @@ test('personal tokens authenticate API and MCP, honor current roles and expire/r
assert.equal((await call('/api/me/git-token',{method:'DELETE',headers})).status,403);
const mcp=await call('/api/mcp',{method:'POST',headers,body:JSON.stringify({jsonrpc:'2.0',id:1,method:'tools/list'})});assert.equal(mcp.status,200);assert.ok((await mcp.json() as any).result.tools.some((t:any)=>t.name==='create_or_update_page'));
const authContext:any={env,req:{header:()=>headers.Authorization},executionCtx:ctx};assert.equal((await resolveBearerAuth(authContext)).kind,'authenticated');
+ const {DISAMBIGUATION_MARKER}=await import('../packages/wiki-shared/src/markup/disambiguation');
+ env.TURNSTILE_SECRET_KEY='test-secret';
+ const body=DISAMBIGUATION_MARKER+'\nName may refer to:\n- [[Meaning one]]\n- [[Meaning two]]';
+ const updated=await call('/api/w/Public',{method:'PUT',headers,body:JSON.stringify({content:body,version:1,summary:'Mark disambiguation'})});assert.equal(updated.status,200,await updated.text());
+ const readPage=await call('/api/w/Public?for_edit=true',{headers});assert.equal(readPage.status,200);const saved:any=await readPage.json();assert.equal(saved.is_disambiguation,true);assert.equal(saved.content,body);
+ const conflict=await call('/api/w/Public',{method:'PUT',headers,body:JSON.stringify({content:body,version:2,redirect_to:'Other'})});assert.equal(conflict.status,400);
+ const rev=sql.prepare('SELECT content,r2_key FROM revisions WHERE page_id=1 ORDER BY id DESC LIMIT 1').get() as any;assert.equal(rev.r2_key?objects.get(rev.r2_key):rev.content,body);
// Bearer writes work without browser Origin; ordinary cookie writes still require same-origin.
assert.equal((await call('/api/me/api-token',{method:'POST',headers:{Cookie:'wiki_session=browser',Origin:'https://evil.example','Content-Type':'text/plain'},body:'{}'})).status,403);
const invalid=await call('/api/me/api-token',{method:'POST',headers:browser,body:JSON.stringify({expires_at:now-1})});assert.equal(invalid.status,400);assert.ok(await authenticatePersonalToken(env,data.token));
@@ -111,3 +119,13 @@ test('personal tokens authenticate API and MCP, honor current roles and expire/r
assert.equal((await call('/api/me/git-token',{method:'DELETE',headers:browser})).status,200);assert.equal(await authenticatePersonalToken(env,legacy),null);
await Promise.all(waits);sql.close();
});
+
+
+test('disambiguation markers preserve source and ignore inline/code examples', async () => {
+ const {DISAMBIGUATION_MARKER,isDisambiguation,setDisambiguation,stripDisambiguation}=await import('../packages/wiki-shared/src/markup/disambiguation');
+ const body='- [[Meaning one]]\n- [[Meaning two]]';const marked=setDisambiguation(body,true);
+ assert.equal(isDisambiguation(marked),true);assert.equal(stripDisambiguation(marked),body);assert.equal(setDisambiguation(marked,true),marked);assert.equal(setDisambiguation(marked,false),body);
+ assert.equal(isDisambiguation('```html\n'+DISAMBIGUATION_MARKER+'\n```'),false);assert.equal(isDisambiguation('Example: '+DISAMBIGUATION_MARKER),false);
+ assert.equal(isDisambiguation('\uFEFF\n'+marked.replace(/\n/g,'\r\n')),true);
+ const {renderForAI}=await import('../src/utils/aiParser');const text=await renderForAI(marked,{} as any);assert.ok(!text.includes(DISAMBIGUATION_MARKER));assert.ok(text.includes('[[Meaning one]]'));assert.ok(text.length>body.length);
+});