feat(checker+typst): 嵌入 render 使 cph 可 cargo install;实现 shell step 执行器(ADR-0013)

三件事,服务于"把恒一小奥 KenKen 课作者化为合法 native 工程文件"这条主线
(工程文件本身在 repo 外的 sibling playground,不入此提交):

1. cph-typst: 把 render/ 编译期嵌入二进制,`cargo install` 后开箱即用
   - build.rs 把 render/ 拷进 OUT_DIR,剔除 dev 自指符号链接 vendor/local-packages
     (否则 include_dir! 无限递归)
   - embedded.rs 用 include_dir! 嵌入,运行时按版本解压到 per-user cache dir
     (CPH_RENDER_DIR 仍优先作 dev override)
   - default_render_dir 不再用编译期 CARGO_MANIFEST_DIR 写死路径

2. cph-check + cph-cli: 实现 Step::Shell 执行器(此前仅占位)
   - run_shell_target: 跑 check 门控 → 以工程根为 cwd 执行 shell step,首非零退出即停
   - target_is_shell + CLI run_shell_build 路由;`check` 不跑 shell(结构校验只看 lesson)
   - compile_targets 只保留含 TypstCompile 的 target,纯 shell target 不走 typst 引擎
   - 语义:失败属 build-过程错误,不入 6 类诊断(taxonomy 保持 6,显式拒绝加 ShellStep 码)

3. spec + ADR: 在 Export/Render.lean 钉 shell step 执行语义 prose;
   ADR-0013(已实现)、ADR-0014(md/HTML 导出后端方向,Proposed/设计先行,未实现)

测试:全 workspace 42 passed(新增 4 个 shell 执行器测试);lake build 25 jobs 绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-23 12:09:49 +08:00
parent c6b4d439ac
commit 9590e15236
12 changed files with 809 additions and 18 deletions
+209 -5
View File
@@ -198,6 +198,198 @@ pub fn build(root: &Path, engine: &Engine, target: &str) -> (Option<Vec<u8>>, Ch
}
}
/// One shell step's execution outcome (for [`run_shell_target`]).
#[derive(Debug, Clone, PartialEq)]
pub struct ShellStepOutcome {
/// The command line that was run (verbatim from the manifest `run` field).
pub run: String,
/// Process exit code (`None` if the process was killed by a signal).
pub status: Option<i32>,
/// Captured stdout.
pub stdout: String,
/// Captured stderr.
pub stderr: String,
}
impl ShellStepOutcome {
/// Whether the step exited successfully (status 0).
pub fn ok(&self) -> bool {
self.status == Some(0)
}
}
/// The result of [`run_shell_target`].
#[derive(Debug, Clone, PartialEq)]
pub struct ShellRunReport {
/// The check phases' report (load → structural → schema). Shell steps run
/// only when this has no errors.
pub check: CheckReport,
/// Each shell step's outcome, in declared order. Empty if the build was
/// refused (check errors, unknown target, or the target has no shell steps).
pub outcomes: Vec<ShellStepOutcome>,
/// `true` if the target was found, check passed, and every shell step exited 0.
pub ok: bool,
}
/// Execute the `Shell` steps of a `file-tree`/tool-generated target (ADR-0009
/// category (b); e.g. the KenKen interactives produced by the `kendoku` CLI).
///
/// This is the **escape-hatch executor**: unlike [`build`] (which compiles a
/// typst template to a PDF), a shell target hands off to an external tool that
/// writes files itself. cph only *runs the declared command* in the engineering
/// root and reports what happened — it assembles nothing.
///
/// Contract / safety:
/// - Runs the check phases first (load → structural → schema). Any error refuses
/// the run (`ok == false`, no command executed) — a shell build of a broken
/// lesson is not attempted.
/// - Each `Step::Shell { run }` is executed via the platform shell
/// (`sh -c <run>` / `cmd /C <run>`) with the **engineering root as the working
/// directory**, so a manifest can use root-relative paths.
/// - **Arbitrary command execution is opt-in by construction**: this function is
/// only reached from `cph build --target <name>`, never from `check`. Callers
/// (the CLI) surface the command before running it.
/// - `TypstCompile` steps inside the same target are skipped here (a shell target
/// is not a typst build); a target with no shell steps yields `ok == false`.
pub fn run_shell_target(root: &Path, engine: &Engine, target: &str) -> ShellRunReport {
let mut diags = Vec::new();
let (lesson, load_diags) = cph_model::load(root);
diags.extend(load_diags);
let Some(lesson) = lesson else {
return ShellRunReport {
check: CheckReport {
diagnostics: dedup(diags),
lesson_loaded: false,
},
outcomes: Vec::new(),
ok: false,
};
};
let known = cph_schema::known_kinds();
run_structural_and_schema(&lesson, known, &mut diags);
// Suppress the unused-parameter warning while keeping the signature uniform
// with `build`/`check` (the engine is not needed to run shell steps, but
// callers pass it so the API is consistent and future steps may use it).
let _ = engine;
let has_error = diags.iter().any(|d| d.severity == Severity::Error);
let Some(tc) = lesson.targets.iter().find(|t| t.name == target) else {
diags.push(
Diagnostic::error(
DiagCode::SchemaViolation,
format!("target '{target}' not declared in manifest"),
)
.with_hint(format!(
"add a `[targets.{target}]` table, or run a declared target"
)),
);
return ShellRunReport {
check: CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
outcomes: Vec::new(),
ok: false,
};
};
let shell_steps: Vec<&str> = tc
.steps
.iter()
.filter_map(|s| match s {
cph_model::Step::Shell { run } => Some(run.as_str()),
cph_model::Step::TypstCompile { .. } => None,
})
.collect();
if has_error || shell_steps.is_empty() {
return ShellRunReport {
check: CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
outcomes: Vec::new(),
ok: false,
};
}
// Run each shell step in the engineering root. First non-zero exit stops the
// sequence (a failed step's successors likely depend on it).
let mut outcomes = Vec::new();
let mut all_ok = true;
for run in shell_steps {
let outcome = run_one_shell(&lesson.root, run);
let ok = outcome.ok();
outcomes.push(outcome);
if !ok {
all_ok = false;
break;
}
}
ShellRunReport {
check: CheckReport {
diagnostics: dedup(diags),
lesson_loaded: true,
},
outcomes,
ok: all_ok,
}
}
/// Whether the named target is a **shell-step** target (its steps contain at
/// least one `Step::Shell` and no `Step::TypstCompile`) — i.e. a tool-generated
/// asset bundle that [`run_shell_target`] executes rather than [`build`]
/// compiling to PDF.
///
/// Loads the lesson read-only (ignoring diagnostics); an unloadable lesson or an
/// unknown target returns `false`, so the caller falls through to the normal
/// (typst) build path, which then reports the real error.
pub fn target_is_shell(root: &Path, target: &str) -> bool {
let (Some(lesson), _) = cph_model::load(root) else {
return false;
};
lesson
.targets
.iter()
.find(|t| t.name == target)
.is_some_and(|t| {
t.steps.iter().any(|s| matches!(s, cph_model::Step::Shell { .. }))
&& !t.steps.iter().any(|s| matches!(s, cph_model::Step::TypstCompile { .. }))
})
}
/// Run one shell command line in `cwd` via the platform shell, capturing output.
fn run_one_shell(cwd: &Path, run: &str) -> ShellStepOutcome {
use std::process::Command;
let output = if cfg!(target_os = "windows") {
Command::new("cmd").arg("/C").arg(run).current_dir(cwd).output()
} else {
Command::new("sh").arg("-c").arg(run).current_dir(cwd).output()
};
match output {
Ok(out) => ShellStepOutcome {
run: run.to_string(),
status: out.status.code(),
stdout: String::from_utf8_lossy(&out.stdout).into_owned(),
stderr: String::from_utf8_lossy(&out.stderr).into_owned(),
},
Err(e) => ShellStepOutcome {
run: run.to_string(),
status: None,
stdout: String::new(),
stderr: format!("failed to spawn command: {e}"),
},
}
}
/// Whether a part's folder exists on disk. cph-model emits `PartPathMissing`
/// for parts whose folder is absent, so the orchestrator skips those parts in
/// the structural/schema/coverage phases to avoid piling diagnostics on the
@@ -245,14 +437,26 @@ fn run_structural_and_schema(
}
}
/// The targets to compile-check for [`check`]: every declared target, or
/// `["student"]` if the lesson declares none.
/// The targets to compile-check for [`check`]: every declared **typst-compile**
/// target, or `["student"]` if the lesson declares none.
///
/// A target whose steps are all `Shell` (e.g. a tool-generated asset bundle —
/// ADR-0005 category (b), like the KenKen interactives produced by `kendoku`) is
/// **not** typst-compiled, so it is excluded here: compile-checking it would
/// wrongly route a non-typst build through the typst engine. Such targets are
/// executed only on an explicit `cph build --target <name>` (see
/// [`run_shell_target`]); `check` validates the lesson structure, not the
/// outcome of running an external tool.
fn compile_targets(lesson: &cph_model::Lesson) -> Vec<&str> {
if lesson.targets.is_empty() {
vec![DEFAULT_TARGET]
} else {
lesson.target_names()
return vec![DEFAULT_TARGET];
}
lesson
.targets
.iter()
.filter(|t| t.steps.iter().any(|s| matches!(s, cph_model::Step::TypstCompile { .. })))
.map(|t| t.name.as_str())
.collect()
}
/// Phase (e): for each `(part.kind, target)` over the lesson's declared targets,
+112
View File
@@ -262,6 +262,118 @@ template = "exports/handout.typ"
cleanup(&tmp);
}
/// Write a one-segment lesson with a `file-tree` shell target whose `run` is the
/// given command. Returns nothing; the caller runs `run_shell_target`.
fn write_shell_target_lesson(tmp: &PathBuf, run: &str) {
std::fs::write(
tmp.join("manifest.toml"),
format!(
r#"
[project]
id = "sh"
name = "sh"
[info]
title = "sh"
[[parts]]
kind = "segment"
path = "segments/intro"
[targets.assets]
artifact = {{ type = "file-tree", root = "build/assets", outputs = "**/*" }}
[[targets.assets.steps]]
type = "shell"
run = "{run}"
"#
),
)
.unwrap();
let part_dir = tmp.join("segments").join("intro");
std::fs::create_dir_all(&part_dir).unwrap();
std::fs::write(part_dir.join("element.toml"), "kind = \"segment\"\n").unwrap();
std::fs::write(part_dir.join("textbook.typ"), "Hi.\n").unwrap();
}
#[test]
fn target_is_shell_detects_shape() {
let tmp = tempdir();
write_shell_target_lesson(&tmp, "true");
assert!(cph_check::target_is_shell(&tmp, "assets"));
assert!(!cph_check::target_is_shell(&tmp, "nonexistent"));
cleanup(&tmp);
}
#[test]
fn run_shell_target_executes_in_engineering_root() {
// The command writes a file under the engineering root; cwd must be the root.
let tmp = tempdir();
write_shell_target_lesson(
&tmp,
"mkdir -p build/assets && printf ok > build/assets/marker.txt",
);
let report = cph_check::run_shell_target(&tmp, &engine(), "assets");
assert!(report.ok, "shell target should succeed: {:#?}", report);
assert_eq!(report.outcomes.len(), 1);
assert!(report.outcomes[0].ok());
// The side effect landed in the engineering root.
let marker = tmp.join("build").join("assets").join("marker.txt");
assert_eq!(std::fs::read_to_string(&marker).unwrap(), "ok");
cleanup(&tmp);
}
#[test]
fn run_shell_target_reports_nonzero_exit() {
let tmp = tempdir();
write_shell_target_lesson(&tmp, "exit 3");
let report = cph_check::run_shell_target(&tmp, &engine(), "assets");
assert!(!report.ok, "non-zero exit must fail the run");
assert_eq!(report.outcomes.len(), 1);
assert_eq!(report.outcomes[0].status, Some(3));
cleanup(&tmp);
}
#[test]
fn run_shell_target_refuses_broken_lesson() {
// A part path that doesn't exist → check error → shell steps never run.
let tmp = tempdir();
std::fs::write(
tmp.join("manifest.toml"),
r#"
[project]
id = "sh"
name = "sh"
[info]
title = "sh"
[[parts]]
kind = "segment"
path = "segments/missing"
[targets.assets]
artifact = { type = "file-tree", root = "build/assets", outputs = "**/*" }
[[targets.assets.steps]]
type = "shell"
run = "printf SHOULD_NOT_RUN > build/ran.txt"
"#,
)
.unwrap();
let report = cph_check::run_shell_target(&tmp, &engine(), "assets");
assert!(!report.ok);
assert!(report.outcomes.is_empty(), "no command should run on a broken lesson");
assert!(report.check.has_errors());
assert!(
!tmp.join("build").join("ran.txt").exists(),
"the command must not have executed"
);
cleanup(&tmp);
}
// --- tiny temp-dir helpers (no external dev-dep) ------------------------------
fn tempdir() -> PathBuf {
+51
View File
@@ -102,6 +102,14 @@ fn run_build(
target: &str,
out: Option<PathBuf>,
) -> ExitCode {
// A target whose steps are shell commands (a tool-generated asset bundle,
// ADR-0009 category (b) — e.g. KenKen interactives via `kendoku`) is run by
// executing those commands, not by compiling a typst template. Detect that
// shape up front and route accordingly.
if cph_check::target_is_shell(path, target) {
return run_shell_build(path, engine, target);
}
let out_path = out.unwrap_or_else(|| path.join("build").join(format!("{target}.pdf")));
let (pdf, report) = cph_check::build(path, engine, target);
@@ -131,3 +139,46 @@ fn run_build(
}
}
}
/// Run a shell-step target: execute its declared commands in the engineering
/// root. Arbitrary command execution is **opt-in** — it only happens on an
/// explicit `cph build --target <name>`, and each command is printed before it
/// runs so the user sees exactly what is executed.
fn run_shell_build(path: &std::path::Path, engine: &Engine, target: &str) -> ExitCode {
eprintln!("running shell-step target '{target}' (commands execute in {})", path.display());
let report = cph_check::run_shell_target(path, engine, target);
print_diagnostics(&report.check);
if report.outcomes.is_empty() && !report.ok {
// Refused before running anything (check errors / unknown target / no
// shell steps): the diagnostics above explain why.
if report.check.has_errors() {
eprintln!("build refused: {} errors (fix the lesson first)", report.check.error_count());
} else {
eprintln!("build failed: target '{target}' has no shell steps to run");
}
return ExitCode::FAILURE;
}
for outcome in &report.outcomes {
eprintln!("$ {}", outcome.run);
if !outcome.stdout.trim().is_empty() {
print!("{}", outcome.stdout);
}
if !outcome.ok() {
eprint!("{}", outcome.stderr);
eprintln!(
"step failed (exit {})",
outcome.status.map(|c| c.to_string()).unwrap_or_else(|| "signal".into())
);
}
}
if report.ok {
println!("shell-step target '{target}' completed: {} step(s) ok", report.outcomes.len());
ExitCode::SUCCESS
} else {
eprintln!("shell-step target '{target}' failed");
ExitCode::FAILURE
}
}
+7
View File
@@ -10,6 +10,13 @@ cph-model = { workspace = true }
cph-schema = { path = "../cph-schema" }
toml = { workspace = true }
# Embed the `render/` package (cph-render + vendored @preview/numbly) INTO the
# binary so an installed `cph` is self-contained — no reliance on a source
# checkout living at a compile-time path. `dirs` locates a per-user cache dir to
# extract the embedded tree to (typst's World reads render files from disk).
include_dir = "0.7"
dirs = "6"
typst = "=0.15.0"
typst-layout = "=0.15.0"
typst-pdf = "=0.15.0"
+64
View File
@@ -0,0 +1,64 @@
//! Build script: stage a clean copy of the repo `render/` package into `OUT_DIR`
//! so `src/embedded.rs` can `include_dir!` it without tripping over the dev-only
//! self-referential symlink at `render/vendor/local-packages/`.
//!
//! Why this exists: `include_dir!` walks a directory at compile time and follows
//! symlinks. The repo keeps `render/vendor/local-packages/local/cph-render/0.1.0`
//! as a symlink pointing back at `render/` (a gitignored dev convenience for
//! typst's local-package resolution). Embedding `render/` directly would make
//! `include_dir!` recurse into that link forever. The runtime never needs that
//! link (the engine resolves `@local/cph-render` from the render dir directly,
//! and `@preview/*` from `vendor/typst-packages/`), so we copy everything EXCEPT
//! `vendor/local-packages/` into `OUT_DIR/render` and embed that staged tree.
use std::path::{Path, PathBuf};
fn main() {
let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap());
let render_src = manifest_dir.join("..").join("..").join("render");
let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap());
let staged = out_dir.join("render");
// Rebuild the staged tree from scratch each run (cheap — ~140 KiB).
if staged.exists() {
let _ = std::fs::remove_dir_all(&staged);
}
copy_tree(&render_src, &staged).expect("stage render/ for embedding");
// Re-run when the render package changes.
println!("cargo:rerun-if-changed={}", render_src.display());
// Expose the staged path to the crate via an env var include_dir! can read.
println!("cargo:rustc-env=CPH_STAGED_RENDER_DIR={}", staged.display());
}
/// Recursively copy `src` → `dst`, skipping the dev-only `vendor/local-packages`
/// subtree and never following symlinks into it.
fn copy_tree(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let name = entry.file_name();
let from = entry.path();
let to = dst.join(&name);
// Skip the self-referential dev symlink tree.
if from.ends_with("vendor/local-packages") || name == *"local-packages" {
continue;
}
// Resolve symlinks/metadata without following into a cycle: use
// `symlink_metadata` so a symlinked dir is treated by its link, not its
// (recursive) target. We only copy real files and real directories.
let meta = std::fs::symlink_metadata(&from)?;
let ft = meta.file_type();
if ft.is_symlink() {
// Skip any symlink (the only one in the tree is the dev convenience).
continue;
} else if ft.is_dir() {
copy_tree(&from, &to)?;
} else if ft.is_file() {
std::fs::copy(&from, &to)?;
}
}
Ok(())
}
+104
View File
@@ -0,0 +1,104 @@
//! Embedded `cph-render` package — makes an installed `cph` self-contained.
//!
//! The typst [`crate::world::LessonWorld`] reads the render package
//! (`@local/cph-render` + the vendored `@preview/numbly`) **from disk**, by
//! `render_dir.join(vpath.realize)`. In a source checkout that dir is the repo's
//! `render/`. But a `cargo install`-ed binary has no repo around it — the old
//! `env!("CARGO_MANIFEST_DIR")/../../render` path points into the build sandbox
//! and does not exist at runtime.
//!
//! So we **embed the whole `render/` tree into the binary** at compile time
//! (`include_dir!`) and, on first use, **extract it to a per-user cache dir**
//! keyed by the crate version. `default_render_dir` returns that extracted path.
//! The tree is tiny (~140 KiB, ~33 files), so embedding is cheap and extraction
//! is a one-time cost per version.
//!
//! `render/vendor/local-packages/` (a dev-only self-referential symlink,
//! gitignored) is excluded by the build script when it stages the tree for
//! embedding — see `build.rs`. The embedded copy never contains it, which is
//! correct: the World resolves `@local/cph-render` from `render_dir` directly,
//! and `@preview/*` from `vendor/typst-packages/`, neither via that symlink.
use std::path::{Path, PathBuf};
use include_dir::{include_dir, Dir};
/// The `render/` tree, embedded at compile time from the **staged** copy the
/// build script wrote to `OUT_DIR/render` (which omits the dev-only symlink that
/// would otherwise make `include_dir!` recurse forever). `CPH_STAGED_RENDER_DIR`
/// is set by `build.rs`.
static RENDER_DIR: Dir<'_> = include_dir!("$CPH_STAGED_RENDER_DIR");
/// Resolve the directory the typst World should read the render package from,
/// extracting the embedded copy to a per-user cache dir if needed.
///
/// Resolution order:
/// 1. `CPH_RENDER_DIR` env var — an explicit override (dev convenience: point
/// at the live repo `render/`).
/// 2. The extracted embedded copy under the user cache dir
/// (`<cache>/cph/render-<version>/`). Extracted once per crate version;
/// subsequent runs reuse it.
///
/// On any failure to locate a cache dir or extract, falls back to a temp-dir
/// location so the engine still works (just re-extracting per process).
pub fn resolve_render_dir() -> PathBuf {
if let Ok(dir) = std::env::var("CPH_RENDER_DIR") {
return PathBuf::from(dir);
}
ensure_extracted().unwrap_or_else(|_| {
// Last-resort: extract under the OS temp dir. Still correct, just not
// cached across processes.
let fallback = std::env::temp_dir().join(format!("cph-render-{}", env!("CARGO_PKG_VERSION")));
let _ = extract_to(&fallback);
fallback
})
}
/// The version-keyed cache location and a guarantee the embedded tree is present
/// there. Returns the directory the World should use.
fn ensure_extracted() -> std::io::Result<PathBuf> {
let base = dirs::cache_dir()
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "no user cache dir"))?;
let dest = base
.join("cph")
.join(format!("render-{}", env!("CARGO_PKG_VERSION")));
// A sentinel marks a complete extraction; if present, reuse as-is. (Keyed by
// version, so a new `cph` version re-extracts into a fresh dir.)
let sentinel = dest.join(".extracted");
if sentinel.is_file() {
return Ok(dest);
}
extract_to(&dest)?;
std::fs::write(&sentinel, env!("CARGO_PKG_VERSION"))?;
Ok(dest)
}
/// Write the embedded `render/` tree under `dest` (creating dirs as needed).
///
/// Each embedded entry's `.path()` is already relative to the embed root (e.g.
/// `src/elements/segment.typ`), so we join the **full** path under `dest` and
/// create parents — no per-level mirroring needed.
fn extract_to(dest: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dest)?;
write_dir(&RENDER_DIR, dest)
}
/// Recursively write an embedded [`Dir`]'s files under `dest`, preserving the
/// full relative path of each file.
fn write_dir(dir: &Dir<'_>, dest: &Path) -> std::io::Result<()> {
for entry in dir.entries() {
match entry {
include_dir::DirEntry::Dir(sub) => write_dir(sub, dest)?,
include_dir::DirEntry::File(file) => {
let file_dest = dest.join(file.path());
if let Some(parent) = file_dest.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&file_dest, file.contents())?;
}
}
}
Ok(())
}
+13 -11
View File
@@ -35,6 +35,7 @@
//! VirtualPath)`, and a diagnostic span is a `DiagSpan` (not a bare `Span`).
mod diag;
mod embedded;
mod manifest;
mod world;
@@ -68,8 +69,9 @@ pub struct Engine {
impl Engine {
/// Construct an engine, discovering system + embedded fonts. The render
/// package directory is taken from the `CPH_RENDER_DIR` environment variable
/// when set, otherwise defaults to `<repo>/render` resolved relative to this
/// crate (`CARGO_MANIFEST_DIR/../../render`).
/// when set, otherwise the **embedded** render package is extracted to a
/// per-user cache dir and used (see [`default_render_dir`]). This keeps an
/// installed `cph` self-contained — no source checkout required at runtime.
pub fn new() -> Self {
Self::with_render_dir(default_render_dir())
}
@@ -234,14 +236,14 @@ fn map_all(world: &LessonWorld, diags: &[typst::diag::SourceDiagnostic]) -> Vec<
.collect()
}
/// `<crate>/../../render` — the repo's render package, used when `CPH_RENDER_DIR`
/// is unset.
/// The render-package directory the engine resolves `@local/cph-render` (and the
/// vendored `@preview/*`) from when no explicit dir is given.
///
/// Honors `CPH_RENDER_DIR` first (dev override → live repo `render/`); otherwise
/// returns a per-user cache dir holding the **embedded** render tree, extracted
/// on demand (see [`embedded`]). This is what makes a `cargo install`-ed `cph`
/// self-contained: it no longer depends on a source checkout living at a
/// compile-time path.
fn default_render_dir() -> PathBuf {
if let Ok(dir) = std::env::var("CPH_RENDER_DIR") {
return PathBuf::from(dir);
}
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("render")
embedded::resolve_render_dir()
}