@caddy-industries/react-zpl-cli 0.0.0-stage → 0.2.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -2
  3. package/dist/index.js +1174 -0
  4. package/package.json +49 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Caddy Industries
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,3 +1,10 @@
1
- # Temporary Holding Version
1
+ # react-zpl-cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Live preview (`react-zpl dev`) and export (`react-zpl export`) for [react-zpl](../react-zpl) labels.
4
+
5
+ ```sh
6
+ npx react-zpl dev [--dir labels] [--port 3000]
7
+ npx react-zpl export [--dir labels] [--out out] [--dpi 203] [--margin 1mm] [--svg] [--template]
8
+ ```
9
+
10
+ See the [react-zpl README](../react-zpl#preview-while-you-design) for what the viewer does.
package/dist/index.js ADDED
@@ -0,0 +1,1174 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { mkdirSync, writeFileSync } from "fs";
5
+ import { dirname, join as join2, relative as relative2, resolve as resolve2 } from "path";
6
+ import { parseArgs } from "util";
7
+
8
+ // src/loader.ts
9
+ import { readFileSync, readdirSync, statSync } from "fs";
10
+ import { createRequire } from "module";
11
+ import { join, relative, resolve, sep } from "path";
12
+ import { pathToFileURL } from "url";
13
+ import { createServer } from "vite";
14
+
15
+ // src/props.ts
16
+ var isPlainObject = (v) => typeof v === "object" && v !== null && Object.getPrototypeOf(v) === Object.prototype;
17
+ function marker(v) {
18
+ if (v instanceof Uint8Array) return `[binary ${v.byteLength} bytes]`;
19
+ if (typeof v === "function") return "[function]";
20
+ if (v instanceof Date) return v.toISOString();
21
+ if (typeof v === "bigint") return `${v}n`;
22
+ return void 0;
23
+ }
24
+ function toJsonSafe(v) {
25
+ const m = marker(v);
26
+ if (m !== void 0) return m;
27
+ if (Array.isArray(v)) return v.map(toJsonSafe);
28
+ if (isPlainObject(v)) {
29
+ return Object.fromEntries(Object.entries(v).filter(([, x]) => x !== void 0).map(([k, x]) => [k, toJsonSafe(x)]));
30
+ }
31
+ if (typeof v === "object" && v !== null) return String(v);
32
+ return v;
33
+ }
34
+ function restoreValues(edited, original) {
35
+ const m = marker(original);
36
+ if (m !== void 0 && edited === m) return original;
37
+ if (Array.isArray(edited)) return edited.map((x, i) => restoreValues(x, Array.isArray(original) ? original[i] : void 0));
38
+ if (isPlainObject(edited)) {
39
+ const src = isPlainObject(original) ? original : {};
40
+ return Object.fromEntries(Object.entries(edited).map(([k, x]) => [k, restoreValues(x, src[k])]));
41
+ }
42
+ return edited;
43
+ }
44
+ function deepMerge(base, over) {
45
+ if (isPlainObject(base) && isPlainObject(over)) {
46
+ const out = { ...base };
47
+ for (const [k, v] of Object.entries(over)) out[k] = v === void 0 ? void 0 : deepMerge(base[k], v);
48
+ return out;
49
+ }
50
+ return over === void 0 ? base : over;
51
+ }
52
+ function stringPaths(v, prefix = "") {
53
+ if (typeof v === "string") return prefix ? [prefix] : [];
54
+ if (Array.isArray(v)) return v.flatMap((x, i) => stringPaths(x, prefix ? `${prefix}.${i}` : String(i)));
55
+ if (isPlainObject(v)) return Object.entries(v).flatMap(([k, x]) => stringPaths(x, prefix ? `${prefix}.${k}` : k));
56
+ return [];
57
+ }
58
+ function withPlaceholders(v, paths, prefix = "") {
59
+ if (typeof v === "string") return paths.has(prefix) ? `{{${prefix}}}` : v;
60
+ if (Array.isArray(v)) return v.map((x, i) => withPlaceholders(x, paths, prefix ? `${prefix}.${i}` : String(i)));
61
+ if (isPlainObject(v)) {
62
+ return Object.fromEntries(
63
+ Object.entries(v).map(([k, x]) => [k, withPlaceholders(x, paths, prefix ? `${prefix}.${k}` : k)])
64
+ );
65
+ }
66
+ return v;
67
+ }
68
+
69
+ // src/loader.ts
70
+ var LABEL_FILE = /\.(tsx|jsx)$/;
71
+ function findLabels(dir) {
72
+ const out = [];
73
+ const walk = (d) => {
74
+ for (const name of readdirSync(d).sort()) {
75
+ if (name.startsWith("_") || name.startsWith(".") || name === "node_modules") continue;
76
+ const full = join(d, name);
77
+ if (statSync(full).isDirectory()) walk(full);
78
+ else if (LABEL_FILE.test(name)) {
79
+ const id = relative(dir, full).split(sep).join("/");
80
+ out.push({ id, name: id.replace(LABEL_FILE, "") });
81
+ }
82
+ }
83
+ };
84
+ walk(dir);
85
+ return out;
86
+ }
87
+ var LabelLoader = class _LabelLoader {
88
+ constructor(vite, dir, zpl, react) {
89
+ this.vite = vite;
90
+ this.dir = dir;
91
+ this.zpl = zpl;
92
+ this.react = react;
93
+ }
94
+ vite;
95
+ dir;
96
+ zpl;
97
+ react;
98
+ static async create(root, dir, watch) {
99
+ const vite = await createServer({
100
+ root,
101
+ configFile: false,
102
+ logLevel: "error",
103
+ appType: "custom",
104
+ server: { middlewareMode: true, hmr: false, ws: false, watch: watch ? {} : null },
105
+ oxc: { jsx: { runtime: "automatic" } },
106
+ optimizeDeps: { noDiscovery: true, include: [] }
107
+ });
108
+ const require2 = createRequire(join(root, "package.json"));
109
+ const zpl = await import(pathToFileURL(require2.resolve("@caddy-industries/react-zpl")).href);
110
+ const react = await import(pathToFileURL(require2.resolve("react")).href);
111
+ return new _LabelLoader(vite, resolve(root, dir), zpl, react);
112
+ }
113
+ list() {
114
+ return findLabels(this.dir);
115
+ }
116
+ invalidate() {
117
+ this.vite.moduleGraph.invalidateAll();
118
+ }
119
+ file(id) {
120
+ const file = resolve(this.dir, id);
121
+ if (!file.startsWith(this.dir + sep)) throw new Error("Label must be inside the labels folder.");
122
+ return file;
123
+ }
124
+ /** The label's source code, for the viewer's React tab. */
125
+ source(id) {
126
+ try {
127
+ return readFileSync(this.file(id), "utf8");
128
+ } catch {
129
+ return "";
130
+ }
131
+ }
132
+ async component(id) {
133
+ const mod = await this.vite.ssrLoadModule(this.file(id));
134
+ const Component = mod.default;
135
+ if (typeof Component !== "function") {
136
+ throw new Error(`${id} needs a default export: a component that returns a <Label>.`);
137
+ }
138
+ const variants = Component.PreviewVariants ?? {};
139
+ return { Component, previewProps: Component.PreviewProps ?? {}, variants };
140
+ }
141
+ /** The props a label renders with: PreviewProps, a variant merged over it, or edited JSON. */
142
+ async resolve(id, opts) {
143
+ const { Component, previewProps, variants } = await this.component(id);
144
+ let base = previewProps;
145
+ if (opts.variant) {
146
+ if (!(opts.variant in variants)) throw new Error(`${id} has no preview variant named "${opts.variant}".`);
147
+ base = deepMerge(previewProps, variants[opts.variant]);
148
+ }
149
+ const props = opts.props === void 0 ? base : restoreValues(opts.props, base);
150
+ return { Component, props, base, variants: Object.keys(variants) };
151
+ }
152
+ async element(id, opts = {}) {
153
+ const { Component, props } = await this.resolve(id, opts);
154
+ return this.react.createElement(Component, props);
155
+ }
156
+ async render(id, opts = {}) {
157
+ const { Component, props, base, variants } = await this.resolve(id, opts);
158
+ const draw = (p) => this.zpl.renderLabel(this.react.createElement(Component, p), { dpi: opts.dpi, printMargin: opts.printMargin });
159
+ let result;
160
+ let template;
161
+ if (opts.template) {
162
+ const all = stringPaths(props);
163
+ const used = new Set(all);
164
+ const attempt = await draw(withPlaceholders(props, used)).catch(() => void 0);
165
+ if (attempt) result = attempt;
166
+ else {
167
+ used.clear();
168
+ result = await draw(props);
169
+ for (const path of all) {
170
+ used.add(path);
171
+ const r = await draw(withPlaceholders(props, used)).catch(() => void 0);
172
+ if (r) result = r;
173
+ else used.delete(path);
174
+ }
175
+ }
176
+ template = { fields: all.filter((p) => used.has(p)), kept: all.filter((p) => !used.has(p)) };
177
+ } else {
178
+ result = await draw(props);
179
+ }
180
+ const preview = this.zpl.previewZpl(result.zpl, { dpi: result.setup.dpi });
181
+ const { ops: _ops, ...rest } = result;
182
+ return {
183
+ ...rest,
184
+ svg: preview.svg,
185
+ previewWarnings: preview.warnings,
186
+ props: toJsonSafe(props),
187
+ defaultProps: toJsonSafe(base),
188
+ variants,
189
+ template
190
+ };
191
+ }
192
+ close() {
193
+ return this.vite.close();
194
+ }
195
+ };
196
+
197
+ // src/server.ts
198
+ import { createServer as createServer2 } from "http";
199
+ import { Socket } from "net";
200
+
201
+ // src/ui.html
202
+ var ui_default = `<!doctype html>
203
+ <html lang="en">
204
+ <head>
205
+ <meta charset="utf-8">
206
+ <meta name="viewport" content="width=device-width, initial-scale=1">
207
+ <title>react-zpl preview</title>
208
+ <style>
209
+ :root {
210
+ --bg: #f4f4f2;
211
+ --panel: #ffffff;
212
+ --border: #e3e3df;
213
+ --text: #1c1c1a;
214
+ --muted: #6e6e68;
215
+ --hover: #ececea;
216
+ --active: #1c1c1a;
217
+ --active-text: #ffffff;
218
+ --accent: #e5484d;
219
+ --warn-bg: #fff7e0;
220
+ --warn-text: #7a5300;
221
+ --error-bg: #fff0f0;
222
+ --error-text: #a3161c;
223
+ --code-bg: #fafaf8;
224
+ --canvas: #e9e9e5;
225
+ --shadow: 0 1px 2px rgba(0,0,0,.08), 0 8px 24px rgba(0,0,0,.08);
226
+ color-scheme: light;
227
+ }
228
+ @media (prefers-color-scheme: dark) {
229
+ :root {
230
+ --bg: #151514;
231
+ --panel: #1c1c1b;
232
+ --border: #2e2e2c;
233
+ --text: #ececea;
234
+ --muted: #9a9a94;
235
+ --hover: #262625;
236
+ --active: #ececea;
237
+ --active-text: #151514;
238
+ --warn-bg: #2d2610;
239
+ --warn-text: #f1c75b;
240
+ --error-bg: #2e1516;
241
+ --error-text: #ff9592;
242
+ --code-bg: #181817;
243
+ --canvas: #0f0f0e;
244
+ --shadow: 0 1px 2px rgba(0,0,0,.4), 0 8px 24px rgba(0,0,0,.4);
245
+ color-scheme: dark;
246
+ }
247
+ }
248
+ * { box-sizing: border-box; }
249
+ [hidden] { display: none !important; }
250
+ html, body { height: 100%; margin: 0; }
251
+ body {
252
+ background: var(--bg);
253
+ color: var(--text);
254
+ font: 13px/1.45 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
255
+ -webkit-font-smoothing: antialiased;
256
+ display: grid;
257
+ grid-template-columns: 240px 1fr 400px;
258
+ grid-template-rows: 100vh;
259
+ }
260
+ button, input, select { font: inherit; color: inherit; }
261
+ code, pre, .mono { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; }
262
+
263
+ /* Sidebar */
264
+ aside {
265
+ background: var(--panel);
266
+ border-right: 1px solid var(--border);
267
+ display: flex;
268
+ flex-direction: column;
269
+ min-height: 0;
270
+ }
271
+ .brand {
272
+ display: flex; align-items: center; gap: 8px;
273
+ padding: 14px 16px; font-weight: 600; letter-spacing: -.01em;
274
+ border-bottom: 1px solid var(--border);
275
+ }
276
+ .brand svg { flex: none; }
277
+ .live { margin-left: auto; display: flex; align-items: center; gap: 6px; font-weight: 400; color: var(--muted); font-size: 12px; }
278
+ .live i { width: 7px; height: 7px; border-radius: 50%; background: #30a46c; }
279
+ .live.off i { background: var(--muted); }
280
+ .search { padding: 10px 12px 6px; }
281
+ .search input {
282
+ width: 100%; padding: 6px 10px; border-radius: 6px;
283
+ border: 1px solid var(--border); background: var(--bg); outline: none;
284
+ }
285
+ .search input:focus { border-color: var(--muted); }
286
+ nav { overflow: auto; padding: 4px 8px 12px; flex: 1; }
287
+ nav a {
288
+ display: block; padding: 6px 10px; border-radius: 6px; color: inherit;
289
+ text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
290
+ }
291
+ nav a:hover { background: var(--hover); }
292
+ nav a[aria-current="true"] { background: var(--active); color: var(--active-text); }
293
+ nav .empty { color: var(--muted); padding: 8px 10px; }
294
+ nav details > summary {
295
+ list-style: none; cursor: pointer; padding: 6px 10px; border-radius: 6px; color: var(--muted);
296
+ display: flex; align-items: center; gap: 6px; user-select: none;
297
+ }
298
+ nav details > summary::-webkit-details-marker { display: none; }
299
+ nav details > summary::before { content: ""; width: 0; height: 0; border: 4px solid transparent; border-left-color: currentColor; transform-origin: 2px 4px; transition: transform .12s; margin-right: -2px; }
300
+ nav details[open] > summary::before { transform: rotate(90deg); }
301
+ nav details > summary:hover { background: var(--hover); color: var(--text); }
302
+ nav details > .children { margin-left: 12px; padding-left: 6px; border-left: 1px solid var(--border); }
303
+ nav a .dir { color: var(--muted); }
304
+ nav a[aria-current="true"] .dir { color: inherit; opacity: .7; }
305
+
306
+ /* Main */
307
+ main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
308
+ .titlebar {
309
+ display: flex; align-items: center; gap: 10px; min-height: 45px;
310
+ padding: 8px 16px; border-bottom: 1px solid var(--border); background: var(--panel);
311
+ }
312
+ .toolbar {
313
+ display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
314
+ padding: 8px 16px; border-bottom: 1px solid var(--border); background: var(--panel);
315
+ }
316
+ .chip { font-size: 11px; padding: 1px 7px; border-radius: 999px; background: var(--hover); color: var(--muted); }
317
+ .chip.accent { background: var(--warn-bg); color: var(--warn-text); }
318
+ .select {
319
+ padding: 3px 8px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg);
320
+ max-width: 180px; font-size: 12px; outline: none;
321
+ }
322
+ .title { font-weight: 600; }
323
+ .meta { color: var(--muted); font-variant-numeric: tabular-nums; }
324
+ .spacer { flex: 1; }
325
+ .seg { display: inline-flex; border: 1px solid var(--border); border-radius: 7px; padding: 2px; gap: 2px; }
326
+ .seg button {
327
+ border: 0; background: none; padding: 3px 8px; border-radius: 5px; cursor: pointer; color: var(--muted);
328
+ font-variant-numeric: tabular-nums;
329
+ }
330
+ .seg button:hover { color: var(--text); }
331
+ .seg button[aria-pressed="true"] { background: var(--hover); color: var(--text); }
332
+ .toggle { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); cursor: pointer; user-select: none; }
333
+ .toggle input { accent-color: var(--accent); margin: 0; }
334
+ .margin-input {
335
+ width: 64px; padding: 3px 8px; border-radius: 6px; border: 1px solid var(--border);
336
+ background: var(--bg); outline: none; font-size: 12px;
337
+ }
338
+ .margin-input:focus { border-color: var(--muted); }
339
+ .canvas {
340
+ flex: 1; overflow: auto; background: var(--canvas);
341
+ display: grid; place-items: center; padding: 32px; min-height: 0;
342
+ }
343
+ .paper { position: relative; background: #fff; box-shadow: var(--shadow); border-radius: 3px; overflow: hidden; line-height: 0; }
344
+ .paper svg { display: block; width: 100%; height: 100%; }
345
+ .paper .overlay { position: absolute; inset: 0; pointer-events: none; }
346
+ .error {
347
+ max-width: 720px; width: 100%; background: var(--error-bg); color: var(--error-text);
348
+ border-radius: 8px; padding: 16px 18px; line-height: 1.5;
349
+ }
350
+ .error strong { display: block; margin-bottom: 6px; }
351
+ .error pre { margin: 10px 0 0; white-space: pre-wrap; font-size: 12px; opacity: .85; }
352
+ .placeholder { color: var(--muted); text-align: center; line-height: 1.6; }
353
+ .placeholder code { background: var(--panel); padding: 1px 5px; border-radius: 4px; }
354
+
355
+ /* Right panel */
356
+ section.side {
357
+ background: var(--panel); border-left: 1px solid var(--border);
358
+ display: flex; flex-direction: column; min-height: 0;
359
+ }
360
+ .side header { display: flex; align-items: center; gap: 6px; padding: 10px 12px; border-bottom: 1px solid var(--border); }
361
+ .side header .label { font-weight: 600; margin-right: auto; padding-left: 4px; }
362
+ .btn {
363
+ border: 1px solid var(--border); background: var(--panel); border-radius: 6px;
364
+ padding: 4px 10px; cursor: pointer; transition: background .12s;
365
+ }
366
+ .btn:hover { background: var(--hover); }
367
+ .btn:active { transform: translateY(.5px); }
368
+ .btn.primary { background: var(--active); color: var(--active-text); border-color: var(--active); }
369
+ .infobar { padding: 8px 14px; background: var(--code-bg); color: var(--muted); border-bottom: 1px solid var(--border); line-height: 1.5; }
370
+ .infobar code { color: var(--text); }
371
+ .infobar .err { color: var(--error-text); }
372
+ .tabs button .count { display: inline-block; min-width: 16px; padding: 0 4px; margin-left: 4px; border-radius: 999px; background: var(--warn-bg); color: var(--warn-text); font-size: 11px; line-height: 16px; text-align: center; }
373
+ .pane { flex: 1; min-height: 0; overflow: auto; }
374
+ textarea.props {
375
+ width: 100%; height: 100%; border: 0; resize: none; outline: none; display: block;
376
+ padding: 12px 14px; background: var(--code-bg); font: 12px/1.6 ui-monospace, "SF Mono", Menlo, Consolas, monospace; tab-size: 2;
377
+ }
378
+ .checks { list-style: none; margin: 0; padding: 8px 0; }
379
+ .checks li { display: flex; gap: 10px; padding: 8px 14px; line-height: 1.45; }
380
+ .checks li + li { border-top: 1px solid var(--border); }
381
+ .checks .icon { flex: none; width: 18px; height: 18px; border-radius: 50%; display: grid; place-items: center; font-size: 11px; font-weight: 700; margin-top: 1px; }
382
+ .checks .warn .icon { background: var(--warn-bg); color: var(--warn-text); }
383
+ .checks .ok .icon { background: #e6f6ec; color: #18794e; }
384
+ .checks .info .icon { background: var(--hover); color: var(--muted); }
385
+ @media (prefers-color-scheme: dark) { .checks .ok .icon { background: #132d21; color: #4cc38a; } }
386
+ .checks small { display: block; color: var(--muted); }
387
+ .zpl .p { background: var(--warn-bg); color: var(--warn-text); border-radius: 3px; }
388
+ pre.zpl {
389
+ margin: 0; flex: 1; min-height: 0; overflow: auto; padding: 12px 14px; background: var(--code-bg);
390
+ font-size: 12px; line-height: 1.6; white-space: pre; tab-size: 2;
391
+ }
392
+ .tabs { margin-right: 4px; }
393
+ .zpl .c { color: #0b6bcb; font-weight: 600; }
394
+ .zpl .k { color: #a626a4; }
395
+ .zpl .s { color: #1a7f37; }
396
+ .zpl .t { color: #0b6bcb; }
397
+ .zpl .a { color: #b35900; }
398
+ .zpl .n { color: #986801; }
399
+ .zpl .m { color: var(--muted); font-style: italic; }
400
+ @media (prefers-color-scheme: dark) {
401
+ .zpl .k { color: #d7a0ff; } .zpl .s { color: #7ee2a8; } .zpl .t { color: #7cb7ff; }
402
+ .zpl .a { color: #ffb86b; } .zpl .n { color: #f1c75b; }
403
+ }
404
+ .zpl .d { color: #1a7f37; }
405
+ @media (prefers-color-scheme: dark) { .zpl .c { color: #7cb7ff; } .zpl .d { color: #7ee2a8; } }
406
+ .size-note { color: var(--muted); padding: 8px 14px; border-top: 1px solid var(--border); font-variant-numeric: tabular-nums; }
407
+
408
+ dialog {
409
+ border: 1px solid var(--border); border-radius: 10px; background: var(--panel); color: var(--text);
410
+ padding: 18px; width: 340px; box-shadow: var(--shadow);
411
+ }
412
+ dialog::backdrop { background: rgba(0,0,0,.3); }
413
+ dialog h2 { margin: 0 0 4px; font-size: 15px; }
414
+ dialog p { margin: 0 0 14px; color: var(--muted); }
415
+ dialog label { display: block; margin-bottom: 10px; color: var(--muted); }
416
+ dialog input { display: block; width: 100%; margin-top: 4px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); }
417
+ dialog .row { display: flex; gap: 8px; justify-content: flex-end; margin-top: 16px; }
418
+ dialog .status { min-height: 1.4em; margin: 4px 0 0; }
419
+ .toast {
420
+ position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%) translateY(8px);
421
+ background: var(--active); color: var(--active-text); padding: 7px 14px; border-radius: 8px;
422
+ opacity: 0; transition: opacity .15s, transform .15s; pointer-events: none;
423
+ }
424
+ .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
425
+
426
+ @media (max-width: 1000px) {
427
+ body { grid-template-columns: 200px 1fr; grid-template-rows: 100vh; }
428
+ section.side { display: none; }
429
+ }
430
+ </style>
431
+ </head>
432
+ <body>
433
+ <aside>
434
+ <div class="brand">
435
+ <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><rect x="1" y="1" width="16" height="16" rx="3" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M5 5v8M7.5 5v8M9 5v8M12 5v8M13.5 5v8" stroke="currentColor" stroke-width="1.2"/></svg>
436
+ react-zpl
437
+ <span class="live off" id="live" title="Reloads when files change"><i></i><span>Live</span></span>
438
+ </div>
439
+ <div class="search"><input id="filter" placeholder="Filter labels" autocomplete="off" spellcheck="false"></div>
440
+ <nav id="list"></nav>
441
+ </aside>
442
+
443
+ <main>
444
+ <div class="titlebar">
445
+ <span class="title" id="title">No label selected</span>
446
+ <span class="chip" id="mode" hidden></span>
447
+ <span class="meta" id="meta"></span>
448
+ <span class="spacer"></span>
449
+ <button class="btn primary" id="print">Print\u2026</button>
450
+ </div>
451
+ <div class="toolbar">
452
+ <label class="toggle" id="variantWrap" title="Which sample data to render with">Data
453
+ <select id="variant" class="select"></select>
454
+ </label>
455
+ <label class="toggle" title="Show {{placeholders}} in place of text props, and export the ZPL as a template"><input type="checkbox" id="template"> Template</label>
456
+ <span class="seg" id="dpi" style="margin-left:auto" role="group" aria-label="Printer resolution">
457
+ <button data-v="" aria-pressed="true" title="Use the label's dpi">Label</button>
458
+ <button data-v="203">203</button>
459
+ <button data-v="300">300</button>
460
+ <button data-v="600">600</button>
461
+ </span>
462
+ <span class="seg" id="zoom" role="group" aria-label="Zoom">
463
+ <button data-v="fit" aria-pressed="true">Fit</button>
464
+ <button data-v="1" title="Physical size on a 96 dpi screen">1\xD7</button>
465
+ <button data-v="2">2\xD7</button>
466
+ </span>
467
+ <label class="toggle" title="Printer margin, e.g. 1.5mm or 1mm 2mm">Margin <input id="margin" class="margin-input mono" placeholder="label" spellcheck="false" autocomplete="off"></label>
468
+ <label class="toggle"><input type="checkbox" id="boxes"> Boxes</label>
469
+ </div>
470
+ <div class="canvas" id="canvas">
471
+ <div class="placeholder" id="placeholder">Loading\u2026</div>
472
+ </div>
473
+ </main>
474
+
475
+ <section class="side">
476
+ <header>
477
+ <span class="seg tabs" id="tabs" role="tablist">
478
+ <button data-v="react" role="tab">React</button>
479
+ <button data-v="props" role="tab" title="Sample data passed to the label">Props</button>
480
+ <button data-v="zpl" role="tab">ZPL</button>
481
+ <button data-v="checks" role="tab">Checks<span class="count" id="checkCount" hidden></span></button>
482
+ </span>
483
+ <span class="spacer"></span>
484
+ <button class="btn" id="copy">Copy</button>
485
+ <button class="btn" id="download" title="Download">Download</button>
486
+ </header>
487
+ <div class="infobar" id="info" hidden></div>
488
+ <pre class="zpl" id="source" data-tab="react"></pre>
489
+ <div class="pane" data-tab="props" hidden><textarea class="props" id="props" spellcheck="false" autocomplete="off" aria-label="Preview props (JSON)"></textarea></div>
490
+ <pre class="zpl" id="zpl" data-tab="zpl" hidden></pre>
491
+ <div class="pane" data-tab="checks" hidden><ul class="checks" id="checks"></ul></div>
492
+ <div class="size-note" id="bytes"></div>
493
+ </section>
494
+
495
+ <dialog id="printDialog">
496
+ <form method="dialog" id="printForm">
497
+ <h2>Send to printer</h2>
498
+ <p>Sends the ZPL over the network to port 9100 on the printer.</p>
499
+ <label>Printer address<input id="printerHost" placeholder="192.168.1.50" required></label>
500
+ <label>Port<input id="printerPort" value="9100" inputmode="numeric"></label>
501
+ <div class="status" id="printStatus"></div>
502
+ <div class="row">
503
+ <button class="btn" value="cancel" formnovalidate>Cancel</button>
504
+ <button class="btn primary" id="sendPrint" value="send">Send</button>
505
+ </div>
506
+ </form>
507
+ </dialog>
508
+ <div class="toast" id="toast"></div>
509
+
510
+ <script>
511
+ const $ = (id) => document.getElementById(id);
512
+ const store = {
513
+ get(k, d) { try { return localStorage.getItem('react-zpl:' + k) ?? d; } catch { return d; } },
514
+ set(k, v) { try { localStorage.setItem('react-zpl:' + k, v); } catch {} },
515
+ del(k) { try { localStorage.removeItem('react-zpl:' + k); } catch {} },
516
+ };
517
+ const state = {
518
+ labels: [], current: null, result: null, source: '',
519
+ tab: store.get('tab', 'react'), margin: store.get('margin', ''), dpi: store.get('dpi', ''),
520
+ zoom: store.get('zoom', 'fit'), boxes: store.get('boxes', '0') === '1',
521
+ template: store.get('template', '0') === '1',
522
+ propsError: '',
523
+ };
524
+ const TABS = ['react', 'props', 'zpl', 'checks'];
525
+ if (!TABS.includes(state.tab)) state.tab = 'react';
526
+
527
+ /* ---------- per-label sample data ---------- */
528
+ const variantKey = () => 'variant:' + state.current;
529
+ const currentVariant = () => store.get(variantKey(), '');
530
+ const editsKey = () => \`props:\${state.current}:\${currentVariant()}\`;
531
+ function editedProps() {
532
+ const raw = store.get(editsKey(), null);
533
+ if (raw == null) return undefined;
534
+ try { return JSON.parse(raw); } catch { return undefined; }
535
+ }
536
+
537
+ function toast(msg) {
538
+ const t = $('toast');
539
+ t.textContent = msg;
540
+ t.classList.add('show');
541
+ clearTimeout(toast.timer);
542
+ toast.timer = setTimeout(() => t.classList.remove('show'), 1600);
543
+ }
544
+
545
+ function setSeg(el, value) {
546
+ for (const b of el.querySelectorAll('button')) b.setAttribute('aria-pressed', String(b.dataset.v === value));
547
+ }
548
+
549
+ function escapeHtml(s) {
550
+ return String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
551
+ }
552
+
553
+ /* ---------- sidebar: folder tree ---------- */
554
+ function labelLink(l, text) {
555
+ return \`<a href="#\${encodeURIComponent(l.id)}" aria-current="\${l.id === state.current}" title="\${escapeHtml(l.id)}">\${text}</a>\`;
556
+ }
557
+
558
+ function renderList() {
559
+ const q = $('filter').value.trim().toLowerCase();
560
+ const nav = $('list');
561
+ if (!state.labels.length) {
562
+ nav.innerHTML = '<div class="empty">No labels found</div>';
563
+ return;
564
+ }
565
+ if (q) {
566
+ // Flat list of matches with their folder shown.
567
+ const items = state.labels.filter((l) => l.name.toLowerCase().includes(q));
568
+ nav.innerHTML = items.length
569
+ ? items.map((l) => {
570
+ const i = l.name.lastIndexOf('/');
571
+ return labelLink(l, i < 0 ? escapeHtml(l.name) : \`<span class="dir">\${escapeHtml(l.name.slice(0, i + 1))}</span>\${escapeHtml(l.name.slice(i + 1))}\`);
572
+ }).join('')
573
+ : '<div class="empty">No matches</div>';
574
+ return;
575
+ }
576
+ const root = { dirs: new Map(), files: [] };
577
+ for (const l of state.labels) {
578
+ const parts = l.name.split('/');
579
+ let node = root;
580
+ for (const dir of parts.slice(0, -1)) {
581
+ if (!node.dirs.has(dir)) node.dirs.set(dir, { dirs: new Map(), files: [] });
582
+ node = node.dirs.get(dir);
583
+ }
584
+ node.files.push(l);
585
+ }
586
+ const html = (node, path) =>
587
+ [...node.dirs].map(([name, child]) => {
588
+ const p = path + name + '/';
589
+ const open = store.get('folder:' + p, '1') === '1' || (state.current || '').startsWith(p);
590
+ return \`<details data-path="\${escapeHtml(p)}"\${open ? ' open' : ''}><summary>\${escapeHtml(name)}</summary><div class="children">\${html(child, p)}</div></details>\`;
591
+ }).join('') + node.files.map((l) => labelLink(l, escapeHtml(l.name.split('/').pop()))).join('');
592
+ nav.innerHTML = html(root, '');
593
+ }
594
+ $('list').addEventListener('toggle', (e) => {
595
+ const d = e.target;
596
+ if (d.tagName === 'DETAILS') store.set('folder:' + d.dataset.path, d.open ? '1' : '0');
597
+ }, true);
598
+
599
+ /* ---------- highlighting ---------- */
600
+ function highlight(zpl) {
601
+ return escapeHtml(zpl)
602
+ .replace(/([\\^~][A-Z0-9@]{1,2})([^\\^~\\n]*)/g, (_, c, rest) => {
603
+ if (c === '^FD') return \`<span class="c">\${c}</span><span class="d">\${rest}</span>\`;
604
+ return \`<span class="c">\${c}</span>\${rest}\`;
605
+ })
606
+ .replace(/\\{\\{[^{}<]*\\}\\}/g, (m) => \`<span class="p">\${m}</span>\`);
607
+ }
608
+
609
+ const KEYWORDS = new Set('import export default from function return const let var if else for of in while new type interface extends satisfies as async await true false null undefined typeof class'.split(' '));
610
+
611
+ /** A small TSX highlighter: comments, strings, keywords, JSX tags and props. */
612
+ function highlightTsx(src) {
613
+ const re = /(\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/)|('(?:\\\\.|[^'\\\\\\n])*'|"(?:\\\\.|[^"\\\\\\n])*"|\`(?:\\\\.|[^\`\\\\])*\`)|(<\\/?)([A-Za-z][\\w.]*)|\\b([A-Za-z_$][\\w$]*)(?==(?:["'{]))|\\b(\\d+(?:\\.\\d+)?)\\b|\\b([A-Za-z_$][\\w$]*)\\b/g;
614
+ let out = '';
615
+ let last = 0;
616
+ for (const m of src.matchAll(re)) {
617
+ out += escapeHtml(src.slice(last, m.index));
618
+ last = m.index + m[0].length;
619
+ if (m[1]) out += \`<span class="m">\${escapeHtml(m[1])}</span>\`;
620
+ else if (m[2]) out += \`<span class="s">\${escapeHtml(m[2])}</span>\`;
621
+ else if (m[4]) out += \`\${escapeHtml(m[3])}<span class="t">\${escapeHtml(m[4])}</span>\`;
622
+ else if (m[5]) out += \`<span class="a">\${m[5]}</span>\`;
623
+ else if (m[6]) out += \`<span class="n">\${m[6]}</span>\`;
624
+ else if (m[7]) out += KEYWORDS.has(m[7]) ? \`<span class="k">\${m[7]}</span>\` : m[7];
625
+ }
626
+ return out + escapeHtml(src.slice(last));
627
+ }
628
+
629
+ /* ---------- right panel ---------- */
630
+ function baseName() {
631
+ return (state.current || 'label').split('/').pop().replace(/\\.(t|j)sx$/, '');
632
+ }
633
+
634
+ /** What Copy and Download act on for the open tab. */
635
+ function tabContent() {
636
+ const res = state.result;
637
+ switch (state.tab) {
638
+ case 'react': return state.source && { text: state.source, file: baseName() + '.tsx', what: 'React source' };
639
+ case 'props': return $('props').value && { text: $('props').value, file: baseName() + '.props.json', what: 'Props' };
640
+ case 'zpl': return res && { text: res.zpl, file: baseName() + (res.template ? '.template.zpl' : '.zpl'), what: res.template ? 'ZPL template' : 'ZPL' };
641
+ case 'checks': return checksList().length && { text: checksList().map((c) => \`\${c.kind === 'warn' ? '!' : '-'} \${c.text}\`).join('\\n'), file: baseName() + '.checks.txt', what: 'Checks' };
642
+ }
643
+ }
644
+
645
+ function showTab() {
646
+ setSeg($('tabs'), state.tab);
647
+ for (const el of document.querySelectorAll('[data-tab]')) el.hidden = el.dataset.tab !== state.tab;
648
+ const content = tabContent();
649
+ $('copy').disabled = $('download').disabled = !content;
650
+ $('download').title = content ? \`Download \${content.file}\` : 'Download';
651
+ const res = state.result;
652
+ let note = '';
653
+ if (state.tab === 'react' && state.source) note = \`\${state.current} \xB7 \${state.source.trimEnd().split('\\n').length} lines\`;
654
+ if (state.tab === 'zpl' && res) note = \`\${new Blob([res.zpl]).size.toLocaleString()} bytes \xB7 \${res.zpl.trim().split('\\n').length} lines\`;
655
+ if (state.tab === 'props') note = editedProps() !== undefined ? 'Edited here \xB7 saved in this browser' : 'From PreviewProps' + (currentVariant() ? \` + "\${currentVariant()}"\` : '');
656
+ if (state.tab === 'checks') note = res ? \`\${checksList().filter((c) => c.kind === 'warn').length} warnings\` : '';
657
+ $('bytes').textContent = note;
658
+ renderInfo();
659
+ }
660
+
661
+ function renderInfo() {
662
+ const info = $('info');
663
+ let html = '';
664
+ const res = state.result;
665
+ if (state.tab === 'props') {
666
+ html = state.propsError
667
+ ? \`<span class="err">\${escapeHtml(state.propsError)}</span>\`
668
+ : \`Edit the JSON to try other data. \${editedProps() !== undefined ? '<button class="btn" id="resetProps">Reset</button>' : 'Values come from <code>PreviewProps</code>.'}\`;
669
+ } else if (state.tab === 'zpl' && res?.template) {
670
+ const t = res.template;
671
+ html = \`Template with \${t.fields.length} <code>{{field}}</code> placeholder\${t.fields.length === 1 ? '' : 's'}. Fill them in before printing.\`;
672
+ if (t.kept.length) html += \` Kept sample value for \${t.kept.map((k) => \`<code>\${escapeHtml(k)}</code>\`).join(', ')}.\`;
673
+ }
674
+ info.hidden = !html;
675
+ info.innerHTML = html;
676
+ $('resetProps')?.addEventListener('click', resetProps);
677
+ }
678
+
679
+ function checksList() {
680
+ const res = state.result;
681
+ if (!res) return [];
682
+ const list = [...res.warnings, ...res.previewWarnings].map((text) => ({ kind: 'warn', text }));
683
+ if (res.template?.kept.length) {
684
+ list.push({ kind: 'info', text: \`Template: kept sample values for \${res.template.kept.join(', ')}\`, hint: 'The label could not render with a placeholder there, for example a barcode that only takes digits.' });
685
+ }
686
+ if (res.template) {
687
+ list.push({ kind: 'info', text: 'Template layout uses placeholder text', hint: 'Line breaks and sizes are worked out for the {{field}} text, not the values you fill in later. Check long values with real data.' });
688
+ }
689
+ return list;
690
+ }
691
+
692
+ function renderChecks() {
693
+ const list = checksList();
694
+ const warns = list.filter((c) => c.kind === 'warn').length;
695
+ $('checkCount').hidden = !warns;
696
+ $('checkCount').textContent = warns;
697
+ const items = warns ? list : [{ kind: 'ok', text: 'No problems found', hint: 'Everything fits on the label and inside the printable area, and every class is supported.' }, ...list];
698
+ const icon = { warn: '!', ok: '\u2713', info: 'i' };
699
+ $('checks').innerHTML = state.result
700
+ ? items.map((c) => \`<li class="\${c.kind}"><span class="icon">\${icon[c.kind]}</span><span>\${escapeHtml(c.text)}\${c.hint ? \`<small>\${escapeHtml(c.hint)}</small>\` : ''}</span></li>\`).join('')
701
+ : '<li class="info"><span class="icon">i</span><span>Fix the render error to run checks.</span></li>';
702
+ }
703
+
704
+ /* ---------- rendering ---------- */
705
+ async function loadLabels() {
706
+ state.labels = await (await fetch('/api/labels')).json();
707
+ const fromHash = decodeURIComponent(location.hash.slice(1));
708
+ if (!state.labels.some((l) => l.id === state.current)) {
709
+ state.current = state.labels.some((l) => l.id === fromHash) ? fromHash : state.labels[0]?.id ?? null;
710
+ }
711
+ renderList();
712
+ }
713
+
714
+ let renderSeq = 0;
715
+ async function renderCurrent() {
716
+ if (!state.current) {
717
+ showPlaceholder('Add a <code>.tsx</code> file to your labels folder that default-exports a component returning <code>&lt;Label&gt;</code>.');
718
+ return;
719
+ }
720
+ const seq = ++renderSeq;
721
+ const label = state.labels.find((l) => l.id === state.current);
722
+ $('title').textContent = label ? label.name : state.current;
723
+ const body = {
724
+ id: state.current, dpi: state.dpi || undefined, margin: state.margin || undefined,
725
+ variant: currentVariant() || undefined, props: editedProps(), template: state.template,
726
+ };
727
+ let res = await (await fetch('/api/render', { method: 'POST', body: JSON.stringify(body) })).json();
728
+ if (res.error && /no preview variant/.test(res.error.message)) {
729
+ // The variant was renamed or removed: fall back to PreviewProps.
730
+ store.del(variantKey());
731
+ body.variant = undefined;
732
+ res = await (await fetch('/api/render', { method: 'POST', body: JSON.stringify(body) })).json();
733
+ }
734
+ if (seq !== renderSeq) return; // a newer render started
735
+ state.source = res.source || '';
736
+ $('source').innerHTML = highlightTsx(state.source);
737
+ renderVariants(res);
738
+ renderMode();
739
+ if (res.error) {
740
+ state.result = null;
741
+ $('meta').textContent = '';
742
+ $('canvas').innerHTML = \`<div class="error"><strong>Could not render this label</strong>\${escapeHtml(res.error.message)}<pre>\${escapeHtml(res.error.stack || '')}</pre></div>\`;
743
+ $('zpl').textContent = '';
744
+ renderChecks();
745
+ showTab();
746
+ return;
747
+ }
748
+ state.result = res;
749
+ // Keep what the user is typing; otherwise show the props the label rendered with.
750
+ if (editedProps() === undefined && document.activeElement !== $('props')) {
751
+ $('props').value = JSON.stringify(res.props, null, 2);
752
+ state.propsError = '';
753
+ }
754
+ const { setup } = res;
755
+ const inch = (d) => +(d / setup.dpi).toFixed(2);
756
+ const m = setup.margin;
757
+ const marginNote = m.top || m.right || m.bottom || m.left
758
+ ? \` \xB7 margin \${[...new Set([m.top, m.right, m.bottom, m.left])].length === 1 ? m.top : \`\${m.top} \${m.right} \${m.bottom} \${m.left}\`} dots\`
759
+ : '';
760
+ $('meta').textContent = \`\${inch(setup.width)} \xD7 \${inch(setup.height)} in \xB7 \${setup.width} \xD7 \${setup.height} dots \xB7 \${setup.dpi} dpi\${marginNote}\`;
761
+ $('zpl').innerHTML = highlight(res.zpl);
762
+ renderChecks();
763
+ showTab();
764
+ drawPaper();
765
+ }
766
+
767
+ function renderVariants(res) {
768
+ const variants = res.variants || [];
769
+ const current = currentVariant();
770
+ $('variantWrap').hidden = !variants.length;
771
+ $('variant').innerHTML = \`<option value="">PreviewProps</option>\` +
772
+ variants.map((v) => \`<option value="\${escapeHtml(v)}"\${v === current ? ' selected' : ''}>\${escapeHtml(v)}</option>\`).join('');
773
+ }
774
+
775
+ function renderMode() {
776
+ const edited = editedProps() !== undefined;
777
+ const parts = [];
778
+ if (state.template) parts.push('Template');
779
+ if (edited) parts.push('Edited props');
780
+ $('mode').hidden = !parts.length;
781
+ $('mode').textContent = parts.join(' \xB7 ');
782
+ $('mode').className = 'chip' + (edited ? ' accent' : '');
783
+ }
784
+
785
+ function showPlaceholder(html) {
786
+ $('canvas').innerHTML = \`<div class="placeholder">\${html}</div>\`;
787
+ }
788
+
789
+ function drawPaper() {
790
+ const res = state.result;
791
+ if (!res) return;
792
+ const { setup } = res;
793
+ const canvas = $('canvas');
794
+ let paper = canvas.querySelector('.paper');
795
+ if (!paper) {
796
+ canvas.innerHTML = '<div class="paper"></div>';
797
+ paper = canvas.querySelector('.paper');
798
+ }
799
+ const m = setup.margin;
800
+ const inner = \`M\${m.left},\${m.top}h\${setup.width - m.left - m.right}v\${setup.height - m.top - m.bottom}h\${-(setup.width - m.left - m.right)}z\`;
801
+ const margins = m.top || m.right || m.bottom || m.left
802
+ ? \`<svg class="overlay" viewBox="0 0 \${setup.width} \${setup.height}" preserveAspectRatio="none"><defs><pattern id="hatch" width="8" height="8" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="8" height="8" fill="#e5484d" fill-opacity="0.08"/><path d="M0,0v8" stroke="#e5484d" stroke-opacity="0.35" stroke-width="2"/></pattern></defs><path d="M0,0h\${setup.width}v\${setup.height}h\${-setup.width}z\${inner}" fill="url(#hatch)" fill-rule="evenodd"><title>Unprintable margin</title></path><path d="\${inner}" fill="none" stroke="#e5484d" stroke-opacity="0.6" stroke-dasharray="6 4" vector-effect="non-scaling-stroke"/></svg>\`
803
+ : '';
804
+ const overlay = state.boxes
805
+ ? \`<svg class="overlay" viewBox="0 0 \${setup.width} \${setup.height}" preserveAspectRatio="none">\${res.boxes
806
+ .map((b) => \`<rect x="\${b.x}" y="\${b.y}" width="\${b.width}" height="\${b.height}" fill="none" stroke="#e5484d" stroke-width="1" vector-effect="non-scaling-stroke"><title>\${b.type} \${b.width}\xD7\${b.height}</title></rect>\`)
807
+ .join('')}</svg>\`
808
+ : '';
809
+ paper.innerHTML = res.svg + margins + overlay;
810
+ const physical = { w: (setup.width / setup.dpi) * 96, h: (setup.height / setup.dpi) * 96 };
811
+ let scale;
812
+ if (state.zoom === 'fit') {
813
+ const pad = 64;
814
+ scale = Math.min((canvas.clientWidth - pad) / physical.w, (canvas.clientHeight - pad) / physical.h, 3);
815
+ } else {
816
+ scale = Number(state.zoom);
817
+ }
818
+ paper.style.width = \`\${physical.w * scale}px\`;
819
+ paper.style.height = \`\${physical.h * scale}px\`;
820
+ }
821
+
822
+ function select(id) {
823
+ state.current = id;
824
+ state.propsError = '';
825
+ const edits = editedProps();
826
+ if (edits !== undefined) $('props').value = JSON.stringify(edits, null, 2);
827
+ else $('props').value = '';
828
+ renderList();
829
+ renderCurrent();
830
+ }
831
+
832
+ /* ---------- props editor ---------- */
833
+ let propsTimer;
834
+ $('props').addEventListener('input', () => {
835
+ clearTimeout(propsTimer);
836
+ propsTimer = setTimeout(() => {
837
+ let value;
838
+ try {
839
+ value = JSON.parse($('props').value);
840
+ } catch (err) {
841
+ state.propsError = 'Not valid JSON yet: ' + err.message;
842
+ renderInfo();
843
+ return;
844
+ }
845
+ state.propsError = '';
846
+ store.set(editsKey(), JSON.stringify(value));
847
+ renderCurrent();
848
+ }, 250);
849
+ });
850
+ $('props').addEventListener('keydown', (e) => {
851
+ if (e.key === 'Tab') {
852
+ e.preventDefault();
853
+ document.execCommand('insertText', false, ' ');
854
+ }
855
+ });
856
+ function resetProps() {
857
+ store.del(editsKey());
858
+ state.propsError = '';
859
+ $('props').value = '';
860
+ $('props').blur();
861
+ toast('Props reset');
862
+ renderCurrent();
863
+ }
864
+
865
+ $('variant').addEventListener('change', (e) => {
866
+ if (e.target.value) store.set(variantKey(), e.target.value);
867
+ else store.del(variantKey());
868
+ select(state.current);
869
+ });
870
+ $('template').checked = state.template;
871
+ $('template').addEventListener('change', (e) => {
872
+ state.template = e.target.checked;
873
+ store.set('template', state.template ? '1' : '0');
874
+ renderCurrent();
875
+ });
876
+
877
+ /* ---------- controls ---------- */
878
+ window.addEventListener('hashchange', () => {
879
+ const id = decodeURIComponent(location.hash.slice(1));
880
+ if (id && id !== state.current) select(id);
881
+ });
882
+ window.addEventListener('resize', () => state.zoom === 'fit' && drawPaper());
883
+ $('filter').addEventListener('input', renderList);
884
+
885
+ document.addEventListener('keydown', (e) => {
886
+ if (e.target.closest('input, textarea, select') || !state.labels.length) return;
887
+ if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
888
+ e.preventDefault();
889
+ // Follow the sidebar's order (folders first, filtered).
890
+ const links = [...$('list').querySelectorAll('a')].filter((a) => a.offsetParent !== null);
891
+ if (!links.length) return;
892
+ const i = links.findIndex((a) => a.getAttribute('aria-current') === 'true');
893
+ const next = links[(i + (e.key === 'ArrowDown' ? 1 : -1) + links.length) % links.length];
894
+ location.hash = next.getAttribute('href').slice(1);
895
+ });
896
+
897
+ $('dpi').addEventListener('click', (e) => {
898
+ const b = e.target.closest('button');
899
+ if (!b) return;
900
+ state.dpi = b.dataset.v;
901
+ store.set('dpi', state.dpi);
902
+ setSeg($('dpi'), state.dpi);
903
+ renderCurrent();
904
+ });
905
+ $('zoom').addEventListener('click', (e) => {
906
+ const b = e.target.closest('button');
907
+ if (!b) return;
908
+ state.zoom = b.dataset.v;
909
+ store.set('zoom', state.zoom);
910
+ setSeg($('zoom'), state.zoom);
911
+ drawPaper();
912
+ });
913
+ $('margin').value = state.margin;
914
+ $('margin').addEventListener('change', (e) => {
915
+ state.margin = e.target.value.trim();
916
+ store.set('margin', state.margin);
917
+ renderCurrent();
918
+ });
919
+ $('boxes').checked = state.boxes;
920
+ $('boxes').addEventListener('change', (e) => {
921
+ state.boxes = e.target.checked;
922
+ store.set('boxes', state.boxes ? '1' : '0');
923
+ drawPaper();
924
+ });
925
+
926
+ $('tabs').addEventListener('click', (e) => {
927
+ const b = e.target.closest('button');
928
+ if (!b) return;
929
+ state.tab = b.dataset.v;
930
+ store.set('tab', state.tab);
931
+ showTab();
932
+ });
933
+ $('copy').addEventListener('click', async () => {
934
+ const c = tabContent();
935
+ if (!c) return;
936
+ await navigator.clipboard.writeText(c.text);
937
+ toast(\`\${c.what} copied\`);
938
+ });
939
+ $('download').addEventListener('click', () => {
940
+ const c = tabContent();
941
+ if (!c) return;
942
+ const a = document.createElement('a');
943
+ a.href = URL.createObjectURL(new Blob([c.text], { type: 'text/plain' }));
944
+ a.download = c.file;
945
+ a.click();
946
+ URL.revokeObjectURL(a.href);
947
+ });
948
+ $('print').addEventListener('click', () => {
949
+ if (!state.result) return;
950
+ $('printerHost').value = store.get('printerHost', '');
951
+ $('printerPort').value = store.get('printerPort', '9100');
952
+ $('printStatus').textContent = state.result.template ? 'This is a template: placeholders will print as written.' : '';
953
+ $('printDialog').showModal();
954
+ });
955
+ $('printForm').addEventListener('submit', async (e) => {
956
+ if (e.submitter?.value !== 'send') return;
957
+ e.preventDefault();
958
+ const host = $('printerHost').value.trim();
959
+ const port = $('printerPort').value.trim() || '9100';
960
+ store.set('printerHost', host);
961
+ store.set('printerPort', port);
962
+ $('printStatus').textContent = 'Sending\u2026';
963
+ $('sendPrint').disabled = true;
964
+ try {
965
+ const res = await (await fetch('/api/print', { method: 'POST', body: JSON.stringify({ host, port, zpl: state.result.zpl }) })).json();
966
+ if (res.error) throw new Error(res.error);
967
+ $('printDialog').close();
968
+ toast(\`Sent to \${host}\`);
969
+ } catch (err) {
970
+ $('printStatus').textContent = err.message;
971
+ } finally {
972
+ $('sendPrint').disabled = false;
973
+ }
974
+ });
975
+
976
+ setSeg($('dpi'), state.dpi);
977
+ setSeg($('zoom'), state.zoom);
978
+
979
+ function connect() {
980
+ const es = new EventSource('/api/events');
981
+ es.onopen = () => $('live').classList.remove('off');
982
+ es.onerror = () => $('live').classList.add('off');
983
+ es.onmessage = async () => {
984
+ await loadLabels();
985
+ await renderCurrent();
986
+ };
987
+ }
988
+
989
+ (async () => {
990
+ await loadLabels();
991
+ select(state.current);
992
+ connect();
993
+ })();
994
+ </script>
995
+ </body>
996
+ </html>
997
+ `;
998
+
999
+ // src/server.ts
1000
+ function json(res, status, body) {
1001
+ res.writeHead(status, { "content-type": "application/json" });
1002
+ res.end(JSON.stringify(body));
1003
+ }
1004
+ function readBody(req) {
1005
+ return new Promise((resolve3, reject) => {
1006
+ let data = "";
1007
+ req.on("data", (c) => {
1008
+ data += c;
1009
+ if (data.length > 5e6) reject(new Error("Body too large"));
1010
+ });
1011
+ req.on("end", () => resolve3(data));
1012
+ req.on("error", reject);
1013
+ });
1014
+ }
1015
+ function sendToPrinter(host, port, zpl) {
1016
+ return new Promise((resolve3, reject) => {
1017
+ const socket = new Socket();
1018
+ socket.setTimeout(5e3);
1019
+ socket.once("timeout", () => {
1020
+ socket.destroy();
1021
+ reject(new Error(`Timed out connecting to ${host}:${port}`));
1022
+ });
1023
+ socket.once("error", reject);
1024
+ socket.connect(port, host, () => {
1025
+ socket.end(zpl, () => resolve3());
1026
+ });
1027
+ });
1028
+ }
1029
+ function startServer(loader, port, host, printMargin) {
1030
+ const clients = /* @__PURE__ */ new Set();
1031
+ let timer;
1032
+ const notify = () => {
1033
+ clearTimeout(timer);
1034
+ timer = setTimeout(() => {
1035
+ loader.invalidate();
1036
+ for (const c of clients) c.write("data: change\n\n");
1037
+ }, 50);
1038
+ };
1039
+ loader.vite.watcher.add(loader.dir);
1040
+ loader.vite.watcher.on("change", notify);
1041
+ loader.vite.watcher.on("add", notify);
1042
+ loader.vite.watcher.on("unlink", notify);
1043
+ const server = createServer2(async (req, res) => {
1044
+ const url = new URL(req.url ?? "/", "http://localhost");
1045
+ try {
1046
+ if (url.pathname === "/") {
1047
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
1048
+ res.end(ui_default);
1049
+ } else if (url.pathname === "/api/labels") {
1050
+ json(res, 200, loader.list());
1051
+ } else if (url.pathname === "/api/render") {
1052
+ const q = url.searchParams;
1053
+ const body = req.method === "POST" ? JSON.parse(await readBody(req) || "{}") : {};
1054
+ const id = String(body.id ?? q.get("id") ?? "");
1055
+ try {
1056
+ const result = await loader.render(id, {
1057
+ dpi: Number(body.dpi ?? q.get("dpi")) || void 0,
1058
+ printMargin: body.margin || q.get("margin") || printMargin,
1059
+ variant: body.variant || q.get("variant") || void 0,
1060
+ props: body.props,
1061
+ template: Boolean(body.template ?? q.get("template"))
1062
+ });
1063
+ json(res, 200, { ...result, source: loader.source(id) });
1064
+ } catch (err) {
1065
+ const e = err;
1066
+ loader.vite.ssrFixStacktrace(e);
1067
+ json(res, 200, { error: { message: e.message, stack: e.stack }, source: loader.source(id) });
1068
+ }
1069
+ } else if (url.pathname === "/api/events") {
1070
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
1071
+ res.write(": connected\n\n");
1072
+ clients.add(res);
1073
+ req.on("close", () => clients.delete(res));
1074
+ } else if (url.pathname === "/api/print" && req.method === "POST") {
1075
+ const { host: printer, port: printerPort = 9100, zpl } = JSON.parse(await readBody(req));
1076
+ if (typeof printer !== "string" || !printer || typeof zpl !== "string") {
1077
+ json(res, 400, { error: "host and zpl are required" });
1078
+ return;
1079
+ }
1080
+ await sendToPrinter(printer, Number(printerPort), zpl);
1081
+ json(res, 200, { ok: true });
1082
+ } else {
1083
+ res.writeHead(404).end("Not found");
1084
+ }
1085
+ } catch (err) {
1086
+ json(res, 500, { error: err.message });
1087
+ }
1088
+ });
1089
+ return new Promise((resolve3) => server.listen(port, host, () => resolve3(server)));
1090
+ }
1091
+
1092
+ // src/index.ts
1093
+ var HELP = `react-zpl - preview and export ZPL labels written in React
1094
+
1095
+ Usage
1096
+ react-zpl dev [--dir labels] [--port 3000] [--host localhost] [--margin 1mm]
1097
+ react-zpl export [--dir labels] [--out out] [--dpi 203] [--margin 1mm] [--svg] [--template]
1098
+
1099
+ --margin sets the printer's unprintable edge (CSS order: "1mm" or "1mm 2mm").
1100
+ It overrides <Label printMargin>.
1101
+ --template writes ZPL with {{field}} placeholders in place of string props.
1102
+
1103
+ Each .tsx file in the labels folder should default-export a component that
1104
+ returns a <Label>. Set Component.PreviewProps to give it sample data, and
1105
+ Component.PreviewVariants to name more examples (merged over PreviewProps).
1106
+ `;
1107
+ async function main() {
1108
+ const { positionals, values } = parseArgs({
1109
+ allowPositionals: true,
1110
+ options: {
1111
+ dir: { type: "string", default: "labels" },
1112
+ port: { type: "string", default: "3000" },
1113
+ host: { type: "string", default: "localhost" },
1114
+ out: { type: "string", default: "out" },
1115
+ dpi: { type: "string" },
1116
+ margin: { type: "string" },
1117
+ svg: { type: "boolean", default: false },
1118
+ template: { type: "boolean", default: false },
1119
+ help: { type: "boolean", short: "h" }
1120
+ }
1121
+ });
1122
+ const command = positionals[0];
1123
+ if (values.help || !command) {
1124
+ console.log(HELP);
1125
+ return;
1126
+ }
1127
+ const root = process.cwd();
1128
+ if (command === "dev") {
1129
+ const loader = await LabelLoader.create(root, values.dir, true);
1130
+ const count = loader.list().length;
1131
+ const port = Number(values.port);
1132
+ await startServer(loader, port, values.host, values.margin);
1133
+ console.log(`
1134
+ react-zpl preview running at http://${values.host}:${port}`);
1135
+ console.log(` Watching ${count} label${count === 1 ? "" : "s"} in ./${values.dir}
1136
+ `);
1137
+ return;
1138
+ }
1139
+ if (command === "export") {
1140
+ const loader = await LabelLoader.create(root, values.dir, false);
1141
+ const labels = loader.list();
1142
+ const dpi = values.dpi ? Number(values.dpi) : void 0;
1143
+ let failed = 0;
1144
+ for (const label of labels) {
1145
+ try {
1146
+ const result = await loader.render(label.id, { dpi, printMargin: values.margin, template: values.template });
1147
+ const target = join2(resolve2(root, values.out), `${label.name}.zpl`);
1148
+ mkdirSync(dirname(target), { recursive: true });
1149
+ writeFileSync(target, result.zpl);
1150
+ if (values.svg) writeFileSync(target.replace(/\.zpl$/, ".svg"), result.svg);
1151
+ const notes = result.warnings.length ? ` (${result.warnings.length} warning${result.warnings.length > 1 ? "s" : ""})` : "";
1152
+ console.log(` \u2713 ${label.name}${notes}`);
1153
+ for (const w of result.warnings) console.log(` ! ${w}`);
1154
+ if (result.template?.kept.length) console.log(` kept sample values for: ${result.template.kept.join(", ")}`);
1155
+ } catch (err) {
1156
+ failed++;
1157
+ console.log(` \u2717 ${label.name}: ${err.message}`);
1158
+ }
1159
+ }
1160
+ await loader.close();
1161
+ console.log(`
1162
+ Exported ${labels.length - failed} of ${labels.length} labels to ${relative2(root, resolve2(root, values.out)) || "."}`);
1163
+ if (failed) process.exitCode = 1;
1164
+ return;
1165
+ }
1166
+ console.error(`Unknown command "${command}".
1167
+ `);
1168
+ console.log(HELP);
1169
+ process.exitCode = 1;
1170
+ }
1171
+ main().catch((err) => {
1172
+ console.error(err);
1173
+ process.exit(1);
1174
+ });
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@caddy-industries/react-zpl-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Live preview and export for react-zpl labels.",
5
+ "license": "MIT",
6
+ "author": "Caddy Industries",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/caddy-industries/react-zpl.git",
10
+ "directory": "packages/cli"
11
+ },
12
+ "homepage": "https://github.com/caddy-industries/react-zpl#readme",
13
+ "bugs": {
14
+ "url": "https://github.com/caddy-industries/react-zpl/issues"
15
+ },
16
+ "type": "module",
17
+ "bin": {
18
+ "react-zpl": "./dist/index.js"
19
+ },
20
+ "files": [
21
+ "dist"
22
+ ],
23
+ "scripts": {
24
+ "build": "tsup",
25
+ "dev": "tsup --watch",
26
+ "typecheck": "tsc --noEmit",
27
+ "test": "vitest run"
28
+ },
29
+ "peerDependencies": {
30
+ "react": "^19.0.0",
31
+ "@caddy-industries/react-zpl": ">=0.1.0 <1.0.0"
32
+ },
33
+ "engines": {
34
+ "node": ">=20"
35
+ },
36
+ "dependencies": {
37
+ "vite": "^8.3.5"
38
+ },
39
+ "devDependencies": {
40
+ "@types/node": "^26.6.5",
41
+ "@types/react": "^19.3.0",
42
+ "react": "19.3.1",
43
+ "@caddy-industries/react-zpl": "^0.2.0",
44
+ "tsup": "^9.0.0",
45
+ "typescript": "^5.9.0",
46
+ "vitest": "^5.0.5"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }