commit 1abb780cb77ea8fccf7771324e747bf5b15c3cf2 Author: Anthony Fitzpatrick <11820350+anthonyfitzpatrick@users.noreply.github.com> Date: Sat Jul 11 22:27:23 2026 +0200 Initial Manuscript Compiler MVP diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5103ff6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +main.js +*.map +Icon? diff --git a/README.md b/README.md new file mode 100644 index 0000000..0b1039b --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# Manuscript Compiler + +Manuscript Compiler is an Obsidian plugin that recursively compiles a book folder into one clean Markdown file for later import into tools such as Vellum. + +## Stage 1 features + +- Compiles front matter, parts, chapters, scenes, and back matter in natural filename order. +- Optionally removes YAML frontmatter and includes scene titles. +- Uses a configurable separator between scenes. +- Creates the export folder when needed and confirms before overwriting. +- Reports structural counts, word count, and warnings without modifying source notes. + +## Development + +```bash +npm install +npm run build +``` + +Copy `manifest.json`, `main.js`, and (if present) `styles.css` to `.obsidian/plugins/manuscript-compiler/` in a test vault, then enable the plugin in Obsidian. + +## Book structure + +The selected book folder may contain `Ebook Front Matter`, part folders containing chapter folders and Markdown scene files, and `Ebook Back Matter`. Hidden files and folders are ignored. Files and folders are sorted using natural, case-insensitive filename order. diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..a8c4ccd --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,23 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const production = process.argv[2] === "production"; +const context = await esbuild.context({ + entryPoints: ["src/main.ts"], + bundle: true, + external: ["obsidian", "electron", "@codemirror/autocomplete", "@codemirror/collab", "@codemirror/commands", "@codemirror/language", "@codemirror/lint", "@codemirror/search", "@codemirror/state", "@codemirror/view", "@lezer/common", "@lezer/highlight", "@lezer/lr", ...builtins], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: production ? false : "inline", + treeShaking: true, + outfile: "main.js" +}); + +if (production) { + await context.rebuild(); + await context.dispose(); +} else { + await context.watch(); +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..88416fc --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "manuscript-compiler", + "name": "Manuscript Compiler", + "version": "0.1.0", + "minAppVersion": "1.5.0", + "description": "Compile a book folder into a single Markdown manuscript.", + "author": "Anthony Fitzpatrick", + "isDesktopOnly": false +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..af4803a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,639 @@ +{ + "name": "obsidian-manuscript-compiler", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "obsidian-manuscript-compiler", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^22.10.2", + "builtin-modules": "^4.0.0", + "esbuild": "^0.24.2", + "obsidian": "^1.8.7", + "typescript": "^5.7.2" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz", + "integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz", + "integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz", + "integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz", + "integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz", + "integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz", + "integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz", + "integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz", + "integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz", + "integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz", + "integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz", + "integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz", + "integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz", + "integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz", + "integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz", + "integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz", + "integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz", + "integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz", + "integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz", + "integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz", + "integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz", + "integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz", + "integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz", + "integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz", + "integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz", + "integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "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==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/builtin-modules": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-4.0.0.tgz", + "integrity": "sha512-p1n8zyCkt1BVrKNFymOHjcDSAl7oq/gUvfgULv2EblgpPVQlQr9yHnWjg9IJ2MhfwPqiYqMMrr01OY7yQoK2yA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/esbuild": { + "version": "0.24.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz", + "integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.2", + "@esbuild/android-arm": "0.24.2", + "@esbuild/android-arm64": "0.24.2", + "@esbuild/android-x64": "0.24.2", + "@esbuild/darwin-arm64": "0.24.2", + "@esbuild/darwin-x64": "0.24.2", + "@esbuild/freebsd-arm64": "0.24.2", + "@esbuild/freebsd-x64": "0.24.2", + "@esbuild/linux-arm": "0.24.2", + "@esbuild/linux-arm64": "0.24.2", + "@esbuild/linux-ia32": "0.24.2", + "@esbuild/linux-loong64": "0.24.2", + "@esbuild/linux-mips64el": "0.24.2", + "@esbuild/linux-ppc64": "0.24.2", + "@esbuild/linux-riscv64": "0.24.2", + "@esbuild/linux-s390x": "0.24.2", + "@esbuild/linux-x64": "0.24.2", + "@esbuild/netbsd-arm64": "0.24.2", + "@esbuild/netbsd-x64": "0.24.2", + "@esbuild/openbsd-arm64": "0.24.2", + "@esbuild/openbsd-x64": "0.24.2", + "@esbuild/sunos-x64": "0.24.2", + "@esbuild/win32-arm64": "0.24.2", + "@esbuild/win32-ia32": "0.24.2", + "@esbuild/win32-x64": "0.24.2" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/obsidian": { + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.13.1.tgz", + "integrity": "sha512-qtTEA2pmhJzhuhJqzbBFRYhpIOqvW+krDYjtFynv66KbxBbumHBlsJfWw3I4jtnK/6fZwbQhCrmmDdRwXmX56w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "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==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT", + "peer": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..52a50ba --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "obsidian-manuscript-compiler", + "version": "0.1.0", + "description": "Compile an Obsidian book folder into a single Markdown manuscript.", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "typecheck": "tsc -noEmit -skipLibCheck" + }, + "keywords": ["obsidian", "manuscript", "markdown"], + "license": "MIT", + "devDependencies": { + "@types/node": "^22.10.2", + "builtin-modules": "^4.0.0", + "esbuild": "^0.24.2", + "obsidian": "^1.8.7", + "typescript": "^5.7.2" + } +} diff --git a/src/compiler.ts b/src/compiler.ts new file mode 100644 index 0000000..51b5476 --- /dev/null +++ b/src/compiler.ts @@ -0,0 +1,82 @@ +import { Vault } from "obsidian"; +import type { ManuscriptCompilerSettings } from "./settings"; +import type { CompileResult, ScannedBook } from "./types"; + +export class ManuscriptCompiler { + constructor(private readonly vault: Vault) {} + + async compile(book: ScannedBook, settings: ManuscriptCompilerSettings): Promise { + const sections: string[] = []; + const warnings = [...book.warnings]; + let scenes = 0; + let chapters = 0; + + if (settings.includeFrontMatter) { + const content = await this.readFiles(book.frontMatter, settings, false); + sections.push(...content); + } + + for (const part of book.parts) { + sections.push(`# ${part.folder.name}`); + const loose = await this.readFiles(part.looseScenes, settings, true); + sections.push(...this.joinScenes(loose, settings.sceneSeparator)); + scenes += loose.length; + + for (const chapter of part.chapters) { + sections.push(`## ${chapter.folder.name}`); + chapters += 1; + const content = await this.readFiles(chapter.scenes, settings, true); + sections.push(...this.joinScenes(content, settings.sceneSeparator)); + scenes += content.length; + if (content.length === 0) warnings.push(`Chapter “${chapter.folder.name}” contains no Markdown files.`); + } + } + + const rootScenes = await this.readFiles(book.looseScenes, settings, true); + sections.push(...this.joinScenes(rootScenes, settings.sceneSeparator)); + scenes += rootScenes.length; + + if (settings.includeBackMatter) { + const content = await this.readFiles(book.backMatter, settings, false); + sections.push(...content); + } + + const markdown = `${sections.filter(Boolean).join("\n\n").trim()}\n`; + return { markdown, parts: book.parts.length, chapters, scenes, wordCount: this.countWords(markdown), warnings }; + } + + private async readFiles(files: ScannedBook["frontMatter"], settings: ManuscriptCompilerSettings, titleScenes: boolean): Promise { + return Promise.all(files.map(async (file) => { + let body = await this.vault.cachedRead(file); + if (settings.stripYamlFrontmatter) body = this.stripYaml(body); + body = body.trim(); + if (titleScenes && settings.includeSceneTitles && body) return `### ${file.basename}\n\n${body}`; + return body; + })); + } + + private joinScenes(scenes: string[], separator: string): string[] { + const nonEmpty = scenes.filter(Boolean); + const result: string[] = []; + nonEmpty.forEach((scene, index) => { + if (index > 0 && separator.trim()) result.push(separator.trim()); + result.push(scene); + }); + return result; + } + + private stripYaml(markdown: string): string { + const normalized = markdown.replace(/^\uFEFF/, ""); + return normalized.replace(/^---[\t ]*\r?\n[\s\S]*?\r?\n(?:---|\.\.\.)[\t ]*(?:\r?\n|$)/, ""); + } + + private countWords(markdown: string): number { + const text = markdown + .replace(/```[\s\S]*?```/g, " ") + .replace(/`[^`]*`/g, " ") + .replace(/^#{1,6}\s+.*$/gm, " ") + .replace(/[\p{P}\p{S}]+/gu, " ") + .trim(); + return text ? text.split(/\s+/u).length : 0; + } +} diff --git a/src/exporter.ts b/src/exporter.ts new file mode 100644 index 0000000..ec03e5f --- /dev/null +++ b/src/exporter.ts @@ -0,0 +1,34 @@ +import { normalizePath, TFile, Vault } from "obsidian"; + +export class MarkdownExporter { + constructor(private readonly vault: Vault) {} + + getOutputPath(exportFolder: string, bookName: string): string { + const folder = normalizePath(exportFolder.trim().replace(/^\/+|\/+$/g, "")); + const safeName = bookName.replace(/[\\/:*?"<>|]/g, "-").trim() || "Manuscript"; + return normalizePath(folder ? `${folder}/${safeName} Manuscript.md` : `${safeName} Manuscript.md`); + } + + exists(path: string): boolean { + return this.vault.getAbstractFileByPath(path) instanceof TFile; + } + + async write(path: string, markdown: string): Promise { + const lastSlash = path.lastIndexOf("/"); + if (lastSlash >= 0) await this.ensureFolder(path.slice(0, lastSlash)); + const existing = this.vault.getAbstractFileByPath(path); + if (existing instanceof TFile) { + await this.vault.modify(existing, markdown); + return existing; + } + return this.vault.create(path, markdown); + } + + private async ensureFolder(path: string): Promise { + const parts = normalizePath(path).split("/"); + for (let index = 1; index <= parts.length; index += 1) { + const current = parts.slice(0, index).join("/"); + if (!this.vault.getAbstractFileByPath(current)) await this.vault.createFolder(current); + } + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..10dd3c6 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,67 @@ +import { Notice, Plugin, TFolder } from "obsidian"; +import { ManuscriptCompiler } from "./compiler"; +import { MarkdownExporter } from "./exporter"; +import { DEFAULT_SETTINGS, ManuscriptCompilerSettings } from "./settings"; +import { CompileReportModal, ConfirmOverwriteModal, FolderSuggestModal, ManuscriptCompilerSettingTab, showError } from "./ui"; +import { VaultScanner } from "./vault-scanner"; + +export default class ManuscriptCompilerPlugin extends Plugin { + settings: ManuscriptCompilerSettings = DEFAULT_SETTINGS; + + async onload(): Promise { + await this.loadSettings(); + this.addSettingTab(new ManuscriptCompilerSettingTab(this.app, this)); + this.addCommand({ id: "compile-current-book", name: "Compile Current Book", callback: () => { void this.compileCurrentBook(); } }); + this.addCommand({ id: "compile-selected-folder", name: "Compile Selected Folder", callback: () => { new FolderSuggestModal(this.app, (folder) => { void this.compileFolder(folder); }).open(); } }); + } + + async loadSettings(): Promise { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial | null); } + async saveSettings(): Promise { await this.saveData(this.settings); } + + private async compileCurrentBook(): Promise { + try { + const folder = this.resolveCurrentBook(); + if (!folder) throw new Error("Set a default manuscript folder, or open a note inside a book folder containing Part folders."); + await this.compileFolder(folder); + } catch (error) { showError(error); } + } + + private resolveCurrentBook(): TFolder | null { + if (this.settings.defaultManuscriptFolder) { + const configured = this.app.vault.getAbstractFileByPath(this.settings.defaultManuscriptFolder); + if (configured instanceof TFolder) return configured; + } + const active = this.app.workspace.getActiveFile(); + let folder = active?.parent ?? null; + while (folder && folder.path !== "/") { + const hasBookStructure = folder.children.some((child) => child instanceof TFolder && (/^part\b/i.test(child.name) || /^ebook (front|back) matter$/i.test(child.name))); + if (hasBookStructure) return folder; + folder = folder.parent; + } + return null; + } + + private async compileFolder(folder: TFolder): Promise { + try { + new Notice(`Compiling “${folder.name}”…`); + const scan = new VaultScanner().scan(folder); + const result = await new ManuscriptCompiler(this.app.vault).compile(scan, this.settings); + const exporter = new MarkdownExporter(this.app.vault); + const outputPath = exporter.getOutputPath(this.settings.defaultExportFolder, folder.name); + if (exporter.exists(outputPath) && !await this.confirmOverwrite(outputPath)) { new Notice("Compilation cancelled."); return; } + const output = await exporter.write(outputPath, result.markdown); + new CompileReportModal(this.app, { output: output.path, parts: result.parts, chapters: result.chapters, scenes: result.scenes, wordCount: result.wordCount, warnings: result.warnings }).open(); + } catch (error) { showError(error); } + } + + private confirmOverwrite(path: string): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (value: boolean): void => { if (!settled) { settled = true; resolve(value); } }; + const modal = new ConfirmOverwriteModal(this.app, path, finish); + const originalClose = modal.onClose.bind(modal); + modal.onClose = (): void => { originalClose(); finish(false); }; + modal.open(); + }); + } +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..f2d8ba4 --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,19 @@ +export interface ManuscriptCompilerSettings { + defaultManuscriptFolder: string; + defaultExportFolder: string; + includeFrontMatter: boolean; + includeBackMatter: boolean; + stripYamlFrontmatter: boolean; + includeSceneTitles: boolean; + sceneSeparator: string; +} + +export const DEFAULT_SETTINGS: ManuscriptCompilerSettings = { + defaultManuscriptFolder: "", + defaultExportFolder: "Manuscript Exports", + includeFrontMatter: true, + includeBackMatter: true, + stripYamlFrontmatter: true, + includeSceneTitles: false, + sceneSeparator: "#" +}; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..b07a147 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,30 @@ +import type { TFile, TFolder } from "obsidian"; + +export interface ScannedBook { + root: TFolder; + frontMatter: TFile[]; + parts: ScannedPart[]; + looseScenes: TFile[]; + backMatter: TFile[]; + warnings: string[]; +} + +export interface ScannedPart { + folder: TFolder; + chapters: ScannedChapter[]; + looseScenes: TFile[]; +} + +export interface ScannedChapter { + folder: TFolder; + scenes: TFile[]; +} + +export interface CompileResult { + markdown: string; + parts: number; + chapters: number; + scenes: number; + wordCount: number; + warnings: string[]; +} diff --git a/src/ui.ts b/src/ui.ts new file mode 100644 index 0000000..8a4c308 --- /dev/null +++ b/src/ui.ts @@ -0,0 +1,65 @@ +import { App, FuzzySuggestModal, Modal, Notice, PluginSettingTab, Setting, TFolder } from "obsidian"; +import type ManuscriptCompilerPlugin from "./main"; + +export class FolderSuggestModal extends FuzzySuggestModal { + constructor(app: App, private readonly onChoose: (folder: TFolder) => void) { super(app); } + getItems(): TFolder[] { return this.app.vault.getAllLoadedFiles().filter((file): file is TFolder => file instanceof TFolder && file.path !== "/"); } + getItemText(folder: TFolder): string { return folder.path; } + onChooseItem(folder: TFolder): void { this.onChoose(folder); } +} + +export class ConfirmOverwriteModal extends Modal { + constructor(app: App, private readonly path: string, private readonly resolve: (confirmed: boolean) => void) { super(app); } + onOpen(): void { + this.titleEl.setText("Overwrite manuscript?"); + this.contentEl.createEl("p", { text: `The file “${this.path}” already exists. Replace it?` }); + new Setting(this.contentEl) + .addButton((button) => button.setButtonText("Cancel").onClick(() => { this.resolve(false); this.close(); })) + .addButton((button) => button.setButtonText("Overwrite").setWarning().onClick(() => { this.resolve(true); this.close(); })); + } + onClose(): void { this.contentEl.empty(); } +} + +export class CompileReportModal extends Modal { + constructor(app: App, private readonly report: { output: string; parts: number; chapters: number; scenes: number; wordCount: number; warnings: string[] }) { super(app); } + onOpen(): void { + this.titleEl.setText("Compilation complete"); + const list = this.contentEl.createEl("dl", { cls: "manuscript-compiler-report" }); + this.addRow(list, "Output file", this.report.output); + this.addRow(list, "Parts", String(this.report.parts)); + this.addRow(list, "Chapters", String(this.report.chapters)); + this.addRow(list, "Scenes", String(this.report.scenes)); + this.addRow(list, "Word count", this.report.wordCount.toLocaleString()); + this.contentEl.createEl("h3", { text: "Warnings" }); + if (this.report.warnings.length === 0) this.contentEl.createEl("p", { text: "None" }); + else { + const warnings = this.contentEl.createEl("ul"); + this.report.warnings.forEach((warning) => warnings.createEl("li", { text: warning })); + } + } + private addRow(list: HTMLElement, label: string, value: string): void { list.createEl("dt", { text: label }); list.createEl("dd", { text: value }); } +} + +export class ManuscriptCompilerSettingTab extends PluginSettingTab { + constructor(app: App, private readonly plugin: ManuscriptCompilerPlugin) { super(app, plugin); } + display(): void { + const { containerEl } = this; + containerEl.empty(); + new Setting(containerEl).setName("Default manuscript folder").setDesc("Vault-relative path to the book folder used by Compile Current Book.").addText((text) => text.setPlaceholder("Books/My Book").setValue(this.plugin.settings.defaultManuscriptFolder).onChange(async (value) => { this.plugin.settings.defaultManuscriptFolder = value.trim(); await this.plugin.saveSettings(); })); + new Setting(containerEl).setName("Default export folder").setDesc("Vault-relative folder where compiled manuscripts are written.").addText((text) => text.setPlaceholder("Manuscript Exports").setValue(this.plugin.settings.defaultExportFolder).onChange(async (value) => { this.plugin.settings.defaultExportFolder = value.trim(); await this.plugin.saveSettings(); })); + this.addToggle(containerEl, "Include front matter", "Include Markdown files under Ebook Front Matter.", "includeFrontMatter"); + this.addToggle(containerEl, "Include back matter", "Include Markdown files under Ebook Back Matter.", "includeBackMatter"); + this.addToggle(containerEl, "Strip YAML frontmatter", "Remove leading YAML frontmatter from every source note.", "stripYamlFrontmatter"); + this.addToggle(containerEl, "Include scene titles", "Add each scene filename as a level-three heading.", "includeSceneTitles"); + new Setting(containerEl).setName("Scene separator").setDesc("Markdown inserted between consecutive scenes. The default is #.").addText((text) => text.setValue(this.plugin.settings.sceneSeparator).onChange(async (value) => { this.plugin.settings.sceneSeparator = value; await this.plugin.saveSettings(); })); + } + private addToggle(container: HTMLElement, name: string, description: string, key: "includeFrontMatter" | "includeBackMatter" | "stripYamlFrontmatter" | "includeSceneTitles"): void { + new Setting(container).setName(name).setDesc(description).addToggle((toggle) => toggle.setValue(this.plugin.settings[key]).onChange(async (value) => { this.plugin.settings[key] = value; await this.plugin.saveSettings(); })); + } +} + +export function showError(error: unknown): void { + const message = error instanceof Error ? error.message : String(error); + new Notice(`Manuscript Compiler: ${message}`, 8000); + console.error("Manuscript Compiler", error); +} diff --git a/src/vault-scanner.ts b/src/vault-scanner.ts new file mode 100644 index 0000000..db9e378 --- /dev/null +++ b/src/vault-scanner.ts @@ -0,0 +1,65 @@ +import { TAbstractFile, TFile, TFolder } from "obsidian"; +import type { ScannedBook, ScannedChapter, ScannedPart } from "./types"; + +const FRONT_MATTER = "ebook front matter"; +const BACK_MATTER = "ebook back matter"; +const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + +export class VaultScanner { + scan(root: TFolder): ScannedBook { + const warnings: string[] = []; + const visibleChildren = this.sortedVisibleChildren(root); + const frontFolder = visibleChildren.find((item): item is TFolder => item instanceof TFolder && item.name.toLowerCase() === FRONT_MATTER); + const backFolder = visibleChildren.find((item): item is TFolder => item instanceof TFolder && item.name.toLowerCase() === BACK_MATTER); + const partFolders = visibleChildren.filter((item): item is TFolder => item instanceof TFolder && item !== frontFolder && item !== backFolder); + const looseScenes = visibleChildren.filter((item): item is TFile => item instanceof TFile && this.isMarkdown(item)); + + if (looseScenes.length > 0) warnings.push(`${looseScenes.length} Markdown file(s) found directly in the book folder; included after parts.`); + if (!frontFolder) warnings.push("No Ebook Front Matter folder found."); + if (!backFolder) warnings.push("No Ebook Back Matter folder found."); + + return { + root, + frontMatter: frontFolder ? this.collectMarkdown(frontFolder) : [], + parts: partFolders.map((folder) => this.scanPart(folder, warnings)), + looseScenes, + backMatter: backFolder ? this.collectMarkdown(backFolder) : [], + warnings + }; + } + + private scanPart(folder: TFolder, warnings: string[]): ScannedPart { + const children = this.sortedVisibleChildren(folder); + const chapterFolders = children.filter((item): item is TFolder => item instanceof TFolder); + const looseScenes = children.filter((item): item is TFile => item instanceof TFile && this.isMarkdown(item)); + if (looseScenes.length > 0) warnings.push(`${looseScenes.length} Markdown file(s) found directly in “${folder.name}”; included before its chapters.`); + return { folder, chapters: chapterFolders.map((chapter) => this.scanChapter(chapter)), looseScenes }; + } + + private scanChapter(folder: TFolder): ScannedChapter { + return { folder, scenes: this.collectMarkdown(folder) }; + } + + private collectMarkdown(folder: TFolder): TFile[] { + const files: TFile[] = []; + for (const child of this.sortedVisibleChildren(folder)) { + if (child instanceof TFile && this.isMarkdown(child)) files.push(child); + if (child instanceof TFolder) files.push(...this.collectMarkdown(child)); + } + return files; + } + + private sortedVisibleChildren(folder: TFolder): TAbstractFile[] { + return folder.children + .filter((item) => !this.isHidden(item.path)) + .sort((a, b) => collator.compare(a.name, b.name)); + } + + private isHidden(path: string): boolean { + return path.split("/").some((segment) => segment.startsWith(".")); + } + + private isMarkdown(file: TFile): boolean { + return file.extension.toLowerCase() === "md" && !this.isHidden(file.path); + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..fd96e8b --- /dev/null +++ b/styles.css @@ -0,0 +1,8 @@ +.manuscript-compiler-report { + display: grid; + grid-template-columns: max-content 1fr; + gap: var(--size-4-2) var(--size-4-4); +} + +.manuscript-compiler-report dt { font-weight: var(--font-semibold); } +.manuscript-compiler-report dd { margin: 0; overflow-wrap: anywhere; } diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3473a67 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { "@/*": ["src/*"] }, + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES2018", + "allowJs": false, + "noImplicitAny": true, + "strictFunctionTypes": true, + "strictNullChecks": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "moduleResolution": "node", + "importHelpers": true, + "downlevelIteration": true, + "esModuleInterop": true, + "lib": ["DOM", "ES2018"] + }, + "include": ["src/**/*.ts"] +} diff --git a/versions.json b/versions.json new file mode 100644 index 0000000..708016d --- /dev/null +++ b/versions.json @@ -0,0 +1,3 @@ +{ + "0.1.0": "1.5.0" +}