How to bundle harper as a CLI with harper-js #2341
-
|
I am looking for a way to use harper as a CLI to automatically fix spelling. For example: $ fixmyspelling "This is a document with a typooo".
This is a document with a typoharper-cliI am aware of the harper-cli, but from what I can tell that is a liner and does not automatically apply fixed. harper.jsI think my best bet is harper-js. I have this script that works! But when I try to bundle it into a CLI with bun it fails. $ bun build ./src/index.ts --compile --outfile fixmyspelling
$ fixmyspelling --help
fixmyspelling: Automatically apply Harper's grammar suggestions to text.
Usage:
fixmyspelling "Your input text here"
echo "This is an test" | fixmyspelling
cat input.txt | fixmyspelling > output.txt
Options:
--dialect=american|british|canadian|australian Default: american
--language=markdown|plaintext Language mode (default: markdown)
--max-iterations=N Safety cap on applied suggestions (default 200)
-h, --help Show this help
-v, --version Show version number
Examples:
echo "This is an test of Harper" | fixmyspelling
fixmyspelling --dialect=british "Color is spelt wrong in this colour example."
cat input.txt | fixmyspelling > output.txt
$ fixmyspelling 'Helloo world'
Error: ENOENT: no such file or directory, open '/$bunfs/root/harper_wasm_bg.wasm'I think the issue is that I am not bundling the WASM correctly. Does anyone have tip? The bun docs say this about bundling WASM. But I can't put it all together. harper.js working script#!/usr/bin/env node
// Injected at build time via --define flag
declare const __VERSION__: string;
// Fallback for when running unbundled (during development/testing)
async function getVersion(): Promise<string> {
// Check if __VERSION__ is defined (it will be in bundled code)
if (typeof __VERSION__ !== "undefined") {
return __VERSION__;
}
// When running unbundled, read from package.json
const packageJsonPath = new URL("../package.json", import.meta.url);
const text = await Bun.file(packageJsonPath).text();
const packageJson = JSON.parse(text);
return packageJson.version;
}
interface Options {
dialect: "american" | "british" | "canadian" | "australian";
language: "markdown" | "plaintext";
maxIterations: number;
}
interface ParsedArgs {
options: Options;
positionalArgs: string[];
}
async function parseArgs(args: string[]): Promise<ParsedArgs> {
// Basic arg parsing (no external deps)
const options: Options = {
dialect: "american",
language: "markdown",
maxIterations: 200,
};
const positionalArgs: string[] = [];
for (const arg of args) {
if (arg.startsWith("--dialect=")) {
const dialect = arg.split("=")[1] as Options["dialect"];
options.dialect = dialect;
} else if (arg.startsWith("--language=")) {
const lang = arg.split("=")[1];
if (lang === "plaintext" || lang === "markdown") {
options.language = lang;
}
} else if (arg.startsWith("--max-iterations=")) {
const n = Number(arg.split("=")[1]);
if (!Number.isNaN(n) && n > 0) options.maxIterations = n;
} else if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
} else if (arg === "--version" || arg === "-v") {
await printVersion();
process.exit(0);
} else {
positionalArgs.push(arg);
}
}
return { options, positionalArgs };
}
async function main(): Promise<void> {
// Lazy import to avoid ESM/CJS friction if someone requires this script
const harper = await import("harper.js");
const { options: opts, positionalArgs: rest } = await parseArgs(
process.argv.slice(2),
);
// Resolve input text
const inputText = await readInput(opts, rest);
if (!inputText) {
printHelp("No input provided.");
process.exit(1);
}
// Map dialect
const dialectMap = {
american: harper.Dialect.American,
british: harper.Dialect.British,
canadian: harper.Dialect.Canadian,
australian: harper.Dialect.Australian,
};
const dialect =
dialectMap[opts.dialect.toLowerCase() as keyof typeof dialectMap] ??
harper.Dialect.American;
// Initialize linter with LocalLinter
const linter = new harper.LocalLinter({
binary: harper.binary,
dialect,
});
let text = inputText;
let iterations = 0;
// Iteratively lint and apply the first available suggestion.
// We re-lint after each application to keep spans correct.
while (iterations < opts.maxIterations) {
const lintOptions =
opts.language === "plaintext"
? { language: "plaintext" as const }
: undefined;
const lints = await linter.lint(text, lintOptions);
if (!lints || lints.length === 0) break;
let applied = false;
for (const lint of lints) {
const count = lint.suggestion_count();
if (count > 0) {
const suggestions = lint.suggestions();
const suggestion = suggestions[0]; // heuristic: first suggestion
text = await linter.applySuggestion(text, lint, suggestion);
applied = true;
break; // re-lint after each application
}
}
if (!applied) break;
iterations += 1;
}
// Output corrected text to stdout
process.stdout.write(text);
}
async function printVersion(): Promise<void> {
const version = await getVersion();
console.log(version);
}
function printHelp(errorMsg?: string): void {
const msg = `
fixmyspelling: Automatically apply Harper's grammar suggestions to text.
Usage:
fixmyspelling "Your input text here"
echo "This is an test" | fixmyspelling
cat input.txt | fixmyspelling > output.txt
Options:
--dialect=american|british|canadian|australian Default: american
--language=markdown|plaintext Language mode (default: markdown)
--max-iterations=N Safety cap on applied suggestions (default 200)
-h, --help Show this help
-v, --version Show version number
Examples:
echo "This is an test of Harper" | fixmyspelling
fixmyspelling --dialect=british "Color is spelt wrong in this colour example."
cat input.txt | fixmyspelling > output.txt
`.trim();
if (errorMsg) {
console.error(errorMsg);
console.error("");
}
console.error(msg);
}
async function readInput(opts: Options, rest: string[]): Promise<string> {
// 1) positional args -> join into a single string
if (rest.length > 0) {
return rest.join(" ");
}
// 2) stdin (if piped)
if (!process.stdin.isTTY) {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString("utf8");
}
return "";
}
main().catch((err: Error | unknown) => {
console.error("Error:", err instanceof Error ? err.message : err);
process.exit(1);
});package.json {
"name": "fixmyspelling",
"version": "0.1.0",
"description": "A CLI tool that uses harper.js to automatically fix grammar and spelling errors in text",
"type": "module",
"bin": {
"fixmyspelling": "./dist/index.js"
},
"scripts": {
"build": "bun build src/index.ts --outdir dist --target node --format esm --external harper.js --define __VERSION__=$(cat package.json | jq '.version')",
"test": "bun test",
"prepublishOnly": "bun run build",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"lint:fix": "prettier --check . && eslint --fix ."
},
"files": [
"dist",
"README.md",
"LICENSE"
],
"keywords": [
"grammar",
"spelling",
"linter",
"harper",
"cli",
"text",
"correction",
"proofreading"
],
"author": "",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/SamEdwardes/fixmyspelling.git"
},
"homepage": "https://github.com/SamEdwardes/fixmyspelling#readme",
"engines": {
"node": ">=18.0.0"
},
"devDependencies": {
"@eslint/js": "^9.38.0",
"@eslint/json": "^0.13.2",
"@types/bun": "latest",
"eslint": "^9.38.0",
"globals": "^16.4.0",
"jiti": "^2.6.1",
"prettier": "^3.6.2",
"typescript-eslint": "^8.46.1"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
"harper.js": "^0.67.0"
}
} |
Beta Was this translation helpful? Give feedback.
Replies: 1 comment 2 replies
-
|
I was able to get a working version. If anyone is interested here is the project: https://github.com/samedwardes/fixmyspelling #!/usr/bin/env node
// Injected at build time via --define flag
declare const __VERSION__: string;
// Fallback for when running unbundled (during development/testing)
async function getVersion(): Promise<string> {
// Check if __VERSION__ is defined (it will be in bundled code)
if (typeof __VERSION__ !== "undefined") {
return __VERSION__;
}
// When running unbundled, read from package.json
const packageJsonPath = new URL("../package.json", import.meta.url);
const text = await Bun.file(packageJsonPath).text();
const packageJson = JSON.parse(text);
return packageJson.version;
}
interface Options {
dialect: "american" | "british" | "canadian" | "australian";
language: "markdown" | "plaintext";
maxIterations: number;
}
interface ParsedArgs {
options: Options;
positionalArgs: string[];
}
async function parseArgs(args: string[]): Promise<ParsedArgs> {
// Basic arg parsing (no external deps)
const options: Options = {
dialect: "american",
language: "markdown",
maxIterations: 200,
};
const positionalArgs: string[] = [];
for (const arg of args) {
if (arg.startsWith("--dialect=")) {
const dialect = arg.split("=")[1] as Options["dialect"];
options.dialect = dialect;
} else if (arg.startsWith("--language=")) {
const lang = arg.split("=")[1];
if (lang === "plaintext" || lang === "markdown") {
options.language = lang;
}
} else if (arg.startsWith("--max-iterations=")) {
const n = Number(arg.split("=")[1]);
if (!Number.isNaN(n) && n > 0) options.maxIterations = n;
} else if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
} else if (arg === "--version" || arg === "-v") {
await printVersion();
process.exit(0);
} else {
positionalArgs.push(arg);
}
}
return { options, positionalArgs };
}
// Import the WASM file to embed it in the compiled binary
import wasmPath from "../node_modules/harper.js/dist/harper_wasm_bg.wasm" with { type: "file" };
async function main(): Promise<void> {
// Reference the WASM to ensure it's bundled (prevents tree-shaking)
void wasmPath;
// Lazy import to avoid ESM/CJS friction if someone requires this script
const harper = await import("harper.js");
const { options: opts, positionalArgs: rest } = await parseArgs(
process.argv.slice(2),
);
// Resolve input text
const inputText = await readInput(opts, rest);
if (!inputText) {
printHelp("No input provided.");
process.exit(1);
}
// Map dialect
const dialectMap = {
american: harper.Dialect.American,
british: harper.Dialect.British,
canadian: harper.Dialect.Canadian,
australian: harper.Dialect.Australian,
};
const dialect =
dialectMap[opts.dialect.toLowerCase() as keyof typeof dialectMap] ??
harper.Dialect.American;
// Initialize linter with LocalLinter
const linter = new harper.LocalLinter({
binary: harper.binary,
dialect,
});
let text = inputText;
let iterations = 0;
// Iteratively lint and apply the first available suggestion.
// We re-lint after each application to keep spans correct.
while (iterations < opts.maxIterations) {
const lintOptions =
opts.language === "plaintext"
? { language: "plaintext" as const }
: undefined;
const lints = await linter.lint(text, lintOptions);
if (!lints || lints.length === 0) break;
let applied = false;
for (const lint of lints) {
const count = lint.suggestion_count();
if (count > 0) {
const suggestions = lint.suggestions();
const suggestion = suggestions[0]; // heuristic: first suggestion
text = await linter.applySuggestion(text, lint, suggestion);
applied = true;
break; // re-lint after each application
}
}
if (!applied) break;
iterations += 1;
}
// Output corrected text to stdout
process.stdout.write(text)
}
async function printVersion(): Promise<void> {
const version = await getVersion();
console.log(version);
}
function printHelp(errorMsg?: string): void {
const msg = `
fixmyspelling: Automatically apply Harper's grammar suggestions to text.
Usage:
fixmyspelling "Your input text here"
echo "This is an test" | fixmyspelling
cat input.txt | fixmyspelling > output.txt
Options:
--dialect=american|british|canadian|australian Default: american
--language=markdown|plaintext Language mode (default: markdown)
--max-iterations=N Safety cap on applied suggestions (default 200)
-h, --help Show this help
-v, --version Show version number
Examples:
echo "This is an test of Harper" | fixmyspelling
fixmyspelling --dialect=british "Color is spelt wrong in this colour example."
cat input.txt | fixmyspelling > output.txt
`.trim();
if (errorMsg) {
console.error(errorMsg);
console.error("");
}
console.error(msg);
}
async function readInput(opts: Options, rest: string[]): Promise<string> {
// 1) positional args -> join into a single string
if (rest.length > 0) {
return rest.join(" ");
}
// 2) stdin (if piped)
if (!process.stdin.isTTY) {
const chunks: Buffer[] = [];
for await (const chunk of process.stdin) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString("utf8");
}
return "";
}
main().catch((err: Error | unknown) => {
console.error("Error:", err instanceof Error ? err.message : err);
process.exit(1);
}); |
Beta Was this translation helpful? Give feedback.
I was able to get a working version. If anyone is interested here is the project: https://github.com/samedwardes/fixmyspelling