@chromamark/renderer 0.1.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chromamark/renderer",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
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",
@@ -8,8 +8,10 @@ import { createRenderer, render as renderString } from './index.js';
8
8
 
9
9
  const STYLE_ID = 'chromamark-theme';
10
10
  const DONE_ATTR = 'data-chromamark-done';
11
+ const SRC_ATTR = 'data-chromamark-src';
12
+ const ERR_ATTR = 'data-chromamark-error';
11
13
  const DEFAULT_SELECTOR =
12
- 'script[type="text/chromamark"], template.chromamark, [data-chromamark], .chromamark';
14
+ 'script[type="text/chromamark"], template.chromamark, [data-chromamark], [data-chromamark-src], .chromamark';
13
15
 
14
16
  /** The theme stylesheet (populated by the bundle via configureTheme). */
15
17
  export let theme = '';
@@ -67,16 +69,20 @@ function sourceOf(el, tag) {
67
69
  export function renderElement(target, options) {
68
70
  const el = resolve(target);
69
71
  if (!el || el.hasAttribute(DONE_ATTR)) return null;
72
+ if (el.hasAttribute && el.hasAttribute(SRC_ATTR)) return renderSrc(el, options);
70
73
 
74
+ const doc = el.ownerDocument || (typeof document !== 'undefined' ? document : null);
71
75
  const tag = (el.tagName || '').toLowerCase();
72
76
  const html = renderString(dedent(sourceOf(el, tag)), options);
73
77
  el.setAttribute(DONE_ATTR, '');
74
78
 
75
79
  if (tag === 'script' || tag === 'template') {
76
- const out = document.createElement('div');
80
+ const out = doc.createElement('div');
77
81
  out.className = 'chromamark-output';
78
82
  out.innerHTML = html;
79
- el.parentNode.insertBefore(out, el.nextSibling);
83
+ // A detached holder has no parent to receive a sibling; return the
84
+ // rendered node anyway so the caller can place it, rather than crashing.
85
+ if (el.parentNode) el.parentNode.insertBefore(out, el.nextSibling);
80
86
  return out;
81
87
  }
82
88
  el.innerHTML = html;
@@ -84,6 +90,43 @@ export function renderElement(target, options) {
84
90
  return el;
85
91
  }
86
92
 
93
+ /**
94
+ * Fetch the external ChromaMark file named by an element's data-chromamark-src
95
+ * and render it into that element, the way a browser loads an external script or
96
+ * stylesheet. Resolves to the element on success, or null when skipped/failed;
97
+ * it never rejects, so a missing file degrades gracefully (the element keeps its
98
+ * existing content and gains a data-chromamark-error marker). Requires a DOM
99
+ * with fetch — i.e. the page must be served over http(s), not opened as file://.
100
+ */
101
+ export function renderSrc(target, options) {
102
+ const el = resolve(target);
103
+ if (!el || el.hasAttribute(DONE_ATTR)) return Promise.resolve(null);
104
+ const url = el.getAttribute(SRC_ATTR);
105
+ if (!url) return Promise.resolve(null);
106
+ el.setAttribute(DONE_ATTR, '');
107
+
108
+ const view = el.ownerDocument && el.ownerDocument.defaultView;
109
+ const doFetch = (view && view.fetch) || (typeof fetch !== 'undefined' ? fetch : null);
110
+ const fail = (message) => {
111
+ el.setAttribute(ERR_ATTR, message);
112
+ return null;
113
+ };
114
+ if (!doFetch) return Promise.resolve(fail('ChromaMark: fetch is unavailable'));
115
+
116
+ return Promise.resolve()
117
+ .then(() => doFetch(url))
118
+ .then((res) => {
119
+ if (!res || !res.ok) throw new Error(`HTTP ${res ? res.status : '?'}`);
120
+ return res.text();
121
+ })
122
+ .then((text) => {
123
+ el.innerHTML = renderString(text, options);
124
+ el.classList.add('chromamark-output');
125
+ return el;
126
+ })
127
+ .catch((err) => fail(`ChromaMark: failed to load ${url} (${(err && err.message) || err})`));
128
+ }
129
+
87
130
  /** Render every element matching the selector (defaults to the standard targets). */
88
131
  export function renderAll(selector, options) {
89
132
  const nodes = document.querySelectorAll(selector || DEFAULT_SELECTOR);
@@ -100,6 +143,7 @@ export const ChromaMark = {
100
143
  render,
101
144
  renderElement,
102
145
  renderAll,
146
+ renderSrc,
103
147
  injectTheme,
104
148
  autoRender,
105
149
  createRenderer,
package/src/containers.js CHANGED
@@ -154,6 +154,19 @@ export default function containerPlugin(md, enabled) {
154
154
  });
155
155
  const esc = md.utils.escapeHtml;
156
156
 
157
+ // Render a title/summary/field value as inline content (so pills, colored
158
+ // text, meters, and markdown work there) while forcing html:false, so raw
159
+ // HTML in that user-controlled text is escaped rather than injected.
160
+ const renderInlineSafe = (text) => {
161
+ const prevHtml = md.options.html;
162
+ md.options.html = false;
163
+ try {
164
+ return md.renderInline(text);
165
+ } finally {
166
+ md.options.html = prevHtml;
167
+ }
168
+ };
169
+
157
170
  function decorate(meta) {
158
171
  const custom = meta.color ? ' cm-custom' : '';
159
172
  const style = meta.color ? ` style="--fg:${esc(meta.color)}"` : '';
@@ -168,11 +181,11 @@ export default function containerPlugin(md, enabled) {
168
181
  const open = meta.open ? ' open' : '';
169
182
  return (
170
183
  `<details class="cm-details${custom}"${tone}${style}${open}>` +
171
- `<summary>${esc(meta.summary)}</summary><div class="cm-body">`
184
+ `<summary>${renderInlineSafe(meta.summary)}</summary><div class="cm-body">`
172
185
  );
173
186
  }
174
187
  let html = `<div class="cm-block${custom}"${tone}${style}>`;
175
- if (meta.title) html += `<div class="cm-title">${esc(meta.title)}</div>`;
188
+ if (meta.title) html += `<div class="cm-title">${renderInlineSafe(meta.title)}</div>`;
176
189
  return html + '<div class="cm-body">';
177
190
  };
178
191
 
@@ -180,18 +193,10 @@ export default function containerPlugin(md, enabled) {
180
193
  tokens[idx].meta.structure === 'details' ? '</div></details>' : '</div></div>';
181
194
 
182
195
  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;
196
+ let html = '<dl class="cm-fields">';
197
+ for (const [key, value] of tokens[idx].meta.rows) {
198
+ html += `<dt>${esc(key)}</dt><dd>${renderInlineSafe(value)}</dd>`;
195
199
  }
200
+ return html + '</dl>';
196
201
  };
197
202
  }
package/src/critic.js CHANGED
@@ -14,6 +14,20 @@ const KINDS = {
14
14
  '>>': { kind: 'comment', close: '<<}' },
15
15
  };
16
16
 
17
+ /**
18
+ * First index of the close token at or after `from`, memoized per token on the
19
+ * parse state so many unterminated openers on one line stay O(n) overall
20
+ * instead of each rescanning the tail (O(n^2)).
21
+ */
22
+ function findClose(state, needle, from) {
23
+ const slot = '_cmCritic' + needle;
24
+ const c = state[slot];
25
+ if (c !== undefined && c.from <= from && (c.at === -1 || from <= c.at)) return c.at;
26
+ const at = state.src.indexOf(needle, from);
27
+ state[slot] = { from, at };
28
+ return at;
29
+ }
30
+
17
31
  function criticRule(state, silent) {
18
32
  const start = state.pos;
19
33
  const src = state.src;
@@ -23,7 +37,7 @@ function criticRule(state, silent) {
23
37
  if (!spec) return false;
24
38
 
25
39
  const contentStart = start + 3;
26
- const closeIdx = src.indexOf(spec.close, contentStart);
40
+ const closeIdx = findClose(state, spec.close, contentStart);
27
41
  if (closeIdx === -1 || closeIdx + spec.close.length > state.posMax) return false;
28
42
 
29
43
  const raw = src.slice(contentStart, closeIdx);
package/src/inline.js CHANGED
@@ -17,10 +17,21 @@ function unescape(text) {
17
17
  return text.replace(/\\([\s\S])/g, '$1');
18
18
  }
19
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() };
20
+ const WS = /\s/;
21
+
22
+ /**
23
+ * Split an inline construct's body (state.src between `from` and `end`) into its
24
+ * leading spec token and the start of the remaining content, without slicing the
25
+ * whole — possibly huge — span first, so an invalid spec is rejected cheaply.
26
+ * Equivalent to matching `^(\S+)(?:\s+…)?$` on the trimmed body.
27
+ */
28
+ function splitInline(src, from, end) {
29
+ let s = from;
30
+ while (s < end && WS.test(src[s])) s++;
31
+ let e = s;
32
+ while (e < end && !WS.test(src[e])) e++;
33
+ if (e === s) return null; // empty / all-whitespace body
34
+ return { specToken: src.slice(s, e), restStart: e };
24
35
  }
25
36
 
26
37
  /** Compute a meter fill width (0–100, as a string) from a value like "87%" or "3/10". */
@@ -41,15 +52,43 @@ function meterWidth(value) {
41
52
  return String(+pct.toFixed(2));
42
53
  }
43
54
 
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; // ]
55
+ /**
56
+ * First index of `needle` (a single char) at or after `from`, memoized on the
57
+ * parse state so N openers on one long line cost O(line) total rather than
58
+ * O(N·line). The cache is valid because `state.src` is constant for a state and
59
+ * the first occurrence at-or-after `from` is monotonic.
60
+ */
61
+ function nextIndex(state, slot, needle, from) {
62
+ const c = state[slot];
63
+ if (c !== undefined && c.from <= from && (c.at === -1 || from <= c.at)) return c.at;
64
+ const at = state.src.indexOf(needle, from);
65
+ state[slot] = { from, at };
66
+ return at;
67
+ }
68
+
69
+ /** True if `src[pos]` is escaped by an odd run of backslashes back to `floor`. */
70
+ function isEscaped(src, pos, floor) {
71
+ let n = 0;
72
+ for (let i = pos - 1; i >= floor && src.charCodeAt(i) === 0x5c; i--) n++;
73
+ return (n & 1) === 1;
74
+ }
75
+
76
+ /** First unescaped `needle` at or after `from` (escapes counted from `floor`). */
77
+ function nextUnescaped(state, slot, needle, from, floor, max) {
78
+ let at = nextIndex(state, slot, needle, from);
79
+ while (at !== -1 && at < max && isEscaped(state.src, at, floor)) {
80
+ at = nextIndex(state, slot, needle, at + 1);
51
81
  }
52
- return -1;
82
+ return at;
83
+ }
84
+
85
+ function findClose(state, from) {
86
+ const max = state.posMax;
87
+ const bracket = nextUnescaped(state, '_cmBr', ']', from, from, max);
88
+ if (bracket === -1 || bracket >= max) return -1;
89
+ const newline = nextUnescaped(state, '_cmNl', '\n', from, from, max);
90
+ if (newline !== -1 && newline < bracket) return -1; // stays on one line
91
+ return bracket;
53
92
  }
54
93
 
55
94
  function makeRule(enabled) {
@@ -63,14 +102,14 @@ function makeRule(enabled) {
63
102
  const end = findClose(state, start + 2);
64
103
  if (end === -1) return false;
65
104
 
66
- const inner = state.src.slice(start + 2, end).trim();
67
- const parts = inner && splitSpec(inner);
105
+ const src = state.src;
106
+ const parts = splitInline(src, start + 2, end);
68
107
  if (!parts) return false;
69
108
 
70
109
  const spec = parseSpec(parts.specToken);
71
110
  if (!spec) return false;
72
111
 
73
- const rest = unescape(parts.rest);
112
+ const rest = unescape(src.slice(parts.restStart, end).trim());
74
113
 
75
114
  let token;
76
115
  if (kind === 'meter') {