coursecast 0.3.0 → 0.3.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": "coursecast",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
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": {
@@ -141,10 +141,41 @@ export function validateLesson(obj) {
141
141
  if (obj.simulation) {
142
142
  if (!obj.simulation.html) e.push('"simulation" is missing "html"')
143
143
  if (!obj.simulation.js) e.push('"simulation" is missing "js"')
144
+ e.push(...validateSimulationWiring(obj.simulation))
144
145
  }
145
146
  return e
146
147
  }
147
148
 
149
+ // The simulation's markup and its script are written as two separate strings, so a typo in
150
+ // one doesn't show up until a learner clicks and the console throws. These checks catch the
151
+ // two ways that actually happens, and because they run inside validate+repair the model gets
152
+ // a chance to fix its own mistake before the lesson is ever rendered.
153
+ function validateSimulationWiring({ html = '', js = '' }) {
154
+ const e = []
155
+ if (!html || !js) return e
156
+
157
+ // 1. inline handlers (onclick="doThing()") that name a function the script never defines
158
+ const handlers = new Set()
159
+ for (const m of String(html).matchAll(/\son[a-z]+\s*=\s*(["'])(.*?)\1/gi)) {
160
+ const fn = m[2].match(/^\s*([A-Za-z_$][\w$]*)\s*\(/)
161
+ if (fn) handlers.add(fn[1])
162
+ }
163
+ for (const fn of handlers) {
164
+ const defined = new RegExp(
165
+ `(function\\s+${fn}\\b|\\b(?:var|let|const)\\s+${fn}\\s*=|\\b${fn}\\s*=\\s*(?:function|\\()|window\\.${fn}\\s*=)`,
166
+ ).test(js)
167
+ if (!defined) e.push(`simulation.html calls "${fn}()" from an inline handler, but simulation.js never defines it`)
168
+ }
169
+
170
+ // 2. getElementById('x') where the markup has no element with that id
171
+ const ids = new Set([...String(html).matchAll(/\sid\s*=\s*(["'])(.*?)\1/gi)].map((m) => m[2].trim()))
172
+ for (const m of String(js).matchAll(/getElementById\(\s*(["'])(.*?)\1\s*\)/gi)) {
173
+ const want = m[2].trim()
174
+ if (want && !ids.has(want)) e.push(`simulation.js looks up id "${want}", which does not exist in simulation.html`)
175
+ }
176
+ return e.slice(0, 6)
177
+ }
178
+
148
179
  export function validateSyllabus(obj) {
149
180
  const e = []
150
181
  if (!obj || typeof obj !== 'object') return ['the output was not a JSON object']
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}