-
Notifications
You must be signed in to change notification settings - Fork 299
/
Copy pathlib.rs
263 lines (236 loc) · 9.84 KB
/
lib.rs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
use eyre::{ContextCompat, bail};
use serde::{Deserialize, Serialize};
use toml::value::Date;
/// The front matter of a markdown blog post.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FrontMatter {
/// Deprecated. The plan was probably to have more specialized templates
/// at some point. That didn't materialize, all posts are rendered with the
/// same template. Once we migrate to Zola, this can be achieved with the
/// "template" key.
#[serde(default, skip_serializing)]
pub layout: Option<String>,
/// Deprecated. Zola doesn't do any path templating based on things like
/// the date. So, in order to preserve our URL structure (YYYY/MM/DD/...)
/// we have to set the path explicitly. Duplicating the date would
/// be inconvenient for content authors who need to keep the date of
/// publication updated.
#[serde(default, skip_serializing)]
pub date: Option<Date>,
#[serde(default)]
pub path: String,
pub title: String,
/// Deprecated. Zola uses an "authors" key with an array instead. The front
/// matter tests can do the migration automatically.
#[serde(default, skip_serializing)]
pub author: Option<String>,
#[serde(default)]
pub authors: Vec<String>,
pub description: Option<String>,
/// Used for `releases/X.XX.X` redirects and ones from the old URL scheme to
/// preserve permalinks (e.g. slug.html => slug/).
#[serde(default)]
pub aliases: Vec<String>,
/// Moved to the `extra` table.
#[serde(default, skip_serializing)]
pub team: Option<String>,
/// Moved to the `extra` table.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub release: bool,
#[serde(default, skip_serializing_if = "Extra::is_empty")]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Extra {
pub team: Option<String>,
pub team_url: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub release: bool,
}
impl Extra {
fn is_empty(&self) -> bool {
self.team.is_none() && !self.release
}
}
/// Extracts the front matter from a markdown file.
///
/// The remaining normal markdown content is returned as the second element of
/// the tuple.
pub fn parse(markdown: &str) -> eyre::Result<(FrontMatter, &str)> {
if !markdown.starts_with("+++\n") {
bail!("missing start of TOML front matter (+++)");
}
let (front_matter, content) = markdown
.trim_start_matches("+++\n")
.split_once("\n+++\n")
.context("missing end of TOML front matter (+++)")?;
Ok((toml::from_str(front_matter)?, content))
}
/// Normalizes the front matter of a markdown file.
pub fn normalize(
front_matter: &FrontMatter,
slug: &str,
inside_rust: bool,
) -> eyre::Result<FrontMatter> {
let mut front_matter = front_matter.clone();
// migrate "author" to "authors" key
if let Some(author) = front_matter.author.take() {
front_matter.authors = vec![author];
}
// migrate "team" to "extra" section
if let Some(team) = front_matter.team.take() {
let (team, url) = team.split_once(" <").unwrap();
let url = url.strip_suffix('>').unwrap();
front_matter.extra.team = Some(team.into());
front_matter.extra.team_url = Some(url.into());
}
// migrate "release" to "extra" section
if front_matter.release {
front_matter.release = false;
front_matter.extra.release = true;
}
// migrate "date" to "path" key
if let Some(date) = front_matter.date.take() {
front_matter.path = format!(
"{inside_rust}{year}/{month:02}/{day:02}/{slug}",
inside_rust = if inside_rust { "inside-rust/" } else { "" },
year = date.year,
month = date.month,
day = date.day,
// remove @ suffix, used for disambiguation only in the source
slug = slug.split_once('@').map(|(s, _)| s).unwrap_or(slug),
);
}
// validate format of 'path'
{
let mut path = front_matter.path.as_str();
let mut rq_fmt = "YYYY/MM/DD/slug-of-your-choice";
if inside_rust {
rq_fmt = "inside-rust/YYYY/MM/DD/slug-of-your-choice";
if !path.starts_with("inside-rust/") {
bail!("the path of inside-rust posts must start with 'inside-rust/'");
}
path = path.trim_start_matches("inside-rust/");
}
let components = path.split('/').collect::<Vec<_>>();
if components.len() != 4
|| components[0].len() != 4
|| components[0].parse::<u32>().is_err()
|| components[1].len() != 2
|| components[1].parse::<u8>().is_err()
|| components[2].len() != 2
|| components[2].parse::<u8>().is_err()
{
bail!("invalid 'path' key in front matter, required format: {rq_fmt}");
}
}
if front_matter.extra.team.is_some() ^ front_matter.extra.team_url.is_some() {
bail!("extra.team and extra.team_url must always come in a pair");
}
if front_matter.extra.release && !front_matter.aliases.iter().any(|a| a.contains("releases")) {
// Make sure release posts have a matching `releases/X.XX.X` alias.
let version = guess_version_from_path(&front_matter.path).unwrap_or("?.??.?".into());
front_matter.aliases.push(format!("releases/{version}"));
}
let serialized = toml::to_string_pretty(&front_matter)?;
let deserialized = toml::from_str(&serialized)?;
Ok(deserialized)
}
fn guess_version_from_path(path: &str) -> Option<String> {
let mut iter = path.split(['-', '.']).filter_map(|s| s.parse::<u32>().ok());
let major = iter.next()?;
let minor = iter.next()?;
let patch = iter.next().unwrap_or(0); // some posts omit the patch version
Some(format!("{major}.{minor}.{patch}"))
}
#[cfg(test)]
mod tests {
use std::{env, fs, path::PathBuf};
use super::*;
#[test]
fn front_matter_is_normalized() {
for post in all_posts() {
let slug = post.file_stem().unwrap().to_str().unwrap();
let inside_rust = post
.as_os_str()
.to_str()
.unwrap()
.contains("content/inside-rust/");
let content = fs::read_to_string(&post).unwrap();
let (front_matter, rest) = parse(&content).unwrap();
let normalized = normalize(&front_matter, slug, inside_rust).unwrap_or_else(|err| {
panic!("failed to normalize {:?}: {err}", post.file_name().unwrap());
});
if front_matter != normalized {
let normalized = format!(
"\
+++\n\
{}\
+++\n\
{rest}\
",
toml::to_string_pretty(&normalized).unwrap(),
);
if env::var("FIX_FRONT_MATTER").is_ok() {
fs::write(post, normalized).unwrap();
continue;
}
let post = post.file_name().unwrap().to_str().unwrap();
let actual = content
.rsplit_once("+++")
.map(|(f, _)| format!("{f}+++"))
.unwrap_or(content);
let expected = normalized
.rsplit_once("+++")
.map(|(f, _)| format!("{f}+++"))
.unwrap_or(normalized);
// better error message than assert_eq!()
panic!(
"
The post {post} has abnormal front matter.
actual:
{actual}
expected:
{expected}
┌──────────────────────────────────────────────────────────────────────────┐
│ │
│ You can fix this automatically by running: │
│ │
│ FIX_FRONT_MATTER=1 cargo test -p front_matter │
│ │
└──────────────────────────────────────────────────────────────────────────┘
",
)
};
}
}
/// This test is run by the merge queue check to make sure a blog post isn't
/// merged before its date of publication is set. The date of a blog post
/// is usually a placeholder (path = "9999/12/31/...") until shortly before
/// it's published.
#[test]
#[ignore]
fn date_is_set() {
for post in all_posts() {
let content = fs::read_to_string(&post).unwrap();
let (front_matter, _) = parse(&content).unwrap();
if front_matter.path.starts_with("9999/12/31") {
panic!(
"\n\
The post {slug} has a placeholder publication date.\n\
If you're about to publish it, please set it to today.\n\
",
slug = post.file_stem().unwrap().to_str().unwrap(),
);
}
}
}
fn all_posts() -> impl Iterator<Item = PathBuf> {
let repo_root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..");
fs::read_dir(repo_root.join("content"))
.unwrap()
.chain(fs::read_dir(repo_root.join("content/inside-rust")).unwrap())
.map(|p| p.unwrap().path())
.filter(|p| p.is_file() && p.file_name() != Some("_index.md".as_ref()))
}
}