mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
Splits HWP handling into three focused skills per issue #155: - hwp (kept): kordoc-based read/convert (Markdown, JSON, diffing, form fields, Markdown->HWPX). Description narrowed to 'read-only' to make the routing policy explicit. - rhwp-edit (new): HWP binary editing via new k-skill-rhwp npm package that wraps the @rhwp/core WASM bindings as CLI subcommands: info, list-paragraphs, search, insert-text, delete-text, replace-all, create-table, set-cell-text, create-blank, and render. - rhwp-advanced (new): guidance for the upstream Rust rhwp CLI (export-svg --debug-overlay, dump, dump-pages, ir-diff, thumbnail, convert) for layout debugging, IR inspection, version comparison, and read-only-document unlocking. The new k-skill-rhwp package under packages/ ships a Node.js 18+ CLI and library that round-trips HWP 5.x documents entirely in-process; no Rust toolchain is required. It auto-installs the WASM-required globalThis.measureTextWidth shim for headless Node, and all editing subcommands always write to a distinct output path so the source file is never mutated. HWPX save remains disabled per the upstream rhwp #196 data-safety gate; HWPX input is accepted but output is written as HWP 5.x. Includes 24 node:test cases covering init, round-trip insertText, replaceAll, createTable + setCellText, deleteText, searchText, listParagraphs, renderPage (SVG/HTML), and full CLI arg-parse + end-to-end round-trip through the CLI layer. Wires README feature table (3 rows for hwp / rhwp-edit / rhwp-advanced), docs/install.md optional-install list, docs/roadmap.md (marks HWP advanced editing as shipped while keeping Windows/security-module automation out of scope), docs/sources.md (adds rhwp upstream, CLI source, @rhwp/core, @rhwp/editor, and rhwp #196 references), and the root pack:dry-run script. Adds a Changesets entry for k-skill-rhwp minor. Closes #155.
159 lines
4.9 KiB
JavaScript
159 lines
4.9 KiB
JavaScript
"use strict";
|
|
|
|
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
const path = require("node:path");
|
|
const { Writable } = require("node:stream");
|
|
|
|
const { parseArgs, main, USAGE } = require("../src/cli");
|
|
|
|
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "k-skill-rhwp-cli-"));
|
|
|
|
test.after(() => {
|
|
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
|
});
|
|
|
|
function collectStream() {
|
|
const chunks = [];
|
|
const stream = new Writable({
|
|
write(chunk, _enc, cb) {
|
|
chunks.push(chunk);
|
|
cb();
|
|
}
|
|
});
|
|
Object.defineProperty(stream, "buffer", {
|
|
get: () => Buffer.concat(chunks).toString("utf8")
|
|
});
|
|
return stream;
|
|
}
|
|
|
|
test("parseArgs handles positional args, --flag value, --flag=value, and boolean flags", () => {
|
|
const a = parseArgs([
|
|
"insert-text",
|
|
"in.hwp",
|
|
"out.hwp",
|
|
"--section",
|
|
"0",
|
|
"--paragraph=1",
|
|
"--text",
|
|
"hi",
|
|
"--case-sensitive"
|
|
]);
|
|
assert.deepEqual(a._, ["insert-text", "in.hwp", "out.hwp"]);
|
|
assert.equal(a.flags.section, "0");
|
|
assert.equal(a.flags.paragraph, "1");
|
|
assert.equal(a.flags.text, "hi");
|
|
assert.equal(a.flags["case-sensitive"], true);
|
|
});
|
|
|
|
test("parseArgs supports -- to pass remaining tokens positionally", () => {
|
|
const a = parseArgs(["cmd", "--", "--weird-text", "with --dashes"]);
|
|
assert.deepEqual(a._, ["cmd", "--weird-text", "with --dashes"]);
|
|
});
|
|
|
|
test("main prints usage with --help", async () => {
|
|
const stdout = collectStream();
|
|
const stderr = collectStream();
|
|
const code = await main(["--help"], { stdout, stderr });
|
|
assert.equal(code, 0);
|
|
assert.ok(stdout.buffer.includes("k-skill-rhwp"));
|
|
assert.ok(stdout.buffer.includes("info <input>"));
|
|
assert.equal(stderr.buffer, "");
|
|
assert.ok(USAGE.length > 100);
|
|
});
|
|
|
|
test("main prints version with -v", async () => {
|
|
const stdout = collectStream();
|
|
const stderr = collectStream();
|
|
const code = await main(["-v"], { stdout, stderr });
|
|
assert.equal(code, 0);
|
|
assert.match(stdout.buffer, /k-skill-rhwp \d+\.\d+\.\d+/);
|
|
});
|
|
|
|
test("main returns exit code 1 for unknown command with a helpful message", async () => {
|
|
const stdout = collectStream();
|
|
const stderr = collectStream();
|
|
const code = await main(["totally-fake-command"], { stdout, stderr });
|
|
assert.equal(code, 1);
|
|
assert.match(stderr.buffer, /unknown command: totally-fake-command/);
|
|
});
|
|
|
|
test("main info on a generated blank document prints valid JSON with sectionCount >= 1", async () => {
|
|
const blank = path.join(tmpRoot, "cli-blank.hwp");
|
|
const createOut = collectStream();
|
|
const createErr = collectStream();
|
|
const createCode = await main(["create-blank", blank], {
|
|
stdout: createOut,
|
|
stderr: createErr
|
|
});
|
|
assert.equal(createCode, 0, `create-blank failed: ${createErr.buffer}`);
|
|
assert.ok(fs.existsSync(blank));
|
|
|
|
const infoOut = collectStream();
|
|
const infoErr = collectStream();
|
|
const infoCode = await main(["info", blank], { stdout: infoOut, stderr: infoErr });
|
|
assert.equal(infoCode, 0, `info failed: ${infoErr.buffer}`);
|
|
const parsed = JSON.parse(infoOut.buffer);
|
|
assert.equal(parsed.sourceFormat, "hwp");
|
|
assert.equal(parsed.sectionCount, 1);
|
|
assert.ok(Array.isArray(parsed.sections));
|
|
});
|
|
|
|
test("main insert-text + info end-to-end round-trip through the CLI layer", async () => {
|
|
const blank = path.join(tmpRoot, "cli-rt-blank.hwp");
|
|
const edited = path.join(tmpRoot, "cli-rt-edited.hwp");
|
|
const nul = collectStream();
|
|
await main(["create-blank", blank], { stdout: nul, stderr: collectStream() });
|
|
|
|
const insertOut = collectStream();
|
|
const insertErr = collectStream();
|
|
const insertCode = await main(
|
|
[
|
|
"insert-text",
|
|
blank,
|
|
edited,
|
|
"--section",
|
|
"0",
|
|
"--paragraph",
|
|
"0",
|
|
"--offset",
|
|
"0",
|
|
"--text",
|
|
"안녕 CLI"
|
|
],
|
|
{ stdout: insertOut, stderr: insertErr }
|
|
);
|
|
assert.equal(insertCode, 0, `insert-text failed: ${insertErr.buffer}`);
|
|
const insertResult = JSON.parse(insertOut.buffer);
|
|
assert.equal(insertResult.ok, true);
|
|
|
|
const infoOut = collectStream();
|
|
await main(["info", edited], { stdout: infoOut, stderr: collectStream() });
|
|
const infoResult = JSON.parse(infoOut.buffer);
|
|
assert.equal(infoResult.sections[0].paragraphs[0].length, "안녕 CLI".length);
|
|
});
|
|
|
|
test("main reports missing required --section flag to stderr and exits 1", async () => {
|
|
const blank = path.join(tmpRoot, "cli-missing-flag.hwp");
|
|
await main(["create-blank", blank], { stdout: collectStream(), stderr: collectStream() });
|
|
const stdout = collectStream();
|
|
const stderr = collectStream();
|
|
const code = await main(
|
|
[
|
|
"insert-text",
|
|
blank,
|
|
path.join(tmpRoot, "cli-missing-out.hwp"),
|
|
"--paragraph",
|
|
"0",
|
|
"--offset",
|
|
"0",
|
|
"--text",
|
|
"hi"
|
|
],
|
|
{ stdout, stderr }
|
|
);
|
|
assert.equal(code, 1);
|
|
assert.match(stderr.buffer, /missing required --section/);
|
|
});
|