diff --git a/release-content/migration-guides/assets-insert-result.md b/release-content/migration-guides/assets-insert-result.md index c18279b941793..9b2a72b85f74c 100644 --- a/release-content/migration-guides/assets-insert-result.md +++ b/release-content/migration-guides/assets-insert-result.md @@ -1,5 +1,5 @@ --- -title: `Assets::insert` and `Assets::get_or_insert_with` now return `Result`. +title: "`Assets::insert` and `Assets::get_or_insert_with` now return `Result`" pull_requests: [20439] --- diff --git a/release-content/migration-guides/clone_behavior_no_longer_eq.md b/release-content/migration-guides/clone_behavior_no_longer_eq.md index 836126751c69e..837b74fb3265f 100644 --- a/release-content/migration-guides/clone_behavior_no_longer_eq.md +++ b/release-content/migration-guides/clone_behavior_no_longer_eq.md @@ -1,5 +1,5 @@ --- -title: `CloneBehavior` is no longer `PartialEq` or `Eq` +title: "`CloneBehavior` is no longer `PartialEq` or `Eq`" pull_requests: [18393] --- diff --git a/release-content/migration-guides/gated_reader.md b/release-content/migration-guides/gated_reader.md index 873598677882f..4c6e0ff2be4fa 100644 --- a/release-content/migration-guides/gated_reader.md +++ b/release-content/migration-guides/gated_reader.md @@ -1,5 +1,5 @@ --- -title: `GatedReader` and `GatedOpener` are now private. +title: "`GatedReader` and `GatedOpener` are now private." pull_requests: [18473] --- diff --git a/release-content/release-notes/constructor_functions_for_val_variants.md b/release-content/release-notes/constructor_functions_for_val_variants.md index 01d410d677949..c08c908f50833 100644 --- a/release-content/release-notes/constructor_functions_for_val_variants.md +++ b/release-content/release-notes/constructor_functions_for_val_variants.md @@ -1,5 +1,5 @@ --- -title: `Val` helper functions +title: "`Val` helper functions" authors: ["@Ickshonpe"] pull_requests: [20518] --- diff --git a/tools/export-content/Cargo.toml b/tools/export-content/Cargo.toml new file mode 100644 index 0000000000000..c4d1281bb00f5 --- /dev/null +++ b/tools/export-content/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "export-content" +version = "0.1.0" +edition = "2024" + +[dependencies] +miette = { version = "7.6.0", features = ["fancy"] } +ratatui = "0.29.0" +regex = "1.11.1" +serde = { version = "1.0", features = ["derive"] } +serde_yml = "0.0.12" +thiserror = "2.0.12" + +[lints] +workspace = true diff --git a/tools/export-content/src/app.rs b/tools/export-content/src/app.rs new file mode 100644 index 0000000000000..2fb5e65bc38c7 --- /dev/null +++ b/tools/export-content/src/app.rs @@ -0,0 +1,352 @@ +use std::{env, fs, io::Write, path}; + +use miette::{diagnostic, Context, Diagnostic, IntoDiagnostic, NamedSource, Result}; +use ratatui::{ + crossterm::event::{self, Event, KeyCode, KeyModifiers}, + prelude::*, + widgets::*, +}; +use regex::Regex; +use serde::Deserialize; +use thiserror::Error; + +enum Mode { + ReleaseNotes, + MigrationGuides, +} + +pub struct App { + content_dir: path::PathBuf, + release_notes: Vec, + release_notes_state: ListState, + migration_guides: Vec, + migration_guide_state: ListState, + text_entry: Option, + mode: Mode, + exit: bool, +} + +impl App { + pub fn new() -> Result { + let exe_dir = env::current_exe() + .into_diagnostic() + .wrap_err("failed to determine path to binary")?; + + let content_dir = exe_dir + .ancestors() + .nth(3) + .ok_or(diagnostic!("failed to determine path to repo root"))? + .join("release-content"); + + let release_notes_dir = content_dir.join("release-notes"); + let release_notes = load_content(release_notes_dir, "release note")?; + + let migration_guides_dir = content_dir.join("migration-guides"); + let migration_guides = load_content(migration_guides_dir, "migration guide")?; + + Ok(App { + content_dir, + release_notes, + release_notes_state: ListState::default().with_selected(Some(0)), + migration_guides, + migration_guide_state: ListState::default().with_selected(Some(0)), + text_entry: None, + mode: Mode::ReleaseNotes, + exit: false, + }) + } + + pub fn run(mut self, terminal: &mut Terminal) -> Result<()> { + while !self.exit { + terminal + .draw(|frame| self.render(frame)) + .into_diagnostic()?; + + let (mode_state, mode_entries) = match self.mode { + Mode::ReleaseNotes => (&mut self.release_notes_state, &mut self.release_notes), + Mode::MigrationGuides => { + (&mut self.migration_guide_state, &mut self.migration_guides) + } + }; + + if let Event::Key(key) = event::read().into_diagnostic()? { + // If text entry is enabled, capture all input events + if let Some(text) = &mut self.text_entry { + match key.code { + KeyCode::Esc => self.text_entry = None, + KeyCode::Backspace => { + text.pop(); + } + KeyCode::Enter => { + if !text.is_empty() + && let Some(index) = mode_state.selected() + { + mode_entries.insert( + index, + Entry::Section { + title: text.clone(), + }, + ); + } + self.text_entry = None; + } + KeyCode::Char(c) => text.push(c), + _ => {} + } + + continue; + } + + match key.code { + KeyCode::Esc => self.exit = true, + KeyCode::Tab => match self.mode { + Mode::ReleaseNotes => self.mode = Mode::MigrationGuides, + Mode::MigrationGuides => self.mode = Mode::ReleaseNotes, + }, + KeyCode::Down => { + if key.modifiers.contains(KeyModifiers::SHIFT) + && let Some(index) = mode_state.selected() + && index < mode_entries.len() - 1 + { + mode_entries.swap(index, index + 1); + } + mode_state.select_next(); + } + KeyCode::Up => { + if key.modifiers.contains(KeyModifiers::SHIFT) + && let Some(index) = mode_state.selected() + && index > 0 + { + mode_entries.swap(index, index - 1); + } + mode_state.select_previous(); + } + KeyCode::Char('+') => { + self.text_entry = Some(String::new()); + } + KeyCode::Char('d') => { + if let Some(index) = mode_state.selected() + && let Entry::Section { .. } = mode_entries[index] + { + mode_entries.remove(index); + } + } + _ => {} + } + } + } + + self.write_output() + } + + pub fn render(&mut self, frame: &mut Frame) { + use Constraint::*; + + let page_area = frame.area().inner(Margin::new(1, 1)); + let [header_area, instructions_area, _, block_area, _, typing_area] = Layout::vertical([ + Length(2), // header + Length(2), // instructions + Length(1), // gap + Fill(1), // blocks + Length(1), // gap + Length(2), // text input + ]) + .areas(page_area); + + frame.render_widget(self.header(), header_area); + frame.render_widget(self.instructions(), instructions_area); + + let (title, mode_state, mode_entries) = match self.mode { + Mode::ReleaseNotes => ( + "Release Notes", + &mut self.release_notes_state, + &self.release_notes, + ), + Mode::MigrationGuides => ( + "Migration Guides", + &mut self.migration_guide_state, + &self.migration_guides, + ), + }; + let items = mode_entries.iter().map(|e| e.as_list_entry()); + let list = List::new(items) + .block(Block::new().title(title).padding(Padding::uniform(1))) + .highlight_symbol(">>") + .highlight_style(Color::Green); + + frame.render_stateful_widget(list, block_area, mode_state); + + if let Some(text) = &self.text_entry { + let text_entry = Paragraph::new(format!("Section Title: {}", text)).fg(Color::Blue); + frame.render_widget(text_entry, typing_area); + } + } + + fn header(&self) -> impl Widget { + let text = "Content Exporter Tool"; + text.bold().underlined().into_centered_line() + } + + fn instructions(&self) -> impl Widget { + let text = + "▲ ▼ : navigate shift + ▲ ▼ : re-order + : insert section d : delete section tab : change focus esc : save and quit"; + Paragraph::new(text) + .fg(Color::Magenta) + .centered() + .wrap(Wrap { trim: false }) + } + + fn write_output(self) -> Result<()> { + // Write release notes + let mut file = + fs::File::create(self.content_dir.join("merged_release_notes.md")).into_diagnostic()?; + + for entry in self.release_notes { + match entry { + Entry::Section { title } => write!(file, "# {title}\n\n").into_diagnostic()?, + Entry::File { metadata, content } => { + let title = metadata.title; + + let authors = metadata + .authors + .iter() + .flatten() + .map(|a| format!("\"{a}\"")) + .collect::>() + .join(", "); + + let pull_requests = metadata + .pull_requests + .iter() + .map(|n| format!("{}", n)) + .collect::>() + .join(", "); + + write!( + file, + "## {title}\n{{% heading_metadata(authors=[{authors}] prs=[{pull_requests}]) %}}\n{content}\n\n" + ) + .into_diagnostic()?; + } + } + } + + // Write migration guide + let mut file = fs::File::create(self.content_dir.join("merged_migration_guides.md")) + .into_diagnostic()?; + + for entry in self.migration_guides { + match entry { + Entry::Section { title } => write!(file, "## {title}\n\n").into_diagnostic()?, + Entry::File { metadata, content } => { + let title = metadata.title; + + let pull_requests = metadata + .pull_requests + .iter() + .map(|n| format!("{}", n)) + .collect::>() + .join(", "); + + write!( + file, + "### {title}\n{{% heading_metadata(prs=[{pull_requests}]) %}}\n{content}\n\n" + ) + .into_diagnostic()?; + } + } + } + + Ok(()) + } +} + +#[derive(Deserialize, Debug)] +struct Metadata { + title: String, + authors: Option>, + pull_requests: Vec, +} + +#[derive(Debug)] +enum Entry { + Section { title: String }, + File { metadata: Metadata, content: String }, +} + +impl Entry { + fn as_list_entry(&'_ self) -> ListItem<'_> { + match self { + Entry::Section { title } => ListItem::new(title.as_str()).underlined().fg(Color::Blue), + Entry::File { metadata, .. } => ListItem::new(metadata.title.as_str()), + } + } +} + +/// Loads release content from files in the specified directory +fn load_content(dir: path::PathBuf, kind: &'static str) -> Result> { + let re = Regex::new(r"(?s)^---\s*\n(?.*?)\s*\n---\s*\n(?.*)").unwrap(); + + let mut entries = vec![]; + + for dir_entry in fs::read_dir(dir) + .into_diagnostic() + .wrap_err("unable to read directory")? + { + let dir_entry = dir_entry + .into_diagnostic() + .wrap_err(format!("unable to access {} file", kind))?; + + // Skip directories + if !dir_entry.path().is_file() { + continue; + } + // Skip files with invalid names + let Ok(file_name) = dir_entry.file_name().into_string() else { + continue; + }; + // Skip hidden files (like .gitkeep or .DS_Store) + if file_name.starts_with(".") { + continue; + } + + let file_content = fs::read_to_string(dir_entry.path()) + .into_diagnostic() + .wrap_err(format!("unable to read {} file", kind))?; + + let caps = re.captures(&file_content).ok_or(diagnostic!( + "failed to find frontmatter in {} file {}", + kind, + file_name + ))?; + + let frontmatter = caps.name("frontmatter").unwrap().as_str(); + let metadata = serde_yml::from_str::(frontmatter).map_err(|e| ParseError { + src: NamedSource::new( + format!("{}", dir_entry.path().display()), + frontmatter.to_owned(), + ), + kind, + file_name, + err_span: e.location().map(|l| l.index()), + error: e, + })?; + let content = caps.name("content").unwrap().as_str().to_owned(); + + entries.push(Entry::File { metadata, content }); + } + + Ok(entries) +} + +#[derive(Diagnostic, Debug, Error)] +#[error("failed to parse metadata in {kind} file {file_name}")] +pub struct ParseError { + #[source_code] + src: NamedSource, + kind: &'static str, + file_name: String, + #[label("{error}")] + err_span: Option, + error: serde_yml::Error, +} diff --git a/tools/export-content/src/main.rs b/tools/export-content/src/main.rs new file mode 100644 index 0000000000000..f27c154e58f17 --- /dev/null +++ b/tools/export-content/src/main.rs @@ -0,0 +1,64 @@ +//! A tool for exporting release content. +//! +//! This terminal-based tool generates a release content file +//! from the content of the `release-content` directory. +//! +//! To run this tool, use the following command from the `bevy` repository root: +//! +//! ```sh +//! cargo run -p export-content +//! ``` + +use std::{ + io, + panic::{set_hook, take_hook}, +}; + +use app::App; +use miette::{IntoDiagnostic, Result}; +use ratatui::{ + crossterm::{ + event::{DisableMouseCapture, EnableMouseCapture}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + }, + prelude::*, +}; + +mod app; + +fn main() -> Result<()> { + init_panic_hook(); + let mut terminal = init_terminal().unwrap(); + let res = run_app(&mut terminal); + restore_terminal().unwrap(); + res +} + +fn run_app(terminal: &mut Terminal) -> Result<()> { + let app = App::new()?; + app.run(terminal) +} + +fn init_panic_hook() { + let original_hook = take_hook(); + set_hook(Box::new(move |panic_info| { + // intentionally ignore errors here since we're already in a panic + let _ = restore_terminal(); + original_hook(panic_info); + })); +} + +fn init_terminal() -> Result> { + enable_raw_mode().into_diagnostic()?; + execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture).into_diagnostic()?; + let backend = CrosstermBackend::new(io::stdout()); + let terminal = Terminal::new(backend).into_diagnostic()?; + Ok(terminal) +} + +fn restore_terminal() -> Result<()> { + disable_raw_mode().into_diagnostic()?; + execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture).into_diagnostic()?; + Ok(()) +}