@qrdx/cli 0.0.3 → 0.0.5
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 +21 -0
- package/README.md +78 -24
- package/dist/index.js +475 -150
- package/package.json +37 -14
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 Ritesh Bucha and QRdx contributors
|
|
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
CHANGED
|
@@ -1,45 +1,64 @@
|
|
|
1
1
|
# @qrdx/cli
|
|
2
2
|
|
|
3
|
-
QR code generator CLI for developers. Generate styled, scannable QR codes as SVG or
|
|
3
|
+
QR code generator CLI for developers. Generate styled, scannable QR codes as SVG, PNG or vector EPS. It runs non-interactively in scripts and CI, and guides you with prompts when run in a terminal. It renders with the same [`qrdx`](https://www.npmjs.com/package/qrdx) library as the [QRdx](https://qrdx.dev) web app.
|
|
4
|
+
|
|
5
|
+
> [!NOTE]
|
|
6
|
+
> This README documents `@qrdx/cli` 0.0.5, the version in this repository. npm still has 0.0.4, so `npx @qrdx/cli` runs 0.0.4 until 0.0.5 is published. 0.0.4 has the qrdx 0.0.46 styles and no `--format` or `--frame` options, and it writes SVG into a file named `qr.eps`. For EPS today, use the [image API](https://qrdx.dev/docs/developers/image-api): `curl -o qr.eps "https://api.qrdx.dev/v1/qr?data=https://example.com&format=eps"`. For frames, download from the [playground](https://qrdx.dev/playground). Run `npm view @qrdx/cli version` to see what npm installs.
|
|
4
7
|
|
|
5
8
|
## Install
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
# Install the qrdx library package (includes the CLI binary)
|
|
9
|
-
npm install -g qrdx
|
|
10
|
+
Run without installing:
|
|
10
11
|
|
|
11
|
-
|
|
12
|
-
|
|
12
|
+
```bash
|
|
13
|
+
npx @qrdx/cli generate "https://example.com" -o qr.svg
|
|
13
14
|
```
|
|
14
15
|
|
|
15
|
-
Or
|
|
16
|
+
Or install globally, which gives you the `qrdx` command:
|
|
16
17
|
|
|
17
18
|
```bash
|
|
18
|
-
|
|
19
|
+
npm install -g @qrdx/cli
|
|
20
|
+
qrdx generate "https://example.com" -o qr.svg
|
|
19
21
|
```
|
|
20
22
|
|
|
23
|
+
PNG output uses [sharp](https://sharp.pixelplumbing.com). It is an optional dependency that npm installs automatically when your platform supports it; if it is missing, SVG and EPS output still work and the CLI tells you how to fix PNG.
|
|
24
|
+
|
|
25
|
+
> The `qrdx` npm package is the React library. The CLI lives only in `@qrdx/cli`.
|
|
26
|
+
|
|
21
27
|
## Usage
|
|
22
28
|
|
|
23
29
|
```bash
|
|
24
30
|
qrdx generate <data> [options]
|
|
25
31
|
```
|
|
26
32
|
|
|
33
|
+
`gen` and `g` are aliases for `generate`, and `qrdx <data>` works too.
|
|
34
|
+
|
|
27
35
|
**Options**
|
|
28
36
|
|
|
29
37
|
| Flag | Short | Default | Description |
|
|
30
38
|
|------|-------|---------|-------------|
|
|
31
|
-
| `--output` | `-o` |
|
|
32
|
-
| `--
|
|
39
|
+
| `--output` | `-o` | `qr.svg` | Output path; the extension sets the format (`.svg`, `.png` or `.eps`) |
|
|
40
|
+
| `--format` | | from the extension | `svg`, `png` or `eps`, for a file name without an extension |
|
|
41
|
+
| `--size` | | `512` | Output size: pixels for SVG and PNG, points (72 per inch) for EPS |
|
|
33
42
|
| `--level` | `-l` | `Q` | Error correction: `L` `M` `Q` `H` |
|
|
34
43
|
| `--body` | | `square` | Body dot pattern |
|
|
35
44
|
| `--eye` | | `square` | Corner eye pattern |
|
|
36
45
|
| `--dot` | | `square` | Corner dot pattern |
|
|
37
|
-
| `--fg` | | `#000000` | Foreground color |
|
|
38
|
-
| `--bg` | | `#ffffff` | Background color |
|
|
39
|
-
| `--eye-color` | |
|
|
40
|
-
| `--dot-color` | |
|
|
41
|
-
| `--logo` | | — | Center logo URL |
|
|
46
|
+
| `--fg` | | `#000000` | Foreground color (hex or JSON gradient) |
|
|
47
|
+
| `--bg` | | `#ffffff` | Background color (hex or JSON gradient) |
|
|
48
|
+
| `--eye-color` | | matches `--fg` | Corner eye color (hex or JSON gradient) |
|
|
49
|
+
| `--dot-color` | | matches `--fg` | Corner dot color (hex or JSON gradient) |
|
|
50
|
+
| `--logo` | | — | Center logo image URL (use with `--level H`) |
|
|
51
|
+
| `--no-logo` | | — | Skip the logo prompt in interactive mode |
|
|
42
52
|
| `--margin` | | `0` | Quiet zone margin (modules) |
|
|
53
|
+
| `--template` | | — | Decorative template: `Arrow` `StandardBox` `SquareBorder` `StrikedBox` `Halloween` |
|
|
54
|
+
| `--frame` | | — | Call-to-action frame: <!-- catalogue:FRAME_STYLES -->`none`, `banner-bottom`, `banner-top`, `border`, `rounded-border`, `speech-bubble`, `banner-top-bottom`, `speech-bubble-top`, `chat-bubble`, `circle`, `polaroid`, `phone`, `ticket`, `badge`, `envelope`, `tag`, `menu-card`, `sticker`, `gradient-border`, `focus`<!-- /catalogue --> |
|
|
55
|
+
| `--frame-text` | | `Scan me` | Frame label, up to 24 characters (`""` hides it) |
|
|
56
|
+
| `--frame-color` | | `#000000` | Frame color (hex) |
|
|
57
|
+
| `--frame-text-color` | | white on banners | Frame label color (hex) |
|
|
58
|
+
| `--help` | `-h` | | Show all options |
|
|
59
|
+
| `--version` | `-v` | | Show the version |
|
|
60
|
+
|
|
61
|
+
Invalid values (unknown pattern, template, frame, level, size, output extension, malformed gradient JSON, frame text over 24 characters or a non-hex frame color) exit with code `1` and a message listing the accepted values. `--frame-text` or a frame color without `--frame` uses `banner-bottom`. A frame adds space around the code, so the file is larger than `--size`.
|
|
43
62
|
|
|
44
63
|
## Examples
|
|
45
64
|
|
|
@@ -50,6 +69,9 @@ qrdx generate "https://qrdx.dev" -o qr.svg
|
|
|
50
69
|
# High-res PNG for print
|
|
51
70
|
qrdx generate "https://qrdx.dev" -o qr.png --size 2048 --level H
|
|
52
71
|
|
|
72
|
+
# Vector EPS for a print shop or Illustrator, 2 inches wide
|
|
73
|
+
qrdx generate "https://qrdx.dev" -o qr.eps --size 144
|
|
74
|
+
|
|
53
75
|
# Branded style
|
|
54
76
|
qrdx generate "https://qrdx.dev" \
|
|
55
77
|
--body circle --eye rounded \
|
|
@@ -59,7 +81,17 @@ qrdx generate "https://qrdx.dev" \
|
|
|
59
81
|
# With center logo
|
|
60
82
|
qrdx generate "https://qrdx.dev" \
|
|
61
83
|
--logo "https://qrdx.dev/logo.png" --level H \
|
|
62
|
-
-o logo-qr.
|
|
84
|
+
-o logo-qr.png
|
|
85
|
+
|
|
86
|
+
# Gradient foreground
|
|
87
|
+
qrdx generate "https://qrdx.dev" -o gradient.svg \
|
|
88
|
+
--fg '{"type":"linear","stops":[{"color":"#6366f1","offset":0},{"color":"#ec4899","offset":100}],"angle":135}'
|
|
89
|
+
|
|
90
|
+
# Template
|
|
91
|
+
qrdx generate "https://qrdx.dev" --template Arrow -o arrow.svg
|
|
92
|
+
|
|
93
|
+
# "Scan me" frame
|
|
94
|
+
qrdx generate "https://qrdx.dev/menu" --frame banner-bottom --frame-text "Scan for the menu" -o menu.png
|
|
63
95
|
|
|
64
96
|
# Wi-Fi QR
|
|
65
97
|
qrdx generate "WIFI:T:WPA;S:MyNetwork;P:MyPassword;;" -o wifi.svg
|
|
@@ -70,25 +102,47 @@ qrdx generate
|
|
|
70
102
|
|
|
71
103
|
## Patterns
|
|
72
104
|
|
|
73
|
-
**Body** (`--body`):
|
|
105
|
+
**Body** (`--body`): <!-- catalogue:BODY_PATTERN -->`circle`, `circle-large`, `square`, `diamond`, `circle-mixed`, `pacman`, `rounded`, `small-square`, `vertical-line`, `horizontal-line`, `liquid`, `liquid-horizontal`, `liquid-vertical`, `classy`, `classy-rounded`, `leaf`, `plus`, `hexagon`, `star`, `heart`, `random-dots`, `mosaic`, `octagon`<!-- /catalogue -->
|
|
106
|
+
|
|
107
|
+
**Eye** (`--eye`): <!-- catalogue:CORNER_EYE_PATTERNS -->`square`, `rounded`, `gear`, `circle`, `diya`, `extra-rounded`, `message`, `pointy`, `leaf`, `drop`, `octagon`, `bevel`, `squircle`, `cushion`, `shield`, `petal`, `leaf-reverse`<!-- /catalogue -->
|
|
108
|
+
|
|
109
|
+
**Dot** (`--dot`): <!-- catalogue:CORNER_EYE_DOT_PATTERNS -->`square`, `rounded`, `circle`, `message`, `message-reverse`, `diya`, `diya-reverse`, `rounded-triangle`, `leaf`, `drop`, `plus`, `cross-rounded`, `hexagon`, `octagon`, `flower`, `heart`, `squircle`, `dots`, `extra-rounded`, `leaf-reverse`, `drop-reverse`<!-- /catalogue -->
|
|
110
|
+
|
|
111
|
+
See them all at [qrdx.dev/docs/styles](https://qrdx.dev/docs/styles). Every option decodes in our tests; `square` and `rounded` eyes scan most reliably, and `circle` eyes may be missed by strict scanners. Curly eyes and diamond, star and banner dots were retired because they don't scan reliably.
|
|
112
|
+
|
|
113
|
+
## Gradients
|
|
114
|
+
|
|
115
|
+
Pass a JSON object to `--fg`, `--bg`, `--eye-color` or `--dot-color`:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
# Linear (angle in degrees: 0 = left→right, 90 = top→bottom)
|
|
119
|
+
--fg '{"type":"linear","stops":[{"color":"#6366f1","offset":0},{"color":"#ec4899","offset":100}],"angle":135}'
|
|
120
|
+
|
|
121
|
+
# Radial
|
|
122
|
+
--bg '{"type":"radial","stops":[{"color":"#fef9c3","offset":0},{"color":"#fde047","offset":100}]}'
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Each gradient needs at least 2 stops; `offset` is a percentage from 0 to 100.
|
|
74
126
|
|
|
75
|
-
|
|
127
|
+
## EPS
|
|
76
128
|
|
|
77
|
-
|
|
129
|
+
`.eps` files are vector PostScript (EPSF-3.0) for print shops and design tools. `--size` is the width in points, so `--size 144` is 2 inches. Linear and radial gradients print as smooth PostScript Level 3 shadings, and there is no transparency. PNG and JPEG logos are embedded; other logo formats are converted with sharp when it is installed, and otherwise left out with a warning.
|
|
78
130
|
|
|
79
131
|
## Interactive mode
|
|
80
132
|
|
|
81
|
-
When run in a TTY without
|
|
133
|
+
When run in a TTY without an output path or data, `qrdx generate` starts a guided prompt flow: data, output path, then optional customizations (patterns, colors, template, logo, margin). In CI (`CI=true`) or when stdin/stdout is not a TTY it never prompts.
|
|
82
134
|
|
|
83
135
|
## Requirements
|
|
84
136
|
|
|
85
|
-
Node.js ≥
|
|
137
|
+
Node.js ≥ 22
|
|
86
138
|
|
|
87
139
|
## Links
|
|
88
140
|
|
|
89
|
-
- [
|
|
90
|
-
- [
|
|
141
|
+
- [CLI docs](https://qrdx.dev/docs/developers/cli)
|
|
142
|
+
- [qrdx.dev](https://qrdx.dev): the web app, with static and dynamic QR codes, hosted pages and scan analytics
|
|
143
|
+
- [Image API](https://qrdx.dev/docs/developers/image-api) and [MCP server](https://qrdx.dev/docs/developers/mcp): QR codes over HTTP or from AI assistants, with nothing to install
|
|
144
|
+
- [github.com/bucharitesh/qrdx](https://github.com/bucharitesh/qrdx): the source
|
|
91
145
|
|
|
92
146
|
## License
|
|
93
147
|
|
|
94
|
-
MIT
|
|
148
|
+
[MIT](./LICENSE) © Ritesh Bucha and QRdx contributors. Versions before 0.0.5 were published under AGPL-3.0-or-later.
|
package/dist/index.js
CHANGED
|
@@ -1,39 +1,90 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
function _interopNamespace(e) {
|
|
12
|
-
if (e && e.__esModule) return e;
|
|
13
|
-
var n = Object.create(null);
|
|
14
|
-
if (e) {
|
|
15
|
-
Object.keys(e).forEach(function (k) {
|
|
16
|
-
if (k !== 'default') {
|
|
17
|
-
var d = Object.getOwnPropertyDescriptor(e, k);
|
|
18
|
-
Object.defineProperty(n, k, d.get ? d : {
|
|
19
|
-
enumerable: true,
|
|
20
|
-
get: function () { return e[k]; }
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
});
|
|
24
|
-
}
|
|
25
|
-
n.default = e;
|
|
26
|
-
return Object.freeze(n);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
var p__namespace = /*#__PURE__*/_interopNamespace(p);
|
|
2
|
+
import { readFileSync, createReadStream, writeFileSync } from 'fs';
|
|
3
|
+
import { join, extname } from 'path';
|
|
4
|
+
import * as readline from 'readline';
|
|
5
|
+
import * as p from '@clack/prompts';
|
|
6
|
+
import { BODY_PATTERN, CORNER_EYE_PATTERNS, CORNER_EYE_DOT_PATTERNS, getTemplateIds, FRAME_STYLES, DEFAULT_FRAME_TEXT, FRAME_TEXT_MAX_LENGTH, DEFAULT_MARGIN, getFrameDimensions, colorConfigSchema, errorLevelSchema, QRCodeSVG, svgToEPS } from 'qrdx';
|
|
7
|
+
import { readFile } from 'fs/promises';
|
|
8
|
+
import { createElement } from 'react';
|
|
9
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
30
10
|
|
|
11
|
+
var OUTPUT_FORMATS = ["svg", "png", "eps"];
|
|
31
12
|
function buildSVGString(props) {
|
|
32
|
-
const element =
|
|
13
|
+
const element = createElement(QRCodeSVG, {
|
|
33
14
|
size: props.size ?? 512,
|
|
34
15
|
...props
|
|
35
16
|
});
|
|
36
|
-
return
|
|
17
|
+
return renderToStaticMarkup(element);
|
|
18
|
+
}
|
|
19
|
+
async function loadLogo(src) {
|
|
20
|
+
if (src.startsWith("data:")) {
|
|
21
|
+
const comma = src.indexOf(",");
|
|
22
|
+
const payload = src.slice(comma + 1);
|
|
23
|
+
return src.slice(0, comma).endsWith(";base64") ? Buffer.from(payload, "base64") : Buffer.from(decodeURIComponent(payload));
|
|
24
|
+
}
|
|
25
|
+
if (/^https?:\/\//i.test(src)) {
|
|
26
|
+
const res = await fetch(src);
|
|
27
|
+
if (!res.ok) {
|
|
28
|
+
throw new Error(
|
|
29
|
+
`Could not download logo (${res.status} ${res.statusText}): ${src}`
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
return Buffer.from(await res.arrayBuffer());
|
|
33
|
+
}
|
|
34
|
+
return readFile(src);
|
|
35
|
+
}
|
|
36
|
+
async function logoAsEmbeddableDataUrl(src) {
|
|
37
|
+
const bytes = await loadLogo(src);
|
|
38
|
+
if (bytes[0] === 137 && bytes[1] === 80) {
|
|
39
|
+
return `data:image/png;base64,${bytes.toString("base64")}`;
|
|
40
|
+
}
|
|
41
|
+
if (bytes[0] === 255 && bytes[1] === 216) {
|
|
42
|
+
return `data:image/jpeg;base64,${bytes.toString("base64")}`;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
const mod = await import('sharp');
|
|
46
|
+
const sharp = mod.default;
|
|
47
|
+
const png = await sharp(bytes).png().toBuffer();
|
|
48
|
+
return `data:image/png;base64,${png.toString("base64")}`;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
async function buildEPSString(props) {
|
|
54
|
+
const src = props.imageSettings?.src;
|
|
55
|
+
let epsProps = props;
|
|
56
|
+
if (src && props.imageSettings) {
|
|
57
|
+
const dataUrl = await logoAsEmbeddableDataUrl(src);
|
|
58
|
+
if (dataUrl) {
|
|
59
|
+
epsProps = {
|
|
60
|
+
...props,
|
|
61
|
+
imageSettings: { ...props.imageSettings, src: dataUrl }
|
|
62
|
+
};
|
|
63
|
+
} else {
|
|
64
|
+
console.warn(
|
|
65
|
+
"The logo could not be converted for EPS (PNG or JPEG work best), so it was left out."
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return await svgToEPS(buildSVGString(epsProps));
|
|
70
|
+
}
|
|
71
|
+
async function findLogoBox(sharp, svgString) {
|
|
72
|
+
const imageTag = svgString.match(/<image\b[^>]*?(?:\/>|><\/image>)/)?.[0];
|
|
73
|
+
if (!imageTag) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const markerTag = imageTag.replace(/^<image\b/, '<rect class="qrdx-logo-box"').replace(/\shref="[^"]*"/, "").replace(/(?:\/>|><\/image>)$/, ' fill="#000"/>');
|
|
77
|
+
const markerSvg = svgString.replace(imageTag, markerTag).replace(
|
|
78
|
+
/<svg\b[^>]*>/,
|
|
79
|
+
(open) => `${open}<style>*{visibility:hidden}.qrdx-logo-box{visibility:visible}</style>`
|
|
80
|
+
);
|
|
81
|
+
const { info } = await sharp(Buffer.from(markerSvg, "utf-8")).png().trim().toBuffer({ resolveWithObject: true });
|
|
82
|
+
return {
|
|
83
|
+
left: -(info.trimOffsetLeft ?? 0),
|
|
84
|
+
top: -(info.trimOffsetTop ?? 0),
|
|
85
|
+
width: info.width,
|
|
86
|
+
height: info.height
|
|
87
|
+
};
|
|
37
88
|
}
|
|
38
89
|
async function saveOutput(options) {
|
|
39
90
|
const { outputPath, format, ...qrProps } = options;
|
|
@@ -41,13 +92,34 @@ async function saveOutput(options) {
|
|
|
41
92
|
if (format === "svg") {
|
|
42
93
|
const svgContent = `<?xml version="1.0" encoding="UTF-8"?>
|
|
43
94
|
${svgString}`;
|
|
44
|
-
|
|
95
|
+
writeFileSync(outputPath, svgContent, "utf-8");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (format === "eps") {
|
|
99
|
+
writeFileSync(outputPath, await buildEPSString(qrProps), "utf-8");
|
|
45
100
|
return;
|
|
46
101
|
}
|
|
47
|
-
|
|
102
|
+
let sharp;
|
|
103
|
+
try {
|
|
104
|
+
const mod = await import('sharp');
|
|
105
|
+
sharp = mod.default;
|
|
106
|
+
} catch {
|
|
107
|
+
throw new Error(
|
|
108
|
+
"PNG output needs the sharp package (native binaries). It is optional and may not have installed; use SVG or reinstall: https://sharp.pixelplumbing.com/install"
|
|
109
|
+
);
|
|
110
|
+
}
|
|
48
111
|
const svgBuffer = Buffer.from(svgString, "utf-8");
|
|
49
112
|
const size = qrProps.size ?? 512;
|
|
50
|
-
|
|
113
|
+
const { width, height } = getFrameDimensions(size, qrProps.frame);
|
|
114
|
+
const qrImage = sharp(svgBuffer).resize(Math.round(width), Math.round(height)).png();
|
|
115
|
+
const logoSrc = qrProps.imageSettings?.src;
|
|
116
|
+
const box = logoSrc ? await findLogoBox(sharp, svgString) : null;
|
|
117
|
+
if (!(logoSrc && box)) {
|
|
118
|
+
await qrImage.toFile(outputPath);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const logo = await sharp(await loadLogo(logoSrc)).resize(box.width, box.height, { fit: "fill" }).png().toBuffer();
|
|
122
|
+
await sharp(await qrImage.toBuffer()).composite([{ input: logo, left: box.left, top: box.top }]).png().toFile(outputPath);
|
|
51
123
|
}
|
|
52
124
|
|
|
53
125
|
// src/commands/generate.ts
|
|
@@ -55,79 +127,217 @@ var RESET = "\x1B[0m";
|
|
|
55
127
|
var TEXT = "\x1B[38;5;145m";
|
|
56
128
|
var GREEN = "\x1B[38;5;114m";
|
|
57
129
|
var DIM = "\x1B[38;5;102m";
|
|
130
|
+
var HEX_COLOR = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
131
|
+
var MAX_PNG_SIZE = 1e4;
|
|
58
132
|
function parseColorFlag(value) {
|
|
59
133
|
const trimmed = value.trim();
|
|
60
|
-
if (trimmed.startsWith("{")) {
|
|
134
|
+
if (!trimmed.startsWith("{")) {
|
|
135
|
+
return trimmed;
|
|
136
|
+
}
|
|
137
|
+
let parsed;
|
|
138
|
+
try {
|
|
139
|
+
parsed = JSON.parse(trimmed);
|
|
140
|
+
} catch {
|
|
141
|
+
throw new Error(`Invalid gradient JSON: ${trimmed}`);
|
|
142
|
+
}
|
|
143
|
+
const result = colorConfigSchema.safeParse(parsed);
|
|
144
|
+
if (!result.success) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Invalid gradient: ${trimmed}
|
|
147
|
+
Expected {"type":"linear"|"radial"|"angular","stops":[{"color":"#hex","offset":0-100}, ...]} with at least 2 stops.`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
return result.data;
|
|
151
|
+
}
|
|
152
|
+
function assertOneOf(flag, value, allowed) {
|
|
153
|
+
if (value !== void 0 && !allowed.includes(value)) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`Invalid ${flag} "${value}". Expected one of: ${allowed.join(", ")}`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function parseIntegerFlag(flag, value, min) {
|
|
160
|
+
const parsed = Number(value);
|
|
161
|
+
if (!(value && Number.isInteger(parsed) && parsed >= min)) {
|
|
162
|
+
throw new Error(
|
|
163
|
+
`Invalid ${flag} "${value ?? ""}". Expected a whole number \u2265 ${min}.`
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
return parsed;
|
|
167
|
+
}
|
|
168
|
+
function assertHexColor(flag, value) {
|
|
169
|
+
if (value !== void 0 && !HEX_COLOR.test(value)) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`Invalid ${flag} "${value}". Expected a hex color like #000000.`
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function requireValue(flag, value) {
|
|
176
|
+
if (value === void 0 || /^--?[a-z]/i.test(value)) {
|
|
177
|
+
throw new Error(`${flag} needs a value. Run qrdx --help to see options.`);
|
|
178
|
+
}
|
|
179
|
+
return value;
|
|
180
|
+
}
|
|
181
|
+
function frameFromFlags(flags) {
|
|
182
|
+
const style = flags.frame ?? (flags.frameText !== void 0 || flags.frameColor !== void 0 || flags.frameTextColor !== void 0 ? "banner-bottom" : void 0);
|
|
183
|
+
if (!style || style === "none") {
|
|
184
|
+
return void 0;
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
style,
|
|
188
|
+
...flags.frameText !== void 0 && { text: flags.frameText },
|
|
189
|
+
...flags.frameColor && { color: flags.frameColor },
|
|
190
|
+
...flags.frameTextColor && { textColor: flags.frameTextColor }
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
function validateFlags(flags) {
|
|
194
|
+
assertOneOf("--level", flags.level, errorLevelSchema.options);
|
|
195
|
+
assertOneOf("--body", flags.body, BODY_PATTERN);
|
|
196
|
+
assertOneOf("--eye", flags.eye, CORNER_EYE_PATTERNS);
|
|
197
|
+
assertOneOf("--dot", flags.dot, CORNER_EYE_DOT_PATTERNS);
|
|
198
|
+
assertOneOf(
|
|
199
|
+
"--template",
|
|
200
|
+
flags.template,
|
|
201
|
+
getTemplateIds().filter((id) => id !== "default")
|
|
202
|
+
);
|
|
203
|
+
assertOneOf("--frame", flags.frame, FRAME_STYLES);
|
|
204
|
+
if (flags.frameText !== void 0 && flags.frameText.length > FRAME_TEXT_MAX_LENGTH) {
|
|
205
|
+
throw new Error(
|
|
206
|
+
`Invalid --frame-text "${flags.frameText}". Use ${FRAME_TEXT_MAX_LENGTH} characters or fewer.`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
assertHexColor("--frame-color", flags.frameColor);
|
|
210
|
+
assertHexColor("--frame-text-color", flags.frameTextColor);
|
|
211
|
+
assertOneOf("--format", flags.format, OUTPUT_FORMATS);
|
|
212
|
+
if (flags.output !== void 0) {
|
|
213
|
+
const ext = extname(flags.output).toLowerCase();
|
|
214
|
+
if (!["", ".svg", ".png", ".eps"].includes(ext)) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`Unsupported output format "${ext}". Use a .svg, .png or .eps file name.`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
if (ext && flags.format && ext !== `.${flags.format}`) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`--format ${flags.format} does not match the output file "${flags.output}". Use a .${flags.format} file name or drop --format.`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
const isPng = extname(flags.output ?? "").toLowerCase() === ".png" || flags.format === "png";
|
|
226
|
+
if (isPng && flags.size !== void 0 && flags.size > MAX_PNG_SIZE) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
`Invalid --size "${flags.size}". PNG output is at most ${MAX_PNG_SIZE} px; use .svg or .eps for larger prints.`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
var isCI = process.env.CI === "true";
|
|
233
|
+
var canUseClack = process.stdin.isTTY === true && process.stdout.isTTY === true && !isCI;
|
|
234
|
+
function readLineFromTTY(question) {
|
|
235
|
+
if (process.platform === "win32") {
|
|
236
|
+
return Promise.resolve(void 0);
|
|
237
|
+
}
|
|
238
|
+
return new Promise((resolve) => {
|
|
239
|
+
let stream;
|
|
61
240
|
try {
|
|
62
|
-
|
|
241
|
+
stream = createReadStream("/dev/tty", { flags: "r" });
|
|
63
242
|
} catch {
|
|
243
|
+
resolve(void 0);
|
|
244
|
+
return;
|
|
64
245
|
}
|
|
65
|
-
|
|
66
|
-
|
|
246
|
+
const rl = readline.createInterface({
|
|
247
|
+
input: stream,
|
|
248
|
+
output: process.stdout
|
|
249
|
+
});
|
|
250
|
+
const finish = (answer) => {
|
|
251
|
+
rl.close();
|
|
252
|
+
stream.destroy();
|
|
253
|
+
resolve(answer);
|
|
254
|
+
};
|
|
255
|
+
stream.once("error", () => finish(void 0));
|
|
256
|
+
rl.question(question, (answer) => finish(answer));
|
|
257
|
+
});
|
|
67
258
|
}
|
|
68
|
-
var IS_TTY = Boolean(process.stdin.isTTY);
|
|
69
259
|
function parseFlags(args) {
|
|
70
260
|
const flags = {};
|
|
71
261
|
let i = 0;
|
|
72
262
|
while (i < args.length) {
|
|
73
263
|
const arg = args[i];
|
|
74
264
|
if (!arg.startsWith("-")) {
|
|
75
|
-
if (
|
|
76
|
-
|
|
265
|
+
if (flags.value !== void 0) {
|
|
266
|
+
throw new Error(
|
|
267
|
+
`Unexpected argument "${arg}". Quote text with spaces: qrdx generate "${flags.value} ${arg}"`
|
|
268
|
+
);
|
|
77
269
|
}
|
|
270
|
+
flags.value = arg;
|
|
78
271
|
i++;
|
|
79
272
|
continue;
|
|
80
273
|
}
|
|
81
274
|
const next = args[i + 1];
|
|
82
275
|
switch (arg) {
|
|
276
|
+
case "-h":
|
|
277
|
+
case "--help":
|
|
278
|
+
flags.help = true;
|
|
279
|
+
i++;
|
|
280
|
+
break;
|
|
281
|
+
case "-v":
|
|
282
|
+
case "--version":
|
|
283
|
+
flags.version = true;
|
|
284
|
+
i++;
|
|
285
|
+
break;
|
|
83
286
|
case "-o":
|
|
84
287
|
case "--output":
|
|
288
|
+
if (!next || /^--?[a-z]/i.test(next)) {
|
|
289
|
+
throw new Error(`${arg} needs a file path, e.g. ${arg} qr.svg`);
|
|
290
|
+
}
|
|
85
291
|
flags.output = next;
|
|
86
292
|
i += 2;
|
|
87
293
|
break;
|
|
294
|
+
case "--format":
|
|
295
|
+
flags.format = requireValue(arg, next).toLowerCase();
|
|
296
|
+
i += 2;
|
|
297
|
+
break;
|
|
88
298
|
case "-l":
|
|
89
299
|
case "--level":
|
|
90
|
-
flags.level = next;
|
|
300
|
+
flags.level = requireValue(arg, next).toUpperCase();
|
|
91
301
|
i += 2;
|
|
92
302
|
break;
|
|
93
303
|
case "--body":
|
|
94
|
-
flags.body = next;
|
|
304
|
+
flags.body = requireValue(arg, next);
|
|
95
305
|
i += 2;
|
|
96
306
|
break;
|
|
97
307
|
case "--eye":
|
|
98
|
-
flags.eye = next;
|
|
308
|
+
flags.eye = requireValue(arg, next);
|
|
99
309
|
i += 2;
|
|
100
310
|
break;
|
|
101
311
|
case "--dot":
|
|
102
|
-
flags.dot = next;
|
|
312
|
+
flags.dot = requireValue(arg, next);
|
|
103
313
|
i += 2;
|
|
104
314
|
break;
|
|
105
315
|
case "--fg":
|
|
106
|
-
flags.fg =
|
|
316
|
+
flags.fg = parseColorFlag(requireValue(arg, next));
|
|
107
317
|
i += 2;
|
|
108
318
|
break;
|
|
109
319
|
case "--bg":
|
|
110
|
-
flags.bg =
|
|
320
|
+
flags.bg = parseColorFlag(requireValue(arg, next));
|
|
111
321
|
i += 2;
|
|
112
322
|
break;
|
|
113
323
|
case "--eye-color":
|
|
114
|
-
flags.eyeColor =
|
|
324
|
+
flags.eyeColor = parseColorFlag(requireValue(arg, next));
|
|
115
325
|
i += 2;
|
|
116
326
|
break;
|
|
117
327
|
case "--dot-color":
|
|
118
|
-
flags.dotColor =
|
|
328
|
+
flags.dotColor = parseColorFlag(requireValue(arg, next));
|
|
119
329
|
i += 2;
|
|
120
330
|
break;
|
|
121
331
|
case "--logo":
|
|
122
|
-
flags.logo = next;
|
|
332
|
+
flags.logo = requireValue(arg, next);
|
|
123
333
|
i += 2;
|
|
124
334
|
break;
|
|
125
335
|
case "--margin":
|
|
126
|
-
flags.margin =
|
|
336
|
+
flags.margin = parseIntegerFlag(arg, next, 0);
|
|
127
337
|
i += 2;
|
|
128
338
|
break;
|
|
129
339
|
case "--size":
|
|
130
|
-
flags.size =
|
|
340
|
+
flags.size = parseIntegerFlag(arg, next, 1);
|
|
131
341
|
i += 2;
|
|
132
342
|
break;
|
|
133
343
|
case "--no-logo":
|
|
@@ -135,77 +345,143 @@ function parseFlags(args) {
|
|
|
135
345
|
i++;
|
|
136
346
|
break;
|
|
137
347
|
case "--template":
|
|
138
|
-
flags.template = next;
|
|
348
|
+
flags.template = requireValue(arg, next);
|
|
349
|
+
i += 2;
|
|
350
|
+
break;
|
|
351
|
+
case "--frame":
|
|
352
|
+
flags.frame = requireValue(arg, next);
|
|
353
|
+
i += 2;
|
|
354
|
+
break;
|
|
355
|
+
case "--frame-text":
|
|
356
|
+
flags.frameText = requireValue(arg, next);
|
|
357
|
+
i += 2;
|
|
358
|
+
break;
|
|
359
|
+
case "--frame-color":
|
|
360
|
+
flags.frameColor = requireValue(arg, next);
|
|
361
|
+
i += 2;
|
|
362
|
+
break;
|
|
363
|
+
case "--frame-text-color":
|
|
364
|
+
flags.frameTextColor = requireValue(arg, next);
|
|
139
365
|
i += 2;
|
|
140
366
|
break;
|
|
141
367
|
default:
|
|
142
|
-
|
|
368
|
+
throw new Error(
|
|
369
|
+
`Unknown option "${arg}". Run qrdx --help to see all options.`
|
|
370
|
+
);
|
|
143
371
|
}
|
|
144
372
|
}
|
|
373
|
+
if (!(flags.help || flags.version)) validateFlags(flags);
|
|
145
374
|
return flags;
|
|
146
375
|
}
|
|
147
376
|
function isCancel2(value) {
|
|
148
|
-
return
|
|
377
|
+
return p.isCancel(value);
|
|
149
378
|
}
|
|
150
379
|
function handleCancel() {
|
|
151
|
-
|
|
380
|
+
p.cancel("Cancelled.");
|
|
381
|
+
console.error(
|
|
382
|
+
`${DIM}Tip:${RESET} ${TEXT}qrdx generate "https://\u2026" -o qr.svg${RESET} \u2014 local dev: ${TEXT}pnpm qrdx -- generate \u2026${RESET}`
|
|
383
|
+
);
|
|
152
384
|
process.exit(0);
|
|
153
385
|
}
|
|
154
|
-
function inferFormat(outputPath) {
|
|
155
|
-
const ext =
|
|
386
|
+
function inferFormat(outputPath, format) {
|
|
387
|
+
const ext = extname(outputPath).toLowerCase();
|
|
156
388
|
if (ext === ".png") {
|
|
157
389
|
return "png";
|
|
158
390
|
}
|
|
159
|
-
|
|
391
|
+
if (ext === ".eps") {
|
|
392
|
+
return "eps";
|
|
393
|
+
}
|
|
394
|
+
return ext === ".svg" ? "svg" : format ?? "svg";
|
|
160
395
|
}
|
|
161
|
-
async function runGenerate(args) {
|
|
396
|
+
async function runGenerate(args, meta) {
|
|
162
397
|
const flags = parseFlags(args);
|
|
163
|
-
|
|
398
|
+
if (meta && flags.help) return meta.help();
|
|
399
|
+
if (meta && flags.version) return meta.version();
|
|
400
|
+
meta?.start?.();
|
|
401
|
+
const nonInteractive = !canUseClack;
|
|
164
402
|
const hasAdvancedFlags = Boolean(
|
|
165
|
-
flags.body || flags.eye || flags.dot || flags.fg || flags.bg || flags.eyeColor || flags.dotColor || flags.logo || flags.level || flags.margin !== void 0 || flags.template
|
|
403
|
+
flags.body || flags.eye || flags.dot || flags.fg || flags.bg || flags.eyeColor || flags.dotColor || flags.logo || flags.level || flags.margin !== void 0 || flags.template || frameFromFlags(flags)
|
|
166
404
|
);
|
|
167
|
-
|
|
405
|
+
p.intro(`${TEXT}qrdx${RESET} ${DIM}\u2014 QR Code Generator${RESET}`);
|
|
406
|
+
if (canUseClack) {
|
|
407
|
+
process.stdin.resume();
|
|
408
|
+
}
|
|
168
409
|
let value = flags.value;
|
|
169
410
|
if (!value) {
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
411
|
+
if (canUseClack) {
|
|
412
|
+
const promptStart = performance.now();
|
|
413
|
+
const res = await p.text({
|
|
414
|
+
message: "URL or text to encode",
|
|
415
|
+
placeholder: "https://qrdx.dev",
|
|
416
|
+
validate(v) {
|
|
417
|
+
if (!v?.trim()) {
|
|
418
|
+
return "Please enter a value to encode.";
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
if (isCancel2(res)) {
|
|
423
|
+
const instant = performance.now() - promptStart < 120 && process.platform !== "win32" && process.stdout.isTTY && !isCI;
|
|
424
|
+
if (instant) {
|
|
425
|
+
const line = await readLineFromTTY(
|
|
426
|
+
`${TEXT}URL or text to encode${RESET} ${DIM}(e.g. https://qrdx.dev)${RESET}: `
|
|
427
|
+
);
|
|
428
|
+
if (line?.trim()) {
|
|
429
|
+
value = line.trim();
|
|
430
|
+
} else {
|
|
431
|
+
handleCancel();
|
|
432
|
+
}
|
|
433
|
+
} else {
|
|
434
|
+
handleCancel();
|
|
180
435
|
}
|
|
436
|
+
} else {
|
|
437
|
+
value = res;
|
|
181
438
|
}
|
|
182
|
-
})
|
|
183
|
-
|
|
184
|
-
|
|
439
|
+
} else if (process.stdout.isTTY && !isCI) {
|
|
440
|
+
const line = await readLineFromTTY(
|
|
441
|
+
`${TEXT}URL or text to encode${RESET} ${DIM}(e.g. https://qrdx.dev)${RESET}: `
|
|
442
|
+
);
|
|
443
|
+
if (line?.trim()) {
|
|
444
|
+
value = line.trim();
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
if (!value) {
|
|
448
|
+
console.error(
|
|
449
|
+
'No data to encode. Example: npx @qrdx/cli generate "https://example.com" -o qr.svg'
|
|
450
|
+
);
|
|
451
|
+
process.exit(1);
|
|
185
452
|
}
|
|
186
|
-
value = res;
|
|
187
453
|
}
|
|
188
454
|
let outputPath = flags.output;
|
|
189
455
|
const size = flags.size ?? 512;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
456
|
+
const defaultOutput = `qr.${flags.format ?? "svg"}`;
|
|
457
|
+
if (!outputPath) {
|
|
458
|
+
if (canUseClack) {
|
|
459
|
+
const pathRes = await p.text({
|
|
460
|
+
message: "Output file (.svg, .png or .eps)",
|
|
461
|
+
placeholder: defaultOutput,
|
|
462
|
+
defaultValue: defaultOutput,
|
|
463
|
+
validate(v) {
|
|
464
|
+
if (!v?.trim()) {
|
|
465
|
+
return "Please enter an output path.";
|
|
466
|
+
}
|
|
198
467
|
}
|
|
468
|
+
});
|
|
469
|
+
if (isCancel2(pathRes)) {
|
|
470
|
+
handleCancel();
|
|
199
471
|
}
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
|
|
472
|
+
outputPath = pathRes || defaultOutput;
|
|
473
|
+
} else if (process.stdout.isTTY && !isCI) {
|
|
474
|
+
const pathAns = await readLineFromTTY(
|
|
475
|
+
`Output file ${DIM}(.svg, .png or .eps, Enter = ${defaultOutput})${RESET}: `
|
|
476
|
+
);
|
|
477
|
+
outputPath = pathAns?.trim() || defaultOutput;
|
|
478
|
+
} else {
|
|
479
|
+
outputPath = defaultOutput;
|
|
203
480
|
}
|
|
204
|
-
outputPath = pathRes || "qr.svg";
|
|
205
481
|
}
|
|
206
482
|
let wantAdvanced = hasAdvancedFlags;
|
|
207
483
|
if (!(hasAdvancedFlags || nonInteractive)) {
|
|
208
|
-
const res = await
|
|
484
|
+
const res = await p.confirm({
|
|
209
485
|
message: "Advanced customizations?",
|
|
210
486
|
initialValue: false
|
|
211
487
|
});
|
|
@@ -223,11 +499,12 @@ async function runGenerate(args) {
|
|
|
223
499
|
let eyeColor = flags.eyeColor;
|
|
224
500
|
let dotColor = flags.dotColor;
|
|
225
501
|
let logo = flags.logo;
|
|
226
|
-
let margin = flags.margin ??
|
|
502
|
+
let margin = flags.margin ?? DEFAULT_MARGIN;
|
|
227
503
|
let template = flags.template;
|
|
504
|
+
let frame = frameFromFlags(flags);
|
|
228
505
|
if (wantAdvanced && !nonInteractive) {
|
|
229
506
|
if (!flags.level) {
|
|
230
|
-
const res = await
|
|
507
|
+
const res = await p.select({
|
|
231
508
|
message: "Error correction level",
|
|
232
509
|
options: [
|
|
233
510
|
{
|
|
@@ -247,9 +524,9 @@ async function runGenerate(args) {
|
|
|
247
524
|
level = res;
|
|
248
525
|
}
|
|
249
526
|
if (!flags.body) {
|
|
250
|
-
const res = await
|
|
527
|
+
const res = await p.select({
|
|
251
528
|
message: "Body dot pattern",
|
|
252
|
-
options:
|
|
529
|
+
options: BODY_PATTERN.map((b) => ({ value: b, label: b })),
|
|
253
530
|
initialValue: "square"
|
|
254
531
|
});
|
|
255
532
|
if (isCancel2(res)) {
|
|
@@ -258,9 +535,9 @@ async function runGenerate(args) {
|
|
|
258
535
|
body = res;
|
|
259
536
|
}
|
|
260
537
|
if (!flags.eye) {
|
|
261
|
-
const res = await
|
|
538
|
+
const res = await p.select({
|
|
262
539
|
message: "Corner eye pattern",
|
|
263
|
-
options:
|
|
540
|
+
options: CORNER_EYE_PATTERNS.map((e) => ({ value: e, label: e })),
|
|
264
541
|
initialValue: "square"
|
|
265
542
|
});
|
|
266
543
|
if (isCancel2(res)) {
|
|
@@ -269,9 +546,9 @@ async function runGenerate(args) {
|
|
|
269
546
|
eye = res;
|
|
270
547
|
}
|
|
271
548
|
if (!flags.dot) {
|
|
272
|
-
const res = await
|
|
549
|
+
const res = await p.select({
|
|
273
550
|
message: "Corner dot pattern",
|
|
274
|
-
options:
|
|
551
|
+
options: CORNER_EYE_DOT_PATTERNS.map((d) => ({ value: d, label: d })),
|
|
275
552
|
initialValue: "square"
|
|
276
553
|
});
|
|
277
554
|
if (isCancel2(res)) {
|
|
@@ -280,13 +557,13 @@ async function runGenerate(args) {
|
|
|
280
557
|
dot = res;
|
|
281
558
|
}
|
|
282
559
|
if (!flags.fg) {
|
|
283
|
-
const res = await
|
|
560
|
+
const res = await p.text({
|
|
284
561
|
message: `Foreground color ${DIM}(hex or JSON gradient)${RESET}`,
|
|
285
562
|
placeholder: "#000000",
|
|
286
563
|
defaultValue: "#000000",
|
|
287
564
|
validate(v) {
|
|
288
565
|
if (!v) {
|
|
289
|
-
return
|
|
566
|
+
return;
|
|
290
567
|
}
|
|
291
568
|
if (v.trim().startsWith("{")) {
|
|
292
569
|
try {
|
|
@@ -294,7 +571,7 @@ async function runGenerate(args) {
|
|
|
294
571
|
} catch {
|
|
295
572
|
return "Enter a valid hex color or JSON gradient object";
|
|
296
573
|
}
|
|
297
|
-
return
|
|
574
|
+
return;
|
|
298
575
|
}
|
|
299
576
|
if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) {
|
|
300
577
|
return "Enter a valid hex color (e.g. #000000) or JSON gradient";
|
|
@@ -307,13 +584,13 @@ async function runGenerate(args) {
|
|
|
307
584
|
fg = parseColorFlag(res || "#000000");
|
|
308
585
|
}
|
|
309
586
|
if (!flags.bg) {
|
|
310
|
-
const res = await
|
|
587
|
+
const res = await p.text({
|
|
311
588
|
message: `Background color ${DIM}(hex or JSON gradient)${RESET}`,
|
|
312
589
|
placeholder: "#ffffff",
|
|
313
590
|
defaultValue: "#ffffff",
|
|
314
591
|
validate(v) {
|
|
315
592
|
if (!v) {
|
|
316
|
-
return
|
|
593
|
+
return;
|
|
317
594
|
}
|
|
318
595
|
if (v.trim().startsWith("{")) {
|
|
319
596
|
try {
|
|
@@ -321,7 +598,7 @@ async function runGenerate(args) {
|
|
|
321
598
|
} catch {
|
|
322
599
|
return "Enter a valid hex color or JSON gradient object";
|
|
323
600
|
}
|
|
324
|
-
return
|
|
601
|
+
return;
|
|
325
602
|
}
|
|
326
603
|
if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) {
|
|
327
604
|
return "Enter a valid hex color (e.g. #ffffff) or JSON gradient";
|
|
@@ -334,12 +611,12 @@ async function runGenerate(args) {
|
|
|
334
611
|
bg = parseColorFlag(res || "#ffffff");
|
|
335
612
|
}
|
|
336
613
|
if (eyeColor === void 0) {
|
|
337
|
-
const res = await
|
|
614
|
+
const res = await p.text({
|
|
338
615
|
message: `Corner eye color ${DIM}(leave blank to match foreground)${RESET}`,
|
|
339
616
|
placeholder: "leave blank",
|
|
340
617
|
validate(v) {
|
|
341
618
|
if (!v) {
|
|
342
|
-
return
|
|
619
|
+
return;
|
|
343
620
|
}
|
|
344
621
|
if (v.trim().startsWith("{")) {
|
|
345
622
|
try {
|
|
@@ -347,7 +624,7 @@ async function runGenerate(args) {
|
|
|
347
624
|
} catch {
|
|
348
625
|
return "Enter a valid hex color or JSON gradient object";
|
|
349
626
|
}
|
|
350
|
-
return
|
|
627
|
+
return;
|
|
351
628
|
}
|
|
352
629
|
if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) {
|
|
353
630
|
return "Enter a valid hex color or leave blank";
|
|
@@ -361,12 +638,12 @@ async function runGenerate(args) {
|
|
|
361
638
|
eyeColor = eyeVal ? parseColorFlag(eyeVal) : void 0;
|
|
362
639
|
}
|
|
363
640
|
if (dotColor === void 0) {
|
|
364
|
-
const res = await
|
|
641
|
+
const res = await p.text({
|
|
365
642
|
message: `Corner dot color ${DIM}(leave blank to match foreground)${RESET}`,
|
|
366
643
|
placeholder: "leave blank",
|
|
367
644
|
validate(v) {
|
|
368
645
|
if (!v) {
|
|
369
|
-
return
|
|
646
|
+
return;
|
|
370
647
|
}
|
|
371
648
|
if (v.trim().startsWith("{")) {
|
|
372
649
|
try {
|
|
@@ -374,7 +651,7 @@ async function runGenerate(args) {
|
|
|
374
651
|
} catch {
|
|
375
652
|
return "Enter a valid hex color or JSON gradient object";
|
|
376
653
|
}
|
|
377
|
-
return
|
|
654
|
+
return;
|
|
378
655
|
}
|
|
379
656
|
if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) {
|
|
380
657
|
return "Enter a valid hex color or leave blank";
|
|
@@ -388,8 +665,8 @@ async function runGenerate(args) {
|
|
|
388
665
|
dotColor = dotVal ? parseColorFlag(dotVal) : void 0;
|
|
389
666
|
}
|
|
390
667
|
if (!flags.template) {
|
|
391
|
-
const templateIds =
|
|
392
|
-
const res = await
|
|
668
|
+
const templateIds = getTemplateIds();
|
|
669
|
+
const res = await p.select({
|
|
393
670
|
message: `Template ${DIM}(decorative wrapper around the QR code)${RESET}`,
|
|
394
671
|
options: [
|
|
395
672
|
{ value: "", label: "none" },
|
|
@@ -402,8 +679,37 @@ async function runGenerate(args) {
|
|
|
402
679
|
}
|
|
403
680
|
template = res || void 0;
|
|
404
681
|
}
|
|
682
|
+
if (flags.frame === void 0 && !frame) {
|
|
683
|
+
const res = await p.select({
|
|
684
|
+
message: `Frame ${DIM}(call-to-action label around the code)${RESET}`,
|
|
685
|
+
options: FRAME_STYLES.map((style) => ({ value: style, label: style })),
|
|
686
|
+
initialValue: "none"
|
|
687
|
+
});
|
|
688
|
+
if (isCancel2(res)) {
|
|
689
|
+
handleCancel();
|
|
690
|
+
}
|
|
691
|
+
if (res !== "none") {
|
|
692
|
+
const text2 = await p.text({
|
|
693
|
+
message: `Frame text ${DIM}(up to ${FRAME_TEXT_MAX_LENGTH} characters)${RESET}`,
|
|
694
|
+
placeholder: DEFAULT_FRAME_TEXT,
|
|
695
|
+
defaultValue: DEFAULT_FRAME_TEXT,
|
|
696
|
+
validate(v) {
|
|
697
|
+
if (v && v.length > FRAME_TEXT_MAX_LENGTH) {
|
|
698
|
+
return `Use ${FRAME_TEXT_MAX_LENGTH} characters or fewer`;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
if (isCancel2(text2)) {
|
|
703
|
+
handleCancel();
|
|
704
|
+
}
|
|
705
|
+
frame = {
|
|
706
|
+
style: res,
|
|
707
|
+
text: text2 || DEFAULT_FRAME_TEXT
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
}
|
|
405
711
|
if (logo === void 0 && !flags.noLogo) {
|
|
406
|
-
const addLogo = await
|
|
712
|
+
const addLogo = await p.confirm({
|
|
407
713
|
message: "Add a logo to the center?",
|
|
408
714
|
initialValue: false
|
|
409
715
|
});
|
|
@@ -411,16 +717,14 @@ async function runGenerate(args) {
|
|
|
411
717
|
handleCancel();
|
|
412
718
|
}
|
|
413
719
|
if (addLogo) {
|
|
414
|
-
const res = await
|
|
720
|
+
const res = await p.text({
|
|
415
721
|
message: "Logo URL",
|
|
416
722
|
placeholder: "https://qrdx.dev/logo.png",
|
|
417
723
|
validate(v) {
|
|
418
|
-
if (!v
|
|
724
|
+
if (!v?.trim()) {
|
|
419
725
|
return "Please enter a URL for the logo.";
|
|
420
726
|
}
|
|
421
|
-
|
|
422
|
-
new URL(v);
|
|
423
|
-
} catch {
|
|
727
|
+
if (!URL.canParse(v)) {
|
|
424
728
|
return "Please enter a valid URL.";
|
|
425
729
|
}
|
|
426
730
|
}
|
|
@@ -432,10 +736,10 @@ async function runGenerate(args) {
|
|
|
432
736
|
}
|
|
433
737
|
}
|
|
434
738
|
if (flags.margin === void 0) {
|
|
435
|
-
const res = await
|
|
739
|
+
const res = await p.text({
|
|
436
740
|
message: "Quiet zone margin (modules)",
|
|
437
|
-
placeholder: String(
|
|
438
|
-
defaultValue: String(
|
|
741
|
+
placeholder: String(DEFAULT_MARGIN),
|
|
742
|
+
defaultValue: String(DEFAULT_MARGIN),
|
|
439
743
|
validate(v) {
|
|
440
744
|
if (v && Number.isNaN(Number.parseInt(v, 10))) {
|
|
441
745
|
return "Enter a number";
|
|
@@ -448,10 +752,10 @@ async function runGenerate(args) {
|
|
|
448
752
|
if (isCancel2(res)) {
|
|
449
753
|
handleCancel();
|
|
450
754
|
}
|
|
451
|
-
margin = Number.parseInt(res || String(
|
|
755
|
+
margin = Number.parseInt(res || String(DEFAULT_MARGIN), 10);
|
|
452
756
|
}
|
|
453
757
|
}
|
|
454
|
-
const spinner2 =
|
|
758
|
+
const spinner2 = p.spinner();
|
|
455
759
|
spinner2.start("Generating QR code...");
|
|
456
760
|
try {
|
|
457
761
|
const qrProps = {
|
|
@@ -467,6 +771,7 @@ async function runGenerate(args) {
|
|
|
467
771
|
margin,
|
|
468
772
|
size,
|
|
469
773
|
...template ? { templateId: template } : {},
|
|
774
|
+
...frame ? { frame } : {},
|
|
470
775
|
...logo ? {
|
|
471
776
|
imageSettings: {
|
|
472
777
|
src: logo,
|
|
@@ -477,21 +782,22 @@ async function runGenerate(args) {
|
|
|
477
782
|
} : {}
|
|
478
783
|
};
|
|
479
784
|
if (outputPath) {
|
|
480
|
-
const format = inferFormat(outputPath);
|
|
785
|
+
const format = inferFormat(outputPath, flags.format);
|
|
481
786
|
await saveOutput({ ...qrProps, outputPath, format });
|
|
787
|
+
const dimensions = getFrameDimensions(size, frame);
|
|
482
788
|
spinner2.stop(
|
|
483
|
-
`${GREEN}\u2714${RESET} Saved to ${TEXT}${outputPath}${RESET} ${DIM}(${
|
|
789
|
+
`${GREEN}\u2714${RESET} Saved to ${TEXT}${outputPath}${RESET} ${DIM}(${Math.round(dimensions.width)}\xD7${Math.round(dimensions.height)})${RESET}`
|
|
484
790
|
);
|
|
485
791
|
} else {
|
|
486
792
|
spinner2.stop("QR code ready");
|
|
487
793
|
}
|
|
488
794
|
} catch (err) {
|
|
489
|
-
spinner2.
|
|
795
|
+
spinner2.error("Failed to generate QR code");
|
|
490
796
|
const msg = err instanceof Error ? err.message : String(err);
|
|
491
|
-
|
|
797
|
+
p.log.error(msg);
|
|
492
798
|
process.exit(1);
|
|
493
799
|
}
|
|
494
|
-
|
|
800
|
+
p.outro(`${DIM}Done!${RESET}`);
|
|
495
801
|
}
|
|
496
802
|
|
|
497
803
|
// src/index.ts
|
|
@@ -517,8 +823,8 @@ var LOGO_LINES = [
|
|
|
517
823
|
];
|
|
518
824
|
function getVersion() {
|
|
519
825
|
try {
|
|
520
|
-
const pkgPath =
|
|
521
|
-
const pkg = JSON.parse(
|
|
826
|
+
const pkgPath = join(import.meta.dirname, "..", "package.json");
|
|
827
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
522
828
|
return pkg.version;
|
|
523
829
|
} catch {
|
|
524
830
|
return "0.0.1";
|
|
@@ -563,32 +869,44 @@ ${BOLD}Commands:${RESET2}
|
|
|
563
869
|
|
|
564
870
|
${BOLD}Generate Options:${RESET2}
|
|
565
871
|
<data> URL or text to encode (positional)
|
|
566
|
-
-o, --output Output file path (.svg or .
|
|
872
|
+
-o, --output Output file path (.svg, .png or .eps)
|
|
873
|
+
--format Output format when the file name has no extension:
|
|
874
|
+
${DIM2}svg, png, eps${RESET2} ${DIM2}(EPS is vector; --size = width in points)${RESET2}
|
|
567
875
|
-l, --level Error correction: L, M, Q, H ${DIM2}(default: Q)${RESET2}
|
|
568
|
-
--body Body dot pattern
|
|
569
|
-
${DIM2}square, circle, circle-large,
|
|
570
|
-
${DIM2}
|
|
571
|
-
--eye Corner eye pattern
|
|
572
|
-
${DIM2}square, rounded, gear,
|
|
573
|
-
${DIM2}
|
|
574
|
-
--dot Corner dot pattern
|
|
575
|
-
${DIM2}square, rounded, circle,
|
|
576
|
-
${DIM2}
|
|
577
|
-
${DIM2}
|
|
876
|
+
--body Body dot pattern, e.g.:
|
|
877
|
+
${DIM2}square, rounded, circle, circle-large, liquid,${RESET2}
|
|
878
|
+
${DIM2}classy, classy-rounded, leaf, hexagon, star, heart${RESET2}
|
|
879
|
+
--eye Corner eye pattern, e.g.:
|
|
880
|
+
${DIM2}square, rounded, extra-rounded, gear, leaf, drop,${RESET2}
|
|
881
|
+
${DIM2}squircle, octagon, shield, petal${RESET2}
|
|
882
|
+
--dot Corner dot pattern, e.g.:
|
|
883
|
+
${DIM2}square, rounded, circle, leaf, drop, squircle,${RESET2}
|
|
884
|
+
${DIM2}octagon, flower, heart, plus, dots${RESET2}
|
|
885
|
+
${DIM2}All options: https://qrdx.dev/docs/styles${RESET2}
|
|
578
886
|
--fg Foreground color \u2014 hex or JSON gradient ${DIM2}(default: #000000)${RESET2}
|
|
579
887
|
--bg Background color \u2014 hex or JSON gradient ${DIM2}(default: #ffffff)${RESET2}
|
|
580
888
|
--eye-color Corner eye color \u2014 hex or JSON gradient ${DIM2}(default: matches --fg)${RESET2}
|
|
581
889
|
--dot-color Corner dot color \u2014 hex or JSON gradient ${DIM2}(default: matches --fg)${RESET2}
|
|
582
|
-
--logo Logo URL for center image
|
|
890
|
+
--logo Logo URL for center image (use with --level H)
|
|
891
|
+
--no-logo Skip the logo prompt in interactive mode
|
|
583
892
|
--margin Quiet zone margin in modules ${DIM2}(default: 0)${RESET2}
|
|
584
893
|
--size Output pixel size for SVG/PNG ${DIM2}(default: 512)${RESET2}
|
|
585
894
|
--template Decorative template wrapper:
|
|
586
895
|
${DIM2}Arrow, StandardBox, SquareBorder, StrikedBox, Halloween${RESET2}
|
|
896
|
+
--frame Call-to-action frame (adds space around the code), e.g.:
|
|
897
|
+
${DIM2}none, banner-bottom, banner-top, rounded-border,${RESET2}
|
|
898
|
+
${DIM2}speech-bubble, polaroid, phone, ticket, sticker${RESET2}
|
|
899
|
+
${DIM2}All frames: https://qrdx.dev/docs/styles${RESET2}
|
|
900
|
+
--frame-text Frame label, up to 24 characters ${DIM2}(default: Scan me; "" hides it)${RESET2}
|
|
901
|
+
--frame-color Frame color \u2014 hex ${DIM2}(default: #000000)${RESET2}
|
|
902
|
+
--frame-text-color Frame label color \u2014 hex ${DIM2}(default: white on banners)${RESET2}
|
|
903
|
+
${DIM2}Frame text/colors without --frame use banner-bottom${RESET2}
|
|
587
904
|
|
|
588
905
|
${BOLD}Gradient Colors:${RESET2}
|
|
589
906
|
Pass a JSON object to --fg / --bg / --eye-color / --dot-color for gradients:
|
|
590
907
|
${DIM2}Linear: '{"type":"linear","stops":[{"color":"#f00","offset":0},{"color":"#00f","offset":100}],"angle":45}'${RESET2}
|
|
591
908
|
${DIM2}Radial: '{"type":"radial","stops":[{"color":"#f00","offset":0},{"color":"#00f","offset":100}]}'${RESET2}
|
|
909
|
+
${DIM2}Angular: '{"type":"angular","stops":[{"color":"#f00","offset":0},{"color":"#00f","offset":100}],"angle":0}'${RESET2}
|
|
592
910
|
|
|
593
911
|
${BOLD}Global Options:${RESET2}
|
|
594
912
|
--help, -h Show this help message
|
|
@@ -598,11 +916,13 @@ ${BOLD}Examples:${RESET2}
|
|
|
598
916
|
${DIM2}$${RESET2} qrdx generate
|
|
599
917
|
${DIM2}$${RESET2} qrdx generate "https://qrdx.dev"
|
|
600
918
|
${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" -o qr.svg
|
|
601
|
-
${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" -o qr.png --body circle --eye gear --dot
|
|
919
|
+
${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" -o qr.png --body circle --eye gear --dot flower
|
|
920
|
+
${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" -o qr.eps --size 144
|
|
602
921
|
${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" --fg "#ff0000" --bg "#ffffff" --size 1024
|
|
603
922
|
${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" --logo "https://qrdx.dev/logo.png" --level H -o branded.svg
|
|
604
923
|
${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" --template Arrow -o arrow.svg
|
|
605
924
|
${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" --template Halloween --body circle --eye gear -o halloween.svg
|
|
925
|
+
${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" --frame banner-bottom --frame-text "Scan for the menu" -o menu.png
|
|
606
926
|
${DIM2}$${RESET2} qrdx generate "https://qrdx.dev" --fg '{"type":"linear","stops":[{"color":"#6366f1","offset":0},{"color":"#ec4899","offset":100}],"angle":135}' -o gradient.svg
|
|
607
927
|
|
|
608
928
|
Visit ${TEXT2}https://qrdx.dev${RESET2} for more.
|
|
@@ -616,13 +936,19 @@ async function main() {
|
|
|
616
936
|
}
|
|
617
937
|
const command = args[0];
|
|
618
938
|
const restArgs = args.slice(1);
|
|
939
|
+
const generateMeta = {
|
|
940
|
+
help: showHelp,
|
|
941
|
+
version: () => console.log(VERSION),
|
|
942
|
+
start: () => {
|
|
943
|
+
showLogo();
|
|
944
|
+
console.log();
|
|
945
|
+
}
|
|
946
|
+
};
|
|
619
947
|
switch (command) {
|
|
620
948
|
case "generate":
|
|
621
949
|
case "gen":
|
|
622
950
|
case "g":
|
|
623
|
-
|
|
624
|
-
console.log();
|
|
625
|
-
await runGenerate(restArgs);
|
|
951
|
+
await runGenerate(restArgs, generateMeta);
|
|
626
952
|
break;
|
|
627
953
|
case "--help":
|
|
628
954
|
case "-h":
|
|
@@ -635,12 +961,11 @@ async function main() {
|
|
|
635
961
|
break;
|
|
636
962
|
default:
|
|
637
963
|
if (command.startsWith("-")) {
|
|
638
|
-
console.
|
|
639
|
-
console.
|
|
964
|
+
console.error(`Unknown command: ${command}`);
|
|
965
|
+
console.error(`Run ${BOLD}qrdx --help${RESET2} for usage.`);
|
|
966
|
+
process.exitCode = 1;
|
|
640
967
|
} else {
|
|
641
|
-
|
|
642
|
-
console.log();
|
|
643
|
-
await runGenerate(args);
|
|
968
|
+
await runGenerate(args, generateMeta);
|
|
644
969
|
}
|
|
645
970
|
}
|
|
646
971
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@qrdx/cli",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "QR code generator CLI for developers",
|
|
3
|
+
"version": "0.0.5",
|
|
4
|
+
"description": "QR code generator CLI for developers: styled SVG, PNG and vector EPS QR codes from the command line",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"qr-code",
|
|
7
|
+
"qrcode",
|
|
8
|
+
"qr-code-generator",
|
|
9
|
+
"cli",
|
|
10
|
+
"svg",
|
|
11
|
+
"png",
|
|
12
|
+
"eps",
|
|
13
|
+
"qr-code-cli"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://qrdx.dev/docs",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/bucharitesh/qrdx/issues"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/bucharitesh/qrdx.git",
|
|
22
|
+
"directory": "packages/cli"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"author": "Ritesh Bucha",
|
|
26
|
+
"type": "module",
|
|
5
27
|
"bin": {
|
|
6
28
|
"qrdx": "./dist/index.js"
|
|
7
29
|
},
|
|
@@ -10,31 +32,32 @@
|
|
|
10
32
|
"README.md"
|
|
11
33
|
],
|
|
12
34
|
"dependencies": {
|
|
13
|
-
"@clack/prompts": "^
|
|
35
|
+
"@clack/prompts": "^1.8.1",
|
|
14
36
|
"picocolors": "^1.1.1",
|
|
15
|
-
"react": "
|
|
16
|
-
"react-dom": "
|
|
17
|
-
"qrdx": "0.0.
|
|
37
|
+
"react": "19.3.1",
|
|
38
|
+
"react-dom": "19.3.1",
|
|
39
|
+
"qrdx": "0.0.47"
|
|
18
40
|
},
|
|
19
41
|
"optionalDependencies": {
|
|
20
|
-
"sharp": "^0.
|
|
42
|
+
"sharp": "^0.35.5"
|
|
21
43
|
},
|
|
22
44
|
"devDependencies": {
|
|
23
|
-
"@types/node": "^
|
|
24
|
-
"@types/react": "19.
|
|
25
|
-
"@types/react-dom": "19.
|
|
26
|
-
"tsup": "^
|
|
27
|
-
"typescript": "
|
|
28
|
-
"vitest": "^
|
|
45
|
+
"@types/node": "^26.6.6",
|
|
46
|
+
"@types/react": "19.3.0",
|
|
47
|
+
"@types/react-dom": "19.3.0",
|
|
48
|
+
"tsup": "^9.0.0",
|
|
49
|
+
"typescript": "6.0.3",
|
|
50
|
+
"vitest": "^5.0.5",
|
|
29
51
|
"@repo/typescript-config": "0.0.0"
|
|
30
52
|
},
|
|
31
53
|
"engines": {
|
|
32
|
-
"node": ">=
|
|
54
|
+
"node": ">=22"
|
|
33
55
|
},
|
|
34
56
|
"publishConfig": {
|
|
35
57
|
"access": "public"
|
|
36
58
|
},
|
|
37
59
|
"scripts": {
|
|
60
|
+
"qrdx": "node ./dist/index.js",
|
|
38
61
|
"build": "tsup",
|
|
39
62
|
"dev": "tsup --watch",
|
|
40
63
|
"test": "vitest run",
|