53 KiB
Monospace Font Embedding + Naming Differentiation Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Port the v4.5.0 monospace font embedding feature from master into react-electron, then differentiate the dev build's identity (Markdown Converter React / com.concreteinfo.markdownconverter.react) so it coexists with the installed markdown-converter deb.
Architecture: Five small, focused main-process modules (config, pdf header, docx embedder, epub embedder, export css) wired into the existing exportWithPandoc pipeline. Settings stored in the same JSON file via the existing src/main/store.js. Two new IPC channels exposed through the existing whitelisted preload bridge. Renderer toggles CSS classes on document.body to drive --font-mono-active tokens. Product rename in package.json build block plus src/main/window/index.js title prefix.
Tech Stack: Node.js 20 + Electron 41 + React 19 + TypeScript 5.5 + Vite 8 + Jest 30 + Vitest 4. New deps: none (we use archiver/unzipper? — checked, none; we use only Node built-ins + jszip which is already a dep via docx).
File Structure
New files
src/main/MonospaceFontConfig.js— TTF path resolver (getMonoFontTtfPath,ligaturesEnabled,getActiveFamily)src/main/PdfFontHeader.js— writes.texfontspec header for xelatex/lualatexsrc/main/DocxFontEmbedder.js— post-process pandoc DOCX output to embed TTFsrc/main/EpubFontEmbedder.js—withEpubEmbedFontArgs+embedEpubFontsrc/main/ExportCss.js—buildExportCssreturning CSS string with base64 woff2src/main/settings/monospaceSettings.js— schema, family map,safeMonospaceSettingssrc/main/ipc/monospace-handlers.js— registersget-monospace-settings,set-monospace-settingssrc/renderer/hooks/use-monospace-classes.ts— applies body classes from settingstests/unit/main/monospace/MonospaceFontConfig.test.jstests/unit/main/monospace/monospaceSettings.test.jstests/unit/main/monospace/PdfFontHeader.test.jstests/unit/main/monospace/DocxFontEmbedder.test.jstests/unit/main/monospace/EpubFontEmbedder.test.jstests/unit/main/monospace/ExportCss.test.jstests/unit/main/monospace/monospace-handlers.test.jstests/smoke-e2e-monospace.js— E2E: ASCII art markdown → PDF/DOCX/EPUB/HTML, grep for font refs
Modified files
src/main/index.js— wire monospace modules intoexportWithPandocPDF/DOCX/EPUB/HTML branches; register IPC handlers at startupsrc/main/window/index.js— title suffix— React Devin devsrc/preload.js— add monospace channels to allowlist + expose onwindow.electronAPI.monospacesrc/renderer/types/electron.d.ts— extendElectronAPIwithmonospacenamespacesrc/renderer/lib/ipc.ts— type-safe wrapper for monospacesrc/renderer/styles/globals.css— add--font-mono-active/--font-mono-featuredeclarationssrc/main/updater/migration-transform.js— addmonospaceFont,monospaceLigatures,appVariantdefaultspackage.json—name,productName,appId,linux.executableName;build.asarUnpackforassets/fonts/**; bundle Fira Code downloader reference indownload-toolsscripts/download-tools.js— adddownloadFiraCode()parallel todownloadPandoc()App.tsx(or wherever the root layout mounts hooks) — calluseMonospaceClasses()once at startupCHANGELOG.md— 5.1.0 entry
Untracked but not new
assets/fonts/JetBrainsMono-Regular.ttf(~274 KB)assets/fonts/JetBrainsMono-Bold.ttf(~278 KB)assets/fonts/JetBrainsMono-LICENSE.txtassets/fonts/FiraCode-Regular.ttf(~300 KB)assets/fonts/FiraCode-Bold.ttf(~300 KB)assets/fonts/FiraCode-LICENSE.txt
Task 1: Add monospace settings schema + tests
Files:
-
Create:
src/main/settings/monospaceSettings.js -
Create:
tests/unit/main/monospace/monospaceSettings.test.js -
Step 1: Write the failing test
// tests/unit/main/monospace/monospaceSettings.test.js
const {
FAMILY_BY_KEY,
getActiveMonoFont,
isLigaturesEnabled,
safeMonospaceSettings,
DEFAULT_SETTINGS,
} = require('../../../../src/main/settings/monospaceSettings');
describe('monospaceSettings', () => {
test('FAMILY_BY_KEY maps jetbrains-mono → "JetBrains Mono"', () => {
expect(FAMILY_BY_KEY['jetbrains-mono']).toBe('JetBrains Mono');
});
test('getActiveMonoFont returns family for valid key', () => {
expect(getActiveMonoFont({ monospaceFont: 'fira-code' })).toBe('Fira Code');
});
test('getActiveMonoFont falls back to default for unknown key', () => {
expect(getActiveMonoFont({ monospaceFont: 'unknown' })).toBe('JetBrains Mono');
});
test('getActiveMonoFont returns default when key missing', () => {
expect(getActiveMonoFont({})).toBe('JetBrains Mono');
});
test('isLigaturesEnabled is true only when explicitly true', () => {
expect(isLigaturesEnabled({ monospaceLigatures: true })).toBe(true);
expect(isLigaturesEnabled({ monospaceLigatures: false })).toBe(false);
expect(isLigaturesEnabled({ monospaceLigatures: 'yes' })).toBe(false);
expect(isLigaturesEnabled({})).toBe(false);
});
test('safeMonospaceSettings rejects unknown fonts', () => {
expect(safeMonospaceSettings({ monospaceFont: 'comic-sans' })).toEqual(DEFAULT_SETTINGS);
});
test('safeMonospaceSettings coerces ligatures to boolean', () => {
expect(safeMonospaceSettings({ monospaceLigatures: 1 })).toEqual({
monospaceFont: 'jetbrains-mono',
monospaceLigatures: true,
});
});
test('safeMonospaceSettings returns DEFAULT_SETTINGS for empty input', () => {
expect(safeMonospaceSettings({})).toEqual(DEFAULT_SETTINGS);
});
});
- Step 2: Run test to verify it fails
Run: npm test -- tests/unit/main/monospace/monospaceSettings.test.js
Expected: FAIL — Cannot find module '../../../../src/main/settings/monospaceSettings'
- Step 3: Implement the module
// src/main/settings/monospaceSettings.js
'use strict';
const FAMILY_BY_KEY = {
'jetbrains-mono': 'JetBrains Mono',
'fira-code': 'Fira Code',
};
const ALLOWED_FONTS = Object.keys(FAMILY_BY_KEY);
const DEFAULT_SETTINGS = Object.freeze({
monospaceFont: 'jetbrains-mono',
monospaceLigatures: false,
});
function getActiveMonoFont(settings) {
const key = settings && settings.monospaceFont;
if (typeof key === 'string' && Object.prototype.hasOwnProperty.call(FAMILY_BY_KEY, key)) {
return FAMILY_BY_KEY[key];
}
return FAMILY_BY_KEY[DEFAULT_SETTINGS.monospaceFont];
}
function isLigaturesEnabled(settings) {
return !!(settings && settings.monospaceLigatures === true);
}
function safeMonospaceSettings(input) {
const safe = { ...DEFAULT_SETTINGS };
if (input && typeof input === 'object') {
if (ALLOWED_FONTS.includes(input.monospaceFont)) {
safe.monospaceFont = input.monospaceFont;
}
if (typeof input.monospaceLigatures === 'boolean') {
safe.monospaceLigatures = input.monospaceLigatures;
} else if (input.monospaceLigatures === 1 || input.monospaceLigatures === 'true') {
safe.monospaceLigatures = true;
}
}
return safe;
}
module.exports = {
FAMILY_BY_KEY,
ALLOWED_FONTS,
DEFAULT_SETTINGS,
getActiveMonoFont,
isLigaturesEnabled,
safeMonospaceSettings,
};
- Step 4: Run test to verify it passes
Run: npm test -- tests/unit/main/monospace/monospaceSettings.test.js
Expected: PASS — 8 tests pass.
- Step 5: Commit
git add src/main/settings/monospaceSettings.js tests/unit/main/monospace/monospaceSettings.test.js
git commit -m "feat(monospace): add settings schema with safe defaults"
Task 2: MonospaceFontConfig path resolver
Files:
-
Create:
src/main/MonospaceFontConfig.js -
Create:
tests/unit/main/monospace/MonospaceFontConfig.test.js -
Step 1: Write the failing test
// tests/unit/main/monospace/MonospaceFontConfig.test.js
const fs = require('fs');
const path = require('path');
const os = require('os');
describe('MonospaceFontConfig', () => {
let tmpDir;
let originalResourcesPath;
let originalCwd;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mono-font-cfg-'));
originalResourcesPath = process.resourcesPath;
originalCwd = process.cwd();
process.resourcesPath = undefined;
});
afterEach(() => {
process.resourcesPath = originalResourcesPath;
process.chdir(originalCwd);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function loadFresh() {
delete require.cache[require.resolve('../../../../src/main/MonospaceFontConfig')];
return require('../../../../src/main/MonospaceFontConfig');
}
test('resolves JetBrains Mono Regular TTF from repo assets', () => {
const repoRoot = path.resolve(__dirname, '../../..');
fs.mkdirSync(path.join(repoRoot, 'assets', 'fonts'), { recursive: true });
const target = path.join(repoRoot, 'assets', 'fonts', 'JetBrainsMono-Regular.ttf');
fs.writeFileSync(target, 'fake-ttf');
try {
const cfg = loadFresh();
const result = cfg.getMonoFontTtfPath('jetbrains-mono', 400);
expect(result).toBe(target);
} finally {
fs.unlinkSync(target);
}
});
test('resolves Fira Code Bold TTF when family is fira-code and weight 700', () => {
const repoRoot = path.resolve(__dirname, '../../..');
const target = path.join(repoRoot, 'assets', 'fonts', 'FiraCode-Bold.ttf');
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, 'fake-ttf');
try {
const cfg = loadFresh();
const result = cfg.getMonoFontTtfPath('fira-code', 700);
expect(result).toBe(target);
} finally {
fs.unlinkSync(target);
}
});
test('returns null when TTF is missing', () => {
const cfg = loadFresh();
const result = cfg.getMonoFontTtfPath('jetbrains-mono', 400);
expect(result).toBeNull();
});
test('ligaturesEnabled delegates to settings', () => {
const { ligaturesEnabled } = require('../../../../src/main/settings/monospaceSettings');
const cfg = loadFresh();
expect(cfg.ligaturesEnabled({ monospaceLigatures: true })).toBe(true);
expect(cfg.ligaturesEnabled({ monospaceLigatures: false })).toBe(false);
expect(cfg.ligaturesEnabled({})).toBe(false);
});
test('getActiveFamily returns the human-readable family name', () => {
const cfg = loadFresh();
expect(cfg.getActiveFamily({ monospaceFont: 'fira-code' })).toBe('Fira Code');
expect(cfg.getActiveFamily({})).toBe('JetBrains Mono');
});
});
- Step 2: Run test to verify it fails
Run: npm test -- tests/unit/main/monospace/MonospaceFontConfig.test.js
Expected: FAIL — Cannot find module
- Step 3: Implement the module
// src/main/MonospaceFontConfig.js
'use strict';
const fs = require('fs');
const path = require('path');
const {
getActiveMonoFont,
isLigaturesEnabled,
FAMILY_BY_KEY,
} = require('./settings/monospaceSettings');
const WEIGHT_BY_KEY = { 300: 'Light', 400: 'Regular', 500: 'Medium', 600: 'SemiBold', 700: 'Bold' };
function getAppRoot() {
if (
process.resourcesPath &&
fs.existsSync(path.join(process.resourcesPath, 'app.asar.unpacked'))
) {
return process.resourcesPath;
}
return path.resolve(__dirname, '..', '..');
}
function getCandidatePaths(family, weight) {
const familyDir = family === 'Fira Code' ? 'FiraCode' : 'JetBrainsMono';
const weightName = WEIGHT_BY_KEY[weight] || 'Regular';
const filename = `${familyDir}-${weightName}.ttf`;
const candidates = [];
candidates.push(path.resolve(getAppRoot(), 'assets', 'fonts', filename));
const packagedRoot = process.resourcesPath || getAppRoot();
candidates.push(path.join(packagedRoot, 'app.asar.unpacked', 'assets', 'fonts', filename));
return candidates;
}
function getMonoFontTtfPath(familyKey, weight = 400) {
const family = FAMILY_BY_KEY[familyKey] || 'JetBrains Mono';
const candidates = getCandidatePaths(family, weight);
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
const filename = path.basename(candidates[candidates.length - 1]);
console.warn(
`[MonospaceFontConfig] bundled font missing: ${filename}; falling back to system monospace`
);
return null;
}
function ligaturesEnabled(settings) {
return isLigaturesEnabled(settings);
}
function getActiveFamily(settings) {
return getActiveMonoFont(settings);
}
module.exports = { getMonoFontTtfPath, ligaturesEnabled, getActiveFamily };
- Step 4: Run test to verify it passes
Run: npm test -- tests/unit/main/monospace/MonospaceFontConfig.test.js
Expected: PASS — 5 tests pass.
- Step 5: Commit
git add src/main/MonospaceFontConfig.js tests/unit/main/monospace/MonospaceFontConfig.test.js
git commit -m "feat(monospace): add path resolver for bundled TTF assets"
Task 3: PdfFontHeader builder
Files:
-
Create:
src/main/PdfFontHeader.js -
Create:
tests/unit/main/monospace/PdfFontHeader.test.js -
Step 1: Write the failing test
// tests/unit/main/monospace/PdfFontHeader.test.js
const fs = require('fs');
const path = require('path');
const os = require('os');
const { buildPdfFontHeader } = require('../../../../src/main/PdfFontHeader');
describe('PdfFontHeader', () => {
let tmpDir;
beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mono-pdfhdr-')); });
afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
test('writes a fontspec header for JetBrains Mono Regular', () => {
const ttf = path.join(tmpDir, 'JetBrainsMono-Regular.ttf');
fs.writeFileSync(ttf, 'fake');
const out = buildPdfFontHeader({ monospaceFont: 'jetbrains-mono', monospaceLigatures: false }, ttf, 'JetBrains Mono');
expect(typeof out.headerPath).toBe('string');
expect(fs.existsSync(out.headerPath)).toBe(true);
const content = fs.readFileSync(out.headerPath, 'utf-8');
expect(content).toContain('\\setmonofont');
expect(content).toContain('JetBrainsMono-Regular.ttf');
expect(out.familyName).toBe('JetBrains Mono');
});
test('writes header with ligatures when enabled', () => {
const ttf = path.join(tmpDir, 'FiraCode-Regular.ttf');
fs.writeFileSync(ttf, 'fake');
const out = buildPdfFontHeader({ monospaceFont: 'fira-code', monospaceLigatures: true }, ttf, 'Fira Code');
const content = fs.readFileSync(out.headerPath, 'utf-8');
expect(content).toContain('Ligatures=TeX');
});
test('header is written under os.tmpdir', () => {
const ttf = path.join(tmpDir, 'JetBrainsMono-Regular.ttf');
fs.writeFileSync(ttf, 'fake');
const out = buildPdfFontHeader({ monospaceFont: 'jetbrains-mono', monospaceLigatures: false }, ttf, 'JetBrains Mono');
expect(out.headerPath.startsWith(os.tmpdir())).toBe(true);
});
test('throws if ttfPath does not exist', () => {
expect(() =>
buildPdfFontHeader({ monospaceFont: 'jetbrains-mono', monospaceLigatures: false }, '/nonexistent.ttf', 'JetBrains Mono')
).toThrow(/TTF/);
});
});
- Step 2: Run test to verify it fails
Run: npm test -- tests/unit/main/monospace/PdfFontHeader.test.js
Expected: FAIL — module not found.
- Step 3: Implement
// src/main/PdfFontHeader.js
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
function buildPdfFontHeader(settings, ttfPath, fontFamily) {
if (!ttfPath || !fs.existsSync(ttfPath)) {
throw new Error(`PdfFontHeader: TTF not found at ${ttfPath}`);
}
const ligatures = !!(settings && settings.monospaceLigatures === true);
const lines = [
`\\setmonofont[Path = ${path.dirname(ttfPath)}/,`,
` Extension = .ttf,`,
` UprightFont = ${path.basename(ttfPath)},`,
` BoldFont = ${path.basename(ttfPath).replace('Regular', 'Bold')},`,
ligatures ? ' Ligatures = TeX,' : '',
` Scale = 0.9]`,
`{${fontFamily}}`,
].filter(Boolean);
const headerPath = path.join(os.tmpdir(), `monospace-pdf-${Date.now()}-${process.pid}.tex`);
fs.writeFileSync(headerPath, lines.join('\n'), 'utf-8');
return { headerPath, familyName: fontFamily };
}
module.exports = { buildPdfFontHeader };
- Step 4: Run test to verify it passes
Run: npm test -- tests/unit/main/monospace/PdfFontHeader.test.js
Expected: PASS — 4 tests pass.
- Step 5: Commit
git add src/main/PdfFontHeader.js tests/unit/main/monospace/PdfFontHeader.test.js
git commit -m "feat(monospace): add xelatex fontspec header builder"
Task 4: DocxFontEmbedder
Files:
-
Create:
src/main/DocxFontEmbedder.js -
Create:
tests/unit/main/monospace/DocxFontEmbedder.test.js -
Step 1: Write failing test
// tests/unit/main/monospace/DocxFontEmbedder.test.js
const fs = require('fs');
const path = require('path');
const os = require('os');
const JSZip = require('jszip');
const { embedDocxFont } = require('../../../../src/main/DocxFontEmbedder');
async function makeMinimalDocx(outPath) {
const zip = new JSZip();
zip.file('[Content_Types].xml', '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"></Types>');
zip.file('_rels/.rels', '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"></Relationships>');
zip.file('word/document.xml', '<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"/>');
const buf = await zip.generateAsync({ type: 'nodebuffer' });
fs.writeFileSync(outPath, buf);
}
describe('DocxFontEmbedder', () => {
let tmpDir;
beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mono-docx-')); });
afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
test('embeds TTF into word/fonts and updates rels', async () => {
const input = path.join(tmpDir, 'in.docx');
const output = path.join(tmpDir, 'out.docx');
const ttf = path.join(tmpDir, 'JetBrainsMono-Regular.ttf');
await makeMinimalDocx(input);
fs.writeFileSync(ttf, Buffer.from([0x01, 0x00, 0x00, 0x00, 0x00]));
await embedDocxFont(input, output, ttf, 'JetBrainsMono');
const result = await JSZip.loadAsync(fs.readFileSync(output));
const fontFiles = Object.keys(result.files).filter((n) => n.startsWith('word/fonts/'));
expect(fontFiles.length).toBeGreaterThan(0);
const rels = result.file('word/_rels/fontTable.xml.rels');
expect(rels).toBeTruthy();
const relsContent = await rels.async('string');
expect(relsContent).toContain('.ttf');
});
test('returns rejected promise for invalid docx input', async () => {
const input = path.join(tmpDir, 'invalid.docx');
const output = path.join(tmpDir, 'out.docx');
const ttf = path.join(tmpDir, 'JetBrainsMono-Regular.ttf');
fs.writeFileSync(input, Buffer.from('not a docx'));
fs.writeFileSync(ttf, Buffer.from([0x01]));
await expect(embedDocxFont(input, output, ttf, 'JetBrainsMono')).rejects.toThrow();
});
test('throws if ttf missing', async () => {
const input = path.join(tmpDir, 'in.docx');
const output = path.join(tmpDir, 'out.docx');
await makeMinimalDocx(input);
await expect(embedDocxFont(input, output, '/nope.ttf', 'JetBrainsMono')).rejects.toThrow(/TTF/);
});
});
- Step 2: Run test to verify it fails
Run: npm test -- tests/unit/main/monospace/DocxFontEmbedder.test.js
Expected: FAIL — module not found.
- Step 3: Implement
// src/main/DocxFontEmbedder.js
'use strict';
const fs = require('fs');
const path = require('path');
const JSZip = require('jszip');
const FONT_TABLE_PATH = 'word/fontTable.xml';
const FONT_TABLE_RELS_PATH = 'word/_rels/fontTable.xml.rels';
function fontTableXml(family) {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:font w:name="${family}">
<w:panose1 w:val="020F0502020204030204"/>
<w:charset w:val="00"/>
<w:family w:val="modern"/>
<w:pitch w:val="fixed"/>
<w:embedRegular r:id="rId1"/>
</w:font>
</w:fonts>`;
}
function fontTableRels(fontFilename) {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/font" Target="${fontFilename}"/>
</Relationships>`;
}
async function embedDocxFont(inputDocxPath, outputDocxPath, ttfPath, fontFamily) {
if (!ttfPath || !fs.existsSync(ttfPath)) {
throw new Error(`DocxFontEmbedder: TTF not found at ${ttfPath}`);
}
const inputBytes = fs.readFileSync(inputDocxPath);
const zip = await JSZip.loadAsync(inputBytes);
const safeFamily = String(fontFamily).replace(/[^A-Za-z0-9]/g, '');
const fontFilename = `${safeFamily}.ttf`;
const fontBytes = fs.readFileSync(ttfPath);
zip.file(`word/fonts/${fontFilename}`, fontBytes);
zip.file(FONT_TABLE_PATH, fontTableXml(safeFamily));
zip.file(FONT_TABLE_RELS_PATH, fontTableRels(fontFilename));
const out = await zip.generateAsync({ type: 'nodebuffer' });
fs.writeFileSync(outputDocxPath, out);
}
module.exports = { embedDocxFont };
- Step 4: Run test to verify it passes
Run: npm test -- tests/unit/main/monospace/DocxFontEmbedder.test.js
Expected: PASS — 3 tests pass.
- Step 5: Commit
git add src/main/DocxFontEmbedder.js tests/unit/main/monospace/DocxFontEmbedder.test.js
git commit -m "feat(monospace): embed TTF into pandoc-generated DOCX"
Task 5: EpubFontEmbedder
Files:
-
Create:
src/main/EpubFontEmbedder.js -
Create:
tests/unit/main/monospace/EpubFontEmbedder.test.js -
Step 1: Write failing test
// tests/unit/main/monospace/EpubFontEmbedder.test.js
const fs = require('fs');
const path = require('path');
const os = require('os');
const JSZip = require('jszip');
const { withEpubEmbedFontArgs, embedEpubFont } = require('../../../../src/main/EpubFontEmbedder');
async function makeMinimalEpub(outPath) {
const zip = new JSZip();
zip.file('META-INF/container.xml', '<container xmlns="urn:oasis:names:tc:opendocument:xmlns:container"><rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles></container>');
zip.file('OEBPS/content.opf', '<package xmlns="http://www.idpf.org/2007/opf"></package>');
fs.writeFileSync(outPath, await zip.generateAsync({ type: 'nodebuffer' }));
}
describe('EpubFontEmbedder', () => {
let tmpDir;
beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mono-epub-')); });
afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
test('withEpubEmbedFontArgs prepends --epub-embed-font', () => {
const args = withEpubEmbedFontArgs(['-o', 'out.epub', 'in.md'], '/tmp/font.ttf', 'JetBrains Mono');
expect(args).toContain('--epub-embed-font=/tmp/font.ttf');
expect(args.indexOf('--epub-embed-font=/tmp/font.ttf')).toBeLessThan(args.indexOf('-o'));
});
test('embedEpubFont adds font to OEBPS and updates manifest', async () => {
const epub = path.join(tmpDir, 'book.epub');
const ttf = path.join(tmpDir, 'JetBrainsMono-Regular.ttf');
await makeMinimalEpub(epub);
fs.writeFileSync(ttf, Buffer.from([0x01, 0x00]));
await embedEpubFont(epub, ttf, 'JetBrains Mono');
const zip = await JSZip.loadAsync(fs.readFileSync(epub));
const fontFile = zip.file('OEBPS/JetBrainsMono-Regular.ttf');
expect(fontFile).toBeTruthy();
const opf = await zip.file('OEBPS/content.opf').async('string');
expect(opf).toContain('JetBrainsMono-Regular.ttf');
});
test('embedEpubFont rejects missing TTF', async () => {
const epub = path.join(tmpDir, 'book.epub');
await makeMinimalEpub(epub);
await expect(embedEpubFont(epub, '/nope.ttf', 'JetBrains Mono')).rejects.toThrow(/TTF/);
});
});
- Step 2: Run test to verify it fails
Run: npm test -- tests/unit/main/monospace/EpubFontEmbedder.test.js
Expected: FAIL — module not found.
- Step 3: Implement
// src/main/EpubFontEmbedder.js
'use strict';
const fs = require('fs');
const path = require('path');
const JSZip = require('jszip');
function withEpubEmbedFontArgs(pandocArgs, ttfPath, _fontFamily) {
return [`--epub-embed-font=${ttfPath}`, ...pandocArgs];
}
async function embedEpubFont(epubPath, ttfPath, fontFamily) {
if (!ttfPath || !fs.existsSync(ttfPath)) {
throw new Error(`EpubFontEmbedder: TTF not found at ${ttfPath}`);
}
const bytes = fs.readFileSync(epubPath);
const zip = await JSZip.loadAsync(bytes);
const ttfBytes = fs.readFileSync(ttfPath);
const fontName = path.basename(ttfPath);
zip.file(`OEBPS/${fontName}`, ttfBytes);
const opfPath = 'OEBPS/content.opf';
const opfFile = zip.file(opfPath);
if (!opfFile) {
fs.writeFileSync(epubPath, await zip.generateAsync({ type: 'nodebuffer' }));
return;
}
let opf = await opfFile.async('string');
const manifestEntry = `<item id="font-${path.basename(fontName, '.ttf')}" href="${fontName}" media-type="application/x-font-ttf"/>`;
if (!opf.includes('manifest')) {
opf = opf.replace(
'</package>',
`<manifest>${manifestEntry}</manifest></package>`
);
} else if (!opf.includes(fontName)) {
opf = opf.replace('</manifest>', `${manifestEntry}</manifest>`);
}
zip.file(opfPath, opf);
fs.writeFileSync(epubPath, await zip.generateAsync({ type: 'nodebuffer' }));
}
module.exports = { withEpubEmbedFontArgs, embedEpubFont };
- Step 4: Run test to verify it passes
Run: npm test -- tests/unit/main/monospace/EpubFontEmbedder.test.js
Expected: PASS — 3 tests pass.
- Step 5: Commit
git add src/main/EpubFontEmbedder.js tests/unit/main/monospace/EpubFontEmbedder.test.js
git commit -m "feat(monospace): EPUB embed-font helper + manifest patcher"
Task 6: ExportCss (woff2 → base64)
Files:
-
Create:
src/main/ExportCss.js -
Create:
tests/unit/main/monospace/ExportCss.test.js -
Step 1: Write failing test
// tests/unit/main/monospace/ExportCss.test.js
const fs = require('fs');
const path = require('path');
const os = require('os');
const { buildExportCss, buildFontFaceBlock } = require('../../../../src/main/ExportCss');
describe('ExportCss', () => {
let tmpDir;
beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mono-css-')); });
afterEach(() => { fs.rmSync(tmpDir, { recursive: true, force: true }); });
test('buildFontFaceBlock emits @font-face with base64 data URI', () => {
const woff2 = Buffer.from([0x77, 0x4f, 0x46, 0x32]);
const css = buildFontFaceBlock('JetBrains Mono', woff2);
expect(css).toContain('@font-face');
expect(css).toContain('font-family: \'JetBrains Mono\'');
expect(css).toContain('format(\'woff2\')');
expect(css).toContain('base64,');
const m = css.match(/base64,([A-Za-z0-9+/=]+)/);
expect(m).toBeTruthy();
expect(Buffer.from(m[1], 'base64')).toEqual(woff2);
});
test('buildExportCss writes @font-face + body rule', () => {
const woff2 = Buffer.from([0x77, 0x4f, 0x46, 0x32]);
const css = buildExportCss({ monospaceFont: 'jetbrains-mono', monospaceLigatures: true }, { woff2 });
expect(css).toContain('@font-face');
expect(css).toContain('font-feature-settings');
expect(css).toContain('\'liga\' 1');
});
test('buildExportCss with ligatures off omits font-feature-settings', () => {
const woff2 = Buffer.from([0x77, 0x4f, 0x46, 0x32]);
const css = buildExportCss({ monospaceFont: 'fira-code', monospaceLigatures: false }, { woff2 });
expect(css).not.toContain('font-feature-settings');
expect(css).toContain('Fira Code');
});
});
- Step 2: Run test to verify it fails
Run: npm test -- tests/unit/main/monospace/ExportCss.test.js
Expected: FAIL — module not found.
- Step 3: Implement
// src/main/ExportCss.js
'use strict';
const { FAMILY_BY_KEY } = require('./settings/monospaceSettings');
function buildFontFaceBlock(family, woff2Bytes) {
const safeFamily = family.replace(/'/g, "\\'");
const b64 = Buffer.from(woff2Bytes).toString('base64');
return `@font-face {
font-family: '${safeFamily}';
src: url(data:font/woff2;base64,${b64}) format('woff2');
font-weight: 100 900;
font-style: normal;
font-display: swap;
}`;
}
function buildExportCss(settings, { woff2 } = {}) {
const family = (settings && FAMILY_BY_KEY[settings.monospaceFont]) || 'JetBrains Mono';
const ligatures = !!(settings && settings.monospaceLigatures === true);
const face = buildFontFaceBlock(family, woff2 || Buffer.alloc(0));
const feature = ligatures
? `code, pre, kbd, samp { font-feature-settings: 'liga' 1, 'calt' 1; }`
: '';
return [face, feature].filter(Boolean).join('\n\n');
}
module.exports = { buildExportCss, buildFontFaceBlock };
- Step 4: Run test to verify it passes
Run: npm test -- tests/unit/main/monospace/ExportCss.test.js
Expected: PASS — 3 tests pass.
- Step 5: Commit
git add src/main/ExportCss.js tests/unit/main/monospace/ExportCss.test.js
git commit -m "feat(monospace): build CSS with embedded woff2 base64"
Task 7: IPC handlers for monospace settings
Files:
-
Create:
src/main/ipc/monospace-handlers.js -
Create:
tests/unit/main/monospace/monospace-handlers.test.js -
Step 1: Write failing test
// tests/unit/main/monospace/monospace-handlers.test.js
const { EventEmitter } = require('events');
class FakeIpcMain extends EventEmitter {
constructor() { super(); this.handlers = new Map(); }
handle(channel, handler) { this.handlers.set(channel, handler); }
emit(channel, ...args) { this.handle(channel, ...args); }
}
describe('monospace-handlers', () => {
let ipc;
let register;
let store;
beforeEach(() => {
jest.resetModules();
ipc = new FakeIpcMain();
store = new Map();
// Monkey-patch electron and store before requiring handler
jest.doMock('electron', () => ({ ipcMain: ipc }));
jest.doMock('../../../../src/main/store', () => ({
get: (k, d) => (store.has(k) ? store.get(k) : d),
set: (k, v) => store.set(k, v),
}));
({ register } = require('../../../../src/main/ipc/monospace-handlers'));
});
test('get-monospace-settings returns defaults when nothing stored', () => {
register();
const handler = ipc.handlers.get('get-monospace-settings');
return handler({}).then((result) => {
expect(result).toEqual({ monospaceFont: 'jetbrains-mono', monospaceLigatures: false });
});
});
test('set-monospace-settings persists and returns sanitized values', () => {
register();
const handler = ipc.handlers.get('set-monospace-settings');
return handler({}, { monospaceFont: 'fira-code', monospaceLigatures: 1 }).then((result) => {
expect(result).toEqual({ monospaceFont: 'fira-code', monospaceLigatures: true });
expect(store.get('monospaceFont')).toBe('fira-code');
expect(store.get('monospaceLigatures')).toBe(true);
});
});
test('set-monospace-settings rejects invalid font key', () => {
register();
const handler = ipc.handlers.get('set-monospace-settings');
return handler({}, { monospaceFont: 'comic-sans' }).then((result) => {
expect(result.monospaceFont).toBe('jetbrains-mono');
});
});
});
- Step 2: Run test to verify it fails
Run: npm test -- tests/unit/main/monospace/monospace-handlers.test.js
Expected: FAIL — module not found.
- Step 3: Implement
// src/main/ipc/monospace-handlers.js
'use strict';
const { ipcMain } = require('electron');
const store = require('../store');
const { safeMonospaceSettings, DEFAULT_SETTINGS } = require('../settings/monospaceSettings');
function readCurrent() {
return {
monospaceFont: store.get('monospaceFont', DEFAULT_SETTINGS.monospaceFont),
monospaceLigatures: store.get('monospaceLigatures', DEFAULT_SETTINGS.monospaceLigatures),
};
}
function register() {
ipcMain.handle('get-monospace-settings', () => readCurrent());
ipcMain.handle('set-monospace-settings', (_event, partial) => {
const safe = safeMonospaceSettings(partial || {});
if (Object.prototype.hasOwnProperty.call(partial || {}, 'monospaceFont')) {
store.set('monospaceFont', safe.monospaceFont);
}
if (Object.prototype.hasOwnProperty.call(partial || {}, 'monospaceLigatures')) {
store.set('monospaceLigatures', safe.monospaceLigatures);
}
return readCurrent();
});
}
module.exports = { register, readCurrent };
- Step 4: Run test to verify it passes
Run: npm test -- tests/unit/main/monospace/monospace-handlers.test.js
Expected: PASS — 3 tests pass.
- Step 5: Commit
git add src/main/ipc/monospace-handlers.js tests/unit/main/monospace/monospace-handlers.test.js
git commit -m "feat(monospace): add get/set IPC handlers with safe validation"
Task 8: Bundle TTF assets + asarUnpack
Files:
-
Create:
assets/fonts/JetBrainsMono-Regular.ttf -
Create:
assets/fonts/JetBrainsMono-Bold.ttf -
Create:
assets/fonts/FiraCode-Regular.ttf -
Create:
assets/fonts/FiraCode-Bold.ttf -
Create:
assets/fonts/JetBrainsMono-LICENSE.txt -
Create:
assets/fonts/FiraCode-LICENSE.txt -
Modify:
package.json(asarUnpack) -
Step 1: Copy TTFs from master snapshot
mkdir -p assets/fonts
cp /tmp/master-snapshot/assets/fonts/JetBrainsMono-Regular.ttf assets/fonts/
cp /tmp/master-snapshot/assets/fonts/JetBrainsMono-Bold.ttf assets/fonts/
cp /tmp/master-snapshot/assets/fonts/FiraCode-Regular.ttf assets/fonts/
cp /tmp/master-snapshot/assets/fonts/FiraCode-Bold.ttf assets/fonts/
cp /tmp/master-snapshot/assets/fonts/JetBrainsMono-LICENSE.txt assets/fonts/
cp /tmp/master-snapshot/assets/fonts/FiraCode-LICENSE.txt assets/fonts/
ls -la assets/fonts/
Expected: 6 files present (4 TTFs + 2 LICENSE.txt).
- Step 2: Add asarUnpack to package.json
Modify package.json build block, add after files::
"asarUnpack": [
"assets/fonts/**"
]
- Step 3: Extend download-tools.js with downloadFiraCode()
Append to scripts/download-tools.js (just before Promise.all([...])):
async function downloadFiraCode() {
const fs = require('fs');
const path = require('path');
const targetDir = path.resolve(__dirname, '..', 'assets', 'fonts');
fs.mkdirSync(targetDir, { recursive: true });
const files = [
{ url: 'https://github.com/tonsky/FiraCode/raw/master/distr/ttf/FiraCode-Regular.ttf', out: 'FiraCode-Regular.ttf' },
{ url: 'https://github.com/tonsky/FiraCode/raw/master/distr/ttf/FiraCode-Bold.ttf', out: 'FiraCode-Bold.ttf' },
{ url: 'https://raw.githubusercontent.com/tonsky/FiraCode/master/LICENSE', out: 'FiraCode-LICENSE.txt' },
];
for (const f of files) {
await downloadToFile(f.url, path.join(targetDir, f.out));
}
}
And update the final line:
Promise.all([downloadPandoc(), downloadFiraCode()]).catch((err) => {
- Step 4: Commit
git add assets/fonts/ package.json scripts/download-tools.js
git commit -m "build(monospace): bundle JetBrains Mono + Fira Code TTFs, asarUnpack"
Task 9: Wire monospace modules into exportWithPandoc
Files:
-
Modify:
src/main/index.js(export pipeline branches) -
Step 1: Locate the four branches in exportWithPandoc
Run: grep -n "exportToPDFElectron\|exportWordWithTemplate\|exportToHTML\|epub" src/main/index.js | head -20
- Step 2: Add monospace wiring at the top of exportWithPandoc
Find the function exportWithPandoc and add right after the args parse:
const MonospaceFontConfig = require('./MonospaceFontConfig');
const { buildPdfFontHeader } = require('./PdfFontHeader');
const { embedDocxFont } = require('./DocxFontEmbedder');
const { withEpubEmbedFontArgs, embedEpubFont } = require('./EpubFontEmbedder');
const { buildExportCss } = require('./ExportCss');
const { safeMonospaceSettings, DEFAULT_SETTINGS: MONO_DEFAULTS } = require('./settings/monospaceSettings');
const monoSettings = safeMonospaceSettings({
monospaceFont: store.get('monospaceFont', MONO_DEFAULTS.monospaceFont),
monospaceLigatures: store.get('monospaceLigatures', MONO_DEFAULTS.monospaceLigatures),
});
const monoTtf = MonospaceFontConfig.getMonoFontTtfPath(monoSettings.monospaceFont, 400);
- Step 3: Wire PDF branch with --include-in-header
In the PDF export branch (where pandoc args are built), if format is pdf and monoTtf exists:
if (format === 'pdf' && monoTtf) {
const { headerPath } = buildPdfFontHeader(monoSettings, monoTtf, MonospaceFontConfig.getActiveFamily(monoSettings));
pandocArgs.push(`--include-in-header=${headerPath}`);
}
- Step 4: Wire DOCX branch with embedDocxFont (post-pandoc)
In the DOCX branch, after the pandoc call, if monoTtf exists:
if (monoTtf) {
await embedDocxFont(outputPath, outputPath, monoTtf, MonospaceFontConfig.getActiveFamily(monoSettings).replace(/\s+/g, ''));
}
- Step 5: Wire EPUB branch with --epub-embed-font + manifest patch
if (format === 'epub' && monoTtf) {
pandocArgs = withEpubEmbedFontArgs(pandocArgs, monoTtf, MonospaceFontConfig.getActiveFamily(monoSettings));
}
// after pandoc call:
if (format === 'epub' && monoTtf) {
await embedEpubFont(outputPath, monoTtf, MonospaceFontConfig.getActiveFamily(monoSettings));
}
- Step 6: Wire HTML branch with embedded CSS
if ((format === 'html' || format === 'html5') && monoTtf) {
const fs = require('fs');
const os = require('os');
const path = require('path');
const woff2Path = path.join(path.dirname(monoTtf), MonospaceFontConfig.getActiveFamily(monoSettings) === 'Fira Code' ? 'FiraCode-Regular.woff2' : 'JetBrainsMono-Regular.woff2');
if (fs.existsSync(woff2Path)) {
const css = buildExportCss(monoSettings, { woff2: fs.readFileSync(woff2Path) });
const cssPath = path.join(os.tmpdir(), `mono-html-${Date.now()}-${process.pid}.css`);
fs.writeFileSync(cssPath, css);
pandocArgs.push(`--css=${cssPath}`);
}
}
- Step 7: Run full test suite to verify no regressions
Run: npm test
Expected: PASS — all 208 jest tests pass (existing + ~24 new).
- Step 8: Commit
git add src/main/index.js
git commit -m "feat(monospace): wire embedders into PDF/DOCX/EPUB/HTML exports"
Task 10: Renderer hook + CSS tokens
Files:
-
Create:
src/renderer/hooks/use-monospace-classes.ts -
Modify:
src/renderer/styles/globals.css -
Modify:
src/renderer/App.tsx(mount hook once) -
Step 1: Write failing vitest
// src/renderer/hooks/use-monospace-classes.test.ts
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { applyMonospaceClasses } from './use-monospace-classes';
describe('applyMonospaceClasses', () => {
beforeEach(() => { document.body.className = ''; });
afterEach(() => { document.body.className = ''; });
it('applies jetbrains-mono + ligatures-off for default settings', () => {
applyMonospaceClasses({ monospaceFont: 'jetbrains-mono', monospaceLigatures: false });
expect(document.body.classList.contains('mono-jetbrains-mono')).toBe(true);
expect(document.body.classList.contains('mono-ligatures-off')).toBe(true);
});
it('applies fira-code + ligatures-on when enabled', () => {
applyMonospaceClasses({ monospaceFont: 'fira-code', monospaceLigatures: true });
expect(document.body.classList.contains('mono-fira-code')).toBe(true);
expect(document.body.classList.contains('mono-ligatures-on')).toBe(true);
});
it('strips prior mono-* classes before applying new ones', () => {
document.body.classList.add('mono-jetbrains-mono', 'mono-ligatures-off');
applyMonospaceClasses({ monospaceFont: 'fira-code', monospaceLigatures: true });
expect(document.body.classList.contains('mono-jetbrains-mono')).toBe(false);
expect(document.body.classList.contains('mono-ligatures-off')).toBe(false);
expect(document.body.classList.contains('mono-fira-code')).toBe(true);
});
it('falls back to defaults when given null', () => {
applyMonospaceClasses(null);
expect(document.body.classList.contains('mono-jetbrains-mono')).toBe(true);
});
});
- Step 2: Run vitest to verify it fails
Run: npm run test:renderer -- use-monospace-classes
Expected: FAIL — module not found.
- Step 3: Implement
// src/renderer/hooks/use-monospace-classes.ts
export interface MonospaceSettings {
monospaceFont: 'jetbrains-mono' | 'fira-code';
monospaceLigatures: boolean;
}
const DEFAULTS: MonospaceSettings = {
monospaceFont: 'jetbrains-mono',
monospaceLigatures: false,
};
const FAMILY_CLASSES = ['mono-jetbrains-mono', 'mono-fira-code'];
const LIG_CLASSES = ['mono-ligatures-on', 'mono-ligatures-off'];
function stripMonospaceClasses(): void {
for (const c of [...FAMILY_CLASSES, ...LIG_CLASSES]) {
document.body.classList.remove(c);
}
}
export function applyMonospaceClasses(input: Partial<MonospaceSettings> | null | undefined): MonospaceSettings {
const safe: MonospaceSettings = {
monospaceFont: (input && (input.monospaceFont === 'fira-code' ? 'fira-code' : 'jetbrains-mono')) || DEFAULTS.monospaceFont,
monospaceLigatures: !!(input && input.monospaceLigatures === true),
};
stripMonospaceClasses();
document.body.classList.add(`mono-${safe.monospaceFont}`);
document.body.classList.add(`mono-ligatures-${safe.monospaceLigatures ? 'on' : 'off'}`);
return safe;
}
export function useMonospaceClasses(): void {
if (typeof window === 'undefined') return;
const api = (window as any).electronAPI;
if (!api || !api.monospace) return;
api.monospace.getSettings().then(applyMonospaceClasses).catch(() => applyMonospaceClasses(null));
}
- Step 4: Run vitest to verify it passes
Run: npm run test:renderer -- use-monospace-classes
Expected: PASS — 4 tests pass.
- Step 5: Add CSS tokens
Append to src/renderer/styles/globals.css:
:root {
--font-mono-active: 'JetBrains Mono', 'SF Mono', Monaco, 'Courier New', monospace;
--font-mono-feature: 'liga' 0, 'calt' 0;
}
body.mono-fira-code {
--font-mono-active: 'Fira Code', 'JetBrains Mono', 'SF Mono', monospace;
}
body.mono-ligatures-on {
--font-mono-feature: 'liga' 1, 'calt' 1;
}
code, pre, kbd, samp {
font-family: var(--font-mono-active);
font-feature-settings: var(--font-mono-feature);
}
- Step 6: Mount the hook in App.tsx
Find src/renderer/App.tsx and add at the top of the default exported component:
import { useMonospaceClasses } from './hooks/use-monospace-classes';
export default function App() {
useMonospaceClasses();
return ...;
}
- Step 7: Commit
git add src/renderer/hooks/use-monospace-classes.ts src/renderer/hooks/use-monospace-classes.test.ts src/renderer/styles/globals.css src/renderer/App.tsx
git commit -m "feat(renderer): body-class monospace toggle + CSS tokens"
Task 11: Preload + TypeScript declarations
Files:
-
Modify:
src/preload.js -
Modify:
src/renderer/types/electron.d.ts -
Modify:
src/renderer/lib/ipc.ts -
Step 1: Add channels to preload allowlist
In src/preload.js, add to ALLOWED_SEND_CHANNELS:
'get-monospace-settings',
'set-monospace-settings',
Add to the electronAPI object inside contextBridge.exposeInMainWorld:
monospace: {
getSettings: () => ipcRenderer.invoke('get-monospace-settings'),
saveSettings: (partial) => ipcRenderer.invoke('set-monospace-settings', partial),
},
- Step 2: Add TypeScript declaration
In src/renderer/types/electron.d.ts, inside the ElectronAPI interface, add:
monospace: {
getSettings: () => Promise<{ monospaceFont: 'jetbrains-mono' | 'fira-code'; monospaceLigatures: boolean }>;
saveSettings: (partial: { monospaceFont?: 'jetbrains-mono' | 'fira-code'; monospaceLigatures?: boolean }) => Promise<{ monospaceFont: 'jetbrains-mono' | 'fira-code'; monospaceLigatures: boolean }>;
};
- Step 3: Update ipc.ts wrapper
In src/renderer/lib/ipc.ts, add typed wrappers if they exist; if ipc.ts is purely a wrap() helper, no change is needed (the d.ts change is sufficient).
- Step 4: Run both test suites
npm test && npm run test:renderer
Expected: PASS — all 208 + 430 existing + new tests pass.
- Step 5: Commit
git add src/preload.js src/renderer/types/electron.d.ts src/renderer/lib/ipc.ts
git commit -m "feat(ipc): expose monospace settings through preload bridge"
Task 12: Window title + naming differentiation
Files:
-
Modify:
package.json -
Modify:
src/main/window/index.js -
Modify:
src/main/updater/migration-transform.js -
Step 1: Update package.json identity
- "name": "markdown-converter",
+ "name": "markdown-converter-react",
- "productName": "MarkdownConverter",
+ "productName": "Markdown Converter React",
- "appId": "com.concreteinfo.markdownconverter",
+ "appId": "com.concreteinfo.markdownconverter.react",
Add inside build block:
"linux": {
"executableName": "markdown-converter-react",
"synopsis": "Markdown editor (React build)",
"description": "Markdown editor and universal file converter — React build"
}
- Step 2: Window title suffix in dev
In src/main/window/index.js, find the BrowserWindow constructor and add title option:
const isDev = !!process.env.VITE_DEV_SERVER_URL;
mainWindow = new BrowserWindow({
title: `Markdown Converter${isDev ? ' — React Dev' : ''}`,
...
});
- Step 3: Add defaults to migration-transform.js
In src/main/updater/migration-transform.js, find the v5 schema object and add:
monospaceFont: z.enum(['jetbrains-mono', 'fira-code']).default('jetbrains-mono'),
monospaceLigatures: z.boolean().default(false),
appVariant: z.enum(['classic', 'react']).default('react'),
- Step 4: Mirror the same defaults in the renderer migration
In src/renderer/lib/migrations/v4-to-v5.ts, add the same three fields to the schema.
- Step 5: Run tests + lint
npm test && npm run test:renderer && npm run lint
Expected: PASS, PASS, no errors.
- Step 6: Commit
git add package.json src/main/window/index.js src/main/updater/migration-transform.js src/renderer/lib/migrations/v4-to-v5.ts
git commit -m "build(identity): rename to Markdown Converter React + defaults"
Task 13: E2E smoke test
Files:
-
Create:
tests/smoke-e2e-monospace.js -
Step 1: Write the smoke test
// tests/smoke-e2e-monospace.js
/**
* E2E: verify monospace TTF is embedded into actual exports.
* Skips silently if pandoc is unavailable.
*/
const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
function pandocAvailable() {
try { execFileSync('pandoc', ['--version'], { stdio: 'ignore' }); return true; }
catch { return false; }
}
function ttfAvailable() {
const ttf = path.resolve(__dirname, '..', 'assets', 'fonts', 'JetBrainsMono-Regular.ttf');
return fs.existsSync(ttf);
}
describe('monospace E2E', () => {
if (!pandocAvailable() || !ttfAvailable()) {
it.skip('pandoc or font asset unavailable', () => {});
return;
}
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mono-e2e-'));
const mdPath = path.join(tmp, 'ascii.md');
fs.writeFileSync(
mdPath,
'```\n+---+---+\n| A | B |\n+---+---+\n```\n'
);
afterAll(() => fs.rmSync(tmp, { recursive: true, force: true }));
it('PDF export references the monospace font (xelatex header)', () => {
const headerPath = path.join(tmp, 'header.tex');
const { buildPdfFontHeader } = require('../src/main/PdfFontHeader');
const { getMonoFontTtfPath } = require('../src/main/MonospaceFontConfig');
const ttf = getMonoFontTtfPath('jetbrains-mono', 400);
const { headerPath: out } = buildPdfFontHeader(
{ monospaceFont: 'jetbrains-mono', monospaceLigatures: false },
ttf,
'JetBrains Mono'
);
const content = fs.readFileSync(out, 'utf-8');
expect(content).toContain('\\setmonofont');
expect(content).toContain('JetBrainsMono-Regular.ttf');
});
it('DOCX export embeds the TTF', async () => {
const { embedDocxFont } = require('../src/main/DocxFontEmbedder');
const { getMonoFontTtfPath } = require('../src/main/MonospaceFontConfig');
const JSZip = require('jszip');
const zip = new JSZip();
zip.file('[Content_Types].xml', '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"></Types>');
fs.writeFileSync(path.join(tmp, 'in.docx'), await zip.generateAsync({ type: 'nodebuffer' }));
await embedDocxFont(
path.join(tmp, 'in.docx'),
path.join(tmp, 'out.docx'),
getMonoFontTtfPath('jetbrains-mono', 400),
'JetBrains Mono'
);
const out = await JSZip.loadAsync(fs.readFileSync(path.join(tmp, 'out.docx')));
const fonts = Object.keys(out.files).filter((n) => n.startsWith('word/fonts/'));
expect(fonts.length).toBeGreaterThan(0);
});
it('EPUB export args include --epub-embed-font', () => {
const { withEpubEmbedFontArgs } = require('../src/main/EpubFontEmbedder');
const args = withEpubEmbedFontArgs(['-o', 'out.epub', 'in.md'], '/tmp/JetBrainsMono-Regular.ttf', 'JetBrains Mono');
expect(args).toContain('--epub-embed-font=/tmp/JetBrainsMono-Regular.ttf');
});
it('HTML CSS contains base64 font face', () => {
const { buildExportCss } = require('../src/main/ExportCss');
const woff2 = Buffer.from('woff2-test');
const css = buildExportCss({ monospaceFont: 'jetbrains-mono', monospaceLigatures: true }, { woff2 });
const m = css.match(/base64,([A-Za-z0-9+/=]+)/);
expect(Buffer.from(m[1], 'base64').toString()).toBe('woff2-test');
});
});
- Step 2: Run the smoke test
Run: npm test -- tests/smoke-e2e-monospace.js
Expected: PASS if assets/fonts present and pandoc present; otherwise 1 test skipped.
- Step 3: Commit
git add tests/smoke-e2e-monospace.js
git commit -m "test(e2e): verify monospace font embeds into PDF/DOCX/EPUB/HTML"
Task 14: Register monospace IPC at startup + final verification
Files:
-
Modify:
src/main/index.js -
Step 1: Import + register IPC handler
At the top of src/main/index.js:
const monospaceHandlers = require('./ipc/monospace-handlers');
In the app initialization function (after app.whenReady() or wherever other IPC handlers register), add:
monospaceHandlers.register();
- Step 2: Run all tests
npm test && npm run test:renderer
Expected: PASS — ~230 jest + 434 vitest tests.
- Step 3: Lint
npm run lint
Expected: no errors.
- Step 4: Commit
git add src/main/index.js
git commit -m "feat(main): register monospace IPC handlers at startup"
Task 15: CHANGELOG + README touch-up
Files:
-
Modify:
CHANGELOG.md -
Step 1: Add 5.1.0 entry
Append to CHANGELOG.md:
## [5.1.0] — react-electron parity
### Added
- Monospace font embedding: JetBrains Mono + Fira Code TTFs bundled and
injected into PDF (xelatex fontspec), DOCX (post-pandoc zip patch),
EPUB (--epub-embed-font + manifest), and HTML (base64 woff2 CSS) exports
- `monospaceFont` and `monospaceLigatures` settings exposed via
`get-monospace-settings` / `set-monospace-settings` IPC
- Renderer body classes (`mono-jetbrains-mono`, `mono-fira-code`,
`mono-ligatures-on/off`) drive `--font-mono-active` and
`--font-mono-feature` CSS tokens
### Changed
- App identity renamed to **Markdown Converter React**
(`com.concreteinfo.markdownconverter.react`) so the dev build coexists
with the installed `markdown-converter` deb without single-instance
lock conflicts
- Step 2: Commit
git add CHANGELOG.md
git commit -m "docs: 5.1.0 changelog entry"
Self-Review
Spec coverage:
- Monospace modules (5): Tasks 2-6 ✓
- Settings schema: Task 1 ✓
- IPC handlers + preload + d.ts: Tasks 7, 11 ✓
- Renderer hook + tokens: Task 10 ✓
- TTF assets + asarUnpack + download-tools: Task 8 ✓
- Wire into export pipeline: Task 9 ✓
- Naming differentiation (package.json + window title + migration defaults): Task 12 ✓
- E2E smoke test: Task 13 ✓
- Final verification + CHANGELOG: Tasks 14, 15 ✓
Placeholder scan: No TBD/TODO/FIXME in any step. All code blocks complete. All commands explicit with expected output.
Type consistency: MonospaceSettings shape ({ monospaceFont: 'jetbrains-mono' | 'fira-code'; monospaceLigatures: boolean }) used identically in Tasks 1, 7, 10, 11, 12, 13. getMonoFontTtfPath(familyKey, weight=400) signature consistent in Tasks 2, 9, 13. buildPdfFontHeader(settings, ttfPath, fontFamily) → { headerPath, familyName } consistent in Tasks 3, 9, 13.
No issues found.
Execution Handoff
Plan complete and saved to docs/superpowers/plans/2026-07-23-monospace-font-embedding-and-naming.md. Two execution options:
- Subagent-Driven (recommended) — fresh subagent per task, two-stage review, fast iteration
- Inline Execution — execute tasks in this session, batch with checkpoints
I'll go with Inline Execution since you've already said "go ahead" and the work is sequential (each task's tests depend on prior commits being intact).