coursecast 0.3.0 → 0.3.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/render.mjs +59 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coursecast",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "One prompt → a full interactive course — live widgets, quizzes, a mastery dashboard — rendered to self-contained HTML and deployed live.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/render.mjs CHANGED
@@ -22,8 +22,49 @@ const INPUT_TYPES = new Set(['button', 'checkbox', 'color', 'date', 'datetime-lo
22
22
  'hidden', 'image', 'month', 'number', 'password', 'radio', 'range', 'reset', 'search', 'submit',
23
23
  'tel', 'text', 'time', 'url', 'week']);
24
24
 
25
+ // The elements a lesson fragment is allowed to use. Anything else in angle brackets is prose
26
+ // the model forgot to escape ("the <namespace> holds…", "Array<T>", "if x < y"), which the
27
+ // parser would otherwise turn into a phantom element that swallows the rest of the section.
28
+ const HTML_TAGS = new Set(['a', 'abbr', 'article', 'aside', 'b', 'bdi', 'bdo', 'blockquote', 'br',
29
+ 'button', 'canvas', 'caption', 'cite', 'code', 'col', 'colgroup', 'data', 'datalist', 'dd',
30
+ 'del', 'details', 'dfn', 'div', 'dl', 'dt', 'em', 'fieldset', 'figcaption', 'figure', 'footer',
31
+ 'form', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hgroup', 'hr', 'i', 'iframe', 'img',
32
+ 'input', 'ins', 'kbd', 'label', 'legend', 'li', 'main', 'mark', 'menu', 'meter', 'nav', 'ol',
33
+ 'optgroup', 'option', 'output', 'p', 'picture', 'pre', 'progress', 'q', 'rp', 'rt', 'ruby', 's',
34
+ 'samp', 'script', 'section', 'select', 'small', 'source', 'span', 'strong', 'style', 'sub',
35
+ 'summary', 'sup', 'svg', 'table', 'tbody', 'td', 'template', 'textarea', 'tfoot', 'th', 'thead',
36
+ 'time', 'tr', 'track', 'u', 'ul', 'var', 'video', 'wbr',
37
+ // SVG children, since simulations draw with inline SVG
38
+ 'circle', 'clippath', 'defs', 'ellipse', 'g', 'line', 'lineargradient', 'marker', 'mask', 'path',
39
+ 'pattern', 'polygon', 'polyline', 'radialgradient', 'rect', 'stop', 'text', 'tspan', 'use']);
40
+
41
+ const VOID_TAGS = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link',
42
+ 'meta', 'param', 'source', 'track', 'wbr']);
43
+
44
+ // Close what the model left open, and drop end tags that close nothing.
45
+ //
46
+ // This is the highest-leverage repair of the lot: one unclosed <div> in a fragment doesn't
47
+ // just fail validation, it swallows every element after it — the rest of the lesson ends up
48
+ // nested inside the runaway element and the page layout collapses.
49
+ function balanceTags(html) {
50
+ const stack = [];
51
+ const out = html.replace(/<\/?([a-z][a-z0-9-]*)\b[^>]*>/gi, (tag, name) => {
52
+ const t = name.toLowerCase();
53
+ if (VOID_TAGS.has(t) || /\/>$/.test(tag)) return tag;
54
+ if (tag[1] === '/') {
55
+ const at = stack.lastIndexOf(t);
56
+ if (at === -1) return ''; // closes nothing — dropping it is safer than leaving it
57
+ stack.length = at;
58
+ return tag;
59
+ }
60
+ stack.push(t);
61
+ return tag;
62
+ });
63
+ return out + stack.reverse().map((t) => `</${t}>`).join('');
64
+ }
65
+
25
66
  function tidyFragment(html = '', sink) {
26
- return String(html)
67
+ return balanceTags(String(html))
27
68
  // Entity-encoded attribute quotes: a model that emits id=&quot;x&quot; produces an
28
69
  // attribute whose value is literally &quot;x&quot;, so the simulation's own
29
70
  // getElementById('x') finds nothing and throws. Only rewritten inside a tag, so prose
@@ -62,8 +103,23 @@ function tidyFragment(html = '', sink) {
62
103
  ? `<strong${attrs.replace(/\bstyle\s*=\s*(["'])/i, 'style=$1display:block;font-size:1.05em;')}>`
63
104
  : `<strong${attrs} style="display:block;font-size:1.05em">`)
64
105
  .replace(/<\/h[1-6]>/gi, '</strong>')
106
+ // Void elements have no end tag; "</br>" is a parse error that some browsers turn into
107
+ // a second line break.
108
+ .replace(/<\/(br|hr|img|input|source|track|wbr|col|area|base|embed|link|meta|param)\s*>/gi, '')
109
+ // Boolean attributes are present-or-absent, not true/false. disabled="true" merely
110
+ // validates badly, but disabled="false" reads as DISABLED — the opposite of what the
111
+ // model meant — so that one is dropped entirely rather than normalised.
112
+ .replace(/\s(disabled|checked|readonly|required|selected|multiple|autofocus|hidden|novalidate|open)\s*=\s*(["'])(.*?)\2/gi,
113
+ (_m, attr, _q, val) => (/^(false|0|no|off)$/i.test(val.trim()) ? '' : ` ${attr}`))
65
114
  // Bare "&" that isn't already an entity is invalid markup.
66
- .replace(/&(?!#?[a-z0-9]{1,8};)/gi, '&amp;');
115
+ .replace(/&(?!#?[a-z0-9]{1,8};)/gi, '&amp;')
116
+ // A "<" that doesn't open a real HTML element is prose the model forgot to escape.
117
+ // Left alone it becomes a phantom element that swallows everything after it.
118
+ .replace(/<\/?([a-z][a-z0-9-]*)?([^>]*)>?/gi, (m, tag) => {
119
+ if (tag && HTML_TAGS.has(tag.toLowerCase())) return m;
120
+ return m.replace(/</g, '&lt;');
121
+ })
122
+ .replace(/<(?![a-z/!])/gi, '&lt;');
67
123
  }
68
124
 
69
125
  // Model-authored simulation JS, repaired the same way its HTML is.
@@ -127,6 +183,7 @@ const CSS = `
127
183
  .quiz-score{text-align:center;padding:26px;background:var(--surface);border:1px solid var(--border);border-radius:14px;margin:20px 0;display:none}.quiz-score .big{font-size:42px;font-weight:700}
128
184
  .nextcard{display:flex;justify-content:space-between;align-items:center;gap:16px;background:var(--surface);border:1px solid var(--border);border-radius:14px;padding:20px;margin-top:48px;text-decoration:none;color:inherit;cursor:pointer}
129
185
  .nextcard:hover{border-color:var(--accent)}.nextcard .nk{font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);font-weight:700}.nextcard .nt{font-size:18px;font-weight:700}
186
+ .crumb-home{color:inherit;text-decoration:none}.crumb-home:hover{text-decoration:underline}
130
187
  .foot{font-size:12.5px;color:var(--muted);margin-top:40px}
131
188
  /* Keyboard users must be able to see where they are — every interactive element. */
132
189
  :focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:4px}