@chromamark/renderer 0.1.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 ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@chromamark/renderer",
3
+ "version": "0.1.0",
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
+ "license": "SEE LICENSE IN LICENSE.md",
6
+ "author": "cjfravel-dev",
7
+ "homepage": "https://github.com/cjfravel-dev/ChromaMark#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/cjfravel-dev/ChromaMark.git",
11
+ "directory": "packages/renderer"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/cjfravel-dev/ChromaMark/issues"
15
+ },
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "engines": {
20
+ "node": ">=18"
21
+ },
22
+ "type": "module",
23
+ "main": "./src/index.js",
24
+ "browser": "./dist/chromamark.esm.js",
25
+ "unpkg": "./dist/chromamark.min.js",
26
+ "jsdelivr": "./dist/chromamark.min.js",
27
+ "exports": {
28
+ ".": "./src/index.js",
29
+ "./browser": "./dist/chromamark.esm.js",
30
+ "./global": "./dist/chromamark.min.js",
31
+ "./theme.css": "./theme/chromamark.css"
32
+ },
33
+ "files": [
34
+ "src/",
35
+ "theme/",
36
+ "dist/"
37
+ ],
38
+ "scripts": {
39
+ "test": "node --test",
40
+ "build": "node build.js",
41
+ "prepublishOnly": "node build.js"
42
+ },
43
+ "keywords": [
44
+ "chromamark",
45
+ "markdown",
46
+ "markdown-it",
47
+ "markdown-it-plugin",
48
+ "commonmark",
49
+ "gfm",
50
+ "admonition",
51
+ "callout"
52
+ ],
53
+ "dependencies": {
54
+ "markdown-it": "^14.1.0"
55
+ },
56
+ "devDependencies": {
57
+ "esbuild": "^0.24.2",
58
+ "jsdom": "^29.1.1"
59
+ }
60
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * DOM-facing ChromaMark browser API, free of the esbuild-only CSS import so it
3
+ * can be unit-tested under Node/jsdom. The bundle entry (browser.js) supplies
4
+ * the theme via configureTheme() and adds the CDN auto-init.
5
+ */
6
+
7
+ import { createRenderer, render as renderString } from './index.js';
8
+
9
+ const STYLE_ID = 'chromamark-theme';
10
+ const DONE_ATTR = 'data-chromamark-done';
11
+ const DEFAULT_SELECTOR =
12
+ 'script[type="text/chromamark"], template.chromamark, [data-chromamark], .chromamark';
13
+
14
+ /** The theme stylesheet (populated by the bundle via configureTheme). */
15
+ export let theme = '';
16
+
17
+ /** Set the theme CSS used by injectTheme(). Called once by the bundle entry. */
18
+ export function configureTheme(css) {
19
+ theme = css || '';
20
+ }
21
+
22
+ /** Render a ChromaMark string to an HTML fragment. */
23
+ export function render(src, options) {
24
+ return renderString(src, options);
25
+ }
26
+
27
+ /** Inject the theme <style> once (idempotent). No-op outside a DOM. */
28
+ export function injectTheme(doc) {
29
+ const d = doc || (typeof document !== 'undefined' ? document : null);
30
+ if (!d || d.getElementById(STYLE_ID)) return;
31
+ const style = d.createElement('style');
32
+ style.id = STYLE_ID;
33
+ style.textContent = theme;
34
+ (d.head || d.documentElement).appendChild(style);
35
+ }
36
+
37
+ /** Strip shared leading indentation so source can be indented inside HTML. */
38
+ function dedent(text) {
39
+ const lines = text.replace(/\t/g, ' ').replace(/\r/g, '').split('\n');
40
+ while (lines.length && lines[0].trim() === '') lines.shift();
41
+ while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
42
+ let min = Infinity;
43
+ for (const line of lines) {
44
+ if (!line.trim()) continue;
45
+ min = Math.min(min, line.match(/^ */)[0].length);
46
+ }
47
+ if (!Number.isFinite(min)) min = 0;
48
+ return lines.map((line) => line.slice(min)).join('\n');
49
+ }
50
+
51
+ function resolve(target) {
52
+ return typeof target === 'string' ? document.querySelector(target) : target;
53
+ }
54
+
55
+ /** Read a holder's raw ChromaMark source. <template> keeps its content in a
56
+ * detached fragment, so read that rather than the (empty) textContent. */
57
+ function sourceOf(el, tag) {
58
+ if (tag === 'template' && el.content) return el.content.textContent || '';
59
+ return el.textContent || '';
60
+ }
61
+
62
+ /**
63
+ * Render one element in place. For a <script>/<template> holder the rendered
64
+ * output is inserted as a following sibling; for any other element its own
65
+ * contents are replaced. Returns the element containing the rendered HTML.
66
+ */
67
+ export function renderElement(target, options) {
68
+ const el = resolve(target);
69
+ if (!el || el.hasAttribute(DONE_ATTR)) return null;
70
+
71
+ const tag = (el.tagName || '').toLowerCase();
72
+ const html = renderString(dedent(sourceOf(el, tag)), options);
73
+ el.setAttribute(DONE_ATTR, '');
74
+
75
+ if (tag === 'script' || tag === 'template') {
76
+ const out = document.createElement('div');
77
+ out.className = 'chromamark-output';
78
+ out.innerHTML = html;
79
+ el.parentNode.insertBefore(out, el.nextSibling);
80
+ return out;
81
+ }
82
+ el.innerHTML = html;
83
+ el.classList.add('chromamark-output');
84
+ return el;
85
+ }
86
+
87
+ /** Render every element matching the selector (defaults to the standard targets). */
88
+ export function renderAll(selector, options) {
89
+ const nodes = document.querySelectorAll(selector || DEFAULT_SELECTOR);
90
+ return Array.from(nodes).map((el) => renderElement(el, options));
91
+ }
92
+
93
+ /** One-call setup: inject the theme, then render all targets on the page. */
94
+ export function autoRender(options = {}) {
95
+ injectTheme();
96
+ return renderAll(options.selector, options);
97
+ }
98
+
99
+ export const ChromaMark = {
100
+ render,
101
+ renderElement,
102
+ renderAll,
103
+ injectTheme,
104
+ autoRender,
105
+ createRenderer,
106
+ get theme() {
107
+ return theme;
108
+ },
109
+ };
110
+
111
+ export default ChromaMark;
package/src/browser.js ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Browser bundle entry point for ChromaMark. Wraps the DOM API in browser-core
3
+ * with the esbuild-embedded theme and a CDN auto-init:
4
+ *
5
+ * <script type="text/chromamark" id="report">::: success ... :::</script>
6
+ * <script src=".../chromamark.min.js" data-chromamark-auto></script>
7
+ *
8
+ * Or drive it explicitly:
9
+ * import ChromaMark from '.../chromamark.esm.js';
10
+ * ChromaMark.autoRender(); // inject theme + render targets
11
+ * ChromaMark.renderElement('#report'); // render one section
12
+ */
13
+
14
+ import themeCss from '../theme/chromamark.css';
15
+ import ChromaMark, { configureTheme, autoRender } from './browser-core.js';
16
+
17
+ configureTheme(themeCss);
18
+
19
+ export * from './browser-core.js';
20
+ export default ChromaMark;
21
+
22
+ /*
23
+ * CDN convenience: when loaded as a classic <script … data-chromamark-auto>,
24
+ * inject the theme and render all targets once the DOM is ready. ES module
25
+ * imports (currentScript === null) opt in explicitly via autoRender().
26
+ */
27
+ (function autoInit() {
28
+ if (typeof document === 'undefined') return;
29
+ const script = document.currentScript;
30
+ if (!script || !script.hasAttribute('data-chromamark-auto')) return;
31
+ if (document.readyState === 'loading') {
32
+ document.addEventListener('DOMContentLoaded', () => autoRender(), { once: true });
33
+ } else {
34
+ autoRender();
35
+ }
36
+ })();
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Block-level ChromaMark containers, fenced with three or more colons:
3
+ * ::: <tone|block> [color=…] [title] → colored callout
4
+ * ::: details [open] [tone] <summary> → collapsible section
5
+ * ::: fields → key/value definition list
6
+ *
7
+ * Nesting uses more colons on the outer fence. The find-closing algorithm is
8
+ * adapted from markdown-it-container.
9
+ */
10
+
11
+ import { resolveTone, parseSpec } from './tones.js';
12
+
13
+ const MARKER = 0x3a; // :
14
+ const MIN_FENCE = 3;
15
+
16
+ const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
17
+
18
+ function parseOpener(info) {
19
+ if (!info) return null;
20
+ const tokens = info.split(/\s+/);
21
+ const kind = tokens.shift().toLowerCase();
22
+
23
+ let structure;
24
+ let tone = null;
25
+ let color = null;
26
+ let open = false;
27
+
28
+ if (kind === 'details') {
29
+ structure = 'details';
30
+ } else if (kind === 'fields') {
31
+ return { structure: 'fields' };
32
+ } else if (kind === 'block') {
33
+ structure = 'callout';
34
+ } else {
35
+ const t = resolveTone(kind);
36
+ if (!t) return null;
37
+ structure = 'callout';
38
+ tone = t;
39
+ }
40
+
41
+ while (tokens.length) {
42
+ const tk = tokens[0];
43
+ const low = tk.toLowerCase();
44
+ if (structure === 'details' && low === 'open') {
45
+ open = true;
46
+ tokens.shift();
47
+ continue;
48
+ }
49
+ if (low.startsWith('color=')) {
50
+ const spec = parseSpec(tk);
51
+ if (spec && spec.color) {
52
+ color = spec.color;
53
+ tokens.shift();
54
+ continue;
55
+ }
56
+ break;
57
+ }
58
+ if (structure === 'details' && tone === null && resolveTone(tk)) {
59
+ tone = resolveTone(tk);
60
+ tokens.shift();
61
+ continue;
62
+ }
63
+ break;
64
+ }
65
+
66
+ const rest = tokens.join(' ').trim();
67
+ if (structure === 'details') {
68
+ return { structure, tone, color, open, summary: rest || 'Details' };
69
+ }
70
+ return { structure, tone, color, title: rest || (tone ? cap(tone) : '') };
71
+ }
72
+
73
+ function fenceLength(src, pos, max) {
74
+ let count = 0;
75
+ let p = pos;
76
+ while (p < max && src.charCodeAt(p) === MARKER) {
77
+ count++;
78
+ p++;
79
+ }
80
+ return count;
81
+ }
82
+
83
+ function makeRule(enabled) {
84
+ return function chromaContainer(state, startLine, endLine, silent) {
85
+ const start = state.bMarks[startLine] + state.tShift[startLine];
86
+ const max = state.eMarks[startLine];
87
+ if (state.src.charCodeAt(start) !== MARKER) return false;
88
+
89
+ const openLen = fenceLength(state.src, start, max);
90
+ if (openLen < MIN_FENCE) return false;
91
+
92
+ const parsed = parseOpener(state.src.slice(start + openLen, max).trim());
93
+ if (!parsed || !enabled[parsed.structure]) return false;
94
+ if (silent) return true;
95
+
96
+ let nextLine = startLine;
97
+ let autoClosed = false;
98
+ for (;;) {
99
+ nextLine++;
100
+ if (nextLine >= endLine) break;
101
+ const lstart = state.bMarks[nextLine] + state.tShift[nextLine];
102
+ const lmax = state.eMarks[nextLine];
103
+ if (state.src.charCodeAt(lstart) !== MARKER) continue;
104
+ if (state.sCount[nextLine] - state.blkIndent >= 4) continue;
105
+ const closeLen = fenceLength(state.src, lstart, lmax);
106
+ if (closeLen < openLen) continue;
107
+ let p = lstart + closeLen;
108
+ while (p < lmax && (state.src.charCodeAt(p) === 0x20 || state.src.charCodeAt(p) === 0x09)) p++;
109
+ if (p < lmax) continue;
110
+ autoClosed = true;
111
+ break;
112
+ }
113
+
114
+ const oldParent = state.parentType;
115
+ const oldLineMax = state.lineMax;
116
+ state.parentType = 'chroma_container';
117
+ state.lineMax = nextLine;
118
+
119
+ if (parsed.structure === 'fields') {
120
+ const rows = [];
121
+ for (let ln = startLine + 1; ln < nextLine; ln++) {
122
+ const line = state.src.slice(state.bMarks[ln] + state.tShift[ln], state.eMarks[ln]);
123
+ if (!line.trim()) continue;
124
+ const ci = line.indexOf(':');
125
+ if (ci === -1) rows.push([line.trim(), '']);
126
+ else rows.push([line.slice(0, ci).trim(), line.slice(ci + 1).trim()]);
127
+ }
128
+ const token = state.push('cm_fields', '', 0);
129
+ token.meta = { rows };
130
+ token.map = [startLine, nextLine];
131
+ } else {
132
+ const openToken = state.push('cm_container_open', 'div', 1);
133
+ openToken.meta = parsed;
134
+ openToken.block = true;
135
+ openToken.map = [startLine, nextLine];
136
+
137
+ state.md.block.tokenize(state, startLine + 1, nextLine);
138
+
139
+ const closeToken = state.push('cm_container_close', 'div', -1);
140
+ closeToken.meta = parsed;
141
+ closeToken.block = true;
142
+ }
143
+
144
+ state.parentType = oldParent;
145
+ state.lineMax = oldLineMax;
146
+ state.line = nextLine + (autoClosed ? 1 : 0);
147
+ return true;
148
+ };
149
+ }
150
+
151
+ export default function containerPlugin(md, enabled) {
152
+ md.block.ruler.before('fence', 'cm_container', makeRule(enabled), {
153
+ alt: ['paragraph', 'reference', 'blockquote', 'list'],
154
+ });
155
+ const esc = md.utils.escapeHtml;
156
+
157
+ function decorate(meta) {
158
+ const custom = meta.color ? ' cm-custom' : '';
159
+ const style = meta.color ? ` style="--fg:${esc(meta.color)}"` : '';
160
+ const tone = !meta.color && meta.tone ? ` data-tone="${meta.tone}"` : '';
161
+ return { custom, style, tone };
162
+ }
163
+
164
+ md.renderer.rules.cm_container_open = (tokens, idx) => {
165
+ const meta = tokens[idx].meta;
166
+ const { custom, style, tone } = decorate(meta);
167
+ if (meta.structure === 'details') {
168
+ const open = meta.open ? ' open' : '';
169
+ return (
170
+ `<details class="cm-details${custom}"${tone}${style}${open}>` +
171
+ `<summary>${esc(meta.summary)}</summary><div class="cm-body">`
172
+ );
173
+ }
174
+ let html = `<div class="cm-block${custom}"${tone}${style}>`;
175
+ if (meta.title) html += `<div class="cm-title">${esc(meta.title)}</div>`;
176
+ return html + '<div class="cm-body">';
177
+ };
178
+
179
+ md.renderer.rules.cm_container_close = (tokens, idx) =>
180
+ tokens[idx].meta.structure === 'details' ? '</div></details>' : '</div></div>';
181
+
182
+ md.renderer.rules.cm_fields = (tokens, idx) => {
183
+ // Field values go through renderInline; force html:false so raw HTML is
184
+ // escaped regardless of the host markdown-it configuration (defense in depth).
185
+ const prevHtml = md.options.html;
186
+ md.options.html = false;
187
+ try {
188
+ let html = '<dl class="cm-fields">';
189
+ for (const [key, value] of tokens[idx].meta.rows) {
190
+ html += `<dt>${esc(key)}</dt><dd>${md.renderInline(value)}</dd>`;
191
+ }
192
+ return html + '</dl>';
193
+ } finally {
194
+ md.options.html = prevHtml;
195
+ }
196
+ };
197
+ }
package/src/critic.js ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Inline diff via CriticMarkup (http://criticmarkup.com/):
3
+ * {++insert++} {--delete--} {~~old~>new~~} {==highlight==} {>>comment<<}
4
+ * Content is treated as plain text and HTML-escaped on output.
5
+ */
6
+
7
+ const OPEN = 0x7b; // {
8
+
9
+ const KINDS = {
10
+ '++': { kind: 'add', close: '++}' },
11
+ '--': { kind: 'del', close: '--}' },
12
+ '~~': { kind: 'sub', close: '~~}' },
13
+ '==': { kind: 'mark', close: '==}' },
14
+ '>>': { kind: 'comment', close: '<<}' },
15
+ };
16
+
17
+ function criticRule(state, silent) {
18
+ const start = state.pos;
19
+ const src = state.src;
20
+ if (src.charCodeAt(start) !== OPEN) return false;
21
+
22
+ const spec = KINDS[src.slice(start + 1, start + 3)];
23
+ if (!spec) return false;
24
+
25
+ const contentStart = start + 3;
26
+ const closeIdx = src.indexOf(spec.close, contentStart);
27
+ if (closeIdx === -1 || closeIdx + spec.close.length > state.posMax) return false;
28
+
29
+ const raw = src.slice(contentStart, closeIdx);
30
+
31
+ if (!silent) {
32
+ const token = state.push('cm_critic', '', 0);
33
+ if (spec.kind === 'sub') {
34
+ const cut = raw.indexOf('~>');
35
+ token.meta = {
36
+ kind: 'sub',
37
+ old: cut === -1 ? raw : raw.slice(0, cut),
38
+ neu: cut === -1 ? '' : raw.slice(cut + 2),
39
+ };
40
+ } else {
41
+ token.meta = { kind: spec.kind, content: raw };
42
+ }
43
+ }
44
+
45
+ state.pos = closeIdx + spec.close.length;
46
+ return true;
47
+ }
48
+
49
+ export default function criticPlugin(md) {
50
+ md.inline.ruler.before('emphasis', 'cm_critic', criticRule);
51
+ const esc = md.utils.escapeHtml;
52
+
53
+ md.renderer.rules.cm_critic = (tokens, idx) => {
54
+ const meta = tokens[idx].meta;
55
+ switch (meta.kind) {
56
+ case 'add':
57
+ return `<ins class="crit-add">${esc(meta.content)}</ins>`;
58
+ case 'del':
59
+ return `<del class="crit-del">${esc(meta.content)}</del>`;
60
+ case 'sub':
61
+ return `<del class="crit-del">${esc(meta.old)}</del><ins class="crit-add">${esc(meta.neu)}</ins>`;
62
+ case 'mark':
63
+ return `<mark class="crit-mark">${esc(meta.content)}</mark>`;
64
+ case 'comment':
65
+ return `<span class="crit-comment">${esc(meta.content)}</span>`;
66
+ default:
67
+ return '';
68
+ }
69
+ };
70
+ }
package/src/index.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * ChromaMark renderer — a markdown-it plugin adding colored blocks, colored
3
+ * pills, collapsible sections, fields, meters, and inline diff on top of
4
+ * CommonMark + GFM.
5
+ */
6
+
7
+ import MarkdownIt from 'markdown-it';
8
+ import inlinePlugin from './inline.js';
9
+ import criticPlugin from './critic.js';
10
+ import containerPlugin from './containers.js';
11
+
12
+ const DEFAULTS = {
13
+ container: true, // ::: colored callouts
14
+ details: true, // ::: details collapsibles
15
+ fields: true, // ::: fields key/value lists
16
+ pill: true, // [!…] badges
17
+ text: true, // [.…] colored text
18
+ meter: true, // [=…] progress meters
19
+ critic: true, // CriticMarkup inline diff
20
+ };
21
+
22
+ /** markdown-it plugin entry point: `md.use(chromamark, options)`. */
23
+ export default function chromamark(md, options = {}) {
24
+ const opts = { ...DEFAULTS, ...options };
25
+
26
+ if (opts.pill || opts.text || opts.meter) {
27
+ inlinePlugin(md, { pill: opts.pill, text: opts.text, meter: opts.meter });
28
+ }
29
+ if (opts.critic) {
30
+ criticPlugin(md);
31
+ }
32
+ if (opts.container || opts.details || opts.fields) {
33
+ containerPlugin(md, {
34
+ callout: opts.container,
35
+ details: opts.details,
36
+ fields: opts.fields,
37
+ });
38
+ }
39
+ }
40
+
41
+ /** Build a markdown-it instance preconfigured with ChromaMark (CommonMark + GFM). */
42
+ export function createRenderer(options = {}) {
43
+ const md = new MarkdownIt({ html: false, linkify: true, typographer: false });
44
+ md.use(chromamark, options);
45
+ return md;
46
+ }
47
+
48
+ /** Convenience: render a ChromaMark string to an HTML fragment. */
49
+ export function render(src, options = {}) {
50
+ return createRenderer(options).render(String(src ?? ''));
51
+ }
package/src/inline.js ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Inline ChromaMark constructs sharing the `[<sigil>…]` family:
3
+ * [!spec label] → pill / badge (filled)
4
+ * [.spec label] → colored text (tint, no fill)
5
+ * [=spec value] → progress meter (bar)
6
+ *
7
+ * The leading `spec` is a tone/alias, `color=<hex|name>`, or a bare `#hex`.
8
+ * An unrecognized spec makes the rule decline so the text stays literal.
9
+ */
10
+
11
+ import { parseSpec } from './tones.js';
12
+
13
+ const OPEN = 0x5b; // [
14
+ const SIGILS = { '!': 'pill', '.': 'text', '=': 'meter' };
15
+
16
+ function unescape(text) {
17
+ return text.replace(/\\([\s\S])/g, '$1');
18
+ }
19
+
20
+ function splitSpec(inner) {
21
+ const match = inner.match(/^(\S+)(?:\s+([\s\S]*))?$/);
22
+ if (!match) return null;
23
+ return { specToken: match[1], rest: (match[2] || '').trim() };
24
+ }
25
+
26
+ /** Compute a meter fill width (0–100, as a string) from a value like "87%" or "3/10". */
27
+ function meterWidth(value) {
28
+ let pct;
29
+ const percent = value.match(/^(\d+(?:\.\d+)?)\s*%$/);
30
+ const fraction = value.match(/^(\d+(?:\.\d+)?)\s*\/\s*(\d+(?:\.\d+)?)$/);
31
+ if (percent) {
32
+ pct = parseFloat(percent[1]);
33
+ } else if (fraction) {
34
+ const denom = parseFloat(fraction[2]);
35
+ if (denom === 0) return null;
36
+ pct = (parseFloat(fraction[1]) / denom) * 100;
37
+ } else {
38
+ return null;
39
+ }
40
+ pct = Math.max(0, Math.min(100, pct));
41
+ return String(+pct.toFixed(2));
42
+ }
43
+
44
+ function findClose(state, from) {
45
+ const src = state.src;
46
+ for (let i = from; i < state.posMax; i++) {
47
+ const c = src.charCodeAt(i);
48
+ if (c === 0x5c) { i++; continue; } // backslash escapes next char
49
+ if (c === 0x0a) return -1; // stay on one line
50
+ if (c === 0x5d) return i; // ]
51
+ }
52
+ return -1;
53
+ }
54
+
55
+ function makeRule(enabled) {
56
+ return function chromaInline(state, silent) {
57
+ const start = state.pos;
58
+ if (state.src.charCodeAt(start) !== OPEN) return false;
59
+
60
+ const kind = SIGILS[state.src[start + 1]];
61
+ if (!kind || !enabled[kind]) return false;
62
+
63
+ const end = findClose(state, start + 2);
64
+ if (end === -1) return false;
65
+
66
+ const inner = state.src.slice(start + 2, end).trim();
67
+ const parts = inner && splitSpec(inner);
68
+ if (!parts) return false;
69
+
70
+ const spec = parseSpec(parts.specToken);
71
+ if (!spec) return false;
72
+
73
+ const rest = unescape(parts.rest);
74
+
75
+ let token;
76
+ if (kind === 'meter') {
77
+ if (!rest) return false;
78
+ const width = meterWidth(rest);
79
+ if (width === null) return false;
80
+ if (!silent) {
81
+ token = state.push('cm_meter', '', 0);
82
+ token.meta = { ...spec, value: rest, width };
83
+ }
84
+ } else if (kind === 'text') {
85
+ if (!rest) return false;
86
+ if (!silent) {
87
+ token = state.push('cm_text', '', 0);
88
+ token.meta = { ...spec, label: rest };
89
+ }
90
+ } else {
91
+ const label = rest || (spec.tone ? parts.specToken.toUpperCase() : spec.color);
92
+ if (!silent) {
93
+ token = state.push('cm_pill', '', 0);
94
+ token.meta = { ...spec, label };
95
+ }
96
+ }
97
+
98
+ state.pos = end + 1;
99
+ return true;
100
+ };
101
+ }
102
+
103
+ function toneAttrs(meta, escapeHtml) {
104
+ const custom = meta.color ? ' cm-custom' : '';
105
+ const tone = meta.tone ? ` data-tone="${meta.tone}"` : '';
106
+ const style = meta.color ? ` style="--fg:${escapeHtml(meta.color)}"` : '';
107
+ return { custom, tone, style };
108
+ }
109
+
110
+ export default function inlinePlugin(md, enabled) {
111
+ md.inline.ruler.before('link', 'cm_inline', makeRule(enabled));
112
+ const esc = md.utils.escapeHtml;
113
+
114
+ md.renderer.rules.cm_pill = (tokens, idx) => {
115
+ const { custom, tone, style } = toneAttrs(tokens[idx].meta, esc);
116
+ return `<span class="cm-pill${custom}"${tone}${style}>${esc(tokens[idx].meta.label)}</span>`;
117
+ };
118
+
119
+ md.renderer.rules.cm_text = (tokens, idx) => {
120
+ const { custom, tone, style } = toneAttrs(tokens[idx].meta, esc);
121
+ return `<span class="cm-text${custom}"${tone}${style}>${esc(tokens[idx].meta.label)}</span>`;
122
+ };
123
+
124
+ md.renderer.rules.cm_meter = (tokens, idx) => {
125
+ const meta = tokens[idx].meta;
126
+ const { custom, tone, style } = toneAttrs(meta, esc);
127
+ return (
128
+ `<span class="cm-meter${custom}"${tone}${style}>` +
129
+ `<span class="cm-track"><span class="cm-fill" style="width:${meta.width}%"></span></span>` +
130
+ `<span class="cm-val">${esc(meta.value)}</span>` +
131
+ `</span>`
132
+ );
133
+ };
134
+ }