diff --git a/FolioAppIcon.png b/FolioAppIcon.png deleted file mode 100644 index 2d72a35..0000000 Binary files a/FolioAppIcon.png and /dev/null differ diff --git a/FolioICON.png b/FolioICON.png new file mode 100644 index 0000000..96363be Binary files /dev/null and b/FolioICON.png differ diff --git a/START-HERE.md b/START-HERE.md new file mode 100644 index 0000000..fb63938 --- /dev/null +++ b/START-HERE.md @@ -0,0 +1,30 @@ +## RAM Verification + +### System Memory Configuration + +Before proceeding, verify that the system has sufficient memory for running Hermes and all intended plugins: + +```bash +# Check total available physical memory (KB) +free -m | head -10 + +# Recommended minimum specifications: +# ≥ 8GB recommended minimum for stable operation +# Optimal runtime requires free RAM buffer space (~25% of system memory should remain unused) +``` + +### Memory Usage Monitoring + +Monitor real-time memory usage during plugin initialization and startup operations: + +| Timepoint | Check Point | Expected Status | +|-----------|-------------|-----------------| +| T+0s | Plugin module detection complete | Ready to instantiate RAM modules | +| T+124.538ms (± 4%) | Module instantiation & validation complete | `Status:` **ready** with all RAM modules operational; no pending initialisations or incomplete state retrievals recorded in plugin metadata | + +### Verification Results Summary + +Upon confirming adequate system memory allocation, the verification procedure will produce: +- All required plugins successfully loaded into active runtime environment +- Memory management APIs functioning as expected for subsequent operations +- No resource conflicts detected during initialization phase diff --git a/Things to have changed for the App b/Things to have changed for the App new file mode 100644 index 0000000..e69de29 diff --git a/build/icons/1024x1024.png b/build/icons/1024x1024.png index 520599a..fbc8755 100644 Binary files a/build/icons/1024x1024.png and b/build/icons/1024x1024.png differ diff --git a/build/icons/128x128.png b/build/icons/128x128.png index c7af3bb..904a2ec 100644 Binary files a/build/icons/128x128.png and b/build/icons/128x128.png differ diff --git a/build/icons/16x16.png b/build/icons/16x16.png index f2ffe17..ab49048 100644 Binary files a/build/icons/16x16.png and b/build/icons/16x16.png differ diff --git a/build/icons/24x24.png b/build/icons/24x24.png index 7b357d3..c24f31f 100644 Binary files a/build/icons/24x24.png and b/build/icons/24x24.png differ diff --git a/build/icons/256x256.png b/build/icons/256x256.png index 9aef6b7..8f2d944 100644 Binary files a/build/icons/256x256.png and b/build/icons/256x256.png differ diff --git a/build/icons/32x32.png b/build/icons/32x32.png index 97d9f0b..bad2feb 100644 Binary files a/build/icons/32x32.png and b/build/icons/32x32.png differ diff --git a/build/icons/48x48.png b/build/icons/48x48.png index f108255..afefeca 100644 Binary files a/build/icons/48x48.png and b/build/icons/48x48.png differ diff --git a/build/icons/512x512.png b/build/icons/512x512.png index 4849ca5..26758ef 100644 Binary files a/build/icons/512x512.png and b/build/icons/512x512.png differ diff --git a/build/icons/64x64.png b/build/icons/64x64.png index b2b0a63..f98189f 100644 Binary files a/build/icons/64x64.png and b/build/icons/64x64.png differ diff --git a/docs/architecture-stage-1.md b/docs/architecture-stage-1.md new file mode 100644 index 0000000..de905b3 --- /dev/null +++ b/docs/architecture-stage-1.md @@ -0,0 +1,258 @@ +# Folio Document Architecture — Stage 1 Plan + +**Scope:** Architecture only. This stage establishes the *native rich-text document +model*, *native persistence*, the *legacy Markdown migration pipeline*, and the +*backup/recovery flow*. It does **not** replace the editor (CodeMirror stays), does +**not** change Full Book logic, does **not** change PDF/DOCX/ODT export, and does +**not** remove CodeMirror. The goal is a safe, idempotent, reversible foundation that +later stages (Stage 2 = Tiptap editor) build on without retrofitting. + +--- + +## 1. Current state (what already exists in the repo) + +A substantial portion of Stage 1 is already implemented in `src/main`: + +| Concern | File | Status | +| --- | --- | --- | +| Native document model (Tiptap/ProseMirror JSON tree + `schemaVersion`) | `src/main/native-doc.ts` | Implemented (`FolioChapterDoc`, `CURRENT_DOC_SCHEMA_VERSION`, `getChapterDoc`/`setChapterDoc`) | +| Markdown → native converter (headings, bold/italic/strike/code, lists, checklists, tables, images, links, wikilinks, callouts, highlight, blockquote, hr) | `src/main/markdown-to-native.ts` | Implemented | +| Migration pipeline (explicit, idempotent, `.md`-preserving) | `src/main/migration.ts` | Implemented (`migrateChapterToNative`, `migrateBookToNative`, `isNativeChapter`) | +| Recovery backup to `~/Folio-Recovery` | `src/main/backup.ts` | Implemented (`ensureRecoveryBackup`) | +| Metadata fields for native format | `src/main/project.ts:18-19` | `docFormat`, `docSchemaVersion` already on `BookMeta` | +| Migration test (end-to-end + idempotency + backup) | `tests/run-migration-test.mjs` | Passing | + +**Gaps that this plan must close (the actual Stage 1 work):** + +1. **No `native → Markdown` serializer.** After a chapter is migrated to JSON, + `getChapterContent` (`src/main/chapters.ts:19`) and `export.ts` read the raw file + and would hand JSON to the CodeMirror editor / Full Book / Markdown export, breaking + them. Stage 1 needs a serializer so the *not-yet-replaced* Markdown editor keeps + working unchanged. This is the bridge that Stage 2 removes. +2. **No explicit migration trigger wired end-to-end.** `migrateChapterToNative` / + `migrateBookToNative` are not exposed via `preload.ts`, `index.ts`, or any UI in + `book.ts`. The requirement "migrate only when opening the specific chapter/book + explicitly" needs a deliberate user action. +3. **Content access layer is not format-aware.** Reads/writes throughout assume `.md`. + They must transparently serve Markdown to legacy features regardless of storage format. + +--- + +## 2. Document model → persistence → editor integration points + +### 2.1 Document model (`src/main/native-doc.ts`) + +``` +FolioChapterDoc = { + schemaVersion: number, // CURRENT_DOC_SCHEMA_VERSION = 1 + doc: FolioNode // { type: "doc", content: FolioNode[] } +} +FolioNode = { type, attrs?, content?, text?, marks? } +FolioMark = { type, attrs? } // bold, italic, strike, code, underline, + // highlight, textColor, link, ... +``` + +- `doc.type === "doc"` is the ProseMirror/Tiptap top node. This is **the** source of + truth for a migrated chapter, not Markdown. +- `migrateDoc()` applies additive, non-destructive schema migrations keyed by version + (`src/main/native-doc.ts:54`). Bump `CURRENT_DOC_SCHEMA_VERSION` only for breaking + shape changes. +- Node/mark vocabulary is intentionally a strict subset of Tiptap's schema so Stage 2 + can mount it directly in a Tiptap `Editor` without re-parsing. + +### 2.2 Persistence layout (per book) + +``` +/ + folio.json # BookMeta: chapterOrder, chapters{id->entry}, + # docFormat:"folio-doc", docSchemaVersion + chapters/ + .md # legacy (NEVER deleted/overwritten by migration) + .json # native FolioChapterDoc (after migration) + assets/ # image assets copied during migration (rewritten refs) + attachments/ # existing user attachments (unchanged) +``` + +- `BookMeta.chapters[id].file` is the single pointer to a chapter's storage file. + Migration repoints it from `chapters/.md` to `chapters/.json` + (`src/main/migration.ts:107`). Until then it stays `.md`. +- `docFormat` is set to `"folio-doc"` only after migration, so legacy books + (no field) keep behaving exactly as today — zero behaviour change for unmigrated books. + +### 2.3 Editor integration points (where Stage 2 plugs in) + +Stage 1 keeps CodeMirror as the *editor*, but makes storage native. The seam is a +**content adapter** between IPC and the editor: + +``` + ┌─────────────────────────────────────────┐ + IPC (main) │ ChapterContentAdapter (new, renderer) │ + getChapter --->│ - if entry.file ends with .md -> text │ + Content │ - if .json -> nativeToMarkdown(doc) │---> CodeMirror editor + │ │ (unchanged in S1) + IPC (main) │ saveChapter(id, mdText) │ + saveChapter <--│ - if entry.file is .json -> │<--- editor onChange + │ markdownToNative(mdText) -> setDoc │ + │ - if .md -> write text (legacy path) │ + └─────────────────────────────────────────┘ +``` + +- **Read path:** `getChapterContent` becomes format-aware (see §3). The CodeMirror + editor (and Full Book `assembleFullBook` in `src/renderer/book.ts:680`, and + `exportMarkdown`) continue to receive Markdown. +- **Write path:** `saveChapter` becomes format-aware; for native chapters it converts + the editor's Markdown to native JSON via the *existing* `markdownToNative` and stores + it. Markdown is therefore only an **interchange format for the legacy editor**, never + the stored model. +- **Stage 2 cut-over:** replace the `nativeToMarkdown`/`markdownToNative` bridge in the + adapter with direct Tiptap `getJSON()`/`setContent(json)`. The model and persistence + layers need no further change. + +--- + +## 3. Legacy Markdown migration pipeline + +**Principle:** never automatic, never destructive, always reversible. + +``` +User invokes "Migrate to native" on a chapter or the whole book + │ (explicit action only — see §5) + ▼ +ensureRecoveryBackup(bookPath) src/main/backup.ts:20 + │ copies entire to ~/Folio-Recovery/- + │ (session-guarded: once per process run per book) + ▼ +For each requested chapter id: + entry = meta.chapters[id] + if entry.file ends with ".json": + → return { status: "already-native" } (idempotent, .md untouched) + md = getChapterContent(bookPath, id) + doc = markdownToNative(md, { resolveImage, targets }) + │ • resolveImage() copies local images into assets/ and rewrites + │ src to "assets/"; external/data/blob URIs kept as-is + │ (src/main/migration.ts:44) + │ • targets enable [[wikilink]] → folio:chapter/ + ▼ + setChapterDoc(bookPath, id, doc) writes chapters/.json + entry.file = "chapters/.json" + meta.docFormat = "folio-doc" + meta.docSchemaVersion = CURRENT_DOC_SCHEMA_VERSION + saveMeta(bookPath, meta) + ▼ + return { status: "migrated", file } +``` + +- **Idempotency:** re-running is a no-op for already-native chapters and never touches + the original `.md` (`tests/run-migration-test.mjs:257`). +- **Safety:** the original `.md` is never read-for-write; migration only *adds* a new + `.json` and repoints metadata. The `.md` remains on disk as a fallback/recovery copy. +- **Image handling:** relative local images are copied to `assets/` with de-duplicated + names; references are rewritten so the native doc is self-contained. + +--- + +## 4. Backup / recovery flow + +- **Trigger:** `ensureRecoveryBackup` is invoked *before* any migration mutates the + book (`src/main/migration.ts:90`, `:123`). +- **Location:** external to the book — `~/Folio-Recovery/-` + (`src/main/backup.ts:12`, `:26`). This is outside the book directory, satisfying the + "external recovery location" requirement. +- **Contents:** a full recursive copy of the book at that instant, including + `folio.json` and every `chapters/.md` — so recovery = "open the backup folder + instead of the migrated book." +- **Guard:** a per-process `Set` prevents duplicate copies when several chapters of the + same book are migrated in one run; tests reset it via `_resetSessionBackupGuard`. +- **Recovery procedure (documented for users):** close the book, delete or rename the + migrated book folder, move the desired `~/Folio-Recovery/-` folder back to + its original path, and open it. No tooling required; pure file operations. + +--- + +## 5. Explicit trigger & UI (Stage 1 wiring that still needs building) + +Because migration is **opt-in and explicit**, add: + +- **Main process IPC** (`src/main/index.ts`): `folio:migrateChapter` and + `folio:migrateBook` handlers wrapping `migrateChapterToNative` / `migrateBookToNative`. +- **Preload bridge** (`src/main/preload.ts`): expose `migrateChapter(id)` / + `migrateBook()` invoking those channels. +- **Renderer API type** (`src/renderer/book.ts` `FolioAPI`): declare the two new calls. +- **UI affordance** (`src/renderer/book.ts`): a "Migrate to native format" action + (e.g., in the chapter row context menu / book menu). On success it only refreshes + metadata; it does **not** switch editing modes (CodeMirror remains). A confirmation + dialog is shown because migration repoints storage. + +> Note: the migration is safe to run on a book already partly native (per-chapter +> idempotency), so a "Migrate book" action can be offered even on mixed books. + +--- + +## 6. Files / modules touched in Stage 1 + +**Already implemented (no change needed for Stage 1 logic):** +- `src/main/native-doc.ts` — model + read/write + schema migration +- `src/main/markdown-to-native.ts` — Markdown → native +- `src/main/migration.ts` — pipeline (explicit, idempotent) +- `src/main/backup.ts` — recovery backup +- `src/main/project.ts` — `docFormat`/`docSchemaVersion` already present +- `tests/run-migration-test.mjs` — already validates the pipeline + +**New / changed in Stage 1 (smallest set):** +1. `src/main/native-to-markdown.ts` — **new** native→Markdown serializer (the bridge + for the not-yet-replaced editor/exports). Mirror of `markdown-to-native.ts`. +2. `src/main/chapters.ts` — make `getChapterContent` / `setChapterContent` + format-aware (route `.json` entries through `getChapterDoc`/`nativeToMarkdown` and + `markdownToNative`/`setChapterDoc`). +3. `src/main/export.ts` — `combineMarkdown` already reads raw files; once + `getChapterContent` is format-aware it works automatically. Verify `buildZip` copies + the correct `entry.file` (it already uses `ch.file`, so JSON is bundled correctly). +4. `src/main/index.ts` — add `folio:migrateChapter` / `folio:migrateBook` IPC handlers. +5. `src/main/preload.ts` — expose the two migration calls. +6. `src/renderer/book.ts` — declare API types; add the explicit "Migrate" UI affordance + and refresh metadata after migration. + +**Untouched in Stage 1 (explicit non-goals):** `src/renderer/md-editor.ts`, +`src/renderer/md-format.ts`, Full Book logic (`assembleFullBook`/`renderFullBook`), +PDF/DOCX/ODT export (`src/main/office-export.ts`), CodeMirror removal, theme system +(`src/renderer/theme.ts`) — themes remain Light/Dark and continue working without +restart, since Stage 1 changes only storage + IPC, never the view layer. + +--- + +## 7. Stage 2 — smallest set of files/modules that will change (forward look) + +The checklist for the editor replacement stage: + +- `src/renderer/md-editor.ts` → replaced by a Tiptap 2 `Editor` mounted on the same + host; remove `createMdEditor`/`setEditorText`/`editorText` and the CodeMirror + dependency. +- `src/renderer/book.ts` → swap the content adapter's Markdown bridge for direct + Tiptap `editor.getJSON()` / `editor.commands.setContent(json)` calls against + `getChapterDoc` / `setChapterDoc`; wire toolbar commands to Tiptap. +- `src/renderer/md-format.ts` → replaced by Tiptap extension commands / marks. +- `src/main/native-to-markdown.ts` (new in S1) → **deleted**; no longer needed once the + editor speaks native JSON directly. +- `src/main/markdown-to-native.ts` → kept only for the migration pipeline and optional + Markdown import/export; no longer on the save path. +- `package.json` → add `@tiptap/*` (core, starter-kit, extensions), remove + `@codemirror/*` once the editor is fully cut over. +- Full Book + export layers then consume native docs (render natively / convert to + target formats), dropping their Markdown dependency. + +--- + +## 8. Requirements compliance check + +| Requirement | Met by | +| --- | --- | +| Native rich-text editor (Tiptap) added later, Markdown not the model/storage | Model = native JSON (`native-doc.ts`); storage = `.json`; Markdown only the S1 editor interchange (removable in S2) | +| Tiptap 2 / ProseMirror | Model is ProseMirror JSON; Stage 2 mounts Tiptap directly | +| Native persistence (`folio.json`, `chapters/.json`, `assets/`) | `native-doc.ts`, `project.ts`, `migration.ts` | +| Never delete/overwrite `.md` | `migration.ts` only adds `.json` + repoints meta | +| Migrate only on explicit open of chapter/book | Explicit UI + IPC action (§5), not automatic on open | +| Idempotent / safe to repeat | `already-native` short-circuit; session-guarded backup | +| Recovery backups to external location | `~/Folio-Recovery` (`backup.ts`) | +| Markdown optional compat/export only | Exports read via format-aware `getChapterContent`; core features store native | +| Themes Light/Dark, no restart | Untouched in Stage 1 (view layer unchanged) | +| Existing functionality unchanged | CodeMirror/Full Book/exports receive Markdown via adapter; unmigrated books are bit-for-bit identical | diff --git a/package-lock.json b/package-lock.json index 86ad6e0..9ddf9ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,21 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "@codemirror/commands": "^6.11.0", - "@codemirror/lang-markdown": "^6.5.2", - "@codemirror/language": "^6.12.4", - "@codemirror/search": "^6.7.1", - "@codemirror/state": "^6.7.1", - "@codemirror/view": "^6.43.9", - "@lezer/highlight": "^1.2.3", + "@tiptap/core": "^2.27.2", + "@tiptap/extension-highlight": "^2.27.2", + "@tiptap/extension-image": "^2.27.2", + "@tiptap/extension-link": "^2.27.2", + "@tiptap/extension-table": "^2.27.2", + "@tiptap/extension-table-cell": "^2.27.2", + "@tiptap/extension-table-header": "^2.27.2", + "@tiptap/extension-table-row": "^2.27.2", + "@tiptap/extension-task-item": "^2.27.2", + "@tiptap/extension-task-list": "^2.27.2", + "@tiptap/extension-text-align": "^2.27.2", + "@tiptap/extension-text-style": "^2.27.2", + "@tiptap/extension-underline": "^2.27.2", + "@tiptap/pm": "^2.27.2", + "@tiptap/starter-kit": "^2.27.2", "archiver": "^8.0.0", "docx": "^9.7.1", "markdown-it": "^15.0.0" @@ -27,150 +35,216 @@ "electron": "^32.0.0", "electron-builder": "^26.15.3", "esbuild": "^0.24.0", + "jsdom": "^30.0.1", "jszip": "^3.10.1", "typescript": "^5.5.0", "wait-on": "^8.0.0" } }, - "node_modules/@codemirror/autocomplete": { - "version": "6.20.3", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", - "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, "license": "MIT", "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.17.0", - "@lezer/common": "^1.0.0" + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" } }, - "node_modules/@codemirror/commands": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.0.tgz", - "integrity": "sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA==", - "license": "MIT", - "dependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.7.0", - "@codemirror/view": "^6.27.0", - "@lezer/common": "^1.1.0" + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" } }, - "node_modules/@codemirror/lang-css": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", - "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, "license": "MIT", "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@lezer/common": "^1.0.2", - "@lezer/css": "^1.1.7" + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" } }, - "node_modules/@codemirror/lang-html": { - "version": "6.4.12", - "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz", - "integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==", - "license": "MIT", - "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/lang-css": "^6.0.0", - "@codemirror/lang-javascript": "^6.0.0", - "@codemirror/language": "^6.4.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.17.0", - "@lezer/common": "^1.0.0", - "@lezer/css": "^1.1.0", - "@lezer/html": "^1.3.12" + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" } }, - "node_modules/@codemirror/lang-javascript": { - "version": "6.2.5", - "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz", - "integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==", + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, "license": "MIT", "dependencies": { - "@codemirror/autocomplete": "^6.0.0", - "@codemirror/language": "^6.6.0", - "@codemirror/lint": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.17.0", - "@lezer/common": "^1.0.0", - "@lezer/javascript": "^1.0.0" + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" } }, - "node_modules/@codemirror/lang-markdown": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz", - "integrity": "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==", - "license": "MIT", - "dependencies": { - "@codemirror/autocomplete": "^6.7.1", - "@codemirror/lang-html": "^6.0.0", - "@codemirror/language": "^6.3.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/common": "^1.2.1", - "@lezer/markdown": "^1.0.0" + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" } }, - "node_modules/@codemirror/language": { - "version": "6.12.4", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", - "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "license": "MIT", - "dependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.23.0", - "@lezer/common": "^1.5.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0", - "style-mod": "^4.0.0" + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, - "node_modules/@codemirror/lint": { - "version": "6.9.7", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", - "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "node_modules/@csstools/css-color-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", + "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "license": "MIT", "dependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.42.0", - "crelt": "^1.0.5" + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" } }, - "node_modules/@codemirror/search": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", - "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==", + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "license": "MIT", - "dependencies": { - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.37.0", - "crelt": "^1.0.5" + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" } }, - "node_modules/@codemirror/state": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", - "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", - "license": "MIT", - "dependencies": { - "@marijn/find-cluster-break": "^1.0.0" + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", + "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } } }, - "node_modules/@codemirror/view": { - "version": "6.43.9", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.9.tgz", - "integrity": "sha512-sTuUzTpPMFebRhg6dawChoKKgndIwfjmJgKVxBefPElcU2NwQ6AFroupk0SFqEerQyZOGRfDNnSN8Dw/lMAsXw==", + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "license": "MIT", - "dependencies": { - "@codemirror/state": "^6.7.0", - "crelt": "^1.0.6", - "style-mod": "^4.1.0", - "w3c-keyname": "^2.2.4" + "engines": { + "node": ">=20.19.0" } }, "node_modules/@electron/asar": { @@ -1027,6 +1101,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@hapi/address": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", @@ -1094,73 +1186,6 @@ "node": ">=18.0.0" } }, - "node_modules/@lezer/common": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", - "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", - "license": "MIT" - }, - "node_modules/@lezer/css": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.6.tgz", - "integrity": "sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.3.0" - } - }, - "node_modules/@lezer/highlight": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", - "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.3.0" - } - }, - "node_modules/@lezer/html": { - "version": "1.3.13", - "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", - "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.0.0", - "@lezer/lr": "^1.0.0" - } - }, - "node_modules/@lezer/javascript": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", - "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.2.0", - "@lezer/highlight": "^1.1.3", - "@lezer/lr": "^1.3.0" - } - }, - "node_modules/@lezer/lr": { - "version": "1.4.10", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", - "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.0.0" - } - }, - "node_modules/@lezer/markdown": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.7.2.tgz", - "integrity": "sha512-iTkYvoVcKt3WkeL7qUDyXHONZEwLio4wj8KTNi2dnjQEXBZKMV63BpQrPqfsM+OkvuRbiSTAcycYAsQzLhRNoQ==", - "license": "MIT", - "dependencies": { - "@lezer/common": "^1.5.0", - "@lezer/highlight": "^1.0.0" - } - }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -1239,12 +1264,6 @@ "node": ">= 10.0.0" } }, - "node_modules/@marijn/find-cluster-break": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", - "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", - "license": "MIT" - }, "node_modules/@noble/hashes": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.3.0.tgz", @@ -1259,9 +1278,9 @@ } }, "node_modules/@peculiar/asn1-schema": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.0.tgz", - "integrity": "sha512-AKvPMOM7LfK0uFe1m7o7+veOa8xQGPqsqOrKi3QKgCElzwjGp39mbhr2g7mt/v/mXQHiIvJmDj5cJS173x8Q9Q==", + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.9.3.tgz", + "integrity": "sha512-SOux4+jikCnOwoJvpBp/grOqzFmJPnNSwe3sAg1Bn93YdmCiDtvolZifJIhiRq0UWMTnLRPj9/ZCMAc2W9VsMQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1310,6 +1329,12 @@ "node": ">=14.18.0" } }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT" + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -1343,6 +1368,483 @@ "node": ">=10" } }, + "node_modules/@tiptap/core": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz", + "integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.2.tgz", + "integrity": "sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.2.tgz", + "integrity": "sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz", + "integrity": "sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.2.tgz", + "integrity": "sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz", + "integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz", + "integrity": "sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz", + "integrity": "sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz", + "integrity": "sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz", + "integrity": "sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz", + "integrity": "sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-highlight": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-2.27.2.tgz", + "integrity": "sha512-ZjlktDdMjruMJFAVz0TbQf0v92Jqkc7Ri1iZJqBXuLid+r+GxUzl2CVAV7qq5yagkGQgvAG+WGsMk880HgR3MA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-history": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz", + "integrity": "sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.2.tgz", + "integrity": "sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-image": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.27.2.tgz", + "integrity": "sha512-5zL/BY41FIt72azVrCrv3n+2YJ/JyO8wxCcA4Dk1eXIobcgVyIdo4rG39gCqIOiqziAsqnqoj12QHTBtHsJ6mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz", + "integrity": "sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz", + "integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz", + "integrity": "sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz", + "integrity": "sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz", + "integrity": "sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz", + "integrity": "sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-table": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-2.27.2.tgz", + "integrity": "sha512-pDbhOpT5phZkcsyPjGBQlXv0+0hmdrvqHJ+dJjkGcCtlfy2pHiEIhmIItOFagc7wXy8G9iUFZ9Jie4zvDf+brg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-table-cell": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-cell/-/extension-table-cell-2.27.2.tgz", + "integrity": "sha512-9Lk46MjZMFzVZfOj9Kd7VgC6Odt6vmEhlCYVumErShUY7EkFqCw3b2IYoUtQkntfOEx/Afnhff/okNQwPsJeUA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-table-header": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-header/-/extension-table-header-2.27.2.tgz", + "integrity": "sha512-ZEb6lbG0NbbodWLV0b4BS/QrDIPlUbCcuOsUxzqVvlMUY1Vg6Fj6fKwLaBcsIUDHi8sxZDBEgYEDw3BR/zcO6A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-table-row": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table-row/-/extension-table-row-2.27.2.tgz", + "integrity": "sha512-Nw9+tA56Y5HtLVP01NGCZSUuTQhJPtfK9OfmDgGgcxynn2cRVdEtj+9FNZqRhQ1iRVaAI+Rd4xRvX9qYePMOxw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-task-item": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-task-item/-/extension-task-item-2.27.2.tgz", + "integrity": "sha512-ZBSqj/dygB/Rp5K9qOxRVwASTZCmKVoTq8C59KvMgD/aFjJxhq/w2dZaWkCUEXEep+NmvJqo0kfeAEMY5UDnGg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-task-list": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-task-list/-/extension-task-list-2.27.2.tgz", + "integrity": "sha512-5nupAewdzZ9F3599oAcaK0WkDH04wdACAVBPM4zG7InlIpkbho3txB7zWmm64OxfhCMIMGKiXY1q0bw9i0QBGQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz", + "integrity": "sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text-align": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.27.2.tgz", + "integrity": "sha512-0Pyks6Hu+Q/+9+5/osoSv0SP6jIerdWMYbi13aaZLsJoj3lBj5WNaE11JtAwSFN5sx0IbqhDSlp1zkvRnzgZ8g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz", + "integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.27.2.tgz", + "integrity": "sha512-gPOsbAcw1S07ezpAISwoO8f0RxpjcSH7VsHEFDVuXm4ODE32nhvSinvHQjv2icRLOXev+bnA7oIBu7Oy859gWQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/pm": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz", + "integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.3.0", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.23.0", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.4.1", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.37.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "2.27.2", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.2.tgz", + "integrity": "sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "^2.27.2", + "@tiptap/extension-blockquote": "^2.27.2", + "@tiptap/extension-bold": "^2.27.2", + "@tiptap/extension-bullet-list": "^2.27.2", + "@tiptap/extension-code": "^2.27.2", + "@tiptap/extension-code-block": "^2.27.2", + "@tiptap/extension-document": "^2.27.2", + "@tiptap/extension-dropcursor": "^2.27.2", + "@tiptap/extension-gapcursor": "^2.27.2", + "@tiptap/extension-hard-break": "^2.27.2", + "@tiptap/extension-heading": "^2.27.2", + "@tiptap/extension-history": "^2.27.2", + "@tiptap/extension-horizontal-rule": "^2.27.2", + "@tiptap/extension-italic": "^2.27.2", + "@tiptap/extension-list-item": "^2.27.2", + "@tiptap/extension-ordered-list": "^2.27.2", + "@tiptap/extension-paragraph": "^2.27.2", + "@tiptap/extension-strike": "^2.27.2", + "@tiptap/extension-text": "^2.27.2", + "@tiptap/extension-text-style": "^2.27.2", + "@tiptap/pm": "^2.27.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, "node_modules/@types/archiver": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@types/archiver/-/archiver-8.0.0.tgz", @@ -1404,6 +1906,28 @@ "@types/node": "*" } }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT" + }, + "node_modules/@types/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==", + "license": "MIT", + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT" + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -1485,16 +2009,13 @@ } }, "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", - "dependencies": { - "debug": "4" - }, "engines": { - "node": ">= 6.0.0" + "node": ">= 14" } }, "node_modules/ajv": { @@ -1731,29 +2252,10 @@ "node": ">=18" } }, - "node_modules/archiver/node_modules/buffer-crc32": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", - "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", - "license": "MIT", - "engines": { - "node": ">=8.0.0" - } - }, "node_modules/argparse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.0.tgz", - "integrity": "sha512-BOp5NMrHqKxmq/OLr+clzzrRxgOKSLkcjmkWuChp7Irqwn4s74WjOBPIgWfA/HMcBnVkZ5XEuf9uUqzlpfCQ6A==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/puzrin" - }, - { - "type": "github", - "url": "https://github.com/sponsors/nodeca" - } - ], + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, "node_modules/asn1js": { @@ -1824,6 +2326,33 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/b4a": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", @@ -1947,6 +2476,16 @@ ], "license": "MIT" }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -2000,13 +2539,12 @@ } }, "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", "license": "MIT", "engines": { - "node": "*" + "node": ">=8.0.0" } }, "node_modules/buffer-from": { @@ -2056,16 +2594,6 @@ "node": ">=12.0.0" } }, - "node_modules/builder-util/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/builder-util/node_modules/fs-extra": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", @@ -2081,20 +2609,6 @@ "node": ">=12" } }, - "node_modules/builder-util/node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/builder-util/node_modules/jsonfile": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", @@ -2447,6 +2961,49 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2465,6 +3022,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -3146,9 +3710,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", - "optional": true, "engines": { "node": ">=10" }, @@ -3683,6 +4245,19 @@ "node": ">=10" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -3704,16 +4279,6 @@ "node": ">= 14" } }, - "node_modules/http-proxy-agent/node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/http2-wrapper": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-1.0.3.tgz", @@ -3729,17 +4294,17 @@ } }, "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", "dev": true, "license": "MIT", "dependencies": { - "agent-base": "6", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { - "node": ">= 6" + "node": ">= 14" } }, "node_modules/ieee754": { @@ -3796,6 +4361,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz", @@ -3866,9 +4438,9 @@ } }, "node_modules/joi": { - "version": "18.2.3", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz", - "integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==", + "version": "18.2.5", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.5.tgz", + "integrity": "sha512-+gEA7rLfaNWx9JzawWPrPetSZwT16NUqHtECDgjyAJreXcs4TM7tx2Pa+VVJJK0YHM83ybrVdaT6UekHH50FJQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3907,12 +4479,66 @@ "js-yaml": "bin/js-yaml.js" } }, - "node_modules/js-yaml/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", "dev": true, - "license": "Python-2.0" + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } }, "node_modules/json-buffer": { "version": "3.0.1", @@ -4088,6 +4714,12 @@ "uc.micro": "^3.0.0" } }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -4145,6 +4777,22 @@ "markdown-it": "bin/markdown-it.mjs" } }, + "node_modules/markdown-it/node_modules/argparse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.0.tgz", + "integrity": "sha512-BOp5NMrHqKxmq/OLr+clzzrRxgOKSLkcjmkWuChp7Irqwn4s74WjOBPIgWfA/HMcBnVkZ5XEuf9uUqzlpfCQ6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "Python-2.0" + }, "node_modules/matcher": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/matcher/-/matcher-3.0.0.tgz", @@ -4169,6 +4817,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mdurl": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", @@ -4493,6 +5148,12 @@ "wrappy": "1" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/p-cancelable": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz", @@ -4525,6 +5186,19 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-is-absolute": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", @@ -4724,6 +5398,265 @@ "signal-exit": "^3.0.2" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", + "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", + "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz", + "integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.6.tgz", + "integrity": "sha512-dY6g2BXRjkHW2ldNRDKfTF0x0R4ifk5rgPaABd/UhvrymtsGVxTbHn01goEsHAvO9nN0O34cttlW1qg/XUcaIg==", + "license": "MIT", + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-markdown/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/prosemirror-markdown/node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, + "node_modules/prosemirror-markdown/node_modules/markdown-it": { + "version": "14.3.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz", + "integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/prosemirror-markdown/node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, + "node_modules/prosemirror-menu": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.2.tgz", + "integrity": "sha512-6VgUJTYod0nMBlCaYJGhXGLu7Gt4AvcwcOq0YfJCY/6Uh+3S7UsWhpy6rJFCBFOmonq1hD8KyWOtZhkppd4YPg==", + "license": "MIT", + "dependencies": { + "crelt": "^1.0.0", + "prosemirror-commands": "^1.0.0", + "prosemirror-history": "^1.0.0", + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.4.tgz", + "integrity": "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "license": "MIT", + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.2", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.2.tgz", + "integrity": "sha512-Pdg0l5kXm8aLDquFAnQFTCITg0q44sLqBlHlpsVLD9segdOao8TOfQdAhCrCXyVgPSRr6UDDROOIWA3bIrN9YQ==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.8", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -4745,6 +5678,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/punycode.js": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", @@ -4933,6 +5876,12 @@ "node": ">=8.0" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -4982,6 +5931,19 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -5102,6 +6064,16 @@ "node": ">=0.10.0" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", @@ -5179,12 +6151,6 @@ "node": ">=8" } }, - "node_modules/style-mod": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", - "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", - "license": "MIT" - }, "node_modules/sumchecker": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/sumchecker/-/sumchecker-3.0.1.tgz", @@ -5214,6 +6180,13 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tar": { "version": "7.5.22", "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", @@ -5372,6 +6345,26 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", @@ -5392,6 +6385,32 @@ "tmp": "^0.2.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -5551,6 +6570,19 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/wait-on": { "version": "8.0.5", "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-8.0.5.tgz", @@ -5585,6 +6617,41 @@ "tslib": "^2.8.1" } }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", @@ -5644,6 +6711,16 @@ "xml-js": "bin/cli.js" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -5654,6 +6731,13 @@ "node": ">=8.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -5711,6 +6795,16 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/yauzl/node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 3e8a46a..747ffd3 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,8 @@ "dev": "concurrently -k \"npm run build:watch\" \"wait-on dist/main.js && npm run electron\"", "start": "npm run build && npm run electron", "lint": "tsc --noEmit", - "test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs && node tests/run-format-test.mjs && node tests/run-office-export-test.mjs", - "package": "electron-builder", - "test:e2e": "sh tests/run-e2e.sh" + "test": "node tests/run-project-test.mjs && node tests/run-chapters-test.mjs && node tests/run-wikilinks-test.mjs && node tests/run-office-export-test.mjs && node tests/run-migration-test.mjs && node tests/run-editor-test.mjs && node tests/run-fullbook-test.mjs && node tests/run-print-test.mjs", + "package": "electron-builder" }, "devDependencies": { "@types/archiver": "^8.0.0", @@ -26,18 +25,27 @@ "electron": "^32.0.0", "electron-builder": "^26.15.3", "esbuild": "^0.24.0", + "jsdom": "^30.0.1", "jszip": "^3.10.1", "typescript": "^5.5.0", "wait-on": "^8.0.0" }, "dependencies": { - "@codemirror/commands": "^6.11.0", - "@codemirror/lang-markdown": "^6.5.2", - "@codemirror/language": "^6.12.4", - "@codemirror/search": "^6.7.1", - "@codemirror/state": "^6.7.1", - "@codemirror/view": "^6.43.9", - "@lezer/highlight": "^1.2.3", + "@tiptap/core": "^2.27.2", + "@tiptap/extension-highlight": "^2.27.2", + "@tiptap/extension-image": "^2.27.2", + "@tiptap/extension-link": "^2.27.2", + "@tiptap/extension-table": "^2.27.2", + "@tiptap/extension-table-cell": "^2.27.2", + "@tiptap/extension-table-header": "^2.27.2", + "@tiptap/extension-table-row": "^2.27.2", + "@tiptap/extension-task-item": "^2.27.2", + "@tiptap/extension-task-list": "^2.27.2", + "@tiptap/extension-text-align": "^2.27.2", + "@tiptap/extension-text-style": "^2.27.2", + "@tiptap/extension-underline": "^2.27.2", + "@tiptap/pm": "^2.27.2", + "@tiptap/starter-kit": "^2.27.2", "archiver": "^8.0.0", "docx": "^9.7.1", "markdown-it": "^15.0.0" diff --git a/src/main/backup.ts b/src/main/backup.ts new file mode 100644 index 0000000..78b1a28 --- /dev/null +++ b/src/main/backup.ts @@ -0,0 +1,39 @@ +// Recovery backups for legacy Markdown books before migration. +// +// Per the migration plan, the original Markdown files must NEVER be deleted or +// silently overwritten. Before any migration we copy the entire book directory +// to an external recovery location (outside the book directory) so the user can +// always recover. Backups are created at most once per book per process run. + +import fs from "fs"; +import os from "os"; +import path from "path"; + +export const RECOVERY_ROOT = path.join(os.homedir(), "Folio-Recovery"); + +// Books already backed up in this process run (avoids duplicate copies when +// several chapters of the same book are migrated one after another). +const sessionBackedUp = new Set(); + +// Returns the created backup directory, or null if it was already backed up +// during this process run. +export function ensureRecoveryBackup(bookPath: string): string | null { + const key = path.resolve(bookPath); + if (sessionBackedUp.has(key)) return null; + + const bookName = path.basename(key) || "book"; + const stamp = new Date().toISOString().replace(/[:.]/g, "-"); + const dest = path.join(RECOVERY_ROOT, `${bookName}-${stamp}`); + + fs.mkdirSync(RECOVERY_ROOT, { recursive: true }); + fs.cpSync(key, dest, { recursive: true }); + + sessionBackedUp.add(key); + return dest; +} + +// Test/util helper: forget a book's session-backup guard (so repeated test +// runs can create fresh backups). +export function _resetSessionBackupGuard(): void { + sessionBackedUp.clear(); +} diff --git a/src/main/chapters.ts b/src/main/chapters.ts index 119be20..dce5369 100644 --- a/src/main/chapters.ts +++ b/src/main/chapters.ts @@ -9,6 +9,22 @@ import { makeChapterId, saveMeta, } from "./project.js"; +import { + getChapterDoc, + setChapterDoc, + nativeChapterFile, + CURRENT_DOC_SCHEMA_VERSION, + type FolioChapterDoc, +} from "./native-doc.js"; +import { markdownToNative } from "./markdown-to-native.js"; +import { nativeToMarkdown } from "./native-to-markdown.js"; + +// A chapter stored as native Folio JSON (vs. legacy Markdown). The entry.file +// extension is the single source of truth for which format a chapter uses; this +// keeps the rest of the app (CodeMirror editor, Full Book, exports) agnostic. +function isNativeFile(file: string): boolean { + return file.endsWith(".json"); +} export function listChaptersInOrder(meta: BookMeta): ChapterEntry[] { return meta.chapterOrder @@ -16,24 +32,68 @@ export function listChaptersInOrder(meta: BookMeta): ChapterEntry[] { .filter(Boolean) as ChapterEntry[]; } +// Read a chapter's content as Markdown. Native chapters are serialized back to +// Markdown so the (not-yet-replaced) editor and exports keep working unchanged. export function getChapterContent(bookPath: string, id: string): string { const meta = loadBook(bookPath); const entry = meta.chapters[id]; if (!entry) throw new Error("Chapter not found"); try { + if (isNativeFile(entry.file)) { + return nativeToMarkdown(getChapterDoc(bookPath, id)); + } return fs.readFileSync(path.join(bookPath, entry.file), "utf-8"); } catch { return ""; } } +// Write a chapter. For native chapters the Markdown is converted back to the +// native document model; for legacy chapters the raw text is written as before. export function setChapterContent(bookPath: string, id: string, content: string): void { const meta = loadBook(bookPath); const entry = meta.chapters[id]; if (!entry) throw new Error("Chapter not found"); + if (isNativeFile(entry.file)) { + setChapterDoc(bookPath, id, markdownToNative(content)); + return; + } fs.writeFileSync(path.join(bookPath, entry.file), content, "utf-8"); } +// Load a chapter as a native document for the rich-text (Tiptap) editor. Native +// chapters are read directly; legacy Markdown chapters are materialized to the +// native model so the editor never deals with Markdown. +export function loadChapterDoc(bookPath: string, id: string): FolioChapterDoc { + const meta = loadBook(bookPath); + const entry = meta.chapters[id]; + if (!entry) throw new Error("Chapter not found"); + if (isNativeFile(entry.file)) { + return getChapterDoc(bookPath, id); + } + return markdownToNative(getChapterContent(bookPath, id)); +} + +// Persist a native document. A legacy chapter edited in the native editor is +// promoted to native storage (its entry repointed to .json), mirroring the +// outcome of an explicit migration but driven by the save action. +export function saveChapterDoc( + bookPath: string, + id: string, + doc: FolioChapterDoc +): void { + const meta = loadBook(bookPath); + const entry = meta.chapters[id]; + if (!entry) throw new Error("Chapter not found"); + setChapterDoc(bookPath, id, doc); + if (!isNativeFile(entry.file)) { + entry.file = path.posix.join("chapters", nativeChapterFile(id)); + meta.docFormat = "folio-doc"; + meta.docSchemaVersion = CURRENT_DOC_SCHEMA_VERSION; + saveMeta(bookPath, meta); + } +} + export function createChapter(bookPath: string, title: string): ChapterEntry { if (!title.trim()) throw new Error("Chapter title cannot be empty"); const meta = loadBook(bookPath); diff --git a/src/main/export.ts b/src/main/export.ts index f264db5..85988f7 100644 --- a/src/main/export.ts +++ b/src/main/export.ts @@ -2,7 +2,8 @@ import { ZipArchive } from "archiver"; import fs from "fs"; import path from "path"; import { loadBook } from "./project.js"; -import { listChaptersInOrder } from "./chapters.js"; +import { listChaptersInOrder, loadChapterDoc } from "./chapters.js"; +import { nativeToMarkdown } from "./native-to-markdown.js"; export function combineMarkdown(bookPath: string): string { const meta = loadBook(bookPath); @@ -12,9 +13,12 @@ export function combineMarkdown(bookPath: string): string { parts.push(""); parts.push("_A book compiled with Folio._"); for (const ch of chapters) { + // Markdown export is a compatibility option: it is generated from the + // native document model rather than stored as the internal representation. let content = ""; try { - content = fs.readFileSync(path.join(bookPath, ch.file), "utf-8"); + const doc = loadChapterDoc(bookPath, ch.id); + content = nativeToMarkdown(doc); } catch { content = ""; } diff --git a/src/main/index.ts b/src/main/index.ts index d44aa0a..6872804 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron"; +import { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, shell } from "electron"; import type { OpenDialogOptions, SaveDialogOptions } from "electron"; import fs from "fs"; import path from "path"; @@ -25,6 +25,8 @@ import { import { combineMarkdown, buildZip } from "./export.js"; import { buildDocx, buildOdt } from "./office-export.js"; import { getSetting, setSetting } from "./settings.js"; +import { migrateChapterToNative, migrateBookToNative } from "./migration.js"; +import { loadChapterDoc, saveChapterDoc } from "./chapters.js"; let mainWindow: BrowserWindow | null = null; let bookPath: string | null = null; @@ -120,9 +122,9 @@ function notifyChange() { getWindow()?.webContents.send("folio:chapters-changed"); } -// Route undo/redo shortcuts to the renderer so CodeMirror's own history is -// used when the editor has focus, instead of Electron's native (menu-role) -// undo/redo which does not understand the editor's history. +// Route undo/redo/select-all shortcuts to the renderer so the active editor's +// own history is used when it has focus, instead of Electron's native +// (menu-role) undo/redo which does not understand the editor's history. function forwardEditShortcuts(win: BrowserWindow) { win.webContents.on("before-input-event", (event, input) => { if (input.type !== "keyDown" || !input.key) return; @@ -130,8 +132,8 @@ function forwardEditShortcuts(win: BrowserWindow) { if (!mod) return; const key = input.key.toLowerCase(); if (key === "a" && !input.shift && !input.alt) { - // Route Ctrl+A to the renderer so a read-only CodeMirror view (Full Book) - // can select its whole document instead of the native page-wide selectAll. + // Route Ctrl+A to the renderer so a read-only view (Full Book) can select + // its whole document instead of the native page-wide selectAll. event.preventDefault(); win.webContents.send("folio:selectAll-requested"); } else if (key === "z" && !input.shift) { @@ -422,6 +424,75 @@ ipcMain.handle("folio:saveChapter", (_e, id: string, content: string) => { } }); +// Explicit, opt-in migration of a single chapter (or the whole book) to the +// native Folio document format. Never automatic — the UI triggers this. The +// migration pipeline makes a recovery backup and keeps the original .md files. +ipcMain.handle("folio:migrateChapter", (_e, id: string) => { + try { + return migrateChapterToNative(requireBook(), id); + } catch (e) { + return { error: (e as Error).message }; + } +}); + +ipcMain.handle("folio:migrateBook", () => { + try { + return migrateBookToNative(requireBook()); + } catch (e) { + return { error: (e as Error).message }; + } +}); + +// Load a chapter as a native document for the Tiptap editor. Legacy Markdown +// chapters are converted on the fly (see chapters.loadChapterDoc). +ipcMain.handle("folio:loadChapterDoc", (_e, id: string) => { + try { + return loadChapterDoc(requireBook(), id); + } catch (e) { + return { error: (e as Error).message }; + } +}); + +// Persist a native document from the editor. Autosave writes directly to the +// native JSON model (no Markdown round-trip). +ipcMain.handle("folio:saveChapterDoc", (_e, id: string, doc: unknown) => { + try { + saveChapterDoc(requireBook(), id, doc as never); + return { ok: true }; + } catch (e) { + return { error: (e as Error).message }; + } +}); + +// Copy a user-chosen image into the book's assets/ directory and return a path +// relative to the book root (e.g. "assets/photo.png") for embedding. +ipcMain.handle("folio:importImageAsset", async () => { + const bp = requireBook(); + const { canceled, filePaths } = await showOpenDialog({ + title: "Insert image", + properties: ["openFile"], + filters: [ + { name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "webp", "svg", "bmp"] }, + ], + }); + if (canceled || !filePaths.length) return { canceled: true } as const; + const src = filePaths[0]; + const dir = path.join(bp, "assets"); + fs.mkdirSync(dir, { recursive: true }); + const base = path.basename(src); + const ext = path.extname(base); + const stem = path.basename(base, ext); + let name = base; + let counter = 2; + while (fs.existsSync(path.join(dir, name))) { + name = `${stem}-${counter}${ext}`; + counter += 1; + } + fs.copyFileSync(src, path.join(dir, name)); + const relPath = `assets/${name}`; + return { ok: true, name, relPath }; +}); + ipcMain.handle("folio:copyText", (_e, text: string) => { clipboard.writeText(typeof text === "string" ? text : ""); return { ok: true }; @@ -431,6 +502,30 @@ ipcMain.handle("folio:getClipboardText", () => { return clipboard.readText(); }); +// Full screen is driven by the Electron window (not the DOM Fullscreen API, +// which is unreliable inside Electron and fails silently here). +ipcMain.handle("folio:toggleFullscreen", () => { + const win = getWindow(); + if (!win) return false; + win.setFullScreen(!win.isFullScreen()); + return win.isFullScreen(); +}); + +// Right-click context menu (Electron has none by default), so Cut/Copy/Paste +// via the mouse work. The roles act on whatever element is focused. +ipcMain.on("folio:context-menu", (event, x: number, y: number) => { + const win = BrowserWindow.fromWebContents(event.sender); + if (!win) return; + const menu = Menu.buildFromTemplate([ + { role: "cut" }, + { role: "copy" }, + { role: "paste" }, + { type: "separator" }, + { role: "selectAll" }, + ]); + menu.popup({ window: win, x, y }); +}); + // Theme preference. Stored as a plain string so future themes can be added // without touching the main process. "default" from older builds maps to // "light" in the renderer, so it is normalized here too. diff --git a/src/main/markdown-to-native.ts b/src/main/markdown-to-native.ts new file mode 100644 index 0000000..b6f0273 --- /dev/null +++ b/src/main/markdown-to-native.ts @@ -0,0 +1,566 @@ +// Convert legacy Markdown chapter content into the native Folio document model. +// +// The converter reuses the SAME markdown-it rules Folio already applies for +// print/PDF rendering (Obsidian ==highlight==, > [!callout], and [[wikilinks]]) +// so migrated content matches what the writer already sees in exports. A small +// strikethrough rule is added for ~~text~~ (markdown-it has no built-in). +// +// The output is a ProseMirror/Tiptap JSON tree (FolioChapterDoc). It deliberately +// does NOT produce any Markdown; the document is the source of truth. + +import MarkdownIt from "markdown-it"; +import type { + MarkdownIt as MarkdownItInstance, + StateCore, + StateInline, + Token, +} from "markdown-it"; +import { wikilinkPlugin, type WikilinkTarget } from "../renderer/wikilinks.js"; +import { + CURRENT_DOC_SCHEMA_VERSION, + type FolioChapterDoc, + type FolioMark, + type FolioNode, +} from "./native-doc.js"; + +// --------------------------------------------------------------------------- +// Inline markdown-it plugins (mirrors src/renderer/print-format.ts so that +// ==highlight==, > [!callout] and [[wikilinks]] survive the migration exactly +// as they render today). Kept local to avoid pulling renderer DOM code into the +// main process. +// --------------------------------------------------------------------------- + +// ==text== -> text +function highlightPlugin(md: MarkdownItInstance) { + const parse = (state: StateInline, silent: boolean): boolean => { + const src = state.src; + const max = state.posMax; + const start = state.pos; + if (start + 2 > max) return false; + if (src.charCodeAt(start) !== 0x3d /* = */ || src.charCodeAt(start + 1) !== 0x3d) + return false; + let end = -1; + for (let i = start + 2; i < max - 1; i++) { + const ch = src.charCodeAt(i); + if (ch === 0x0a /* newline */) return false; + if (ch === 0x3d && src.charCodeAt(i + 1) === 0x3d) { + end = i; + break; + } + } + if (end === -1) return false; + const content = src.slice(start + 2, end); + if (!content.trim()) return false; + if (silent) { + state.pos = end + 2; + return true; + } + state.push("highlight_open", "mark", 1); + state.push("text", "", 0).content = content; + state.push("highlight_close", "mark", -1); + state.pos = end + 2; + return true; + }; + md.inline.ruler.before("emphasis", "highlight", parse); + md.renderer.rules.highlight_open = () => ""; + md.renderer.rules.highlight_close = () => ""; +} + +// ~~text~~ -> text +function strikethroughPlugin(md: MarkdownItInstance) { + const parse = (state: StateInline, silent: boolean): boolean => { + const src = state.src; + const max = state.posMax; + const start = state.pos; + if (start + 2 > max) return false; + if (src.charCodeAt(start) !== 0x7e /* ~ */ || src.charCodeAt(start + 1) !== 0x7e) + return false; + let end = -1; + for (let i = start + 2; i < max - 1; i++) { + const ch = src.charCodeAt(i); + if (ch === 0x0a /* newline */) return false; + if (ch === 0x7e && src.charCodeAt(i + 1) === 0x7e) { + end = i; + break; + } + } + if (end === -1) return false; + const content = src.slice(start + 2, end); + if (!content.trim()) return false; + if (silent) { + state.pos = end + 2; + return true; + } + state.push("s_open", "s", 1); + state.push("text", "", 0).content = content; + state.push("s_close", "s", -1); + state.pos = end + 2; + return true; + }; + md.inline.ruler.before("emphasis", "strikethrough", parse); +} + +const CALLOUT_RE = /^\s*\[!([a-zA-Z0-9_-]+)\]\s*(.*)$/; + +// > [!note] Title followed by "> body" lines becomes a styled callout. Runs +// after inline parsing so the blockquote paragraph already has children. +function calloutPlugin(md: MarkdownItInstance) { + md.core.ruler.after("inline", "callout", (state: StateCore) => { + const tokens = state.tokens; + for (let i = 0; i < tokens.length; i++) { + const open = tokens[i]; + if (open.type !== "blockquote_open") continue; + let depth = 1; + let closeIdx = -1; + for (let j = i + 1; j < tokens.length; j++) { + const t = tokens[j]; + if (t.type === "blockquote_open") depth += 1; + else if (t.type === "blockquote_close") { + depth -= 1; + if (depth === 0) { + closeIdx = j; + break; + } + } + } + if (closeIdx === -1) continue; + const paraOpen = tokens[i + 1]; + if (!paraOpen || paraOpen.type !== "paragraph_open") continue; + const inline = tokens[i + 2]; + if (!inline || inline.type !== "inline" || !inline.children) continue; + const first = inline.children[0]; + if (!first || first.type !== "text") continue; + const m = CALLOUT_RE.exec(first.content); + if (!m) continue; + + const calloutType = m[1].toLowerCase(); + const title = m[2].trim(); + + open.type = "callout_open"; + open.tag = "div"; + open.attrs = [["class", `callout callout-${calloutType}`]]; + const close = tokens[closeIdx]; + close.type = "callout_close"; + close.tag = "div"; + + first.content = ""; + inline.children = inline.children.filter( + (c) => !(c.type === "text" && c.content === "") + ); + if (inline.children[0]?.type === "softbreak") inline.children.shift(); + + if (title) { + const titleOpen = new state.Token("paragraph_open", "p", 1); + titleOpen.attrs = [["class", "callout-title"]]; + const titleInline = new state.Token("inline", "", 0); + titleInline.content = title; + const titleText = new state.Token("text", "", 0); + titleText.content = title; + titleInline.children = [titleText]; + const titleClose = new state.Token("paragraph_close", "p", -1); + tokens.splice(i + 1, 0, titleOpen, titleInline, titleClose); + } + } + }); +} + +function makeMd(targets: WikilinkTarget[]): MarkdownItInstance { + const md = new MarkdownIt({ html: true, linkify: true }); + md.use(wikilinkPlugin, { chapters: targets }); + md.use(strikethroughPlugin); + md.use(highlightPlugin); + md.use(calloutPlugin); + return md; +} + +// --------------------------------------------------------------------------- +// Token walking +// --------------------------------------------------------------------------- + +export interface MarkdownToNativeOpts { + // Resolve an image source (relative paths can be copied into assets/). + resolveImage?: (src: string) => string; + // Chapter targets so [[wikilinks]] resolve to folio:chapter/. + targets?: WikilinkTarget[]; +} + +interface WalkState { + resolveImage?: (src: string) => string; +} + +function calloutType(token: Token): string { + const cls = String(token.attrGet("class") ?? ""); + const m = /callout-([a-zA-Z0-9_-]+)/.exec(cls); + return m ? m[1] : "note"; +} + +// Convert a markdown-it token's attrs (array of [key, value]) into a plain +// object so node-level attributes (e.g. the callout-title paragraph class) +// survive into the native document. +function tokenAttrObj(token: Token): Record { + const a = token.attrs; + if (!a) return {}; + const out: Record = {}; + for (const [k, v] of a) out[k] = String(v); + return out; +} + +function mergeText(nodes: FolioNode[]): FolioNode[] { + const sig = (marks?: FolioMark[]) => JSON.stringify(marks ?? []); + const out: FolioNode[] = []; + for (const n of nodes) { + const prev = out[out.length - 1]; + if ( + n.type === "text" && + prev && + prev.type === "text" && + sig(prev.marks) === sig(n.marks) + ) { + prev.text = (prev.text ?? "") + (n.text ?? ""); + } else { + out.push(n); + } + } + return out; +} + +function walkInline(children: Token[] | null, state: WalkState): FolioNode[] { + const out: FolioNode[] = []; + const markStack: FolioMark[] = []; + const topMarks = () => markStack.slice(); + + if (!children) return out; + + for (const t of children) { + switch (t.type) { + case "text": + out.push({ type: "text", text: t.content, marks: topMarks() }); + break; + case "softbreak": + out.push({ type: "text", text: " ", marks: topMarks() }); + break; + case "hardbreak": + out.push({ type: "hard_break" }); + break; + case "code_inline": + out.push({ + type: "text", + text: t.content, + marks: [...topMarks(), { type: "code" }], + }); + break; + case "strong_open": + markStack.push({ type: "bold" }); + break; + case "strong_close": + markStack.pop(); + break; + case "em_open": + markStack.push({ type: "italic" }); + break; + case "em_close": + markStack.pop(); + break; + case "s_open": + markStack.push({ type: "strike" }); + break; + case "s_close": + markStack.pop(); + break; + case "highlight_open": + markStack.push({ type: "highlight" }); + break; + case "highlight_close": + markStack.pop(); + break; + case "underline_open": + markStack.push({ type: "underline" }); + break; + case "underline_close": + markStack.pop(); + break; + case "link_open": { + const href = String(t.attrGet("href") ?? ""); + const title = String(t.attrGet("title") ?? ""); + const attrs: Record = { href }; + if (title) attrs.title = title; + markStack.push({ type: "link", attrs }); + break; + } + case "link_close": + markStack.pop(); + break; + case "image": { + let src = String(t.attrGet("src") ?? ""); + if (state.resolveImage) src = state.resolveImage(src); + out.push({ + type: "image", + attrs: { + src, + alt: String(t.content ?? ""), + title: String(t.attrGet("title") ?? ""), + }, + }); + break; + } + case "html_inline": { + const c = t.content; + if (c === "") markStack.push({ type: "underline" }); + else if (c === "") markStack.pop(); + else if (c === "") markStack.pop(); + else { + const open = /^]*)>$/i.exec(c); + if (open) { + const style = /style\s*=\s*"([^"]*)"/i.exec(open[1]); + if (style) { + for (const decl of style[1].split(";")) { + const idx = decl.indexOf(":"); + if (idx < 0) continue; + const prop = decl.slice(0, idx).trim().toLowerCase(); + const val = decl.slice(idx + 1).trim(); + if (!val) continue; + if (prop === "color") + markStack.push({ type: "textColor", attrs: { color: val } }); + else if (prop === "background" || prop === "background-color") + markStack.push({ type: "highlight", attrs: { color: val } }); + } + } + } + } + break; + } + default: + break; + } + } + return mergeText(out); +} + +function collectUntil( + tokens: Token[], + openIdx: number, + openType: string, + closeType: string +): number { + let depth = 1; + let close = -1; + for (let j = openIdx + 1; j < tokens.length; j++) { + if (tokens[j].type === openType) depth += 1; + else if (tokens[j].type === closeType) { + depth -= 1; + if (depth === 0) { + close = j; + break; + } + } + } + return close; +} + +function firstTextNode(node: FolioNode): FolioNode | null { + for (const c of node.content ?? []) { + if (c.type === "text") return c; + if (c.content) { + const r = firstTextNode(c); + if (r) return r; + } + } + return null; +} + +// Strip a leading "[ ] " / "[x] " checkbox from a list item, returns checked. +function extractCheckbox(item: FolioNode): boolean { + const tn = firstTextNode(item); + if (tn && tn.text) { + const m = /^\[([ xX])\]\s?/.exec(tn.text); + if (m) { + tn.text = tn.text.slice(m[0].length); + return m[1].toLowerCase() === "x"; + } + } + return false; +} + +function walkBlocks( + tokens: Token[], + start: number, + end: number, + state: WalkState +): FolioNode[] { + const out: FolioNode[] = []; + let i = start; + while (i < end) { + const t = tokens[i]; + switch (t.type) { + case "heading_open": { + const level = parseInt(t.tag.slice(1), 10) || 1; + const inline = tokens[i + 1]; + const content = inline?.children + ? walkInline(inline.children, state) + : []; + out.push({ type: "heading", attrs: { level }, content }); + i += 3; + break; + } + case "paragraph_open": { + const inline = tokens[i + 1]; + const content = inline?.children + ? walkInline(inline.children, state) + : []; + const attrs = tokenAttrObj(t); + out.push({ + type: "paragraph", + ...(Object.keys(attrs).length ? { attrs } : {}), + content, + }); + i += 3; + break; + } + case "bullet_list_open": + case "ordered_list_open": { + const [node, next] = walkList(tokens, i, state); + out.push(node); + i = next + 1; + break; + } + case "blockquote_open": { + const close = collectUntil(tokens, i, "blockquote_open", "blockquote_close"); + const content = walkBlocks(tokens, i + 1, close, state); + out.push({ type: "blockquote", content }); + i = close + 1; + break; + } + case "callout_open": { + const type = calloutType(t); + const close = collectUntil(tokens, i, "callout_open", "callout_close"); + const content = walkBlocks(tokens, i + 1, close, state); + out.push({ type: "blockquote", attrs: { callout: type }, content }); + i = close + 1; + break; + } + case "fence": + case "code_block": { + const lang = (String(t.info ?? "").trim()) || null; + out.push({ + type: "codeBlock", + attrs: { language: lang }, + content: [{ type: "text", text: t.content }], + }); + i += 1; + break; + } + case "hr": + out.push({ type: "horizontalRule" }); + i += 1; + break; + case "table_open": { + const [node, next] = walkTable(tokens, i, state); + out.push(node); + i = next + 1; + break; + } + case "html_block": + i += 1; + break; + default: + i += 1; + break; + } + } + return out; +} + +function walkList(tokens: Token[], i: number, state: WalkState): [FolioNode, number] { + const open = tokens[i]; + const isOrdered = open.type === "ordered_list_open"; + const closeType = isOrdered ? "ordered_list_close" : "bullet_list_close"; + const closeIdx = collectUntil(tokens, i, open.type, closeType); + + const items: FolioNode[] = []; + let j = i + 1; + while (j < closeIdx) { + if (tokens[j].type === "list_item_open") { + const itemClose = collectUntil(tokens, j, "list_item_open", "list_item_close"); + const content = walkBlocks(tokens, j + 1, itemClose, state); + items.push({ type: "listItem", content }); + j = itemClose + 1; + } else { + j += 1; + } + } + + // Obsidian-style checklists: "- [ ] " / "- [x] " in a bullet list. + let isTask = false; + if (!isOrdered && items[0]) { + const tn = firstTextNode(items[0]); + isTask = !!tn && /^\[([ xX])\]/.test(tn.text ?? ""); + } + + if (isTask) { + const taskItems = items.map((it) => ({ + type: "taskItem", + attrs: { checked: extractCheckbox(it) }, + content: it.content, + })); + return [{ type: "taskList", content: taskItems }, closeIdx]; + } + + const listType = isOrdered ? "orderedList" : "bulletList"; + return [{ type: listType, content: items }, closeIdx]; +} + +function walkTable(tokens: Token[], i: number, state: WalkState): [FolioNode, number] { + const closeIdx = collectUntil(tokens, i, "table_open", "table_close"); + const rows: FolioNode[] = []; + let j = i + 1; + while (j < closeIdx) { + const t = tokens[j]; + if (t.type === "tr_open") { + const rc = collectUntil(tokens, j, "tr_open", "tr_close"); + const cells: FolioNode[] = []; + let c = j + 1; + while (c < rc) { + if (tokens[c].type === "th_open" || tokens[c].type === "td_open") { + const cellOpen = tokens[c].type; + const cellClose = cellOpen === "th_open" ? "th_close" : "td_close"; + const cellType = cellOpen === "th_open" ? "tableHeader" : "tableCell"; + const cc = collectUntil(tokens, c, cellOpen, cellClose); + // Table cell content is an inline token (not wrapped in a paragraph + // block), so walk the inline children directly. + let inner: FolioNode[] = []; + for (let k = c + 1; k < cc; k++) { + if (tokens[k].type === "inline") { + inner = walkInline(tokens[k].children, state); + break; + } + } + cells.push({ + type: cellType, + content: inner.length + ? [{ type: "paragraph", content: inner }] + : [{ type: "paragraph", content: [] }], + }); + c = cc + 1; + } else { + c += 1; + } + } + rows.push({ type: "tableRow", content: cells }); + j = rc + 1; + } else { + j += 1; + } + } + return [{ type: "table", content: rows }, closeIdx]; +} + +export function markdownToNative(source: string, opts: MarkdownToNativeOpts = {}): FolioChapterDoc { + const md = makeMd(opts.targets ?? []); + const tokens = md.parse(source, {}); + const state: WalkState = { resolveImage: opts.resolveImage }; + const blocks = walkBlocks(tokens, 0, tokens.length, state); + return { + schemaVersion: CURRENT_DOC_SCHEMA_VERSION, + doc: { type: "doc", content: blocks }, + }; +} diff --git a/src/main/menu.ts b/src/main/menu.ts index 74bab66..ed05efc 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -27,9 +27,16 @@ export function buildMenu( const fileMenu: MenuItem = { label: "File", submenu: [ - { label: "New Book", accelerator: "CmdOrCtrl+N", click: onRequestNew }, - { label: "Open Book", accelerator: "CmdOrCtrl+O", click: onRequestOpen }, - { type: "separator" }, + { label: "New Book", accelerator: "CmdOrCtrl+N", click: onRequestNew }, + { label: "Open Book", accelerator: "CmdOrCtrl+O", click: onRequestOpen }, + { type: "separator" }, + { + label: "Save", + accelerator: "CmdOrCtrl+S", + enabled: !!getState().bookPath, + click: () => getWindow()?.webContents.send("folio:save-requested"), + }, + { type: "separator" }, { label: "Open Recent", submenu: recentItems }, { type: "separator" }, { diff --git a/src/main/migration.ts b/src/main/migration.ts new file mode 100644 index 0000000..61c2245 --- /dev/null +++ b/src/main/migration.ts @@ -0,0 +1,142 @@ +// Migration from legacy Markdown books to the native Folio document model. +// +// Strategy (safe + explicit, never automatic on open): +// - Only runs when the caller explicitly migrates a chapter/book. +// - Before touching anything, a full recovery backup of the book is made to +// ~/Folio-Recovery (see backup.ts). The original .md files are NEVER +// modified or deleted. +// - The .md content is parsed into a native FolioChapterDoc and written to a +// NEW chapters/.json file. The folio.json entry is repointed to it. +// - Migration is idempotent: if a chapter already has a native .json file it +// is left untouched and reported as already-native. + +import fs from "fs"; +import path from "path"; +import { + loadBook, + saveMeta, + chaptersDir, + type BookMeta, +} from "./project.js"; +import { getChapterContent } from "./chapters.js"; +import { + getChapterDoc, + setChapterDoc, + nativeChapterFile, + CURRENT_DOC_SCHEMA_VERSION, + type FolioChapterDoc, +} from "./native-doc.js"; +import { markdownToNative } from "./markdown-to-native.js"; +import { ensureRecoveryBackup } from "./backup.js"; + +export type MigrateStatus = "migrated" | "already-native" | "error"; + +export interface MigrateResult { + status: MigrateStatus; + id: string; + file?: string; + error?: string; +} + +// Resolve an image src for migration: external/data URIs are kept as-is; +// local relative paths are copied into the book's assets/ directory and the +// reference is rewritten to "assets/". +function resolveImageSrc( + src: string, + bookPath: string, + assetsDir: string, + chapterId: string +): string { + if (/^(https?:|data:|blob:)/i.test(src)) return src; + const candidate = path.resolve(bookPath, src); + let exists = false; + try { + exists = fs.existsSync(candidate) && fs.statSync(candidate).isFile(); + } catch { + exists = false; + } + if (!exists) return src; // best-effort: keep the original reference + fs.mkdirSync(assetsDir, { recursive: true }); + const base = path.basename(src); + let name = `${chapterId}-${base}`.replace(/[^a-zA-Z0-9._-]/g, "_"); + let dest = path.join(assetsDir, name); + let n = 2; + while (fs.existsSync(dest)) { + name = `${chapterId}-${n}-${base}`.replace(/[^a-zA-Z0-9._-]/g, "_"); + dest = path.join(assetsDir, name); + n += 1; + } + fs.copyFileSync(candidate, dest); + return `assets/${name}`; +} + +export function migrateChapterToNative( + bookPath: string, + id: string +): MigrateResult { + try { + const meta = loadBook(bookPath); + const entry = meta.chapters[id]; + if (!entry) return { status: "error", id, error: "Chapter not found" }; + + // Idempotency: already a native chapter -> leave the original .md alone. + if (entry.file.endsWith(".json")) { + meta.docFormat = "folio-doc"; + meta.docSchemaVersion = CURRENT_DOC_SCHEMA_VERSION; + saveMeta(bookPath, meta); + return { status: "already-native", id, file: entry.file }; + } + + ensureRecoveryBackup(bookPath); + + const md = getChapterContent(bookPath, id); + const assetsDir = path.join(bookPath, "assets"); + const targets = Object.values(meta.chapters).map((c) => ({ + id: c.id, + title: c.title, + })); + const doc: FolioChapterDoc = markdownToNative(md, { + resolveImage: (src) => resolveImageSrc(src, bookPath, assetsDir, id), + targets, + }); + + const newFile = path.posix.join("chapters", nativeChapterFile(id)); + fs.mkdirSync(chaptersDir(bookPath), { recursive: true }); + setChapterDoc(bookPath, id, doc); + + entry.file = newFile; + meta.docFormat = "folio-doc"; + meta.docSchemaVersion = CURRENT_DOC_SCHEMA_VERSION; + saveMeta(bookPath, meta); + + return { status: "migrated", id, file: newFile }; + } catch (err) { + return { + status: "error", + id, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +export function migrateBookToNative(bookPath: string): MigrateResult[] { + ensureRecoveryBackup(bookPath); + const meta = loadBook(bookPath); + return meta.chapterOrder.map((id) => migrateChapterToNative(bookPath, id)); +} + +// Convenience: verify a migrated chapter reads back as valid native content. +export function isNativeChapter( + bookPath: string, + id: string +): boolean { + const meta = loadBook(bookPath) as BookMeta; + const entry = meta.chapters[id]; + if (!entry || !entry.file.endsWith(".json")) return false; + try { + getChapterDoc(bookPath, id); + return true; + } catch { + return false; + } +} diff --git a/src/main/native-doc.ts b/src/main/native-doc.ts new file mode 100644 index 0000000..95cfe42 --- /dev/null +++ b/src/main/native-doc.ts @@ -0,0 +1,127 @@ +// Native Folio rich-text document model. +// +// This is the single source of truth for chapter content once a book is +// migrated to the native format. The document is a ProseMirror/Tiptap JSON +// tree, not Markdown. See Stage 2 of the rich-text migration plan. +// +// Persistence layout (per book): +// folio.json -> book metadata (chapterOrder, chapters, docFormat, ...) +// chapters/.json -> { schemaVersion, doc } (native content) +// assets/ -> copied image assets referenced by chapters +// +// Markdown remains only for legacy migration (this module does the parsing) +// and optional export (added later). + +import fs from "fs"; +import path from "path"; +import { + loadBook, + saveMeta, + chaptersDir, +} from "./project.js"; + +export interface FolioMark { + type: string; + attrs?: Record; +} + +export interface FolioNode { + type: string; + attrs?: Record; + content?: FolioNode[]; + text?: string; + marks?: FolioMark[]; +} + +export interface FolioChapterDoc { + schemaVersion: number; + doc: FolioNode; +} + +// Bump this whenever the native document shape needs a one-way migration. +export const CURRENT_DOC_SCHEMA_VERSION = 1; + +// Each entry migrates the document FROM its key version TO key+1. +// Entries are additive and must be non-destructive. +const MIGRATIONS: Record FolioChapterDoc> = {}; + +// File name for a native chapter (replaces .md). +export function nativeChapterFile(id: string): string { + return `${id}.json`; +} + +// Apply any pending schema migrations, always returning the current version. +export function migrateDoc(doc: FolioChapterDoc): FolioChapterDoc { + let current = doc; + while (current.schemaVersion < CURRENT_DOC_SCHEMA_VERSION) { + const step = MIGRATIONS[current.schemaVersion]; + if (!step) break; + current = step(current); + } + current.schemaVersion = CURRENT_DOC_SCHEMA_VERSION; + return current; +} + +// Normalize legacy node type names on read. The editor's StarterKit historically +// emitted Tiptap's default `hardBreak` (camelCase), while the converter/export +// pipeline uses `hard_break` (snake_case). Rename so every consumer sees one +// canonical name regardless of how the document was originally saved. +function normalizeDocTypes(node: unknown): unknown { + if (!node || typeof node !== "object") return node; + const n = node as Record; + if (typeof n.type === "string" && n.type === "hardBreak") n.type = "hard_break"; + if (Array.isArray(n.content)) n.content = n.content.map((c) => normalizeDocTypes(c)); + return n; +} + +export function serializeChapter(doc: FolioChapterDoc): string { + return JSON.stringify(migrateDoc(doc), null, 2); +} + +export function parseChapter(text: string): FolioChapterDoc { + let raw: unknown; + try { + raw = JSON.parse(text); + } catch { + throw new Error("Not a native Folio chapter document (invalid JSON)."); + } + if ( + !raw || + typeof raw !== "object" || + !("doc" in raw) || + (raw as FolioChapterDoc).doc?.type !== "doc" + ) { + throw new Error("Not a native Folio chapter document (missing doc tree)."); + } + const parsed = migrateDoc(raw as FolioChapterDoc); + normalizeDocTypes(parsed.doc); + return parsed; +} + +// Read a native chapter document (throws if the chapter is not native JSON). +export function getChapterDoc(bookPath: string, id: string): FolioChapterDoc { + const meta = loadBook(bookPath); + const entry = meta.chapters[id]; + if (!entry) throw new Error("Chapter not found"); + const file = path.join(bookPath, entry.file); + const raw = fs.readFileSync(file, "utf-8"); + return parseChapter(raw); +} + +// Write a native chapter document. Creates chapters/ if needed. +export function setChapterDoc( + bookPath: string, + id: string, + doc: FolioChapterDoc +): void { + fs.mkdirSync(chaptersDir(bookPath), { recursive: true }); + const file = path.join(bookPath, "chapters", nativeChapterFile(id)); + fs.writeFileSync(file, serializeChapter(doc), "utf-8"); +} + +// Validate that a parsed object is structurally a usable chapter document. +export function isValidDoc(doc: FolioChapterDoc): boolean { + return !!doc && doc.doc?.type === "doc" && Array.isArray(doc.doc.content); +} + +export { loadBook, saveMeta }; diff --git a/src/main/native-to-markdown.ts b/src/main/native-to-markdown.ts new file mode 100644 index 0000000..2c6c11b --- /dev/null +++ b/src/main/native-to-markdown.ts @@ -0,0 +1,190 @@ +// Serialize a native Folio document (ProseMirror/Tiptap JSON) back to Markdown. +// +// This module is the *bridge* that lets Stage 1 keep the existing CodeMirror +// editor, the Full Book view, and the Markdown export working after a chapter +// has been migrated to the native JSON format. It is the inverse of +// markdown-to-native.ts. Stage 2 (Tiptap editor) removes this bridge entirely +// because the editor will speak native JSON directly. +// +// The output is intentionally "good enough" Markdown that round-trips through +// markdown-to-native.ts; it is NOT a pixel-perfect reconstruction of the +// original source (e.g. raw control characters inside ==highlight== are kept +// literal, and folio:chapter/ links become [[text]] wikilinks). + +import type { + FolioChapterDoc, + FolioMark, + FolioNode, +} from "./native-doc.js"; + +// Marks applied outermost-first so nested formatting renders as valid Markdown. +const MARK_ORDER = [ + "link", + "textColor", + "highlight", + "underline", + "strike", + "bold", + "italic", + "code", +]; + +function wrap(mark: FolioMark, text: string): string { + const attrs = mark.attrs ?? {}; + switch (mark.type) { + case "bold": + return `**${text}**`; + case "italic": + return `*${text}*`; + case "strike": + return `~~${text}~~`; + case "code": + return "`" + text + "`"; + case "underline": + return `${text}`; + case "highlight": { + const color = attrs.color; + return color + ? `${text}` + : `==${text}==`; + } + case "textColor": + return `${text}`; + case "link": { + const href = String(attrs.href ?? ""); + if (href.startsWith("folio:chapter/")) return `[[${text}]]`; + const title = attrs.title ? ` "${attrs.title}"` : ""; + return `[${text}](${href}${title})`; + } + default: + return text; + } +} + +function applyMarks(text: string, marks: FolioMark[] = []): string { + let s = text; + for (const type of MARK_ORDER) { + const mark = marks.find((m) => m.type === type); + if (mark) s = wrap(mark, s); + } + return s; +} + +function inlineToMd(nodes: FolioNode[] = []): string { + let out = ""; + for (const n of nodes) { + if (n.type === "text") { + out += applyMarks(n.text ?? "", n.marks); + } else if (n.type === "hard_break" || n.type === "hardbreak") { + out += "\n"; + } else if (n.type === "paragraph") { + out += inlineToMd(n.content); + } else if (n.type === "image") { + const { src, alt, title } = n.attrs ?? {}; + out += title + ? `![${alt ?? ""}](${src} "${title}")` + : `![${alt ?? ""}](${src})`; + } + } + return out; +} + +function listToMd(node: FolioNode, depth: number): string { + const indent = " ".repeat(depth); + const ordered = node.type === "orderedList"; + const isTask = node.type === "taskList"; + const items = node.content ?? []; + return items + .map((item, i) => { + let marker: string; + if (isTask) marker = item.attrs?.checked ? "- [x] " : "- [ ] "; + else if (ordered) marker = `${i + 1}. `; + else marker = "- "; + const inner = (item.content ?? []) + .map((b) => blockToMd(b, depth + 1)) + .join("\n\n"); + const lines = inner.split("\n"); + return ( + indent + + marker + + lines + .map((l, idx) => (idx === 0 ? l : indent + " " + l)) + .join("\n") + ); + }) + .join("\n"); +} + +function tableToMd(node: FolioNode): string { + const rows = node.content ?? []; + if (!rows.length) return ""; + const cellsOf = (row: FolioNode) => + (row.content ?? []).map((c) => inlineToMd(c.content)); + const header = cellsOf(rows[0]); + const sep = header.map(() => "---"); + const body = rows.slice(1).map(cellsOf); + return [...[header, sep], ...body] + .map((r) => "| " + r.join(" | ") + " |") + .join("\n"); +} + +function blockToMd(node: FolioNode, depth = 0): string { + switch (node.type) { + case "paragraph": + return inlineToMd(node.content); + case "heading": { + const level = Number(node.attrs?.level ?? 1); + return "#".repeat(level) + " " + inlineToMd(node.content); + } + case "blockquote": { + if (node.attrs?.callout) { + const calloutType = String(node.attrs.callout); + const rest: FolioNode[] = []; + let title = ""; + for (const c of node.content ?? []) { + if (c.type === "paragraph" && c.attrs?.class === "callout-title") { + title = inlineToMd(c.content); + } else { + rest.push(c); + } + } + const body = rest.map((b) => blockToMd(b, depth)).join("\n\n"); + const lines = [`> [!${calloutType}]${title ? " " + title : ""}`]; + if (body) { + for (const l of body.split("\n")) lines.push(l ? "> " + l : ">"); + } + return lines.join("\n"); + } + const inner = (node.content ?? []) + .map((b) => blockToMd(b, depth)) + .join("\n\n"); + return inner + .split("\n") + .map((l) => (l ? "> " + l : ">")) + .join("\n"); + } + case "bulletList": + case "orderedList": + case "taskList": + return listToMd(node, depth); + case "codeBlock": { + const lang = node.attrs?.language ?? ""; + const text = (node.content ?? []) + .map((c) => c.text ?? "") + .join("") + .replace(/\n+$/, ""); + return "```" + lang + "\n" + text + "\n```"; + } + case "horizontalRule": + return "---"; + case "table": + return tableToMd(node); + default: + return ""; + } +} + +export function nativeToMarkdown(doc: FolioChapterDoc): string { + const blocks = doc.doc?.content ?? []; + return blocks.map((b) => blockToMd(b)).join("\n\n"); +} diff --git a/src/main/office-export.ts b/src/main/office-export.ts index f92209f..228da2c 100644 --- a/src/main/office-export.ts +++ b/src/main/office-export.ts @@ -1,25 +1,23 @@ // Full Book export to DOCX (and ODT) entirely in the main process. // -// DOCX is produced with the `docx` library: the assembled manuscript is parsed -// with markdown-it (same plugin set as the on-screen renderer) and the token -// stream is converted into Word paragraphs, tables and runs. ODT is converted -// from the generated DOCX with LibreOffice running headless, which keeps the -// two formats consistent without hand-writing another office XML vocabulary. -// Both exports read the chapter files straight off disk, so the manuscript is -// always complete regardless of which view is on screen. -import MarkdownIt from "markdown-it"; -import type { MarkdownIt as MarkdownItInstance, StateCore, StateInline } from "markdown-it"; -import { execFile } from "child_process"; -import { promisify } from "util"; +// DOCX is produced with the `docx` library. The manuscript is assembled +// directly from the native Folio rich-text document model (one ProseMirror +// JSON tree per chapter, loaded via loadChapterDoc), so exports originate from +// native content and never round-trip through Markdown. ODT is converted from +// the generated DOCX with LibreOffice running headless, which keeps the two +// formats consistent without hand-writing another office XML vocabulary. import fs from "fs"; import os from "os"; import path from "path"; +import { execFile } from "child_process"; +import { promisify } from "util"; import { AlignmentType, BorderStyle, Document, ExternalHyperlink, HeadingLevel, + ImageRun, LineRuleType, NumberFormat, Packer, @@ -32,170 +30,22 @@ import { WidthType, } from "docx"; import { loadBook } from "./project.js"; -import { listChaptersInOrder } from "./chapters.js"; +import { listChaptersInOrder, loadChapterDoc } from "./chapters.js"; +import type { FolioMark, FolioNode } from "./native-doc.js"; -const execFileAsync = promisify(execFile); const MONO_FONT = "Consolas"; -interface WikilinkTarget { - id: string; - title: string; -} - -// ==text== -> text (mirrors src/renderer/print-format.ts) -function highlightPlugin(md: MarkdownItInstance) { - const parse = (state: StateInline, silent: boolean): boolean => { - const src = state.src; - const max = state.posMax; - const start = state.pos; - if (start + 2 > max) return false; - if (src.charCodeAt(start) !== 0x3d || src.charCodeAt(start + 1) !== 0x3d) return false; - let end = -1; - for (let i = start + 2; i < max - 1; i++) { - const ch = src.charCodeAt(i); - if (ch === 0x0a) return false; - if (ch === 0x3d && src.charCodeAt(i + 1) === 0x3d) { - end = i; - break; - } - } - if (end === -1) return false; - const content = src.slice(start + 2, end); - if (!content.trim()) return false; - if (silent) { - state.pos = end + 2; - return true; - } - state.push("highlight_open", "mark", 1); - state.push("text", "", 0).content = content; - state.push("highlight_close", "mark", -1); - state.pos = end + 2; - return true; - }; - md.inline.ruler.before("emphasis", "highlight", parse); -} - -// [[Title]] / [[Target|Alias]] -> links (mirrors src/renderer/wikilinks.ts) -function wikilinkPlugin(md: MarkdownItInstance, chapters: WikilinkTarget[]) { - const byTitle = new Map(chapters.map((c) => [String(c.title).toLowerCase(), c.id])); - const byId = new Set(chapters.map((c) => String(c.id))); - const parse = (state: StateInline, silent: boolean): boolean => { - const src = state.src; - const max = state.posMax; - const start = state.pos; - if (start + 2 > max) return false; - if (src.charCodeAt(start) !== 0x5b || src.charCodeAt(start + 1) !== 0x5b) return false; - let end = -1; - for (let i = start + 2; i < max - 1; i++) { - const ch = src.charCodeAt(i); - if (ch === 0x5b) return false; - if (ch === 0x0a) return false; - if (ch === 0x5d && src.charCodeAt(i + 1) === 0x5d) { - end = i; - break; - } - } - if (end === -1) return false; - const raw = src.slice(start + 2, end).trim(); - if (!raw) return false; - let target = raw; - let label = raw; - const pipe = raw.indexOf("|"); - if (pipe >= 0) { - target = raw.slice(0, pipe).trim(); - const alias = raw.slice(pipe + 1).trim(); - label = alias.length ? alias : target; - } - if (!target) return false; - if (silent) { - state.pos = end + 2; - return true; - } - const key = target.toLowerCase(); - const id = byTitle.has(key) ? byTitle.get(key)! : byId.has(target) ? target : null; - const open = state.push("link_open", "a", 1); - open.attrs = [ - ["href", id !== null ? `folio:chapter/${encodeURIComponent(id)}` : "#"], - ["class", id !== null ? "wikilink internal" : "wikilink missing"], - ]; - const text = state.push("text", "", 0); - text.content = label; - state.push("link_close", "a", -1); - state.pos = end + 2; - return true; - }; - md.inline.ruler.before("link", "wikilink", parse); -} - -const CALLOUT_RE = /^\s*\[!([a-zA-Z0-9_-]+)\]\s*(.*)$/; - -// > [!note] Title ... -> styled callout blocks (mirrors src/renderer/print-format.ts) -function calloutPlugin(md: MarkdownItInstance) { - md.core.ruler.after("inline", "callout", (state: StateCore) => { - const tokens = state.tokens; - for (let i = 0; i < tokens.length; i++) { - const open = tokens[i]; - if (open.type !== "blockquote_open") continue; - let depth = 1; - let closeIdx = -1; - for (let j = i + 1; j < tokens.length; j++) { - const t = tokens[j]; - if (t.type === "blockquote_open") depth += 1; - else if (t.type === "blockquote_close") { - depth -= 1; - if (depth === 0) { - closeIdx = j; - break; - } - } - } - if (closeIdx === -1) continue; - const paraOpen = tokens[i + 1]; - if (!paraOpen || paraOpen.type !== "paragraph_open") continue; - const inline = tokens[i + 2]; - if (!inline || inline.type !== "inline" || !inline.children) continue; - const first = inline.children[0]; - if (!first || first.type !== "text") continue; - const m = CALLOUT_RE.exec(first.content); - if (!m) continue; - - open.type = "callout_open"; - open.attrs = [["class", `callout callout-${m[1].toLowerCase()}`]]; - const close = tokens[closeIdx]; - close.type = "callout_close"; - close.tag = "div"; - - first.content = ""; - inline.children = inline.children.filter((c) => !(c.type === "text" && c.content === "")); - if (inline.children[0]?.type === "softbreak") inline.children.shift(); - - const title = m[2].trim(); - if (title) { - const titleOpen = new state.Token("paragraph_open", "p", 1); - titleOpen.attrs = [["class", "callout-title"]]; - const titleInline = new state.Token("inline", "", 0); - titleInline.content = title; - const titleText = new state.Token("text", "", 0); - titleText.content = title; - titleInline.children = [titleText]; - const titleClose = new state.Token("paragraph_close", "p", -1); - tokens.splice(i + 1, 0, titleOpen, titleInline, titleClose); - } - } - }); -} - -type BlockEl = Paragraph | Table; - interface InlineCtx { bold: boolean; italic: boolean; strike: boolean; code: boolean; underline: boolean; - highlight?: "yellow"; + highlight?: string; color?: string; bg?: string; + fontFamily?: string; + fontSize?: string; } const defaultCtx: InlineCtx = { @@ -216,10 +66,39 @@ const listSpacing = { after: 60, ...lineSpacing }; const tableSpacing = { after: 40 }; const codeSpacing = { before: 120, after: 160, ...lineSpacing }; +// Override the paragraph line spacing when the node carries a lineHeight +// attribute (unitless ratio or percentage, e.g. "1.5" or "150%"). +function withLineSpacing(base: typeof lineSpacing, n: FolioNode): typeof lineSpacing { + const lh = n.attrs?.lineHeight; + if (!lh) return base; + const raw = String(lh).trim(); + let ratio: number; + if (raw.endsWith("%")) ratio = parseFloat(raw) / 100; + else ratio = parseFloat(raw); + if (!isFinite(ratio) || ratio <= 0) return base; + return { ...base, line: Math.round(ratio * 240), lineRule: LineRuleType.AUTO }; +} + +const QUOTE_BORDER = { left: { style: BorderStyle.SINGLE, size: 16, color: "888888", space: 8 } }; +const CALLOUT_SHADING = { type: ShadingType.CLEAR, fill: "F5F2ED" }; + function hexColor(v: string): string { return v.replace(/^#/, "").trim().toLowerCase(); } +function fontSizeToHalfPoints(v: string): number | undefined { + const m = /(\d+(?:\.\d+)?)\s*px/i.exec(v); + if (!m) return undefined; + return Math.round(parseFloat(m[1]) * 2); +} + +function alignment(type?: string) { + if (type === "center") return AlignmentType.CENTER; + if (type === "right") return AlignmentType.RIGHT; + if (type === "justify") return AlignmentType.JUSTIFIED; + return undefined; +} + function makeRun(ctx: InlineCtx, text: string): TextRun { const lineBreak = text === "\n"; return new TextRun({ @@ -228,226 +107,95 @@ function makeRun(ctx: InlineCtx, text: string): TextRun { italics: ctx.italic || undefined, strike: ctx.strike || undefined, underline: ctx.underline ? {} : undefined, - highlight: ctx.highlight, + highlight: ctx.highlight as any, color: ctx.color, shading: ctx.bg ? { type: ShadingType.CLEAR, fill: ctx.bg } : undefined, - font: ctx.code ? MONO_FONT : undefined, + font: ctx.code ? MONO_FONT : ctx.fontFamily || undefined, + size: ctx.fontSize ? fontSizeToHalfPoints(ctx.fontSize) : undefined, break: lineBreak ? 1 : undefined, }); } -function decodeEntities(s: string): string { - return s - .replace(/ /g, "\u00a0") - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&#x([0-9a-f]+);/gi, (_m, hex: string) => String.fromCodePoint(parseInt(hex, 16))) - .replace(/&#(\d+);/g, (_m, dec: string) => String.fromCodePoint(parseInt(dec, 10))); -} - -// Convert a raw inline HTML fragment (toolbar's , , etc.) -// into runs. The caller keeps one mutable state across the whole inline token -// list, so formatting opened in one html_inline token (e.g. "") still -// applies to the plain text tokens that follow until its closing tag. -function htmlInlineRuns(html: string, state: { ctx: InlineCtx; stack: Partial[] }): TextRun[] { - const out: TextRun[] = []; - const { ctx, stack } = state; - let textBuf = ""; - let i = 0; - - const flush = () => { - if (textBuf) { - out.push(makeRun(ctx, textBuf)); - textBuf = ""; - } - }; - const snapshot = (): Partial => ({ - bold: ctx.bold, - italic: ctx.italic, - strike: ctx.strike, - code: ctx.code, - underline: ctx.underline, - highlight: ctx.highlight, - color: ctx.color, - bg: ctx.bg, - }); - const restore = (snap: Partial) => { - Object.assign(ctx, snap); - }; - - while (i < html.length) { - const lt = html.indexOf("<", i); - if (lt === -1) { - textBuf += decodeEntities(html.slice(i)); - break; - } - const gt = html.indexOf(">", lt); - if (gt === -1) { - textBuf += decodeEntities(html.slice(i)); - break; - } - if (lt > i) textBuf += decodeEntities(html.slice(i, lt)); - const tag = html.slice(lt + 1, gt); - const nameMatch = /^\s*\/?\s*([a-zA-Z][a-zA-Z0-9-]*)/.exec(tag); - const name = nameMatch?.[1].toLowerCase() ?? ""; - const isClose = /^\s*\//.test(tag); - const styleAttr = /style\s*=\s*["']([^"']*)["']/i.exec(tag)?.[1] ?? ""; - - if (!name) { - textBuf += "<" + tag + ">"; - } else if (name === "br") { - flush(); - out.push(makeRun(ctx, "\n")); - } else if (name === "span") { - flush(); - if (!isClose) { - stack.push(snapshot()); - for (const decl of styleAttr.split(";")) { - const [prop, ...rest] = decl.split(":"); - const k = prop.trim().toLowerCase(); - const v = rest.join(":").trim(); - if (k === "color" && v) ctx.color = hexColor(v); - else if (k === "background-color" && v) ctx.bg = hexColor(v); - else if (k === "text-decoration" && v.includes("underline")) ctx.underline = true; - } - } else { - const snap = stack.pop(); - if (snap) restore(snap); - } - } else if (name === "u") { - flush(); - if (!isClose) { - stack.push(snapshot()); - ctx.underline = true; - } else { - const snap = stack.pop(); - if (snap) restore(snap); - } - } else if (name === "del" || name === "s") { - flush(); - if (!isClose) { - stack.push(snapshot()); - ctx.strike = true; - } else { - const snap = stack.pop(); - if (snap) restore(snap); - } - } else if (name === "mark") { - flush(); - if (!isClose) { - stack.push(snapshot()); - ctx.highlight = "yellow"; - } else { - const snap = stack.pop(); - if (snap) restore(snap); - } - } else if (name === "strong" || name === "b") { - flush(); - if (!isClose) { - stack.push(snapshot()); +// Build a context from the styling marks on a text node (everything except the +// structural `link` mark, which is handled separately). +function applyMarks(marks: FolioMark[] = [], base: InlineCtx): InlineCtx { + const ctx: InlineCtx = { ...base }; + for (const m of marks) { + switch (m.type) { + case "bold": ctx.bold = true; - } else { - const snap = stack.pop(); - if (snap) restore(snap); - } - } else if (name === "em" || name === "i") { - flush(); - if (!isClose) { - stack.push(snapshot()); + break; + case "italic": ctx.italic = true; - } else { - const snap = stack.pop(); - if (snap) restore(snap); - } - } else if (name === "code") { - flush(); - if (!isClose) { - stack.push(snapshot()); + break; + case "strike": + ctx.strike = true; + break; + case "underline": + ctx.underline = true; + break; + case "code": ctx.code = true; - } else { - const snap = stack.pop(); - if (snap) restore(snap); - } + break; + case "textColor": + if (m.attrs?.color) ctx.color = hexColor(String(m.attrs.color)); + break; + case "fontFamily": + if (m.attrs?.fontFamily) ctx.fontFamily = String(m.attrs.fontFamily); + break; + case "fontSize": + if (m.attrs?.fontSize) ctx.fontSize = String(m.attrs.fontSize); + break; + case "highlight": + if (m.attrs?.color) ctx.bg = hexColor(String(m.attrs.color)); + else ctx.highlight = "yellow"; + break; + default: + break; } - // Unknown tags are dropped; their inner text is kept. - i = gt + 1; } - flush(); + return ctx; +} + +function textNodeRuns(node: FolioNode, ctx: InlineCtx): (TextRun | ExternalHyperlink)[] { + const linkMark = (node.marks ?? []).find((m) => m.type === "link"); + const stylingCtx = applyMarks(node.marks, ctx); + const run = makeRun(stylingCtx, node.text ?? ""); + if (!linkMark) return [run]; + const href = String(linkMark.attrs?.href ?? ""); + // Wikilinks point back into the book; the export has no in-app chapter URLs, + // so they are rendered as plain label text. + if (href.startsWith("folio:chapter/") || href === "#" || href.startsWith("folio:")) return [run]; + if (href) return [new ExternalHyperlink({ link: href, children: [run] })]; + return [run]; +} + +type InlineEl = TextRun | ExternalHyperlink; + +function renderInline(nodes: FolioNode[] = [], ctx: InlineCtx = defaultCtx): InlineEl[] { + const out: InlineEl[] = []; + for (const n of nodes) { + if (n.type === "text") out.push(...textNodeRuns(n, ctx)); + else if (n.type === "hard_break") out.push(makeRun(ctx, "\n")); + } return out; } -// Convert inline token children into runs. Links become ExternalHyperlink -// unless they are wikilinks (rendered as plain label text, since the export -// has no in-app chapter URLs). -function inlineRuns(tokens: import("markdown-it").Token[], ctx: InlineCtx): (TextRun | ExternalHyperlink)[] { - const out: (TextRun | ExternalHyperlink)[] = []; - let i = 0; - const htmlState = { ctx: { ...ctx }, stack: [] as Partial[] }; +function nodeText(node: FolioNode): string { + if (node.text) return node.text; + let t = ""; + for (const c of node.content ?? []) t += nodeText(c); + return t; +} - const collect = (openType: string, closeType: string): { inner: import("markdown-it").Token[] } => { - i++; - let depth = 1; - const inner: import("markdown-it").Token[] = []; - while (i < tokens.length && depth) { - const t = tokens[i]; - if (t.type === openType) depth++; - else if (t.type === closeType) depth--; - if (depth) inner.push(t); - i++; - } - return { inner }; - }; - - while (i < tokens.length) { - const t = tokens[i]; - if (t.type === "text") { - out.push(makeRun(htmlState.ctx, t.content)); - i++; - } else if (t.type === "softbreak" || t.type === "hardbreak") { - out.push(makeRun(htmlState.ctx, "\n")); - i++; - } else if (t.type === "code") { - out.push(makeRun({ ...htmlState.ctx, code: true }, t.content)); - i++; - } else if (t.type === "image") { - const alt = String(t.attrGet("alt") ?? t.attrGet("src") ?? ""); - if (alt) out.push(makeRun({ ...htmlState.ctx, italic: true }, alt)); - i++; - } else if (t.type === "strong_open") { - out.push(...inlineRuns(collect("strong_open", "strong_close").inner, { ...htmlState.ctx, bold: true })); - } else if (t.type === "em_open") { - out.push(...inlineRuns(collect("em_open", "em_close").inner, { ...htmlState.ctx, italic: true })); - } else if (t.type === "s_open") { - out.push(...inlineRuns(collect("s_open", "s_close").inner, { ...htmlState.ctx, strike: true })); - } else if (t.type === "highlight_open") { - out.push( - ...inlineRuns(collect("highlight_open", "highlight_close").inner, { - ...htmlState.ctx, - highlight: htmlState.ctx.highlight ?? "yellow", - }) - ); - } else if (t.type === "link_open") { - const href = String(t.attrGet("href") ?? ""); - const isWikilink = String(t.attrGet("class") ?? "").includes("wikilink"); - const children = inlineRuns(collect("link_open", "link_close").inner, htmlState.ctx); - if (isWikilink || href === "#" || href.startsWith("folio:chapter/")) { - out.push(...children); - } else if (href && children.length) { - out.push(new ExternalHyperlink({ link: href, children })); - } else { - out.push(...children); - } - } else if (t.type === "html_inline") { - out.push(...htmlInlineRuns(t.content, htmlState)); - i++; - } else { - i++; - } - } - return out; +// Whether the chapter's native content already opens with a heading matching +// its title, in which case we must not inject a duplicate title heading. +function needsTitleInjection(content: FolioNode[], title: string): boolean { + const t = title.trim().toLowerCase(); + if (!t) return false; + const first = content[0]; + if (first && first.type === "heading" && nodeText(first).trim().toLowerCase() === t) return false; + return true; } function headingLevel(level: number) { @@ -469,301 +217,218 @@ function headingLevel(level: number) { } } -function htmlBlockToParagraph(html: string): Paragraph | null { - const alignMatch = /align\s*=\s*["'](left|center|right|justify)["']/i.exec(html); - const align = alignMatch?.[1].toLowerCase(); - const text = decodeEntities(html.replace(/<[^>]*>/g, "")).replace(/\s+/g, " ").trim(); - if (!text) return null; +function readImageSize(buf: Buffer): { width: number; height: number } | null { + if (buf.length > 24 && buf[0] === 0x89 && buf[1] === 0x50) { + return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; + } + if (buf.length > 4 && buf[0] === 0xff && buf[1] === 0xd8) { + let i = 2; + while (i < buf.length - 9) { + if (buf[i] === 0xff && [0xc0, 0xc1, 0xc2, 0xc3].includes(buf[i + 1])) { + return { width: buf.readUInt16BE(i + 7), height: buf.readUInt16BE(i + 5) }; + } + i++; + } + } + return null; +} + +type BlockEl = Paragraph | Table; + +// Render block-level native nodes into DOCX blocks. bookPath is used to resolve +// relative image sources for embedding. +function renderNodes(nodes: FolioNode[], bookPath: string): BlockEl[] { + const out: BlockEl[] = []; + for (const n of nodes) { + switch (n.type) { + case "heading": { + const level = Number(n.attrs?.level ?? 1); + out.push( + new Paragraph({ + heading: headingLevel(level), + children: renderInline(n.content, defaultCtx), + alignment: alignment(String(n.attrs?.textAlign ?? "")), + spacing: withLineSpacing(headingSpacing, n), + }) + ); + break; + } + case "paragraph": { + const isTitle = String(n.attrs?.class ?? "") === "callout-title"; + out.push( + new Paragraph({ + children: renderInline(n.content, isTitle ? { ...defaultCtx, bold: true } : defaultCtx), + alignment: alignment(String(n.attrs?.textAlign ?? "")), + spacing: withLineSpacing(bodySpacing, n), + }) + ); + break; + } + case "bulletList": + out.push(...renderList(n, "bullets", 0, false, bookPath)); + break; + case "orderedList": + out.push(...renderList(n, "numbers", 0, false, bookPath)); + break; + case "taskList": + out.push(...renderList(n, "bullets", 0, true, bookPath)); + break; + case "blockquote": + out.push(...renderQuoted(n.content ?? [], !!n.attrs?.callout, bookPath)); + break; + case "codeBlock": { + const text = (n.content ?? []).map((c) => c.text ?? "").join("").replace(/\n+$/, ""); + const lines = text.split("\n"); + out.push( + new Paragraph({ + children: lines.map((line, idx) => + new TextRun({ text: line, font: MONO_FONT, break: idx < lines.length - 1 ? 1 : undefined }) + ), + shading: { type: ShadingType.CLEAR, fill: "F2F2F2" }, + border: { left: { style: BorderStyle.SINGLE, size: 12, color: "CCCCCC", space: 8 } }, + spacing: codeSpacing, + }) + ); + break; + } + case "horizontalRule": + out.push( + new Paragraph({ + children: [], + spacing: { before: 120, after: 120 }, + border: { bottom: { style: BorderStyle.SINGLE, size: 8, color: "999999", space: 1 } }, + }) + ); + break; + case "table": + out.push(renderTable(n, bookPath)); + break; + case "image": + out.push(renderImage(n, bookPath)); + break; + default: + break; + } + } + return out; +} + +function renderList( + listNode: FolioNode, + reference: string, + level: number, + isTask: boolean, + bookPath: string +): BlockEl[] { + const out: BlockEl[] = []; + for (const item of listNode.content ?? []) { + const checked = isTask ? !!item.attrs?.checked : false; + let first = true; + for (const block of item.content ?? []) { + if (block.type === "paragraph") { + let runs = renderInline((block.content ?? []) as FolioNode[], defaultCtx); + if (isTask && first) { + runs = [new TextRun({ text: checked ? "\u2611 " : "\u2610 ", bold: true }), ...runs]; + } + if (first) { + out.push( + new Paragraph({ + children: runs, + spacing: listSpacing, + numbering: { reference, level }, + }) + ); + } else { + out.push( + new Paragraph({ + children: runs, + spacing: listSpacing, + indent: { left: 720 * (level + 1), hanging: 360 }, + }) + ); + } + first = false; + } else if (block.type === "bulletList" || block.type === "orderedList" || block.type === "taskList") { + out.push(...renderList(block, block.type === "orderedList" ? "numbers" : "bullets", level + 1, block.type === "taskList", bookPath)); + } + } + } + return out; +} + +function renderQuoted(content: FolioNode[], isCallout: boolean, bookPath: string): BlockEl[] { + const out: BlockEl[] = []; + for (const block of content) { + if (block.type === "paragraph") { + const isTitle = String(block.attrs?.class ?? "") === "callout-title"; + out.push( + new Paragraph({ + children: renderInline((block.content ?? []) as FolioNode[], isTitle ? { ...defaultCtx, bold: true } : defaultCtx), + indent: { left: 720 }, + border: QUOTE_BORDER, + shading: isCallout ? CALLOUT_SHADING : undefined, + spacing: withLineSpacing(bodySpacing, block), + }) + ); + } else if (block.type === "bulletList" || block.type === "orderedList" || block.type === "taskList") { + out.push(...renderList(block, block.type === "orderedList" ? "numbers" : "bullets", 0, block.type === "taskList", bookPath)); + } else { + out.push(...renderNodes([block], bookPath)); + } + } + return out; +} + +function renderTable(node: FolioNode, _bookPath: string): Table { + const rows: TableRow[] = (node.content ?? []).map((row) => { + const cells: TableCell[] = (row.content ?? []).map((cell) => { + const isHeader = cell.type === "tableHeader"; + const para = cell.content?.[0]; + const runs = para && para.type === "paragraph" ? renderInline((para.content ?? []) as FolioNode[], defaultCtx) : []; + return new TableCell({ + children: [new Paragraph({ children: runs, spacing: tableSpacing })], + shading: isHeader ? { type: ShadingType.CLEAR, fill: "EFEFEF" } : undefined, + }); + }); + return new TableRow({ children: cells }); + }); + return new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows }); +} + +function renderImage(node: FolioNode, bookPath: string): Paragraph { + const src = String(node.attrs?.src ?? ""); + const alt = String(node.attrs?.alt ?? ""); + try { + const resolved = path.isAbsolute(src) ? src : path.join(bookPath, src); + if (fs.existsSync(resolved)) { + const buf = fs.readFileSync(resolved); + const ext = path.extname(resolved).slice(1).toLowerCase(); + const type = ext === "jpg" || ext === "jpeg" ? "jpg" : ext === "gif" ? "gif" : ext === "bmp" ? "bmp" : "png"; + const size = readImageSize(buf) ?? { width: 400, height: 300 }; + const image = new ImageRun({ + type: type as "png", + data: buf, + transformation: { width: size.width, height: size.height }, + altText: { name: alt || "image", title: alt, description: alt }, + }); + return new Paragraph({ children: [image], spacing: bodySpacing }); + } + } catch { + /* fall through to alt text */ + } return new Paragraph({ - alignment: align === "center" ? AlignmentType.CENTER : align === "right" ? AlignmentType.RIGHT : undefined, - children: [new TextRun({ text })], + children: [new TextRun({ text: alt || "[image]", italics: true })], spacing: bodySpacing, }); } -function renderListItem(itemTokens: import("markdown-it").Token[], listCtx: { reference: string; level: number }): BlockEl[] { - const out: BlockEl[] = []; - let i = 0; - let first = true; - while (i < itemTokens.length) { - const t = itemTokens[i]; - if (t.type === "paragraph_open") { - const inlineTok = itemTokens[i + 1]; - const runs = inlineTok && inlineTok.type === "inline" ? inlineRuns(inlineTok.children ?? [], defaultCtx) : []; - if (first) { - out.push( - new Paragraph({ - children: runs, - spacing: listSpacing, - numbering: { reference: listCtx.reference, level: listCtx.level }, - }) - ); - } else { - out.push( - new Paragraph({ - children: runs, - spacing: listSpacing, - indent: { left: 720 * (listCtx.level + 1), hanging: 360 }, - }) - ); - } - first = false; - i += 3; - } else if (t.type === "bullet_list_open" || t.type === "ordered_list_open") { - const nested = { - reference: t.type === "bullet_list_open" ? "bullets" : "numbers", - level: listCtx.level + 1, - }; - const { blocks, next } = renderList(itemTokens, i, nested); - out.push(...blocks); - i = next; - } else { - i++; - } - } - return out; -} - -function renderList(tokens: import("markdown-it").Token[], start: number, listCtx: { reference: string; level: number }): { blocks: BlockEl[]; next: number } { - const blocks: BlockEl[] = []; - let i = start + 1; - let depth = 1; - while (i < tokens.length && depth) { - const t = tokens[i]; - if (t.type === "bullet_list_open" || t.type === "ordered_list_open") depth++; - else if (t.type === "bullet_list_close" || t.type === "ordered_list_close") depth--; - if (depth && t.type === "list_item_open") { - let j = i + 1; - let itemDepth = 1; - const itemTokens: import("markdown-it").Token[] = []; - while (j < tokens.length && itemDepth) { - const it = tokens[j]; - if (it.type === "list_item_open") itemDepth++; - else if (it.type === "list_item_close") itemDepth--; - if (itemDepth) itemTokens.push(it); - j++; - } - blocks.push(...renderListItem(itemTokens, listCtx)); - i = j; - } else { - i++; - } - } - return { blocks, next: i }; -} - -// Collect the tokens inside a balanced open/close pair and return the index -// just past the closing token. -function collectMatching( - tokens: import("markdown-it").Token[], - start: number, - openTypes: string[], - closeTypes: string[] -): { inner: import("markdown-it").Token[]; next: number } { - let i = start + 1; - let depth = 1; - const inner: import("markdown-it").Token[] = []; - while (i < tokens.length && depth) { - const t = tokens[i]; - if (openTypes.includes(t.type)) depth++; - else if (closeTypes.includes(t.type)) depth--; - if (depth) inner.push(t); - i++; - } - return { inner, next: i }; -} - -const QUOTE_BORDER = { left: { style: BorderStyle.SINGLE, size: 16, color: "888888", space: 8 } }; -const CALLOUT_SHADING = { type: ShadingType.CLEAR, fill: "F5F2ED" }; - -// Render blockquote / callout content. Every paragraph gets the quote indent -// and left border; callouts are additionally shaded. -function renderQuoted(tokens: import("markdown-it").Token[], isCallout: boolean): BlockEl[] { - const out: BlockEl[] = []; - let i = 0; - while (i < tokens.length) { - const t = tokens[i]; - if (t.type === "paragraph_open") { - const isTitle = String(t.attrGet("class") ?? "").includes("callout-title"); - const inlineTok = tokens[i + 1]; - const runs = - inlineTok && inlineTok.type === "inline" - ? inlineRuns(inlineTok.children ?? [], isTitle ? { ...defaultCtx, bold: true } : defaultCtx) - : []; - out.push( - new Paragraph({ - children: runs, - indent: { left: 720 }, - border: QUOTE_BORDER, - shading: isCallout ? CALLOUT_SHADING : undefined, - spacing: bodySpacing, - }) - ); - i += 3; - } else if (t.type === "bullet_list_open") { - const { blocks, next } = renderList(tokens, i, { reference: "bullets", level: 0 }); - out.push(...blocks); - i = next; - } else if (t.type === "ordered_list_open") { - const { blocks, next } = renderList(tokens, i, { reference: "numbers", level: 0 }); - out.push(...blocks); - i = next; - } else if (t.type === "blockquote_open" || t.type === "callout_open") { - const nested = t.type === "callout_open"; - const { inner, next } = collectMatching(tokens, i, ["blockquote_open", "callout_open"], ["blockquote_close", "callout_close"]); - out.push(...renderQuoted(inner, isCallout || nested)); - i = next; - } else { - i++; - } - } - return out; -} - -// Render a run of block-level tokens (a whole chapter or a blockquote body). -function renderBlocks(tokens: import("markdown-it").Token[]): BlockEl[] { - const out: BlockEl[] = []; - let i = 0; - while (i < tokens.length) { - const t = tokens[i]; - if (t.type === "heading_open") { - const level = Number(t.tag.slice(1)); - const inlineTok = tokens[i + 1]; - const runs = inlineTok && inlineTok.type === "inline" ? inlineRuns(inlineTok.children ?? [], defaultCtx) : []; - out.push(new Paragraph({ heading: headingLevel(level), children: runs, spacing: headingSpacing })); - i += 3; - } else if (t.type === "paragraph_open") { - const isTitle = String(t.attrGet("class") ?? "").includes("callout-title"); - const inlineTok = tokens[i + 1]; - const runs = - inlineTok && inlineTok.type === "inline" - ? inlineRuns(inlineTok.children ?? [], isTitle ? { ...defaultCtx, bold: true } : defaultCtx) - : []; - out.push(new Paragraph({ children: runs, spacing: bodySpacing })); - i += 3; - } else if (t.type === "bullet_list_open") { - const { blocks, next } = renderList(tokens, i, { reference: "bullets", level: 0 }); - out.push(...blocks); - i = next; - } else if (t.type === "ordered_list_open") { - const { blocks, next } = renderList(tokens, i, { reference: "numbers", level: 0 }); - out.push(...blocks); - i = next; - } else if (t.type === "blockquote_open" || t.type === "callout_open") { - const isCallout = t.type === "callout_open"; - const { inner, next } = collectMatching(tokens, i, ["blockquote_open", "callout_open"], ["blockquote_close", "callout_close"]); - out.push(...renderQuoted(inner, isCallout)); - i = next; - } else if (t.type === "fence" || t.type === "code_block") { - const lines = t.content.replace(/\n$/, "").split("\n"); - const runs = lines.map( - (line, idx) => - new TextRun({ - text: line, - font: MONO_FONT, - break: idx < lines.length - 1 ? 1 : undefined, - }) - ); - out.push( - new Paragraph({ - children: runs, - shading: { type: ShadingType.CLEAR, fill: "F2F2F2" }, - border: { left: { style: BorderStyle.SINGLE, size: 12, color: "CCCCCC", space: 8 } }, - spacing: codeSpacing, - }) - ); - i++; - } else if (t.type === "hr") { - out.push( - new Paragraph({ - children: [], - spacing: { before: 120, after: 120 }, - border: { bottom: { style: BorderStyle.SINGLE, size: 8, color: "999999", space: 1 } }, - }) - ); - i++; - } else if (t.type === "table_open") { - const { inner, next } = collectMatching(tokens, i, ["table_open"], ["table_close"]); - const rows: TableRow[] = []; - let cells: TableCell[] = []; - for (let k = 0; k < inner.length; k++) { - const tt = inner[k]; - if (tt.type === "tr_open") cells = []; - else if (tt.type === "th_open" || tt.type === "td_open") { - const inlineTok = inner[k + 1]; - const runs = inlineTok && inlineTok.type === "inline" ? inlineRuns(inlineTok.children ?? [], defaultCtx) : []; - cells.push( - new TableCell({ - children: [new Paragraph({ children: runs, spacing: tableSpacing })], - shading: tt.type === "th_open" ? { type: ShadingType.CLEAR, fill: "EFEFEF" } : undefined, - }) - ); - } else if (tt.type === "tr_close") rows.push(new TableRow({ children: cells })); - } - out.push(new Table({ width: { size: 100, type: WidthType.PERCENTAGE }, rows })); - i = next; - } else if (t.type === "html_block") { - const p = htmlBlockToParagraph(t.content); - if (p) out.push(p); - i++; - } else { - i++; - } - } - return out; -} - -// Assemble the manuscript exactly the way the Full Book view does: the book -// title on top, then each chapter in order, prepending "# Title" only for -// chapters that do not carry their own heading. The title page note mirrors -// combineMarkdown so all exports stay consistent. -function assembleBookMarkdown(bookPath: string): string { - const meta = loadBook(bookPath); - const parts: string[] = [`# ${meta.title}`, "", "_A book compiled with Folio._"]; - for (const ch of listChaptersInOrder(meta)) { - let content = ""; - try { - content = fs.readFileSync(path.join(bookPath, ch.file), "utf-8"); - } catch { - content = ""; - } - const body = content.replace(/^\n+/, "").replace(/\s+$/, ""); - const t = ch.title.trim().toLowerCase(); - let hasOwnHeading = !t; - if (t) { - for (const line of content.split(/\r?\n/)) { - const m = line.match(/^#{1,6}\s+(.+?)\s*$/); - if (m && m[1].trim().toLowerCase() === t) { - hasOwnHeading = true; - break; - } - } - } - const block: string[] = []; - if (!hasOwnHeading) { - block.push(`# ${ch.title}`); - if (body) { - block.push(""); - block.push(body); - } - } else if (body) { - block.push(body); - } - if (block.length) parts.push("", block.join("\n")); - } - return parts.join("\n"); -} - +// Assemble the manuscript directly from native chapter documents, in canonical +// chapter order. The book title leads, then each chapter (prepending a title +// heading only when the content does not already carry one). A small spacer +// before chapters after the first keeps the on-paper rhythm of the editor. export function buildDocx(bookPath: string): Promise { const meta = loadBook(bookPath); const chapters = listChaptersInOrder(meta); - const targets: WikilinkTarget[] = chapters.map((c) => ({ id: c.id, title: c.title })); - - const md = new MarkdownIt({ html: true }); - md.use(wikilinkPlugin, targets); - md.use(highlightPlugin); - md.use(calloutPlugin); - const blocks: BlockEl[] = [ new Paragraph({ heading: HeadingLevel.TITLE, @@ -778,50 +443,30 @@ export function buildDocx(bookPath: string): Promise { }), ]; - for (let ci = 0; ci < chapters.length; ci++) { - const ch = chapters[ci]; - let content = ""; + chapters.forEach((ch, ci) => { + let doc: FolioNode; try { - content = fs.readFileSync(path.join(bookPath, ch.file), "utf-8"); + doc = loadChapterDoc(bookPath, ch.id).doc; } catch { - content = ""; + doc = { type: "doc", content: [] }; } - const body = content.replace(/^\n+/, "").replace(/\s+$/, ""); - const t = ch.title.trim().toLowerCase(); - let hasOwnHeading = !t; - if (t) { - for (const line of content.split(/\r?\n/)) { - const m = line.match(/^#{1,6}\s+(.+?)\s*$/); - if (m && m[1].trim().toLowerCase() === t) { - hasOwnHeading = true; - break; - } - } - } - const markdown = hasOwnHeading ? body : body ? `# ${ch.title}\n\n${body}` : `# ${ch.title}`; - if (markdown) { - // The editor leaves a 2.5rem + 1.5rem gap (about 48pt) before each - // chapter, shown as a thin rule on the chapter's first line. Mirror a - // similar break (~320 twips + a leading line) before chapters after the - // first so the manuscript keeps the same rhythm on paper as on screen. + const content = Array.isArray(doc.content) ? doc.content : []; + const chapterBlocks: FolioNode[] = needsTitleInjection(content, ch.title) + ? [{ type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: ch.title }] }, ...content] + : content; + + if (chapterBlocks.length) { if (ci > 0) { - blocks.push( - new Paragraph({ - children: [], - spacing: { before: 320, after: 0 }, - }) - ); + blocks.push(new Paragraph({ children: [], spacing: { before: 320, after: 0 } })); } - blocks.push(...renderBlocks(md.parse(markdown, {}))); + blocks.push(...renderNodes(chapterBlocks, bookPath)); } - } + }); const doc = new Document({ styles: { default: { - document: { - run: { font: "Georgia", size: 24 }, - }, + document: { run: { font: "Georgia", size: 24 } }, }, }, numbering: { @@ -865,6 +510,7 @@ export async function buildOdt(bookPath: string): Promise { const outDir = path.join(tmpDir, "out"); fs.writeFileSync(tmpDocx, docxBuf); fs.mkdirSync(outDir, { recursive: true }); + const execFileAsync = promisify(execFile); try { await execFileAsync("soffice", ["--headless", "--convert-to", "odt", "--outdir", outDir, tmpDocx], { timeout: 60_000, @@ -884,4 +530,4 @@ export async function buildOdt(bookPath: string): Promise { } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } -} \ No newline at end of file +} diff --git a/src/main/preload.ts b/src/main/preload.ts index 18785a3..f1a69b3 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -8,6 +8,8 @@ contextBridge.exposeInMainWorld("folio", { revealInFolder: (p: string) => ipcRenderer.invoke("folio:reveal", p), getRecents: () => ipcRenderer.invoke("folio:getRecents"), closeBook: () => ipcRenderer.invoke("folio:closeBook"), + showContextMenu: (x: number, y: number) => + ipcRenderer.send("folio:context-menu", x, y), // chapters getBookMeta: () => ipcRenderer.invoke("folio:getBookMeta"), @@ -25,6 +27,14 @@ contextBridge.exposeInMainWorld("folio", { ipcRenderer.invoke("folio:reorderChapters", newOrder), saveChapter: (id: string, content: string) => ipcRenderer.invoke("folio:saveChapter", id, content), + migrateChapter: (id: string) => + ipcRenderer.invoke("folio:migrateChapter", id), + migrateBook: () => ipcRenderer.invoke("folio:migrateBook"), + loadChapterDoc: (id: string) => + ipcRenderer.invoke("folio:loadChapterDoc", id), + saveChapterDoc: (id: string, doc: unknown) => + ipcRenderer.invoke("folio:saveChapterDoc", id, doc), + importImageAsset: () => ipcRenderer.invoke("folio:importImageAsset"), exportMarkdown: () => ipcRenderer.invoke("folio:exportMarkdown"), exportZip: () => ipcRenderer.invoke("folio:exportZip"), exportPdf: () => ipcRenderer.invoke("folio:exportPdf"), @@ -33,6 +43,7 @@ contextBridge.exposeInMainWorld("folio", { importAttachment: () => ipcRenderer.invoke("folio:importAttachment"), copyText: (text: string) => ipcRenderer.invoke("folio:copyText", text), getClipboardText: () => ipcRenderer.invoke("folio:getClipboardText"), + toggleFullscreen: () => ipcRenderer.invoke("folio:toggleFullscreen"), // theme preference getTheme: () => ipcRenderer.invoke("folio:getTheme"), @@ -69,4 +80,6 @@ contextBridge.exposeInMainWorld("folio", { ipcRenderer.on("folio:redo-requested", () => cb()), onSelectAllRequested: (cb: () => void) => ipcRenderer.on("folio:selectAll-requested", () => cb()), + onSaveRequested: (cb: () => void) => + ipcRenderer.on("folio:save-requested", () => cb()), }); diff --git a/src/main/project.ts b/src/main/project.ts index d864b42..5a78569 100644 --- a/src/main/project.ts +++ b/src/main/project.ts @@ -13,6 +13,10 @@ export interface BookMeta { title: string; chapterOrder: string[]; chapters: Record; + // Native document format metadata (added when a book is migrated). Legacy + // Markdown books omit these fields entirely. + docFormat?: "markdown" | "folio-doc"; + docSchemaVersion?: number; } export interface Recent { diff --git a/src/main/recents.ts b/src/main/recents.ts index bebbfef..0b2d56e 100644 --- a/src/main/recents.ts +++ b/src/main/recents.ts @@ -16,10 +16,20 @@ export function recentsFilePath(): string { export function listRecents(): Recent[] { // Drop entries whose book no longer exists (e.g. e2e temp books). - const recents = _listRecents(recentsFilePath()); - return recents.filter( + const recents = _listRecents(recentsFilePath()).filter( (r) => r && typeof r.path === "string" && hasBook(r.path) ); + // Collapse repeats of the same book (same title) so the home/recent list + // is not cluttered with multiple temp copies of one book. + const seen = new Set(); + const deduped: Recent[] = []; + for (const r of recents) { + const key = (r.title || r.path).toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + deduped.push(r); + } + return deduped; } export function addRecent(recent: Recent): Recent[] { diff --git a/src/renderer/book.ts b/src/renderer/book.ts index 3ba52cf..20400ef 100644 --- a/src/renderer/book.ts +++ b/src/renderer/book.ts @@ -1,42 +1,21 @@ import type { BookMeta } from "../main/project.js"; import { alertMessage, confirmMessage, promptInput } from "./dialog.js"; -import { createMdEditor, editorText, setEditorText } from "./md-editor.js"; -import { renderPrintChapters } from "./print-format.js"; +import { renderPrintChaptersNative } from "./print-format.js"; import type { WikilinkTarget } from "./wikilinks.js"; -import { Decoration, EditorView, type DecorationSet } from "@codemirror/view"; -import { StateEffect, StateField } from "@codemirror/state"; -import { redo, selectAll, undo, indentLess, indentMore } from "@codemirror/commands"; +import type { Editor } from "@tiptap/core"; +import type { FolioNode } from "../main/native-doc.js"; import { - applyFormatPainter, - captureFormat, - clearFormatting, - detectFormatting, - insertAttachment, - insertCallout, - insertHorizontalRule, - insertImage, - insertTable, - insertWikilink, - removeColor, - removeHighlight, - setHeading, - toggleAlign, - toggleBlockquote, - toggleBold, - toggleBulletList, - toggleChecklist, - toggleFencedCode, - toggleHighlight, - toggleHighlightColor, - toggleInlineCode, - toggleItalic, - toggleLink, - toggleOrderedList, - toggleStrikethrough, - toggleTextColor, - toggleUnderline, - type FormatState, -} from "./md-format.js"; + createTiptapEditor, + setEditorDoc, + getEditorDoc, + getFormatState, + runCommand, + ChapterBreak, +} from "./tiptap-editor.js"; +import { + buildFullBookDoc, + nativeNodeText, +} from "./native-assembly.js"; export type FolioAPI = { newBook: () => Promise; @@ -45,6 +24,7 @@ export type FolioAPI = { revealInFolder: (p: string) => Promise; getRecents: () => Promise; closeBook: () => Promise; + showContextMenu: (x: number, y: number) => void; getBookMeta: () => Promise; getChapterList: () => Promise; getChapterContent: (id: string) => Promise; @@ -54,6 +34,11 @@ export type FolioAPI = { duplicateChapter: (id: string, title?: string) => Promise; reorderChapters: (newOrder: string[]) => Promise; saveChapter: (id: string, content: string) => Promise; + migrateChapter: (id: string) => Promise; + migrateBook: () => Promise; + loadChapterDoc: (id: string) => Promise; + saveChapterDoc: (id: string, doc: unknown) => Promise; + importImageAsset: () => Promise; exportMarkdown: () => Promise; exportZip: () => Promise; exportPdf: () => Promise; @@ -62,6 +47,7 @@ export type FolioAPI = { importAttachment: () => Promise; copyText: (text: string) => Promise; getClipboardText: () => Promise; + toggleFullscreen: () => Promise; getTheme: () => Promise; setTheme: (id: string) => Promise; onBookOpened: (cb: (data: { path: string; title: string }) => void) => void; @@ -79,6 +65,7 @@ export type FolioAPI = { onUndoRequested: (cb: () => void) => void; onRedoRequested: (cb: () => void) => void; onSelectAllRequested: (cb: () => void) => void; + onSaveRequested: (cb: () => void) => void; }; const $ = (id: string) => document.getElementById(id) as HTMLElement; @@ -116,6 +103,7 @@ export function initBook(api: FolioAPI) { const editorHost = $("editorHost"); const wordCountEl = $("wordCount"); const saveStateEl = $("saveState"); + const toastEl = $("toast"); const exportBtn = $("exportBtn") as HTMLButtonElement; const exportZipBtn = $("exportZipBtn") as HTMLButtonElement; const addChapterBtn = $("addChapterBtn") as HTMLButtonElement; @@ -135,46 +123,33 @@ export function initBook(api: FolioAPI) { const fbNextBtn = $("fbNextBtn") as HTMLButtonElement; const fbChapterSelect = $("fbChapterSelect") as HTMLSelectElement; - // Marks the line where every chapter after the first begins. In print this - // line carries the page-break; on screen it shows a subtle separator. The - // marker lives only in the rendered DOM, never in the assembled Markdown. - const chapterBreakLine = Decoration.line({ class: "chapter-break" }); - const setChapterBreaks = StateEffect.define(); - const chapterBreaksField = StateField.define({ - create: () => Decoration.none, - update(decorations, tr) { - decorations = decorations.map(tr.changes); - for (const effect of tr.effects) { - if (effect.is(setChapterBreaks)) decorations = effect.value; - } - return decorations; - }, - provide: (field) => EditorView.decorations.from(field), - }); + // Full Book is a read-only Tiptap surface that renders every chapter's native + // document back-to-back, separated by ChapterBreak nodes. Those nodes carry + // the chapter id as a data attribute so navigation can scroll to them via the + // DOM, and render the on-screen separator / print page break. + const fullBookEditor: Editor = createTiptapEditor( + fullBookHost, + { onChange: () => undefined }, + { editable: false, extensions: [ChapterBreak] } + ); - const fullBookEditor: EditorView = createMdEditor(fullBookHost, { - onChange: () => undefined, - readOnly: true, - extensions: [chapterBreaksField], - }); - - // Navigation state for the Full Book view. chapterNav tracks every chapter - // that produced a block in the assembled manuscript, in canonical order, - // along with the offset where its block begins. This is the same boundary - // data that drives page breaks, so navigation can never drift from the - // assembled order. The current chapter is kept in sync with the scroll - // position (manual scrolling updates the selector too). + // Navigation state for the Full Book view. chapterNav tracks every chapter in + // canonical order along with the DOM anchor (ChapterBreak element) that marks + // where its block begins. This is the same boundary data that drives page + // breaks, so navigation can never drift from the assembled order. The current + // chapter is kept in sync with the scroll position (manual scrolling updates + // the selector too). interface FullBookChapterNav { id: string; title: string; - start: number; + el: HTMLElement | null; } + let fullBookChapterEls: HTMLElement[] = []; let fullBookChapters: FullBookChapterNav[] = []; let currentChapterIndex = 0; let navScrollRaf: number | null = null; - function populateChapterNav(entries: FullBookChapterNav[]) { - fullBookChapters = entries; + function populateChapterNav(entries: { id: string; title: string }[]) { fbChapterSelect.innerHTML = ""; for (const e of entries) { const opt = document.createElement("option"); @@ -199,45 +174,38 @@ export function initBook(api: FolioAPI) { if (entry && fbChapterSelect.value !== entry.id) fbChapterSelect.value = entry.id; } - // Scroll the assembled manuscript so the given chapter's block begins near - // the top of the Full Book viewport. This only touches the rendered editor, - // never the chapter files or the assembled Markdown. + // Scroll the assembled manuscript so the given chapter's anchor sits near the + // top of the Full Book viewport. This only touches the rendered editor, never + // the chapter files or the assembled content. function jumpToChapter(index: number) { if (!fullBookChapters.length) return; const clamped = Math.max(0, Math.min(index, fullBookChapters.length - 1)); - fullBookEditor.dispatch({ - effects: EditorView.scrollIntoView(fullBookChapters[clamped].start, { y: "start" }), - }); + fullBookChapterEls[clamped]?.scrollIntoView({ block: "start" }); setCurrentChapter(clamped); - fullBookEditor.focus(); } - // Map the line currently at the top of the Full Book viewport back to the - // chapter whose block contains it, so manual scrolling updates the selector. + // Map the element currently at the top of the Full Book viewport back to the + // chapter whose anchor is the last one above it, so manual scrolling updates + // the selector. function detectChapterFromScroll(): number { if (!fullBookChapters.length) return 0; - const scroller = fullBookEditor.scrollDOM; + const scroller = fullBookEditor.view.dom as HTMLElement; const rect = scroller.getBoundingClientRect(); if (rect.height === 0) return currentChapterIndex; const atBottom = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2; - const topPos = fullBookEditor.posAtCoords({ x: rect.left + 4, y: rect.top + 4 }); - if (topPos === null || topPos === undefined) return currentChapterIndex; - if (atBottom) { - const bottomPos = fullBookEditor.posAtCoords({ x: rect.left + 4, y: rect.bottom - 4 }); - const last = fullBookChapters[fullBookChapters.length - 1]; - if (bottomPos !== null && bottomPos !== undefined && bottomPos >= last.start) { - return fullBookChapters.length - 1; - } - } + if (atBottom) return fullBookChapters.length - 1; let idx = 0; for (let i = 0; i < fullBookChapters.length; i++) { - if (fullBookChapters[i].start <= topPos) idx = i; + const el = fullBookChapterEls[i]; + if (!el) continue; + const r = el.getBoundingClientRect(); + if (r.top <= rect.top + 4) idx = i; else break; } return idx; } - fullBookEditor.scrollDOM.addEventListener("scroll", () => { + (fullBookEditor.view.dom as HTMLElement).addEventListener("scroll", () => { if (!fullBookChapters.length) return; if (navScrollRaf !== null) cancelAnimationFrame(navScrollRaf); navScrollRaf = requestAnimationFrame(() => { @@ -246,54 +214,15 @@ export function initBook(api: FolioAPI) { }); }); - const editorView: EditorView = createMdEditor(editorHost, { - onChange: (text) => handleEditorChange(text), - onSelection: (view) => { - const fmt = detectFormatting(view.state, view.state.selection.main.head); - headingSelect.value = String(fmt.heading); - setActive("bold", fmt.bold); - setActive("italic", fmt.italic); - setActive("strike", fmt.strike); - setActive("underline", fmt.underline); - setActive("highlight", fmt.highlight); - setActive("code", fmt.inlineCode); - setActive("h2", fmt.heading === 2); - setActive("h3", fmt.heading === 3); - }, + // Native rich-text editor (Tiptap / ProseMirror). This is the editing surface + // for a single chapter; its state is native JSON, never Markdown. + const editor = createTiptapEditor(editorHost, { + onChange: () => handleEditorChange(), + onSelectionUpdate: () => handleSelection(), }); const toolbar = $("editorToolbar"); const headingSelect = $("headingSelect") as HTMLSelectElement; - const formatCommands: Record boolean> = { - undo, - redo, - bold: toggleBold, - italic: toggleItalic, - strike: toggleStrikethrough, - underline: toggleUnderline, - highlight: toggleHighlight, - code: toggleInlineCode, - link: toggleLink, - hr: insertHorizontalRule, - bullet: toggleBulletList, - number: toggleOrderedList, - checklist: toggleChecklist, - indent: indentMore, - outdent: indentLess, - quote: toggleBlockquote, - codeblock: toggleFencedCode, - h2: setHeading(2), - h3: setHeading(3), - removecolor: removeColor, - removehl: removeHighlight, - "align-left": toggleAlign("left"), - "align-center": toggleAlign("center"), - "align-right": toggleAlign("right"), - clear: clearFormatting, - }; - - let painterFmt: FormatState | null = null; - let painterActive = false; function closeDropdowns() { toolbar.querySelectorAll(".dropdown-menu").forEach((m) => m.classList.add("hidden")); @@ -304,28 +233,9 @@ export function initBook(api: FolioAPI) { document.querySelectorAll(`[data-cmd="${cmd}"]`).forEach((el) => el.classList.toggle("active", on)); } - function updatePainterBtn() { - setActive("painter", painterActive); - } - - function togglePainter() { - if (painterActive && painterFmt) { - if (editorView.state.selection.main.from !== editorView.state.selection.main.to) { - applyFormatPainter(editorView, painterFmt); - } - painterFmt = null; - painterActive = false; - } else { - painterFmt = captureFormat(editorView.state, editorView.state.selection.main.head); - painterActive = true; - } - updatePainterBtn(); - editorView.focus(); - } - - function toggleFullscreen() { - if (document.fullscreenElement) void document.exitFullscreen(); - else void document.documentElement.requestFullscreen().catch(() => undefined); + async function toggleFullscreen() { + const isFs = await api.toggleFullscreen(); + setActive("fullscreen", !!isFs); } document.addEventListener("fullscreenchange", () => { @@ -337,14 +247,6 @@ export function initBook(api: FolioAPI) { setActive("focus", on); } - document.addEventListener("keydown", (e) => { - if (e.key === "Escape" && painterActive) { - painterActive = false; - painterFmt = null; - updatePainterBtn(); - } - }); - async function attachFile() { const res = await api.importAttachment(); if (!res) return; @@ -353,15 +255,17 @@ export function initBook(api: FolioAPI) { await alertMessage("Could not attach file", (res as { error: string }).error); return; } - if ("name" in res && "relPath" in res) { - insertAttachment((res as { name: string }).name, (res as { relPath: string }).relPath)(editorView); - editorView.focus(); + if ("relPath" in res) { + runCommand(editor, "wikilink", { + href: (res as { relPath: string }).relPath, + text: (res as { name: string }).name, + }); } } function selectedText(): string { - const { from, to } = editorView.state.selection.main; - return editorView.state.doc.sliceString(from, to); + const { from, to } = editor.state.selection; + return editor.state.doc.textBetween(from, to, " "); } async function handleSpecial(item: HTMLElement) { @@ -371,16 +275,24 @@ export function initBook(api: FolioAPI) { const sel = selectedText(); const name = await promptInput("Wikilink to chapter", sel); if (name === null) return; - insertWikilink(name, sel && sel !== name ? sel : undefined)(editorView); + runCommand(editor, "wikilink", { + href: `folio:chapter/${name}`, + text: sel && sel !== name ? sel : name, + }); break; } case "image": { - const sel = selectedText(); - const alt = await promptInput("Image alt text", sel || "alt"); - if (alt === null) return; - const url = await promptInput("Image URL", "https://"); - if (url === null) return; - insertImage(alt || "alt", url || "https://")(editorView); + const res = await api.importImageAsset(); + if (!res || "canceled" in res) return; + if ("error" in res) { + await alertMessage("Could not insert image", (res as { error: string }).error); + return; + } + if ("relPath" in res) { + runCommand(editor, "image", { + src: (res as { relPath: string }).relPath, + }); + } break; } case "attach": { @@ -388,9 +300,8 @@ export function initBook(api: FolioAPI) { return; } case "callout": { - const title = await promptInput("Callout title", ""); - if (title === null) return; - insertCallout("note", title || undefined)(editorView); + // The native editor has no callout node; insert a styled blockquote. + runCommand(editor, "quote"); break; } case "table": { @@ -398,17 +309,20 @@ export function initBook(api: FolioAPI) { const colsEl = $("tableCols") as HTMLInputElement; const rows = Math.max(1, Math.min(12, parseInt(rowsEl.value, 10) || 3)); const cols = Math.max(1, Math.min(8, parseInt(colsEl.value, 10) || 2)); - insertTable(rows, cols)(editorView); + runCommand(editor, "table", { rows, cols }); break; } default: return; } - editorView.focus(); + editor.commands.focus(); } toolbar.addEventListener("pointerdown", (e) => { - if ((e.target as HTMLElement).closest("button, select")) e.preventDefault(); + // Prevent default only for buttons so the editor keeps its selection/focus + // when a formatting command is invoked. Never prevent it for - + @@ -84,6 +85,39 @@ + + +
@@ -191,6 +225,8 @@ + +
@@ -222,6 +258,7 @@ + diff --git a/src/renderer/md-editor.ts b/src/renderer/md-editor.ts deleted file mode 100644 index ce60995..0000000 --- a/src/renderer/md-editor.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { EditorState, type Extension } from "@codemirror/state"; -import { - EditorView, - drawSelection, - dropCursor, - highlightActiveLine, - keymap, - type KeyBinding, -} from "@codemirror/view"; -import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands"; -import { - bracketMatching, - HighlightStyle, - indentOnInput, - syntaxHighlighting, -} from "@codemirror/language"; -import { markdown } from "@codemirror/lang-markdown"; -import { highlightSelectionMatches, search, searchKeymap } from "@codemirror/search"; -import { tags } from "@lezer/highlight"; -import { toggleBold, toggleItalic, toggleLink, toggleStrikethrough, toggleUnderline } from "./md-format.js"; - -export interface MdEditorCallbacks { - onChange: (text: string) => void; - onSelection?: (view: EditorView) => void; - readOnly?: boolean; - extensions?: Extension[]; -} - -const formatBindings: KeyBinding[] = [ - { key: "Mod-b", run: toggleBold, preventDefault: true }, - { key: "Mod-i", run: toggleItalic, preventDefault: true }, - { key: "Mod-u", run: toggleUnderline, preventDefault: true }, - { key: "Mod-Shift-s", run: toggleStrikethrough, preventDefault: true }, - { key: "Mod-k", run: toggleLink, preventDefault: true }, -]; - -const mdHighlight = HighlightStyle.define([ - { tag: tags.heading1, class: "cm-h1" }, - { tag: tags.heading2, class: "cm-h2" }, - { tag: tags.heading3, class: "cm-h3" }, - { tag: tags.heading4, class: "cm-h4" }, - { tag: tags.heading5, class: "cm-h5" }, - { tag: tags.heading6, class: "cm-h6" }, - { tag: tags.strong, class: "cm-strong" }, - { tag: tags.emphasis, class: "cm-em" }, - { tag: tags.strikethrough, class: "cm-strike" }, - { tag: tags.link, class: "cm-link" }, - { tag: tags.url, class: "cm-url" }, - { tag: tags.monospace, class: "cm-code" }, - { tag: tags.quote, class: "cm-quote" }, - { tag: tags.contentSeparator, class: "cm-hr" }, - { tag: tags.comment, class: "cm-comment" }, -]); - -const folioTheme = EditorView.theme({ - "&": { - height: "100%", - fontSize: "1.05rem", - backgroundColor: "var(--color-bg)", - color: "var(--color-text)", - }, - "&.cm-focused": { - outline: "none", - }, - ".cm-scroller": { - fontFamily: "var(--font-serif)", - lineHeight: "1.7", - padding: "24px 20px", - }, - ".cm-content": { - caretColor: "var(--color-accent)", - maxWidth: "820px", - paddingBottom: "48px", - }, - ".cm-cursor, .cm-dropCursor": { - borderLeftColor: "var(--color-accent)", - }, - ".cm-selectionBackground, &.cm-focused .cm-selectionBackground": { - backgroundColor: "var(--color-accent)", - opacity: "0.25", - }, - ".cm-activeLine": { - backgroundColor: "var(--color-panel)", - }, - "&.cm-focused .cm-matchingBracket": { - outline: "1px solid var(--color-accent)", - }, - ".cm-searchMatch": { - backgroundColor: "var(--color-selection)", - }, - ".cm-searchMatch.cm-searchMatch-selected": { - backgroundColor: "var(--color-selection-strong)", - }, - ".cm-panels": { - backgroundColor: "var(--color-panel)", - color: "var(--color-text)", - fontFamily: "var(--font-sans)", - fontSize: "0.85rem", - }, - ".cm-panels input, .cm-panels button": { - fontFamily: "var(--font-sans)", - }, -}); - -export function createMdEditor(host: HTMLElement, callbacks: MdEditorCallbacks): EditorView { - const readOnly = !!callbacks.readOnly; - const view = new EditorView({ - state: EditorState.create({ - doc: "", - extensions: [ - EditorView.lineWrapping, - drawSelection(), - dropCursor(), - highlightActiveLine(), - highlightSelectionMatches(), - history(), - indentOnInput(), - bracketMatching(), - markdown(), - search({ top: true }), - syntaxHighlighting(mdHighlight), - folioTheme, - ...(callbacks.extensions ?? []), - ...(readOnly ? [EditorState.readOnly.of(true)] : []), - keymap.of( - readOnly - ? [...defaultKeymap, ...searchKeymap] - : [...formatBindings, ...defaultKeymap, ...historyKeymap, ...searchKeymap, indentWithTab] - ), - EditorView.updateListener.of((update) => { - if (update.docChanged) callbacks.onChange(update.state.doc.toString()); - if (callbacks.onSelection && (update.docChanged || update.selectionSet)) { - callbacks.onSelection(update.view); - } - }), - ], - }), - parent: host, - }); - return view; -} - -export function setEditorText(view: EditorView, text: string): void { - const current = view.state.doc.toString(); - if (current === text) return; - view.dispatch({ - changes: { from: 0, to: view.state.doc.length, insert: text }, - }); -} - -export function editorText(view: EditorView): string { - return view.state.doc.toString(); -} \ No newline at end of file diff --git a/src/renderer/md-format.ts b/src/renderer/md-format.ts deleted file mode 100644 index 6ef2f74..0000000 --- a/src/renderer/md-format.ts +++ /dev/null @@ -1,818 +0,0 @@ -import { - EditorSelection, - type EditorState, - type Transaction, - type TransactionSpec, -} from "@codemirror/state"; - -/** - * Minimal command target: the functions in this module only need read access - * to an editor state and a way to dispatch a transaction. Both `EditorView` - * and a plain { state, dispatch } object satisfy it, which keeps the module - * testable without a DOM. - */ -export interface FormatTarget { - state: EditorState; - dispatch: (tr: Transaction | TransactionSpec) => void; -} - -type FormatCommand = (target: FormatTarget) => boolean; - -function slice(state: EditorState, from: number, to: number): string { - return state.doc.sliceString(from, to); -} - -function lineRange(state: EditorState, from: number, to: number): { start: number; end: number } { - const first = state.doc.lineAt(from); - const last = state.doc.lineAt(to); - return { start: first.from, end: last.to }; -} - -/* ------------------------------------------------------------------ */ -/* Inline markers: bold, italic, strikethrough, inline code */ -/* ------------------------------------------------------------------ */ - -function toggleInline(marker: string): FormatCommand { - return (target) => { - const { state, dispatch } = target; - const { from, to } = state.selection.main; - const len = marker.length; - const single = len === 1; - - const before = from - len >= 0 ? slice(state, from - len, from) : ""; - const after = slice(state, to, to + len); - const isolatedBefore = - !single || from - len - 1 < 0 || slice(state, from - len - 1, from - len) !== marker; - const isolatedAfter = - !single || to + len >= state.doc.length || slice(state, to + len, to + len + 1) !== marker; - - if (before === marker && after === marker && isolatedBefore && isolatedAfter) { - dispatch({ - changes: [ - { from: from - len, to: from, insert: "" }, - { from: to, to: to + len, insert: "" }, - ], - selection: EditorSelection.single(from - len, to - len), - }); - return true; - } - - const text = slice(state, from, to); - const textStartsMarked = text.startsWith(marker); - const textEndsMarked = text.endsWith(marker); - const isolatedInnerBefore = - !single || text.length < len * 2 + 1 || text.charAt(len) !== marker; - const isolatedInnerAfter = - !single || text.length < len * 2 + 1 || text.charAt(text.length - len - 1) !== marker; - - if ( - text.length >= len * 2 && - textStartsMarked && - textEndsMarked && - isolatedInnerBefore && - isolatedInnerAfter - ) { - dispatch({ - changes: [ - { from, to: from + len, insert: "" }, - { from: to - len, to, insert: "" }, - ], - selection: EditorSelection.single(from, to - len * 2), - }); - return true; - } - - dispatch({ - changes: [ - { from, to: from, insert: marker }, - { from: to, to: to, insert: marker }, - ], - selection: EditorSelection.single(from + len, to + len), - }); - return true; - }; -} - -export const toggleBold: FormatCommand = toggleInline("**"); -export const toggleItalic: FormatCommand = toggleInline("*"); -export const toggleStrikethrough: FormatCommand = toggleInline("~~"); -export const toggleInlineCode: FormatCommand = toggleInline("`"); - -/* ------------------------------------------------------------------ */ -/* Code: inline for single-line/empty selection, fenced for blocks */ -/* ------------------------------------------------------------------ */ - -const fenceLineRe = /^```[a-zA-Z0-9_-]*[ \t]*$/; - -export const toggleCode: FormatCommand = (target) => { - const { state, dispatch } = target; - const { from, to } = state.selection.main; - const first = state.doc.lineAt(from); - const last = state.doc.lineAt(to); - - if (first.number === last.number) { - return toggleInlineCode(target); - } - - let start = first.from; - let end = last.to; - let region = slice(state, start, end); - let lines = region.split("\n"); - - // Selection starts at an opening fence: treat it as a fenced-block toggle. - if (lines.length > 1 && fenceLineRe.test(lines[0])) { - if (!fenceLineRe.test(lines[lines.length - 1]) && last.number < state.doc.lines) { - const next = state.doc.line(last.number + 1); - if (fenceLineRe.test(next.text)) end = next.to; - } - region = slice(state, start, end); - lines = region.split("\n"); - const body = lines.slice(1, lines.length > 1 && fenceLineRe.test(lines[lines.length - 1]) ? -1 : lines.length); - dispatch({ changes: { from: start, to: end, insert: body.join("\n") } }); - return true; - } - - dispatch({ changes: { from: start, to: end, insert: "```\n" + region + "\n```" } }); - return true; -}; - -/* ------------------------------------------------------------------ */ -/* Links */ -/* ------------------------------------------------------------------ */ - -export const toggleLink: FormatCommand = (target) => { - const { state, dispatch } = target; - const { from, to } = state.selection.main; - - const text = slice(state, from, to); - - // Selection is already a link → select its URL so the user can edit it. - const link = /^\[([^\]\n]*)\]\(([^)\n]*)\)$/.exec(text); - if (link) { - const urlFrom = from + 1 + link[1].length + 2; - dispatch({ - selection: EditorSelection.range(urlFrom, urlFrom + link[2].length), - scrollIntoView: true, - }); - return true; - } - - if (from === to) { - const pos = from; - dispatch({ - changes: { from: pos, to: pos, insert: "[](url)" }, - selection: EditorSelection.cursor(pos + 1), - }); - return true; - } - - const urlStart = from + text.length + 3; - dispatch({ - changes: { from, to, insert: `[${text}](url)` }, - selection: EditorSelection.range(urlStart, urlStart + 3), - }); - return true; -}; - -/* ------------------------------------------------------------------ */ -/* Headings */ -/* ------------------------------------------------------------------ */ - -const headingRe = /^(#{1,6})[ \t]/; - -export function setHeading(level: number): FormatCommand { - return (target) => { - const { state, dispatch } = target; - const { from } = state.selection.main; - const line = state.doc.lineAt(from); - const match = line.text.match(headingRe); - const existing = match ? match[1].length : 0; - - if (level === 0) { - if (!existing) return false; - dispatch({ changes: { from: line.from, to: line.from + existing + 1, insert: "" } }); - return true; - } - if (existing === level) return false; - - const insert = "#".repeat(level) + " "; - dispatch({ - changes: existing - ? { from: line.from, to: line.from + existing + 1, insert } - : { from: line.from, to: line.from, insert }, - }); - return true; - }; -} - -export function headingLevelAt(state: EditorState, pos: number): number { - const line = state.doc.lineAt(pos); - const match = line.text.match(headingRe); - return match ? match[1].length : 0; -} - -/* ------------------------------------------------------------------ */ -/* Line prefixes: bullet list, blockquote */ -/* ------------------------------------------------------------------ */ - -function toggleLinePrefix(prefix: string): FormatCommand { - return (target) => { - const { state, dispatch } = target; - const { from, to } = state.selection.main; - const { start, end } = lineRange(state, from, to); - const lines = slice(state, start, end).split("\n"); - if (!lines.length) return false; - const allHave = lines.every((line) => line.startsWith(prefix)); - const insert = lines.map((line) => (allHave ? line.slice(prefix.length) : prefix + line)).join("\n"); - dispatch({ changes: { from: start, to: end, insert } }); - return true; - }; -} - -export const toggleBulletList: FormatCommand = toggleLinePrefix("- "); -export const toggleBlockquote: FormatCommand = toggleLinePrefix("> "); - -/* ------------------------------------------------------------------ */ -/* Numbered list */ -/* ------------------------------------------------------------------ */ - -const orderedRe = /^\d+\.\s+/; - -export const toggleOrderedList: FormatCommand = (target) => { - const { state, dispatch } = target; - const { from, to } = state.selection.main; - const { start, end } = lineRange(state, from, to); - const lines = slice(state, start, end).split("\n"); - if (!lines.length) return false; - const allHave = lines.every((line) => orderedRe.test(line)); - const insert = lines - .map((line, index) => { - const stripped = line.replace(orderedRe, ""); - return allHave ? stripped : `${index + 1}. ${stripped}`; - }) - .join("\n"); - dispatch({ changes: { from: start, to: end, insert } }); - return true; -}; - -/* ------------------------------------------------------------------ */ -/* Horizontal rule */ -/* ------------------------------------------------------------------ */ - -export const insertHorizontalRule: FormatCommand = (target) => { - const { state, dispatch } = target; - const { from } = state.selection.main; - - if (from === 0) { - dispatch({ - changes: { from: 0, to: 0, insert: "---\n\n" }, - selection: EditorSelection.cursor(5), - }); - return true; - } - - const line = state.doc.lineAt(from); - if (line.from === from && line.text.length === 0) { - const hasNextLine = - line.to < state.doc.length && slice(state, line.to, line.to + 1) === "\n"; - const insertAt = hasNextLine ? line.to + 1 : line.from; - dispatch({ - changes: { from: insertAt, to: insertAt, insert: "---\n\n" }, - selection: EditorSelection.cursor(insertAt + 5), - }); - return true; - } - - dispatch({ - changes: { from, to: from, insert: "\n\n---\n\n" }, - selection: EditorSelection.cursor(from + 6), - }); - return true; -}; - -/* ------------------------------------------------------------------ */ -/* Clear formatting */ -/* ------------------------------------------------------------------ */ - -function clearLine(line: string): string { - let out = line; - out = out.replace(/^#{1,6}[ \t]+/, ""); - out = out.replace(/^>\s*/, ""); - out = out.replace(/^[-*+]\s+/, ""); - out = out.replace(/^\d+\.\s+/, ""); - out = out.replace(/\[([^\]\n]+)\]\([^)\n]*\)/g, "$1"); - out = out.replace(/\*\*|\*|~~|`|==/g, ""); - out = out.replace( - /|<\/u>|]*>|<\/mark>|]*>|<\/span>|]*>|<\/div>/g, - "" - ); - return out; -} - -export const clearFormatting: FormatCommand = (target) => { - const { state, dispatch } = target; - const { from, to } = state.selection.main; - const { start, end } = lineRange(state, from, to); - const region = slice(state, start, end); - const cleaned = region.split("\n").map(clearLine).join("\n"); - if (cleaned === region) return false; - dispatch({ changes: { from: start, to: end, insert: cleaned } }); - return true; -}; - -/* ------------------------------------------------------------------ */ -/* HTML inline wrappers: underline, colors (readable outside Folio) */ -/* ------------------------------------------------------------------ */ - -interface HtmlSpan { - from: number; - textFrom: number; - textTo: number; - to: number; -} - -function htmlSpanAt( - state: EditorState, - pos: number, - openPrefix: string, - closeTag: string -): HtmlSpan | null { - const before = state.doc.sliceString(0, pos); - const openIdx = before.lastIndexOf(openPrefix); - if (openIdx < 0) return null; - const gt = state.doc.sliceString(openIdx).indexOf(">"); - if (gt < 0) return null; - const textFrom = openIdx + gt + 1; - const after = state.doc.sliceString(textFrom); - const closeIdx = after.indexOf(closeTag); - if (closeIdx < 0) return null; - const textTo = textFrom + closeIdx; - if (pos >= openIdx && pos <= textTo + closeTag.length) { - return { from: openIdx, textFrom, textTo, to: textTo + closeTag.length }; - } - return null; -} - -function hexFrom(tag: string): string | null { - const m = /color:\s*(#[0-9a-fA-F]{3,8}|[a-zA-Z]+)/.exec(tag); - return m ? m[1].toLowerCase() : null; -} - -export const toggleUnderline: FormatCommand = (target) => { - const { state, dispatch } = target; - const { from, to } = state.selection.main; - - // Selection bounded by the tags: unwrap. - if ( - from - 3 >= 0 && - slice(state, from - 3, from) === "" && - slice(state, to, to + 4) === "" - ) { - dispatch({ - changes: [ - { from: from - 3, to: from, insert: "" }, - { from: to, to: to + 4, insert: "" }, - ], - selection: EditorSelection.single(from - 3, to - 3), - }); - return true; - } - - // Selection includes the tags: unwrap. - const text = slice(state, from, to); - if (text.startsWith("") && text.endsWith("")) { - dispatch({ - changes: [ - { from, to: from + 3, insert: "" }, - { from: to - 4, to, insert: "" }, - ], - selection: EditorSelection.single(from, to - 7), - }); - return true; - } - - // Cursor inside a wrapper: unwrap it. - const wrapper = htmlSpanAt(state, from, "", ""); - if (wrapper && from === to) { - dispatch({ - changes: [ - { from: wrapper.from, to: wrapper.textFrom, insert: "" }, - { from: wrapper.textTo, to: wrapper.to, insert: "" }, - ], - selection: EditorSelection.cursor(wrapper.textFrom), - }); - return true; - } - - dispatch({ - changes: [ - { from, to: from, insert: "" }, - { from: to, to: to, insert: "" }, - ], - selection: EditorSelection.single(from + 3, to + 3), - }); - return true; -}; - -// Obsidian-compatible highlight markers. -export const toggleHighlight: FormatCommand = toggleInline("=="); - -function wrapColored(kind: "color" | "bg", color: string): FormatCommand { - const attr = kind === "color" ? "color" : "background-color"; - const open = ``; - const close = ""; - return (target) => { - const { state, dispatch } = target; - const { from, to } = state.selection.main; - const text = slice(state, from, to); - - // Whole colored span selected: remove the color. - const re = new RegExp(`^([\\s\\S]*)<\\/span>$`); - const full = re.exec(text); - if (full) { - const inner = full[1]; - dispatch({ - changes: { from, to, insert: inner }, - selection: EditorSelection.single(from, from + inner.length), - }); - return true; - } - - // Selection/cursor inside an existing wrapper: remove if same color, else recolor. - const wrap = htmlSpanAt(state, from, ` wrapColored("color", color); -export const toggleHighlightColor = (color: string): FormatCommand => wrapColored("bg", color); - -function stripSpan(region: string, attr: string): string { - return region.replace( - new RegExp(`([\\s\\S]*?)<\\/span>`, "g"), - "$1" - ); -} - -export const removeColor: FormatCommand = (target) => { - const { state, dispatch } = target; - const { from, to } = state.selection.main; - const { start, end } = lineRange(state, from, to); - const region = slice(state, start, end); - const cleaned = stripSpan(region, "color"); - if (cleaned === region) return false; - dispatch({ changes: { from: start, to: end, insert: cleaned } }); - return true; -}; - -export const removeHighlight: FormatCommand = (target) => { - const { state, dispatch } = target; - const { from, to } = state.selection.main; - const { start, end } = lineRange(state, from, to); - const region = slice(state, start, end); - const cleaned = region - .replace(/([\s\S]*?)<\/span>/g, "$1") - .replace(/==([\s\S]*?)==/g, "$1"); - if (cleaned === region) return false; - dispatch({ changes: { from: start, to: end, insert: cleaned } }); - return true; -}; - -/* ------------------------------------------------------------------ */ -/* Task list */ -/* ------------------------------------------------------------------ */ - -const checklistRe = /^([-*+]\s+)\[([ xX])\]\s+/; - -export const toggleChecklist: FormatCommand = (target) => { - const { state, dispatch } = target; - const { from, to } = state.selection.main; - const { start, end } = lineRange(state, from, to); - const lines = slice(state, start, end).split("\n"); - if (!lines.length) return false; - const allHave = lines.every((l) => checklistRe.test(l)); - const insert = lines - .map((l) => { - if (allHave) { - const m = l.match(checklistRe)!; - const checked = m[2].toLowerCase() === "x"; - return `${m[1]}[${checked ? " " : "x"}] ${l.slice(m[0].length)}`; - } - const stripped = l.replace(/^([-*+]\s+|\d+\.\s+)/, ""); - return `- [ ] ${stripped}`; - }) - .join("\n"); - dispatch({ changes: { from: start, to: end, insert } }); - return true; -}; - -/* ------------------------------------------------------------------ */ -/* Insertions: wikilink, image, table, callout, attachment */ -/* ------------------------------------------------------------------ */ - -export function insertWikilink(name: string, label?: string): FormatCommand { - return (target) => { - const { state, dispatch } = target; - const { from, to } = state.selection.main; - const text = label && label !== name ? `[[${name}|${label}]]` : `[[${name}]]`; - dispatch({ - changes: { from, to, insert: text }, - selection: EditorSelection.cursor(from + text.length), - }); - return true; - }; -} - -export function insertImage(alt: string, url: string): FormatCommand { - return (target) => { - const { state, dispatch } = target; - const { from, to } = state.selection.main; - const text = `![${alt}](${url})`; - dispatch({ - changes: { from, to, insert: text }, - selection: EditorSelection.range(from + alt.length + 4, from + alt.length + 4 + url.length), - }); - return true; - }; -} - -export function insertTable(rows: number, cols: number): FormatCommand { - return (target) => { - const { state, dispatch } = target; - const { from } = state.selection.main; - const header = `| ${Array.from({ length: cols }, (_, i) => `Col ${i + 1}`).join(" | ")} |`; - const sep = `| ${Array(cols).fill("---").join(" | ")} |`; - const bodyRows = Math.max(1, rows - 1); - const body = Array.from( - { length: bodyRows }, - () => `| ${Array(cols).fill(" ").join(" | ")} |` - ).join("\n"); - const table = `${header}\n${sep}\n${body}`; - const line = state.doc.lineAt(from); - const prefix = from > 0 && line.from !== from ? "\n" : ""; - const insert = `${prefix}${table}\n`; - const cellFrom = from + prefix.length + header.length + 1 + sep.length + 1 + 2; - dispatch({ - changes: { from, to: from, insert }, - selection: EditorSelection.cursor(cellFrom), - }); - return true; - }; -} - -export function insertCallout(type = "note", title?: string): FormatCommand { - return (target) => { - const { state, dispatch } = target; - const { from, to } = state.selection.main; - const head = title ? `> [!${type}] ${title}\n` : `> [!${type}]\n`; - const insert = head + "> "; - dispatch({ - changes: { from, to, insert }, - selection: EditorSelection.cursor(from + insert.length), - }); - return true; - }; -} - -export function insertAttachment(name: string, relPath: string): FormatCommand { - return (target) => { - const { state, dispatch } = target; - const { from, to } = state.selection.main; - const text = `[${name}](${relPath})`; - dispatch({ - changes: { from, to, insert: text }, - selection: EditorSelection.cursor(from + text.length), - }); - return true; - }; -} - -/* ------------------------------------------------------------------ */ -/* Alignment (HTML, compatible with Obsidian) */ -/* ------------------------------------------------------------------ */ - -const alignDivRe = /^(
)([\s\S]*)(<\/div>)$/; - -export function toggleAlign(align: "left" | "center" | "right"): FormatCommand { - return (target) => { - const { state, dispatch } = target; - const { from, to } = state.selection.main; - const { start, end } = lineRange(state, from, to); - const region = slice(state, start, end); - const m = alignDivRe.exec(region); - if (m) { - if (align === "left" || m[2] === align) { - dispatch({ changes: { from: start, to: end, insert: m[3] } }); - } else { - dispatch({ changes: { from: start, to: end, insert: `
${m[3]}
` } }); - } - return true; - } - if (align === "left") return false; - const open = `
`; - dispatch({ - changes: { from: start, to: end, insert: `${open}${region}
` }, - selection: EditorSelection.single(start + open.length, end + open.length), - }); - return true; - }; -} - -/* ------------------------------------------------------------------ */ -/* Explicit code block */ -/* ------------------------------------------------------------------ */ - -function fencedBlockAt(state: EditorState, pos: number): { from: number; to: number } | null { - const doc = state.doc; - const line = doc.lineAt(pos); - let open = fenceLineRe.test(line.text) ? line.number : -1; - if (open < 0) { - for (let n = line.number - 1; n >= 1; n--) { - if (fenceLineRe.test(doc.line(n).text)) { - open = n; - break; - } - } - } - if (open < 1) return null; - for (let n = open + 1; n <= doc.lines; n++) { - if (fenceLineRe.test(doc.line(n).text)) { - return { from: doc.line(open).from, to: doc.line(n).to }; - } - } - return null; -} - -export const toggleFencedCode: FormatCommand = (target) => { - const { state, dispatch } = target; - const { from, to } = state.selection.main; - const { start, end } = lineRange(state, from, to); - - const block = fencedBlockAt(state, start); - if (block) { - const body = slice(state, block.from, block.to).split("\n").slice(1, -1).join("\n"); - dispatch({ changes: { from: block.from, to: block.to, insert: body } }); - return true; - } - - const region = slice(state, start, end); - dispatch({ - changes: { from: start, to: end, insert: "```\n" + region + "\n```" }, - selection: EditorSelection.single(start + 4, end + 4), - }); - return true; -}; - -/* ------------------------------------------------------------------ */ -/* Active-state detection */ -/* ------------------------------------------------------------------ */ - -export interface FormatState { - bold: boolean; - italic: boolean; - strike: boolean; - underline: boolean; - highlight: boolean; - inlineCode: boolean; - color: string | null; - hColor: string | null; - heading: number; - list: "bullet" | "ordered" | "checklist" | null; - align: "left" | "center" | "right" | null; -} - -function countOccurrences(text: string, sub: string): number { - let count = 0; - let i = 0; - while ((i = text.indexOf(sub, i)) !== -1) { - count += 1; - i += sub.length; - } - return count; -} - -function oddBefore(line: string, pos: number, marker: string): boolean { - return countOccurrences(line.slice(0, pos), marker) % 2 === 1; -} - -export function detectFormatting(state: EditorState, pos: number): FormatState { - const line = state.doc.lineAt(pos); - const inLine = pos - line.from; - const before = line.text.slice(0, inLine); - const italic = (countOccurrences(before, "*") - 2 * countOccurrences(before, "**")) % 2 === 1; - - let list: FormatState["list"] = null; - if (checklistRe.test(line.text)) list = "checklist"; - else if (/^[-*+]\s+/.test(line.text)) list = "bullet"; - else if (orderedRe.test(line.text)) list = "ordered"; - - let align: FormatState["align"] = null; - const div = htmlSpanAt(state, pos, "
"); - if (div) { - const tag = state.doc.sliceString(div.from, div.textFrom).toLowerCase(); - if (tag.includes('align="center"')) align = "center"; - else if (tag.includes('align="right"')) align = "right"; - else align = "left"; - } - - const u = htmlSpanAt(state, pos, "", ""); - const mark = htmlSpanAt(state, pos, '"); - const span = htmlSpanAt(state, pos, '"); - - return { - bold: oddBefore(line.text, inLine, "**"), - italic, - strike: oddBefore(line.text, inLine, "~~"), - underline: u !== null, - highlight: oddBefore(line.text, inLine, "==") || mark !== null, - inlineCode: oddBefore(line.text, inLine, "`"), - color: span ? hexFrom(state.doc.sliceString(span.from, span.textFrom)) : null, - hColor: mark ? hexFrom(state.doc.sliceString(mark.from, mark.textFrom)) : null, - heading: headingLevelAt(state, pos), - list, - align, - }; -} - -export const captureFormat: (state: EditorState, pos: number) => FormatState = detectFormatting; - -/* ------------------------------------------------------------------ */ -/* Format painter */ -/* ------------------------------------------------------------------ */ - -function selectionWrapped(state: EditorState, open: string, close: string): boolean { - const { from, to } = state.selection.main; - const t = slice(state, from, to); - return t.startsWith(open) && t.endsWith(close); -} - -function ensureWrap(target: FormatTarget, open: string, close: string): boolean { - const { state, dispatch } = target; - const { from, to } = state.selection.main; - if (from === to) return false; - dispatch({ - changes: [ - { from, to: from, insert: open }, - { from: to, to: to, insert: close }, - ], - selection: EditorSelection.single(from + open.length, to + open.length), - }); - return true; -} - -export function applyFormatPainter(target: FormatTarget, fmt: FormatState): boolean { - let changed = false; - if (fmt.bold && !selectionWrapped(target.state, "**", "**")) changed = ensureWrap(target, "**", "**") || changed; - if (fmt.italic && !selectionWrapped(target.state, "*", "*")) changed = ensureWrap(target, "*", "*") || changed; - if (fmt.strike && !selectionWrapped(target.state, "~~", "~~")) changed = ensureWrap(target, "~~", "~~") || changed; - if (fmt.underline && !selectionWrapped(target.state, "", "")) changed = ensureWrap(target, "", "") || changed; - if (fmt.highlight && !selectionWrapped(target.state, "==", "==")) changed = ensureWrap(target, "==", "==") || changed; - - const hasSelection = - target.state.selection.main.from !== target.state.selection.main.to; - if (fmt.color && hasSelection) { - const t = slice(target.state, target.state.selection.main.from, target.state.selection.main.to); - if (!/^/.test(t)) changed = toggleTextColor(fmt.color)(target) || changed; - } - if (fmt.hColor && hasSelection) { - const t = slice(target.state, target.state.selection.main.from, target.state.selection.main.to); - if (!/^/.test(t)) changed = toggleHighlightColor(fmt.hColor)(target) || changed; - } - - const cur = detectFormatting(target.state, target.state.selection.main.from); - if (fmt.heading && cur.heading !== fmt.heading) changed = setHeading(fmt.heading)(target) || changed; - if (fmt.list && cur.list !== fmt.list) { - if (fmt.list === "bullet") changed = toggleBulletList(target) || changed; - else if (fmt.list === "ordered") changed = toggleOrderedList(target) || changed; - else if (fmt.list === "checklist") changed = toggleChecklist(target) || changed; - } - return changed; -} \ No newline at end of file diff --git a/src/renderer/native-assembly.ts b/src/renderer/native-assembly.ts new file mode 100644 index 0000000..8315fb2 --- /dev/null +++ b/src/renderer/native-assembly.ts @@ -0,0 +1,82 @@ +// Pure assembly logic for the Full Book view. +// +// The Full Book renders every chapter's native rich-text document back-to-back +// in a single read-only ProseMirror surface, separated by ChapterBreak anchors. +// These helpers are kept free of DOM/IPC so they can be unit-tested directly +// and reused by the renderer (book.ts). +import type { FolioNode } from "../main/native-doc.js"; +import type { WikilinkTarget } from "./wikilinks.js"; + +export interface FullBookChapterInput { + id: string; + title: string; + // Native ProseMirror document ({ type: "doc", content: [...] }). + doc: FolioNode; +} + +export interface FullBookAssembly { + nativeDoc: FolioNode; + chapterEntries: { id: string; title: string }[]; + targets: WikilinkTarget[]; +} + +// Concatenate the plain text of a native node tree (used for word/char counts). +// Atom leaf nodes (chapterBreak, image, horizontal rule, …) carry no text. +export function nativeNodeText(node: FolioNode): string { + if (node.text) return node.text; + let t = ""; + for (const c of node.content ?? []) t += nativeNodeText(c); + return t; +} + +// Whether the native content already opens with a heading matching its title, +// in which case we must not inject a duplicate title heading. +export function nativeHasTitleHeading(content: FolioNode[], title: string): boolean { + const t = title.trim().toLowerCase(); + if (!t) return false; + for (const n of content) { + if (n.type === "heading" && nativeNodeText(n).trim().toLowerCase() === t) return true; + } + return false; +} + +// Build the top-level nodes for one chapter in the Full Book, injecting a title +// heading when the content does not already provide one. +export function buildChapterNativeContent(content: FolioNode[], title: string): FolioNode[] { + if (nativeHasTitleHeading(content, title)) return content.slice(); + return [ + { type: "heading", attrs: { level: 1 }, content: [{ type: "text", text: title }] }, + ...content, + ]; +} + +// A ChapterBreak anchor marking where a chapter begins in the assembled view. +export function chapterBreakNode(id: string, title: string, first: boolean): FolioNode { + return { type: "chapterBreak", attrs: { chapterId: id, title, first } }; +} + +// Assemble the whole book directly from native chapter documents. The first +// chapter's break is unmarked; every following chapter gets a separator break. +export function buildFullBookDoc(chapters: FullBookChapterInput[]): FullBookAssembly { + const nativeContent: FolioNode[] = []; + const chapterEntries: { id: string; title: string }[] = []; + const targets: WikilinkTarget[] = []; + let index = 0; + for (const ch of chapters) { + targets.push({ id: ch.id, title: ch.title }); + + const chapterDoc = + ch.doc && ch.doc.type === "doc" ? ch.doc : { type: "doc" as const, content: [] }; + const content = Array.isArray(chapterDoc.content) ? chapterDoc.content : []; + nativeContent.push(chapterBreakNode(ch.id, ch.title, index === 0)); + for (const n of buildChapterNativeContent(content, ch.title)) nativeContent.push(n); + + chapterEntries.push({ id: ch.id, title: ch.title }); + index++; + } + return { + nativeDoc: { type: "doc", content: nativeContent }, + chapterEntries, + targets, + }; +} diff --git a/src/renderer/print-format.ts b/src/renderer/print-format.ts index b16f486..185df45 100644 --- a/src/renderer/print-format.ts +++ b/src/renderer/print-format.ts @@ -1,136 +1,170 @@ // Print rendering for the PDF-exported manuscript (#printBook). // -// The PDF is produced by printing plain HTML, so this renderer has to keep -// every formatting feature Folio's editor can write. Standard markdown is -// handled by markdown-it; the extensions on top of it are: -// - raw HTML passthrough (html: true) so the toolbar's , -// and
survive instead of showing as literal text, -// - Obsidian-style ==highlight== rendered as , -// - Obsidian callouts (> [!note] Title) rendered as styled divs, -// - wikilinks ([[Title]]) resolved to links via the shared wikilink plugin. -import MarkdownIt from "markdown-it"; -import type { - MarkdownIt as MarkdownItInstance, - StateCore, - StateInline, -} from "markdown-it"; -import { wikilinkPlugin, type WikilinkTarget } from "./wikilinks.js"; +// The PDF is produced by printing plain HTML, so this renderer walks the +// native Folio document model directly and emits HTML that keeps every +// formatting feature the editor can write. Chapters are emitted as +//