@chromamark/cli 0.1.3 → 0.2.1

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.md CHANGED
@@ -1,15 +1,9 @@
1
- Copyright 2026 CJ Fravel
1
+ # MIT License
2
2
 
3
- Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
3
+ Copyright (c) 2026 CJ Fravel
4
4
 
5
- 1. The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
6
 
7
- 2. **SaaS Provision**: If you use this software to provide a Software-as-a-Service (SaaS) offering, you must provide access to the source code of the modified version of the Software that you are using in your service, under the terms of this license. The access must be provided within a reasonable time, but no later than 30 days, and the access method should be publicly documented.
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
8
 
9
- THE SOFTWARE IS PROVIDED AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10
-
11
- ---
12
-
13
- ## Third-party components
14
-
15
- The published browser bundles in `packages/renderer/dist/` and the bundled VS Code extension embed [markdown-it](https://github.com/markdown-it/markdown-it), which is licensed under the [MIT License](https://github.com/markdown-it/markdown-it/blob/master/LICENSE).
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
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,21 +22,48 @@ 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
+ Commands accept one input source. Extra positional arguments, including a second
39
+ `-` stdin marker, are rejected instead of being silently ignored.
40
+
41
+ The `render` command turns tones into terminal colors, pills into bracketed
42
+ icon chips (`[✓ PASS]`), blocks into a colored left bar, and meters into a
43
+ unicode bar. Color is automatic on a TTY and can be forced with `--color`; it
44
+ also honors the [`NO_COLOR`](https://no-color.org) convention.
45
+
46
+ The `lint` command flags mistakes that otherwise degrade silently — a construct
47
+ wrapped in backticks (`CM001`), an unknown tone (`CM002`), an unknown block kind
48
+ (`CM003`), a bad meter value (`CM004`), or an unclosed container (`CM005`) — and
49
+ exits non-zero when any are found. Suppress rules with `--disable CM001,CM003`.
50
+
29
51
  ## Programmatic
30
52
 
31
53
  ```js
32
- import { compile, render } from '@chromamark/cli';
54
+ import { compile, render, renderAnsi, lint } from '@chromamark/cli';
33
55
 
34
56
  const page = compile('::: success\nAll good [!pass]\n:::', { title: 'Report' });
35
57
  // → complete <!DOCTYPE html> document with the theme inlined
36
58
  const fragment = render('[!pass]'); // just the HTML fragment
59
+ const ansi = renderAnsi('[!pass]', { color: 'always' }); // terminal-styled text
60
+ const problems = lint('Use `[!pass]` here'); // [{ line, column, rule, ... }]
37
61
  ```
38
62
 
63
+ TypeScript declarations ship with the package.
64
+
39
65
  ## License
40
66
 
41
- Modified MIT License with a SaaS source-availability provision see LICENSE.md.
67
+ This software package is licensed under the MIT License; see LICENSE.md. The
68
+ [ChromaMark specification](https://github.com/cjfravel-dev/ChromaMark/blob/main/LICENSE-SPEC.md)
69
+ is licensed separately under CC BY-SA 4.0.
package/package.json CHANGED
@@ -1,8 +1,8 @@
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).",
5
- "license": "SEE LICENSE IN LICENSE.md",
3
+ "version": "0.2.1",
4
+ "description": "ChromaMark command-line toolcompile .cm to self-contained HTML, render to a color terminal, or lint.",
5
+ "license": "MIT",
6
6
  "author": "cjfravel-dev",
7
7
  "homepage": "https://github.com/cjfravel-dev/ChromaMark#readme",
8
8
  "repository": {
@@ -24,8 +24,12 @@
24
24
  "chromamark": "./bin/chromamark.js"
25
25
  },
26
26
  "main": "./src/index.js",
27
+ "types": "./src/index.d.ts",
27
28
  "exports": {
28
- ".": "./src/index.js"
29
+ ".": {
30
+ "types": "./src/index.d.ts",
31
+ "default": "./src/index.js"
32
+ }
29
33
  },
30
34
  "files": [
31
35
  "bin/",
@@ -42,6 +46,6 @@
42
46
  "html"
43
47
  ],
44
48
  "dependencies": {
45
- "@chromamark/renderer": "^0.2.2"
49
+ "@chromamark/renderer": "^0.3.1"
46
50
  }
47
51
  }
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,11 +87,20 @@ 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
- else if (a === '-') opts.input = '-';
99
+ else if (a === '-' && opts.input == null) opts.input = '-';
100
+ else if (a === '-') throw new Error('unexpected argument: -');
55
101
  else if (a.startsWith('-')) throw new Error(`unknown option: ${a}`);
56
102
  else if (opts.input == null) opts.input = a;
103
+ else throw new Error(`unexpected argument: ${a}`);
57
104
  }
58
105
  return opts;
59
106
  }
@@ -62,6 +109,60 @@ function titleFor(file) {
62
109
  return basename(file, extname(file));
63
110
  }
64
111
 
112
+ /** Read source from a file, `-`, or piped stdin. Returns { src, path } or an
113
+ * error shape ({ error } / { empty }) the command can turn into an exit code. */
114
+ function readInput(opts) {
115
+ if (opts.input === '-' || (opts.input == null && !process.stdin.isTTY)) {
116
+ const src = readStdin();
117
+ if (opts.input !== '-' && !src.trim()) return { empty: true };
118
+ return { src, path: '<stdin>' };
119
+ }
120
+ if (opts.input == null) return null;
121
+ return { src: readFileSync(opts.input, 'utf8'), path: opts.input };
122
+ }
123
+
124
+ /** Render ChromaMark to ANSI on stdout, from a file, `-`, or piped stdin. */
125
+ function runRender(opts) {
126
+ let input;
127
+ try {
128
+ input = readInput(opts);
129
+ } catch (err) {
130
+ process.stderr.write(`error: ${err.message}\n`);
131
+ return 1;
132
+ }
133
+ if (!input || input.empty) {
134
+ process.stderr.write(`error: render needs an input file or piped stdin\n\n${HELP}`);
135
+ return 1;
136
+ }
137
+ process.stdout.write(renderAnsi(input.src, { color: opts.color }));
138
+ return 0;
139
+ }
140
+
141
+ /** Lint ChromaMark and print diagnostics; exit non-zero when any are found. */
142
+ function runLint(opts) {
143
+ let input;
144
+ try {
145
+ input = readInput(opts);
146
+ } catch (err) {
147
+ process.stderr.write(`error: ${err.message}\n`);
148
+ return 1;
149
+ }
150
+ if (!input || input.empty) {
151
+ process.stderr.write(`error: lint needs an input file or piped stdin\n\n${HELP}`);
152
+ return 1;
153
+ }
154
+ const diags = lint(input.src, { disable: opts.disable });
155
+ for (const d of diags) {
156
+ process.stdout.write(`${input.path}:${d.line}:${d.column} ${d.severity} ${d.rule} ${d.message}\n`);
157
+ }
158
+ if (diags.length) {
159
+ process.stderr.write(`✗ ${diags.length} problem${diags.length === 1 ? '' : 's'}\n`);
160
+ return 1;
161
+ }
162
+ process.stderr.write(`✓ ${input.path}: no problems\n`);
163
+ return 0;
164
+ }
165
+
65
166
  function listCmFiles(dir) {
66
167
  const out = [];
67
168
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
@@ -109,8 +210,15 @@ export function run(argv = process.argv.slice(2)) {
109
210
  if (opts.help) { process.stdout.write(HELP); return 0; }
110
211
  if (opts.version) { process.stdout.write(`${version()}\n`); return 0; }
111
212
 
213
+ if (opts.command === 'render') {
214
+ return runRender(opts);
215
+ }
216
+ if (opts.command === 'lint') {
217
+ return runLint(opts);
218
+ }
219
+
112
220
  if (opts.input === '-' || (opts.input == null && !process.stdin.isTTY)) {
113
- const src = readFileSync(0, 'utf8');
221
+ const src = readStdin();
114
222
  if (opts.input !== '-' && !src.trim()) {
115
223
  process.stderr.write(`error: no input file given and stdin was empty\n\n${HELP}`);
116
224
  return 1;
@@ -128,23 +236,38 @@ export function run(argv = process.argv.slice(2)) {
128
236
 
129
237
  try {
130
238
  info = statSync(opts.input);
131
- rebuild();
132
239
  } catch (err) {
133
240
  process.stderr.write(`error: ${err.message}\n`);
134
241
  return 1;
135
242
  }
136
243
 
137
244
  if (opts.watch && !opts.stdout) {
138
- process.stderr.write('watching for changes… (Ctrl+C to stop)\n');
139
- const onChange = () => {
245
+ const onChange = (_eventType, filename) => {
246
+ if (info.isDirectory() && filename && extname(String(filename)) !== '.cm') return;
140
247
  try { rebuild(); } catch (err) { process.stderr.write(`error: ${err.message}\n`); }
141
248
  };
249
+ let watcher;
142
250
  try {
143
- watch(opts.input, { recursive: info.isDirectory() }, onChange);
144
- } catch {
145
- watch(opts.input, onChange); // non-recursive fallback (older Node / platforms)
251
+ try {
252
+ watcher = watch(opts.input, { recursive: info.isDirectory() }, onChange);
253
+ } catch {
254
+ watcher = watch(opts.input, onChange); // non-recursive fallback (older Node / platforms)
255
+ }
256
+ process.stderr.write('watching for changes… (Ctrl+C to stop)\n');
257
+ rebuild();
258
+ } catch (err) {
259
+ if (watcher) watcher.close();
260
+ process.stderr.write(`error: ${err.message}\n`);
261
+ return 1;
146
262
  }
147
263
  return 0;
148
264
  }
265
+
266
+ try {
267
+ rebuild();
268
+ } catch (err) {
269
+ process.stderr.write(`error: ${err.message}\n`);
270
+ return 1;
271
+ }
149
272
  return 0;
150
273
  }
package/src/index.d.ts ADDED
@@ -0,0 +1,25 @@
1
+ import type {
2
+ LintDiagnostic,
3
+ LintOptions,
4
+ RendererOptions,
5
+ RenderAnsiOptions,
6
+ } from '@chromamark/renderer';
7
+
8
+ export interface CompileOptions {
9
+ title?: string;
10
+ theme?: string;
11
+ rendererOptions?: RendererOptions;
12
+ }
13
+
14
+ export function render(source: unknown, options?: RendererOptions): string;
15
+ export function renderAnsi(source: unknown, options?: RenderAnsiOptions): string;
16
+ export function lint(source: unknown, options?: LintOptions): LintDiagnostic[];
17
+ export function theme(): string;
18
+ export function compile(source: unknown, options?: CompileOptions): string;
19
+
20
+ export type {
21
+ LintDiagnostic,
22
+ LintOptions,
23
+ RendererOptions,
24
+ RenderAnsiOptions,
25
+ };
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. */