forked from EduCraft/curriculum-project-hub
feat(cph): add outline export command
This commit is contained in:
@@ -11,6 +11,8 @@ path = "src/main.rs"
|
||||
[dependencies]
|
||||
cph-check = { path = "../cph-check" }
|
||||
cph-diag = { workspace = true }
|
||||
cph-model = { workspace = true }
|
||||
cph-typst = { path = "../cph-typst" }
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
clap_complete = "4"
|
||||
serde_json = "1"
|
||||
|
||||
@@ -7,11 +7,14 @@
|
||||
//! exits 1 when there is any `Error`-severity diagnostic (warnings alone exit
|
||||
//! 0); `build` exits 1 when the PDF could not be produced.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
use cph_check::CheckReport;
|
||||
use cph_model::OutlineDocument;
|
||||
use cph_typst::Engine;
|
||||
|
||||
/// The `cph` checker for curriculum engineering files.
|
||||
@@ -70,6 +73,21 @@ enum Command {
|
||||
#[arg(short = 'o', long, value_name = "OUT")]
|
||||
out: Option<PathBuf>,
|
||||
},
|
||||
/// Export the teacher-facing outline as Markdown, PDF, or JSON.
|
||||
Outline {
|
||||
/// Path to the engineering-file root. Defaults to the current directory.
|
||||
#[arg(default_value = ".")]
|
||||
path: PathBuf,
|
||||
/// Output format. Defaults to PDF.
|
||||
#[arg(long, value_enum, default_value_t = OutlineFormat::Pdf)]
|
||||
format: OutlineFormat,
|
||||
/// Output path. Defaults to `<PATH>/outline.<format>`.
|
||||
#[arg(short = 'o', long, value_name = "OUT")]
|
||||
out: Option<PathBuf>,
|
||||
/// Allow replacing an existing output file.
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
/// Print a shell-completion script to stdout (clap_complete; ADR-0013 opt-in
|
||||
/// sibling: a local convenience, no lesson involved). Pipe to your shell's
|
||||
/// completion file, e.g. `cph completions zsh > ~/.zfunc/_cph`.
|
||||
@@ -79,6 +97,23 @@ enum Command {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
|
||||
enum OutlineFormat {
|
||||
Md,
|
||||
Pdf,
|
||||
Json,
|
||||
}
|
||||
|
||||
impl OutlineFormat {
|
||||
fn extension(self) -> &'static str {
|
||||
match self {
|
||||
Self::Md => "md",
|
||||
Self::Pdf => "pdf",
|
||||
Self::Json => "json",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, clap::ValueEnum)]
|
||||
enum CompletionTarget {
|
||||
Bash,
|
||||
@@ -100,6 +135,12 @@ fn main() -> ExitCode {
|
||||
Command::Check { path } => run_check(&path, &engine),
|
||||
Command::Build { path, targets, out } => run_build_command(&path, &engine, targets, out),
|
||||
Command::Bundle { path, targets, out } => run_bundle_command(&path, &engine, targets, out),
|
||||
Command::Outline {
|
||||
path,
|
||||
format,
|
||||
out,
|
||||
force,
|
||||
} => run_outline(&path, &engine, format, out, force),
|
||||
Command::Completions { shell } => run_completions(shell),
|
||||
}
|
||||
}
|
||||
@@ -157,6 +198,135 @@ fn run_check(path: &std::path::Path, engine: &Engine) -> ExitCode {
|
||||
}
|
||||
}
|
||||
|
||||
fn run_outline(
|
||||
path: &std::path::Path,
|
||||
engine: &Engine,
|
||||
format: OutlineFormat,
|
||||
out: Option<PathBuf>,
|
||||
force: bool,
|
||||
) -> ExitCode {
|
||||
let out_path = out.unwrap_or_else(|| path.join(format!("outline.{}", format.extension())));
|
||||
|
||||
if out_path.exists() && !force {
|
||||
eprintln!(
|
||||
"warning: output '{}' already exists; pass --force to overwrite",
|
||||
out_path.display()
|
||||
);
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
|
||||
let (outline, report) = cph_check::outline(path);
|
||||
print_diagnostics(&report);
|
||||
let Some(outline) = outline else {
|
||||
eprintln!("outline failed: fix the lesson before exporting");
|
||||
return ExitCode::FAILURE;
|
||||
};
|
||||
|
||||
let bytes = match format {
|
||||
OutlineFormat::Md => render_outline_markdown(&outline).into_bytes(),
|
||||
OutlineFormat::Json => match serde_json::to_vec_pretty(&outline) {
|
||||
Ok(mut bytes) => {
|
||||
bytes.push(b'\n');
|
||||
bytes
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("outline failed: cannot serialize JSON: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
},
|
||||
OutlineFormat::Pdf => match engine.build_outline_pdf(&outline) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(diags) => {
|
||||
for diagnostic in &diags {
|
||||
eprintln!("{diagnostic}");
|
||||
}
|
||||
eprintln!("outline failed: PDF compilation failed");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if force && out_path.exists() {
|
||||
eprintln!(
|
||||
"warning: overwriting existing output '{}'",
|
||||
out_path.display()
|
||||
);
|
||||
}
|
||||
if let Err(e) = write_outline_output(&out_path, &bytes, force) {
|
||||
eprintln!("outline failed: {e}");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
println!("wrote {} ({} bytes)", out_path.display(), bytes.len());
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
fn render_outline_markdown(outline: &OutlineDocument) -> String {
|
||||
let mut body = format!("# {}\n\n", outline.title.trim());
|
||||
if !outline.authors.is_empty() {
|
||||
body.push_str("作者:");
|
||||
body.push_str(&outline.authors.join("、"));
|
||||
body.push_str("\n\n");
|
||||
}
|
||||
for child in &outline.children {
|
||||
append_outline_markdown(&mut body, child, 2);
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn append_outline_markdown(body: &mut String, node: &cph_model::OutlineNode, level: usize) {
|
||||
let level = level.min(6);
|
||||
body.push_str(&"#".repeat(level));
|
||||
body.push(' ');
|
||||
body.push_str(&node.title);
|
||||
if !node.kind.is_empty() {
|
||||
body.push_str(" `[");
|
||||
body.push_str(&node.kind);
|
||||
body.push_str("]`");
|
||||
}
|
||||
body.push_str("\n\n");
|
||||
|
||||
if let Some(notes) = node.notes.as_deref() {
|
||||
body.push_str("> 教学提示:\n");
|
||||
for line in notes.lines() {
|
||||
body.push_str("> ");
|
||||
body.push_str(line);
|
||||
body.push('\n');
|
||||
}
|
||||
body.push('\n');
|
||||
}
|
||||
|
||||
for child in &node.children {
|
||||
append_outline_markdown(body, child, level + 1);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_outline_output(path: &std::path::Path, bytes: &[u8], force: bool) -> Result<(), String> {
|
||||
if let Some(parent) = path
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
{
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("cannot create output directory '{}': {e}", parent.display()))?;
|
||||
}
|
||||
|
||||
if force {
|
||||
std::fs::write(path, bytes).map_err(|e| format!("cannot write '{}': {e}", path.display()))
|
||||
} else {
|
||||
let mut file = match OpenOptions::new().write(true).create_new(true).open(path) {
|
||||
Ok(file) => file,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
return Err(format!(
|
||||
"output '{}' already exists; pass --force to overwrite",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(format!("cannot create '{}': {e}", path.display())),
|
||||
};
|
||||
file.write_all(bytes)
|
||||
.map_err(|e| format!("cannot write '{}': {e}", path.display()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch `cph build` (ADR-0030): with an explicit `--target` (repeatable),
|
||||
/// build exactly that ordered set; with none, batch every target the lesson
|
||||
/// declares. Each target builds **independently** — one failing does not stop
|
||||
|
||||
Reference in New Issue
Block a user