mirror of
https://github.com/evdboom/Bindery.git
synced 2026-07-22 06:49:36 +00:00
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:
parent
78d69fefc5
commit
a0d77832ac
25 changed files with 220 additions and 184 deletions
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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, '\\$&');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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." },
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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, '\\$&');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 `})\n\n`;
|
||||
return `})\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 `})\n\n`;
|
||||
return `})\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}`);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue