@chromamark/renderer 0.2.1 → 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/README.md +19 -0
- package/dist/chromamark.esm.js +73 -7
- package/dist/chromamark.min.js +12 -12
- package/package.json +1 -1
- package/src/ansi.js +302 -0
- package/src/browser-core.js +17 -6
- package/src/containers.js +69 -2
- package/src/index.js +3 -0
- package/src/lint.js +157 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chromamark/renderer",
|
|
3
|
-
"version": "0.
|
|
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/browser-core.js
CHANGED
|
@@ -36,18 +36,29 @@ export function injectTheme(doc) {
|
|
|
36
36
|
(d.head || d.documentElement).appendChild(style);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
/** Strip shared leading indentation so source can be indented inside HTML.
|
|
39
|
+
/** Strip shared leading indentation so source can be indented inside HTML.
|
|
40
|
+
* Only the common leading-whitespace prefix is removed; tabs that appear in
|
|
41
|
+
* content (e.g. inside a code span) are preserved. */
|
|
40
42
|
function dedent(text) {
|
|
41
|
-
const lines = text.replace(/\
|
|
43
|
+
const lines = text.replace(/\r/g, '').split('\n');
|
|
42
44
|
while (lines.length && lines[0].trim() === '') lines.shift();
|
|
43
45
|
while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
|
|
44
|
-
let
|
|
46
|
+
let prefix = null;
|
|
45
47
|
for (const line of lines) {
|
|
46
48
|
if (!line.trim()) continue;
|
|
47
|
-
|
|
49
|
+
const lead = line.match(/^[ \t]*/)[0];
|
|
50
|
+
if (prefix === null) {
|
|
51
|
+
prefix = lead;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
let i = 0;
|
|
55
|
+
const lim = Math.min(prefix.length, lead.length);
|
|
56
|
+
while (i < lim && prefix[i] === lead[i]) i++;
|
|
57
|
+
prefix = prefix.slice(0, i);
|
|
58
|
+
if (prefix === '') break;
|
|
48
59
|
}
|
|
49
|
-
|
|
50
|
-
return lines.map((line) => line.slice(
|
|
60
|
+
const cut = prefix ? prefix.length : 0;
|
|
61
|
+
return lines.map((line) => line.slice(cut)).join('\n');
|
|
51
62
|
}
|
|
52
63
|
|
|
53
64
|
function resolve(target) {
|
package/src/containers.js
CHANGED
|
@@ -95,13 +95,56 @@ function makeRule(enabled) {
|
|
|
95
95
|
|
|
96
96
|
let nextLine = startLine;
|
|
97
97
|
let autoClosed = false;
|
|
98
|
+
let fenceCh = 0;
|
|
99
|
+
let fenceLen = 0;
|
|
98
100
|
for (;;) {
|
|
99
101
|
nextLine++;
|
|
100
102
|
if (nextLine >= endLine) break;
|
|
101
103
|
const lstart = state.bMarks[nextLine] + state.tShift[nextLine];
|
|
102
104
|
const lmax = state.eMarks[nextLine];
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
const indent = state.sCount[nextLine] - state.blkIndent;
|
|
106
|
+
|
|
107
|
+
// Skip over fenced code blocks so a ::: (or fence) line inside one counts
|
|
108
|
+
// as content, not as the container's closing fence.
|
|
109
|
+
if (fenceCh) {
|
|
110
|
+
if (indent < 4 && state.src.charCodeAt(lstart) === fenceCh) {
|
|
111
|
+
let q = lstart;
|
|
112
|
+
while (q < lmax && state.src.charCodeAt(q) === fenceCh) q++;
|
|
113
|
+
if (q - lstart >= fenceLen) {
|
|
114
|
+
let r = q;
|
|
115
|
+
while (r < lmax && (state.src.charCodeAt(r) === 0x20 || state.src.charCodeAt(r) === 0x09)) r++;
|
|
116
|
+
if (r >= lmax) {
|
|
117
|
+
fenceCh = 0;
|
|
118
|
+
fenceLen = 0;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const openCh = state.src.charCodeAt(lstart);
|
|
125
|
+
if (indent < 4 && (openCh === 0x60 || openCh === 0x7e)) {
|
|
126
|
+
let q = lstart;
|
|
127
|
+
while (q < lmax && state.src.charCodeAt(q) === openCh) q++;
|
|
128
|
+
if (q - lstart >= 3) {
|
|
129
|
+
let ok = true;
|
|
130
|
+
if (openCh === 0x60) {
|
|
131
|
+
for (let r = q; r < lmax; r++) {
|
|
132
|
+
if (state.src.charCodeAt(r) === 0x60) {
|
|
133
|
+
ok = false;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (ok) {
|
|
139
|
+
fenceCh = openCh;
|
|
140
|
+
fenceLen = q - lstart;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (openCh !== MARKER) continue;
|
|
147
|
+
if (indent >= 4) continue;
|
|
105
148
|
const closeLen = fenceLength(state.src, lstart, lmax);
|
|
106
149
|
if (closeLen < openLen) continue;
|
|
107
150
|
let p = lstart + closeLen;
|
|
@@ -199,4 +242,28 @@ export default function containerPlugin(md, enabled) {
|
|
|
199
242
|
}
|
|
200
243
|
return html + '</dl>';
|
|
201
244
|
};
|
|
245
|
+
|
|
246
|
+
// Container bodies are always safe: escape any raw HTML inside a container so
|
|
247
|
+
// ChromaMark stays consistent with its force-escaped titles/fields even when
|
|
248
|
+
// attached to a host MarkdownIt({ html: true }). Raw HTML outside a container
|
|
249
|
+
// still honors the host setting. Under the default html:false there are no
|
|
250
|
+
// html_block/html_inline tokens, so this is a no-op.
|
|
251
|
+
md.core.ruler.push('cm_sanitize_bodies', (state) => {
|
|
252
|
+
let depth = 0;
|
|
253
|
+
for (const token of state.tokens) {
|
|
254
|
+
if (token.type === 'cm_container_open') {
|
|
255
|
+
depth++;
|
|
256
|
+
} else if (token.type === 'cm_container_close') {
|
|
257
|
+
depth--;
|
|
258
|
+
} else if (depth > 0) {
|
|
259
|
+
if (token.type === 'html_block') {
|
|
260
|
+
token.content = esc(token.content);
|
|
261
|
+
} else if (token.type === 'inline' && token.children) {
|
|
262
|
+
for (const child of token.children) {
|
|
263
|
+
if (child.type === 'html_inline') child.content = esc(child.content);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
});
|
|
202
269
|
}
|
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
|
+
}
|