@chromamark/renderer 0.2.0 → 0.2.2

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.0",
3
+ "version": "0.2.2",
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",
@@ -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(/\t/g, ' ').replace(/\r/g, '').split('\n');
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 min = Infinity;
46
+ let prefix = null;
45
47
  for (const line of lines) {
46
48
  if (!line.trim()) continue;
47
- min = Math.min(min, line.match(/^ */)[0].length);
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
- if (!Number.isFinite(min)) min = 0;
50
- return lines.map((line) => line.slice(min)).join('\n');
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
- if (state.src.charCodeAt(lstart) !== MARKER) continue;
104
- if (state.sCount[nextLine] - state.blkIndent >= 4) continue;
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;
@@ -154,6 +197,19 @@ export default function containerPlugin(md, enabled) {
154
197
  });
155
198
  const esc = md.utils.escapeHtml;
156
199
 
200
+ // Render a title/summary/field value as inline content (so pills, colored
201
+ // text, meters, and markdown work there) while forcing html:false, so raw
202
+ // HTML in that user-controlled text is escaped rather than injected.
203
+ const renderInlineSafe = (text) => {
204
+ const prevHtml = md.options.html;
205
+ md.options.html = false;
206
+ try {
207
+ return md.renderInline(text);
208
+ } finally {
209
+ md.options.html = prevHtml;
210
+ }
211
+ };
212
+
157
213
  function decorate(meta) {
158
214
  const custom = meta.color ? ' cm-custom' : '';
159
215
  const style = meta.color ? ` style="--fg:${esc(meta.color)}"` : '';
@@ -168,11 +224,11 @@ export default function containerPlugin(md, enabled) {
168
224
  const open = meta.open ? ' open' : '';
169
225
  return (
170
226
  `<details class="cm-details${custom}"${tone}${style}${open}>` +
171
- `<summary>${esc(meta.summary)}</summary><div class="cm-body">`
227
+ `<summary>${renderInlineSafe(meta.summary)}</summary><div class="cm-body">`
172
228
  );
173
229
  }
174
230
  let html = `<div class="cm-block${custom}"${tone}${style}>`;
175
- if (meta.title) html += `<div class="cm-title">${esc(meta.title)}</div>`;
231
+ if (meta.title) html += `<div class="cm-title">${renderInlineSafe(meta.title)}</div>`;
176
232
  return html + '<div class="cm-body">';
177
233
  };
178
234
 
@@ -180,18 +236,34 @@ export default function containerPlugin(md, enabled) {
180
236
  tokens[idx].meta.structure === 'details' ? '</div></details>' : '</div></div>';
181
237
 
182
238
  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;
239
+ let html = '<dl class="cm-fields">';
240
+ for (const [key, value] of tokens[idx].meta.rows) {
241
+ html += `<dt>${esc(key)}</dt><dd>${renderInlineSafe(value)}</dd>`;
195
242
  }
243
+ return html + '</dl>';
196
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
+ });
197
269
  }