@liiift-studio/vf-clamp-cli 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Liiift Studio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,194 @@
1
+ # @liiift-studio/vf-clamp-cli
2
+
3
+ A command-line interface for [`@liiift-studio/vf-clamp`](https://www.npmjs.com/package/@liiift-studio/vf-clamp) — restrict variable font axis ranges from the terminal without writing any JavaScript.
4
+
5
+ ## Install
6
+
7
+ Requires **Node.js 18 or newer**.
8
+
9
+ ```bash
10
+ npm install -g @liiift-studio/vf-clamp-cli
11
+ ```
12
+
13
+ ## Discovery
14
+
15
+ ```bash
16
+ vf-clamp --help # top-level overview, both subcommands
17
+ vf-clamp --version # also: -v
18
+ vf-clamp clamp --help # subcommand-specific help, examples, axis spec guide
19
+ vf-clamp instances --help
20
+ ```
21
+
22
+ ## Usage
23
+
24
+ ### Inspect a font
25
+
26
+ List all variable axes and named instances in a font file (TTF, OTF, WOFF, or WOFF2):
27
+
28
+ ```bash
29
+ vf-clamp instances MyFont.ttf
30
+ vf-clamp instances MyFont.ttf --json # machine-readable
31
+ ```
32
+
33
+ Example output (exact column spacing depends on your font):
34
+
35
+ ```
36
+ Axes:
37
+ wght Weight 100 → 900 (default: 400)
38
+ slnt Slant -10 → 0 (default: 0)
39
+
40
+ Instances (18):
41
+ Thin wght=100 slnt=0
42
+ ExtraLight wght=200 slnt=0
43
+ Light wght=300 slnt=0
44
+ Regular wght=400 slnt=0
45
+ Medium wght=500 slnt=0
46
+ SemiBold wght=600 slnt=0
47
+ Bold wght=700 slnt=0
48
+ ...
49
+ ```
50
+
51
+ ### Clamp a font
52
+
53
+ Produce a restricted VF spanning the hull of named instances:
54
+
55
+ ```bash
56
+ vf-clamp clamp MyFont.ttf --instance Light --instance Bold --name Light-Bold
57
+ ```
58
+
59
+ Short flags are accepted for every option:
60
+
61
+ ```bash
62
+ vf-clamp clamp MyFont.ttf -i Light -i Bold -n Light-Bold
63
+ ```
64
+
65
+ Restrict using explicit axis ranges:
66
+
67
+ ```bash
68
+ vf-clamp clamp MyFont.ttf -a wght:300:700 -n Custom -f woff2
69
+ ```
70
+
71
+ Pin a single axis value, or keep an axis at its full original range:
72
+
73
+ ```bash
74
+ vf-clamp clamp MyFont.ttf -a wght:400 -n Regular-Only
75
+ vf-clamp clamp MyFont.ttf -i Light -i Bold -a wdth:* # keep wdth untouched
76
+ ```
77
+
78
+ Combine instances and axis overrides:
79
+
80
+ ```bash
81
+ vf-clamp clamp MyFont.ttf -i Light -i Bold -a slnt:-5:0 -n LightBold-Slanted
82
+ ```
83
+
84
+ Read source font from stdin and pipe results:
85
+
86
+ ```bash
87
+ cat MyFont.ttf | vf-clamp clamp - -i Bold -n Bold-only -o ./out
88
+ ```
89
+
90
+ Write output to a specific directory:
91
+
92
+ ```bash
93
+ vf-clamp clamp MyFont.ttf -i Light -i Bold -o ./dist/fonts
94
+ ```
95
+
96
+ When `--name` is omitted, the output filename is derived from the instance names (`Light-Bold`) or axis tags. All names are sanitised for filesystem safety: characters like `/`, `\`, `:`, `..`, ASCII control codes, and Windows reserved device names are stripped or rejected.
97
+
98
+ ### Clamp using a config file
99
+
100
+ For multiple outputs in one pass, use a JSON config file. All outputs are produced in a single engine invocation — the font is parsed once, not N times.
101
+
102
+ ```bash
103
+ vf-clamp clamp MyFont.ttf -c clamp.config.json
104
+ ```
105
+
106
+ **clamp.config.json:**
107
+
108
+ ```json
109
+ {
110
+ "format": "ttf",
111
+ "outputDir": "./output",
112
+ "outputs": [
113
+ {
114
+ "name": "Light-Bold",
115
+ "instances": ["Light", "Bold"]
116
+ },
117
+ {
118
+ "name": "Condensed",
119
+ "axes": { "wdth": { "min": 50, "max": 75 } }
120
+ },
121
+ {
122
+ "name": "WeightRange-KeepWidth",
123
+ "axes": { "wght": { "min": 100, "max": 700 }, "wdth": null }
124
+ }
125
+ ]
126
+ }
127
+ ```
128
+
129
+ Each output must specify either `instances`, `axes` (non-empty), or both. Axis values may be a number (pin), `{ "min": …, "max": … }` (range), or `null` (keep full original range).
130
+
131
+ CLI flags `--instance`, `--axis`, and `--name` are ignored when `--config` is used; a warning is printed to stderr.
132
+
133
+ ## Options
134
+
135
+ ### `vf-clamp instances <font>`
136
+
137
+ | Argument / Option | Description |
138
+ |----------|-------------|
139
+ | `<font>` | Path to TTF, OTF, WOFF, or WOFF2 file (or `-` for stdin) |
140
+ | `--json` | Print results as JSON to stdout |
141
+ | `-q, --quiet` | Suppress progress messages on stderr |
142
+ | `--verbose` | Print Python tracebacks on engine errors |
143
+
144
+ ### `vf-clamp clamp <font> [options]`
145
+
146
+ | Option | Description |
147
+ |--------|-------------|
148
+ | `<font>` | Path to TTF, OTF, WOFF, or WOFF2 file (or `-` for stdin) |
149
+ | `-i, --instance <name>` | Named instance to include (repeatable) |
150
+ | `-a, --axis <tag:value>` | Pin an axis to a fixed value |
151
+ | `-a, --axis <tag:min:max>` | Restrict an axis to a range |
152
+ | `-a, --axis <tag:*>` (or `tag:keep`) | Keep axis at its full original range |
153
+ | `-n, --name <name>` | Output name (filename stem; sanitised for filesystem safety) |
154
+ | `-f, --format <fmt>` | Output format: `ttf` (default), `otf`, `woff`, `woff2` |
155
+ | `-o, --output <dir>` | Output directory (default: current directory) |
156
+ | `-c, --config <file>` | JSON config file (for multiple outputs) |
157
+ | `--no-force` | Refuse to overwrite existing output files |
158
+ | `--dry-run` | Validate inputs without invoking the engine or writing files |
159
+ | `--json` | Print results as JSON to stdout |
160
+ | `-q, --quiet` | Suppress progress messages on stderr |
161
+ | `--verbose` | Print Python tracebacks on engine errors |
162
+
163
+ ## Output and exit codes
164
+
165
+ For scripting, output paths are written one-per-line to **stdout**; all progress and error messages go to **stderr**:
166
+
167
+ ```bash
168
+ vf-clamp clamp MyFont.ttf -i Light -i Bold | xargs -n1 woff2_compress
169
+ ```
170
+
171
+ Exit codes follow BSD `sysexits` conventions:
172
+
173
+ | Code | Meaning |
174
+ |------|---------|
175
+ | 0 | Success |
176
+ | 2 | Usage / validation error (bad flags, malformed `--axis`, missing inputs) |
177
+ | 65 | Input data error (bad font, bad JSON config) |
178
+ | 66 | Input file missing or unreadable |
179
+ | 70 | Internal engine error |
180
+ | 78 | Invalid configuration |
181
+ | 130 | Aborted by Ctrl-C (SIGINT) |
182
+
183
+ ## Performance Note
184
+
185
+ The underlying engine uses Pyodide (Python WASM). The first run in a process takes approximately **10–20 seconds** to initialise. Subsequent calls in the same process are **1–2 seconds**. This is expected — plan config-file batching for large workflows so the engine pays cold-start only once.
186
+
187
+ ## Related
188
+
189
+ - [`@liiift-studio/vf-clamp`](https://www.npmjs.com/package/@liiift-studio/vf-clamp) — the core library
190
+ - [vfclamp.com](https://vfclamp.com) — web interface
191
+
192
+ ## License
193
+
194
+ MIT © [Liiift Studio](https://liiift.studio)
@@ -0,0 +1,9 @@
1
+ import type { Command } from 'commander';
2
+ import { EX_USAGE, EX_DATAERR, EX_NOINPUT, EX_CONFIG, EX_SOFTWARE } from '../core/exitCodes.js';
3
+ export { parseAxisSpecs } from '../core/axisSpecs.js';
4
+ /**
5
+ * Registers the `clamp` subcommand on the given commander program.
6
+ * Usage: vf-clamp clamp <font> [options]
7
+ */
8
+ export declare function registerClampCommand(program: Command): void;
9
+ export { EX_USAGE, EX_DATAERR, EX_NOINPUT, EX_CONFIG, EX_SOFTWARE };
@@ -0,0 +1,202 @@
1
+ // `vf-clamp clamp <font>` — produce one or more restricted variable font files.
2
+ // Wires commander flags onto the pure config/parser/format-validator layer in
3
+ // `src/core/*` and the IO helpers in `src/utils/*`.
4
+ import { readFontFile, writeOutputs, assertFontExtension, sanitizeFilename } from '../utils/font.js';
5
+ import { ellipsisGlyph } from '../utils/format.js';
6
+ import { SUPPORTED_FORMATS, assertFormat } from '../core/format.js';
7
+ import { parseAxisSpecs } from '../core/axisSpecs.js';
8
+ import { readConfig } from '../core/config.js';
9
+ import { EX_USAGE, EX_DATAERR, EX_NOINPUT, EX_CONFIG, EX_SOFTWARE, classifyError } from '../core/exitCodes.js';
10
+ // Re-export parseAxisSpecs for backward compatibility with existing tests
11
+ // while keeping the canonical home in `src/core/axisSpecs.ts`.
12
+ export { parseAxisSpecs } from '../core/axisSpecs.js';
13
+ /**
14
+ * Registers the `clamp` subcommand on the given commander program.
15
+ * Usage: vf-clamp clamp <font> [options]
16
+ */
17
+ export function registerClampCommand(program) {
18
+ program
19
+ .command('clamp <font>')
20
+ .description('Produce one or more restricted variable font files.\n' +
21
+ 'Pass "-" for <font> to read the source font from stdin.')
22
+ .option('-i, --instance <name>', 'Named instance to include in hull (repeatable)', collect, [])
23
+ .option('-a, --axis <spec>', 'Pin or restrict an axis: tag:value, tag:min:max, or tag:* (repeatable)', collect, [])
24
+ .option('-n, --name <name>', 'Output name (filename stem)')
25
+ .option('-f, --format <fmt>', `Output format: ${SUPPORTED_FORMATS.join(' | ')}`, 'ttf')
26
+ .option('-o, --output <dir>', 'Output directory', '.')
27
+ .option('-c, --config <file>', 'JSON config file for multiple outputs')
28
+ .option('--force', 'Overwrite existing output files without warning', true)
29
+ .option('--no-force', 'Refuse to overwrite existing output files')
30
+ .option('--dry-run', 'Validate inputs without invoking the engine or writing files')
31
+ .option('--json', 'Print results as JSON to stdout')
32
+ .option('-q, --quiet', 'Suppress progress messages on stderr')
33
+ .option('--verbose', 'Print extra diagnostic output (Python tracebacks on error)')
34
+ .addHelpText('after', `
35
+ Examples:
36
+ $ vf-clamp clamp MyFont.ttf -i Light -i Bold -n Light-Bold
37
+ $ vf-clamp clamp MyFont.ttf -a wght:300:700 -f woff2
38
+ $ vf-clamp clamp MyFont.ttf -a wght:400 -n Regular-Only
39
+ $ vf-clamp clamp MyFont.ttf -i Light -i Bold -a slnt:-5:0 -n LightBold-Slanted
40
+ $ vf-clamp clamp MyFont.ttf -c clamp.config.json
41
+ $ cat MyFont.ttf | vf-clamp clamp - -i Bold -n Bold-only
42
+
43
+ Axis spec forms:
44
+ tag:value pin the axis to a single value
45
+ tag:min:max restrict the axis to a sub-range
46
+ tag:* keep the axis at its full original range
47
+ tag:keep alias for tag:*
48
+
49
+ Exit codes:
50
+ 0 success
51
+ 2 usage / validation error (bad flags, malformed --axis, missing inputs)
52
+ 65 input data error (bad font, bad JSON config)
53
+ 66 input file missing or unreadable
54
+ 70 internal engine error
55
+ 78 invalid configuration
56
+
57
+ First run in a fresh process takes 10–20s while Pyodide initialises.
58
+ `)
59
+ .action(async (fontPath, opts) => {
60
+ try {
61
+ await runClamp(fontPath, opts);
62
+ }
63
+ catch (err) {
64
+ const message = err instanceof Error ? err.message : String(err);
65
+ const code = classifyError(err);
66
+ const detail = opts.verbose && err instanceof Error && err.stack ? `\n${err.stack}` : '';
67
+ // Set exitCode and write to stderr; the entry point's parseAsync
68
+ // handler will exit with the correct code after the event loop drains.
69
+ process.exitCode = code;
70
+ process.stderr.write(`vf-clamp clamp: ${message}${detail}\n`);
71
+ }
72
+ });
73
+ }
74
+ /**
75
+ * Core clamp action — separated from the commander handler so it can be
76
+ * driven directly from tests.
77
+ */
78
+ async function runClamp(fontPath, opts) {
79
+ assertFontExtension(fontPath);
80
+ // Build request list and resolve effective format/outputDir.
81
+ let outputRequests;
82
+ let format;
83
+ let outputDir;
84
+ if (opts.config) {
85
+ // Warn loudly when CLI flags conflict with config-supplied values.
86
+ warnIfFlagsIgnored(opts);
87
+ const config = await readConfig(opts.config);
88
+ outputRequests = config.outputs.map((output, idx) => ({
89
+ name: sanitizeFilename(output.name ?? `output-${idx + 1}`),
90
+ ...(output.instances ? { instances: output.instances } : {}),
91
+ ...(output.axes ? { axes: output.axes } : {}),
92
+ }));
93
+ const candidateFormat = config.format ?? opts.format;
94
+ assertFormat(candidateFormat);
95
+ format = candidateFormat;
96
+ outputDir = config.outputDir ?? opts.output;
97
+ }
98
+ else {
99
+ assertFormat(opts.format);
100
+ format = opts.format;
101
+ outputDir = opts.output;
102
+ outputRequests = buildRequestFromFlags(opts);
103
+ }
104
+ if (opts.dryRun) {
105
+ const lines = outputRequests.map((r, i) => ` [${i + 1}] ${r.name}.${format}`).join('\n');
106
+ writeLog(opts, `Dry run: ${outputRequests.length} output(s) would be written to ${outputDir}\n${lines}\n`);
107
+ return;
108
+ }
109
+ // Read source font (after dry-run check so dry-run does not read the file).
110
+ const buffer = await readFontFile(fontPath);
111
+ writeLog(opts, `Processing ${outputRequests.length} output(s)${ellipsisGlyph()} (first run may take 10–20s)\n`);
112
+ // Dynamic import so `--help`/`--version`/dry-run do not pay the engine's
113
+ // ESM resolution and Pyodide bootstrap cost.
114
+ const { clampFont } = await import('@liiift-studio/vf-clamp');
115
+ // Batch ALL outputs into a single clampFont call so Pyodide is initialised
116
+ // once, the font buffer crosses the WASM bridge once, and fontTools parses
117
+ // the TTFont once. The engine's `outputs` array is designed for this.
118
+ const clampOutputs = outputRequests.map(buildClampOutput);
119
+ const results = await clampFont(buffer, {
120
+ outputs: clampOutputs,
121
+ format,
122
+ });
123
+ // Pair engine results back with the names we requested so writeOutputs can
124
+ // compose filenames using our sanitised names rather than whatever the
125
+ // engine echoed back.
126
+ const writeInputs = results.map((result, i) => ({
127
+ name: outputRequests[i]?.name ?? result.name,
128
+ buffer: result.buffer,
129
+ format: result.format ?? format,
130
+ }));
131
+ const written = await writeOutputs(writeInputs, outputDir, { force: opts.force !== false });
132
+ if (opts.json) {
133
+ process.stdout.write(JSON.stringify({ written }) + '\n');
134
+ }
135
+ else {
136
+ for (const filePath of written) {
137
+ // Bare paths on stdout so consumers can `... | xargs` them.
138
+ process.stdout.write(`${filePath}\n`);
139
+ }
140
+ }
141
+ }
142
+ /** Commander value collector — appends each repeated flag value into an array. */
143
+ function collect(value, previous) {
144
+ return [...previous, value];
145
+ }
146
+ /**
147
+ * Emits stderr warnings when --config is combined with flags whose values
148
+ * would have been used in flag-mode. Avoids the silent-override footgun.
149
+ */
150
+ function warnIfFlagsIgnored(opts) {
151
+ const ignored = [];
152
+ if (opts.instance.length > 0)
153
+ ignored.push('--instance');
154
+ if (opts.axis.length > 0)
155
+ ignored.push('--axis');
156
+ if (opts.name)
157
+ ignored.push('--name');
158
+ if (ignored.length > 0 && !opts.quiet) {
159
+ process.stderr.write(`vf-clamp clamp: warning — ${ignored.join(', ')} ignored when --config is used\n`);
160
+ }
161
+ }
162
+ /**
163
+ * Builds a single output request from CLI flags.
164
+ * Requires at least one --instance or --axis flag.
165
+ */
166
+ function buildRequestFromFlags(opts) {
167
+ const { instance: instances, axis: axisSpecs, name } = opts;
168
+ if (instances.length === 0 && axisSpecs.length === 0) {
169
+ const err = new Error('Provide at least one --instance or --axis flag, or use --config for a config file.');
170
+ // Mark for the exit-code classifier.
171
+ err.code = 'USAGE';
172
+ throw err;
173
+ }
174
+ const axes = parseAxisSpecs(axisSpecs);
175
+ const rawName = name ?? (instances.length > 0 ? instances.join('-') : Object.keys(axes).join('-'));
176
+ const safeName = sanitizeFilename(rawName);
177
+ const req = { name: safeName };
178
+ if (instances.length > 0)
179
+ req.instances = instances;
180
+ if (Object.keys(axes).length > 0)
181
+ req.axes = axes;
182
+ return [req];
183
+ }
184
+ /**
185
+ * Converts an OutputRequest into the shape expected by clampFont's `outputs` array.
186
+ */
187
+ function buildClampOutput(req) {
188
+ return {
189
+ name: req.name,
190
+ ...(req.instances ? { instances: req.instances } : {}),
191
+ ...(req.axes ? { axes: req.axes } : {}),
192
+ };
193
+ }
194
+ /** Writes a progress/log line to stderr unless quiet or stderr is closed. */
195
+ function writeLog(opts, message) {
196
+ if (opts.quiet || opts.json)
197
+ return;
198
+ process.stderr.write(message);
199
+ }
200
+ // Re-export typed exit codes so other modules can use them without
201
+ // reaching into core/.
202
+ export { EX_USAGE, EX_DATAERR, EX_NOINPUT, EX_CONFIG, EX_SOFTWARE };
@@ -0,0 +1,6 @@
1
+ import type { Command } from 'commander';
2
+ /**
3
+ * Registers the `instances` subcommand on the given commander program.
4
+ * Usage: vf-clamp instances <font>
5
+ */
6
+ export declare function registerInstancesCommand(program: Command): void;
@@ -0,0 +1,89 @@
1
+ // `vf-clamp instances <font>` — print all variable axes and named instances in a font.
2
+ import { readFontFile, assertFontExtension } from '../utils/font.js';
3
+ import { formatTable, arrowGlyph, ellipsisGlyph } from '../utils/format.js';
4
+ import { classifyError } from '../core/exitCodes.js';
5
+ /**
6
+ * Registers the `instances` subcommand on the given commander program.
7
+ * Usage: vf-clamp instances <font>
8
+ */
9
+ export function registerInstancesCommand(program) {
10
+ program
11
+ .command('instances <font>')
12
+ .description('List all variable axes and named instances in a font file.\n' +
13
+ 'Pass "-" for <font> to read the source font from stdin.')
14
+ .option('--json', 'Print results as JSON to stdout')
15
+ .option('-q, --quiet', 'Suppress progress messages on stderr')
16
+ .option('--verbose', 'Print extra diagnostic output (Python tracebacks on error)')
17
+ .addHelpText('after', '\nFirst run in a fresh process takes 10–20s while Pyodide initialises.\n')
18
+ .action(async (fontPath, opts) => {
19
+ try {
20
+ await runInstances(fontPath, opts);
21
+ }
22
+ catch (err) {
23
+ const message = err instanceof Error ? err.message : String(err);
24
+ const code = classifyError(err);
25
+ const detail = opts.verbose && err instanceof Error && err.stack ? `\n${err.stack}` : '';
26
+ process.exitCode = code;
27
+ process.stderr.write(`vf-clamp instances: ${message}${detail}\n`);
28
+ }
29
+ });
30
+ }
31
+ /** Core action — extracted so tests can drive it directly. */
32
+ async function runInstances(fontPath, opts) {
33
+ assertFontExtension(fontPath);
34
+ const buffer = await readFontFile(fontPath);
35
+ if (!opts.quiet && !opts.json) {
36
+ process.stderr.write(`Reading font${ellipsisGlyph()} (first run may take 10–20s)\n`);
37
+ }
38
+ // Dynamic import so --help/--version do not pay engine ESM resolution cost.
39
+ const { getInstances } = await import('@liiift-studio/vf-clamp');
40
+ const { axes, instances } = await getInstances(buffer);
41
+ if (axes.length === 0 && !opts.quiet && !opts.json) {
42
+ process.stderr.write(`Warning: "${fontPath}" has no variable axes — this appears to be a static font.\n`);
43
+ }
44
+ if (opts.json) {
45
+ process.stdout.write(JSON.stringify({ axes, instances }) + '\n');
46
+ return;
47
+ }
48
+ printAxes(axes);
49
+ printInstances(instances);
50
+ }
51
+ /**
52
+ * Prints the axes section to stdout.
53
+ * Format: tag Name min → max (default: N)
54
+ */
55
+ function printAxes(axes) {
56
+ process.stdout.write('\nAxes:\n');
57
+ if (axes.length === 0) {
58
+ process.stdout.write(' (none — this may not be a variable font)\n');
59
+ return;
60
+ }
61
+ const TAG_WIDTH = Math.max(4, ...axes.map((a) => a.tag.length)) + 2;
62
+ const NAME_WIDTH = Math.max(4, ...axes.map((a) => a.name.length)) + 2;
63
+ const arrow = arrowGlyph();
64
+ for (const axis of axes) {
65
+ const tag = axis.tag.padEnd(TAG_WIDTH);
66
+ const name = axis.name.padEnd(NAME_WIDTH);
67
+ const range = `${axis.minimum} ${arrow} ${axis.maximum}`;
68
+ process.stdout.write(` ${tag}${name}${range} (default: ${axis.default})\n`);
69
+ }
70
+ }
71
+ /**
72
+ * Prints the named instances section to stdout.
73
+ * Format: Name wght=300 slnt=0 …
74
+ */
75
+ function printInstances(instances) {
76
+ process.stdout.write(`\nInstances (${instances.length}):\n`);
77
+ if (instances.length === 0) {
78
+ process.stdout.write(' (none)\n');
79
+ return;
80
+ }
81
+ const NAME_WIDTH = Math.max(4, ...instances.map((i) => i.name.length)) + 2;
82
+ const rows = instances.map((inst) => {
83
+ const coords = Object.entries(inst.coordinates)
84
+ .map(([tag, val]) => `${tag}=${val}`)
85
+ .join(' ');
86
+ return [inst.name, coords];
87
+ });
88
+ process.stdout.write(formatTable(rows, NAME_WIDTH) + '\n');
89
+ }
@@ -0,0 +1,15 @@
1
+ import type { AxisValue } from '@liiift-studio/vf-clamp';
2
+ /**
3
+ * Parses `--axis` flag values into a structured axes map.
4
+ * Accepted forms:
5
+ * tag:value → pin to exact value (stored as a number)
6
+ * tag:min:max → restrict to range (both min and max are required)
7
+ * tag:* / tag:keep → keep axis at its full original range (stored as null)
8
+ *
9
+ * Rejects non-finite numbers (Infinity, NaN), empty strings, and tags that
10
+ * are not the OpenType 4-character printable-ASCII form.
11
+ *
12
+ * Negative values are supported: slnt:-10:0 parses correctly because `-10`
13
+ * contains no colon.
14
+ */
15
+ export declare function parseAxisSpecs(specs: string[]): Record<string, AxisValue>;
@@ -0,0 +1,85 @@
1
+ // Axis spec parser — pure logic, no commander/IO dependencies.
2
+ // Moved out of `commands/clamp.ts` so the test suite can import it without
3
+ // pulling commander or the engine.
4
+ /** Regex for OpenType axis tags — exactly 4 printable ASCII characters. */
5
+ const AXIS_TAG_REGEX = /^[\x20-\x7E]{4}$/;
6
+ /**
7
+ * Parses `--axis` flag values into a structured axes map.
8
+ * Accepted forms:
9
+ * tag:value → pin to exact value (stored as a number)
10
+ * tag:min:max → restrict to range (both min and max are required)
11
+ * tag:* / tag:keep → keep axis at its full original range (stored as null)
12
+ *
13
+ * Rejects non-finite numbers (Infinity, NaN), empty strings, and tags that
14
+ * are not the OpenType 4-character printable-ASCII form.
15
+ *
16
+ * Negative values are supported: slnt:-10:0 parses correctly because `-10`
17
+ * contains no colon.
18
+ */
19
+ export function parseAxisSpecs(specs) {
20
+ const axes = {};
21
+ for (const spec of specs) {
22
+ const colonIdx = spec.indexOf(':');
23
+ if (colonIdx === -1) {
24
+ throw new Error(`Invalid --axis value "${spec}". Expected tag:value or tag:min:max`);
25
+ }
26
+ const tag = spec.slice(0, colonIdx).trim();
27
+ if (!tag) {
28
+ throw new Error(`Invalid --axis value "${spec}": axis tag is empty`);
29
+ }
30
+ // OpenType axis tags are exactly 4 printable ASCII characters. Reject
31
+ // anything else early — the engine error otherwise is cryptic.
32
+ if (!AXIS_TAG_REGEX.test(tag)) {
33
+ throw new Error(`Invalid --axis value "${spec}": tag "${tag}" must be exactly 4 printable ASCII characters`);
34
+ }
35
+ const rest = spec.slice(colonIdx + 1);
36
+ // Null form: tag:* or tag:keep — keep axis at its full original range.
37
+ if (rest === '*' || rest.toLowerCase() === 'keep') {
38
+ axes[tag] = null;
39
+ continue;
40
+ }
41
+ const secondColon = rest.indexOf(':');
42
+ if (secondColon === -1) {
43
+ // Pin form: tag:value
44
+ const value = parseFiniteNumber(rest);
45
+ if (value === null) {
46
+ throw new Error(`Invalid --axis value "${spec}": "${rest}" is not a finite number`);
47
+ }
48
+ axes[tag] = value;
49
+ }
50
+ else {
51
+ // Range form: tag:min:max
52
+ const minStr = rest.slice(0, secondColon);
53
+ const maxStr = rest.slice(secondColon + 1);
54
+ const min = parseFiniteNumber(minStr);
55
+ const max = parseFiniteNumber(maxStr);
56
+ if (min === null) {
57
+ throw new Error(`Invalid --axis value "${spec}": min "${minStr}" is not a finite number`);
58
+ }
59
+ if (max === null) {
60
+ throw new Error(`Invalid --axis value "${spec}": max "${maxStr}" is not a finite number`);
61
+ }
62
+ if (min > max) {
63
+ throw new Error(`Invalid --axis "${spec}": min (${min}) must not exceed max (${max})`);
64
+ }
65
+ axes[tag] = { min, max };
66
+ }
67
+ }
68
+ return axes;
69
+ }
70
+ /**
71
+ * Parses a string as a finite number, returning `null` for any value that
72
+ * is not a finite, fully-numeric string. Unlike global `isNaN`, this:
73
+ * - rejects the empty string (`Number('')` returns 0)
74
+ * - rejects `'Infinity'` and `'-Infinity'`
75
+ * - rejects whitespace-only or trailing-garbage strings
76
+ */
77
+ function parseFiniteNumber(raw) {
78
+ const trimmed = raw.trim();
79
+ if (trimmed === '')
80
+ return null;
81
+ const n = Number(trimmed);
82
+ if (!Number.isFinite(n))
83
+ return null;
84
+ return n;
85
+ }
@@ -0,0 +1,35 @@
1
+ import type { AxisValue } from '@liiift-studio/vf-clamp';
2
+ import { type Format } from './format.js';
3
+ /** Shape of a single output entry in a config file. */
4
+ export interface ConfigOutput {
5
+ name?: string;
6
+ instances?: string[];
7
+ /** Axis constraints: number to pin, {min,max} to restrict, null to keep full range. */
8
+ axes?: Record<string, AxisValue>;
9
+ }
10
+ /** Validated shape of a config file. */
11
+ export interface ClampConfig {
12
+ format?: Format;
13
+ outputDir?: string;
14
+ outputs: ConfigOutput[];
15
+ }
16
+ /** A resolved output request passed to clampFont. */
17
+ export interface OutputRequest {
18
+ name: string;
19
+ instances?: string[];
20
+ axes?: Record<string, AxisValue>;
21
+ }
22
+ /**
23
+ * Reads and parses a JSON config file from disk, then validates every field
24
+ * before returning a typed object. Throws a descriptive error if the file
25
+ * cannot be read, is not valid JSON, or contains invalid fields.
26
+ *
27
+ * Error messages echo the user-supplied path (not the resolved absolute path)
28
+ * to avoid leaking server-side filesystem layout when invoked from services.
29
+ */
30
+ export declare function readConfig(configPath: string): Promise<ClampConfig>;
31
+ /**
32
+ * Validates a parsed JSON value against the ClampConfig schema.
33
+ * Exported separately so tests can exercise validation without disk I/O.
34
+ */
35
+ export declare function validateConfig(input: unknown): ClampConfig;