calepin 0.0.18

A Rust CLI for preprocessing Typst documents with executable code chunks
#import "../core/state.typ": _auto-inline-label-index, _base-options, _call-defaults
#import "../core/state.typ": _derive-label, _disable-raw-chunk-transforms
#import "../core/state.typ": _raw-node, _raw-text, _sync-auto-label-counter
#import "../core/state.typ": _relocate-opts
#import "../core/target.typ": _is-query
#import "render.typ": _html-themed-raw-block, _input-block, _render-results
#import "options.typ": _resolve-options


#let _chunk-spec(body, engine, label, crossref-labels, options) = {
  let out = (
    body: body,
    engine: engine,
    label: label,
    "crossref-labels": crossref-labels,
  )
  for key in _base-options.keys() {
    if key != "fenced-chunks" {
      out.insert(key, options.at(key))
    }
  }
  out
}

#let _query-crossref-placeholders(crossref-labels) = {
  let out = []
  for name in crossref-labels {
    if type(name) == str and name.starts-with("fig-") {
      out += [#figure(box(width: 0pt, height: 0pt), caption: none) #label(name)]
    }
  }
  out
}

#let _strip-qmd-label-quotes(value) = {
  let value = value.trim()
  if value.len() >= 2 and (
    (value.starts-with("\"") and value.ends-with("\"")) or
    (value.starts-with("'") and value.ends-with("'"))
  ) {
    value.slice(1, value.len() - 1)
  } else {
    value
  }
}

#let _parse-qmd-label-value(value) = {
  let value = value.trim()
  if value.starts-with("[") and value.ends-with("]") {
    let inner = value.slice(1, value.len() - 1).trim()
    if inner == "" {
      return ()
    }
    let labels = ()
    for item in inner.split(",") {
      labels.push(_strip-qmd-label-quotes(item))
    }
    labels
  } else {
    _strip-qmd-label-quotes(value)
  }
}

#let _qmd-label-from-body(body) = {
  let code = _raw-text(body)
  let code = if code.starts-with("\n") { code.slice(1) } else { code }
  for line in code.split("\n") {
    let trimmed = line.trim()
    if not trimmed.starts-with("#|") {
      return none
    }
    let directive = trimmed.slice(2).trim()
    let colon = directive.position(":")
    if colon == none {
      continue
    }
    let key = directive.slice(0, colon).trim()
    if key == "label" {
      return _parse-qmd-label-value(directive.slice(colon + 1))
    }
  }
  none
}

#let _label-name(value) = {
  let value = str(value)
  if value.starts-with("<") and value.ends-with(">") and value.len() >= 2 {
    value.slice(1, value.len() - 1)
  } else {
    value
  }
}

#let _metadata-fence-label(node) = {
  if node.has("label") and node.label == <calepin-fence-label> {
    let value = node.value
    if type(value) == dictionary and value.at("label", default: none) != none {
      return _label-name(value.at("label"))
    }
    panic("calepin.chunk: trailing fence label metadata is malformed")
  }
  none
}

#let _fence-label-from-body(body) = {
  let labels = ()
  let raw = _raw-node(body)
  if raw.has("label") {
    labels.push(_label-name(raw.label))
  }
  if body.has("children") {
    for child in body.children {
      let label = _metadata-fence-label(child)
      if label != none {
        labels.push(label)
      }
    }
  }
  if labels.len() > 1 {
    panic("calepin.chunk: label supplied more than once")
  }
  if labels.len() == 1 {
    labels.first()
  } else {
    none
  }
}

#let _strip-qmd-header(code) = {
  let out = ""
  let reading-header = true
  for line in code.split("\n") {
    if reading-header and line.trim().starts-with("#|") {
      continue
    }
    reading-header = false
    if out == "" {
      out = line
    } else {
      out += "\n" + line
    }
  }
  out
}

// Detect and strip a version suffix that Typst's fence parser split from
// the lang identifier.  For example, ```julia-1.2 produces lang="julia-1"
// with ".2\n" prepended to the code text.  This mirrors the
// reattach_version_suffix() logic in query.rs so the echo shows clean code.
#let _strip-lang-version-suffix(engine, code) = {
  let builtin-engines = ("python", "r", "mermaid", "dot", "tikz", "d2")
  if engine in builtin-engines { return code }
  let nl = code.position("\n")
  if nl == none { return code }
  let first-line = code.slice(0, nl)
  if not first-line.starts-with(".") or first-line.len() < 2 { return code }
  let tail = first-line.slice(1)
  let parts = tail.split(".")
  let is-version = parts.all(part =>
    part.len() > 0 and part.match(regex("^[0-9]+$")) != none
  )
  if not is-version { return code }
  code.slice(nl + 1)
}

#let _emit-chunk(engine, body, ..args) = context {
  let options = _call-defaults + args.named()
  let label-opt = options.at("label")
  let qmd-label-opt = _qmd-label-from-body(body)
  let fence-label-opt = _fence-label-from-body(body)
  let label-count = (
    if label-opt != none { 1 } else { 0 }
  ) + (
    if qmd-label-opt != none { 1 } else { 0 }
  ) + (
    if fence-label-opt != none { 1 } else { 0 }
  )
  if label-count > 1 {
    panic("calepin.chunk: label supplied more than once")
  }
  let label-opt = if qmd-label-opt != none {
    qmd-label-opt
  } else if fence-label-opt != none {
    fence-label-opt
  } else {
    label-opt
  }
  let auto-label-state = options.at("auto-label-state")
  let auto-label-prefix = options.at("auto-label-prefix")
  let derived = _derive-label(label-opt, auto-label-prefix, auto-label-state.get())
  let label = derived.id
  let crossref-labels = derived.names
  let generated-label = derived.generated
  let label-step = if generated-label {
    auto-label-state.update(n => n + 1)
  } else {
    _sync-auto-label-counter(auto-label-state, label)
  }
  if _is-query() {
    [
      #label-step
      #metadata(_chunk-spec(body, engine, label, crossref-labels, options)) <calepin-chunk>
      #_query-crossref-placeholders(crossref-labels)
    ]
  } else {
    let code = _raw-text(body)
    let code = if code.starts-with("\n") { code.slice(1) } else { code }
    let code = _strip-lang-version-suffix(engine, code)
    let code = _strip-qmd-header(code)
    let options = _resolve-options(engine, options)
    let show-echo = options.at("echo") == true
    let results-mode = options.at("results")
    let results-path = sys.inputs.at("calepin-results", default: "")
    label-step
    [#metadata((label: label, page: here().page())) <calepin-page>]

    // Stash the resolved display options so a `#calepin.results(label)` call
    // placed elsewhere can render this chunk with identical settings. Only the
    // render-relevant keys are kept so the stored value stays plain data.
    let stashed = (:)
    for key in _base-options.keys() {
      stashed.insert(key, options.at(key))
    }
    stashed.insert("inline-output", options.at("inline-output"))
    _relocate-opts.update(reg => {
      reg.insert(label, stashed)
      reg
    })

    if show-echo {
      _input-block(code, lang: engine)
    } else if results-path == "" {
      _input-block(code, lang: engine)
    }
    // `results: "hide"` runs the chunk but renders nothing here; the output can
    // still be shown elsewhere with `#calepin.results(label)`.
    if results-path != "" and results-mode != "hide" {
      _render-results(label, options, anchor: true)
    }
  }
}

#let _without-raw-chunk-transforms(body) = context {
  let disabled = _disable-raw-chunk-transforms.get()
  _disable-raw-chunk-transforms.update(_ => true)
  let rendered = body()
  _disable-raw-chunk-transforms.update(_ => disabled)
  rendered
}

// `fenced-chunks` is the single switch for auto-running plain fenced blocks:
// `true` (every engine), an engine name, or a list of engine names.
#let _fenced-chunks-runs(engine, setting) = {
  if engine in ("typ", "typst") {
    false
  } else if setting == true {
    true
  } else if type(setting) == str {
    setting == engine
  } else if type(setting) == array {
    setting.contains(engine)
  } else {
    false
  }
}

#let chunk-from-raw-plain(engine, it) = {
  let defaults = _resolve-options(engine, _call-defaults)
  if _fenced-chunks-runs(engine, defaults.at("fenced-chunks")) {
    _emit-chunk(engine, it, ..defaults)
  } else {
    _html-themed-raw-block(it)
  }
}

#let _infer-engine(body) = {
  let node = _raw-node(body)
  if node.has("lang") and node.lang != none {
    node.lang
  } else {
    panic("calepin.chunk: no engine given; add a language to the fence (e.g. ```python) or pass the engine name")
  }
}

// `chunk` accepts either an explicit engine (`chunk("python")[...]`) or just a
// body (`chunk[```python ... ```]`), in which case the engine is read from the
// fenced block's language.
#let chunk(..args) = {
  let positional = args.pos()
  let engine = none
  let body = none
  if positional.len() >= 2 and type(positional.at(0)) == str {
    engine = positional.at(0)
    body = positional.at(1)
  } else if positional.len() >= 1 {
    body = positional.at(0)
    engine = _infer-engine(body)
  } else {
    panic("calepin.chunk: missing code block")
  }
  _without-raw-chunk-transforms(() => _emit-chunk(engine, body, ..args.named()))
}

#let inline(engine, body, ..args) = {
  let opts = args.named()
  if opts.at("label", default: none) != none {
    panic("unexpected argument: label")
  }
  let defaults = (
    echo: false,
    inline-output: true,
    auto-label-prefix: "inline",
    auto-label-state: _auto-inline-label-index,
  )
  chunk(engine, body, ..(defaults + opts))
}

// Render a chunk's output at this location instead of (or in addition to) the
// chunk's own position. Pair it with `results: "hide"` on the chunk to move the
// output elsewhere. The label is given positionally (`calepin.results("foo")`)
// or named (`calepin.results(label: "foo")`).
//
// A cross-reference anchor (a `fig-`/`tbl-`/`lst-` label) lives wherever the
// figure is shown: at the chunk's own position when it is visible, and here when
// the source chunk is hidden. Referencing a figure that is shown in more than
// one place is ambiguous, and Typst reports it as a duplicate-label error.
#let results(..args) = {
  let positional = args.pos()
  let named = args.named()
  let label = if named.at("label", default: none) != none {
    named.at("label")
  } else if positional.len() >= 1 {
    positional.at(0)
  } else {
    panic("calepin.results: provide a chunk label, e.g. calepin.results(\"my-label\")")
  }
  if type(label) != str {
    panic("calepin.results: label must be a string")
  }
  if _is-query() {
    // Nothing to emit in the query pass; rendering happens during the render pass.
  } else {
    context {
      let results-path = sys.inputs.at("calepin-results", default: "")
      if results-path != "" {
        let results-doc = json(results-path)
        let chunk = results-doc.at("chunks", default: (:)).at(label, default: none)
        if chunk == none {
          panic("calepin.results: no chunk is labeled `" + label + "`")
        }
        let opts = _relocate-opts.final().at(label, default: none)
        if opts == none {
          panic("calepin.results: cannot find a chunk labeled `" + label + "` to relocate")
        }
        // The anchor follows the figure: attach it here only when the source
        // chunk is hidden (and so renders nothing at its own position).
        let hidden = opts.at("results", default: "render") == "hide"
        _render-results(label, opts, anchor: hidden)
      }
    }
  }
}