/*
 * jsx-transform.js — JSX/TSX in, plain JS out. No dependencies.
 *
 * Scope is deliberately the single self-contained component a model writes into
 * a chat fence: elements, fragments, attributes, spreads, expression children,
 * and the TypeScript annotations that decorate them. It is not a TypeScript
 * compiler and does not try to be - the real one costs more bytes than this
 * whole app, and nothing here needs to survive input the user didn't ask a
 * local model to produce.
 *
 * Failure is loud on purpose. Anything this can't parse throws TransformError,
 * which the caller shows in place of the preview; anything it declines to touch
 * is passed through, so unsupported TypeScript reaches the browser and surfaces
 * as a SyntaxError in the preview's error bar (see Markdown.jsx's bootstrap).
 * Both beat quietly emitting code that runs and does the wrong thing.
 *
 * Pure string -> string, no DOM and no eval, which is what lets it run in the
 * parent app instead of inside the sandbox: transforming untrusted text is not
 * executing it. Tested headless by jsx-transform.test.js (node --test).
 */

export class TransformError extends Error {}

// Characters after which a value may start, so a `<` opens JSX rather than
// acting as less-than and a `/` starts a regex rather than dividing.
//
// `)` and `]` are deliberately absent: they *end* a value, so `f(x) / 2` is a
// division and `xs[0] < 3` is a comparison. Including them made
// `Math.sin(t) / 6` scan as an unterminated regex.
const EXPR_START = new Set([
  "", "(", ",", "=", ":", ";", "{", "}", "[", "&", "|", "?", "!",
  "+", "-", "*", "/", "%", "^", "~", ">", "<",
]);

// Same idea for keywords: `return <div/>` is JSX, `a in <b` is not real code.
const EXPR_KEYWORDS = new Set([
  "return", "yield", "await", "typeof", "in", "of", "case", "do", "else",
  "new", "delete", "void", "throw", "default", "instanceof",
]);

const ID_START = /[A-Za-z_$]/;
const ID_CHAR = /[\w$]/;

// A generic argument list holds only type syntax. Used to tell `useState<T>(0)`
// (strip the <T>) from `a < b > (c)` (arithmetic, leave alone).
const TYPE_ARG_CHARS = /^[\w$\s,.[\]|&<>'"-]*$/;

function isIdentifier(ch) {
  return ch !== undefined && ID_CHAR.test(ch);
}

function countNewlines(text) {
  let n = 0;
  for (let i = 0; i < text.length; i++) if (text[i] === "\n") n++;
  return n;
}

/** The newlines `consumed` had that `produced` lost, so line numbers hold. */
function missingNewlines(consumed, produced) {
  return "\n".repeat(Math.max(0, countNewlines(consumed) - countNewlines(produced)));
}

class Scanner {
  constructor(src) {
    this.src = src;
    this.i = 0;
    this.out = [];
    this.prevSig = "";    // last significant char emitted
    this.prevWord = "";   // last identifier/keyword emitted
    this.defaultExport = null;
    this.components = []; // capitalized declarations, in source order
    this.imports = [];    // {names, module} for every import statement dropped
    // One frame per unclosed opener. `ch` tells a parameter list from an object
    // literal - the difference between stripping a type annotation and eating a
    // property's value - and `ternaries` counts `?`s still waiting for their
    // `:`, so a conditional's else-branch isn't mistaken for a type either.
    this.frames = [{ ch: "", ternaries: 0 }];
  }

  get frame() {
    return this.frames[this.frames.length - 1];
  }

  get context() {
    return this.frame.ch;
  }

  get eof() {
    return this.i >= this.src.length;
  }

  peek(offset = 0) {
    return this.src[this.i + offset];
  }

  emit(text) {
    if (!text) return;
    this.out.push(text);
    const trimmed = text.trimEnd();
    if (trimmed) this.prevSig = trimmed[trimmed.length - 1];
  }

  fail(message) {
    const line = this.src.slice(0, this.i).split("\n").length;
    throw new TransformError(`${message} (line ${line})`);
  }
}

/** Transform a JSX/TSX source string into runnable JS. */
export function transform(source) {
  const sc = new Scanner(String(source ?? ""));
  scanCode(sc, null);
  return {
    code: sc.out.join(""),
    defaultExport: sc.defaultExport,
    components: sc.components,
    imports: sc.imports,
  };
}

/**
 * Scan code until `stop` is reached at depth zero (or end of input). Used for
 * the whole program (stop = null) and for the interiors of `${...}` and JSX
 * `{...}`, both of which may contain arbitrary code including more JSX.
 */
function scanCode(sc, stop) {
  let depth = 0;

  while (!sc.eof) {
    const ch = sc.peek();

    if (stop && depth === 0 && ch === stop) return;
    if (ch === "{" || ch === "(" || ch === "[") { depth++; sc.frames.push({ ch, ternaries: 0 }); }
    if (ch === "}" || ch === ")" || ch === "]") { depth--; if (sc.frames.length > 1) sc.frames.pop(); }
    // `?` opens a conditional whose `:` is not a type annotation. `?.` and `??`
    // are their own operators, and a trailing `?` marks an optional parameter.
    if (ch === "?" && sc.peek(1) !== "." && sc.peek(1) !== "?" && !/\s*:/.test(sc.src.slice(sc.i + 1, sc.i + 3))) {
      sc.frame.ternaries++;
    }

    // --- things copied through verbatim ---
    if (ch === "/" && sc.peek(1) === "/") { copyLineComment(sc); continue; }
    if (ch === "/" && sc.peek(1) === "*") { copyBlockComment(sc); continue; }
    if (ch === '"' || ch === "'") { copyString(sc, ch); continue; }
    if (ch === "`") { copyTemplate(sc); continue; }
    if (ch === "/" && regexAllowed(sc)) { copyRegex(sc); continue; }

    // --- JSX ---
    if (ch === "<" && jsxAllowed(sc)) {
      const from = sc.i;
      const call = parseElement(sc);
      // A multi-line element becomes a single-line h() call, which would shift
      // every line after it. The preview reports runtime errors by line number
      // and the user reads those against the original in the Code tab, so the
      // difference is padded back.
      sc.emit(call + missingNewlines(sc.src.slice(from, sc.i), call));
      continue;
    }

    // --- identifiers and keywords (where TS and module syntax live) ---
    if (ID_START.test(ch)) {
      const word = readWord(sc);
      if (handleWord(sc, word)) continue;
      sc.emit(word);
      sc.prevWord = word;
      continue;
    }

    // --- TypeScript punctuation ---
    if (ch === ":" && annotationAhead(sc)) { skipTypeAnnotation(sc); continue; }
    if (ch === "!" && nonNullAssertion(sc)) { sc.i++; continue; }

    if (!/\s/.test(ch)) sc.prevWord = "";
    sc.emit(ch);
    sc.i++;
  }

  if (stop) sc.fail(`unterminated block, expected '${stop}'`);
}

// ── verbatim copiers ────────────────────────────────────────────────────────

function copyLineComment(sc) {
  const end = sc.src.indexOf("\n", sc.i);
  const stop = end === -1 ? sc.src.length : end;
  sc.out.push(sc.src.slice(sc.i, stop));
  sc.i = stop;
}

function copyBlockComment(sc) {
  const end = sc.src.indexOf("*/", sc.i + 2);
  if (end === -1) sc.fail("unterminated block comment");
  sc.out.push(sc.src.slice(sc.i, end + 2));
  sc.i = end + 2;
}

function copyString(sc, quote) {
  const start = sc.i;
  sc.i++;
  while (!sc.eof) {
    const ch = sc.peek();
    if (ch === "\\") { sc.i += 2; continue; }
    if (ch === quote) { sc.i++; sc.emit(sc.src.slice(start, sc.i)); return; }
    if (ch === "\n") break;
    sc.i++;
  }
  sc.fail("unterminated string");
}

function copyTemplate(sc) {
  sc.emit("`");
  sc.i++;
  while (!sc.eof) {
    const ch = sc.peek();
    if (ch === "\\") { sc.out.push(sc.src.slice(sc.i, sc.i + 2)); sc.i += 2; continue; }
    if (ch === "`") { sc.emit("`"); sc.i++; return; }
    // `${...}` can hold anything, JSX included - hand it back to the scanner.
    if (ch === "$" && sc.peek(1) === "{") {
      sc.emit("${");
      sc.i += 2;
      const saved = sc.prevSig;
      sc.prevSig = "";
      scanCode(sc, "}");
      sc.prevSig = saved;
      sc.emit("}");
      sc.i++;
      continue;
    }
    sc.out.push(ch);
    sc.i++;
  }
  sc.fail("unterminated template literal");
}

function copyRegex(sc) {
  const start = sc.i;
  sc.i++;
  let inClass = false;
  while (!sc.eof) {
    const ch = sc.peek();
    if (ch === "\\") { sc.i += 2; continue; }
    if (ch === "[") inClass = true;
    else if (ch === "]") inClass = false;
    else if (ch === "/" && !inClass) {
      sc.i++;
      while (isIdentifier(sc.peek())) sc.i++;  // flags
      sc.emit(sc.src.slice(start, sc.i));
      return;
    } else if (ch === "\n") break;
    sc.i++;
  }
  sc.fail("unterminated regular expression");
}

/** A `/` starts a regex only where a value may start. */
function regexAllowed(sc) {
  if (sc.peek(1) === "=") return false;
  if (EXPR_KEYWORDS.has(sc.prevWord)) return true;
  if (sc.prevWord) return false;
  return sc.prevSig === "" || EXPR_START.has(sc.prevSig);
}

/** A `<` opens JSX only where a value may start, and only before a tag name. */
function jsxAllowed(sc) {
  const next = sc.peek(1);
  if (next !== ">" && !ID_START.test(next ?? "")) return false;
  if (EXPR_KEYWORDS.has(sc.prevWord)) return true;
  if (sc.prevWord) return false;   // identifier before `<` means comparison or generics
  return EXPR_START.has(sc.prevSig);
}

// ── identifiers, modules, TypeScript declarations ───────────────────────────

function readWord(sc) {
  const start = sc.i;
  while (isIdentifier(sc.peek())) sc.i++;
  return sc.src.slice(start, sc.i);
}

function skipSpace(sc) {
  while (!sc.eof && /\s/.test(sc.peek())) sc.i++;
}

/** True when the word was consumed here and needs no further emitting. */
function handleWord(sc, word) {
  const atStatement = sc.prevSig === "" || sc.prevSig === ";" || sc.prevSig === "}";

  // Note capitalized declarations as they go past: with no default export and
  // nothing named App, the caller mounts the last one declared.
  if (word === "function" || word === "class" || word === "const" || word === "let" || word === "var") {
    const named = sc.src.slice(sc.i).match(/^\s*\*?\s*([A-Z][\w$]*)/);
    if (named && !sc.components.includes(named[1])) sc.components.push(named[1]);
  }

  // Nothing can be imported into the sandbox - there is no module loader and
  // no network. Hooks arrive as globals instead, so `import {useState} from
  // "react"` is dropped rather than rewritten.
  if (word === "import" && atStatement) {
    if (sc.peek() === "(" || sc.peek() === ".") { sc.emit(word); return true; }  // import()/import.meta
    const from = sc.i;
    skipImportStatement(sc);
    recordImport(sc, sc.src.slice(from, sc.i));
    return true;
  }

  if (word === "export" && atStatement) {
    skipSpace(sc);
    if (sc.src.startsWith("default", sc.i) && !isIdentifier(sc.peek(7))) {
      sc.i += 7;
      skipSpace(sc);
      // `export default App;` names a component; `export default function App()`
      // declares one. Either way the mount target is what follows.
      const rest = sc.src.slice(sc.i);
      const named = rest.match(/^(?:function\s*\*?\s*|class\s+)?([A-Za-z_$][\w$]*)/);
      if (named) sc.defaultExport = named[1];
      if (/^[A-Za-z_$][\w$]*\s*;?\s*$/.test(rest)) { sc.i = sc.src.length; return true; }
    }
    sc.prevWord = "";
    return true;   // `export ` itself is dropped either way
  }

  // `interface X {...}` / `type X = ...` are types entire - drop the statement.
  if ((word === "interface" || word === "type") && atStatement && typeDeclarationAhead(sc)) {
    if (word === "interface") skipBalancedBraces(sc);
    else skipStatement(sc);
    return true;
  }

  // `x as Foo` / `x satisfies Foo` - drop the cast, keep the value.
  if ((word === "as" || word === "satisfies") && sc.prevSig && sc.prevSig !== "=") {
    skipSpace(sc);
    if (sc.peek() === "c" && sc.src.startsWith("const", sc.i)) sc.i += 5;
    else skipTypeExpression(sc);
    sc.prevWord = "";
    return true;
  }

  // `useState<number>(0)` - a generic argument list, not a comparison.
  const save = sc.i;
  skipSpace(sc);
  if (sc.peek() === "<") {
    const end = matchTypeArgs(sc, sc.i);
    if (end !== -1) {
      sc.emit(word);
      sc.prevWord = word;
      sc.i = end;
      return true;
    }
  }
  sc.i = save;
  return false;
}

/**
 * Note the bindings an import would have introduced, so the caller can say
 * where a missing name was supposed to come from.
 *
 * Without this, dropping `import { useInView } from 'react-infinite-scroll'`
 * turns into "useInView is not defined" at the first *use* — a line nowhere
 * near the import, describing a symptom rather than the cause. Parsing is
 * deliberately forgiving: models write malformed imports (a missing brace was
 * what prompted this), and a half-read name is still worth naming.
 */
function recordImport(sc, statement) {
  const quoted = statement.match(/['"]([^'"]+)['"]\s*;?\s*$/);
  const module = quoted ? quoted[1] : "";
  const clause = statement
    .replace(/^\s*import\s*/, "")
    .replace(/['"][^'"]*['"]\s*;?\s*$/, "")
    .replace(/\bfrom\b/, "");
  const names = clause
    .replace(/[{}]/g, " ")
    .split(",")
    .map((part) => part.trim().replace(/^\*\s*as\s+/, "").split(/\s+as\s+/).pop().trim())
    .filter((name) => /^[A-Za-z_$][\w$]*$/.test(name));
  if (module || names.length) sc.imports.push({ names, module });
}

function typeDeclarationAhead(sc) {
  return /^\s+[A-Za-z_$][\w$]*\s*[<={]/.test(sc.src.slice(sc.i));
}

/**
 * Skip to the end of the current statement (semicolon or line end). Nothing is
 * emitted, so the scanner's idea of the last significant character has to be
 * restored - otherwise a dropped `import ... from "react";` leaves a quote
 * behind as `prevSig` and the next statement no longer looks like one.
 */
function skipStatement(sc) {
  const sig = sc.prevSig;
  const word = sc.prevWord;
  while (!sc.eof) {
    const ch = sc.peek();
    if (ch === ";") { sc.i++; break; }
    if (ch === "\n") break;
    if (ch === '"' || ch === "'") { const o = sc.out.length; copyString(sc, ch); sc.out.length = o; continue; }
    sc.i++;
  }
  sc.prevSig = sig;
  sc.prevWord = word;
}

/**
 * Drop a static import, including the common multiline named-import form.
 * A bare newline before the module string is part of the import; one after it
 * ends a semicolon-free import. Semicolons always win so a malformed import
 * (for example a missing `}`) cannot swallow the rest of the component.
 */
function skipImportStatement(sc) {
  const from = sc.i;
  const sig = sc.prevSig;
  const word = sc.prevWord;
  let sawModule = false;

  while (!sc.eof) {
    const ch = sc.peek();
    if (ch === ";") { sc.i++; break; }
    if (ch === "\n" && sawModule) break;

    if (ch === '"' || ch === "'") {
      const outLength = sc.out.length;
      copyString(sc, ch);
      sc.out.length = outLength;
      sawModule = true;
      continue;
    }
    if (ch === "/" && sc.peek(1) === "/") {
      const outLength = sc.out.length;
      copyLineComment(sc);
      sc.out.length = outLength;
      continue;
    }
    if (ch === "/" && sc.peek(1) === "*") {
      const outLength = sc.out.length;
      copyBlockComment(sc);
      sc.out.length = outLength;
      continue;
    }
    sc.i++;
  }

  // Keep runtime error line numbers aligned with the source shown in Code.
  sc.out.push(missingNewlines(sc.src.slice(from, sc.i), ""));
  sc.prevSig = sig;
  sc.prevWord = word;
}

function skipBalancedBraces(sc) {
  const from = sc.i;
  const sig = sc.prevSig;
  const word = sc.prevWord;
  while (!sc.eof && sc.peek() !== "{") sc.i++;
  let depth = 0;
  while (!sc.eof) {
    const ch = sc.peek();
    if (ch === "{") depth++;
    else if (ch === "}") {
      depth--;
      sc.i++;
      if (depth === 0) {
        // A multi-line `interface {...}` would otherwise shift the code below it.
        sc.out.push(missingNewlines(sc.src.slice(from, sc.i), ""));
        sc.prevSig = sig;
        sc.prevWord = word;
        return;
      }
      continue;
    }
    sc.i++;
  }
  sc.fail("unterminated type declaration");
}

/**
 * Find the `>` closing a generic argument list starting at `from`, or -1 when
 * the contents don't look like types at all (then it was a comparison).
 */
function matchTypeArgs(sc, from) {
  let depth = 0;
  for (let j = from; j < sc.src.length; j++) {
    const ch = sc.src[j];
    if (ch === "<") depth++;
    else if (ch === ">") {
      depth--;
      if (depth === 0) {
        const inner = sc.src.slice(from + 1, j);
        if (!TYPE_ARG_CHARS.test(inner)) return -1;
        // Only a call or a value position may follow a real generic list.
        const after = sc.src.slice(j + 1).match(/^\s*(.)/);
        return after && "(;,)]}=".includes(after[1]) ? j + 1 : -1;
      }
    } else if (ch === "\n" || ch === "{" || ch === ";") return -1;
  }
  return -1;
}

// ── TypeScript annotations ──────────────────────────────────────────────────

/**
 * True when this `:` introduces a type annotation rather than an object-literal
 * key, a ternary branch, or a label. Only the positions a small component
 * actually uses are recognised; everything else is left alone deliberately.
 */
function annotationAhead(sc) {
  // An unresolved `?` claims this `:` for its conditional first.
  if (sc.frame.ternaries > 0) { sc.frame.ternaries--; return false; }

  const before = sc.src.slice(0, sc.i);

  // `const x: T` and a return type `): T` read the same anywhere.
  if (/(?:\b(?:const|let|var)\s+[A-Za-z_$][\w$]*|\))\s*$/.test(before)) return true;

  // The rest are parameter annotations, and only count inside a parameter
  // list. The same shapes inside braces are object literals, where the value
  // after `:` must survive.
  if (sc.context !== "(") return false;

  // `(a: T`, `, b: T`, `(...rest: T`
  if (/[(,]\s*(?:\.\.\.)?[A-Za-z_$][\w$]*\??\s*$/.test(before)) return true;
  // `({ a, b }: Props`, `([x, y]: T` - a destructured parameter
  return /[}\]]\s*\??\s*$/.test(before);
}

function skipTypeAnnotation(sc) {
  sc.i++;   // the ':'
  skipTypeExpression(sc);
  sc.prevWord = "";
}

/** Consume a type expression, stopping where the surrounding value resumes. */
function skipTypeExpression(sc) {
  let depth = 0;
  while (!sc.eof) {
    const ch = sc.peek();
    if ("({[<".includes(ch)) depth++;
    else if (")}]>".includes(ch)) {
      if (depth === 0) return;   // the enclosing paren/brace, not ours
      depth--;
    } else if (depth === 0) {
      if (ch === "," || ch === ";" || ch === "\n") return;
      if (ch === "=" && sc.peek(1) !== ">") return;
      if (ch === "{") return;
      if (ch === "=" && sc.peek(1) === ">") return;
    }
    sc.i++;
  }
}

function nonNullAssertion(sc) {
  const next = sc.peek(1);
  if (next === "=" ) return false;                 // `!=`
  return /[\w$)\]]/.test(sc.src[sc.i - 1] ?? "");  // `foo!.bar`, `arr[0]!`
}

// ── JSX ─────────────────────────────────────────────────────────────────────

/** Parse one JSX element (cursor on `<`) and return the equivalent h() call. */
function parseElement(sc) {
  sc.i++;   // '<'

  if (sc.peek() === ">") {          // fragment
    sc.i++;
    const kids = parseChildren(sc, "");
    return `h(Fragment,null${kids})`;
  }

  const name = readTagName(sc);
  if (!name) sc.fail("expected a JSX tag name after '<'");

  const props = parseAttributes(sc);
  const tag = /^[a-z][\w-]*$/.test(name) ? JSON.stringify(name) : name;

  if (sc.peek() === "/") {          // self-closing
    sc.i++;
    if (sc.peek() !== ">") sc.fail(`expected '>' to close <${name} />`);
    sc.i++;
    return `h(${tag},${props})`;
  }

  if (sc.peek() !== ">") sc.fail(`expected '>' to close <${name}>`);
  sc.i++;
  const kids = parseChildren(sc, name);
  return `h(${tag},${props}${kids})`;
}

function readTagName(sc) {
  if (!ID_START.test(sc.peek() ?? "")) return "";
  const start = sc.i;
  while (!sc.eof && /[\w$.-]/.test(sc.peek())) sc.i++;
  return sc.src.slice(start, sc.i);
}

function parseAttributes(sc) {
  const parts = [];

  for (;;) {
    skipSpace(sc);
    const ch = sc.peek();
    if (ch === undefined) sc.fail("unterminated JSX tag");
    if (ch === ">" || ch === "/") break;

    if (ch === "{") {               // {...spread}
      sc.i++;
      skipSpace(sc);
      if (!sc.src.startsWith("...", sc.i)) sc.fail("expected '...' in a JSX attribute spread");
      sc.i += 3;
      parts.push(`...${captureExpression(sc, "}")}`);
      continue;
    }

    const name = readAttrName(sc);
    if (!name) sc.fail("expected a JSX attribute name");
    const key = /^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name);

    skipSpace(sc);
    if (sc.peek() !== "=") { parts.push(`${key}:true`); continue; }
    sc.i++;
    skipSpace(sc);

    const valueCh = sc.peek();
    if (valueCh === '"' || valueCh === "'") {
      parts.push(`${key}:${JSON.stringify(readQuoted(sc, valueCh))}`);
    } else if (valueCh === "{") {
      sc.i++;
      parts.push(`${key}:${captureExpression(sc, "}")}`);
    } else if (valueCh === "<") {
      parts.push(`${key}:${parseElement(sc)}`);
    } else {
      sc.fail(`unsupported value for JSX attribute '${name}'`);
    }
  }

  return parts.length ? `{${parts.join(",")}}` : "null";
}

function readAttrName(sc) {
  const start = sc.i;
  while (!sc.eof && /[\w$:.-]/.test(sc.peek())) sc.i++;
  return sc.src.slice(start, sc.i);
}

function readQuoted(sc, quote) {
  sc.i++;
  const start = sc.i;
  while (!sc.eof && sc.peek() !== quote) sc.i++;
  if (sc.eof) sc.fail("unterminated JSX attribute value");
  const text = sc.src.slice(start, sc.i);
  sc.i++;
  return text;
}

/**
 * Capture a braced expression as source, running it through the scanner so
 * nested JSX inside it is transformed too. Leaves the cursor past `close`.
 */
function captureExpression(sc, close) {
  const inner = new Scanner(sc.src);
  inner.i = sc.i;
  scanCode(inner, close);
  if (inner.eof) sc.fail("unterminated JSX expression");
  sc.i = inner.i + 1;
  const code = inner.out.join("").trim();
  return code === "" ? "undefined" : `(${code})`;
}

function parseChildren(sc, tagName) {
  const kids = [];

  for (;;) {
    if (sc.eof) sc.fail(tagName ? `unclosed <${tagName}>` : "unclosed fragment");

    // closing tag?
    if (sc.peek() === "<" && sc.peek(1) === "/") {
      sc.i += 2;
      const closing = readTagName(sc);
      skipSpace(sc);
      if (sc.peek() !== ">") sc.fail(`expected '>' to close </${closing}>`);
      sc.i++;
      if (closing !== tagName) {
        sc.fail(`</${closing}> does not match <${tagName || ""}>`);
      }
      break;
    }

    if (sc.peek() === "<") { kids.push(parseElement(sc)); continue; }

    if (sc.peek() === "{") {
      sc.i++;
      skipSpace(sc);
      // `{/* note */}` is a JSX comment - it renders nothing.
      if (sc.peek() === "/" && sc.peek(1) === "*") {
        const end = sc.src.indexOf("*/", sc.i);
        if (end === -1) sc.fail("unterminated JSX comment");
        sc.i = end + 2;
        skipSpace(sc);
        if (sc.peek() !== "}") sc.fail("expected '}' after a JSX comment");
        sc.i++;
        continue;
      }
      const expr = captureExpression(sc, "}");
      if (expr !== "undefined") kids.push(expr);
      continue;
    }

    const text = readText(sc);
    if (text !== null) kids.push(JSON.stringify(text));
  }

  return kids.length ? `,${kids.join(",")}` : "";
}

/**
 * JSX text, with JSX's whitespace rule: runs of whitespace containing a newline
 * are layout, not content, so they collapse away at the edges and become a
 * single space in the middle.
 */
function readText(sc) {
  const start = sc.i;
  while (!sc.eof && sc.peek() !== "<" && sc.peek() !== "{") sc.i++;
  const raw = sc.src.slice(start, sc.i);
  if (raw === "") return null;

  const lines = raw.split("\n");
  if (lines.length === 1) return raw;

  const kept = lines
    .map((line, idx) => (idx === 0 ? line.replace(/\s+$/, "") : line.trim()))
    .filter((line) => line !== "");
  const text = kept.join(" ");
  return text === "" ? null : text;
}
