@drbaher/draft-cli 0.1.1 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +106 -0
- package/PARAM_SCHEMA.md +47 -1
- package/draft-cli.mjs +411 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,112 @@ All notable changes to this project will be documented in this file. The
|
|
|
4
4
|
format is loosely based on [Keep a Changelog](https://keepachangelog.com/),
|
|
5
5
|
and the project adheres to semantic versioning once it leaves 0.x.
|
|
6
6
|
|
|
7
|
+
## 0.3.2 — 2026-05-16
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Publish auth: restored `NODE_AUTH_TOKEN` env block.** v0.3.1's
|
|
12
|
+
hotfix bumped `publish.yml` Node from 20 to 22 on the hypothesis
|
|
13
|
+
that npm CLI 11.5.1+ would auto-detect OIDC and ignore the
|
|
14
|
+
setup-node placeholder. It didn't: Node 22's npm 11.x still sent
|
|
15
|
+
the literal `XXXXX-XXXXX-XXXXX-XXXXX` placeholder and got the
|
|
16
|
+
same 404 from npm. Root cause is *not* Node version; either
|
|
17
|
+
`setup-node@v6` always writes the placeholder env into the
|
|
18
|
+
publish step, or npm CLI prefers the `.npmrc` token over OIDC
|
|
19
|
+
even when both are available. Under investigation.
|
|
20
|
+
- **v0.3.1 tag exists on GitHub but did NOT publish to npm.** Skip
|
|
21
|
+
it. Registry latest is `0.2.0` until v0.3.2 ships.
|
|
22
|
+
|
|
23
|
+
Pragmatic call: v0.3.2 ships via the bootstrap `NPM_TOKEN` path.
|
|
24
|
+
Trusted Publisher stays configured on npm so the switch back to
|
|
25
|
+
pure OIDC is a one-line change in `publish.yml` once we understand
|
|
26
|
+
why npm CLI isn't using OIDC. `feedback_oidc_setup_node_v6_placeholder.md`
|
|
27
|
+
in memory tracks the symptom + workarounds tried.
|
|
28
|
+
|
|
29
|
+
## 0.3.1 — 2026-05-16
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- **`publish.yml` now uses Node 22 instead of Node 20.** npm
|
|
34
|
+
Trusted Publishing requires npm CLI 11.5.1 or later, which ships
|
|
35
|
+
with Node 22.14+. Node 20 (npm 10.x) silently falls back to
|
|
36
|
+
`NODE_AUTH_TOKEN` when configured for a registry, and to
|
|
37
|
+
`setup-node@v6`'s placeholder value (`XXXXX-...`) when the env
|
|
38
|
+
isn't set — producing a 404 from npm masking the actual 401.
|
|
39
|
+
- **v0.3.0 tag exists on GitHub but did NOT publish to npm.** v0.3.0
|
|
40
|
+
was the first publish attempt without an `NPM_TOKEN` fallback
|
|
41
|
+
(PR #10 reverted the bootstrap); the npm-CLI-too-old issue
|
|
42
|
+
surfaced immediately. No package was uploaded. v0.3.1 is the
|
|
43
|
+
rebrand of v0.3.0's typed-parameter feature with the workflow
|
|
44
|
+
fix applied. Skip v0.3.0.
|
|
45
|
+
|
|
46
|
+
## 0.3.0 — 2026-05-16
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **Typed parameters (`type: date | money | party`).** Long-form
|
|
51
|
+
schema entries can declare a `type`, with optional `format` (date)
|
|
52
|
+
or `currency` (money). Inputs are validated and normalized between
|
|
53
|
+
value resolution and substitution; bad inputs hard-error with a
|
|
54
|
+
per-key message (exit 4). See `PARAM_SCHEMA.md` §5 for the accepted
|
|
55
|
+
shapes per type, the rejected ambiguous forms (Q3.1: US
|
|
56
|
+
`MM/DD/YYYY` and European `DD/MM/YYYY` are rejected as ambiguous),
|
|
57
|
+
and the v2 currency scope (Q3.2: USD only).
|
|
58
|
+
- **`--validate` now catches type errors** before draft runs. With
|
|
59
|
+
`--json`, errors are emitted as a `type_errors` array on the
|
|
60
|
+
result payload.
|
|
61
|
+
- **New public API:** `parseDateValue(raw)`, `formatDateValue(date, fmt)`,
|
|
62
|
+
`parseMoneyValue(raw)` → minor units, `formatMoneyValue(minor, currency)`,
|
|
63
|
+
`normalizeTypedValue(raw, placeholder)`, `normalizeTypedValues(placeholders, resolved)`.
|
|
64
|
+
|
|
65
|
+
### Schema-contract change
|
|
66
|
+
|
|
67
|
+
`PARAM_SCHEMA.md` §5 gains a "Typed parameters" section. Long-form
|
|
68
|
+
entries can now include `type`, `format`, and `currency` fields; short
|
|
69
|
+
form is unchanged. v0.3.0 schemas are forward-compatible with v0.2.x
|
|
70
|
+
readers (which will silently ignore the new fields, since they're
|
|
71
|
+
opt-in metadata on the long-form entry).
|
|
72
|
+
|
|
73
|
+
## 0.2.0 — 2026-05-16
|
|
74
|
+
|
|
75
|
+
### Added
|
|
76
|
+
|
|
77
|
+
- **`.docx` output round-trip.** Templates read from `.docx` (tier 3
|
|
78
|
+
highlight detection) now write back as `.docx`, preserving runs,
|
|
79
|
+
styles, paragraph breaks, and every non-document part of the package
|
|
80
|
+
(`[Content_Types].xml`, relationships, images, headers, etc.).
|
|
81
|
+
Default output filename is `<basename>-filled.docx` next to the
|
|
82
|
+
input; override with `--output PATH.docx`. Schema-rescue, T1/T2
|
|
83
|
+
bracket/mustache detection, and T4/T5 substitution all benefit
|
|
84
|
+
too — any tier that detects a placeholder in a `.docx` template
|
|
85
|
+
now substitutes back into the same runs.
|
|
86
|
+
- **`--output -` writes plain text to stdout** (Unix `-` convention).
|
|
87
|
+
Use this on a `.docx` input to get the substituted body as text
|
|
88
|
+
instead of a `.docx` file: `draft contract.docx --output -`.
|
|
89
|
+
- **`writeDocxBuffer(originalPath, newDocumentXml)`**, **`makeDocxOutputPath(inputPath)`**,
|
|
90
|
+
**`substituteDocxXml(xml, placeholders, values, tier)`**, **`decideDocxOutput(opts, input)`**,
|
|
91
|
+
and **`encodeXml(s)`** added to the public API for programmatic
|
|
92
|
+
drivers. Same import surface as `substitute` and `extractDocxText`.
|
|
93
|
+
|
|
94
|
+
### Changed
|
|
95
|
+
|
|
96
|
+
- **Default output for `.docx` input is now `<basename>-filled.docx`,
|
|
97
|
+
not stdout text.** Previously, `draft contract.docx` (no
|
|
98
|
+
`--output`) extracted text and wrote substituted plain text to
|
|
99
|
+
stdout. v0.2.0 writes `contract-filled.docx` next to the input.
|
|
100
|
+
Pipelines that depended on the stdout-text behavior should pass
|
|
101
|
+
`--output -` to opt back in.
|
|
102
|
+
|
|
103
|
+
### Split-run handling
|
|
104
|
+
|
|
105
|
+
When a placeholder's text spans multiple `<w:t>` runs in the source
|
|
106
|
+
`.docx` (Word sometimes splits runs at punctuation, auto-correct
|
|
107
|
+
boundaries, or comment anchors), v0.2.0 emits a warning and skips
|
|
108
|
+
that substitution rather than merging the runs and losing run-level
|
|
109
|
+
styling. The warning explains how to fix the source: open the
|
|
110
|
+
document, retype the placeholder so it lives in one run, save, and
|
|
111
|
+
retry. This decision is logged in `PARAM_SCHEMA.md` §2.
|
|
112
|
+
|
|
7
113
|
## 0.1.1 — 2026-05-16
|
|
8
114
|
|
|
9
115
|
### Fixed
|
package/PARAM_SCHEMA.md
CHANGED
|
@@ -22,7 +22,20 @@ draft <cat>/<name>[@ver] ... # pulls via `template-vault get`
|
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
- **Input forms accepted:** `path/to/file.md`, `path/to/file.txt`, `path/to/file.docx`, stdin (`-`), or a `template-vault` ref shaped `<category>/<name>[@version]`. Vault refs shell out to `template-vault get` — no library import.
|
|
25
|
-
- **Output:**
|
|
25
|
+
- **Output:** depends on input kind and `--output` target.
|
|
26
|
+
|
|
27
|
+
| Input | `--output` | Output |
|
|
28
|
+
| ------------ | ------------------- | ------------------------------------- |
|
|
29
|
+
| text (any) | absent | plain text on stdout |
|
|
30
|
+
| text (any) | `-` | plain text on stdout |
|
|
31
|
+
| text (any) | `PATH` (any ext) | plain text written to `PATH` |
|
|
32
|
+
| `.docx` | absent | `.docx` to `<basename>-filled.docx` |
|
|
33
|
+
| `.docx` | `PATH.docx` | `.docx` to `PATH.docx` |
|
|
34
|
+
| `.docx` | `-` | plain text (substituted body) on stdout |
|
|
35
|
+
| `.docx` | `PATH` (non-`.docx`)| plain text written to `PATH` |
|
|
36
|
+
|
|
37
|
+
`.docx` output is a round-trip: the original `.docx` package is reopened, the substituted text is written back into the same `<w:t>` runs that detection found, and all other parts of the package (relationships, images, headers, `[Content_Types].xml`, etc.) pass through unchanged. Run-level styling is preserved. If a placeholder's text spans multiple `<w:t>` runs in the source (Word sometimes splits runs at punctuation or auto-correct boundaries), that placeholder is **skipped**, not substituted, and a warning is emitted explaining how to fix the source — locked decision Q1.1.
|
|
38
|
+
- `--json`, `--diff`, `--validate`, and `--list-placeholders` all override the `.docx` round-trip and produce text/JSON to stdout (or to `--output PATH`, when provided).
|
|
26
39
|
- **Encoding:** UTF-8 in, UTF-8 out. No BOM written; BOM tolerated on read.
|
|
27
40
|
|
|
28
41
|
## 3. Detection cascade (sequential-with-stop)
|
|
@@ -224,6 +237,39 @@ its own alias list (Q3 locked) — list it explicitly if needed.
|
|
|
224
237
|
Parser selects long form iff a top-level `_meta` key is present. Short
|
|
225
238
|
and long are not mixable within one file.
|
|
226
239
|
|
|
240
|
+
### Typed parameters (v0.3.0, opt-in)
|
|
241
|
+
|
|
242
|
+
Long-form entries can declare `type`, with optional `format` (`date`)
|
|
243
|
+
or `currency` (`money`). Inputs are validated and normalized between
|
|
244
|
+
value resolution and substitution. Bad input → exit 4
|
|
245
|
+
(`EXIT.VALIDATION`) with a per-key error message; all type errors are
|
|
246
|
+
collected before exit so the user sees every issue at once.
|
|
247
|
+
|
|
248
|
+
```json
|
|
249
|
+
{
|
|
250
|
+
"_meta": { "schema_version": 1 },
|
|
251
|
+
"effective_date": { "aliases": ["Effective Date"], "type": "date", "format": "MMMM d, yyyy" },
|
|
252
|
+
"purchase_amount": { "aliases": ["Purchase Amount"], "type": "money", "currency": "USD" },
|
|
253
|
+
"party_a": { "aliases": ["Party A"], "type": "party" }
|
|
254
|
+
}
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
| `type` | Accepts | Normalizes to | Notes |
|
|
258
|
+
| -------- | -------------------------------------------------------------------- | -------------------------------------- | ----- |
|
|
259
|
+
| `date` | ISO (`2027-01-15`) or spelled (`January 15, 2027`, `Jan 15 2027`) | `format` field (default `MMMM d, yyyy`) | Q3.1 locked: US (`MM/DD/YYYY`) and European (`DD/MM/YYYY`) numeric forms are **rejected** as ambiguous. |
|
|
260
|
+
| `money` | `$5,000`, `5000.50`, `$5M`, `2.5K`, `1B`; rejects `$$5`, `5,00`, etc. | `currency`-formatted (e.g. `$5,000,000.00`) | Q3.2 locked: v0.3.0 supports `currency: "USD"` only. |
|
|
261
|
+
| `party` | Non-empty after trim; no markdown links `[text](url)`; no trailing punctuation `.,;:!?` | Trimmed string | Q3.3 locked: hard error on bad input — typed params are opt-in. |
|
|
262
|
+
|
|
263
|
+
`format` for `date` supports `yyyy`, `MMMM`, `MM`, `d` tokens (matched
|
|
264
|
+
in a single pass — `MM` doesn't accidentally consume `MMMM`, `d`
|
|
265
|
+
doesn't leak into month names). Other literal characters pass through
|
|
266
|
+
unchanged. Other tokens (e.g. `HH:mm`, `dd`, `EEEE`) are deferred to
|
|
267
|
+
future versions.
|
|
268
|
+
|
|
269
|
+
Programmatic API for drivers: `parseDateValue`, `formatDateValue`,
|
|
270
|
+
`parseMoneyValue`, `formatMoneyValue`, `normalizeTypedValue`,
|
|
271
|
+
`normalizeTypedValues`.
|
|
272
|
+
|
|
227
273
|
### Orphan handling (Q4 locked)
|
|
228
274
|
|
|
229
275
|
Schema declares a key whose alias list matches no detected phrase →
|
package/draft-cli.mjs
CHANGED
|
@@ -70,7 +70,7 @@ import { fileURLToPath } from "node:url";
|
|
|
70
70
|
*/
|
|
71
71
|
|
|
72
72
|
/** @type {string} */
|
|
73
|
-
export const VERSION = "0.
|
|
73
|
+
export const VERSION = "0.3.2";
|
|
74
74
|
|
|
75
75
|
// ─── EXIT CODES ─────────────────────────────────────────────────────────────
|
|
76
76
|
/**
|
|
@@ -489,6 +489,43 @@ export async function extractDocxText(path) {
|
|
|
489
489
|
return { body: docxXmlToText(xml), xml };
|
|
490
490
|
}
|
|
491
491
|
|
|
492
|
+
/**
|
|
493
|
+
* Re-read the original `.docx`, swap in a new `word/document.xml`, and
|
|
494
|
+
* return the resulting `.docx` as a `Buffer`. All other parts of the
|
|
495
|
+
* package (`[Content_Types].xml`, relationships, images, headers, etc.)
|
|
496
|
+
* pass through unchanged.
|
|
497
|
+
*
|
|
498
|
+
* @param {string} originalPath — filesystem path to the source `.docx`.
|
|
499
|
+
* @param {string} newDocumentXml — replacement content for `word/document.xml`.
|
|
500
|
+
* @returns {Promise<Buffer>}
|
|
501
|
+
* @throws {Error} with `.exitCode = EXIT.IO` on missing jszip or invalid source.
|
|
502
|
+
*/
|
|
503
|
+
export async function writeDocxBuffer(originalPath, newDocumentXml) {
|
|
504
|
+
const JSZip = await loadJSZip();
|
|
505
|
+
let zip;
|
|
506
|
+
try { zip = await JSZip.loadAsync(readFileSync(originalPath)); }
|
|
507
|
+
catch (err) {
|
|
508
|
+
const e = new Error(`could not re-open source .docx (${err.message})`);
|
|
509
|
+
e.exitCode = EXIT.IO;
|
|
510
|
+
throw e;
|
|
511
|
+
}
|
|
512
|
+
zip.file("word/document.xml", newDocumentXml);
|
|
513
|
+
return await zip.generateAsync({ type: "nodebuffer" });
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Derive the default `.docx` output filename from an input path. Appends
|
|
518
|
+
* `-filled` before the extension: `contract.docx` → `contract-filled.docx`.
|
|
519
|
+
* If the input has no extension, appends `-filled.docx`.
|
|
520
|
+
* @param {string} inputPath
|
|
521
|
+
* @returns {string}
|
|
522
|
+
*/
|
|
523
|
+
export function makeDocxOutputPath(inputPath) {
|
|
524
|
+
const ext = extname(inputPath);
|
|
525
|
+
if (!ext) return `${inputPath}-filled.docx`;
|
|
526
|
+
return `${inputPath.slice(0, -ext.length)}-filled${ext}`;
|
|
527
|
+
}
|
|
528
|
+
|
|
492
529
|
// Walk the XML in document order. For each <w:p> emit a line; concatenate
|
|
493
530
|
// <w:t> contents within. Decode XML entities. Used for both output body and
|
|
494
531
|
// T1/T2 detection on docx input.
|
|
@@ -524,6 +561,18 @@ export function decodeXml(s) {
|
|
|
524
561
|
.replace(/"/g, '"').replace(/'/g, "'");
|
|
525
562
|
}
|
|
526
563
|
|
|
564
|
+
/**
|
|
565
|
+
* Inverse of {@link decodeXml}. Used when writing substituted text back into
|
|
566
|
+
* a Word document's `<w:t>` runs. Only encodes the three structural
|
|
567
|
+
* characters; double- and single-quotes don't need encoding inside element
|
|
568
|
+
* text content.
|
|
569
|
+
* @param {string} s
|
|
570
|
+
* @returns {string}
|
|
571
|
+
*/
|
|
572
|
+
export function encodeXml(s) {
|
|
573
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
574
|
+
}
|
|
575
|
+
|
|
527
576
|
const RECOGNIZED_HIGHLIGHTS = new Set(["yellow", "green", "cyan", "magenta"]);
|
|
528
577
|
|
|
529
578
|
// Scan the XML for highlighted runs. Returns an array of { text, color }.
|
|
@@ -869,6 +918,12 @@ export function parseSchema(parsed, sourceLabel = "<schema>") {
|
|
|
869
918
|
aliases: v.aliases.slice(),
|
|
870
919
|
required: v.required !== false,
|
|
871
920
|
default: Object.prototype.hasOwnProperty.call(v, "default") ? v.default : null,
|
|
921
|
+
// v2 #3: typed parameters. `type` is one of `date|money|party` (or
|
|
922
|
+
// absent → no validation/normalization). `format` (date) and
|
|
923
|
+
// `currency` (money) are optional.
|
|
924
|
+
type: typeof v.type === "string" ? v.type : null,
|
|
925
|
+
format: typeof v.format === "string" ? v.format : null,
|
|
926
|
+
currency: typeof v.currency === "string" ? v.currency : null,
|
|
872
927
|
};
|
|
873
928
|
} else {
|
|
874
929
|
if (!Array.isArray(v)) {
|
|
@@ -876,7 +931,7 @@ export function parseSchema(parsed, sourceLabel = "<schema>") {
|
|
|
876
931
|
e.exitCode = EXIT.IO;
|
|
877
932
|
throw e;
|
|
878
933
|
}
|
|
879
|
-
entries[k] = { aliases: v.slice(), required: true, default: null };
|
|
934
|
+
entries[k] = { aliases: v.slice(), required: true, default: null, type: null, format: null, currency: null };
|
|
880
935
|
}
|
|
881
936
|
}
|
|
882
937
|
return { form: long ? "long" : "short", entries };
|
|
@@ -1017,6 +1072,9 @@ function assemble(tier, hits, schema, warnings, fromLlm = false) {
|
|
|
1017
1072
|
required: resolved.required,
|
|
1018
1073
|
default: resolved.default,
|
|
1019
1074
|
aliases: resolved.aliases,
|
|
1075
|
+
type: resolved.type,
|
|
1076
|
+
format: resolved.format,
|
|
1077
|
+
currency: resolved.currency,
|
|
1020
1078
|
hits: [],
|
|
1021
1079
|
});
|
|
1022
1080
|
}
|
|
@@ -1031,14 +1089,22 @@ function resolveKey(hit, schema, fromLlm) {
|
|
|
1031
1089
|
if (schema) {
|
|
1032
1090
|
for (const [key, entry] of Object.entries(schema.entries)) {
|
|
1033
1091
|
if (entry.aliases.includes(hit.inner)) {
|
|
1034
|
-
return {
|
|
1092
|
+
return {
|
|
1093
|
+
key,
|
|
1094
|
+
required: entry.required,
|
|
1095
|
+
default: entry.default,
|
|
1096
|
+
aliases: entry.aliases,
|
|
1097
|
+
type: entry.type || null,
|
|
1098
|
+
format: entry.format || null,
|
|
1099
|
+
currency: entry.currency || null,
|
|
1100
|
+
};
|
|
1035
1101
|
}
|
|
1036
1102
|
}
|
|
1037
1103
|
return null;
|
|
1038
1104
|
}
|
|
1039
1105
|
const key = fromLlm && hit.suggested_key ? hit.suggested_key : canonicalKey(hit.inner);
|
|
1040
1106
|
if (!validKey(key)) return null;
|
|
1041
|
-
return { key, required: true, default: null, aliases: [hit.inner] };
|
|
1107
|
+
return { key, required: true, default: null, aliases: [hit.inner], type: null, format: null, currency: null };
|
|
1042
1108
|
}
|
|
1043
1109
|
|
|
1044
1110
|
// ─── VALUE RESOLUTION (CLI > JSON > prompt > default) ───────────────────────
|
|
@@ -1116,6 +1182,218 @@ export async function resolveValues(placeholders, opts, paramsObj, { prompter =
|
|
|
1116
1182
|
return { resolved, missing, sources };
|
|
1117
1183
|
}
|
|
1118
1184
|
|
|
1185
|
+
// ─── TYPED-PARAMETER NORMALIZATION (v2 #3) ──────────────────────────────────
|
|
1186
|
+
// Schema entries can declare `type: date | money | party` with optional
|
|
1187
|
+
// `format` (date) or `currency` (money). Inputs are validated and normalized
|
|
1188
|
+
// after value resolution and before substitution. Hard error (exit 4) on
|
|
1189
|
+
// invalid input — typed params are opt-in; the user asked for validation.
|
|
1190
|
+
|
|
1191
|
+
const MONTH_NAMES = ["January", "February", "March", "April", "May", "June",
|
|
1192
|
+
"July", "August", "September", "October", "November", "December"];
|
|
1193
|
+
const MONTH_INDEX = (() => {
|
|
1194
|
+
const m = {};
|
|
1195
|
+
MONTH_NAMES.forEach((name, i) => {
|
|
1196
|
+
m[name.toLowerCase()] = i;
|
|
1197
|
+
m[name.slice(0, 3).toLowerCase()] = i;
|
|
1198
|
+
});
|
|
1199
|
+
// "Sept" is a common 4-letter abbrev.
|
|
1200
|
+
m.sept = 8;
|
|
1201
|
+
return m;
|
|
1202
|
+
})();
|
|
1203
|
+
|
|
1204
|
+
/**
|
|
1205
|
+
* Parse a date input. Accepts ISO `YYYY-MM-DD` or spelled
|
|
1206
|
+
* `Month D, YYYY` / `Mon D YYYY`. Returns a UTC `Date` on success or `null`
|
|
1207
|
+
* on failure. Q3.1: US (`MM/DD/YYYY`) and European (`DD/MM/YYYY`) numeric
|
|
1208
|
+
* formats are NOT accepted — they're ambiguous and footgun-y. Use ISO for
|
|
1209
|
+
* machine input, spelled for human input.
|
|
1210
|
+
*
|
|
1211
|
+
* @param {string} raw
|
|
1212
|
+
* @returns {Date | null}
|
|
1213
|
+
*/
|
|
1214
|
+
export function parseDateValue(raw) {
|
|
1215
|
+
const s = String(raw).trim();
|
|
1216
|
+
const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
|
|
1217
|
+
if (iso) {
|
|
1218
|
+
const [, y, m, d] = iso;
|
|
1219
|
+
const date = new Date(Date.UTC(+y, +m - 1, +d));
|
|
1220
|
+
// Reject impossible dates (e.g. 2026-02-31 round-trips to 2026-03-03).
|
|
1221
|
+
if (date.getUTCFullYear() !== +y || date.getUTCMonth() !== +m - 1 || date.getUTCDate() !== +d) return null;
|
|
1222
|
+
return date;
|
|
1223
|
+
}
|
|
1224
|
+
const spelled = /^([A-Za-z]+)\s+(\d{1,2}),?\s+(\d{4})$/.exec(s);
|
|
1225
|
+
if (spelled) {
|
|
1226
|
+
const month = MONTH_INDEX[spelled[1].toLowerCase()];
|
|
1227
|
+
if (month === undefined) return null;
|
|
1228
|
+
const date = new Date(Date.UTC(+spelled[3], month, +spelled[2]));
|
|
1229
|
+
if (date.getUTCMonth() !== month || date.getUTCDate() !== +spelled[2]) return null;
|
|
1230
|
+
return date;
|
|
1231
|
+
}
|
|
1232
|
+
return null;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/**
|
|
1236
|
+
* Format a `Date` per a simple format string. Supported tokens:
|
|
1237
|
+
* `yyyy` (year), `MMMM` (full month name), `MM` (2-digit month), `d` (day).
|
|
1238
|
+
* Order doesn't matter; tokens are matched in a single pass so MMMM doesn't
|
|
1239
|
+
* accidentally consume MM, and `d` doesn't leak into month names.
|
|
1240
|
+
*
|
|
1241
|
+
* @param {Date} date
|
|
1242
|
+
* @param {string} format
|
|
1243
|
+
* @returns {string}
|
|
1244
|
+
*/
|
|
1245
|
+
export function formatDateValue(date, format) {
|
|
1246
|
+
const y = date.getUTCFullYear();
|
|
1247
|
+
const m = date.getUTCMonth();
|
|
1248
|
+
const d = date.getUTCDate();
|
|
1249
|
+
return format.replace(/yyyy|MMMM|MM|d/g, (token) => {
|
|
1250
|
+
if (token === "yyyy") return String(y);
|
|
1251
|
+
if (token === "MMMM") return MONTH_NAMES[m];
|
|
1252
|
+
if (token === "MM") return String(m + 1).padStart(2, "0");
|
|
1253
|
+
if (token === "d") return String(d);
|
|
1254
|
+
return token;
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* Parse a money input. Accepts `$5,000`, `5000.50`, `$5M`, `2.5K`, etc.
|
|
1260
|
+
* Handles `K`/`M`/`B` suffixes (case-insensitive). Returns the value in
|
|
1261
|
+
* minor units (cents for USD) as an integer, or `null` on failure.
|
|
1262
|
+
*
|
|
1263
|
+
* @param {string} raw
|
|
1264
|
+
* @returns {number | null}
|
|
1265
|
+
*/
|
|
1266
|
+
export function parseMoneyValue(raw) {
|
|
1267
|
+
const s = String(raw).trim();
|
|
1268
|
+
if (!s) return null;
|
|
1269
|
+
// Strict shape: optional minus, optional single $, digits (with optional
|
|
1270
|
+
// thousand-comma groups), optional decimal, optional K/M/B. Rejects
|
|
1271
|
+
// doubled `$`, ad-hoc comma placement, multiple decimals, words.
|
|
1272
|
+
if (!/^-?\$?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?[KMB]?$/i.test(s)) return null;
|
|
1273
|
+
let core = s.replace(/[$,\s]/g, "");
|
|
1274
|
+
let mult = 1;
|
|
1275
|
+
if (/[KMB]$/i.test(core)) {
|
|
1276
|
+
mult = { K: 1e3, M: 1e6, B: 1e9 }[core.slice(-1).toUpperCase()];
|
|
1277
|
+
core = core.slice(0, -1);
|
|
1278
|
+
}
|
|
1279
|
+
const n = parseFloat(core);
|
|
1280
|
+
if (!isFinite(n)) return null;
|
|
1281
|
+
return Math.round(n * mult * 100);
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* Format a money value (in minor units, e.g. cents for USD) per a currency.
|
|
1286
|
+
* Q3.2: v2 supports USD only. Adds thousand separators and always renders
|
|
1287
|
+
* two decimal places.
|
|
1288
|
+
*
|
|
1289
|
+
* @param {number} minor — value in minor units (cents).
|
|
1290
|
+
* @param {string} currency — currency code (only "USD" supported in v2).
|
|
1291
|
+
* @returns {string}
|
|
1292
|
+
* @throws {Error} on unsupported currency.
|
|
1293
|
+
*/
|
|
1294
|
+
export function formatMoneyValue(minor, currency) {
|
|
1295
|
+
if (currency !== "USD") {
|
|
1296
|
+
throw new Error(`only USD is supported in v0.3.0; got currency="${currency}"`);
|
|
1297
|
+
}
|
|
1298
|
+
const sign = minor < 0 ? "-" : "";
|
|
1299
|
+
const abs = Math.abs(minor);
|
|
1300
|
+
const dollars = Math.floor(abs / 100);
|
|
1301
|
+
const cents = abs % 100;
|
|
1302
|
+
const intPart = String(dollars).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
1303
|
+
return `${sign}$${intPart}.${String(cents).padStart(2, "0")}`;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/**
|
|
1307
|
+
* Normalize a raw value per a placeholder's schema-declared type. Returns
|
|
1308
|
+
* the normalized string. Throws on invalid input (Q3.3 → hard error).
|
|
1309
|
+
* If no `type` is declared on the placeholder, returns the raw value
|
|
1310
|
+
* unchanged.
|
|
1311
|
+
*
|
|
1312
|
+
* @param {string} rawValue
|
|
1313
|
+
* @param {{ type?: string|null, format?: string|null, currency?: string|null, key?: string }} placeholder
|
|
1314
|
+
* @returns {string}
|
|
1315
|
+
* @throws {Error} with `.exitCode = EXIT.VALIDATION` on bad input.
|
|
1316
|
+
*/
|
|
1317
|
+
export function normalizeTypedValue(rawValue, placeholder) {
|
|
1318
|
+
const type = placeholder && placeholder.type;
|
|
1319
|
+
if (!type) return rawValue;
|
|
1320
|
+
if (type === "date") {
|
|
1321
|
+
const date = parseDateValue(rawValue);
|
|
1322
|
+
if (!date) {
|
|
1323
|
+
const e = new Error(
|
|
1324
|
+
`could not parse "${rawValue}" as a date. expected ISO ` +
|
|
1325
|
+
`(2027-01-15) or spelled ("January 15, 2027"). ` +
|
|
1326
|
+
`US ("01/15/2027") and European ("15/01/2027") forms are not ` +
|
|
1327
|
+
`accepted — they're ambiguous.`
|
|
1328
|
+
);
|
|
1329
|
+
e.exitCode = EXIT.VALIDATION;
|
|
1330
|
+
throw e;
|
|
1331
|
+
}
|
|
1332
|
+
return formatDateValue(date, placeholder.format || "MMMM d, yyyy");
|
|
1333
|
+
}
|
|
1334
|
+
if (type === "money") {
|
|
1335
|
+
const minor = parseMoneyValue(rawValue);
|
|
1336
|
+
if (minor === null) {
|
|
1337
|
+
const e = new Error(
|
|
1338
|
+
`could not parse "${rawValue}" as money. expected like ` +
|
|
1339
|
+
`"$5,000", "5000.50", "$5M", "2.5K".`
|
|
1340
|
+
);
|
|
1341
|
+
e.exitCode = EXIT.VALIDATION;
|
|
1342
|
+
throw e;
|
|
1343
|
+
}
|
|
1344
|
+
return formatMoneyValue(minor, placeholder.currency || "USD");
|
|
1345
|
+
}
|
|
1346
|
+
if (type === "party") {
|
|
1347
|
+
const s = String(rawValue).trim();
|
|
1348
|
+
if (!s) {
|
|
1349
|
+
const e = new Error(`party value must be non-empty`);
|
|
1350
|
+
e.exitCode = EXIT.VALIDATION;
|
|
1351
|
+
throw e;
|
|
1352
|
+
}
|
|
1353
|
+
if (/\]\(/.test(s)) {
|
|
1354
|
+
const e = new Error(`party value "${rawValue}" contains a markdown link; pass the bare party name instead.`);
|
|
1355
|
+
e.exitCode = EXIT.VALIDATION;
|
|
1356
|
+
throw e;
|
|
1357
|
+
}
|
|
1358
|
+
if (/[.!?,;:]$/.test(s)) {
|
|
1359
|
+
const e = new Error(`party value "${rawValue}" has trailing punctuation; remove it before passing.`);
|
|
1360
|
+
e.exitCode = EXIT.VALIDATION;
|
|
1361
|
+
throw e;
|
|
1362
|
+
}
|
|
1363
|
+
return s;
|
|
1364
|
+
}
|
|
1365
|
+
const e = new Error(`unknown type "${type}" on placeholder${placeholder.key ? ` "${placeholder.key}"` : ""}. expected one of: date, money, party.`);
|
|
1366
|
+
e.exitCode = EXIT.IO;
|
|
1367
|
+
throw e;
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* Run {@link normalizeTypedValue} across every resolved placeholder value.
|
|
1372
|
+
* Mutates `resolved` in place with normalized strings. Collects all errors
|
|
1373
|
+
* before returning so the user sees every type failure at once.
|
|
1374
|
+
*
|
|
1375
|
+
* @param {Placeholder[]} placeholders
|
|
1376
|
+
* @param {Object<string,string>} resolved
|
|
1377
|
+
* @returns {{ ok: boolean, errors: Array<{ key: string, message: string }>, normalized: Object<string,{from: string, to: string, type: string}> }}
|
|
1378
|
+
*/
|
|
1379
|
+
export function normalizeTypedValues(placeholders, resolved) {
|
|
1380
|
+
const errors = [];
|
|
1381
|
+
const normalized = {};
|
|
1382
|
+
for (const p of placeholders) {
|
|
1383
|
+
if (!p.type) continue;
|
|
1384
|
+
if (resolved[p.key] === undefined) continue;
|
|
1385
|
+
const raw = resolved[p.key];
|
|
1386
|
+
try {
|
|
1387
|
+
const norm = normalizeTypedValue(raw, p);
|
|
1388
|
+
if (norm !== raw) normalized[p.key] = { from: raw, to: norm, type: p.type };
|
|
1389
|
+
resolved[p.key] = norm;
|
|
1390
|
+
} catch (e) {
|
|
1391
|
+
errors.push({ key: p.key, message: e.message });
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
return { ok: errors.length === 0, errors, normalized };
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1119
1397
|
async function nodePrompter(placeholder) {
|
|
1120
1398
|
if (!process.stdin.isTTY) return null;
|
|
1121
1399
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
@@ -1180,6 +1458,83 @@ function replaceAll(s, find, repl) {
|
|
|
1180
1458
|
return s.split(find).join(repl);
|
|
1181
1459
|
}
|
|
1182
1460
|
|
|
1461
|
+
/**
|
|
1462
|
+
* Substitute placeholder values *inside the Word XML*, preserving runs
|
|
1463
|
+
* and styling. Returns a new XML string plus warnings for any placeholder
|
|
1464
|
+
* whose text spans multiple `<w:t>` runs in the source — these are
|
|
1465
|
+
* skipped rather than substituted (merging the runs would lose styling
|
|
1466
|
+
* information; leaving them is reversible).
|
|
1467
|
+
*
|
|
1468
|
+
* For T1 (bracket) / T2 (mustache) the search text is the literal match
|
|
1469
|
+
* (e.g. `[Party A]` or `{{party_a}}`). For T3 (docx-highlight), T4
|
|
1470
|
+
* (heuristic), T5 (llm) the search text is the run's inner content with
|
|
1471
|
+
* whole-word boundaries — same semantics as {@link substitute}.
|
|
1472
|
+
*
|
|
1473
|
+
* @param {string} xml — original `word/document.xml`.
|
|
1474
|
+
* @param {Placeholder[]} placeholders
|
|
1475
|
+
* @param {Object<string,string>} values — `{ key: resolvedValue }`.
|
|
1476
|
+
* @param {Tier} tier
|
|
1477
|
+
* @returns {{ xml: string, warnings: string[] }}
|
|
1478
|
+
*/
|
|
1479
|
+
export function substituteDocxXml(xml, placeholders, values, tier) {
|
|
1480
|
+
let out = xml;
|
|
1481
|
+
const warnings = [];
|
|
1482
|
+
const originalText = docxXmlToText(xml);
|
|
1483
|
+
for (const p of placeholders) {
|
|
1484
|
+
const v = values[p.key];
|
|
1485
|
+
if (v === undefined) continue;
|
|
1486
|
+
for (const h of p.hits) {
|
|
1487
|
+
const find = (tier === "bracket" || tier === "mustache") ? h.match : h.inner;
|
|
1488
|
+
const literal = (tier === "bracket" || tier === "mustache");
|
|
1489
|
+
const buildRe = (global) => literal
|
|
1490
|
+
? new RegExp(escapeRegex(find), global ? "g" : "")
|
|
1491
|
+
: new RegExp(`(?<![A-Za-z0-9])${escapeRegex(find)}(?![A-Za-z0-9])`, global ? "g" : "");
|
|
1492
|
+
const replaceRe = buildRe(true);
|
|
1493
|
+
let madeSubstitution = false;
|
|
1494
|
+
out = out.replace(/<w:t(\s[^>]*)?>([\s\S]*?)<\/w:t>/g, (match, attrs, content) => {
|
|
1495
|
+
const decoded = decodeXml(content);
|
|
1496
|
+
replaceRe.lastIndex = 0;
|
|
1497
|
+
const replaced = decoded.replace(replaceRe, v);
|
|
1498
|
+
if (replaced === decoded) return match;
|
|
1499
|
+
madeSubstitution = true;
|
|
1500
|
+
return `<w:t${attrs || ""}>${encodeXml(replaced)}</w:t>`;
|
|
1501
|
+
});
|
|
1502
|
+
if (!madeSubstitution && buildRe(false).test(originalText)) {
|
|
1503
|
+
warnings.push(
|
|
1504
|
+
`docx substitution skipped for "${find}" (→ "${v}"): the placeholder spans ` +
|
|
1505
|
+
`multiple text runs in the source, which would lose run-level styling if merged. ` +
|
|
1506
|
+
`Open the document, retype the placeholder so it lives in a single run, and retry.`
|
|
1507
|
+
);
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
return { xml: out, warnings };
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
/**
|
|
1515
|
+
* Decide whether to write `.docx` output (round-trip) versus plain text.
|
|
1516
|
+
* Returns `{ path }` for `.docx`, or `null` for text. Rules:
|
|
1517
|
+
* - input must be `.docx`;
|
|
1518
|
+
* - `--json`, `--diff`, `--validate`, `--list-placeholders` force text;
|
|
1519
|
+
* - `--output PATH.docx` writes `.docx` to PATH;
|
|
1520
|
+
* - `--output -` writes plain text to stdout (Unix `-` convention);
|
|
1521
|
+
* - `--output PATH` with any other extension writes plain text;
|
|
1522
|
+
* - no `--output` defaults to `<basename>-filled.docx`.
|
|
1523
|
+
*
|
|
1524
|
+
* @param {Object} opts — parsed CLI args.
|
|
1525
|
+
* @param {{kind: "text"|"docx", path: string|null}} input
|
|
1526
|
+
* @returns {{ path: string } | null}
|
|
1527
|
+
*/
|
|
1528
|
+
export function decideDocxOutput(opts, input) {
|
|
1529
|
+
if (input.kind !== "docx") return null;
|
|
1530
|
+
if (opts.json || opts.diff || opts.listPlaceholders || opts.validate) return null;
|
|
1531
|
+
if (opts.output === "-") return null;
|
|
1532
|
+
if (opts.output) {
|
|
1533
|
+
return extname(opts.output) === ".docx" ? { path: opts.output } : null;
|
|
1534
|
+
}
|
|
1535
|
+
return { path: makeDocxOutputPath(input.path || "out.docx") };
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1183
1538
|
// ─── --why BUILDER ──────────────────────────────────────────────────────────
|
|
1184
1539
|
/**
|
|
1185
1540
|
* Format the `--why` stderr block. Stable shape across minor versions; see
|
|
@@ -1277,6 +1632,21 @@ export async function cmdValidate(opts, input, schema, paramsObj, envObj, { fetc
|
|
|
1277
1632
|
}
|
|
1278
1633
|
return EXIT.VALIDATION;
|
|
1279
1634
|
}
|
|
1635
|
+
// v2 #3: typed-parameter validation. Mirror what cmdDraft does so
|
|
1636
|
+
// `--validate` catches type errors before the user runs draft.
|
|
1637
|
+
const typeCheck = normalizeTypedValues(result.placeholders, resolved);
|
|
1638
|
+
if (!typeCheck.ok) {
|
|
1639
|
+
for (const te of typeCheck.errors) {
|
|
1640
|
+
err.write(paint(`error: type validation failed for "${te.key}": ${te.message}\n`, "red", err));
|
|
1641
|
+
}
|
|
1642
|
+
if (opts.json) {
|
|
1643
|
+
out.write(JSON.stringify({
|
|
1644
|
+
ok: false,
|
|
1645
|
+
type_errors: typeCheck.errors.map(({ key, message }) => ({ key, message })),
|
|
1646
|
+
}, null, 2) + "\n");
|
|
1647
|
+
}
|
|
1648
|
+
return EXIT.VALIDATION;
|
|
1649
|
+
}
|
|
1280
1650
|
if (opts.json) {
|
|
1281
1651
|
out.write(JSON.stringify({ ok: true, resolved: Object.keys(resolved), sources }, null, 2) + "\n");
|
|
1282
1652
|
} else {
|
|
@@ -1346,6 +1716,17 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
|
|
|
1346
1716
|
return EXIT.VALIDATION;
|
|
1347
1717
|
}
|
|
1348
1718
|
|
|
1719
|
+
// v2 #3: typed-parameter normalization. Schema entries can declare
|
|
1720
|
+
// `type: date | money | party`. Inputs are validated and normalized
|
|
1721
|
+
// before substitution. Hard error on bad input (Q3.3 decision).
|
|
1722
|
+
const typeCheck = normalizeTypedValues(result.placeholders, resolved);
|
|
1723
|
+
if (!typeCheck.ok) {
|
|
1724
|
+
for (const te of typeCheck.errors) {
|
|
1725
|
+
err.write(paint(`error: type validation failed for "${te.key}": ${te.message}\n`, "red", err));
|
|
1726
|
+
}
|
|
1727
|
+
return EXIT.VALIDATION;
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1349
1730
|
// Diff mode: print a substitution table and exit without writing output.
|
|
1350
1731
|
if (opts.diff) {
|
|
1351
1732
|
if (opts.json) {
|
|
@@ -1367,9 +1748,29 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
|
|
|
1367
1748
|
|
|
1368
1749
|
const output = substitute(input.body, result.placeholders, resolved, result.tier);
|
|
1369
1750
|
|
|
1370
|
-
// Write output.
|
|
1371
|
-
|
|
1372
|
-
|
|
1751
|
+
// Write output. Three paths:
|
|
1752
|
+
// (a) docx round-trip: input is .docx and target is .docx (default for .docx
|
|
1753
|
+
// inputs, unless --output is set to a non-.docx extension or `-`).
|
|
1754
|
+
// (b) write text to a file (--output PATH, where PATH ≠ "-").
|
|
1755
|
+
// (c) write text to stdout (no --output, or --output "-").
|
|
1756
|
+
// --json suppresses (c) so it doesn't collide with the JSON payload.
|
|
1757
|
+
const docxOut = decideDocxOutput(opts, input);
|
|
1758
|
+
let writtenPath = null;
|
|
1759
|
+
if (docxOut) {
|
|
1760
|
+
try {
|
|
1761
|
+
const { xml: newXml, warnings: docxWarnings } = substituteDocxXml(
|
|
1762
|
+
input.docxXml, result.placeholders, resolved, result.tier
|
|
1763
|
+
);
|
|
1764
|
+
if (docxWarnings.length) result.warnings.push(...docxWarnings);
|
|
1765
|
+
const buf = await writeDocxBuffer(input.path, newXml);
|
|
1766
|
+
writeFileSync(docxOut.path, buf);
|
|
1767
|
+
writtenPath = docxOut.path;
|
|
1768
|
+
} catch (e) {
|
|
1769
|
+
err.write(paint(`error: could not write ${docxOut.path}: ${e.message}\n`, "red", err));
|
|
1770
|
+
return EXIT.IO;
|
|
1771
|
+
}
|
|
1772
|
+
} else if (opts.output && opts.output !== "-") {
|
|
1773
|
+
try { writeFileSync(opts.output, output, "utf8"); writtenPath = opts.output; }
|
|
1373
1774
|
catch (e) {
|
|
1374
1775
|
err.write(paint(`error: could not write ${opts.output}: ${e.message}\n`, "red", err));
|
|
1375
1776
|
return EXIT.IO;
|
|
@@ -1382,8 +1783,8 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
|
|
|
1382
1783
|
out.write(JSON.stringify({
|
|
1383
1784
|
ok: true,
|
|
1384
1785
|
tier: result.tier,
|
|
1385
|
-
output_path:
|
|
1386
|
-
output:
|
|
1786
|
+
output_path: writtenPath,
|
|
1787
|
+
output: writtenPath ? null : output,
|
|
1387
1788
|
placeholders: publicPlaceholders(result.placeholders),
|
|
1388
1789
|
sources,
|
|
1389
1790
|
warnings: result.warnings,
|
|
@@ -1401,7 +1802,7 @@ export async function cmdDraft(opts, input, schema, paramsObj, envObj, { fetcher
|
|
|
1401
1802
|
missing,
|
|
1402
1803
|
unmapped: result.unmapped,
|
|
1403
1804
|
warnings: result.warnings,
|
|
1404
|
-
outputPath:
|
|
1805
|
+
outputPath: writtenPath,
|
|
1405
1806
|
}) + "\n");
|
|
1406
1807
|
}
|
|
1407
1808
|
for (const w of result.warnings) err.write(paint(`warning: ${w}\n`, "yellow", err));
|