Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 关闭。


### 消歧义页面

在编辑器中勾选“消歧义页面”,或点击“生成消歧义模板”列出同名条目的不同含义。使用 `[[条目标题|显示名称]]` 添加链接与简短说明。也可从普通条目手动链接到 `[[名称(消歧义)]]`。

页面顶部会显示消歧义提示和类型徽标。类型保存在正文开头的 `<!-- vireowiki:disambiguation -->` 标记中,Git、MCP 和修订历史均能保留;取消勾选只移除标记,保留正文。消歧义页不能同时设置重定向,编辑仍遵循普通文档权限与 ACL。
10 changes: 9 additions & 1 deletion packages/wiki-shared/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
10 changes: 9 additions & 1 deletion packages/wiki-shared/src/i18n/source.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "동음이의어 문서는 넘겨주기를 함께 설정할 수 없습니다."
}
10 changes: 9 additions & 1 deletion packages/wiki-shared/src/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "消歧义页面不能同时设置页面重定向。"
}
13 changes: 13 additions & 0 deletions packages/wiki-shared/src/markup/disambiguation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** Kept in the document so revisions, Git and MCP preserve the page type together. */
export const DISAMBIGUATION_MARKER = '<!-- vireowiki:disambiguation -->';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove the marker before deriving SEO descriptions

Every marked page passes this HTML comment unchanged to extractMetaDescription(page.content) in src/index.ts:784-790. That extractor removes ordinary HTML tags but not comments, so the generated description begins with the literal <!-- vireowiki:disambiguation -->; it is then used for both standard and Open Graph description metadata and for crawler responses. Strip the marker before description extraction so search and link previews expose the actual article text rather than internal metadata.

Useful? React with 👍 / 👎.

const prefix = /^(?:\uFEFF)?[ \t\r\n]*<!-- vireowiki:disambiguation -->[ \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;
}
5 changes: 5 additions & 0 deletions packages/wiki-shared/src/render/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 집합을 받는
Expand Down Expand Up @@ -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 = `<div class="alert alert-info wiki-disambiguation-notice" role="note"><strong>${escapeHtml(ui('disambiguation.type'))}</strong><br>${escapeHtml(ui('disambiguation.notice'))}</div>` + html;

if (options.showCategory && slug) {
// index.html 의 route() 가 decodeURIComponent 실패 시 원본 slug 를 그대로
// 넘기므로 (`100%news` 같은 잘못된 인코딩 케이스), 여기서 다시 디코드를
Expand Down
26 changes: 26 additions & 0 deletions src/client/edit/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -2748,6 +2751,28 @@ document.addEventListener('DOMContentLoaded', async () => {
// 넘겨주기(redirect) 변경 시 편집 요약 자동 갱신
// input: 매 키 입력마다 디바운스로 갱신 (입력 중간 prefix 가 길어졌다 짧아졌다 반복하는 것 완화)
// change: blur 직후 즉시 확정
const redirectField = document.getElementById('redirectInput')?.closest('.mb-3');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Hide the controls for raw extension documents

This condition also adds the disambiguation controls when isExtensionData is true. Those namespaces deliberately use the raw textarea and R2-only storage, and the reader bypasses renderWikiContent, passing page.content directly to the extension renderer (src/client/pages/index.ts:975-1042). Checking the box therefore prepends a Wiki HTML comment to an extension's raw data without ever showing the reader notice, while the template button can replace the dataset with Markdown; extension editors should not expose these document-type controls.

Useful? React with 👍 / 👎.

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' : '';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore the type controls after section-mode fallback

When an edit opens with sectionMode set, this permanently hides the new controls unless the page is reloaded. If the requested section no longer exists, the fallback at main.ts:2932-2936 switches to full-document editing without restoring this element; likewise, the section-conflict promotion at main.ts:4320-4330 restores the other metadata wrappers but omits disambiguationType. In both cases the user is now editing the whole page but cannot mark or unmark its type, so every transition back to full-edit mode needs to reveal this wrapper too.

Useful? React with 👍 / 👎.

controls.append(label,hint,template); redirectField.after(controls);
}
const redirectInputEl = document.getElementById('redirectInput');
if (redirectInputEl) {
let redirectDebounce = null;
Expand Down Expand Up @@ -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) {
Expand Down
3 changes: 2 additions & 1 deletion src/client/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)) ? `<span class="badge bg-info text-dark ms-2">${window.escapeHtml(ui('disambiguation.type'))}</span>` : '';
if (page.category) {
const cats = page.category.split(',').map(c => c.trim()).filter(c => c);
cats.forEach(cat => {
Expand Down
5 changes: 5 additions & 0 deletions src/client/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 집합을 받는
Expand Down Expand Up @@ -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 = `<div class="alert alert-info wiki-disambiguation-notice" role="note"><strong>${escapeHtml(ui('disambiguation.type'))}</strong><br>${escapeHtml(ui('disambiguation.notice'))}</div>` + html;

if (options.showCategory && slug) {
// index.html 의 route() 가 decodeURIComponent 실패 시 원본 slug 를 그대로
// 넘기므로 (`100%news` 같은 잘못된 인코딩 케이스), 여기서 다시 디코드를
Expand Down
5 changes: 4 additions & 1 deletion src/routes/wiki.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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 네비게이션·검색 등에서는 응답에서 제거한다.
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce the redirect conflict in every mutation path

This guard only covers direct PUT /api/w/:slug saves, so the stated invariant can still be violated by other existing writers. MCP approval passes draft.content and draft.redirect_to directly to commitPageMutation (src/utils/mcpDraftApply.ts:222-230), pending-edit approval does likewise (src/routes/pending-edits.ts:770-779), and reverting a currently redirected page can restore a marked revision without changing redirect_to (src/routes/wiki.ts:3612-3731). Validate the final content/redirect pair in the shared mutation layer, and in the revert path, so these workflows cannot persist a page that is both a redirect and a disambiguation page.

Useful? React with 👍 / 👎.


// CRLF/CR → LF 정규화. 클라이언트 환경(Windows 클립보드, 외부 임포트 등)에서
// \r 가 섞여 들어오면 렌더 파이프라인의 펜스/`:::`/폴드 정규식이 깨진다.
// 저장 시점에 한 번만 정규화하면 이후 모든 읽기 경로가 안전해진다.
Expand Down
6 changes: 4 additions & 2 deletions src/utils/aiParser.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -334,7 +335,8 @@ function substituteParams(templateContent: string, args: Record<string, string>,
export async function renderForAI(content: string, db: D1Database, depth = 0, currentSlug?: string): Promise<string> {
if (!content) return '';

let processed = content;
const disambiguation = isDisambiguation(content);
let processed = stripDisambiguation(content);
const placeholders = new Map<string, string>();
let placeholderIndex = 0;

Expand Down Expand Up @@ -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;
}

/**
Expand Down
Loading
Loading