Skip to content

Commit d2d6289

Browse files
NthTensoralice-i-cecileDaAlbrecht
authored
Release content export tool (#20500)
# Objective Adds a simple tool to order and merge release notes and migration guides. To use, go to `tools/export-content` and use `cargo run`. The output formatting may need to be tweaked, and we will probably want to add/change the zola shortcodes a bit. --------- Co-authored-by: Alice Cecile <[email protected]> Co-authored-by: DAA <[email protected]>
1 parent 803a843 commit d2d6289

File tree

7 files changed

+435
-4
lines changed

7 files changed

+435
-4
lines changed

release-content/migration-guides/assets-insert-result.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
title: `Assets::insert` and `Assets::get_or_insert_with` now return `Result`.
2+
title: "`Assets::insert` and `Assets::get_or_insert_with` now return `Result`"
33
pull_requests: [20439]
44
---
55

release-content/migration-guides/clone_behavior_no_longer_eq.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
title: `CloneBehavior` is no longer `PartialEq` or `Eq`
2+
title: "`CloneBehavior` is no longer `PartialEq` or `Eq`"
33
pull_requests: [18393]
44
---
55

release-content/migration-guides/gated_reader.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
title: `GatedReader` and `GatedOpener` are now private.
2+
title: "`GatedReader` and `GatedOpener` are now private."
33
pull_requests: [18473]
44
---
55

release-content/release-notes/constructor_functions_for_val_variants.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
title: `Val` helper functions
2+
title: "`Val` helper functions"
33
authors: ["@Ickshonpe"]
44
pull_requests: [20518]
55
---

tools/export-content/Cargo.toml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
[package]
2+
name = "export-content"
3+
version = "0.1.0"
4+
edition = "2024"
5+
6+
[dependencies]
7+
miette = { version = "7.6.0", features = ["fancy"] }
8+
ratatui = "0.29.0"
9+
regex = "1.11.1"
10+
serde = { version = "1.0", features = ["derive"] }
11+
serde_yml = "0.0.12"
12+
thiserror = "2.0.12"
13+
14+
[lints]
15+
workspace = true

tools/export-content/src/app.rs

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
use std::{env, fs, io::Write, path};
2+
3+
use miette::{diagnostic, Context, Diagnostic, IntoDiagnostic, NamedSource, Result};
4+
use ratatui::{
5+
crossterm::event::{self, Event, KeyCode, KeyModifiers},
6+
prelude::*,
7+
widgets::*,
8+
};
9+
use regex::Regex;
10+
use serde::Deserialize;
11+
use thiserror::Error;
12+
13+
enum Mode {
14+
ReleaseNotes,
15+
MigrationGuides,
16+
}
17+
18+
pub struct App {
19+
content_dir: path::PathBuf,
20+
release_notes: Vec<Entry>,
21+
release_notes_state: ListState,
22+
migration_guides: Vec<Entry>,
23+
migration_guide_state: ListState,
24+
text_entry: Option<String>,
25+
mode: Mode,
26+
exit: bool,
27+
}
28+
29+
impl App {
30+
pub fn new() -> Result<App> {
31+
let exe_dir = env::current_exe()
32+
.into_diagnostic()
33+
.wrap_err("failed to determine path to binary")?;
34+
35+
let content_dir = exe_dir
36+
.ancestors()
37+
.nth(3)
38+
.ok_or(diagnostic!("failed to determine path to repo root"))?
39+
.join("release-content");
40+
41+
let release_notes_dir = content_dir.join("release-notes");
42+
let release_notes = load_content(release_notes_dir, "release note")?;
43+
44+
let migration_guides_dir = content_dir.join("migration-guides");
45+
let migration_guides = load_content(migration_guides_dir, "migration guide")?;
46+
47+
Ok(App {
48+
content_dir,
49+
release_notes,
50+
release_notes_state: ListState::default().with_selected(Some(0)),
51+
migration_guides,
52+
migration_guide_state: ListState::default().with_selected(Some(0)),
53+
text_entry: None,
54+
mode: Mode::ReleaseNotes,
55+
exit: false,
56+
})
57+
}
58+
59+
pub fn run<B: Backend>(mut self, terminal: &mut Terminal<B>) -> Result<()> {
60+
while !self.exit {
61+
terminal
62+
.draw(|frame| self.render(frame))
63+
.into_diagnostic()?;
64+
65+
let (mode_state, mode_entries) = match self.mode {
66+
Mode::ReleaseNotes => (&mut self.release_notes_state, &mut self.release_notes),
67+
Mode::MigrationGuides => {
68+
(&mut self.migration_guide_state, &mut self.migration_guides)
69+
}
70+
};
71+
72+
if let Event::Key(key) = event::read().into_diagnostic()? {
73+
// If text entry is enabled, capture all input events
74+
if let Some(text) = &mut self.text_entry {
75+
match key.code {
76+
KeyCode::Esc => self.text_entry = None,
77+
KeyCode::Backspace => {
78+
text.pop();
79+
}
80+
KeyCode::Enter => {
81+
if !text.is_empty()
82+
&& let Some(index) = mode_state.selected()
83+
{
84+
mode_entries.insert(
85+
index,
86+
Entry::Section {
87+
title: text.clone(),
88+
},
89+
);
90+
}
91+
self.text_entry = None;
92+
}
93+
KeyCode::Char(c) => text.push(c),
94+
_ => {}
95+
}
96+
97+
continue;
98+
}
99+
100+
match key.code {
101+
KeyCode::Esc => self.exit = true,
102+
KeyCode::Tab => match self.mode {
103+
Mode::ReleaseNotes => self.mode = Mode::MigrationGuides,
104+
Mode::MigrationGuides => self.mode = Mode::ReleaseNotes,
105+
},
106+
KeyCode::Down => {
107+
if key.modifiers.contains(KeyModifiers::SHIFT)
108+
&& let Some(index) = mode_state.selected()
109+
&& index < mode_entries.len() - 1
110+
{
111+
mode_entries.swap(index, index + 1);
112+
}
113+
mode_state.select_next();
114+
}
115+
KeyCode::Up => {
116+
if key.modifiers.contains(KeyModifiers::SHIFT)
117+
&& let Some(index) = mode_state.selected()
118+
&& index > 0
119+
{
120+
mode_entries.swap(index, index - 1);
121+
}
122+
mode_state.select_previous();
123+
}
124+
KeyCode::Char('+') => {
125+
self.text_entry = Some(String::new());
126+
}
127+
KeyCode::Char('d') => {
128+
if let Some(index) = mode_state.selected()
129+
&& let Entry::Section { .. } = mode_entries[index]
130+
{
131+
mode_entries.remove(index);
132+
}
133+
}
134+
_ => {}
135+
}
136+
}
137+
}
138+
139+
self.write_output()
140+
}
141+
142+
pub fn render(&mut self, frame: &mut Frame) {
143+
use Constraint::*;
144+
145+
let page_area = frame.area().inner(Margin::new(1, 1));
146+
let [header_area, instructions_area, _, block_area, _, typing_area] = Layout::vertical([
147+
Length(2), // header
148+
Length(2), // instructions
149+
Length(1), // gap
150+
Fill(1), // blocks
151+
Length(1), // gap
152+
Length(2), // text input
153+
])
154+
.areas(page_area);
155+
156+
frame.render_widget(self.header(), header_area);
157+
frame.render_widget(self.instructions(), instructions_area);
158+
159+
let (title, mode_state, mode_entries) = match self.mode {
160+
Mode::ReleaseNotes => (
161+
"Release Notes",
162+
&mut self.release_notes_state,
163+
&self.release_notes,
164+
),
165+
Mode::MigrationGuides => (
166+
"Migration Guides",
167+
&mut self.migration_guide_state,
168+
&self.migration_guides,
169+
),
170+
};
171+
let items = mode_entries.iter().map(|e| e.as_list_entry());
172+
let list = List::new(items)
173+
.block(Block::new().title(title).padding(Padding::uniform(1)))
174+
.highlight_symbol(">>")
175+
.highlight_style(Color::Green);
176+
177+
frame.render_stateful_widget(list, block_area, mode_state);
178+
179+
if let Some(text) = &self.text_entry {
180+
let text_entry = Paragraph::new(format!("Section Title: {}", text)).fg(Color::Blue);
181+
frame.render_widget(text_entry, typing_area);
182+
}
183+
}
184+
185+
fn header(&self) -> impl Widget {
186+
let text = "Content Exporter Tool";
187+
text.bold().underlined().into_centered_line()
188+
}
189+
190+
fn instructions(&self) -> impl Widget {
191+
let text =
192+
"▲ ▼ : navigate shift + ▲ ▼ : re-order + : insert section d : delete section tab : change focus esc : save and quit";
193+
Paragraph::new(text)
194+
.fg(Color::Magenta)
195+
.centered()
196+
.wrap(Wrap { trim: false })
197+
}
198+
199+
fn write_output(self) -> Result<()> {
200+
// Write release notes
201+
let mut file =
202+
fs::File::create(self.content_dir.join("merged_release_notes.md")).into_diagnostic()?;
203+
204+
for entry in self.release_notes {
205+
match entry {
206+
Entry::Section { title } => write!(file, "# {title}\n\n").into_diagnostic()?,
207+
Entry::File { metadata, content } => {
208+
let title = metadata.title;
209+
210+
let authors = metadata
211+
.authors
212+
.iter()
213+
.flatten()
214+
.map(|a| format!("\"{a}\""))
215+
.collect::<Vec<_>>()
216+
.join(", ");
217+
218+
let pull_requests = metadata
219+
.pull_requests
220+
.iter()
221+
.map(|n| format!("{}", n))
222+
.collect::<Vec<_>>()
223+
.join(", ");
224+
225+
write!(
226+
file,
227+
"## {title}\n{{% heading_metadata(authors=[{authors}] prs=[{pull_requests}]) %}}\n{content}\n\n"
228+
)
229+
.into_diagnostic()?;
230+
}
231+
}
232+
}
233+
234+
// Write migration guide
235+
let mut file = fs::File::create(self.content_dir.join("merged_migration_guides.md"))
236+
.into_diagnostic()?;
237+
238+
for entry in self.migration_guides {
239+
match entry {
240+
Entry::Section { title } => write!(file, "## {title}\n\n").into_diagnostic()?,
241+
Entry::File { metadata, content } => {
242+
let title = metadata.title;
243+
244+
let pull_requests = metadata
245+
.pull_requests
246+
.iter()
247+
.map(|n| format!("{}", n))
248+
.collect::<Vec<_>>()
249+
.join(", ");
250+
251+
write!(
252+
file,
253+
"### {title}\n{{% heading_metadata(prs=[{pull_requests}]) %}}\n{content}\n\n"
254+
)
255+
.into_diagnostic()?;
256+
}
257+
}
258+
}
259+
260+
Ok(())
261+
}
262+
}
263+
264+
#[derive(Deserialize, Debug)]
265+
struct Metadata {
266+
title: String,
267+
authors: Option<Vec<String>>,
268+
pull_requests: Vec<u32>,
269+
}
270+
271+
#[derive(Debug)]
272+
enum Entry {
273+
Section { title: String },
274+
File { metadata: Metadata, content: String },
275+
}
276+
277+
impl Entry {
278+
fn as_list_entry(&'_ self) -> ListItem<'_> {
279+
match self {
280+
Entry::Section { title } => ListItem::new(title.as_str()).underlined().fg(Color::Blue),
281+
Entry::File { metadata, .. } => ListItem::new(metadata.title.as_str()),
282+
}
283+
}
284+
}
285+
286+
/// Loads release content from files in the specified directory
287+
fn load_content(dir: path::PathBuf, kind: &'static str) -> Result<Vec<Entry>> {
288+
let re = Regex::new(r"(?s)^---\s*\n(?<frontmatter>.*?)\s*\n---\s*\n(?<content>.*)").unwrap();
289+
290+
let mut entries = vec![];
291+
292+
for dir_entry in fs::read_dir(dir)
293+
.into_diagnostic()
294+
.wrap_err("unable to read directory")?
295+
{
296+
let dir_entry = dir_entry
297+
.into_diagnostic()
298+
.wrap_err(format!("unable to access {} file", kind))?;
299+
300+
// Skip directories
301+
if !dir_entry.path().is_file() {
302+
continue;
303+
}
304+
// Skip files with invalid names
305+
let Ok(file_name) = dir_entry.file_name().into_string() else {
306+
continue;
307+
};
308+
// Skip hidden files (like .gitkeep or .DS_Store)
309+
if file_name.starts_with(".") {
310+
continue;
311+
}
312+
313+
let file_content = fs::read_to_string(dir_entry.path())
314+
.into_diagnostic()
315+
.wrap_err(format!("unable to read {} file", kind))?;
316+
317+
let caps = re.captures(&file_content).ok_or(diagnostic!(
318+
"failed to find frontmatter in {} file {}",
319+
kind,
320+
file_name
321+
))?;
322+
323+
let frontmatter = caps.name("frontmatter").unwrap().as_str();
324+
let metadata = serde_yml::from_str::<Metadata>(frontmatter).map_err(|e| ParseError {
325+
src: NamedSource::new(
326+
format!("{}", dir_entry.path().display()),
327+
frontmatter.to_owned(),
328+
),
329+
kind,
330+
file_name,
331+
err_span: e.location().map(|l| l.index()),
332+
error: e,
333+
})?;
334+
let content = caps.name("content").unwrap().as_str().to_owned();
335+
336+
entries.push(Entry::File { metadata, content });
337+
}
338+
339+
Ok(entries)
340+
}
341+
342+
#[derive(Diagnostic, Debug, Error)]
343+
#[error("failed to parse metadata in {kind} file {file_name}")]
344+
pub struct ParseError {
345+
#[source_code]
346+
src: NamedSource<String>,
347+
kind: &'static str,
348+
file_name: String,
349+
#[label("{error}")]
350+
err_span: Option<usize>,
351+
error: serde_yml::Error,
352+
}

0 commit comments

Comments
 (0)