@chromamark/cli 0.1.3 → 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.
package/README.md CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  Compile [ChromaMark](https://github.com/cjfravel-dev/ChromaMark) (`.cm`) files
4
4
  into **self-contained HTML** — the theme is inlined, so there's no CDN or build
5
- step to deploy.
5
+ step to deploy — render them straight to a **color terminal**, or **lint** them
6
+ in CI.
6
7
 
7
8
  ## Install
8
9
 
@@ -21,19 +22,39 @@ chromamark build docs/ -o site/ # every .cm in a tree → site/*.html
21
22
  chromamark report.cm --stdout > out.html # write to stdout
22
23
  cat report.cm | chromamark - # read from stdin
23
24
  chromamark build report.cm --watch # rebuild on change
25
+
26
+ chromamark render report.cm # render to the terminal (ANSI)
27
+ cat report.cm | chromamark render # render piped stdin
28
+ chromamark render report.cm --no-color # plain, icon-annotated text
29
+
30
+ chromamark lint report.cm # check for common mistakes (CI)
31
+ chromamark lint report.cm --disable CM001 # suppress a rule
24
32
  ```
25
33
 
26
- Options: `-o/--output`, `--stdout`, `--title <text>`, `--watch`, `-h/--help`,
34
+ Options: `-o/--output`, `--stdout`, `--title <text>`, `--watch`,
35
+ `--color <auto|always|never>`, `--no-color`, `--disable <rules>`, `-h/--help`,
27
36
  `-v/--version`.
28
37
 
38
+ The `render` command turns tones into terminal colors, pills into bracketed
39
+ icon chips (`[✓ PASS]`), blocks into a colored left bar, and meters into a
40
+ unicode bar. Color is automatic on a TTY and can be forced with `--color`; it
41
+ also honors the [`NO_COLOR`](https://no-color.org) convention.
42
+
43
+ The `lint` command flags mistakes that otherwise degrade silently — a construct
44
+ wrapped in backticks (`CM001`), an unknown tone (`CM002`), an unknown block kind
45
+ (`CM003`), a bad meter value (`CM004`), or an unclosed container (`CM005`) — and
46
+ exits non-zero when any are found. Suppress rules with `--disable CM001,CM003`.
47
+
29
48
  ## Programmatic
30
49
 
31
50
  ```js
32
- import { compile, render } from '@chromamark/cli';
51
+ import { compile, render, renderAnsi, lint } from '@chromamark/cli';
33
52
 
34
53
  const page = compile('::: success\nAll good [!pass]\n:::', { title: 'Report' });
35
54
  // → complete <!DOCTYPE html> document with the theme inlined
36
55
  const fragment = render('[!pass]'); // just the HTML fragment
56
+ const ansi = renderAnsi('[!pass]', { color: 'always' }); // terminal-styled text
57
+ const problems = lint('Use `[!pass]` here'); // [{ line, column, rule, ... }]
37
58
  ```
38
59
 
39
60
  ## License
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@chromamark/cli",
3
- "version": "0.1.3",
4
- "description": "ChromaMark command-line compilerturn .cm files into self-contained HTML (theme inlined, no CDN).",
3
+ "version": "0.2.0",
4
+ "description": "ChromaMark command-line toolcompile .cm to self-contained HTML, render to a color terminal, or lint.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "cjfravel-dev",
7
7
  "homepage": "https://github.com/cjfravel-dev/ChromaMark#readme",
@@ -42,6 +42,6 @@
42
42
  "html"
43
43
  ],
44
44
  "dependencies": {
45
- "@chromamark/renderer": "^0.2.2"
45
+ "@chromamark/renderer": "^0.3.0"
46
46
  }
47
47
  }
package/src/cli.js CHANGED
@@ -4,37 +4,75 @@
4
4
  */
5
5
 
6
6
  import {
7
- readFileSync, writeFileSync, statSync, readdirSync, mkdirSync, watch,
7
+ readFileSync, readSync, writeFileSync, statSync, readdirSync, mkdirSync, watch,
8
8
  } from 'node:fs';
9
9
  import { join, basename, extname, dirname, relative } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
- import { compile } from './index.js';
11
+ import { compile, renderAnsi, lint } from './index.js';
12
12
 
13
- const HELP = `chromamark — compile ChromaMark (.cm) to self-contained HTML
13
+ const HELP = `chromamark — compile ChromaMark (.cm) to HTML, render it, or lint it
14
14
 
15
15
  Usage:
16
- chromamark [build] <input.cm> [-o <output.html>] compile one file
16
+ chromamark [build] <input.cm> [-o <output.html>] compile one file to HTML
17
17
  chromamark [build] <dir> -o <outdir> compile every .cm in a tree
18
+ chromamark render <input.cm> render to ANSI on stdout
19
+ chromamark lint <input.cm> check for common mistakes
20
+ cat file.cm | chromamark render render stdin to ANSI
18
21
  chromamark <input.cm> --stdout write HTML to stdout
19
- cat file.cm | chromamark - read from stdin
22
+ cat file.cm | chromamark - read HTML from stdin
20
23
 
21
24
  Options:
22
- -o, --output <path> output file or directory
25
+ -o, --output <path> output file or directory (build)
23
26
  --stdout write HTML to stdout instead of a file
24
27
  --title <text> page title (default: derived from the file name)
28
+ --color <when> colorize render output: auto (default), always, never
29
+ --no-color disable color (same as --color never)
30
+ --disable <rules> lint: comma-separated rule ids to suppress (e.g. CM001)
25
31
  --watch rebuild when inputs change
26
32
  -h, --help show this help
27
33
  -v, --version print the version
28
34
  `;
29
35
 
36
+ const COMMANDS = new Set(['build', 'render', 'lint']);
37
+
38
+ /**
39
+ * Read all of stdin synchronously. Unlike readFileSync(0), this tolerates a
40
+ * non-blocking pipe from another process (which throws EAGAIN when no data is
41
+ * ready yet) by retrying after a short sleep until EOF.
42
+ */
43
+ function readStdin() {
44
+ const chunks = [];
45
+ const buf = Buffer.alloc(65536);
46
+ const idle = new Int32Array(new SharedArrayBuffer(4));
47
+ for (;;) {
48
+ let bytes;
49
+ try {
50
+ bytes = readSync(0, buf, 0, buf.length, null);
51
+ } catch (err) {
52
+ if (err.code === 'EAGAIN') { Atomics.wait(idle, 0, 0, 5); continue; }
53
+ if (err.code === 'EOF') break; // some platforms signal EOF this way
54
+ throw err;
55
+ }
56
+ if (bytes === 0) break;
57
+ chunks.push(Buffer.from(buf.subarray(0, bytes)));
58
+ }
59
+ return Buffer.concat(chunks).toString('utf8');
60
+ }
61
+
30
62
  function version() {
31
63
  const url = new URL('../package.json', import.meta.url);
32
64
  return JSON.parse(readFileSync(fileURLToPath(url), 'utf8')).version;
33
65
  }
34
66
 
35
67
  function parseArgs(argv) {
36
- const opts = { input: null, output: null, stdout: false, title: null, watch: false };
37
- const rest = argv[0] === 'build' ? argv.slice(1) : argv.slice(0);
68
+ const opts = {
69
+ command: 'build', input: null, output: null, stdout: false, title: null, watch: false, color: 'auto', disable: [],
70
+ };
71
+ let rest = argv;
72
+ if (argv.length && COMMANDS.has(argv[0])) {
73
+ opts.command = argv[0];
74
+ rest = argv.slice(1);
75
+ }
38
76
  for (let i = 0; i < rest.length; i++) {
39
77
  const a = rest[i];
40
78
  const takeValue = (flag) => {
@@ -49,7 +87,14 @@ function parseArgs(argv) {
49
87
  else if (a === '-v' || a === '--version') opts.version = true;
50
88
  else if (a === '--stdout') opts.stdout = true;
51
89
  else if (a === '--watch') opts.watch = true;
52
- else if (a === '-o' || a === '--output') opts.output = takeValue(a);
90
+ else if (a === '--no-color') opts.color = 'never';
91
+ else if (a === '--color') {
92
+ const v = takeValue(a);
93
+ if (!['auto', 'always', 'never'].includes(v)) throw new Error('--color must be auto, always, or never');
94
+ opts.color = v;
95
+ } else if (a === '--disable') {
96
+ opts.disable = opts.disable.concat(takeValue(a).split(',').map((s) => s.trim()).filter(Boolean));
97
+ } else if (a === '-o' || a === '--output') opts.output = takeValue(a);
53
98
  else if (a === '--title') opts.title = takeValue(a);
54
99
  else if (a === '-') opts.input = '-';
55
100
  else if (a.startsWith('-')) throw new Error(`unknown option: ${a}`);
@@ -62,6 +107,60 @@ function titleFor(file) {
62
107
  return basename(file, extname(file));
63
108
  }
64
109
 
110
+ /** Read source from a file, `-`, or piped stdin. Returns { src, path } or an
111
+ * error shape ({ error } / { empty }) the command can turn into an exit code. */
112
+ function readInput(opts) {
113
+ if (opts.input === '-' || (opts.input == null && !process.stdin.isTTY)) {
114
+ const src = readStdin();
115
+ if (opts.input !== '-' && !src.trim()) return { empty: true };
116
+ return { src, path: '<stdin>' };
117
+ }
118
+ if (opts.input == null) return null;
119
+ return { src: readFileSync(opts.input, 'utf8'), path: opts.input };
120
+ }
121
+
122
+ /** Render ChromaMark to ANSI on stdout, from a file, `-`, or piped stdin. */
123
+ function runRender(opts) {
124
+ let input;
125
+ try {
126
+ input = readInput(opts);
127
+ } catch (err) {
128
+ process.stderr.write(`error: ${err.message}\n`);
129
+ return 1;
130
+ }
131
+ if (!input || input.empty) {
132
+ process.stderr.write(`error: render needs an input file or piped stdin\n\n${HELP}`);
133
+ return 1;
134
+ }
135
+ process.stdout.write(renderAnsi(input.src, { color: opts.color }));
136
+ return 0;
137
+ }
138
+
139
+ /** Lint ChromaMark and print diagnostics; exit non-zero when any are found. */
140
+ function runLint(opts) {
141
+ let input;
142
+ try {
143
+ input = readInput(opts);
144
+ } catch (err) {
145
+ process.stderr.write(`error: ${err.message}\n`);
146
+ return 1;
147
+ }
148
+ if (!input || input.empty) {
149
+ process.stderr.write(`error: lint needs an input file or piped stdin\n\n${HELP}`);
150
+ return 1;
151
+ }
152
+ const diags = lint(input.src, { disable: opts.disable });
153
+ for (const d of diags) {
154
+ process.stdout.write(`${input.path}:${d.line}:${d.column} ${d.severity} ${d.rule} ${d.message}\n`);
155
+ }
156
+ if (diags.length) {
157
+ process.stderr.write(`✗ ${diags.length} problem${diags.length === 1 ? '' : 's'}\n`);
158
+ return 1;
159
+ }
160
+ process.stderr.write(`✓ ${input.path}: no problems\n`);
161
+ return 0;
162
+ }
163
+
65
164
  function listCmFiles(dir) {
66
165
  const out = [];
67
166
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -109,8 +208,15 @@ export function run(argv = process.argv.slice(2)) {
109
208
  if (opts.help) { process.stdout.write(HELP); return 0; }
110
209
  if (opts.version) { process.stdout.write(`${version()}\n`); return 0; }
111
210
 
211
+ if (opts.command === 'render') {
212
+ return runRender(opts);
213
+ }
214
+ if (opts.command === 'lint') {
215
+ return runLint(opts);
216
+ }
217
+
112
218
  if (opts.input === '-' || (opts.input == null && !process.stdin.isTTY)) {
113
- const src = readFileSync(0, 'utf8');
219
+ const src = readStdin();
114
220
  if (opts.input !== '-' && !src.trim()) {
115
221
  process.stderr.write(`error: no input file given and stdin was empty\n\n${HELP}`);
116
222
  return 1;
package/src/index.js CHANGED
@@ -5,13 +5,19 @@
5
5
 
6
6
  import { readFileSync } from 'node:fs';
7
7
  import { createRequire } from 'node:module';
8
- import { render as renderFragment } from '@chromamark/renderer';
8
+ import { render as renderFragment, renderAnsi, lint } from '@chromamark/renderer';
9
9
 
10
10
  const require = createRequire(import.meta.url);
11
11
 
12
12
  /** Render ChromaMark to an HTML fragment (no page chrome). */
13
13
  export const render = renderFragment;
14
14
 
15
+ /** Render ChromaMark to ANSI-styled text for a terminal. */
16
+ export { renderAnsi };
17
+
18
+ /** Lint ChromaMark source, returning an array of diagnostics. */
19
+ export { lint };
20
+
15
21
  let cachedTheme;
16
22
 
17
23
  /** The ChromaMark theme stylesheet, read from @chromamark/renderer. */