1import { Controller } from "@hotwired/stimulus" 2 3// Tiny regex-based syntax highlighter â direct port of `hl()` from 4// tmp/Code Explainer/shared.jsx, extended to recognise C / C++ / Arduino 5// so the same controller handles every language we ship in courses. 6// 7// We deliberately don't pull in Prism or Highlight.js: the input is short 8// (rarely more than ~60 lines) and our authors hand-write samples, so a 9// 60-line tokeniser is enough to make Python and Arduino read clean 10// without paying the bundle cost or the importmap churn. 11// 12// Token classes (`.tok-*`) live in app/views/layouts/courses/_styles.html.erb 13// and swap colour under `data-theme="dark"` so dark mode rebinds wholesale. 14 15const PY_KEYWORDS = new Set([ 16 "from", "import", "while", "for", "in", "if", "else", "elif", "def", 17 "return", "True", "False", "None", "and", "or", "not", "as", "with", 18 "try", "except", "finally", "raise", "pass", "break", "continue", 19 "class", "lambda", "yield", "global", "nonlocal", "is", "assert", "del" 20]) 21const PY_BUILTINS = new Set([ 22 "range", "print", "len", "int", "float", "str", "list", "dict", "tuple", 23 "set", "bool", "open", "abs", "min", "max", "sum", "round", "input" 24]) 25 26const C_KEYWORDS = new Set([ 27 "auto", "break", "case", "char", "const", "continue", "default", "do", 28 "double", "else", "enum", "extern", "float", "for", "goto", "if", 29 "int", "long", "register", "return", "short", "signed", "sizeof", 30 "static", "struct", "switch", "typedef", "union", "unsigned", "void", 31 "volatile", "while", "true", "false", "bool", 32 // C++ on top 33 "class", "namespace", "new", "delete", "public", "private", "protected", 34 "template", "typename", "using", "virtual", "this", "operator", 35 "nullptr", "constexpr", "noexcept", "override", "final", "try", "catch", "throw" 36]) 37const ARDUINO_BUILTINS = new Set([ 38 "setup", "loop", 39 "pinMode", "digitalWrite", "digitalRead", "analogWrite", "analogRead", 40 "delay", "delayMicroseconds", "millis", "micros", 41 "Serial", "begin", "print", "println", "write", "read", "available", 42 "HIGH", "LOW", "INPUT", "OUTPUT", "INPUT_PULLUP", 43 "LED_BUILTIN", "A0", "A1", "A2", "A3", "A4", "A5", 44 "map", "constrain", "tone", "noTone", "attachInterrupt", "detachInterrupt" 45]) 46 47export default class extends Controller { 48 static values = { language: { type: String, default: "python" } } 49 50 connect() { 51 // Idempotent â Turbo can re-attach the controller on a back-nav and 52 // we don't want to re-wrap already-tokenised spans. 53 if (this.element.dataset.codeHighlighted === "true") return 54 const raw = this.element.textContent 55 const html = this.highlight(raw, this.languageValue) 56 this.element.innerHTML = html 57 this.element.dataset.codeHighlighted = "true" 58 } 59 60 highlight(src, lang) { 61 const lines = src.split("\n") 62 return lines.map(line => this.highlightLine(line, lang)).join("\n") 63 } 64 65 highlightLine(line, lang) { 66 const { keywords, builtins, commentStart } = this.profileFor(lang) 67 const out = [] 68 let i = 0 69 while (i < line.length) { 70 const rest = line.slice(i) 71 72 // Line comment â Python `#â¦`, C/C++/Arduino `//â¦`. 73 if (commentStart && rest.startsWith(commentStart)) { 74 out.push(`<span class="tok-comment">${escape(rest)}</span>`) 75 return out.join("") 76 } 77 // Block comment for C-family: /* ⦠*/ (single-line only â the 78 // tokeniser is line-by-line and we don't fight multiline comments). 79 if (commentStart === "//" && rest.startsWith("/*")) { 80 const end = rest.indexOf("*/") 81 const chunk = end >= 0 ? rest.slice(0, end + 2) : rest 82 out.push(`<span class="tok-comment">${escape(chunk)}</span>`) 83 i += chunk.length 84 continue 85 } 86 87 // Strings: triple-quoted, single, double. 88 const st = rest.match(/^(?:f|r|b|u)?(?:'''[\s\S]*?'''|"""[\s\S]*?"""|'(?:\\.|[^'\\])*'|"(?:\\.|[^"\\])*")/) 89 if (st) { 90 out.push(`<span class="tok-string">${escape(st[0])}</span>`) 91 i += st[0].length 92 continue 93 } 94 95 // Numbers (ints / floats / hex / 0bâ¦). 96 const nm = rest.match(/^(?:0x[0-9a-fA-F]+|0b[01]+|\d+(?:\.\d+)?)/) 97 if (nm) { 98 out.push(`<span class="tok-number">${escape(nm[0])}</span>`) 99 i += nm[0].length 100 continue 101 } 102 103 // Identifiers â keyword / builtin / plain. 104 const id = rest.match(/^[A-Za-z_][A-Za-z0-9_]*/) 105 if (id) { 106 const w = id[0] 107 if (keywords.has(w)) out.push(`<span class="tok-keyword">${escape(w)}</span>`) 108 else if (builtins.has(w)) out.push(`<span class="tok-builtin">${escape(w)}</span>`) 109 else out.push(escape(w)) 110 i += w.length 111 continue 112 } 113 114 // Everything else (operators, punctuation, whitespace) â passthrough. 115 out.push(escape(line[i])) 116 i++ 117 } 118 return out.join("") 119 } 120 121 profileFor(lang) { 122 const l = (lang || "").toLowerCase() 123 if (l === "python" || l === "micropython") { 124 return { keywords: PY_KEYWORDS, builtins: PY_BUILTINS, commentStart: "#" } 125 } 126 if (l === "c" || l === "cpp" || l === "c++" || l === "arduino") { 127 const builtins = l === "arduino" ? ARDUINO_BUILTINS : new Set() 128 return { keywords: C_KEYWORDS, builtins, commentStart: "//" } 129 } 130 // Fallback â no highlighting, but still safe to render. 131 return { keywords: new Set(), builtins: new Set(), commentStart: null } 132 } 133} 134 135function escape(str) { 136 return str 137 .replace(/&/g, "&") 138 .replace(/</g, "<") 139 .replace(/>/g, ">") 140}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.