@chromamark/renderer 0.2.2 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chromamark/renderer",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "ChromaMark renderer — a markdown-it plugin adding colored blocks, colored pills, collapsible sections, fields, meters and inline diff on top of CommonMark + GFM.",
5
5
  "license": "SEE LICENSE IN LICENSE.md",
6
6
  "author": "cjfravel-dev",
package/src/ansi.js ADDED
@@ -0,0 +1,302 @@
1
+ /**
2
+ * ANSI / terminal renderer for ChromaMark. Reuses the markdown-it parse (so all
3
+ * ChromaMark constructs and CommonMark + GFM are understood) and walks the token
4
+ * stream into styled text for a TTY: tones become SGR colors, pills become
5
+ * bracketed icon chips, blocks gain a colored left bar, meters draw a unicode
6
+ * bar. Falls back to plain, legible text whenever color is off (NO_COLOR,
7
+ * non-TTY, or `color: 'never'`).
8
+ */
9
+
10
+ import { createRenderer } from './index.js';
11
+
12
+ const ESC = '\x1b[';
13
+ const RESET = '\x1b[0m';
14
+
15
+ /** Basic-16 SGR foreground per semantic tone. */
16
+ const TONE_SGR = {
17
+ success: '32', danger: '31', warning: '33', info: '36', tip: '35', muted: '90',
18
+ };
19
+
20
+ /** A glyph that distinguishes each tone without relying on color. */
21
+ const TONE_ICON = {
22
+ success: '✓', danger: '✗', warning: '⚠', info: 'ℹ', tip: '✱', muted: '·',
23
+ };
24
+
25
+ /** A small CSS-name → RGB map so `color=<name>` still tints a terminal. */
26
+ const NAMED_RGB = {
27
+ red: [220, 50, 47], green: [35, 134, 54], blue: [56, 139, 253], yellow: [210, 153, 34],
28
+ orange: [219, 109, 40], purple: [130, 80, 223], magenta: [188, 76, 200], cyan: [57, 197, 207],
29
+ gray: [139, 148, 158], grey: [139, 148, 158], white: [230, 237, 243], black: [1, 4, 9],
30
+ };
31
+
32
+ const hexToRgb = (hex) => {
33
+ let h = hex.slice(1);
34
+ if (h.length === 3) h = h.split('').map((c) => c + c).join('');
35
+ if (h.length === 8) h = h.slice(0, 6);
36
+ const n = parseInt(h, 16);
37
+ return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
38
+ };
39
+
40
+ /** Resolve a token's meta to an SGR foreground code, or null (no color). */
41
+ function sgrFor(meta) {
42
+ if (!meta) return null;
43
+ if (meta.tone) return TONE_SGR[meta.tone] || null;
44
+ if (meta.color) {
45
+ if (meta.color.startsWith('#')) {
46
+ const [r, g, b] = hexToRgb(meta.color);
47
+ return `38;2;${r};${g};${b}`;
48
+ }
49
+ const rgb = NAMED_RGB[meta.color.toLowerCase()];
50
+ if (rgb) return `38;2;${rgb[0]};${rgb[1]};${rgb[2]}`;
51
+ }
52
+ return null;
53
+ }
54
+
55
+ const joinCodes = (...codes) => codes.filter(Boolean).join(';');
56
+
57
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
58
+ const visibleWidth = (s) => stripAnsi(s).length;
59
+ const padEndVisible = (s, w) => s + ' '.repeat(Math.max(0, w - visibleWidth(s)));
60
+
61
+ /** Wrap text in an SGR sequence when color is enabled and codes are present. */
62
+ function paint(ctx, s, codes) {
63
+ return ctx.color && codes ? `${ESC}${codes}m${s}${RESET}` : s;
64
+ }
65
+
66
+ function popCode(stack, code) {
67
+ const i = stack.lastIndexOf(code);
68
+ if (i !== -1) stack.splice(i, 1);
69
+ }
70
+
71
+ function renderPill(ctx, meta) {
72
+ const icon = meta.tone ? TONE_ICON[meta.tone] : '•';
73
+ return paint(ctx, `[${icon} ${meta.label}]`, joinCodes('1', sgrFor(meta)));
74
+ }
75
+
76
+ function renderMeter(ctx, meta, cells = 10) {
77
+ const filled = Math.round((parseFloat(meta.width) / 100) * cells);
78
+ const bar = '█'.repeat(filled) + '░'.repeat(cells - filled);
79
+ return `${paint(ctx, bar, sgrFor(meta))} ${meta.value}`;
80
+ }
81
+
82
+ function renderCritic(ctx, meta) {
83
+ switch (meta.kind) {
84
+ case 'add': return paint(ctx, meta.content, joinCodes('32', '4'));
85
+ case 'del': return paint(ctx, meta.content, joinCodes('31', '9'));
86
+ case 'sub': return paint(ctx, meta.old, joinCodes('31', '9')) + paint(ctx, meta.neu, joinCodes('32', '4'));
87
+ case 'mark': return paint(ctx, meta.content, '7');
88
+ case 'comment': return paint(ctx, `(${meta.content})`, '2');
89
+ default: return '';
90
+ }
91
+ }
92
+
93
+ /** Render an inline token array (with `.children`) into a styled one-liner. */
94
+ function renderInline(ctx, children) {
95
+ let out = '';
96
+ const stack = [];
97
+ const cur = () => stack.join(';');
98
+ for (const t of children) {
99
+ switch (t.type) {
100
+ case 'text': out += paint(ctx, t.content, cur()); break;
101
+ case 'softbreak': out += ' '; break;
102
+ case 'hardbreak': out += '\n'; break;
103
+ case 'strong_open': stack.push('1'); break;
104
+ case 'strong_close': popCode(stack, '1'); break;
105
+ case 'em_open': stack.push('3'); break;
106
+ case 'em_close': popCode(stack, '3'); break;
107
+ case 's_open': stack.push('9'); break;
108
+ case 's_close': popCode(stack, '9'); break;
109
+ case 'link_open': stack.push('4'); break;
110
+ case 'link_close': {
111
+ popCode(stack, '4');
112
+ const href = t.__href;
113
+ if (href && !href.startsWith('#')) out += paint(ctx, ` (${href})`, '2');
114
+ break;
115
+ }
116
+ case 'code_inline': out += paint(ctx, t.content, joinCodes(cur(), '36')); break;
117
+ case 'cm_pill': out += renderPill(ctx, t.meta); break;
118
+ case 'cm_text': out += paint(ctx, t.meta.label, joinCodes(cur(), sgrFor(t.meta))); break;
119
+ case 'cm_meter': out += renderMeter(ctx, t.meta); break;
120
+ case 'cm_critic': out += renderCritic(ctx, t.meta); break;
121
+ case 'image': out += paint(ctx, `[image: ${t.content || ''}]`, cur()); break;
122
+ case 'html_inline': out += paint(ctx, t.content, cur()); break;
123
+ default: if (t.content) out += paint(ctx, t.content, cur());
124
+ }
125
+ }
126
+ return out;
127
+ }
128
+
129
+ /** Carry a link's href from link_open to link_close for the inline renderer. */
130
+ function tagLinkHrefs(children) {
131
+ const open = [];
132
+ for (const t of children) {
133
+ if (t.type === 'link_open') open.push(t);
134
+ else if (t.type === 'link_close') { const o = open.pop(); if (o) t.__href = o.attrGet('href'); }
135
+ }
136
+ }
137
+
138
+ function inlineString(ctx, text) {
139
+ const toks = ctx.md.parseInline(text || '', {});
140
+ const kids = (toks[0] && toks[0].children) || [];
141
+ tagLinkHrefs(kids);
142
+ return renderInline(ctx, kids);
143
+ }
144
+
145
+ /** Build a shallow tree from the flat markdown-it token stream. */
146
+ function buildTree(tokens) {
147
+ const root = { type: 'root', children: [] };
148
+ const stack = [root];
149
+ for (const t of tokens) {
150
+ const top = stack[stack.length - 1];
151
+ if (t.nesting === 1) {
152
+ const node = { type: t.type.replace(/_open$/, ''), token: t, children: [] };
153
+ top.children.push(node);
154
+ stack.push(node);
155
+ } else if (t.nesting === -1) {
156
+ stack.pop();
157
+ } else {
158
+ top.children.push({ type: t.type, token: t, children: t.children || [] });
159
+ }
160
+ }
161
+ return root;
162
+ }
163
+
164
+ /** Render a list of block nodes, one blank line between blocks (lists hug). */
165
+ function renderBlocks(ctx, children) {
166
+ const out = [];
167
+ const rendered = children
168
+ .map((c) => ({ node: c, lines: renderNode(ctx, c) }))
169
+ .filter((b) => b.lines.length);
170
+ rendered.forEach((b, i) => {
171
+ const isList = b.node.type === 'bullet_list' || b.node.type === 'ordered_list';
172
+ if (i && !isList) out.push('');
173
+ out.push(...b.lines);
174
+ });
175
+ return out;
176
+ }
177
+
178
+ function renderListItem(ctx, item, marker) {
179
+ const lines = renderBlocks(ctx, item.children);
180
+ const pad = ' '.repeat(marker.length);
181
+ return lines.map((l, i) => (i === 0 ? marker : pad) + l);
182
+ }
183
+
184
+ function renderTable(ctx, node) {
185
+ const rows = [];
186
+ const collect = (n) => {
187
+ for (const c of n.children) {
188
+ if (c.type === 'tr') {
189
+ const cells = c.children
190
+ .filter((x) => x.type === 'th' || x.type === 'td')
191
+ .map((x) => {
192
+ const inline = x.children.find((k) => k.type === 'inline');
193
+ if (inline) tagLinkHrefs(inline.children);
194
+ return inline ? renderInline(ctx, inline.children) : '';
195
+ });
196
+ rows.push(cells);
197
+ } else if (c.children) collect(c);
198
+ }
199
+ };
200
+ collect(node);
201
+ if (!rows.length) return [];
202
+ const cols = Math.max(...rows.map((r) => r.length));
203
+ const widths = [];
204
+ for (let c = 0; c < cols; c++) widths[c] = Math.max(...rows.map((r) => visibleWidth(r[c] || '')));
205
+ const fmt = (r) => r.map((cell, c) => padEndVisible(cell || '', widths[c])).join(' ').replace(/\s+$/, '');
206
+ const out = [fmt(rows[0])];
207
+ out.push(widths.map((w) => paint(ctx, '─'.repeat(w), '2')).join(' '));
208
+ for (let i = 1; i < rows.length; i++) out.push(fmt(rows[i]));
209
+ return out;
210
+ }
211
+
212
+ function renderContainer(ctx, node) {
213
+ const meta = node.token.meta;
214
+ const codes = sgrFor(meta);
215
+ const inner = renderBlocks(ctx, node.children);
216
+ const bar = paint(ctx, '┃', codes) + ' ';
217
+ const lines = [];
218
+ if (meta.structure === 'details') {
219
+ lines.push(bar + paint(ctx, `▾ ${stripAnsi(inlineString(ctx, meta.summary))}`, joinCodes('1', codes)));
220
+ } else if (meta.title) {
221
+ const icon = meta.tone ? `${TONE_ICON[meta.tone]} ` : (meta.color ? '• ' : '');
222
+ lines.push(bar + paint(ctx, icon + stripAnsi(inlineString(ctx, meta.title)), joinCodes('1', codes)));
223
+ }
224
+ for (const l of inner) lines.push(bar + l);
225
+ return lines;
226
+ }
227
+
228
+ function renderFields(ctx, node) {
229
+ const rows = node.token.meta.rows;
230
+ const keyW = Math.max(0, ...rows.map(([k]) => k.length));
231
+ return rows.map(([k, v]) => ` ${paint(ctx, padEndVisible(k, keyW), '1')} ${inlineString(ctx, v)}`);
232
+ }
233
+
234
+ function renderNode(ctx, node) {
235
+ switch (node.type) {
236
+ case 'root': return renderBlocks(ctx, node.children);
237
+ case 'paragraph': {
238
+ const inline = node.children.find((c) => c.type === 'inline');
239
+ if (!inline) return [];
240
+ tagLinkHrefs(inline.children);
241
+ return renderInline(ctx, inline.children).split('\n');
242
+ }
243
+ case 'heading': {
244
+ const inline = node.children.find((c) => c.type === 'inline');
245
+ const text = inline ? (tagLinkHrefs(inline.children), renderInline(ctx, inline.children)) : '';
246
+ const styled = paint(ctx, text, joinCodes('1', '4'));
247
+ return [styled];
248
+ }
249
+ case 'blockquote':
250
+ return renderBlocks(ctx, node.children).map((l) => paint(ctx, '│', '2') + ' ' + l);
251
+ case 'bullet_list':
252
+ return node.children
253
+ .filter((c) => c.type === 'list_item')
254
+ .flatMap((item) => renderListItem(ctx, item, '• '));
255
+ case 'ordered_list': {
256
+ let n = Number(node.token.attrGet('start') || 1);
257
+ return node.children
258
+ .filter((c) => c.type === 'list_item')
259
+ .flatMap((item) => renderListItem(ctx, item, `${n++}. `));
260
+ }
261
+ case 'list_item': return renderBlocks(ctx, node.children);
262
+ case 'table': return renderTable(ctx, node);
263
+ case 'fence':
264
+ case 'code_block':
265
+ return String(node.token.content).replace(/\n$/, '').split('\n').map((l) => paint(ctx, ' ' + l, '2'));
266
+ case 'hr': return [paint(ctx, '─'.repeat(Math.min(ctx.width, 60)), '2')];
267
+ case 'cm_container': return renderContainer(ctx, node);
268
+ case 'cm_fields': return renderFields(ctx, node);
269
+ case 'inline': {
270
+ tagLinkHrefs(node.children);
271
+ return renderInline(ctx, node.children).split('\n');
272
+ }
273
+ case 'html_block':
274
+ return String(node.token.content).replace(/\n$/, '').split('\n');
275
+ default:
276
+ return node.token && node.token.content ? [node.token.content] : [];
277
+ }
278
+ }
279
+
280
+ /** Decide whether to emit color for the given option and environment. */
281
+ export function colorEnabled(option, env = process.env, isTTY = process.stdout && process.stdout.isTTY) {
282
+ if (option === 'always') return true;
283
+ if (option === 'never') return false;
284
+ return Boolean(isTTY) && !('NO_COLOR' in env) && env.TERM !== 'dumb';
285
+ }
286
+
287
+ /**
288
+ * Render ChromaMark source to ANSI-styled text for a terminal.
289
+ * @param {string} src ChromaMark source
290
+ * @param {{color?:'auto'|'always'|'never', width?:number, rendererOptions?:object}} [options]
291
+ */
292
+ export function renderAnsi(src, options = {}) {
293
+ const md = createRenderer(options.rendererOptions);
294
+ const ctx = {
295
+ md,
296
+ color: colorEnabled(options.color || 'auto'),
297
+ width: options.width || (process.stdout && process.stdout.columns) || 80,
298
+ };
299
+ const tokens = md.parse(String(src ?? ''), {});
300
+ const tree = buildTree(tokens);
301
+ return renderBlocks(ctx, tree.children).join('\n') + '\n';
302
+ }
package/src/index.js CHANGED
@@ -9,6 +9,9 @@ import inlinePlugin from './inline.js';
9
9
  import criticPlugin from './critic.js';
10
10
  import containerPlugin from './containers.js';
11
11
 
12
+ export { renderAnsi, colorEnabled } from './ansi.js';
13
+ export { lint } from './lint.js';
14
+
12
15
  const DEFAULTS = {
13
16
  container: true, // ::: colored callouts
14
17
  details: true, // ::: details collapsibles
package/src/lint.js ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * A linter for ChromaMark source. Because every construct degrades to readable
3
+ * text when malformed, mistakes are otherwise silent — this flags the ones that
4
+ * usually mean the author wanted rich output:
5
+ * CM001 a construct wrapped in backticks (renders as code, not a pill)
6
+ * CM002 an unknown tone/color in a pill, colored text, or meter
7
+ * CM003 an unknown block kind (the ::: fence will render as literal text)
8
+ * CM004 a meter value that is not NN% or A/B (B ≠ 0)
9
+ * CM005 a container that is opened but never closed
10
+ *
11
+ * Each diagnostic is { line, column, severity, rule, message } with 1-based
12
+ * positions. Positions are approximate for constructs inside multi-line code
13
+ * spans, which are rare in practice.
14
+ */
15
+
16
+ import { parseSpec, resolveTone } from './tones.js';
17
+
18
+ const BLOCK_KINDS = new Set(['details', 'fields', 'block']);
19
+ const HEX = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
20
+
21
+ const isBlockKind = (k) =>
22
+ BLOCK_KINDS.has(k) || Boolean(resolveTone(k)) || k.startsWith('color=') || HEX.test(k);
23
+
24
+ function meterValueValid(value) {
25
+ const v = String(value == null ? '' : value).trim();
26
+ if (/^\d+(?:\.\d+)?\s*%$/.test(v)) return true;
27
+ const frac = /^(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)$/.exec(v);
28
+ return Boolean(frac) && parseFloat(frac[2]) !== 0;
29
+ }
30
+
31
+ /** True if the char at `idx` is preceded by an odd run of backslashes. */
32
+ function isEscaped(line, idx) {
33
+ let n = 0;
34
+ for (let j = idx - 1; j >= 0 && line[j] === '\\'; j--) n++;
35
+ return (n & 1) === 1;
36
+ }
37
+
38
+ /** Backtick-delimited code-span ranges on one line (approximate). */
39
+ function codeSpans(line) {
40
+ const ranges = [];
41
+ const re = /(`+)(.+?)\1/g;
42
+ for (let m; (m = re.exec(line)); ) {
43
+ ranges.push({ start: m.index, end: m.index + m[0].length, inner: m[2] });
44
+ if (re.lastIndex === m.index) re.lastIndex++;
45
+ }
46
+ return ranges;
47
+ }
48
+
49
+ const INLINE = /\[([!.=])([^\s\]]+)(?:\s+([^\]]*))?\]/g;
50
+ const CONSTRUCT_IN_CODE = /\[[!.=]|\{(?:\+\+|--|~~|==|>>)/;
51
+
52
+ function scanInline(line, row, diags) {
53
+ const spans = codeSpans(line);
54
+ for (const s of spans) {
55
+ if (CONSTRUCT_IN_CODE.test(s.inner)) {
56
+ diags.push({
57
+ line: row, column: s.start + 1, severity: 'warning', rule: 'CM001',
58
+ message: 'ChromaMark construct is inside backticks; it renders as code, not rich output',
59
+ });
60
+ }
61
+ }
62
+ const inSpan = (idx) => spans.some((s) => idx >= s.start && idx < s.end);
63
+
64
+ INLINE.lastIndex = 0;
65
+ for (let m; (m = INLINE.exec(line)); ) {
66
+ const idx = m.index;
67
+ if (inSpan(idx) || isEscaped(line, idx)) continue;
68
+ const after = line[idx + m[0].length];
69
+ if (after === '(' || after === '[') continue; // markdown link / reference
70
+ const [, sigil, specTok, label] = m;
71
+ const spec = parseSpec(specTok);
72
+ if (!spec) {
73
+ const what = sigil === '!' ? 'pill' : sigil === '.' ? 'colored text' : 'meter';
74
+ diags.push({
75
+ line: row, column: idx + 1, severity: 'warning', rule: 'CM002',
76
+ message: `unknown tone/color "${specTok}"; this looks like a ${what} but renders as literal text`,
77
+ });
78
+ } else if (sigil === '=' && !meterValueValid(label)) {
79
+ diags.push({
80
+ line: row, column: idx + 1, severity: 'warning', rule: 'CM004',
81
+ message: label ? `meter value "${label}" is not NN% or A/B` : 'meter is missing a value (NN% or A/B)',
82
+ });
83
+ }
84
+ }
85
+ }
86
+
87
+ const FENCE_CODE = /^ {0,3}(`{3,}|~{3,})/;
88
+ const FENCE_CODE_CLOSE = /^ {0,3}(`{3,}|~{3,})\s*$/;
89
+ const CONTAINER = /^( {0,3})(:{3,})(.*)$/;
90
+
91
+ /**
92
+ * Lint ChromaMark source.
93
+ * @param {string} src
94
+ * @param {{disable?: string[]}} [options] rule ids to suppress (e.g. ['CM001'])
95
+ * @returns {{line:number, column:number, severity:string, rule:string, message:string}[]}
96
+ */
97
+ export function lint(src, options = {}) {
98
+ const disabled = new Set(options.disable || []);
99
+ const lines = String(src ?? '').split('\n');
100
+ const diags = [];
101
+ const openStack = [];
102
+ let inCode = false;
103
+ let fenceCh = '';
104
+ let fenceLen = 0;
105
+
106
+ for (let i = 0; i < lines.length; i++) {
107
+ const line = lines[i];
108
+ const row = i + 1;
109
+
110
+ if (inCode) {
111
+ const close = FENCE_CODE_CLOSE.exec(line);
112
+ if (close && close[1][0] === fenceCh && close[1].length >= fenceLen) inCode = false;
113
+ continue;
114
+ }
115
+ const codeOpen = FENCE_CODE.exec(line);
116
+ if (codeOpen) {
117
+ inCode = true;
118
+ fenceCh = codeOpen[1][0];
119
+ fenceLen = codeOpen[1].length;
120
+ continue;
121
+ }
122
+
123
+ const cont = CONTAINER.exec(line);
124
+ if (cont) {
125
+ const colons = cont[2].length;
126
+ const info = cont[3].trim();
127
+ if (info === '') {
128
+ for (let s = openStack.length - 1; s >= 0; s--) {
129
+ if (colons >= openStack[s].colons) { openStack.splice(s, 1); break; }
130
+ }
131
+ continue;
132
+ }
133
+ const kind = info.split(/\s+/)[0];
134
+ if (!isBlockKind(kind.toLowerCase())) {
135
+ diags.push({
136
+ line: row, column: cont[1].length + colons + 1, severity: 'warning', rule: 'CM003',
137
+ message: `unknown block kind "${kind}"; this ":::" fence renders as literal text`,
138
+ });
139
+ } else {
140
+ openStack.push({ line: row, colons, kind });
141
+ }
142
+ }
143
+
144
+ scanInline(line, row, diags);
145
+ }
146
+
147
+ for (const o of openStack) {
148
+ diags.push({
149
+ line: o.line, column: 1, severity: 'warning', rule: 'CM005',
150
+ message: `container "${o.kind}" is opened here but never closed (auto-closes at end of input)`,
151
+ });
152
+ }
153
+
154
+ return diags
155
+ .filter((d) => !disabled.has(d.rule))
156
+ .sort((a, b) => a.line - b.line || a.column - b.column);
157
+ }