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); +});