feat(cph): add outline export command

This commit is contained in:
2026-08-05 16:34:02 +08:00
committed by 洪佳荣
parent 9927d38c18
commit fe8a17c6ad
15 changed files with 635 additions and 56 deletions
+169 -8
View File
@@ -60,6 +60,17 @@ pub struct Lesson {
}
impl Lesson {
/// Project the nested depth-first outline into the target-independent
/// document shape used by `cph outline`.
pub fn outline_document(&self) -> OutlineDocument {
let (children, _) = consume_outline_children(self, 0, 0);
OutlineDocument {
title: self.info.title.clone(),
authors: self.info.authors.clone(),
children,
}
}
/// The declared export-target names, in declared order.
pub fn target_names(&self) -> Vec<&str> {
self.targets.iter().map(|t| t.name.as_str()).collect()
@@ -67,12 +78,14 @@ impl Lesson {
}
/// One entry in the lesson's full rendering-order sequence (ADR-0029).
#[derive(Debug, Clone, PartialEq, Serialize)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum OutlineEntry {
/// An element at this position: `part_index` into [`Lesson::parts`].
Element {
/// Index into `Lesson::parts`.
part_index: usize,
/// Container depth: 0 for a root child, 1 inside a direct section, etc.
depth: u32,
},
/// A section container opens here. Contributes no element; it is a
/// heading, not a part (ADR-0029). `depth` is the section's nesting depth
@@ -84,6 +97,8 @@ pub enum OutlineEntry {
/// Heading text: the container's `[group].title` if present and
/// non-empty, else the folder's basename.
title: String,
/// Optional teacher-facing planning note from the `children` entry.
notes: Option<String>,
/// Nesting depth (1-based) — the heading level a renderer should use.
depth: u32,
/// The container folder's path, relative to the engineering-file root.
@@ -91,6 +106,95 @@ pub enum OutlineEntry {
},
}
/// A target-independent outline projection of a nested lesson.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OutlineDocument {
/// Course title from the root `[info].title`.
pub title: String,
/// Authors from the root `[info].author`.
pub authors: Vec<String>,
/// Ordered root children, preserving nested sections.
pub children: Vec<OutlineNode>,
}
/// One section or element in the exported outline tree.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct OutlineNode {
/// Heading text shown to a teacher.
pub title: String,
/// Element or container kind.
pub kind: String,
/// Root-relative source path.
pub path: PathBuf,
/// Optional teacher-facing planning note.
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
/// Nested children; empty for an element leaf.
pub children: Vec<OutlineNode>,
}
impl OutlineNode {
fn from_part(part: &Part) -> Self {
let title = part
.path
.file_name()
.and_then(|name| name.to_str())
.filter(|name| !name.is_empty())
.map(str::to_owned)
.unwrap_or_else(|| part.path.to_string_lossy().into_owned());
Self {
title,
kind: part.kind.clone(),
path: part.path.clone(),
notes: part.notes.clone().filter(|notes| !notes.trim().is_empty()),
children: Vec::new(),
}
}
}
/// Convert the flat depth-first sequence into nested JSON/format nodes.
fn consume_outline_children(
lesson: &Lesson,
start: usize,
minimum_section_depth: u32,
) -> (Vec<OutlineNode>, usize) {
let mut children = Vec::new();
let mut index = start;
while index < lesson.outline.len() {
match &lesson.outline[index] {
OutlineEntry::Element { part_index, depth } => {
if *depth < minimum_section_depth {
break;
}
children.push(OutlineNode::from_part(&lesson.parts[*part_index]));
index += 1;
}
OutlineEntry::Section {
kind,
title,
notes,
depth,
path,
} => {
if *depth < minimum_section_depth {
break;
}
let section_depth = *depth;
let (nested, next) = consume_outline_children(lesson, index + 1, section_depth);
children.push(OutlineNode {
title: title.clone(),
kind: kind.clone(),
path: path.clone(),
notes: notes.clone().filter(|note| !note.trim().is_empty()),
children: nested,
});
index = next;
}
}
}
(children, index)
}
/// One declared export target's build config (ADR-0009/0011).
///
/// An export target is a **build** producing a typed [`Artifact`] via an
@@ -289,6 +393,9 @@ pub struct Part {
/// Element folder path, root-relative, kept verbatim (forward/back slashes
/// as the OS provides) for diagnostics/display.
pub path: PathBuf,
/// Optional teacher-facing planning note from the `children` entry.
/// This is outline metadata, not a content field in `element.toml`.
pub notes: Option<String>,
/// The element's self-description loaded from its `element.toml`.
pub descriptor: ElementDescriptor,
}
@@ -380,6 +487,8 @@ struct RawGroup {
struct RawChild {
kind: String,
path: String,
#[serde(default)]
notes: Option<String>,
}
/// Load the engineering file at `root` into a [`Lesson`].
@@ -559,6 +668,7 @@ fn load_children(
outline: &mut Vec<OutlineEntry>,
) {
for child in children {
let element_depth = next_section_depth.saturating_sub(1);
let local_path = PathBuf::from(&child.path);
let full_rel_path = join_rel(rel_prefix, &local_path);
@@ -571,7 +681,15 @@ fn load_children(
)
.with_hint("child paths must be relative folders inside the containing folder"),
);
push_broken_leaf(parts, outline, child.kind, full_rel_path, root);
push_broken_leaf(
parts,
outline,
child.kind,
full_rel_path,
root,
child.notes,
element_depth,
);
continue;
}
@@ -588,7 +706,15 @@ fn load_children(
full_rel_path.display()
)),
);
push_broken_leaf(parts, outline, child.kind, full_rel_path, root);
push_broken_leaf(
parts,
outline,
child.kind,
full_rel_path,
root,
child.notes,
element_depth,
);
continue;
}
@@ -603,14 +729,26 @@ fn load_children(
parts.push(Part {
kind: child.kind,
path: full_rel_path,
notes: child.notes,
descriptor,
});
outline.push(OutlineEntry::Element { part_index: idx });
outline.push(OutlineEntry::Element {
part_index: idx,
depth: element_depth,
});
}
// A container: recurse into its own manifest.toml.
(true, false) => {
let Some(raw_container) = read_container_manifest(&abs_dir, diags) else {
push_broken_leaf(parts, outline, child.kind, full_rel_path, root);
push_broken_leaf(
parts,
outline,
child.kind,
full_rel_path,
root,
child.notes,
element_depth,
);
continue;
};
@@ -643,6 +781,7 @@ fn load_children(
outline.push(OutlineEntry::Section {
kind: child.kind,
title,
notes: child.notes,
depth: next_section_depth,
path: full_rel_path.clone(),
});
@@ -671,7 +810,15 @@ fn load_children(
)
.with_hint("remove one of manifest.toml or element.toml from this folder"),
);
push_broken_leaf(parts, outline, child.kind, full_rel_path, root);
push_broken_leaf(
parts,
outline,
child.kind,
full_rel_path,
root,
child.notes,
element_depth,
);
}
// Incomplete: neither a container nor a leaf.
(false, false) => {
@@ -688,7 +835,15 @@ fn load_children(
(container) to this folder",
),
);
push_broken_leaf(parts, outline, child.kind, full_rel_path, root);
push_broken_leaf(
parts,
outline,
child.kind,
full_rel_path,
root,
child.notes,
element_depth,
);
}
}
}
@@ -737,19 +892,25 @@ fn push_broken_leaf(
kind: String,
path: PathBuf,
root: &Path,
notes: Option<String>,
depth: u32,
) {
let idx = parts.len();
let dir = root.join(&path);
parts.push(Part {
kind: kind.clone(),
path,
notes,
descriptor: ElementDescriptor {
kind,
dir,
scalars: toml::Table::new(),
},
});
outline.push(OutlineEntry::Element { part_index: idx });
outline.push(OutlineEntry::Element {
part_index: idx,
depth,
});
}
/// Join a container-relative child path onto that container's own