Refactor: Replace replace with replaceAll for consistent string replacements

- Updated `format.ts`, `index.ts`, `registry.ts`, `search.ts`, `templates.ts`, `tools.ts`, and `vscode-ext` files to use `replaceAll` instead of `replace` for string manipulations.
- Changed import statements in various files to use `node:` prefix for built-in modules.
- Adjusted test files to reflect changes in string replacement methods and updated descriptions for clarity.
- Modified README and manifest files to correct descriptions related to notes file handling.
This commit is contained in:
Erik van der Boom 2026-04-09 21:56:12 +02:00
parent 78d69fefc5
commit a0d77832ac
25 changed files with 220 additions and 184 deletions

View file

@ -38,7 +38,7 @@ mcpb/ ← Claude Desktop extension package (mcpb manifest + bundled
The MCP server is published to the Anthropic MCPB directory. These rules are hard requirements to stay publishable.
### Every tool MUST have the correct annotations
Readonly/destructive are mutaully exclusive categories that determine how the tool is presented to users and whether it can be called by read-only agents. Every tool MUST have exactly one of these annotations:
Readonly/destructive are mutually exclusive categories that determine how the tool is presented to users and whether it can be called by read-only agents. Every tool MUST have exactly one of these annotations:
```typescript
annotations: { readOnlyHint: true } // reads only — never writes files
annotations: { destructiveHint: true } // writes, creates, or modifies files

View file

@ -68,8 +68,9 @@ Packages the MCP server as a `.mcpb` file for one-click installation in Claude D
3. Fill in the **Books** field with semicolon-separated `Name=path` pairs:
`ScaryBook=C:\Users\My\Projects\ScaryBook;MyNovel=D:\Writing\MyNovel`
4. Optionally set the **Ollama URL** if you want semantic reranking
5. Optionally enable the semantic index and choose a default search mode if you want `full_semantic` search with rebuild warnings when the embedding index becomes stale
5. Tools are now available — the agent calls `list_books` to discover book names
5. Optionally enable the semantic index and choose a default search mode if you want `full_semantic` search with rebuild warnings when the embedding index becomes stale.
- Note full embedding can be a heavy operation, depending on your hardware when running a local Ollama instance.
6. Tools are now available — the agent calls `list_books` to discover book names
### Formatting & Export only (no MCP)

View file

@ -18,36 +18,36 @@ const CLOSE_DOUBLE_AFTER_EM_DASH_RE = /—"([\s)\].,;:!?]|$)/gm;
export function updateTypography(text: string): string {
let result = text;
result = result.replace(/\.\.\./g, ELLIPSIS);
result = result.replaceAll(/\.\.\./g, ELLIPSIS);
const protectedComments: string[] = [];
result = result.replace(COMMENT_RE, (match) => {
result = result.replaceAll(COMMENT_RE, (match) => {
const placeholder = `\x00COMMENT${protectedComments.length}\x00`;
protectedComments.push(match);
return placeholder;
});
const protectedTriple = '\x00TRIPLE\x00';
result = result.replace(/---/g, protectedTriple);
result = result.replace(/--/g, EM_DASH);
result = result.replace(new RegExp(escapeRegex(protectedTriple), 'g'), '---');
result = result.replaceAll(/---/g, protectedTriple);
result = result.replaceAll(/--/g, EM_DASH);
result = result.replaceAll(new RegExp(escapeRegex(protectedTriple), 'g'), '---');
for (let i = 0; i < protectedComments.length; i++) {
result = result.replace(`\x00COMMENT${i}\x00`, protectedComments[i]);
result = result.replaceAll(`\x00COMMENT${i}\x00`, protectedComments[i]);
}
result = result.replace(CLOSE_DOUBLE_AFTER_EM_DASH_RE, (_match, after) => {
result = result.replaceAll(CLOSE_DOUBLE_AFTER_EM_DASH_RE, (_match, after) => {
return `${EM_DASH}${CLOSE_DOUBLE}${after}`;
});
result = result.replace(OPEN_DOUBLE_RE, (_match, before) => `${before}${OPEN_DOUBLE}`);
result = result.replace(/"/g, CLOSE_DOUBLE);
result = result.replace(OPEN_SINGLE_RE, (_match, before) => `${before}${OPEN_SINGLE}`);
result = result.replace(/'/g, CLOSE_SINGLE);
result = result.replaceAll(OPEN_DOUBLE_RE, (_match, before) => `${before}${OPEN_DOUBLE}`);
result = result.replaceAll(/"/g, CLOSE_DOUBLE);
result = result.replaceAll(OPEN_SINGLE_RE, (_match, before) => `${before}${OPEN_SINGLE}`);
result = result.replaceAll(/'/g, CLOSE_SINGLE);
return result;
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

View file

@ -67,7 +67,7 @@ server.registerTool('list_books', {
}, async () => {
const books = listBooks();
if (books.length === 0) {
return ok(
return ok(
'No books configured.\n\n' +
'Add --book args to the MCP server in your claude_desktop_config.json:\n\n' +
' "args": ["dist/index.js", "--book", "MyNovel=/path/to/project"]'
@ -174,7 +174,7 @@ server.registerTool('get_overview', {
server.registerTool('get_notes', {
title: 'Get Notes',
description: 'Read from Notes/ and Details_*.md files, optionally filtered by category name or character/place name.',
description: 'Read from Notes/ files, optionally filtered by category name or character/place name.',
inputSchema: {
book: bookSchema,
category: z.string().optional().describe('Filter by file/category name substring'),

View file

@ -14,8 +14,8 @@
* discover available names).
*/
import * as path from 'path';
import * as fs from 'fs';
import * as path from 'node:path'
import * as fs from 'node:fs';
// ─── Startup configuration ────────────────────────────────────────────────────

View file

@ -4,9 +4,9 @@
* index.
*/
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import MiniSearch from 'minisearch';
import { chunkWorkspace, type Chunk } from './docstore.js';
@ -382,6 +382,18 @@ export function semanticDiscoverOptions() {
return { includeArc: true } as const;
}
function embeddingMaxChars(): number {
const raw = Number.parseInt(process.env['BINDERY_EMBEDDING_MAX_CHARS'] ?? '4000', 10);
if (Number.isNaN(raw)) { return 4000; }
return raw > 0 ? raw : 4000;
}
function normalizeEmbeddingInput(text: string): string {
const compact = text.replaceAll(/\s+/g, ' ').trim();
const maxChars = embeddingMaxChars();
return compact.length <= maxChars ? compact : compact.slice(0, maxChars);
}
function ollamaConfig(): { url: string | null; model: string } {
return {
url: process.env['BINDERY_OLLAMA_URL']?.trim() || null,
@ -415,7 +427,7 @@ function chapterStatusSignature(root: string): string {
const interesting = (parsed.chapters ?? [])
.filter(chapter => chapter.status === 'done' || chapter.status === 'needs-review')
.map(chapter => `${(chapter.language ?? 'EN').toUpperCase()}:${chapter.number}:${chapter.status}`)
.sort()
.sort((a, b) => a.localeCompare(b))
.join('|');
return interesting || 'none';
} catch {
@ -428,11 +440,12 @@ async function fetchEmbedding(
model: string,
text: string,
): Promise<number[] | null> {
const url = baseUrl.replace(/\/$/, '') + '/api/embeddings';
const url = baseUrl.replaceAll(/\/$/, '') + '/api/embeddings';
const prompt = normalizeEmbeddingInput(text);
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model, prompt: text }),
body: JSON.stringify({ model, prompt }),
signal: AbortSignal.timeout(10000),
});
if (!res.ok) { return null; }
@ -441,7 +454,7 @@ async function fetchEmbedding(
}
async function validateOllamaEndpoint(baseUrl: string): Promise<string | null> {
const url = baseUrl.replace(/\/$/, '') + '/api/tags';
const url = baseUrl.replaceAll(/\/$/, '') + '/api/tags';
try {
const res = await fetch(url, {
method: 'GET',

View file

@ -155,7 +155,7 @@ function claudeMd(ctx: TemplateContext): string {
'| `get_text` | Read any file by relative path, with optional line range |',
'| `get_chapter` | Full chapter content by number and language |',
'| `get_overview` | Chapter structure — acts, chapters, titles |',
'| `get_notes` | Notes/ and Details_*.md files, filterable by category or name |',
'| `get_notes` | Notes/ files, filterable by category or name |',
'| `search` | BM25 full-text search with ranked snippets, optional semantic ranking |',
'| `format` | Apply typography formatting to a file or folder |',
'| `get_review_text` | Structured git diff with optional auto-staging |',
@ -197,7 +197,6 @@ function copilotMd(ctx: TemplateContext): string {
'## Writing guidelines',
'- HTML comments `<!-- -->` in chapter files are writer notes — treat as context only.',
'- Quotation marks and dashes are managed by the Bindery VS Code extension. Do not normalize them.',
'- Check `Notes/Details_Translation_notes.md` before using or translating world-specific terms.',
);
if (ctx.audience) {
lines.push(`- Content targets ${ctx.audience}. Keep vocabulary accessible and themes appropriate.`);
@ -216,10 +215,8 @@ function cursorRules(ctx: TemplateContext): string {
'',
'## Context files to read',
`- \`${memoriesFolder}/global.md\` — cross-chapter decisions (read at start of session)`,
`- \`${arcFolder}/Overall.md\` — full story arc`,
`- \`${notesFolder}/Details_Characters.md\` — character profiles`,
`- \`${notesFolder}/Details_World_and_Magic.md\` — world rules`,
`- \`${notesFolder}/Details_Translation_notes.md\` — term translations`,
`- \`${arcFolder}/\` — story arc files for overall and per-act structure and beats`,
`- \`${notesFolder}/\` — story notes, like character profiles and world rules`,
'',
'## Rules',
'- HTML comments `<!-- -->` in chapter files are writer notes. Treat as context, not story content.',
@ -246,7 +243,6 @@ function agentsMd(ctx: TemplateContext): string {
'## Start of session',
`1. Read \`${memoriesFolder}/global.md\` for cross-chapter context.`,
`2. If working on a specific chapter, read \`${memoriesFolder}/chXX.md\` if it exists.`,
`3. Check \`${notesFolder}/Details_Translation_notes.md\` before using or translating world-specific terms.`,
'',
'## Story files',
`- Chapter files are \`.md\` files in \`${storyFolder}/\`, organized in act subfolders.`,
@ -264,11 +260,8 @@ function agentsMd(ctx: TemplateContext): string {
'## Key reference files',
'| File | Contains |',
'|---|---|',
`| \`${arcFolder}/Overall.md\` | Full story arc |`,
`| \`${arcFolder}/Act_I_*.md\`, \`Act_II_*.md\`, \`Act_III_*.md\` | Per-act arc details |`,
`| \`${notesFolder}/Details_Characters.md\` | Character profiles |`,
`| \`${notesFolder}/Details_World_and_Magic.md\` | World rules |`,
`| \`${notesFolder}/Details_Translation_notes.md\` | EN ↔ translation term table |`,
`| \`${arcFolder}/\` | Story arc files for overall and per-act structure and beats |`,
`| \`${notesFolder}/\` | Story notes, like character profiles and world rules |`,
`| \`${memoriesFolder}/global.md\` | Cross-session decisions |`,
);
return lines.join('\n') + '\n';

View file

@ -697,7 +697,7 @@ export function toolGitSnapshot(root: string, args: GitSnapshotArgs): string {
if (!staged.trim()) { return 'Nothing to snapshot — no changes in content folders.'; }
const fileCount = staged.trim().split('\n').length;
const msg = args.message ?? `Snapshot ${new Date().toISOString().slice(0, 16).replace('T', ' ')}`;
const msg = args.message ?? `Snapshot ${new Date().toISOString().slice(0, 16).replaceAll('T', ' ')}`;
try {
const result = spawnSync('git', ['commit', '-m', msg], { cwd: root, encoding: 'utf-8' });
@ -1202,7 +1202,7 @@ export function toolInitWorkspace(root: string, args: InitWorkspaceArgs): string
const existingLangs = ((existing['languages'] as unknown[] | undefined) ?? []) as Array<Record<string, unknown>>;
const languages = detectWorkspaceLangs(path.join(root, storyFolderName), existingLangs);
const slug = bookTitle.replaceAll(/[^a-zA-Z0-9]+/g, '_').replace(/^_|_$/, '') || 'Book';
const slug = bookTitle.replaceAll(/[^a-zA-Z0-9]+/g, '_').replaceAll(/^_|_$/, '') || 'Book';
const settings: Record<string, unknown> = {
...existing,
bookTitle,

View file

@ -1,6 +1,6 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as fs from 'node:fs';
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest';
const tempRoots: string[] = [];

View file

@ -1,6 +1,6 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import {
@ -131,16 +131,7 @@ describe('toolGetNotes', () => {
expect(result).not.toContain('Bob');
});
it('includes Details_*.md files at root', () => {
const root = makeRoot();
write(path.join(root, 'Details_Setting.md'), '# Setting\nA fantasy world.\n');
const result = toolGetNotes(root, {});
expect(result).toContain('Details_Setting.md');
expect(result).toContain('A fantasy world.');
});
it('returns no-files message when Notes directory is missing and no Details_* files', () => {
it('returns no-files message when Notes directory is missing', () => {
const root = makeRoot();
const result = toolGetNotes(root, {});
expect(result).toContain('No notes files found.');

View file

@ -1,7 +1,7 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { spawnSync } from 'child_process';
import * as fs from 'node:fs';
import * as os from 'node:os'
import * as path from 'node:path'
import { spawnSync } from 'node:child_process'
import { afterEach, describe, expect, it } from 'vitest';
import {

View file

@ -7,10 +7,10 @@
* Prerequisites: `npm run build` must have been run so `out/index.js` exists.
*/
import * as child_process from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as child_process from 'node:child_process'
import * as fs from 'node:fs';
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest';
// ─── Helpers ──────────────────────────────────────────────────────────────────

View file

@ -24,6 +24,7 @@ function write(filePath: string, content: string): void {
afterEach(() => {
delete process.env['BINDERY_MAX_RESPONSE_BYTES'];
delete process.env['BINDERY_EMBEDDING_MAX_CHARS'];
for (const root of tempRoots.splice(0, tempRoots.length)) {
fs.rmSync(root, { recursive: true, force: true });
}
@ -180,22 +181,6 @@ describe('Arc folder in lexical index', () => {
describe('mocked Ollama — semantic_rerank', () => {
const mockEmbedding = Array.from({ length: 8 }, (_, i) => (i + 1) / 10);
function ollamaFetchMock() {
return vi.fn().mockImplementation(async (input: unknown) => {
const url = String(input);
if (url.endsWith('/api/tags')) {
return {
ok: true,
json: async () => ({ models: [{ name: 'nomic-embed-text' }] }),
};
}
return {
ok: true,
json: async () => ({ embedding: mockEmbedding }),
};
});
}
afterEach(() => {
vi.restoreAllMocks();
delete process.env['BINDERY_OLLAMA_URL'];
@ -208,7 +193,7 @@ describe('mocked Ollama — semantic_rerank', () => {
process.env['BINDERY_OLLAMA_URL'] = 'http://localhost:11434';
vi.stubGlobal('fetch', ollamaFetchMock());
vi.stubGlobal('fetch', ollamaFetchMock(mockEmbedding));
const result = await toolSearch(root, { query: 'captain sailed', mode: 'semantic_rerank' });
expect(result).not.toContain('Warning:');
@ -257,15 +242,18 @@ describe('mocked Ollama — semantic_rerank', () => {
expect(result).toContain('Warning: semantic_rerank requested but BINDERY_OLLAMA_URL is not an Ollama endpoint; using lexical results.');
expect(result).toContain('/api/tags response did not contain a models array');
});
});
// ─── Mocked Ollama — buildSemanticIndex + full_semantic ──────────────────────
it('truncates rerank embedding prompts to BINDERY_EMBEDDING_MAX_CHARS', async () => {
const root = makeRoot();
const longText = 'Alpha '.repeat(200);
write(path.join(root, 'Story', 'EN', 'Act I', 'Chapter 1.md'), `# Long\n${longText}\n`);
await toolIndexBuild(root);
describe('mocked Ollama — full_semantic', () => {
const mockEmbedding = Array.from({ length: 8 }, (_, i) => (i + 1) / 10);
process.env['BINDERY_OLLAMA_URL'] = 'http://localhost:11434';
process.env['BINDERY_EMBEDDING_MAX_CHARS'] = '64';
function ollamaFetchMock() {
return vi.fn().mockImplementation(async (input: unknown) => {
const prompts: string[] = [];
vi.stubGlobal('fetch', vi.fn().mockImplementation(async (input: unknown, init?: RequestInit) => {
const url = String(input);
if (url.endsWith('/api/tags')) {
return {
@ -273,12 +261,24 @@ describe('mocked Ollama — full_semantic', () => {
json: async () => ({ models: [{ name: 'nomic-embed-text' }] }),
};
}
const body = typeof init?.body === 'string' ? JSON.parse(init.body) as { prompt?: string } : {};
if (typeof body.prompt === 'string') { prompts.push(body.prompt); }
return {
ok: true,
json: async () => ({ embedding: mockEmbedding }),
};
});
}
}));
await toolSearch(root, { query: 'Alpha', mode: 'semantic_rerank' });
expect(prompts.length).toBeGreaterThan(0);
expect(prompts.every(p => p.length <= 64)).toBe(true);
});
});
// ─── Mocked Ollama — buildSemanticIndex + full_semantic ──────────────────────
describe('mocked Ollama — full_semantic', () => {
const mockEmbedding = Array.from({ length: 8 }, (_, i) => (i + 1) / 10);
afterEach(() => {
vi.restoreAllMocks();
@ -293,7 +293,7 @@ describe('mocked Ollama — full_semantic', () => {
process.env['BINDERY_OLLAMA_URL'] = 'http://localhost:11434';
process.env['BINDERY_ENABLE_SEMANTIC_INDEX'] = 'true';
vi.stubGlobal('fetch', ollamaFetchMock());
vi.stubGlobal('fetch', ollamaFetchMock(mockEmbedding));
const result = await toolIndexBuild(root);
expect(result).toContain('Lexical index built:');
@ -308,7 +308,7 @@ describe('mocked Ollama — full_semantic', () => {
process.env['BINDERY_OLLAMA_URL'] = 'http://localhost:11434';
process.env['BINDERY_ENABLE_SEMANTIC_INDEX'] = 'true';
vi.stubGlobal('fetch', ollamaFetchMock());
vi.stubGlobal('fetch', ollamaFetchMock(mockEmbedding));
// Build the semantic index
await toolIndexBuild(root);
@ -327,7 +327,7 @@ describe('mocked Ollama — full_semantic', () => {
process.env['BINDERY_ENABLE_SEMANTIC_INDEX'] = 'true';
// Build succeeds
vi.stubGlobal('fetch', ollamaFetchMock());
vi.stubGlobal('fetch', ollamaFetchMock(mockEmbedding));
await toolIndexBuild(root);
// Query fails
@ -363,4 +363,51 @@ describe('mocked Ollama — full_semantic', () => {
expect(result).toContain('Semantic index failed: Semantic indexing requires an Ollama server.');
expect(result).toContain('/api/tags response did not contain a models array');
});
it('truncates semantic index embedding prompts to BINDERY_EMBEDDING_MAX_CHARS', async () => {
const root = makeRoot();
const longText = 'Forest '.repeat(220);
write(path.join(root, 'Story', 'EN', 'Act I', 'Chapter 1.md'), `# Forest\n${longText}\n`);
process.env['BINDERY_OLLAMA_URL'] = 'http://localhost:11434';
process.env['BINDERY_ENABLE_SEMANTIC_INDEX'] = 'true';
process.env['BINDERY_EMBEDDING_MAX_CHARS'] = '80';
const prompts: string[] = [];
vi.stubGlobal('fetch', vi.fn().mockImplementation(async (input: unknown, init?: RequestInit) => {
const url = String(input);
if (url.endsWith('/api/tags')) {
return {
ok: true,
json: async () => ({ models: [{ name: 'nomic-embed-text' }] }),
};
}
const body = typeof init?.body === 'string' ? JSON.parse(init.body) as { prompt?: string } : {};
if (typeof body.prompt === 'string') { prompts.push(body.prompt); }
return {
ok: true,
json: async () => ({ embedding: mockEmbedding }),
};
}));
await toolIndexBuild(root);
expect(prompts.length).toBeGreaterThan(0);
expect(prompts.every(p => p.length <= 80)).toBe(true);
});
});
function ollamaFetchMock(mockEmbedding: number[]) {
return vi.fn().mockImplementation(async (input: unknown) => {
const url = String(input);
if (url.endsWith('/api/tags')) {
return {
ok: true,
json: async () => ({ models: [{ name: 'nomic-embed-text' }] }),
};
}
return {
ok: true,
json: async () => ({ embedding: mockEmbedding }),
};
});
}

View file

@ -1,6 +1,6 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as fs from 'node:fs';
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest';
import {

View file

@ -1,6 +1,6 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as fs from 'node:fs';
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest';
import {
@ -222,15 +222,6 @@ describe('toolAddLanguage', () => {
// ─── toolSetupAiFiles ─────────────────────────────────────────────────────────
describe('toolSetupAiFiles', () => {
function makeSettingsRoot(): string {
const root = makeRoot();
write(
path.join(root, '.bindery', 'settings.json'),
JSON.stringify({ bookTitle: 'Test Book', storyFolder: 'Story', languages: [{ code: 'EN', folderName: 'EN' }] }) + '\n'
);
return root;
}
it('creates CLAUDE.md when claude target requested', () => {
const root = makeSettingsRoot();
toolSetupAiFiles(root, { targets: ['claude'], skills: [], overwrite: true });
@ -462,3 +453,12 @@ describe('toolChapterStatusGet', () => {
expect(result).toContain('Needs polish');
});
});
function makeSettingsRoot(): string {
const root = makeRoot();
write(
path.join(root, '.bindery', 'settings.json'),
JSON.stringify({ bookTitle: 'Test Book', storyFolder: 'Story', languages: [{ code: 'EN', folderName: 'EN' }] }) + '\n'
);
return root;
}

View file

@ -132,7 +132,7 @@ profiles and world rules, then uses `search` to verify specific details against
| `get_text` | Read any file by relative path, with optional line range |
| `get_chapter` | Full chapter content by number and language |
| `get_overview` | Chapter structure — acts, chapters, titles |
| `get_notes` | Notes/ and Details_*.md files, filterable by category or name |
| `get_notes` | Notes/ files, filterable by category or name |
| `search` | Search in lexical, semantic-rerank, or full-semantic mode; semantic modes fall back to lexical with warnings |
| `format` | Apply typography formatting to a file or folder |
| `get_review_text` | Structured git diff with optional auto-staging |

View file

@ -69,7 +69,7 @@
{ "name": "get_text", "description": "Read a source file by relative path, optionally restricted to a line range." },
{ "name": "get_chapter", "description": "Fetch the full content of a chapter by number and language code (e.g. EN or NL)." },
{ "name": "get_overview", "description": "List the chapter structure (acts, chapters, titles) for one or all languages." },
{ "name": "get_notes", "description": "Read from Notes/ and Details_*.md, optionally filtered by category or character name." },
{ "name": "get_notes", "description": "Read from Notes/, optionally filtered by category or character name." },
{ "name": "format", "description": "Apply typography formatting (curly quotes, em-dashes, ellipses) to a file or folder." },
{ "name": "get_review_text", "description": "Structured git diff of uncommitted changes. Filter by language (EN, NL, ALL). Set autoStage to stage reviewed files." },
{ "name": "git_snapshot", "description": "Save a snapshot (git commit) of all changes in story, notes, and arc folders. Use after writing sessions or reviews." },

View file

@ -12,21 +12,21 @@
*/
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
import * as fs from 'node:fs';
import * as path from 'node:path'
import { execSync } from 'node:child_process'
import { updateTypography } from './format';
import {
mergeBook, checkPandoc, getBuiltInUkReplacements,
type LanguageConfig, type DialectConfig, type OutputType, type MergeOptions, type UkReplacement,
} from './merge';
import {
readWorkspaceSettings, readTranslations, writeTranslations,
readWorkspaceSettings, readTranslations,
getBinderyFolder, getSettingsPath, getTranslationsPath,
getBookTitleForLang, getSubstitutionRules, getIgnoredWords,
upsertSubstitutionRule, upsertGlossaryRule, addIgnoredWords,
getDefaultLanguage, getDialectsForLanguage, getGlossaryRules,
type WorkspaceSettings, type TranslationsFile, type TranslationEntry,
getDefaultLanguage, getDialectsForLanguage,
type WorkspaceSettings, type TranslationsFile,
} from './workspace';
import {
ALL_SKILLS,
@ -112,7 +112,7 @@ function getEffectiveConfig(wsSettings: WorkspaceSettings | null): EffectiveConf
/** True if filePath is inside <root>/<storyFolder>/. */
function isInsideStoryFolder(filePath: string, root: string, storyFolder: string): boolean {
// Normalize separators so Windows paths compare correctly
const norm = (p: string) => p.replace(/\\/g, '/');
const norm = (p: string) => p.replaceAll(/\\/g, '/');
const story = norm(path.join(root, storyFolder));
const file = norm(filePath);
return file.startsWith(story + '/');
@ -194,16 +194,16 @@ function suggestUkSpelling(usWord: string): string | undefined {
const lower = usWord.toLowerCase();
const builtInMap = new Map(getBuiltInUkReplacements().map(r => [r.us.toLowerCase(), r.uk]));
if (builtInMap.has(lower)) { return builtInMap.get(lower); }
if (lower.endsWith('izations')) { return lower.replace(/izations$/, 'isations'); }
if (lower.endsWith('ization')) { return lower.replace(/ization$/, 'isation'); }
if (lower.endsWith('izing')) { return lower.replace(/izing$/, 'ising'); }
if (lower.endsWith('izes')) { return lower.replace(/izes$/, 'ises'); }
if (lower.endsWith('ized')) { return lower.replace(/ized$/, 'ised'); }
if (lower.endsWith('ize')) { return lower.replace(/ize$/, 'ise'); }
if (lower.endsWith('yzing')) { return lower.replace(/yzing$/, 'ysing'); }
if (lower.endsWith('yzes')) { return lower.replace(/yzes$/, 'yses'); }
if (lower.endsWith('yzed')) { return lower.replace(/yzed$/, 'ysed'); }
if (lower.endsWith('yze')) { return lower.replace(/yze$/, 'yse'); }
if (lower.endsWith('izations')) { return lower.replaceAll(/izations$/, 'isations'); }
if (lower.endsWith('ization')) { return lower.replaceAll(/ization$/, 'isation'); }
if (lower.endsWith('izing')) { return lower.replaceAll(/izing$/, 'ising'); }
if (lower.endsWith('izes')) { return lower.replaceAll(/izes$/, 'ises'); }
if (lower.endsWith('ized')) { return lower.replaceAll(/ized$/, 'ised'); }
if (lower.endsWith('ize')) { return lower.replaceAll(/ize$/, 'ise'); }
if (lower.endsWith('yzing')) { return lower.replaceAll(/yzing$/, 'ysing'); }
if (lower.endsWith('yzes')) { return lower.replaceAll(/yzes$/, 'yses'); }
if (lower.endsWith('yzed')) { return lower.replaceAll(/yzed$/, 'ysed'); }
if (lower.endsWith('yze')) { return lower.replaceAll(/yze$/, 'yse'); }
return undefined;
}
@ -293,7 +293,7 @@ async function initWorkspaceCommand() {
const detectedLangs = detectLanguageFolders(path.join(root, storyFolder));
const languages = detectedLangs.length > 0 ? detectedLangs : [DEFAULT_LANGUAGE];
const slug: string = title.replace(/[^a-zA-Z0-9]+/g, '_').replace(/^_|_$/, '') || 'Book';
const slug: string = title.replaceAll(/[^a-zA-Z0-9]+/g, '_').replaceAll(/^_|_$/, '') || 'Book';
const settings: WorkspaceSettings = {
...(title ? { bookTitle: title } : {}),
@ -441,8 +441,8 @@ async function addDialectCommand() {
let sourceLang: LanguageConfig | undefined;
if (editor) {
const sf = wsSettings?.storyFolder ?? 'Story';
const file = editor.document.uri.fsPath.replace(/\\/g, '/');
const base = path.join(root, sf).replace(/\\/g, '/');
const file = editor.document.uri.fsPath.replaceAll(/\\/g, '/');
const base = path.join(root, sf).replaceAll(/\\/g, '/');
if (file.startsWith(base + '/')) {
const folderName = file.slice(base.length + 1).split('/')[0];
sourceLang = wsSettings?.languages?.find(

View file

@ -44,11 +44,11 @@ export function updateTypography(text: string): string {
let result = text;
// Step 1: Convert ... to ellipsis (must happen before quote processing)
result = result.replace(/\.\.\./g, ELLIPSIS);
result = result.replaceAll(/\.\.\./g, ELLIPSIS);
// Step 2: Protect HTML comments from em-dash conversion
const protectedComments: string[] = [];
result = result.replace(COMMENT_RE, (match) => {
result = result.replaceAll(COMMENT_RE, (match) => {
const placeholder = `\x00COMMENT${protectedComments.length}\x00`;
protectedComments.push(match);
return placeholder;
@ -56,39 +56,39 @@ export function updateTypography(text: string): string {
// Step 3: Convert -- to em-dash (but preserve --- for markdown HR)
const protectedTriple = '\x00TRIPLE\x00';
result = result.replace(/---/g, protectedTriple);
result = result.replace(/--/g, EM_DASH);
result = result.replace(new RegExp(escapeRegex(protectedTriple), 'g'), '---');
result = result.replaceAll(/---/g, protectedTriple);
result = result.replaceAll(/--/g, EM_DASH);
result = result.replaceAll(new RegExp(escapeRegex(protectedTriple), 'g'), '---');
// Step 4: Restore HTML comments
for (let i = 0; i < protectedComments.length; i++) {
result = result.replace(`\x00COMMENT${i}\x00`, protectedComments[i]);
result = result.replaceAll(`\x00COMMENT${i}\x00`, protectedComments[i]);
}
// Step 4b: Fix closing quotes after em-dash introduced from --
result = result.replace(CLOSE_DOUBLE_AFTER_EM_DASH_RE, (_match, after) => {
result = result.replaceAll(CLOSE_DOUBLE_AFTER_EM_DASH_RE, (_match, after) => {
return `${EM_DASH}${CLOSE_DOUBLE}${after}`;
});
// Step 5: Convert double quotes
// Opening: after whitespace, start of line, or opening brackets
result = result.replace(OPEN_DOUBLE_RE, (_match, before) => {
result = result.replaceAll(OPEN_DOUBLE_RE, (_match, before) => {
return `${before}${OPEN_DOUBLE}`;
});
// Closing: all remaining straight double quotes
result = result.replace(/"/g, CLOSE_DOUBLE);
result = result.replaceAll(/"/g, CLOSE_DOUBLE);
// Step 6: Convert single quotes
// Opening: after whitespace, start of line, or opening brackets
result = result.replace(OPEN_SINGLE_RE, (_match, before) => {
result = result.replaceAll(OPEN_SINGLE_RE, (_match, before) => {
return `${before}${OPEN_SINGLE}`;
});
// Closing/apostrophe: all remaining straight single quotes
result = result.replace(/'/g, CLOSE_SINGLE);
result = result.replaceAll(/'/g, CLOSE_SINGLE);
return result;
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return str.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

View file

@ -4,9 +4,9 @@
* Ported from mcp-rust/src/merge.rs
*/
import * as fs from 'fs';
import * as path from 'path';
import * as cp from 'child_process';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as cp from 'node:child_process';
import { updateTypography } from './format';
// ─── Types ──────────────────────────────────────────────────────────────────
@ -14,6 +14,8 @@ import { updateTypography } from './format';
export interface LanguageConfig {
code: string;
folderName: string;
/** Optional per-language export title from settings.json languages[]. */
bookTitle?: string;
chapterWord: string;
actPrefix: string;
prologueLabel: string;
@ -118,9 +120,6 @@ const FIRST_H1_RE = /^(\s*)#\s+/m;
/** Matches heading line for image insertion */
const HEADING_LINE_RE = /^#[^\n]*\n/m;
/** Matches book title row in translation notes */
const BOOK_TITLE_RE = /^\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*Name of the book\s*\|\s*$/m;
// ─── UK Conversion (US → UK) ───────────────────────────────────────────────
export interface UkReplacement {
@ -209,7 +208,7 @@ function applyCasing(source: string, target: string): string {
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return value.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function getBuiltInUkReplacements(): UkReplacement[] {
@ -266,7 +265,7 @@ function convertUsToUkText(text: string, customReplacements: UkReplacement[] = [
continue;
}
const converted = line.replace(replacementData.pattern, (match) => {
const converted = line.replaceAll(replacementData.pattern, (match) => {
const replacement = replacementData.map.get(match.toLowerCase());
if (!replacement) {
return match;
@ -366,24 +365,24 @@ function parseActFolder(name: string): ActInfo | undefined {
function extractChapterNum(filename: string): number | undefined {
const m = CHAPTER_NUM_RE.exec(filename);
return m ? parseInt(m[1], 10) : undefined;
return m ? Number.parseInt(m[1], 10) : undefined;
}
function generateSlug(text: string): string {
return text
.toLowerCase()
.replace(SLUG_CLEAN_RE, '')
.replaceAll(SLUG_CLEAN_RE, '')
.trim()
.split(/\s+/)
.join('-');
}
function demoteH1ToH2(text: string): string {
return text.replace(FIRST_H1_RE, '$1## ');
return text.replaceAll(FIRST_H1_RE, '$1## ');
}
function collapseBlankLines(text: string): string {
return text.replace(BLANK_LINES_RE, '\n\n');
return text.replaceAll(BLANK_LINES_RE, '\n\n');
}
function formatActTitle(act: ActInfo, lang: LanguageConfig): string {
@ -577,7 +576,7 @@ function imageMarkdownFor(file: OrderedFile, options: MergeOptions): string | un
const imagePath = path.join(options.root, 'images', imageName);
if (!fs.existsSync(imagePath)) { return undefined; }
return `![](${imagePath.replace(/\\/g, '/')})\n\n`;
return `![](${imagePath.replaceAll(/\\/g, '/')})\n\n`;
}
function insertImageAfterHeading(content: string, imageMd: string): string {
@ -592,7 +591,7 @@ function insertImageAfterHeading(content: string, imageMd: string): string {
function coverMarkdown(options: MergeOptions): string | undefined {
const coverPath = path.join(options.root, options.storyFolder, options.language.folderName, 'cover.jpg');
if (!fs.existsSync(coverPath)) { return undefined; }
return `![](${coverPath.replace(/\\/g, '/')})\n\n`;
return `![](${coverPath.replaceAll(/\\/g, '/')})\n\n`;
}
function buildPandocContent(files: OrderedFile[], options: MergeOptions, outputType: OutputType): string {
@ -647,20 +646,12 @@ function buildPandocContent(files: OrderedFile[], options: MergeOptions, outputT
// ─── Pandoc Invocation ──────────────────────────────────────────────────────
function getBookTitle(root: string, lang: LanguageConfig): string {
const notesPath = path.join(root, 'Notes', 'Details_Translation_notes.md');
if (!fs.existsSync(notesPath)) { return 'Book'; }
function resolveBookTitle(options: MergeOptions): string {
const fromOptions = options.bookTitle?.trim();
if (fromOptions) { return fromOptions; }
try {
const content = fs.readFileSync(notesPath, 'utf-8');
const m = BOOK_TITLE_RE.exec(content);
if (m) {
const useEnglishTitle = lang.code.toUpperCase() === 'EN' || lang.code.toUpperCase() === 'UK';
const idx = useEnglishTitle ? 1 : 2;
const title = m[idx]?.trim();
if (title) { return title; }
}
} catch { /* ignore */ }
const fromLanguage = options.language.bookTitle?.trim();
if (fromLanguage) { return fromLanguage; }
return 'Book';
}
@ -874,8 +865,8 @@ export async function mergeBook(options: MergeOptions): Promise<MergeResult> {
await checkPandoc(options.pandocPath);
}
// Determine book title
const title = options.bookTitle || getBookTitle(options.root, options.language);
// Determine book title from settings-driven options only
const title = resolveBookTitle(options);
for (const outputType of options.outputTypes) {
const outputPath = path.join(outputDir, `${baseName}.${outputType}`);

View file

@ -8,8 +8,8 @@
* Machine-specific paths (pandoc, LibreOffice) always come from VS Code settings.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as fs from 'node:fs';
import * as path from 'node:path'
import type { LanguageConfig, DialectConfig, UkReplacement } from './merge';
export const BINDERY_FOLDER = '.bindery';

View file

@ -11,9 +11,9 @@
* without an extension host and complete quickly.
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as fs from 'node:fs';
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest';
// ─── Mock vscode ──────────────────────────────────────────────────────────────

View file

@ -1,6 +1,6 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as fs from 'node:fs';
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('vscode', () => {

View file

@ -28,9 +28,9 @@ vi.mock('vscode', () => ({
// ─── Imports ──────────────────────────────────────────────────────────────────
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as fs from 'node:fs';
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest';
import {
getBuiltInUkReplacements,

View file

@ -1,6 +1,6 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as fs from 'node:fs';
import * as os from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest';
import {