mirror of
https://github.com/amitwh/markdown-converter.git
synced 2026-08-02 18:10:18 +05:30
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f63a678c88 | ||
|
|
5e76d77ece | ||
|
|
f43e34ca2b | ||
|
|
6da2ba7cc7 | ||
|
|
f1f8a16a79 | ||
|
|
772a791d9a | ||
|
|
ec3d53ea7e | ||
|
|
50c6369348 | ||
|
|
81d38160d8 | ||
|
|
28ef350f39 | ||
|
|
d29f19b1f5 | ||
|
|
b2ad8b8326 | ||
|
|
d2f3118bfe | ||
|
|
4cb38cd861 | ||
|
|
65dfdfb307 | ||
|
|
f1c3aaa0ef | ||
|
|
0c37a8ca2d | ||
|
|
992c6b72d6 | ||
|
|
001c9463e3 | ||
|
|
3602bc35a7 | ||
|
|
14b5a38a5a | ||
|
|
33a61e65ef | ||
|
|
857fd8a75a | ||
|
|
b8d26c8d78 |
@@ -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
@@ -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.
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
```
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+20
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
{
|
||||||
kind: 'uncaughtException',
|
filename: '1700000000000-1-uncaughtException.json',
|
||||||
message: 'boom',
|
kind: 'uncaughtException',
|
||||||
timestamp: '2026-01-01T00:00:00.000Z',
|
message: 'boom',
|
||||||
},
|
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',
|
{
|
||||||
kind: 'uncaughtException',
|
filename: '1700000000000-1-uncaughtException.json',
|
||||||
message: 'boom',
|
kind: 'uncaughtException',
|
||||||
timestamp: '2026-01-01T00:00:00.000Z',
|
message: 'boom',
|
||||||
},
|
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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user