diff --git a/src/core/cjk.ts b/src/core/cjk.ts index d9c531876..49efab249 100644 --- a/src/core/cjk.ts +++ b/src/core/cjk.ts @@ -20,6 +20,14 @@ export const CJK_SLUG_CHARS = '一-鿿぀-ゟ゠-ヿ가-힯'; export const CJK_RANGES_REGEX = new RegExp(`[${CJK_SLUG_CHARS}]`); +/** + * Page-slug segment grammar (no anchors): alnum-or-CJK lead char, then + * alnum/CJK/hyphen continuation. Single source for validatePageSlug + * (operations.ts), SlugRegistry's SLUG_RE, and the dream-cycle + * SUMMARY_SLUG_RE so every slug validator shares one grammar (#738). + */ +export const PAGE_SLUG_SEG = `[a-z0-9${CJK_SLUG_CHARS}][a-z0-9${CJK_SLUG_CHARS}\\-]*`; + export const CJK_SENTENCE_DELIMITERS = ['。', '!', '?']; // 。!? export const CJK_CLAUSE_DELIMITERS = [';', ':', ',', '、']; // ;:,、 diff --git a/src/core/cycle/synthesize.ts b/src/core/cycle/synthesize.ts index 2dc1a5f7a..813b06a45 100644 --- a/src/core/cycle/synthesize.ts +++ b/src/core/cycle/synthesize.ts @@ -43,10 +43,11 @@ import { serializeMarkdown, serializePageToMarkdown } from '../markdown.ts'; import type { Page, PageType } from '../types.ts'; import { validateSourceId } from '../utils.ts'; import { safeSplitIndex } from '../text-safe.ts'; +import { PAGE_SLUG_SEG } from '../cjk.ts'; -// Slug regex from validatePageSlug — kept in sync. +// Slug grammar from validatePageSlug — shared via PAGE_SLUG_SEG (#738). // Used for the orchestrator-written summary index slug. -const SUMMARY_SLUG_RE = /^[a-z0-9][a-z0-9\-]*(\/[a-z0-9][a-z0-9\-]*)*$/; +const SUMMARY_SLUG_RE = new RegExp(`^${PAGE_SLUG_SEG}(\\/${PAGE_SLUG_SEG})*$`); // ── Model context budget (D1, D5, D7, D9) ───────────────────────────── diff --git a/src/core/operations.ts b/src/core/operations.ts index 0d1e5dcef..ca442c687 100644 --- a/src/core/operations.ts +++ b/src/core/operations.ts @@ -25,7 +25,7 @@ import { bumpLastRetrievedAt } from './last-retrieved.ts'; import { isSearchMode } from './search/mode.ts'; import { stampEvidence } from './search/evidence.ts'; import type { SearchResult } from './types.ts'; -import { CJK_SLUG_CHARS } from './cjk.ts'; +import { CJK_SLUG_CHARS, PAGE_SLUG_SEG } from './cjk.ts'; import * as db from './db.ts'; import { VERSION } from '../version.ts'; import { @@ -162,7 +162,6 @@ export function validatePageSlug(slug: string): void { } // v0.32.7: CJK ranges (Han / Hiragana / Katakana / Hangul Syllables) allowed // in segments. ASCII shape rules (lead char, hyphen continuation) preserved. - const PAGE_SLUG_SEG = `[a-z0-9${CJK_SLUG_CHARS}][a-z0-9${CJK_SLUG_CHARS}\\-]*`; if (!new RegExp(`^${PAGE_SLUG_SEG}(\\/${PAGE_SLUG_SEG})*$`, 'i').test(slug)) { throw new OperationError('invalid_params', `Invalid page_slug: ${slug} (allowed: alphanumeric, CJK, hyphens, forward-slash separated segments)`); } diff --git a/src/core/output/slug-registry.ts b/src/core/output/slug-registry.ts index 42c7c420b..f7010139c 100644 --- a/src/core/output/slug-registry.ts +++ b/src/core/output/slug-registry.ts @@ -17,6 +17,7 @@ import type { BrainEngine } from '../engine.ts'; import type { PageType } from '../types.ts'; +import { PAGE_SLUG_SEG } from '../cjk.ts'; export interface CreateSlugInput { /** @@ -71,7 +72,9 @@ export class SlugRegistryError extends Error { // SlugRegistry // --------------------------------------------------------------------------- -const SLUG_RE = /^[a-z0-9][a-z0-9\-]*(\/[a-z0-9][a-z0-9\-]*)+$/; +// Shares the page-slug segment grammar (incl. CJK ranges, #738) with +// validatePageSlug; keeps this site's dir/name shape (>= 2 segments). +const SLUG_RE = new RegExp(`^${PAGE_SLUG_SEG}(\\/${PAGE_SLUG_SEG})+$`); export class SlugRegistry { constructor(private engine: BrainEngine) {} diff --git a/test/cycle-dream-output-root.test.ts b/test/cycle-dream-output-root.test.ts index 384a7a64d..6df647de0 100644 --- a/test/cycle-dream-output-root.test.ts +++ b/test/cycle-dream-output-root.test.ts @@ -86,6 +86,12 @@ describe('#2415: loadOutputRoot validation + patterns gather scope', () => { expect(await loadOutputRoot(engine)).toBe('wiki'); }); + test('CJK root passes the slug grammar (#738)', async () => { + await engine.setConfig('dream.synthesize.output_root', '知识/笔记'); + expect(await loadOutputRoot(engine)).toBe('知识/笔记'); + await engine.setConfig('dream.synthesize.output_root', ''); + }); + test('patterns phase gathers reflections under the configured root', async () => { await engine.setConfig('dream.synthesize.output_root', 'notes'); for (let i = 0; i < 3; i++) { diff --git a/test/writer.test.ts b/test/writer.test.ts index 1bf1d60e4..53cefca71 100644 --- a/test/writer.test.ts +++ b/test/writer.test.ts @@ -203,6 +203,17 @@ describe('SlugRegistry', () => { })).rejects.toThrow(SlugRegistryError); }); + test('create accepts CJK slugs (#738)', async () => { + const reg = new SlugRegistry(engine); + const r = await reg.create({ + desiredSlug: '知识/品牌圣经', + displayName: '品牌圣经', + type: 'note', + }); + expect(r.slug).toBe('知识/品牌圣经'); + expect(r.exact).toBe(true); + }); + test('create throws on invalid slug', async () => { const reg = new SlugRegistry(engine); await expect(reg.create({