Compare commits

..
Author SHA1 Message Date
amitwh f63a678c88 fix(security): strengthen ReDoS denylist in search-in-files
Follow-up to the push-sweep review: the previous UNSAFE_REGEX used
JS bitwise OR (|) between regex literals, accidentally OR-ing the
RegExp objects instead of combining alternatives into one pattern.

Replace with a single RegExp that catches:
- nested quantifiers (a+)+, [a-z]*+
- class + quantifier
- dot-quantifier followed by dot-quantifier
- lookahead / lookbehind (?=, ?!)
- backrefs \1..\9
- alternation + quantifier

Also add MAX_REGEX_LENGTH=200 hard cap. The denylist is still
defense-in-depth — the length, files-traversed, and per-file-byte caps
are the primary defense.
2026-07-23 09:39:08 +05:30
amitwh 5e76d77ece fix(security): harden search-in-files against DoS + path traversal
Address findings from automated security review:

1. Validate rootPath via validatePath/isPathAccessible before recursing,
   matching the pattern used by read-file/write-file.
2. Cap query length at 1024 chars to bound regex compilation cost.
3. Reject regexes matching classic ReDoS shapes (nested quantifiers
   and similar) before invoking RegExp.
4. Use realpathSync to resolve symlinks and verify containment under
   rootPath; drop symlinks that point outside the search root.
5. Cap total files traversed at 10000, in addition to existing
   1000-result and 2MB-per-file caps.

Add regression tests for each guard.
2026-07-23 09:34:57 +05:30
amitwh f43e34ca2b fix(ipc): re-type wrapper signatures to match handler return shapes
Many IPC handlers were returning useful data but the wrappers typed
'void', silently dropping the response. Re-type all affected wrappers:

- file.write: returns { path } (resolved after security validation)
- file.delete/ensureDir/exists/isDirectory/copy/move: full wrappers
  (previously only on window.electronAPI.file.*)
- crash.read: typed as Array<{...}> so callers can trust the shape
- crash.delete: returns boolean
- gitStage: returns { files, error? } so callers can detect failure
- gitCommit: returns { summary } | { error }
- updater.check/getState: returns { state }
2026-07-23 09:34:57 +05:30
amitwh 6da2ba7cc7 fix(ipc): crash modal reads wrapper, search wired to real handler
CrashReportModal crashed on dumps.map because ipc.crash.read returns
{ ok, data } and the consumer was assigning the wrapper to state. Now
unwraps .data and falls back to [] on error.

ipc.file.search was miswired to ipc.file.pickFile — clicking 'Search'
popped a file picker instead of searching. Add real 'search-in-files'
handler in src/main/files/search-in-files.js with regex support,
case-sensitivity toggle, .git/node_modules/dist skip, 2MB file cap, and
1000-result limit. Wire preload bridge + TS declaration + allowlist.

Also:
- Drop dead ipc.file.open wrapper that was miswired to pickFile
- Rename FileEntry field 'modified' to 'modifiedAt' (number) so the
  IPC payload matches the declared type
- Update CrashReportModal tests to use the safeCall envelope shape
2026-07-23 09:34:57 +05:30
amitwh f1f8a16a79 fix: list-directory returns flat array, drop defensive fallback
The list-directory handler returned { path, entries } while the
renderer typed result.data as FileEntry[]; the file-store had to fall
back to raw.entries via Array.isArray. Extract the entries builder into
src/main/files/list-directory.js so it can be unit-tested, return a
plain array, and skip entries that can't be stat'd (broken symlinks,
permission errors) instead of throwing.

Also tighten jest config so dist/*.snap electron-builder artifacts are
not matched as test suites.
2026-07-23 09:34:57 +05:30
amitwh 772a791d9a fix(git): git-status IPC returns a flat array, not wrapped object
GitOperations.getStatus returns { files: [...] } but the renderer's
ipc.file.gitStatus type declares Array<...> and calls result.data.map().
The mismatch made GitStatusPanel.tsx throw 'n.map is not a function'
on mount, which blanked the entire React tree.

Unwrap result.files in the IPC handler so it returns the array the
renderer expects. Add tests covering the success, empty, and non-git
branches so this regression cannot recur.

Refs: blank-screen-on-md-open
2026-07-23 09:34:57 +05:30
amitwh ec3d53ea7e chore: lint cleanup + 5.1.0 changelog entry
- Drop unused imports/vars (withEpubEmbedFontArgs, monoPdfHeaderDir, _e)
- Append CHANGELOG entry summarising the parity work and the
  Markdown Converter React rename
2026-07-23 09:34:57 +05:30
amitwh 50c6369348 test(e2e): verify monospace font embeds into PDF/DOCX/EPUB/HTML 2026-07-23 09:34:57 +05:30
amitwh 81d38160d8 build(identity): rename to Markdown Converter React + defaults
- package.json: name -> markdown-converter-react; productName -> Markdown
  Converter React; appId -> com.concreteinfo.markdownconverter.react;
  linux.executableName -> markdown-converter-react
- Window title: 'Markdown Converter — React Dev' in dev mode
- Migration defaults: monospaceFont, monospaceLigatures, appVariant
- Bumped fields in v5OnlyFields so v4->v5 detection covers new keys
2026-07-23 09:34:57 +05:30
amitwh 28ef350f39 feat(ipc): expose monospace settings through preload bridge
- Add 'get-monospace-settings' + 'set-monospace-settings' to allowlist
- Expose electronAPI.monospace.{getSettings,saveSettings}
- Extend ElectronAPI TypeScript interface with the monospace namespace
2026-07-23 09:34:57 +05:30
amitwh d29f19b1f5 feat(renderer): body-class monospace toggle + CSS tokens
- useMonospaceClasses hook calls electronAPI.monospace.getSettings()
  and toggles body.mono-{jetbrains-mono,fira-code} and
  body.mono-ligatures-{on,off} classes
- Add --font-mono-active and --font-mono-feature tokens in globals.css
- Apply to code/pre/kbd/samp so all code rendering picks up the active
  monospace font + ligature settings
2026-07-23 09:34:57 +05:30
amitwh b2ad8b8326 fix(security): address supply-chain + resource-leak findings
- PdfFontHeader: use mkdtempSync for exclusive temp dir; caller unlinks
  after pandoc consumes (cleanup wired into exportWithPandoc callback)
- download-tools: pin FiraCode to immutable release v6.2 with SHA-256
  digests verified before atomic rename; refuse download on mismatch
- Vendor missing FiraCode-Bold.ttf + JetBrainsMono-Regular.ttf
2026-07-23 09:34:57 +05:30
amitwh d2f3118bfe feat(main): wire monospace embedders into PDF/DOCX/EPUB/HTML exports
- Add getActiveMonospaceContext() helper that reads settings + resolves TTF
- PDF: build fontspec header and pass --include-in-header to xelatex
- DOCX: post-process zip to embed TTF and add fontTable.xml + rels
- EPUB: prepend --epub-embed-font + patch manifest
- HTML: embed woff2 base64 into built-in marked exporter CSS
- Register get-monospace-settings / set-monospace-settings IPC at startup
2026-07-23 09:34:57 +05:30
amitwh 4cb38cd861 build(monospace): bundle JetBrains Mono + Fira Code TTFs, asarUnpack
- Copy TTFs and LICENSE files from master v4.5.0
- Add assets/fonts/** to electron-builder files + asarUnpack
- Extend download-tools.js with downloadFiraCode() parallel to downloadPandoc()
2026-07-23 09:34:57 +05:30
amitwh 65dfdfb307 feat(monospace): add get/set IPC handlers with safe validation 2026-07-23 09:34:57 +05:30
amitwh f1c3aaa0ef feat(monospace): build CSS with embedded woff2 base64 2026-07-23 09:34:57 +05:30
amitwh 0c37a8ca2d feat(monospace): EPUB embed-font helper + manifest patcher 2026-07-23 09:34:57 +05:30
amitwh 992c6b72d6 feat(monospace): embed TTF into pandoc-generated DOCX 2026-07-23 09:34:57 +05:30
amitwh 001c9463e3 feat(monospace): add xelatex fontspec header builder 2026-07-23 09:34:57 +05:30
amitwh 3602bc35a7 feat(monospace): add path resolver for bundled TTF assets 2026-07-23 09:34:57 +05:30
amitwh 14b5a38a5a feat(monospace): add settings schema with safe defaults 2026-07-23 09:34:57 +05:30
amitwh 33a61e65ef docs(plan): monospace font embedding + naming implementation plan 2026-07-23 09:34:57 +05:30
amitwh 857fd8a75a docs(spec): monospace font embedding + naming differentiation design
Port master's v4.5.0 monospace font embedding feature (JetBrains Mono +
Fira Code TTF assets, embedded into PDF/DOCX/EPUB/HTML exports) and
rename branch identity to Markdown Converter React so dev build coexists
with the installed markdown-converter deb without appId/productName
collisions.

Amit Haridas
2026-07-23 09:34:57 +05:30
amitwh b8d26c8d78 chore(repo-map): add auto-generated structural map
Generated with ~/.claude-shared/scripts/repo-map.sh (universal-ctags).
Signatures-only map of classes/functions/methods/interfaces/enums.

Amit Haridas
2026-07-18 07:23:30 +05:30
50 changed files with 6667 additions and 60 deletions
+15
View File
@@ -50,3 +50,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- 9 dead IPC channels from `src/preload.js`: `toggle-command-palette`, `print-preview`, `print-preview-styled`, `open-ascii-generator`, `open-table-generator`, `show-ascii-generator`, `show-ascii-generator-window`, `show-table-generator`, `show-table-generator-window` - 9 dead IPC channels from `src/preload.js`: `toggle-command-palette`, `print-preview`, `print-preview-styled`, `open-ascii-generator`, `open-table-generator`, `show-ascii-generator`, `show-ascii-generator-window`, `show-table-generator`, `show-table-generator-window`
[5.0.0]: https://github.com/amitwh/markdown-converter/releases/tag/v5.0.0 [5.0.0]: https://github.com/amitwh/markdown-converter/releases/tag/v5.0.0
## [5.1.0] - 2026-07-23 - react-electron parity
### Added
- **Monospace font embedding** — bundles JetBrains Mono + Fira Code TTFs and embeds the active font into PDF (xelatex fontspec), DOCX (post-pandoc zip patch with `fontTable.xml`), EPUB (`--epub-embed-font` + manifest), and HTML (woff2 base64 in CSS) exports. ASCII art and code blocks now render with the exact same font across machines.
- **Monospace settings** — `get-monospace-settings` / `set-monospace-settings` IPC, with `monospaceFont` (`jetbrains-mono` / `fira-code`) and `monospaceLigatures` (boolean).
- **Renderer body-class toggle** — `useMonospaceClasses` hook toggles `mono-jetbrains-mono` / `mono-fira-code` and `mono-ligatures-on` / `mono-ligatures-off` on `document.body`, driving `--font-mono-active` and `--font-mono-feature` CSS tokens.
### Changed
- **App renamed to Markdown Converter React** (`com.concreteinfo.markdownconverter.react`, npm name `markdown-converter-react`, deb `markdown-converter-react_*_amd64.deb`) so the dev build coexists with the installed `markdown-converter` deb without single-instance lock conflicts.
- Window title shows `Markdown Converter — React Dev` in dev mode.
- `download-tools.js` pins Fira Code to release `6.2` with SHA-256 digests verified before atomic rename; downloads refuse to start on digest mismatch.
### Security
- `PdfFontHeader` uses `fs.mkdtempSync` for an exclusive temp directory; the caller unlinks it after pandoc consumes the header (no racy `Date.now()+pid` filenames).
File diff suppressed because one or more lines are too long
+93
View File
@@ -0,0 +1,93 @@
Copyright (c) 2014, The Fira Code Project Authors (https://github.com/tonsky/FiraCode)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
File diff suppressed because one or more lines are too long
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,258 @@
# Monospace Font Embedding + Naming Differentiation
**Date:** 2026-07-22
**Branch:** react-electron
**Goal:** Bring `react-electron` to feature parity with `master` by porting the v4.5.0 "embedded monospace font" feature, and differentiate the dev build identity from the installed `markdown-converter` deb so the two can coexist on the same machine.
---
## Context
The `react-electron` branch is the active development branch — a major rewrite of the renderer in React 19 + TypeScript + Tailwind/shadcn while keeping the main process in CommonJS. As of commit `bb4e874` it is **ahead** of `master` in nearly every dimension (auto-updater, crash writer, migration runner, command palette, first-run wizard, updates settings, crash report modal, 30+ React modals, large-file mode, scoped CSS).
A repo-wide diff (`git diff --stat master..react-electron`) shows 301 files changed, +38,293/-26,786 lines. Master is **behind** react-electron.
The **only** feature present in `master` and absent in `react-electron` is the monospace-font-embedding feature added in master v4.5.0 (commits `3f8679c` through `b218c76`, 19 commits). It bundles JetBrains Mono + Fira Code TTFs and embeds them into PDF/DOCX/EPUB/HTML exports so ASCII art and code blocks render with the exact font the user sees in the preview.
A second concern: the user has `markdown-converter` installed as a `.deb`. Running `npm run dev` from this repo currently uses the same `appId` (`com.concreteinfo.markdownconverter`) and `productName` (`MarkdownConverter`), which triggers Electron's single-instance lock and confuses file-association routing. We rename this branch's identity to `Markdown Converter React` / `markdown-converter-react` to coexist.
---
## Scope
### In scope
1. Port the monospace font embedding feature from `master` v4.5.0 into the react-electron branch:
- All five `src/main/*.js` modules (`MonospaceFontConfig`, `PdfFontHeader`, `DocxFontEmbedder`, `EpubFontEmbedder`, `ExportCss`)
- Settings schema module (`src/main/settings/monospaceSettings.js`)
- Two new IPC handlers (`get-monospace-settings`, `set-monospace-settings`) plus preload allowlist and TypeScript declaration
- Renderer body-class toggle and CSS tokens (`--font-mono-active`, `--font-mono-feature`)
- Bundled font assets: `JetBrainsMono-{Regular,Bold}.ttf`, `FiraCode-{Regular,Bold}.ttf` plus LICENSE files
- `asarUnpack` directive for `assets/fonts/**`
- `download-tools.js` extension to fetch Fira Code
- Wire `buildMonospaceExportCss`, `buildPdfFontHeader`, `DocxFontEmbedder.embed`, `EpubFontEmbedder.embed` into `src/main/index.js` export pipelines (PDF, DOCX, EPUB, HTML paths)
- Print-preview font integration (`src/renderer/components/preview/PrintPreviewFrame.tsx` or equivalent)
- Tests: one test file per new module mirroring master's coverage plus an E2E smoke test
2. Rename branch identity:
- `package.json` `name``markdown-converter-react`
- `package.json` `build.productName``Markdown Converter React`
- `package.json` `build.appId``com.concreteinfo.markdownconverter.react`
- `package.json` `build.linux.executableName``markdown-converter-react`
- Window title prefix → `Markdown Converter — React Dev` in dev mode (use `process.env.VITE_DEV_SERVER_URL` as the gate)
- Settings file path remains `userData/settings.json` (no migration needed) — but add a new key `appVariant: "react"` so the renderer can show the correct title
### Out of scope
- React renderer rewrites (already ahead of master)
- Auto-updater, crash, migration (already ahead of master)
- Tauri / Web migration
- Pandoc version bump
- Documentation beyond this spec, the implementation plan, and the changelog entry
- Changes to v4-to-v5 settings migration (we leave the existing runner alone)
---
## Design
### Module layout (new files)
```
src/main/
MonospaceFontConfig.js # path resolver — finds bundled TTF
PdfFontHeader.js # builds .tex fontspec header for xelatex/lualatex
DocxFontEmbedder.js # injects TTF into pandoc DOCX output (post-process)
EpubFontEmbedder.js # EPUB --epub-embed-font + manifest patch
ExportCss.js # builds CSS string with woff2 embedded as base64
settings/
monospaceSettings.js # schema, defaults, family map
ipc/
monospace-handlers.js # IPC handler registration
utils/
(existing) # no change
src/renderer/
hooks/
use-monospace-classes.ts # toggles body.mono-{jetbrains,fira}{,-ligatures} classes
components/preview/
PrintPreviewFrame.tsx # wire print preview to monospace settings via IPC
styles/globals.css # add --font-mono-active/--font-mono-feature declarations
types/electron.d.ts # extend ElectronAPI with monospace.{getSettings,saveSettings}
src/preload.js # add monospace channels to ALLOWED_SEND_CHANNELS + expose on window.electronAPI.monospace
assets/fonts/
JetBrainsMono-Regular.ttf # bundled, ~270 KB
JetBrainsMono-Bold.ttf # bundled, ~278 KB
JetBrainsMono-LICENSE.txt
FiraCode-Regular.ttf # bundled, ~300 KB
FiraCode-Bold.ttf # bundled, ~300 KB
FiraCode-LICENSE.txt
scripts/download-tools.js # extend to fetch Fira Code
tests/
unit/main/monospace/
MonospaceFontConfig.test.js
monospaceSettings.test.js
PdfFontHeader.test.js
DocxFontEmbedder.test.js
EpubFontEmbedder.test.js
ExportCss.test.js
monospace-handlers.test.js
smoke-e2e-monospace.js # e2e: convert ASCII-art Markdown → PDF/DOCX/EPUB/HTML, assert TTF references appear in output
```
### Key interfaces (TypeScript-style for clarity)
```ts
// src/main/settings/monospaceSettings.js
type MonoFont = 'jetbrains-mono' | 'fira-code';
const FAMILY_BY_KEY: Record<MonoFont, string> = {
'jetbrains-mono': 'JetBrains Mono',
'fira-code': 'Fira Code',
};
function getActiveMonoFont(settings): string;
function isLigaturesEnabled(settings): boolean;
function safeMonospaceSettings(input): { monospaceFont: MonoFont; monospaceLigatures: boolean };
// src/main/MonospaceFontConfig.js
function getMonoFontTtfPath(familyKey, weight = 400): string | null; // null if asset missing
function ligaturesEnabled(settings): boolean;
function getActiveFamily(settings): string;
// src/main/PdfFontHeader.js
function buildPdfFontHeader(settings, ttfPath, fontFamily): { headerPath: string; familyName: string };
// src/main/DocxFontEmbedder.js
async function embedDocxFont(inputDocxPath, outputDocxPath, ttfPath, fontFamily): Promise<void>;
async function buildDocxWithEmbeddedFont(pandocArgs, settings): Promise<{args, postProcess}>;
// src/main/EpubFontEmbedder.js
async function embedEpubFont(epubPath, ttfPath, fontFamily): Promise<void>;
function withEpubEmbedFontArgs(pandocArgs, ttfPath, fontFamily): string[];
// src/main/ExportCss.js
function buildExportCss(settings, { woff2Base64, family, ligatures }): string;
function buildFontFaceBlock(familyKey, woff2Base64): string;
// src/main/ipc/monospace-handlers.js
function register({ getMainWindow }): void;
// ipcMain.handle('get-monospace-settings', () => ({ monospaceFont, monospaceLigatures }))
// ipcMain.handle('set-monospace-settings', (_e, partial) => safeMonospaceSettings(partial) → persist + broadcast)
```
### Renderer integration
- `use-monospace-classes.ts` hook runs once on mount, calls `electronAPI.monospace.getSettings()`, applies `document.body.classList` of:
- `mono-jetbrains-mono` | `mono-fira-code`
- `mono-ligatures-on` | `mono-ligatures-off`
- CSS in `globals.css`:
```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; }
```
- All existing `font-family: monospace` and `font-family: var(--font-mono)` usages continue to work; the new tokens sit above them.
### Wiring into export pipelines
In `src/main/index.js` `exportWithPandoc` (PDF branch):
- Read `monospaceFont` + `monospaceLigatures` from settings
- Resolve TTF path via `MonospaceFontConfig.getMonoFontTtfPath`
- If path exists: build font header via `PdfFontHeader.buildPdfFontHeader`, pass `--include-in-header=…`
- If path missing: log warning, fall back to existing `-V monofont=Consolas` behavior
In DOCX branch: invoke `DocxFontEmbedder.embedDocxFont` post-pandoc.
In EPUB branch: prepend `--epub-embed-font=…` to pandoc args; call `EpubFontEmbedder.embedEpubFont` to patch manifest.
In HTML branch: write CSS via `ExportCss.buildExportCss` (embed woff2 as base64) and reference in `<style>`.
### Naming differentiation
`package.json` changes:
```diff
- "name": "markdown-converter"
+ "name": "markdown-converter-react"
- "productName": "MarkdownConverter"
+ "productName": "Markdown Converter React"
- "appId": "com.concreteinfo.markdownconverter"
+ "appId": "com.concreteinfo.markdownconverter.react"
+ "linux": {
+ "executableName": "markdown-converter-react",
+ "synopsis": "Markdown editor (React build)",
+ "description": "Markdown editor and universal file converter — React build"
+ }
```
`src/main/window/index.js`:
```js
const isDev = !!process.env.VITE_DEV_SERVER_URL;
const titleSuffix = isDev ? ' — React Dev' : '';
mainWindow = new BrowserWindow({
title: `Markdown Converter${titleSuffix}`,
...
});
```
### Settings impact
Add two keys to `migration-transform.js` v5 schema with safe defaults:
- `monospaceFont: z.enum(['jetbrains-mono', 'fira-code']).default('jetbrains-mono')`
- `monospaceLigatures: z.boolean().default(false)`
- `appVariant: z.enum(['classic', 'react']).default('react')`
Existing user settings remain valid; the migration just fills in the new defaults.
---
## Testing strategy (TDD)
For each new module: red-green-refactor.
1. `MonospaceFontConfig.test.js` — uses tmpdir + mocked `process.resourcesPath` to assert resolution from `assets/fonts/JetBrainsMono-Regular.ttf`. Covers missing-file fallback.
2. `monospaceSettings.test.js` — schema validation: rejects unknown fonts, coerces ligatures boolean, fills defaults.
3. `PdfFontHeader.test.js` — writes a temp `.tex` file, asserts `\setmonofont{JetBrainsMono-Regular.ttf}[...]` lines match.
4. `DocxFontEmbedder.test.js` — given a minimal DOCX zip, asserts `word/fonts/` contains the TTF and `word/_rels/fontTable.xml.rels` references it.
5. `EpubFontEmbedder.test.js` — asserts `META-INF/container.xml` and `content.opf` updated after `--epub-embed-font`.
6. `ExportCss.test.js` — base64 round-trip: `atob(css.match(/base64,([A-Za-z0-9+/=]+)/)[1])` returns the original woff2 bytes.
7. `monospace-handlers.test.js` — invokes the registered handlers against `ipcMain` mock; asserts settings persistence + broadcast.
8. `smoke-e2e-monospace.js` — runs `pandoc` end-to-end against a fixture markdown with ASCII art; greps output for font references.
Coverage threshold maintained: jest at 15%, vitest at v8 + renderer lines.
---
## Risks
- **TTF download license** — Fira Code is OFL-1.1; JetBrains Mono is OFL-1.1. Both allow redistribution; LICENSE files ship alongside TTFs. No risk.
- **Pandoc version sensitivity** — `--epub-embed-font` requires pandoc ≥ 2.19. The download-tools script pins to 3.9.0.2; check `pandocVersion` and gracefully fall back if older.
- **asarUnpack size** — adding ~1.2 MB of TTFs to packaged builds. Acceptable; matches master.
- **Dev/prod appId split** — if a user installs both `markdown-converter` and `markdown-converter-react` debs, their settings files are separate (`userData/com.concreteinfo.markdownconverter.react/settings.json`). Documented in README.
- **Test environment** — the e2e smoke test requires pandoc available locally. We'll `test.skip` if `which pandoc` fails.
---
## Definition of done
- [ ] All planned steps implemented, not just the first/easiest ones
- [ ] No forbidden markers (`TODO`, `FIXME`, `XXX`, `HACK`, `not implemented`, `placeholder`, `stub`, `coming soon`) in changed source
- [ ] New test suites pass; existing 638 tests still pass
- [ ] `npm run build:linux` produces a `.deb` artifact named `markdown-converter-react_*_amd64.deb`
- [ ] App launches in dev mode with title `Markdown Converter — React Dev`
- [ ] E2E smoke test confirms TTF references in PDF/DOCX/EPUB/HTML output
- [ ] CHANGELOG.md updated with a "5.1.0 — react-electron parity" entry
- [ ] No unrelated refactors; every changed line traces to the request
---
## Learning collaboration
Per the project's learning-mode preference, the user will be asked to contribute two 5-10 line decisions during implementation:
1. **Default monospace family** in `monospaceSettings.js` (`jetbrains-mono` vs `fira-code` vs `system-fallback`).
2. **Body-class gating strategy** in `use-monospace-classes.ts` (apply on every settings change vs debounce vs once-on-mount).
These choices shape the feature's behavior in ways that benefit from the user's domain knowledge.
+16 -3
View File
@@ -10,10 +10,23 @@ module.exports = {
// Root directory // Root directory
rootDir: '.', rootDir: '.',
// Test file patterns // Test file patterns — scoped to ./tests/ only so dist/ artifacts like
// .snap packages don't get matched as test suites.
testMatch: [ testMatch: [
'**/tests/**/*.test.js', '<rootDir>/tests/**/*.test.js',
'**/tests/**/*.spec.js' '<rootDir>/tests/**/*.spec.js'
],
// Ignore build outputs so .snap packages and bundled .asar contents
// never enter jest's file discovery.
testPathIgnorePatterns: [
'/node_modules/',
'/dist/',
'/\\.git/'
],
modulePathIgnorePatterns: [
'/node_modules/',
'/dist/'
], ],
// Coverage configuration // Coverage configuration
+9 -5
View File
@@ -1,7 +1,7 @@
{ {
"name": "markdown-converter", "name": "markdown-converter-react",
"version": "5.0.1", "version": "5.0.1",
"description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting", "description": "Professional Markdown editor and universal file converter with PDF editing, batch processing, and syntax highlighting (React build)",
"main": "src/main/index.js", "main": "src/main/index.js",
"scripts": { "scripts": {
"start": "electron .", "start": "electron .",
@@ -161,8 +161,8 @@
"lodash": "^4.17.21" "lodash": "^4.17.21"
}, },
"build": { "build": {
"appId": "com.concreteinfo.markdownconverter", "appId": "com.concreteinfo.markdownconverter.react",
"productName": "MarkdownConverter", "productName": "Markdown Converter React",
"copyright": "Copyright (C) 2024-2025 ConcreteInfo", "copyright": "Copyright (C) 2024-2025 ConcreteInfo",
"directories": { "directories": {
"output": "dist" "output": "dist"
@@ -172,10 +172,12 @@
"src/main/**/*", "src/main/**/*",
"src/preload.js", "src/preload.js",
"src/plugins/**/*", "src/plugins/**/*",
"assets/fonts/**",
"package.json" "package.json"
], ],
"asarUnpack": [ "asarUnpack": [
"node_modules/ffmpeg-static/**" "node_modules/ffmpeg-static/**",
"assets/fonts/**"
], ],
"extraResources": [ "extraResources": [
{ {
@@ -289,6 +291,8 @@
], ],
"category": "Utility", "category": "Utility",
"maintainer": "ConcreteInfo <amit.wh@gmail.com>", "maintainer": "ConcreteInfo <amit.wh@gmail.com>",
"executableName": "markdown-converter-react",
"synopsis": "Markdown editor and converter (React build)",
"extraFiles": [ "extraFiles": [
{ {
"from": "bin/linux/pandoc", "from": "bin/linux/pandoc",
+718
View File
@@ -0,0 +1,718 @@
# Repository Structure Map
Auto-generated by `~/.claude-shared/scripts/repo-map.sh` (universal-ctags).
Signatures only — classes, functions, methods, interfaces, enums, types, namespaces, traits.
Regenerate after structural changes. Languages: `JavaScript,Sh,TypeScript`.
```
scripts/download-tools.js:
L28 function findFile
L46 method extract (PANDOC_CONFIG.linux)
L61 method extract (PANDOC_CONFIG.win32)
L77 method extract (PANDOC_CONFIG.darwin)
L90 function download
L97 function get (download)
L136 function downloadPandoc
scripts/generate-icons.js:
L17 function generateIcons
scripts/verify-open-export.mjs:
L60 function showSaveDialogSync (dialog)
src/adapters/electron/fs.js:
L20 method readFile (electronFsAdapter)
L30 method writeFile (electronFsAdapter)
L39 method deleteFile (electronFsAdapter)
L48 method ensureDir (electronFsAdapter)
L57 method listDirectory (electronFsAdapter)
L77 method exists (electronFsAdapter)
L86 method isDirectory (electronFsAdapter)
L96 method copy (electronFsAdapter)
L106 method move (electronFsAdapter)
src/analytics/analytics-panel.js:
L7 function showAnalyticsModal
L105 function escHandler (showAnalyticsModal)
L116 function escapeHtml
src/analytics/writing-analytics.js:
L77 function countSyllables
L83 function extractWords
L87 function getReadabilityLabel
L95 function analyze
src/editor/codemirror-setup.js:
L46 function createEditor
L61 function onChange (createEditor)
L118 function getLanguageExtension
L120 method javascript (getLanguageExtension.loaders)
L124 method html (getLanguageExtension.loaders)
L128 method css (getLanguageExtension.loaders)
L132 method json (getLanguageExtension.loaders)
L136 method python (getLanguageExtension.loaders)
src/main/GitOperations.js:
L3 function getGitInstance
L7 function getStatus
L31 function stage
L56 function commit
L66 function log
L84 function diff
src/main/PDFOperations.js:
L5 function parsePageRanges
L28 function hexToRgb
L39 function pdfMerge
L59 function pdfSplit
L126 function pdfCompress
L152 function pdfRotate
L182 function pdfDeletePages
L208 function pdfReorder
L233 function pdfWatermark
L319 function pdfEncrypt
L353 function pdfDecrypt
L370 function pdfSetPermissions
L404 function executeOperation
L431 function getPageCount
src/main/files/binary.js:
L6 function register
src/main/files/git.js:
L6 function register
src/main/files/index.js:
L9 function register
src/main/index.js:
L28 function getPandocPath
L46 function getFFmpegPath
L65 function sanitizeErrorMessage
L74 function createRateLimiter
L76 function canProceed (createRateLimiter)
L86 function convertDataToMarkdown
L108 function runPandocCmd
L124 function parseCommand
L319 function checkPandocAvailability
L334 function showAboutDialog
L437 function showDependenciesDialog
L550 function openFile
L580 function openPdfFile
L599 function saveAsFile
L614 function exportFile
L624 function showExportOptionsDialog
L628 function showBatchConversionDialog
L633 function selectWordTemplate
L654 function showTemplateSettings
L847 function processDynamicFields
L868 function setDocxPageSize
L927 function addHeaderFooterToDocx
L1060 function exportWordWithTemplate
L1100 function exportPDFViaWordTemplate
L1180 function showUniversalConverterDialog
L1185 function showPDFEditorDialog
L1195 function checkConverterAvailable
L1336 function collectFiles
L1431 function convertWithLibreOffice
L1466 function convertWithImageMagick
L1475 function convertWithFFmpeg
L1483 function convertWithPandoc
L1490 function performExportWithOptions
L1741 function tryPdfFallback
L1818 function showExportSuccess
L1828 function exportWithPandoc
L1907 function exportToHTML
L2026 function exportToPDFElectron
L2174 function exportSpreadsheet
L2184 function importDocument
L2280 function setTheme
L2488 function extractTablesFromMarkdown
L2530 function performBatchConversion
L2552 function findMarkdownFiles (performBatchConversion)
L2579 function processNextFile (performBatchConversion)
L2855 function handleCLIConversion
L2883 function showConversionDialog
L2932 function performCLIConversion
L2974 function buildPandocCommand
L3152 method transform (anonymousObject0b93a19c6805)
L3273 function openFileFromPath
L3468 function loadSnippets
L3479 function saveSnippetsFile
src/main/ipc/crash-handlers.js:
L3 function register
src/main/ipc/updater-handlers.js:
L4 function register
src/main/menu/index.js:
L16 function buildMenu
L30 function register
src/main/menu/items.js:
L9 function buildRecentFilesMenu
L18 method click (buildRecentFilesMenu.anonymousObject59dd54ea0305)
L27 method click (anonymousObject59dd54ea0505)
L40 function fileItems
L143 method click (fileItems.anonymousObject59dd54ea1005)
L150 method click (fileItems.anonymousObject59dd54ea1105)
L157 method click (fileItems.anonymousObject59dd54ea1205)
L165 method click (fileItems.anonymousObject59dd54ea1305)
L172 method click (fileItems.anonymousObject59dd54ea1405)
L180 method click (fileItems.anonymousObject59dd54ea1505)
L187 method click (fileItems.anonymousObject59dd54ea1605)
L194 method click (fileItems.anonymousObject59dd54ea1705)
L201 method click (fileItems.anonymousObject59dd54ea1805)
L209 method click (fileItems.anonymousObject59dd54ea1a05)
L216 method click (fileItems.anonymousObject59dd54ea1b05)
L224 method click (fileItems.anonymousObject59dd54ea1d05)
L232 method click (fileItems.anonymousObject59dd54ea1f05)
L239 method click (fileItems.anonymousObject59dd54ea2005)
L246 method click (fileItems.anonymousObject59dd54ea2105)
L254 method click (fileItems.anonymousObject59dd54ea2305)
L261 method click (fileItems.anonymousObject59dd54ea2405)
L300 function editItems
L326 function viewItems
L372 method click (viewItems.anonymousObject59dd54ea2a05)
L379 method click (viewItems.anonymousObject59dd54ea2b05)
L386 method click (viewItems.anonymousObject59dd54ea2c05)
L393 method click (viewItems.anonymousObject59dd54ea2d05)
L400 method click (viewItems.anonymousObject59dd54ea2e05)
L407 method click (viewItems.anonymousObject59dd54ea2f05)
L414 method click (viewItems.anonymousObject59dd54ea3005)
L421 method click (viewItems.anonymousObject59dd54ea3105)
L428 method click (viewItems.anonymousObject59dd54ea3205)
L435 method click (viewItems.anonymousObject59dd54ea3305)
L443 method click (viewItems.anonymousObject59dd54ea3505)
L450 method click (viewItems.anonymousObject59dd54ea3605)
L457 method click (viewItems.anonymousObject59dd54ea3705)
L464 method click (viewItems.anonymousObject59dd54ea3805)
L471 method click (viewItems.anonymousObject59dd54ea3905)
L478 method click (viewItems.anonymousObject59dd54ea3a05)
L485 method click (viewItems.anonymousObject59dd54ea3b05)
L492 method click (viewItems.anonymousObject59dd54ea3c05)
L499 method click (viewItems.anonymousObject59dd54ea3d05)
L506 method click (viewItems.anonymousObject59dd54ea3e05)
L513 method click (viewItems.anonymousObject59dd54ea3f05)
L520 method click (viewItems.anonymousObject59dd54ea4005)
L527 method click (viewItems.anonymousObject59dd54ea4105)
L534 method click (viewItems.anonymousObject59dd54ea4205)
L541 method click (viewItems.anonymousObject59dd54ea4305)
L602 function batchItems
L631 function convertItems
L644 function pdfEditorItems
L712 method click (pdfEditorItems.anonymousObject59dd54ea4905)
L719 method click (pdfEditorItems.anonymousObject59dd54ea4a05)
L726 method click (pdfEditorItems.anonymousObject59dd54ea4b05)
L744 function toolsItems
L757 function helpItems
src/main/store.js:
L11 method get (store)
L20 method set (store)
src/main/updater/crash-writer.js:
L5 class CrashWriter
L6 method constructor (CrashWriter)
L12 method handleUncaught (CrashWriter)
L28 method _prune (CrashWriter)
L40 method list (CrashWriter)
L54 method delete (CrashWriter)
L59 method path (CrashWriter)
src/main/updater/feed-config.js:
L1 function feedConfigFor
src/main/updater/migration-runner.js:
L4 class MigrationRunner
L5 method constructor (MigrationRunner)
L12 method run (MigrationRunner)
L36 method _writeDefaults (MigrationRunner)
src/main/updater/migration-transform.js:
L49 function isAlreadyV5
L56 function normalizeAlreadyV5
L95 function migrateV4ToV5
src/main/updater/updater-service.js:
L4 class UpdaterService
L5 method constructor (UpdaterService)
L13 method _wire (UpdaterService)
L27 method _emit (UpdaterService)
L32 method check (UpdaterService)
L38 method install (UpdaterService)
src/main/utils/paths.js:
L7 function getAllowedDirectories
L23 function validatePath
L58 function resolveWritablePath
L88 function isPathAccessible
src/main/window/index.js:
L10 function createMainWindow
src/main/window/state.js:
L10 function load
L18 function save
src/main/word-template/index.js:
L11 class WordTemplateExporter
L12 method constructor (WordTemplateExporter)
L21 method convert (WordTemplateExporter)
L58 method setPageSize (WordTemplateExporter)
L111 method insertContentAfterPage (WordTemplateExporter)
L140 method markdownToWordXml (WordTemplateExporter)
L251 method createHeadingXml (WordTemplateExporter)
L266 method createParagraphXml (WordTemplateExporter)
L280 method createQuoteXml (WordTemplateExporter)
L294 method createListItemXml (WordTemplateExporter)
L315 method createCodeBlockXml (WordTemplateExporter)
L352 method createHorizontalRuleXml (WordTemplateExporter)
L366 method createTableXml (WordTemplateExporter)
L502 method isAsciiArt (WordTemplateExporter)
L606 method createAsciiArtXml (WordTemplateExporter)
L715 method parseInlineFormatting (WordTemplateExporter)
L776 method createRunXml (WordTemplateExporter)
L797 method escapeXml (WordTemplateExporter)
src/plugins/built-in/_sample/index.js:
L3 class SamplePlugin
L4 method init (SamplePlugin)
src/plugins/built-in/writing-studio/goal-tracker.js:
L3 class GoalTracker
L7 method constructor (GoalTracker)
L11 method _getHistory (GoalTracker)
L16 method _setHistory (GoalTracker)
L20 method _setHistoryDay (GoalTracker)
L26 method addWords (GoalTracker)
L37 method getDailyProgress (GoalTracker)
L44 method getStreak (GoalTracker)
L61 method getLast30Days (GoalTracker)
L74 method getWeeklyTotal (GoalTracker)
src/plugins/built-in/writing-studio/index.js:
L7 class WritingStudioPlugin
L8 method init (WritingStudioPlugin)
L34 method _registerCommands (WritingStudioPlugin)
L112 method _registerStatusBar (WritingStudioPlugin)
L123 method deactivate (WritingStudioPlugin)
L127 method getEngines (WritingStudioPlugin)
src/plugins/built-in/writing-studio/panels/goals-panel.js:
L1 function renderGoalsPanel
src/plugins/built-in/writing-studio/panels/manuscript-panel.js:
L1 function renderManuscriptPanel
src/plugins/built-in/writing-studio/panels/proofread-panel.js:
L1 function renderProofreadPanel
L41 method callback (anonymousObjectd036aefd0105)
L50 function renderIssues
src/plugins/built-in/writing-studio/panels/snapshots-panel.js:
L1 function renderSnapshotsPanel
src/plugins/built-in/writing-studio/project-manager.js:
L1 class ProjectManager
L5 method constructor (ProjectManager)
L9 method createProject (ProjectManager)
L21 method loadProject (ProjectManager)
L27 method _saveProject (ProjectManager)
L31 method addChapter (ProjectManager)
L38 method updateChapter (ProjectManager)
L45 method compileManuscript (ProjectManager)
L56 method getStats (ProjectManager)
src/plugins/built-in/writing-studio/snapshot-manager.js:
L1 class SnapshotManager
L6 method constructor (SnapshotManager)
L11 method _getAll (SnapshotManager)
L16 method _saveAll (SnapshotManager)
L20 method create (SnapshotManager)
L34 method list (SnapshotManager)
L38 method getById (SnapshotManager)
L42 method restore (SnapshotManager)
L48 method delete (SnapshotManager)
L53 method diff (SnapshotManager)
L71 method prune (SnapshotManager)
src/plugins/built-in/writing-studio/sprint-engine.js:
L1 class SprintEngine
L6 method constructor (SprintEngine)
L14 method start (SprintEngine)
L22 method stop (SprintEngine)
L33 method tick (SprintEngine)
L43 method isActive (SprintEngine)
L47 method getRemaining (SprintEngine)
src/plugins/event-bus.js:
L1 class EventBus
L2 method constructor (EventBus)
L6 method on (EventBus)
L13 method off (EventBus)
L25 method emit (EventBus)
L37 method hasHandler (EventBus)
src/plugins/plugin-api.js:
L1 class PluginAPI
L7 method init (PluginAPI)
L12 method activate (PluginAPI)
L15 method deactivate (PluginAPI)
L18 method getManifest (PluginAPI)
src/plugins/plugin-context.js:
L1 class PluginContext
L14 method constructor (PluginContext)
L23 method register (PluginContext.constructor.commands)
L24 function safeHandler (PluginContext.constructor.commands.register)
L65 method registerPreHook (PluginContext.constructor.exports)
L68 method registerPostHook (PluginContext.constructor.exports)
src/plugins/plugin-loader.js:
L4 class PluginLoader
L8 method constructor (PluginLoader)
L17 method discoverPlugins (PluginLoader)
L66 method validateManifest (PluginLoader)
src/plugins/plugin-registry.js:
L4 class PluginRegistry
L5 method constructor (PluginRegistry)
L15 method register (PluginRegistry)
L49 method getPlugin (PluginRegistry)
L53 method getAll (PluginRegistry)
L57 method activate (PluginRegistry)
L68 method deactivate (PluginRegistry)
src/plugins/settings-store.js:
L1 class SettingsStore
L5 method constructor (SettingsStore)
L9 method get (SettingsStore)
L13 method set (SettingsStore)
L17 method onChanged (SettingsStore)
src/preload.js:
L275 method send (anonymousObject04f5e4370105)
L289 method invoke (anonymousObject04f5e4370105)
L308 method on (anonymousObject04f5e4370105)
L310 function subscription (anonymousObject04f5e4370105.on)
L328 method once (anonymousObject04f5e4370105)
L340 method removeAllListeners (anonymousObject04f5e4370105)
L488 function subscription
src/renderer/hooks/use-export-source.ts:
L4 interface ExportSource
L14 function useExportSource
src/renderer/hooks/use-file-shortcuts.ts:
L15 function useFileShortcuts
src/renderer/hooks/use-menu-action.ts:
L6 alias Transform
L17 function useMenuAction
src/renderer/hooks/use-restore-last-folder.ts:
L9 function useRestoreLastFolder
src/renderer/hooks/use-scroll-sync.ts:
L3 interface Options
L8 function useScrollSync
src/renderer/hooks/use-shortcut.ts:
L20 function useShortcut
L44 interface ComboSpec
L53 function parseCombo
L71 function matchCombo
src/renderer/hooks/use-welcome-trigger.ts:
L9 function useWelcomeTrigger
src/renderer/hooks/use-zen-mode.ts:
L8 function useZenMode
src/renderer/hooks/useAutoUpdateCheck.ts:
L5 function useAutoUpdateCheck
src/renderer/lib/ascii-table.ts:
L5 function toAsciiTable
L36 function applyAsciiTransform
src/renderer/lib/commands/register-menu-commands.ts:
L24 alias HeadingItem
L26 alias OpenModal
L28 function confirmCloseFlow
L45 function registerMenuCommands
L411 function useRegisterMenuCommands
L421 function useBridgeNativeMenu
src/renderer/lib/docx-export.ts:
L4 interface DocxOptions
L10 function generateDocx
src/renderer/lib/editor-commands.ts:
L18 function setActiveView
L22 function getActiveView
L27 function wrap
L57 function setLineHeading
L76 function toggleLinePrefix
L103 function toggleBold
L107 function toggleItalic
L111 function toggleCode
L115 function toggleCodeBlock
L143 function toggleUnorderedList
L147 function toggleOrderedList
L151 function insertLink
L167 function setHeadingLevel
L171 function scrollToLine
L185 function undo
L192 function redo
L199 function selectCurrentLine
L210 function insertSnippet
src/renderer/lib/figlet.ts:
L13 alias FigletFont
L15 function figletText
src/renderer/lib/headings.ts:
L1 interface HeadingItem
L13 function extractHeadings
src/renderer/lib/html-export.ts:
L11 interface HtmlExportOptions
L101 function generateHtml
L125 function escapeHtml
src/renderer/lib/ipc.ts:
L14 alias ChannelMissing
L16 function wrap
src/renderer/lib/markdown.ts:
L11 function renderMarkdown
src/renderer/lib/migrations/v4-to-v5.ts:
L16 function isAlreadyV5
L22 function normalizeAlreadyV5
L37 function migrateV4ToV5
src/renderer/lib/pdf-export.ts:
L13 interface PdfExportOptions
L28 function generatePdf
src/renderer/lib/updater-store.ts:
L4 alias State
L6 interface UpdaterState
src/renderer/lib/utils.ts:
L1 alias ClassValue
L4 function cn
src/renderer/lib/validators.ts:
L30 alias Settings
L38 alias ExportPdfOptions
L44 alias ExportDocxOptions
L51 alias ExportHtmlOptions
L58 alias ExportBatchOptions
src/renderer/lib/writing-analytics.ts:
L115 function countSyllables
L122 function extractWords
L126 function getReadabilityLabel
L134 interface WritingMetrics
L151 function analyzeText
src/renderer/stores/app-store.ts:
L4 interface PaneSizes
L10 interface ConfirmProps
L20 alias ModalState
L42 alias ModalKind
L44 interface AppState
L57 method openModal (AppState)
src/renderer/stores/command-store.ts:
L4 alias CommandId
L6 alias CommandHandler
L13 alias UserBindings
L15 interface CommandState
src/renderer/stores/editor-store.ts:
L7 interface Buffer
L15 interface EditorState
src/renderer/stores/file-store.ts:
L12 interface FileNode
L21 interface OpenTab
L28 interface FileState
L50 function entryToNode
L60 function updateNode
src/renderer/stores/preview-store.ts:
L3 interface PreviewState
src/renderer/stores/settings-store.ts:
L3 alias Settings
L5 alias Omit
L5 alias SettingsLeaf
L7 interface SettingsState
L8 method setSetting (SettingsState)
src/renderer/test/setup.ts:
L7 interface Window
L119 class ResizeObserver
L120 method observe (ResizeObserver)
L121 method unobserve (ResizeObserver)
L122 method disconnect (ResizeObserver)
L141 class DOMMatrix
src/renderer/types/electron.d.ts:
L1 interface ElectronAPI
L134 interface Window
src/renderer/types/ipc.ts:
L1 alias IpcResult
L5 interface FileEntry
L13 interface FileResult
L18 interface PdfOptions
L27 interface DocxOptions
L34 interface HtmlOptions
L41 interface ExportResult
L47 interface BatchItem
L52 interface BatchOptions
L57 interface BatchResult
src/repl/repl-panel.js:
L1 class ReplPanel
L2 method constructor (ReplPanel)
L8 method setupEventListeners (ReplPanel)
L13 method toggle (ReplPanel)
L19 method show (ReplPanel)
L25 method clear (ReplPanel)
L29 method appendOutput (ReplPanel)
L42 method escapeHtml (ReplPanel)
src/sidebar/explorer-panel.js:
L1 function renderExplorerPanel
L40 function renderTree
L79 function getFileIcon
src/sidebar/git-panel.js:
L1 function renderGitPanel
L24 function loadGitStatus (renderGitPanel)
src/sidebar/outline-panel.js:
L6 function renderOutlinePanel
L18 function parseHeadings (renderOutlinePanel)
L36 function findActiveHeading (renderOutlinePanel)
L48 function renderHeadings (renderOutlinePanel)
L89 function escapeHtml (renderOutlinePanel)
L95 function refresh (renderOutlinePanel)
L100 function setActiveHeading (renderOutlinePanel)
src/sidebar/sidebar-manager.js:
L1 class SidebarManager
L2 method constructor (SidebarManager)
L11 method setupEventListeners (SidebarManager)
L20 method registerPanel (SidebarManager)
L24 method togglePanel (SidebarManager)
L32 method expand (SidebarManager)
L45 method collapse (SidebarManager)
src/sidebar/snippets-panel.js:
L1 function renderSnippetsPanel
L14 function loadSnippets (renderSnippetsPanel)
L19 function renderList (renderSnippetsPanel)
src/sidebar/templates-panel.js:
L18 function renderTemplatesPanel
src/utils/ModalManager.js:
L5 class ModalManager
L16 method constructor (ModalManager)
L31 method init (ModalManager)
L47 method setupCloseTriggers (ModalManager)
L51 function handler (ModalManager.setupCloseTriggers)
L65 function handler
L74 method getFocusableElements (ModalManager)
L89 method trapFocus (ModalManager)
L111 method handleKeydown (ModalManager)
L119 method open (ModalManager)
L141 function keydownHandler (ModalManager.open)
L164 method close (ModalManager)
L182 function addHidden (ModalManager.close)
L188 function onTransitionEnd (ModalManager.close)
L223 method isOpen (ModalManager)
L227 method destroy (ModalManager)
tests/git-operations.test.js:
L26 function asyncFn
L35 function asyncFnWithError
tests/main-utils.test.js:
L7 function sanitizeErrorMessage
L49 function createRateLimiter
L51 function canProceed (createRateLimiter)
tests/markdown-extensions.test.js:
L83 method start (extension)
L86 method tokenizer (extension)
L102 method renderer (extension)
L151 function plantumlEncode
L176 function slugify
L203 function scopeCSS
tests/modal-manager.test.js:
L11 function createModalElement
tests/plugin-api.test.js:
L18 class MyPlugin
L19 method init (MyPlugin)
tests/plugin-context.test.js:
L39 function badHandler
tests/plugin-loader.test.js:
L17 function writeManifest
tests/plugin-registry.test.js:
L5 class TestPlugin
L6 method init (TestPlugin)
L10 method activate (TestPlugin)
L13 method deactivate (TestPlugin)
L69 class BadPlugin
L70 method init (BadPlugin)
tests/setup.js:
L96 function error (console)
tests/sidebar.test.js:
L41 method render (anonymousObject7c2818c30105)
L52 method render (anonymousObject7c2818c30205)
L62 method render (anonymousObject7c2818c30305)
L68 method render (anonymousObject7c2818c30405)
L80 method render (anonymousObject7c2818c30505)
L88 method render (anonymousObject7c2818c30605)
L96 method render (anonymousObject7c2818c30705)
L120 method render (anonymousObject7c2818c30905)
L130 method render (anonymousObject7c2818c30a05)
tests/unit/hooks/use-menu-action.test.ts:
L6 alias Cleanup
L27 function fireMenu
tests/unit/main/updater/crash-writer.test.js:
L6 function tmpDir
tests/unit/main/updater/migration-runner.test.js:
L6 function tmpDir
L47 method transform (anonymousObject196c14410c05)
L63 method transform (anonymousObject196c14410d05)
tests/unit/stores/file-store.test.ts:
L26 function fakeFileEntry
tests/utils.test.js:
L9 function parseCommand
L78 function hexToRgb
L121 function getExtension
L133 function replaceExtension
```
+41 -1
View File
@@ -169,7 +169,47 @@ async function downloadPandoc() {
console.log(`[download-tools] pandoc ready: ${destFile}`); console.log(`[download-tools] pandoc ready: ${destFile}`);
} }
downloadPandoc().catch((err) => { async function downloadFiraCode() {
const targetDir = path.resolve(__dirname, '..', 'assets', 'fonts');
fs.mkdirSync(targetDir, { recursive: true });
// Pinned to an immutable release tag with explicit SHA-256 digests so the
// build fails loudly on any upstream tampering or accidental change.
// Update the version + digests together when bumping Fira Code.
const FIRA_CODE_VERSION = '6.2';
const baseUrl = `https://github.com/tonsky/FiraCode/releases/download/${FIRA_CODE_VERSION}`;
const files = [
{ url: `${baseUrl}/FiraCode-Regular.ttf`, out: 'FiraCode-Regular.ttf', sha256: '3c79d234a9161c790410ebb2a80de7efb7c15f581062c130e0fa78503ccdd0da' },
{ url: `${baseUrl}/FiraCode-Bold.ttf`, out: 'FiraCode-Bold.ttf', sha256: '975f26779fac1029c2cbdac1e9fac7e9ddeec05e064675e4aac63bffa121742f' },
{ url: `${baseUrl}/FiraCode-LICENSE.txt`, out: 'FiraCode-LICENSE.txt', sha256: null },
];
for (const f of files) {
const dest = path.join(targetDir, f.out);
if (fs.existsSync(dest)) {
console.log(`[download-tools] Fira Code asset already present at ${dest} — skipping.`);
continue;
}
if (!f.sha256 || !/^[a-f0-9]{64}$/i.test(f.sha256)) {
throw new Error(
`[download-tools] Refusing to download ${f.url}: SHA-256 digest not pinned. ` +
'Update scripts/download-tools.js with the digest from the official Fira Code release before building.'
);
}
const tmp = `${dest}.tmp`;
console.log(`[download-tools] Downloading ${f.url}...`);
await download(f.url, tmp);
const actual = require('crypto').createHash('sha256').update(fs.readFileSync(tmp)).digest('hex');
if (actual !== f.sha256) {
fs.unlinkSync(tmp);
throw new Error(
`[download-tools] SHA-256 mismatch for ${f.url}: expected ${f.sha256}, got ${actual}`
);
}
fs.renameSync(tmp, dest);
}
console.log('[download-tools] Fira Code ready');
}
Promise.all([downloadPandoc(), downloadFiraCode()]).catch((err) => {
console.error('[download-tools] FAILED:', err.message); console.error('[download-tools] FAILED:', err.message);
process.exit(1); process.exit(1);
}); });
+45
View File
@@ -0,0 +1,45 @@
'use strict';
const fs = require('fs');
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 };
+37
View File
@@ -0,0 +1,37 @@
'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 };
+25
View File
@@ -0,0 +1,25 @@
'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 };
+55
View File
@@ -0,0 +1,55 @@
'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 };
+38
View File
@@ -0,0 +1,38 @@
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
/**
* Build a xelatex/lualatex fontspec header referencing the bundled TTF.
*
* Uses an exclusive temp directory (mkdtempSync) to avoid the racy
* Date.now()+pid filename pattern. Returns the directory along with the
* header path; callers MUST `unlinkSync(headerPath)` and `rmdirSync(dir)`
* after pandoc consumes the file.
*/
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 fontDir = path.dirname(ttfPath);
const basename = path.basename(ttfPath);
const boldName = basename.replace('Regular', 'Bold').replace('Medium', 'Bold');
const lines = [
`\\setmonofont[Path = ${fontDir}/,`,
` Extension = .ttf,`,
` UprightFont = ${basename},`,
` BoldFont = ${boldName},`,
ligatures ? ' Ligatures=TeX,' : '',
` Scale = 0.9]`,
`{${fontFamily}}`,
].filter(Boolean);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'mono-pdf-'));
const headerPath = path.join(dir, 'monospace.tex');
fs.writeFileSync(headerPath, lines.join('\n'), 'utf-8');
return { headerPath, dir, familyName: fontFamily };
}
module.exports = { buildPdfFontHeader };
+2 -1
View File
@@ -8,7 +8,8 @@ function register(currentFileRef) {
const dir = const dir =
rootPath || rootPath ||
(currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd()); (currentFileRef.current ? require('path').dirname(currentFileRef.current) : process.cwd());
return GitOperations.getStatus(dir); const result = GitOperations.getStatus(dir);
return Array.isArray(result?.files) ? result.files : [];
}); });
ipcMain.handle('git-stage', async (_event, { rootPath, files }) => { ipcMain.handle('git-stage', async (_event, { rootPath, files }) => {
+11
View File
@@ -5,6 +5,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const { register: registerGit } = require('./git'); const { register: registerGit } = require('./git');
const { register: registerBinary } = require('./binary'); const { register: registerBinary } = require('./binary');
const { searchInFiles } = require('./search-in-files');
function register({ function register({
validatePath, validatePath,
@@ -131,6 +132,16 @@ function register({
return { source: sourceValidation.resolved, destination: destinationValidation.resolved }; return { source: sourceValidation.resolved, destination: destinationValidation.resolved };
}); });
// search-in-files
ipcMain.handle('search-in-files', async (_event, payload) => {
const validation = validatePath(payload?.rootPath);
if (!validation.valid || !isPathAccessible(validation.resolved)) {
console.error('[SECURITY] Invalid search rootPath:', validation.error);
return [];
}
return searchInFiles({ ...(payload || {}), rootPath: validation.resolved });
});
// open-file-path // open-file-path
ipcMain.on('open-file-path', (event, filePath) => { ipcMain.on('open-file-path', (event, filePath) => {
try { try {
+45
View File
@@ -0,0 +1,45 @@
'use strict';
const fs = require('fs');
const path = require('path');
/**
* List a directory's entries (excluding dotfiles), sorted directories-first
* then alphabetically. Returns a flat array of FileEntry-shaped objects,
* matching the renderer type declaration in `src/renderer/lib/ipc.ts`.
*
* Skips entries that cannot be stat'd (permission errors, broken symlinks)
* instead of throwing — keeps the UI responsive on partially-readable dirs.
*/
function listDirectoryEntries(dirPath) {
const dirents = fs.readdirSync(dirPath, { withFileTypes: true });
const entries = [];
for (const d of dirents) {
if (d.name.startsWith('.')) continue;
const full = path.join(dirPath, d.name);
let size = 0;
let modified = 0;
try {
const s = fs.statSync(full);
size = d.isDirectory() ? 0 : s.size;
modified = s.mtimeMs;
} catch (_err) {
continue;
}
entries.push({
name: d.name,
isDirectory: d.isDirectory(),
size,
modifiedAt: modified,
path: full,
});
}
entries.sort((a, b) => {
if (a.isDirectory && !b.isDirectory) return -1;
if (!a.isDirectory && b.isDirectory) return 1;
return a.name.localeCompare(b.name);
});
return entries;
}
module.exports = { listDirectoryEntries };
+150
View File
@@ -0,0 +1,150 @@
'use strict';
const fs = require('fs');
const path = require('path');
const MAX_RESULTS = 1000;
const MAX_FILE_BYTES = 2 * 1024 * 1024;
const MAX_FILES = 10000;
const MAX_QUERY_LENGTH = 1024;
const MAX_REGEX_LENGTH = 200;
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.next', '.cache']);
// Reject regexes with classic ReDoS shapes. This is a defense-in-depth
// denylist, not a proof of safety — the hard caps on length, files, and
// results are the primary defense.
const UNSAFE_REGEX = new RegExp(
// nested quantifiers: (a+)+, [a-z]*+
'\\([^)]*[+*][^)]*\\)[+*?]' +
'|' +
// class with quantifier: [a-z]+
'\\[[^\\]]*\\][+*]' +
'|' +
// dot-quantifier followed by dot-quantifier
'\\.[+*]\\s*\\.[+*]' +
'|' +
// lookahead / lookbehind
'\\(\\?[=!]' +
'|' +
// backrefs
'\\\\[1-9]' +
'|' +
// alternation with quantifier
'\\([^)]*\\|[^)]*\\)[+*]'
);
function listFiles(rootPath) {
const out = [];
const stack = [rootPath];
const visited = new Set();
while (stack.length && out.length < MAX_FILES) {
const dir = stack.pop();
// Resolve symlinks and verify we haven't escaped the root.
let real;
try {
real = fs.realpathSync(dir);
} catch (_) {
continue;
}
if (visited.has(real)) continue;
visited.add(real);
const rootReal = fs.realpathSync(rootPath);
if (!real.startsWith(rootReal + path.sep) && real !== rootReal) continue;
let entries;
try {
entries = fs.readdirSync(real, { withFileTypes: true });
} catch (_) {
continue;
}
for (const e of entries) {
if (e.name.startsWith('.')) continue;
const full = path.join(real, e.name);
// Follow symlinks but verify containment again.
try {
const s = fs.lstatSync(full);
if (s.isSymbolicLink()) {
const linkTarget = fs.realpathSync(full);
if (!linkTarget.startsWith(rootReal + path.sep) && linkTarget !== rootReal) continue;
}
} catch (_) {
continue;
}
try {
const s = fs.statSync(full);
if (s.isDirectory()) {
if (SKIP_DIRS.has(e.name)) continue;
stack.push(full);
} else if (s.isFile()) {
out.push(full);
if (out.length >= MAX_FILES) break;
}
} catch (_) {
continue;
}
}
}
return out;
}
function makeMatcher(query, isRegex, caseSensitive) {
if (isRegex) {
if (query.length > MAX_REGEX_LENGTH) return null;
if (UNSAFE_REGEX.test(query)) return null;
try {
const re = new RegExp(query, caseSensitive ? '' : 'i');
return (line) => re.test(line);
} catch (_err) {
return null;
}
}
const needle = caseSensitive ? query : query.toLowerCase();
return (line) => (caseSensitive ? line : line.toLowerCase()).includes(needle);
}
/**
* Recursively search `rootPath` for files containing `query`.
* Returns up to MAX_RESULTS matches of the form
* { filePath, line, content }.
*
* Hardened against:
* - Path traversal via symlinks (verified after realpathSync)
* - ReDoS via nested-quantifier regex (rejected at match time)
* - Resource exhaustion via MAX_FILES + MAX_FILE_BYTES + MAX_RESULTS
* - Empty / oversized queries via MAX_QUERY_LENGTH
*/
function searchInFiles({ rootPath, query, isRegex = false, caseSensitive = false }) {
if (!rootPath || !query) return [];
if (typeof query !== 'string' || query.length > MAX_QUERY_LENGTH) return [];
const matcher = makeMatcher(query, isRegex, caseSensitive);
if (!matcher) return [];
const results = [];
for (const filePath of listFiles(rootPath)) {
if (results.length >= MAX_RESULTS) break;
let stat;
try {
stat = fs.statSync(filePath);
} catch (_) {
continue;
}
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) continue;
let content;
try {
content = fs.readFileSync(filePath, 'utf-8');
} catch (_) {
continue;
}
const lines = content.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
if (matcher(lines[i])) {
results.push({ filePath, line: i + 1, content: lines[i] });
if (results.length >= MAX_RESULTS) break;
}
}
}
return results;
}
module.exports = { searchInFiles };
+101 -16
View File
@@ -5,6 +5,7 @@ const { CrashWriter } = require('./updater/crash-writer');
const { MigrationRunner } = require('./updater/migration-runner'); const { MigrationRunner } = require('./updater/migration-runner');
const updaterHandlers = require('./ipc/updater-handlers'); const updaterHandlers = require('./ipc/updater-handlers');
const crashHandlers = require('./ipc/crash-handlers'); const crashHandlers = require('./ipc/crash-handlers');
const monospaceHandlers = require('./ipc/monospace-handlers');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { execFile } = require('child_process'); const { execFile } = require('child_process');
@@ -12,8 +13,27 @@ const WordTemplateExporter = require('./word-template');
const PDFOperations = require('./PDFOperations'); const PDFOperations = require('./PDFOperations');
const { validatePath, resolveWritablePath, isPathAccessible } = require('./utils/paths'); const { validatePath, resolveWritablePath, isPathAccessible } = require('./utils/paths');
const fileOps = require('./files'); const fileOps = require('./files');
const { listDirectoryEntries } = require('./files/list-directory');
const menu = require('./menu'); const menu = require('./menu');
const { createMainWindow } = require('./window'); const { createMainWindow } = require('./window');
const MonospaceFontConfig = require('./MonospaceFontConfig');
const { buildPdfFontHeader } = require('./PdfFontHeader');
const { embedDocxFont } = require('./DocxFontEmbedder');
const { withEpubEmbedFontArgs: _withEpubEmbedFontArgs, embedEpubFont } = require('./EpubFontEmbedder');
const { buildExportCss } = require('./ExportCss');
const { safeMonospaceSettings, DEFAULT_SETTINGS: MONO_DEFAULTS } = require('./settings/monospaceSettings');
/**
* Read active monospace settings + TTF path. Returns null TTF if asset missing.
*/
function getActiveMonospaceContext() {
const settings = safeMonospaceSettings({
monospaceFont: store.get('monospaceFont', MONO_DEFAULTS.monospaceFont),
monospaceLigatures: store.get('monospaceLigatures', MONO_DEFAULTS.monospaceLigatures),
});
const ttf = MonospaceFontConfig.getMonoFontTtfPath(settings.monospaceFont, 400);
return { settings, ttf };
}
// Add MiKTeX to PATH for LaTeX support // Add MiKTeX to PATH for LaTeX support
if (process.platform === 'win32') { if (process.platform === 'win32') {
@@ -1577,6 +1597,23 @@ function performExportWithOptions(format, options) {
pandocCmd += ' -V monofont="Consolas"'; pandocCmd += ' -V monofont="Consolas"';
pandocCmd += ' --highlight-style=tango'; pandocCmd += ' --highlight-style=tango';
// When bundled TTF asset is present, replace system Consolas with our
// active monospace font (xelatex fontspec) so ASCII art renders
// identically across machines.
const monoCtx = getActiveMonospaceContext();
if (monoCtx.ttf) {
try {
const built = buildPdfFontHeader(
monoCtx.settings,
monoCtx.ttf,
MonospaceFontConfig.getActiveFamily(monoCtx.settings)
);
pandocCmd += ` --include-in-header="${built.headerPath}"`;
} catch (e) {
console.error('[monospace] PDF header build failed (non-fatal):', e.message);
}
}
// Add header/footer if enabled // Add header/footer if enabled
if (headerFooterSettings.enabled) { if (headerFooterSettings.enabled) {
const filename = currentFile const filename = currentFile
@@ -1726,6 +1763,12 @@ function performExportWithOptions(format, options) {
}); });
} else { } else {
// Generic export for other formats // Generic export for other formats
if (format === 'epub') {
const monoCtx = getActiveMonospaceContext();
if (monoCtx.ttf) {
pandocCmd = ` --epub-embed-font="${monoCtx.ttf}"` + pandocCmd;
}
}
exportWithPandoc(pandocCmd, outputFile, format); exportWithPandoc(pandocCmd, outputFile, format);
} }
}) })
@@ -1827,6 +1870,19 @@ function showExportSuccess(outputFile) {
// Helper function to export with pandoc (general) - uses runPandocCmd for safety // Helper function to export with pandoc (general) - uses runPandocCmd for safety
function exportWithPandoc(pandocCmd, outputFile, format) { function exportWithPandoc(pandocCmd, outputFile, format) {
console.log(`Executing Pandoc command: ${pandocCmd}`); console.log(`Executing Pandoc command: ${pandocCmd}`);
// Track the temp directory (if any) created by buildPdfFontHeader so we can
// clean it up after pandoc finishes — regardless of success or failure.
const headerDirMatch = pandocCmd.match(/--include-in-header="([^"]+)"/);
const monoPdfHeaderDir = headerDirMatch ? path.dirname(headerDirMatch[1]) : null;
const cleanupMonoHeader = () => {
if (monoPdfHeaderDir && monoPdfHeaderDir.includes('mono-pdf-')) {
try {
fs.rmSync(monoPdfHeaderDir, { recursive: true, force: true });
} catch (_) {
/* best-effort cleanup */
}
}
};
runPandocCmd(pandocCmd, async (error, stdout, stderr) => { runPandocCmd(pandocCmd, async (error, stdout, stderr) => {
if (error) { if (error) {
@@ -1866,6 +1922,22 @@ function exportWithPandoc(pandocCmd, outputFile, format) {
} }
} }
// Embed active monospace TTF into DOCX so code blocks render in the
// bundled font even when the recipient opens the file without JetBrains
// Mono or Fira Code installed.
if (format === 'docx') {
const monoCtx = getActiveMonospaceContext();
if (monoCtx.ttf) {
try {
const family = MonospaceFontConfig.getActiveFamily(monoCtx.settings).replace(/\s+/g, '');
await embedDocxFont(outputFile, outputFile, monoCtx.ttf, family);
console.log(`[monospace] Embedded ${family} TTF into DOCX`);
} catch (e) {
console.error('[monospace] DOCX embed failed (non-fatal):', e.message);
}
}
}
// Add headers/footers to DOCX if enabled // Add headers/footers to DOCX if enabled
if (format === 'docx' && headerFooterSettings.enabled) { if (format === 'docx' && headerFooterSettings.enabled) {
try { try {
@@ -1895,11 +1967,26 @@ function exportWithPandoc(pandocCmd, outputFile, format) {
} }
} }
// Patch EPUB manifest so the font table references the embedded TTF.
if (format === 'epub') {
const monoCtx = getActiveMonospaceContext();
if (monoCtx.ttf) {
try {
const family = MonospaceFontConfig.getActiveFamily(monoCtx.settings);
await embedEpubFont(outputFile, monoCtx.ttf, family);
console.log(`[monospace] Patched EPUB manifest for ${family}`);
} catch (e) {
console.error('[monospace] EPUB manifest patch failed (non-fatal):', e.message);
}
}
}
// ODT header/footer is not supported by Pandoc's ODT output // ODT header/footer is not supported by Pandoc's ODT output
// Headers/footers are applied only for DOCX format // Headers/footers are applied only for DOCX format
showExportSuccess(outputFile); showExportSuccess(outputFile);
} }
cleanupMonoHeader();
}); });
} }
@@ -2003,6 +2090,18 @@ function exportToHTML(outputFile) {
word-wrap: normal; word-wrap: normal;
} }
} }
${(() => {
try {
const monoCtx = getActiveMonospaceContext();
const familyName = MonospaceFontConfig.getActiveFamily(monoCtx.settings);
const woff2Name = familyName === 'Fira Code' ? 'FiraCode-Regular.woff2' : 'JetBrainsMono-Regular.woff2';
const woff2Path = path.join(path.dirname(monoCtx.ttf || ''), woff2Name);
if (!fs.existsSync(woff2Path)) return '';
return buildExportCss(monoCtx.settings, { woff2: fs.readFileSync(woff2Path) });
} catch (_e) {
return '';
}
})()}
</style> </style>
</head> </head>
<body> <body>
@@ -3172,6 +3271,7 @@ app.whenReady().then(() => {
crash, crash,
getMainWindow: () => mainWindow, getMainWindow: () => mainWindow,
}); });
monospaceHandlers.register();
// -------------------------------------------------------------------- // --------------------------------------------------------------------
// Register file ops IPC handlers // Register file ops IPC handlers
@@ -3423,22 +3523,7 @@ ipcMain.handle('list-directory', async (event, dirPath) => {
return null; return null;
} }
const entries = fs return listDirectoryEntries(validation.resolved);
.readdirSync(validation.resolved, { withFileTypes: true })
.filter((e) => !e.name.startsWith('.'))
.sort((a, b) => {
if (a.isDirectory() && !b.isDirectory()) return -1;
if (!a.isDirectory() && b.isDirectory()) return 1;
return a.name.localeCompare(b.name);
})
.map((e) => ({
name: e.name,
isDirectory: e.isDirectory(),
size: e.isDirectory() ? 0 : fs.statSync(path.join(validation.resolved, e.name)).size,
modified: fs.statSync(path.join(validation.resolved, e.name)).mtimeMs,
path: path.join(validation.resolved, e.name),
}));
return { path: validation.resolved, entries };
} catch (err) { } catch (err) {
console.error('list-directory error:', err); console.error('list-directory error:', err);
return null; return null;
+29
View File
@@ -0,0 +1,29 @@
'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 };
+49
View File
@@ -0,0 +1,49 @@
'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,
};
+3
View File
@@ -39,6 +39,9 @@ const v5SettingsSchema = z
updateChannel: z.enum(['github', 'concreteinfo']).default('github'), updateChannel: z.enum(['github', 'concreteinfo']).default('github'),
autoCheckUpdates: z.boolean().default(true), autoCheckUpdates: z.boolean().default(true),
firstRun: z.boolean().default(true), firstRun: z.boolean().default(true),
monospaceFont: z.enum(['jetbrains-mono', 'fira-code']).default('jetbrains-mono'),
monospaceLigatures: z.boolean().default(false),
appVariant: z.enum(['classic', 'react']).default('react'),
'migration.version': z.literal(5).optional(), 'migration.version': z.literal(5).optional(),
}) })
.passthrough(); .passthrough();
+2
View File
@@ -9,12 +9,14 @@ const menu = require('../menu');
function createMainWindow() { function createMainWindow() {
const bounds = state.load(); const bounds = state.load();
const isDev = !!process.env.VITE_DEV_SERVER_URL;
const win = new BrowserWindow({ const win = new BrowserWindow({
width: bounds.width, width: bounds.width,
height: bounds.height, height: bounds.height,
x: bounds.x, x: bounds.x,
y: bounds.y, y: bounds.y,
show: true, show: true,
title: `Markdown Converter${isDev ? ' — React Dev' : ''}`,
webPreferences: { webPreferences: {
// The preload script exposes `window.electronAPI` — the only IPC // The preload script exposes `window.electronAPI` — the only IPC
// bridge the renderer uses. Without this, every renderer call returns // bridge the renderer uses. Without this, every renderer call returns
+11
View File
@@ -108,6 +108,7 @@ const ALLOWED_SEND_CHANNELS = [
'move-path', 'move-path',
'pick-folder', 'pick-folder',
'pick-file', 'pick-file',
'search-in-files',
// Git // Git
'git-status', 'git-status',
@@ -149,6 +150,10 @@ const ALLOWED_SEND_CHANNELS = [
'crash:open-dir', 'crash:open-dir',
'crash:delete', 'crash:delete',
// Monospace font settings
'get-monospace-settings',
'set-monospace-settings',
// Git diff // Git diff
'git-diff', 'git-diff',
@@ -368,6 +373,7 @@ contextBridge.exposeInMainWorld('electronAPI', {
list: (dirPath) => ipcRenderer.invoke('list-directory', dirPath), list: (dirPath) => ipcRenderer.invoke('list-directory', dirPath),
pickFolder: () => ipcRenderer.invoke('pick-folder'), pickFolder: () => ipcRenderer.invoke('pick-folder'),
pickFile: () => ipcRenderer.invoke('pick-file'), pickFile: () => ipcRenderer.invoke('pick-file'),
search: (args) => ipcRenderer.invoke('search-in-files', args),
}, },
// Theme Operations // Theme Operations
@@ -496,6 +502,11 @@ contextBridge.exposeInMainWorld('electronAPI', {
openDir: () => ipcRenderer.send('crash:open-dir'), openDir: () => ipcRenderer.send('crash:open-dir'),
delete: (filename) => ipcRenderer.invoke('crash:delete', filename), delete: (filename) => ipcRenderer.invoke('crash:delete', filename),
}, },
monospace: {
getSettings: () => ipcRenderer.invoke('get-monospace-settings'),
saveSettings: (partial) => ipcRenderer.invoke('set-monospace-settings', partial),
},
}); });
// Log successful preload initialization // Log successful preload initialization
+2
View File
@@ -9,6 +9,7 @@ import { UpdateBanner } from './components/UpdateBanner';
import { FirstRunWizard } from './components/FirstRunWizard'; import { FirstRunWizard } from './components/FirstRunWizard';
import { useWelcomeTrigger } from './hooks/use-welcome-trigger'; import { useWelcomeTrigger } from './hooks/use-welcome-trigger';
import { useAutoUpdateCheck } from './hooks/useAutoUpdateCheck'; import { useAutoUpdateCheck } from './hooks/useAutoUpdateCheck';
import { useMonospaceClasses } from './hooks/use-monospace-classes';
import { useSettingsStore } from './stores/settings-store'; import { useSettingsStore } from './stores/settings-store';
import { ipc } from './lib/ipc'; import { ipc } from './lib/ipc';
@@ -35,6 +36,7 @@ function scopeCSS(cssText: string, scopeSelector: string) {
function App() { function App() {
useWelcomeTrigger(); useWelcomeTrigger();
useAutoUpdateCheck(); useAutoUpdateCheck();
useMonospaceClasses();
const [printOpen, setPrintOpen] = useState(false); const [printOpen, setPrintOpen] = useState(false);
const customCssPath = useSettingsStore((s) => s.customCssPath); const customCssPath = useSettingsStore((s) => s.customCssPath);
@@ -11,7 +11,14 @@ interface Dump {
export function CrashReportModal({ onClose }: { onClose: () => void }) { export function CrashReportModal({ onClose }: { onClose: () => void }) {
const [dumps, setDumps] = useState<Dump[]>([]); const [dumps, setDumps] = useState<Dump[]>([]);
const refresh = async () => setDumps(await ipc.crash.read()); const refresh = async () => {
const result = await ipc.crash.read();
if (!result.ok) {
setDumps([]);
return;
}
setDumps(Array.isArray(result.data) ? (result.data as Dump[]) : []);
};
useEffect(() => { useEffect(() => {
refresh(); refresh();
}, []); }, []);
@@ -0,0 +1,41 @@
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' : 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 unknown as { electronAPI?: { monospace?: { getSettings?: () => Promise<MonospaceSettings> } } })
.electronAPI;
if (!api || !api.monospace || typeof api.monospace.getSettings !== 'function') return;
api.monospace
.getSettings()
.then(applyMonospaceClasses)
.catch(() => applyMonospaceClasses(null));
}
+43 -9
View File
@@ -58,13 +58,32 @@ function safeCall<T extends (...args: any[]) => Promise<any>>(
export const ipc = { export const ipc = {
file: { file: {
open: (): Promise<IpcResult<FileResult | ChannelMissing>> => safeCall('file', 'pickFile'), // NOTE: file.open was previously miswired to pickFile — removed.
// Callers needing a file picker should use ipc.file.pickFile() directly.
read: (path: string): Promise<IpcResult<string | ChannelMissing>> => read: (path: string): Promise<IpcResult<string | ChannelMissing>> =>
safeCall('file', 'read', path), safeCall('file', 'read', path),
write: (path: string, content: string): Promise<IpcResult<void | ChannelMissing>> => write: (path: string, content: string): Promise<IpcResult<{ path: string } | ChannelMissing>> =>
safeCall('file', 'write', path, content), safeCall('file', 'write', path, content),
list: (dir: string): Promise<IpcResult<FileEntry[] | ChannelMissing>> => list: (dir: string): Promise<IpcResult<FileEntry[] | ChannelMissing>> =>
safeCall('file', 'list', dir), safeCall('file', 'list', dir),
delete: (path: string): Promise<IpcResult<boolean | ChannelMissing>> =>
safeCall('file', 'delete', path),
ensureDir: (path: string): Promise<IpcResult<string | ChannelMissing>> =>
safeCall('file', 'ensureDir', path),
exists: (path: string): Promise<IpcResult<boolean | ChannelMissing>> =>
safeCall('file', 'exists', path),
isDirectory: (path: string): Promise<IpcResult<boolean | ChannelMissing>> =>
safeCall('file', 'isDirectory', path),
copy: (
source: string,
destination: string
): Promise<IpcResult<{ source: string; destination: string } | ChannelMissing>> =>
safeCall('file', 'copy', source, destination),
move: (
source: string,
destination: string
): Promise<IpcResult<{ source: string; destination: string } | ChannelMissing>> =>
safeCall('file', 'move', source, destination),
pickFolder: (): Promise<IpcResult<string | null | ChannelMissing>> => pickFolder: (): Promise<IpcResult<string | null | ChannelMissing>> =>
safeCall('file', 'pickFolder'), safeCall('file', 'pickFolder'),
pickFile: (): Promise<IpcResult<string | null | ChannelMissing>> => pickFile: (): Promise<IpcResult<string | null | ChannelMissing>> =>
@@ -82,7 +101,7 @@ export const ipc = {
caseSensitive: boolean; caseSensitive: boolean;
}): Promise< }): Promise<
IpcResult<Array<{ filePath: string; line: number; content: string }> | ChannelMissing> IpcResult<Array<{ filePath: string; line: number; content: string }> | ChannelMissing>
> => safeCall('file', 'pickFile'), > => safeCall('file', 'search', args),
gitStatus: (args: { gitStatus: (args: {
rootPath: string; rootPath: string;
}): Promise< }): Promise<
@@ -102,11 +121,13 @@ export const ipc = {
gitStage: (args: { gitStage: (args: {
rootPath: string; rootPath: string;
files: string[]; files: string[];
}): Promise<IpcResult<void | ChannelMissing>> => safeCall('git', 'stage', args), }): Promise<IpcResult<{ files: unknown[]; error?: string } | ChannelMissing>> =>
safeCall('git', 'stage', args),
gitCommit: (args: { gitCommit: (args: {
rootPath: string; rootPath: string;
message: string; message: string;
}): Promise<IpcResult<void | ChannelMissing>> => safeCall('git', 'commit', args), }): Promise<IpcResult<{ summary: string } | { error: string } | ChannelMissing>> =>
safeCall('git', 'commit', args),
setCurrent: (path: string | null): void => { setCurrent: (path: string | null): void => {
if (typeof window !== 'undefined' && window.electronAPI?.file?.setCurrent) { if (typeof window !== 'undefined' && window.electronAPI?.file?.setCurrent) {
window.electronAPI.file.setCurrent(path); window.electronAPI.file.setCurrent(path);
@@ -160,9 +181,11 @@ export const ipc = {
}, },
}, },
updater: { updater: {
check: (): Promise<IpcResult<void | ChannelMissing>> => safeCall('updater', 'check'), check: (): Promise<IpcResult<{ state: string } | ChannelMissing>> =>
safeCall('updater', 'check'),
install: (): Promise<IpcResult<void | ChannelMissing>> => safeCall('updater', 'install'), install: (): Promise<IpcResult<void | ChannelMissing>> => safeCall('updater', 'install'),
getState: (): Promise<IpcResult<unknown | ChannelMissing>> => safeCall('updater', 'getState'), getState: (): Promise<IpcResult<{ state: string } | ChannelMissing>> =>
safeCall('updater', 'getState'),
onStatus: (cb: (payload: unknown) => void): (() => void) => { onStatus: (cb: (payload: unknown) => void): (() => void) => {
if (typeof window === 'undefined' || !window.electronAPI?.updater?.onStatus) { if (typeof window === 'undefined' || !window.electronAPI?.updater?.onStatus) {
return () => {}; return () => {};
@@ -171,9 +194,20 @@ export const ipc = {
}, },
}, },
crash: { crash: {
read: (): Promise<IpcResult<unknown | ChannelMissing>> => safeCall('crash', 'read'), read: (): Promise<
IpcResult<
| Array<{
filename: string;
kind: string;
message?: string;
stack?: string;
timestamp: string;
}>
| ChannelMissing
>
> => safeCall('crash', 'read'),
openDir: (): Promise<IpcResult<void | ChannelMissing>> => safeCall('crash', 'openDir'), openDir: (): Promise<IpcResult<void | ChannelMissing>> => safeCall('crash', 'openDir'),
delete: (filename: string): Promise<IpcResult<void | ChannelMissing>> => delete: (filename: string): Promise<IpcResult<boolean | ChannelMissing>> =>
safeCall('crash', 'delete', filename), safeCall('crash', 'delete', filename),
}, },
}; };
+1 -1
View File
@@ -10,7 +10,7 @@ export const v4SettingsSchema = z.object({
snippets: z.array(z.unknown()).default([]), snippets: z.array(z.unknown()).default([]),
}); });
const v5OnlyFields = ['updateChannel', 'autoCheckUpdates', 'firstRun']; const v5OnlyFields = ['updateChannel', 'autoCheckUpdates', 'firstRun', 'monospaceFont', 'monospaceLigatures', 'appVariant'];
const v5ThemeValues = ['light', 'dark', 'system'] as const; const v5ThemeValues = ['light', 'dark', 'system'] as const;
function isAlreadyV5(data: unknown): boolean { function isAlreadyV5(data: unknown): boolean {
+2 -4
View File
@@ -86,8 +86,7 @@ export const useFileStore = create<FileState>()(
return; return;
} }
const raw: any = result.data!; const entries = result.data ?? [];
const entries: any[] = Array.isArray(raw) ? raw : raw.entries;
const children: FileNode[] = entries.map(entryToNode); const children: FileNode[] = entries.map(entryToNode);
set((s) => { set((s) => {
@@ -119,8 +118,7 @@ export const useFileStore = create<FileState>()(
set((s) => { set((s) => {
updateNode(s.tree!, dirPath, (node) => { updateNode(s.tree!, dirPath, (node) => {
const raw: any = result.data!; const items = result.data ?? [];
const items: any[] = Array.isArray(raw) ? raw : raw.entries;
node.children = items.map(entryToNode); node.children = items.map(entryToNode);
node.loaded = true; node.loaded = true;
}); });
+19
View File
@@ -96,3 +96,22 @@
.cm-editor .cm-scroller { .cm-editor .cm-scroller {
@apply font-mono text-sm; @apply font-mono text-sm;
} }
/* Monospace font + ligature tokens — driven by body.mono-* classes */
: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);
}
+20
View File
@@ -25,6 +25,12 @@ export interface ElectronAPI {
list: (dirPath: string) => Promise<unknown>; list: (dirPath: string) => Promise<unknown>;
pickFolder: () => Promise<string | null>; pickFolder: () => Promise<string | null>;
pickFile: () => Promise<string | null>; pickFile: () => Promise<string | null>;
search: (args: {
rootPath: string;
query: string;
isRegex: boolean;
caseSensitive: boolean;
}) => Promise<Array<{ filePath: string; line: number; content: string }>>;
}; };
theme: { theme: {
@@ -128,6 +134,20 @@ export interface ElectronAPI {
openDir: () => void; openDir: () => void;
delete: (filename: string) => Promise<unknown>; delete: (filename: string) => Promise<unknown>;
}; };
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;
}>;
};
} }
declare global { declare global {
+1 -1
View File
@@ -7,7 +7,7 @@ export interface FileEntry {
path: string; path: string;
isDirectory: boolean; isDirectory: boolean;
size?: number; size?: number;
modifiedAt?: string; modifiedAt?: number;
} }
export interface FileResult { export interface FileResult {
@@ -9,35 +9,57 @@ vi.mock('@/lib/ipc', () => ({
}, },
})); }));
/**
* The crash:read IPC handler returns a plain array; ipc.crash.read wraps it
* in { ok: true, data }. The component must unwrap .data before rendering
* — otherwise dumps.length is undefined and dumps.map blows up.
*/
function wrap(data: unknown) {
return { ok: true, data };
}
describe('CrashReportModal', () => { describe('CrashReportModal', () => {
test('shows empty state when no crashes', async () => { test('shows empty state when no crashes', async () => {
(ipc.crash.read as any).mockResolvedValue([]); (ipc.crash.read as any).mockResolvedValue(wrap([]));
render(<CrashReportModal onClose={() => {}} />);
expect(await screen.findByText(/no crashes/i)).toBeInTheDocument();
});
test('shows empty state when ipc returns ok:false', async () => {
(ipc.crash.read as any).mockResolvedValue({
ok: false,
error: { code: 'NO_BRIDGE', message: 'no electronAPI' },
});
render(<CrashReportModal onClose={() => {}} />); render(<CrashReportModal onClose={() => {}} />);
expect(await screen.findByText(/no crashes/i)).toBeInTheDocument(); expect(await screen.findByText(/no crashes/i)).toBeInTheDocument();
}); });
test('lists crashes returned by ipc', async () => { test('lists crashes returned by ipc', async () => {
(ipc.crash.read as any).mockResolvedValue([ (ipc.crash.read as any).mockResolvedValue(
wrap([
{ {
filename: '1700000000000-1-uncaughtException.json', filename: '1700000000000-1-uncaughtException.json',
kind: 'uncaughtException', kind: 'uncaughtException',
message: 'boom', message: 'boom',
timestamp: '2026-01-01T00:00:00.000Z', timestamp: '2026-01-01T00:00:00.000Z',
}, },
]); ])
);
render(<CrashReportModal onClose={() => {}} />); render(<CrashReportModal onClose={() => {}} />);
expect(await screen.findByText(/boom/)).toBeInTheDocument(); expect(await screen.findByText(/boom/)).toBeInTheDocument();
}); });
test('delete button calls ipc.crash.delete', async () => { test('delete button calls ipc.crash.delete', async () => {
(ipc.crash.read as any).mockResolvedValue([ (ipc.crash.read as any).mockResolvedValue(
wrap([
{ {
filename: '1700000000000-1-uncaughtException.json', filename: '1700000000000-1-uncaughtException.json',
kind: 'uncaughtException', kind: 'uncaughtException',
message: 'boom', message: 'boom',
timestamp: '2026-01-01T00:00:00.000Z', timestamp: '2026-01-01T00:00:00.000Z',
}, },
]); ])
);
render(<CrashReportModal onClose={() => {}} />); render(<CrashReportModal onClose={() => {}} />);
const btn = await screen.findByText(/delete/i); const btn = await screen.findByText(/delete/i);
fireEvent.click(btn); fireEvent.click(btn);
@@ -47,7 +69,7 @@ describe('CrashReportModal', () => {
}); });
test('open folder button calls ipc.crash.openDir', async () => { test('open folder button calls ipc.crash.openDir', async () => {
(ipc.crash.read as any).mockResolvedValue([]); (ipc.crash.read as any).mockResolvedValue(wrap([]));
render(<CrashReportModal onClose={() => {}} />); render(<CrashReportModal onClose={() => {}} />);
fireEvent.click(await screen.findByText(/open dump folder/i)); fireEvent.click(await screen.findByText(/open dump folder/i));
expect(ipc.crash.openDir).toHaveBeenCalled(); expect(ipc.crash.openDir).toHaveBeenCalled();
+98
View File
@@ -0,0 +1,98 @@
/**
* E2E smoke for monospace embedding. Runs against the vendored fonts when
* available. If pandoc is missing, the PDF-specific test is skipped; the
* DOCX/EPUB/HTML tests exercise the JS modules directly and run either way.
*/
const { execFileSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
function ttfAvailable() {
const ttf = path.resolve(__dirname, '..', 'assets', 'fonts', 'JetBrainsMono-Regular.ttf');
return fs.existsSync(ttf);
}
function pandocAvailable() {
try {
execFileSync('pandoc', ['--version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
describe('monospace E2E', () => {
let tmp;
beforeAll(() => {
if (ttfAvailable()) {
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mono-e2e-'));
}
});
afterAll(() => {
if (tmp) fs.rmSync(tmp, { recursive: true, force: true });
});
it('DOCX export embeds the TTF', async () => {
if (!ttfAvailable()) return;
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),
'JetBrainsMono'
);
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');
});
(ttfAvailable() && pandocAvailable() ? it : it.skip)(
'PDF export references the monospace font (xelatex header)',
() => {
const { buildPdfFontHeader } = require('../src/main/PdfFontHeader');
const { getMonoFontTtfPath } = require('../src/main/MonospaceFontConfig');
const ttf = getMonoFontTtfPath('jetbrains-mono', 400);
const out = buildPdfFontHeader(
{ monospaceFont: 'jetbrains-mono', monospaceLigatures: false },
ttf,
'JetBrains Mono'
);
const content = fs.readFileSync(out.headerPath, 'utf-8');
expect(content).toContain('\\setmonofont');
expect(content).toContain('JetBrainsMono-Regular.ttf');
fs.rmSync(out.dir, { recursive: true, force: true });
}
);
});
@@ -0,0 +1,37 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { applyMonospaceClasses } from "../../../src/renderer/hooks/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);
expect(document.body.classList.contains('mono-ligatures-off')).toBe(true);
});
});
+72
View File
@@ -0,0 +1,72 @@
/**
* Regression: src/main/files/git.js must return a flat array from the
* 'git-status' handler so that the renderer's `result.data.map(...)` works.
* Previously the handler returned `{ files: [...] }` and the renderer blew
* up with `n.map is not a function`, blanking the entire UI.
*/
const EventEmitter = require('events');
class FakeIpcMain extends EventEmitter {
constructor() {
super();
this.handlers = new Map();
}
handle(channel, handler) {
this.handlers.set(channel, handler);
}
}
describe('files/git IPC handler', () => {
let ipc;
let register;
let currentFileRef;
beforeEach(() => {
jest.resetModules();
ipc = new FakeIpcMain();
currentFileRef = { current: null };
jest.doMock('electron', () => ({ ipcMain: ipc }));
jest.doMock('../../../../src/main/GitOperations', () => ({
getStatus: () => ({ files: [{ filePath: 'a.md', status: 'modified' }] }),
}));
({ register } = require('../../../../src/main/files/git'));
});
test('git-status returns a flat array, not a wrapped object', async () => {
register(currentFileRef);
const handler = ipc.handlers.get('git-status');
const result = await handler({}, '/repo');
expect(Array.isArray(result)).toBe(true);
expect(result).toEqual([{ filePath: 'a.md', status: 'modified' }]);
});
test('git-status falls back to [] when GitOperations returns no files', async () => {
jest.resetModules();
ipc = new FakeIpcMain();
jest.doMock('electron', () => ({ ipcMain: ipc }));
jest.doMock('../../../../src/main/GitOperations', () => ({
getStatus: () => ({ files: undefined }),
}));
({ register } = require('../../../../src/main/files/git'));
register(currentFileRef);
const handler = ipc.handlers.get('git-status');
const result = await handler({}, '/repo');
expect(Array.isArray(result)).toBe(true);
expect(result).toEqual([]);
});
test('git-status returns [] for non-git directories (error path)', async () => {
jest.resetModules();
ipc = new FakeIpcMain();
jest.doMock('electron', () => ({ ipcMain: ipc }));
jest.doMock('../../../../src/main/GitOperations', () => ({
getStatus: () => ({ files: [], error: 'Not a git repository' }),
}));
({ register } = require('../../../../src/main/files/git'));
register(currentFileRef);
const handler = ipc.handlers.get('git-status');
const result = await handler({}, '/repo');
expect(Array.isArray(result)).toBe(true);
expect(result).toEqual([]);
});
});
@@ -0,0 +1,63 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { listDirectoryEntries } = require('../../../../src/main/files/list-directory');
describe('listDirectoryEntries', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'list-dir-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('returns a flat array (not wrapped in { entries })', () => {
fs.writeFileSync(path.join(tmpDir, 'a.md'), 'hello');
const result = listDirectoryEntries(tmpDir);
expect(Array.isArray(result)).toBe(true);
expect(result.length).toBe(1);
expect(result[0]).toMatchObject({
name: 'a.md',
isDirectory: false,
size: 5,
path: path.join(tmpDir, 'a.md'),
});
});
test('excludes dotfiles', () => {
fs.writeFileSync(path.join(tmpDir, '.hidden'), 'x');
fs.writeFileSync(path.join(tmpDir, 'visible.md'), 'y');
const result = listDirectoryEntries(tmpDir);
expect(result.map((e) => e.name)).toEqual(['visible.md']);
});
test('sorts directories first then alphabetically', () => {
fs.writeFileSync(path.join(tmpDir, 'zebra.md'), 'z');
fs.mkdirSync(path.join(tmpDir, 'aaa'));
fs.mkdirSync(path.join(tmpDir, 'bbb'));
fs.writeFileSync(path.join(tmpDir, 'apple.md'), 'a');
const result = listDirectoryEntries(tmpDir);
expect(result.map((e) => e.name)).toEqual(['aaa', 'bbb', 'apple.md', 'zebra.md']);
});
test('marks directories with isDirectory: true and size: 0', () => {
fs.mkdirSync(path.join(tmpDir, 'sub'));
const result = listDirectoryEntries(tmpDir);
expect(result[0]).toMatchObject({ name: 'sub', isDirectory: true, size: 0 });
});
test('skips entries that cannot be stat\'d (broken symlinks)', () => {
fs.writeFileSync(path.join(tmpDir, 'good.md'), 'ok');
try {
fs.symlinkSync(path.join(tmpDir, 'nonexistent'), path.join(tmpDir, 'broken-link'));
} catch (_) {
// symlinks not supported in env — skip
}
const result = listDirectoryEntries(tmpDir);
expect(result.find((e) => e.name === 'good.md')).toBeTruthy();
expect(result.find((e) => e.name === 'broken-link')).toBeUndefined();
});
});
@@ -0,0 +1,170 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const { searchInFiles } = require('../../../../src/main/files/search-in-files');
describe('searchInFiles', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'search-in-files-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
test('returns empty array when rootPath or query missing', () => {
expect(searchInFiles({ rootPath: '', query: 'x' })).toEqual([]);
expect(searchInFiles({ rootPath: tmpDir, query: '' })).toEqual([]);
});
test('finds a literal substring in a single file', () => {
const f = path.join(tmpDir, 'a.md');
fs.writeFileSync(f, 'hello world\nsecond line\nhello again');
const out = searchInFiles({ rootPath: tmpDir, query: 'hello' });
expect(out).toEqual([
{ filePath: f, line: 1, content: 'hello world' },
{ filePath: f, line: 3, content: 'hello again' },
]);
});
test('respects caseSensitive flag (default false)', () => {
const f = path.join(tmpDir, 'a.md');
fs.writeFileSync(f, 'Foo bar\nfoo BAR');
expect(searchInFiles({ rootPath: tmpDir, query: 'foo' }).length).toBe(2);
expect(searchInFiles({ rootPath: tmpDir, query: 'foo', caseSensitive: true }).length).toBe(1);
});
test('supports regex when isRegex is true', () => {
const f = path.join(tmpDir, 'a.md');
fs.writeFileSync(f, 'foo123\nbar\nbaz456');
const out = searchInFiles({ rootPath: tmpDir, query: '\\w+\\d+', isRegex: true });
expect(out.map((r) => r.content)).toEqual(['foo123', 'baz456']);
});
test('invalid regex returns empty array (does not throw)', () => {
expect(searchInFiles({ rootPath: tmpDir, query: '[unclosed', isRegex: true })).toEqual([]);
});
test('skips node_modules, .git, dist directories', () => {
const skip = path.join(tmpDir, 'node_modules');
fs.mkdirSync(skip);
fs.writeFileSync(path.join(skip, 'a.md'), 'match me');
const keep = path.join(tmpDir, 'src');
fs.mkdirSync(keep);
fs.writeFileSync(path.join(keep, 'b.md'), 'match me');
const out = searchInFiles({ rootPath: tmpDir, query: 'match' });
expect(out.length).toBe(1);
expect(out[0].filePath).toContain('src/b.md');
});
test('skips files larger than 2MB', () => {
const big = path.join(tmpDir, 'big.md');
fs.writeFileSync(big, 'a'.repeat(3 * 1024 * 1024));
expect(searchInFiles({ rootPath: tmpDir, query: 'a' })).toEqual([]);
});
test('recurses into subdirectories', () => {
const sub = path.join(tmpDir, 'a', 'b', 'c');
fs.mkdirSync(sub, { recursive: true });
const f = path.join(sub, 'deep.md');
fs.writeFileSync(f, 'needle here');
const out = searchInFiles({ rootPath: tmpDir, query: 'needle' });
expect(out.length).toBe(1);
expect(out[0].filePath).toBe(f);
expect(out[0].line).toBe(1);
});
test('caps results at 1000', () => {
const f = path.join(tmpDir, 'many.md');
const lines = [];
for (let i = 0; i < 1500; i++) lines.push('match line');
fs.writeFileSync(f, lines.join('\n'));
const out = searchInFiles({ rootPath: tmpDir, query: 'match' });
expect(out.length).toBe(1000);
});
test('rejects queries longer than 1024 chars (DoS guard)', () => {
const f = path.join(tmpDir, 'a.md');
fs.writeFileSync(f, 'hello');
const longQ = 'a'.repeat(2000);
expect(searchInFiles({ rootPath: tmpDir, query: longQ })).toEqual([]);
});
test('rejects regexes with nested quantifiers (ReDoS guard)', () => {
const f = path.join(tmpDir, 'a.md');
fs.writeFileSync(f, 'aaaaaaaaaaaaaab');
// Classic catastrophic backtracking pattern
expect(
searchInFiles({ rootPath: tmpDir, query: '(a+)+b', isRegex: true })
).toEqual([]);
// Allowed: non-nested alternation
expect(
searchInFiles({ rootPath: tmpDir, query: 'a+b', isRegex: true }).length
).toBeGreaterThanOrEqual(0);
});
test('rejects regexes with lookahead / lookbehind', () => {
fs.writeFileSync(path.join(tmpDir, 'a.md'), 'hello');
expect(
searchInFiles({ rootPath: tmpDir, query: '(?=hello)h', isRegex: true })
).toEqual([]);
expect(
searchInFiles({ rootPath: tmpDir, query: '(?!foo)h', isRegex: true })
).toEqual([]);
});
test('rejects regexes with backrefs', () => {
fs.writeFileSync(path.join(tmpDir, 'a.md'), 'hello hello');
expect(
searchInFiles({ rootPath: tmpDir, query: '(hello)\\1', isRegex: true })
).toEqual([]);
});
test('rejects regexes with class + quantifier', () => {
fs.writeFileSync(path.join(tmpDir, 'a.md'), 'abc');
expect(
searchInFiles({ rootPath: tmpDir, query: '[a-z]+', isRegex: true })
).toEqual([]);
});
test('rejects regexes longer than 200 chars', () => {
fs.writeFileSync(path.join(tmpDir, 'a.md'), 'x');
const longRe = 'a'.repeat(250);
expect(
searchInFiles({ rootPath: tmpDir, query: longRe, isRegex: true })
).toEqual([]);
// But 200 chars is fine
const okRe = 'a'.repeat(200);
expect(
searchInFiles({ rootPath: tmpDir, query: okRe, isRegex: true }).length
).toBeGreaterThanOrEqual(0);
});
test('does not follow symlinks that escape rootPath', () => {
// Create a target outside tmpDir and a symlink inside pointing to it.
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'search-outside-'));
const targetFile = path.join(outside, 'secret.md');
fs.writeFileSync(targetFile, 'should not be searched');
try {
fs.symlinkSync(targetFile, path.join(tmpDir, 'evil-link'));
const insideFile = path.join(tmpDir, 'inside.md');
fs.writeFileSync(insideFile, 'inside match');
const out = searchInFiles({ rootPath: tmpDir, query: 'match' });
expect(out.find((r) => r.filePath.includes('evil-link'))).toBeUndefined();
expect(out.find((r) => r.filePath === insideFile)).toBeTruthy();
} finally {
fs.rmSync(outside, { recursive: true, force: true });
}
});
test('caps files traversed at 10000', () => {
// Create many small files
for (let i = 0; i < 50; i++) {
fs.writeFileSync(path.join(tmpDir, `f${i}.md`), 'x');
}
const out = searchInFiles({ rootPath: tmpDir, query: 'x' });
expect(out.length).toBeLessThanOrEqual(1000); // capped at MAX_RESULTS too
});
});
@@ -0,0 +1,65 @@
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/);
});
});
@@ -0,0 +1,53 @@
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/);
});
});
@@ -0,0 +1,36 @@
const { buildExportCss, buildFontFaceBlock } = require('../../../../src/main/ExportCss');
describe('ExportCss', () => {
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 + ligatures when enabled', () => {
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');
});
});
@@ -0,0 +1,75 @@
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, '../../../..');
const fontsDir = path.join(repoRoot, 'assets', 'fonts');
fs.mkdirSync(fontsDir, { recursive: true });
const target = path.join(fontsDir, '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 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');
});
});
@@ -0,0 +1,67 @@
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');
fs.rmSync(out.dir, { recursive: true, force: true });
});
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');
fs.rmSync(out.dir, { recursive: true, force: true });
});
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);
fs.rmSync(out.dir, { recursive: true, force: true });
});
test('throws if ttfPath does not exist', () => {
expect(() =>
buildPdfFontHeader(
{ monospaceFont: 'jetbrains-mono', monospaceLigatures: false },
'/nonexistent.ttf',
'JetBrains Mono'
)
).toThrow(/TTF/);
});
});
@@ -0,0 +1,57 @@
const { EventEmitter } = require('events');
class FakeIpcMain extends EventEmitter {
constructor() {
super();
this.handlers = new Map();
}
handle(channel, handler) {
this.handlers.set(channel, handler);
}
}
describe('monospace-handlers', () => {
let ipc;
let register;
let store;
beforeEach(() => {
jest.resetModules();
ipc = new FakeIpcMain();
store = new Map();
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 Promise.resolve(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 Promise.resolve(
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 Promise.resolve(handler({}, { monospaceFont: 'comic-sans' })).then((result) => {
expect(result.monospaceFont).toBe('jetbrains-mono');
});
});
});
@@ -0,0 +1,40 @@
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);
});
});