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 +1 -1
- package/src/engines/shared-prompts.mjs +31 -0
- package/src/render.mjs +59 -2
package/package.json
CHANGED
|
@@ -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="x" produces an
|
|
28
69
|
// attribute whose value is literally "x", 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, '&')
|
|
115
|
+
.replace(/&(?!#?[a-z0-9]{1,8};)/gi, '&')
|
|
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, '<');
|
|
121
|
+
})
|
|
122
|
+
.replace(/<(?![a-z/!])/gi, '<');
|
|
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}
|