use std::fs;
use std::path::{Component, Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use crate::typst::io::write_if_changed;
use crate::typst::model::LayoutPaths;
use crate::typst::paths::{artifact_reference, slash_path, CALEPIN_DIR};
const RUNTIME_DIR: &str = "runtime";
const GENERATED_SYNTAX_THEME_FILE: &str = "00_syntax-theme.typ";
pub(crate) const SYNTAX_THEME_FILE: &str = "syntax.tmTheme";
const FACADE_FILE: &str = "calepin.typ";
const ACTIVE_FILE: &str = "active.typ";
const NOTEBOOK_CONFIG_FILE: &str = "runtime-config.typ";
const DEFAULT_ACTIVE_SOURCE: &str = r#"// Generated by Calepin. Do not edit.
#let config = (
enabled: false,
results: none,
"image-meta": none,
"source-dir": "",
source: none,
"raw-langs": ("python", "r", "mermaid", "dot", "tikz", "d2"),
)
"#;
const RUNTIME_FACADE_SOURCE: &str = r#"// Generated by Calepin. Do not edit.
#import "runtime/core/assets.typ" as assets
#import "runtime/core/config.typ" as runtimeconfig
#import "runtime/core/pages.typ" as pagesmod
#import "runtime/core/target.typ" as target
#import "runtime/core/store.typ" as storemod
#import "runtime/notebook/chunk-support.typ" as chunksupport
#import "runtime/notebook/chrome.typ" as chrome
#import "runtime/notebook/code.typ" as code
#import "runtime/notebook/defaults.typ" as defaults
#import "runtime/notebook/render.typ" as render
#import "runtime/notebook/options.typ" as options
#import "runtime/notebook/chunk.typ" as chunks
#import "runtime/elements/mod.typ" as elementmod
#let pages = pagesmod.pages
#let setup = options.setup
#let chunk = chunks.chunk
#let inline = chunks.inline
#let results = chunks.results
#let store = storemod
// The staged source rewrite calls the runtime in place of the fence it
// replaced, so a block this document does not run is handed back as an ordinary
// raw element: a package such as codly declares its `raw` rule after
// `calepin.document`, making that rule the outermost one, and it claims the
// element before Calepin sees it again.
//
// Calepin's own rule is suppressed across the element rather than around this
// call. The suppression is read where the element sits, and a show rule runs at
// layout time, long after a `_without-raw-chunk-transforms` scope would have
// restored the flag — leaving Calepin to treat the element as a second,
// unlabelled chunk.
#let _fenced-source-fallback = it => context {
let previous = chunksupport._disable-raw-chunk-transforms.get()
chunksupport._disable-raw-chunk-transforms.update(_ => true)
it
chunksupport._disable-raw-chunk-transforms.update(_ => previous)
}
#let _fenced-chunk = (engine, it) => chunks._fenced-chunk(
none,
engine,
it,
fallback: _fenced-source-fallback,
)
#let code-block = code.code-block
#let elements = elementmod
// A reference to a panel reports both numbers: `@fig-x-2` reads "Figure 1b",
// the form Quarto and LaTeX subcaption use. The panel caption itself is
// rendered by a rule the grid installs.
// A panel's own supplement is suppressed so its caption reads "(a) ...", so a
// reference borrows the enclosing figure's. Typst resolves the localized default
// for `auto` at layout and does not expose it, hence the literal fallback.
#let _subfigure-supplement(it, location) = {
if it.supplement != auto and it.supplement != none {
return it.supplement
}
let parents = query(figure.where(kind: image).before(location))
if parents.len() > 0 and parents.last().supplement not in (auto, none) {
return parents.last().supplement
}
[Figure]
}
#let _subfigure-ref = it => {
let el = it.element
if el == none or el.func() != figure or el.kind != render._subfigure-kind {
return it
}
context {
let parent = counter(figure.where(kind: image)).at(el.location()).first()
let sub = render._subfigure-number(el.location())
link(el.location())[#_subfigure-supplement(it, el.location()) #parent#sub]
}
}
#let _document(config, body) = {
let config = runtimeconfig._runtime-config(bound: config)
show ref: _subfigure-ref
// Which languages run is decided by `_fenced-chunk` alone, from the
// document's own `fenced-chunks` setting. Gating here on a language list
// instead would make the decision depend on data that differs between the
// query and render passes, which is what desynchronises the `chunk-N`
// counter (issue #108).
show raw.where(block: true, theme: auto): it => {
let lang = if it.has("lang") { it.lang } else { none }
if chunks._disable-raw-chunk-transforms.get() {
code._html-themed-raw-block(it)
} else if lang in ("typ", "typst") {
chunksupport._without-raw-chunk-transforms(() => code._html-themed-raw-block(it))
} else if lang != none {
chunks._fenced-chunk(config, lang, it)
} else {
code._html-themed-raw-block(it)
}
}
body
}
#let document(body) = _document(none, body)
#let bind(config) = {
let config = runtimeconfig._runtime-config(bound: config)
(
pages: pagesmod.pages,
setup: options.setup,
chunk: (..args) => chunks._chunk(config, ..args),
inline: (engine, body, ..args) => chunks._inline(config, engine, body, ..args),
results: (..args) => chunks._results(config, ..args),
store: storemod,
_fenced-chunk: (engine, body) => chunks._fenced-chunk(
config,
engine,
body,
fallback: _fenced-source-fallback,
),
code-block: code.code-block,
elements: elementmod.bind(config),
_resolve-asset-href: (path) => assets._resolve-asset-href(path, config: config),
_resolve-asset-path: (path) => assets._resolve-asset-path(path, config: config),
document: body => _document(config, body),
)
}
#let _mode = target._mode
#let _is-html = target._is-html
#let _is-paged = target._is-paged
#let _is-query = target._is-query
#let _is-render = target._is-render
#let _call-defaults = defaults._call-defaults
#let _disable-raw-chunk-transforms = chunksupport._disable-raw-chunk-transforms
#let _resolve-options = options._resolve-options
#let _html-themed-raw-block = code._html-themed-raw-block
#let _default-chunk-chrome = chrome._default-chunk-chrome
#let _without-raw-chunk-transforms = chunksupport._without-raw-chunk-transforms
#let _fenced-chunks-runs = chunks._fenced-chunks-runs
#let _resolve-asset-href = assets._resolve-asset-href
#let _resolve-asset-path = assets._resolve-asset-path
"#;
struct RuntimeFile {
path: &'static str,
source: &'static str,
}
include!(concat!(env!("OUT_DIR"), "/typst_runtime_assets.rs"));
fn runtime_source_files() -> &'static [RuntimeFile] {
RUNTIME_FILES
}
fn runtime_facade_source() -> &'static str {
RUNTIME_FACADE_SOURCE
}
fn runtime_asset_path(path: &str) -> Result<&Path> {
let path = Path::new(path);
let is_valid = path.is_relative()
&& path.extension().and_then(|extension| extension.to_str()) == Some("typ")
&& path
.components()
.all(|component| matches!(component, Component::Normal(_)));
if !is_valid {
return Err(anyhow!(
"invalid embedded runtime asset path `{}`",
path.display()
));
}
Ok(path)
}
#[cfg(test)]
pub fn write_runtime(root: &Path) -> Result<PathBuf> {
write_runtime_with_syntax_theme_in_dir(
root,
Path::new(CALEPIN_DIR),
&crate::html::HtmlSyntaxTheme::builtin(),
)
}
pub(crate) fn write_runtime_with_syntax_theme(
root: &Path,
syntax_theme: &crate::html::HtmlSyntaxTheme,
) -> Result<PathBuf> {
write_runtime_with_syntax_theme_in_dir(root, Path::new(CALEPIN_DIR), syntax_theme)
}
pub(crate) fn write_runtime_with_syntax_theme_in_dir(
root: &Path,
asset_dir: &Path,
syntax_theme: &crate::html::HtmlSyntaxTheme,
) -> Result<PathBuf> {
let calepin_dir = root.join(asset_dir);
let generated_runtime_dir = calepin_dir.join(RUNTIME_DIR);
fs::create_dir_all(&generated_runtime_dir)
.with_context(|| format!("failed to create {}", generated_runtime_dir.display()))?;
let active = calepin_dir.join(ACTIVE_FILE);
if !active.exists() || !active_binding_resolves(&calepin_dir, &active) {
write_if_changed(&active, DEFAULT_ACTIVE_SOURCE)?;
}
write_if_changed(
&generated_runtime_dir.join(GENERATED_SYNTAX_THEME_FILE),
syntax_theme.typst_runtime_source(),
)?;
write_if_changed(
&calepin_dir.join(SYNTAX_THEME_FILE),
syntax_theme.paged_theme_source(),
)?;
for source_file in runtime_source_files() {
let destination = generated_runtime_dir.join(runtime_asset_path(source_file.path)?);
write_if_changed(&destination, source_file.source)?;
}
let facade = calepin_dir.join(FACADE_FILE);
write_if_changed(&facade, runtime_facade_source())?;
Ok(facade)
}
pub(crate) fn write_notebook_binding(
layout: &LayoutPaths,
raw_languages: &[String],
) -> Result<PathBuf> {
let mut raw_languages = raw_languages.to_vec();
raw_languages.sort();
raw_languages.dedup();
let source_dir = layout
.input_rel
.parent()
.map(slash_path)
.unwrap_or_default();
let languages = raw_languages
.iter()
.map(|language| typst_string(language))
.collect::<Vec<_>>()
.join(", ");
let config_source = format!(
r#"// Generated by Calepin. Do not edit.
#let config = (
enabled: true,
results: path("results.json"),
"image-meta": path("image-meta.json"),
"source-dir": {},
source: {},
"raw-langs": ({}),
)
"#,
typst_string(&source_dir),
typst_string(&slash_path(&layout.input_rel)),
languages,
);
let config_path = layout.artifact_path(NOTEBOOK_CONFIG_FILE);
write_if_changed(&config_path, config_source)?;
let runtime_import =
artifact_reference(&layout.root, &layout.artifact_root().join(FACADE_FILE))?;
let facade_source = format!(
r#"// Generated by Calepin. Do not edit.
#import {} as runtime
#import "{}": config
#let _bound = runtime.bind(config)
#let pages = _bound.pages
#let setup = _bound.setup
#let chunk = _bound.chunk
#let inline = _bound.inline
#let results = _bound.results
#let _fenced-chunk = _bound._fenced-chunk
#let code-block = _bound.code-block
#let elements = _bound.elements
#let _resolve-asset-href = _bound._resolve-asset-href
#let _resolve-asset-path = _bound._resolve-asset-path
#let document = _bound.document
"#,
typst_string(&runtime_import),
NOTEBOOK_CONFIG_FILE,
);
let facade = layout.artifact_path(FACADE_FILE);
write_if_changed(&facade, facade_source)?;
Ok(facade)
}
fn active_binding_resolves(calepin_dir: &Path, active: &Path) -> bool {
let Ok(source) = fs::read_to_string(active) else {
return false;
};
let Some(import) = source
.split_once("#import \"")
.and_then(|(_, rest)| rest.split_once('"'))
.map(|(path, _)| path)
else {
return true;
};
calepin_dir.join(import).is_file()
}
pub(crate) fn publish_active_binding(layout: &LayoutPaths) -> Result<PathBuf> {
let config_path = layout.artifact_path(NOTEBOOK_CONFIG_FILE);
if !config_path.is_file() {
return Err(anyhow!(
"missing notebook runtime configuration {}",
config_path.display()
));
}
let artifact_root = layout.artifact_root();
let config_relative = config_path.strip_prefix(&artifact_root).with_context(|| {
format!(
"notebook runtime configuration {} is outside artifact root {}",
config_path.display(),
artifact_root.display()
)
})?;
let source = format!(
"// Generated by Calepin. Do not edit.\n\n#import {}: config\n",
typst_string(&slash_path(config_relative)),
);
let active = artifact_root.join(ACTIVE_FILE);
write_if_changed(&active, source)?;
Ok(active)
}
fn typst_string(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for character in value.chars() {
match character {
'\\' => escaped.push_str("\\\\"),
'"' => escaped.push_str("\\\""),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
'\t' => escaped.push_str("\\t"),
other => escaped.push(other),
}
}
format!("\"{escaped}\"")
}
#[cfg(test)]
mod embedded_runtime_tests {
use super::*;
#[test]
fn runtime_source_files_are_embedded_relative_typ_files() {
let files = runtime_source_files();
assert!(!files.is_empty(), "expected embedded Typst runtime files");
for file in files {
let path = Path::new(file.path);
assert!(
path.is_relative(),
"runtime asset path should be relative: {}",
file.path
);
assert_eq!(
path.extension().and_then(|extension| extension.to_str()),
Some("typ")
);
assert!(
!file.source.is_empty(),
"runtime asset should have embedded source: {}",
file.path
);
}
}
#[test]
fn runtime_asset_path_rejects_non_normal_paths() {
for path in ["../escape.typ", "core/../escape.typ", "/absolute.typ"] {
assert!(
runtime_asset_path(path).is_err(),
"expected runtime asset path to be rejected: {path}"
);
}
}
#[test]
fn runtime_facade_imports_embedded_runtime_files() {
let embedded_paths = runtime_source_files()
.iter()
.map(|file| file.path)
.collect::<std::collections::HashSet<_>>();
for target in runtime_facade_imports(runtime_facade_source()) {
let Some(runtime_path) = target.strip_prefix("runtime/") else {
panic!("facade import should target runtime directory: {target}");
};
assert!(
embedded_paths.contains(runtime_path),
"facade import target is not embedded: {target}"
);
}
}
#[test]
fn runtime_generation_preserves_existing_active_binding() {
let dir = tempfile::tempdir().unwrap();
let active = dir.path().join(".calepin/active.typ");
fs::create_dir_all(active.parent().unwrap()).unwrap();
fs::write(&active, "#let config = (enabled: true)\n").unwrap();
write_runtime(dir.path()).unwrap();
assert_eq!(
fs::read_to_string(active).unwrap(),
"#let config = (enabled: true)\n"
);
}
#[test]
fn runtime_generation_replaces_active_binding_that_lost_its_notebook() {
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("paper.typ");
fs::write(&input, "").unwrap();
let layout = crate::typst::paths::resolve_layout(&input, Some(dir.path())).unwrap();
write_runtime(dir.path()).unwrap();
write_notebook_binding(&layout, &["python".to_string()]).unwrap();
let active = publish_active_binding(&layout).unwrap();
assert!(fs::read_to_string(&active).unwrap().contains("#import"));
fs::remove_dir_all(&layout.artifact_dir).unwrap();
write_runtime(dir.path()).unwrap();
assert_eq!(fs::read_to_string(&active).unwrap(), DEFAULT_ACTIVE_SOURCE);
}
#[test]
fn notebook_binding_and_active_pointer_follow_nested_layout() {
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("chapters/intro.typ");
fs::create_dir_all(input.parent().unwrap()).unwrap();
fs::write(&input, "").unwrap();
let layout = crate::typst::paths::resolve_layout(&input, Some(dir.path())).unwrap();
write_runtime(dir.path()).unwrap();
let facade = write_notebook_binding(
&layout,
&[
"python".to_string(),
"bash".to_string(),
"python".to_string(),
],
)
.unwrap();
let active = publish_active_binding(&layout).unwrap();
assert_eq!(
facade,
dir.path().join(".calepin/chapters/intro/calepin.typ")
);
let facade_source = fs::read_to_string(facade).unwrap();
assert!(
facade_source.contains(r#"#import "/.calepin/calepin.typ" as runtime"#),
"{facade_source}"
);
let config = fs::read_to_string(
dir.path()
.join(".calepin/chapters/intro/runtime-config.typ"),
)
.unwrap();
assert!(
config.contains(r#"source: "chapters/intro.typ""#),
"{config}"
);
assert!(config.contains(r#""source-dir": "chapters""#), "{config}");
assert_eq!(config.matches(r#""python""#).count(), 1, "{config}");
assert!(
config.find(r#""bash""#).unwrap() < config.find(r#""python""#).unwrap(),
"{config}"
);
let active_source = fs::read_to_string(active).unwrap();
assert!(
active_source.contains(r#"#import "chapters/intro/runtime-config.typ": config"#),
"{active_source}"
);
}
fn runtime_facade_imports(source: &str) -> impl Iterator<Item = &str> {
source.lines().filter_map(|line| {
line.trim_start()
.strip_prefix("#import \"")
.and_then(|line| line.split('"').next())
})
}
}
#[cfg(test)]
#[path = "runtime_tests.rs"]
mod runtime_tests;