@@ -1,14 +1,15 @@
-use anyhow::Result;
-use clap::{Arg, ArgMatches, Command};
+use anyhow::{Context, Result};
use mdbook::BookItem;
use mdbook::book::{Book, Chapter};
use mdbook::preprocess::CmdPreprocessor;
use regex::Regex;
use settings::KeymapFile;
-use std::collections::HashSet;
+use std::borrow::Cow;
+use std::collections::{HashMap, HashSet};
use std::io::{self, Read};
use std::process;
use std::sync::LazyLock;
+use util::paths::PathExt;
static KEYMAP_MACOS: LazyLock<KeymapFile> = LazyLock::new(|| {
load_keymap("keymaps/default-macos.json").expect("Failed to load MacOS keymap")
@@ -20,60 +21,68 @@ static KEYMAP_LINUX: LazyLock<KeymapFile> = LazyLock::new(|| {
static ALL_ACTIONS: LazyLock<Vec<ActionDef>> = LazyLock::new(dump_all_gpui_actions);
-pub fn make_app() -> Command {
- Command::new("zed-docs-preprocessor")
- .about("Preprocesses Zed Docs content to provide rich action & keybinding support and more")
- .subcommand(
- Command::new("supports")
- .arg(Arg::new("renderer").required(true))
- .about("Check whether a renderer is supported by this preprocessor"),
- )
-}
+const FRONT_MATTER_COMMENT: &'static str = "<!-- ZED_META {} -->";
fn main() -> Result<()> {
- let matches = make_app().get_matches();
+ zlog::init();
+ zlog::init_output_stderr();
// call a zed:: function so everything in `zed` crate is linked and
// all actions in the actual app are registered
zed::stdout_is_a_pty();
-
- if let Some(sub_args) = matches.subcommand_matches("supports") {
- handle_supports(sub_args);
- } else {
- handle_preprocessing()?;
+ let args = std::env::args().skip(1).collect::<Vec<_>>();
+
+ match args.get(0).map(String::as_str) {
+ Some("supports") => {
+ let renderer = args.get(1).expect("Required argument");
+ let supported = renderer != "not-supported";
+ if supported {
+ process::exit(0);
+ } else {
+ process::exit(1);
+ }
+ }
+ Some("postprocess") => handle_postprocessing()?,
+ _ => handle_preprocessing()?,
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
-enum Error {
+enum PreprocessorError {
ActionNotFound { action_name: String },
DeprecatedActionUsed { used: String, should_be: String },
+ InvalidFrontmatterLine(String),
}
-impl Error {
+impl PreprocessorError {
fn new_for_not_found_action(action_name: String) -> Self {
for action in &*ALL_ACTIONS {
for alias in action.deprecated_aliases {
if alias == &action_name {
- return Error::DeprecatedActionUsed {
+ return PreprocessorError::DeprecatedActionUsed {
used: action_name.clone(),
should_be: action.name.to_string(),
};
}
}
}
- Error::ActionNotFound {
+ PreprocessorError::ActionNotFound {
action_name: action_name.to_string(),
}
}
}
-impl std::fmt::Display for Error {
+impl std::fmt::Display for PreprocessorError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
- Error::ActionNotFound { action_name } => write!(f, "Action not found: {}", action_name),
- Error::DeprecatedActionUsed { used, should_be } => write!(
+ PreprocessorError::InvalidFrontmatterLine(line) => {
+ write!(f, "Invalid frontmatter line: {}", line)
+ }
+ PreprocessorError::ActionNotFound { action_name } => {
+ write!(f, "Action not found: {}", action_name)
+ }
+ PreprocessorError::DeprecatedActionUsed { used, should_be } => write!(
f,
"Deprecated action used: {} should be {}",
used, should_be
@@ -89,8 +98,9 @@ fn handle_preprocessing() -> Result<()> {
let (_ctx, mut book) = CmdPreprocessor::parse_input(input.as_bytes())?;
- let mut errors = HashSet::<Error>::new();
+ let mut errors = HashSet::<PreprocessorError>::new();
+ handle_frontmatter(&mut book, &mut errors);
template_and_validate_keybindings(&mut book, &mut errors);
template_and_validate_actions(&mut book, &mut errors);
@@ -108,19 +118,41 @@ fn handle_preprocessing() -> Result<()> {
Ok(())
}
-fn handle_supports(sub_args: &ArgMatches) -> ! {
- let renderer = sub_args
- .get_one::<String>("renderer")
- .expect("Required argument");
- let supported = renderer != "not-supported";
- if supported {
- process::exit(0);
- } else {
- process::exit(1);
- }
+fn handle_frontmatter(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
+ let frontmatter_regex = Regex::new(r"(?s)^\s*---(.*?)---").unwrap();
+ for_each_chapter_mut(book, |chapter| {
+ let new_content = frontmatter_regex.replace(&chapter.content, |caps: ®ex::Captures| {
+ let frontmatter = caps[1].trim();
+ let frontmatter = frontmatter.trim_matches(&[' ', '-', '\n']);
+ let mut metadata = HashMap::<String, String>::default();
+ for line in frontmatter.lines() {
+ let Some((name, value)) = line.split_once(':') else {
+ errors.insert(PreprocessorError::InvalidFrontmatterLine(format!(
+ "{}: {}",
+ chapter_breadcrumbs(&chapter),
+ line
+ )));
+ continue;
+ };
+ let name = name.trim();
+ let value = value.trim();
+ metadata.insert(name.to_string(), value.to_string());
+ }
+ FRONT_MATTER_COMMENT.replace(
+ "{}",
+ &serde_json::to_string(&metadata).expect("Failed to serialize metadata"),
+ )
+ });
+ match new_content {
+ Cow::Owned(content) => {
+ chapter.content = content;
+ }
+ Cow::Borrowed(_) => {}
+ }
+ });
}
-fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<Error>) {
+fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
let regex = Regex::new(r"\{#kb (.*?)\}").unwrap();
for_each_chapter_mut(book, |chapter| {
@@ -128,7 +160,9 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<Error
.replace_all(&chapter.content, |caps: ®ex::Captures| {
let action = caps[1].trim();
if find_action_by_name(action).is_none() {
- errors.insert(Error::new_for_not_found_action(action.to_string()));
+ errors.insert(PreprocessorError::new_for_not_found_action(
+ action.to_string(),
+ ));
return String::new();
}
let macos_binding = find_binding("macos", action).unwrap_or_default();
@@ -144,7 +178,7 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet<Error
});
}
-fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<Error>) {
+fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<PreprocessorError>) {
let regex = Regex::new(r"\{#action (.*?)\}").unwrap();
for_each_chapter_mut(book, |chapter| {
@@ -152,7 +186,9 @@ fn template_and_validate_actions(book: &mut Book, errors: &mut HashSet<Error>) {
.replace_all(&chapter.content, |caps: ®ex::Captures| {
let name = caps[1].trim();
let Some(action) = find_action_by_name(name) else {
- errors.insert(Error::new_for_not_found_action(name.to_string()));
+ errors.insert(PreprocessorError::new_for_not_found_action(
+ name.to_string(),
+ ));
return String::new();
};
format!("<code class=\"hljs\">{}</code>", &action.human_name)
@@ -217,6 +253,13 @@ fn name_for_action(action_as_str: String) -> String {
.unwrap_or(action_as_str)
}
+fn chapter_breadcrumbs(chapter: &Chapter) -> String {
+ let mut breadcrumbs = Vec::with_capacity(chapter.parent_names.len() + 1);
+ breadcrumbs.extend(chapter.parent_names.iter().map(String::as_str));
+ breadcrumbs.push(chapter.name.as_str());
+ format!("[{:?}] {}", chapter.source_path, breadcrumbs.join(" > "))
+}
+
fn load_keymap(asset_path: &str) -> Result<KeymapFile> {
let content = util::asset_str::<settings::SettingsAssets>(asset_path);
KeymapFile::parse(content.as_ref())
@@ -254,3 +297,126 @@ fn dump_all_gpui_actions() -> Vec<ActionDef> {
return actions;
}
+
+fn handle_postprocessing() -> Result<()> {
+ let logger = zlog::scoped!("render");
+ let mut ctx = mdbook::renderer::RenderContext::from_json(io::stdin())?;
+ let output = ctx
+ .config
+ .get_mut("output")
+ .expect("has output")
+ .as_table_mut()
+ .expect("output is table");
+ let zed_html = output.remove("zed-html").expect("zed-html output defined");
+ let default_description = zed_html
+ .get("default-description")
+ .expect("Default description not found")
+ .as_str()
+ .expect("Default description not a string")
+ .to_string();
+ let default_title = zed_html
+ .get("default-title")
+ .expect("Default title not found")
+ .as_str()
+ .expect("Default title not a string")
+ .to_string();
+
+ output.insert("html".to_string(), zed_html);
+ mdbook::Renderer::render(&mdbook::renderer::HtmlHandlebars::new(), &ctx)?;
+ let ignore_list = ["toc.html"];
+
+ let root_dir = ctx.destination.clone();
+ let mut files = Vec::with_capacity(128);
+ let mut queue = Vec::with_capacity(64);
+ queue.push(root_dir.clone());
+ while let Some(dir) = queue.pop() {
+ for entry in std::fs::read_dir(&dir).context(dir.to_sanitized_string())? {
+ let Ok(entry) = entry else {
+ continue;
+ };
+ let file_type = entry.file_type().context("Failed to determine file type")?;
+ if file_type.is_dir() {
+ queue.push(entry.path());
+ }
+ if file_type.is_file()
+ && matches!(
+ entry.path().extension().and_then(std::ffi::OsStr::to_str),
+ Some("html")
+ )
+ {
+ if ignore_list.contains(&&*entry.file_name().to_string_lossy()) {
+ zlog::info!(logger => "Ignoring {}", entry.path().to_string_lossy());
+ } else {
+ files.push(entry.path());
+ }
+ }
+ }
+ }
+
+ zlog::info!(logger => "Processing {} `.html` files", files.len());
+ let meta_regex = Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "(.*)")).unwrap();
+ for file in files {
+ let contents = std::fs::read_to_string(&file)?;
+ let mut meta_description = None;
+ let mut meta_title = None;
+ let contents = meta_regex.replace(&contents, |caps: ®ex::Captures| {
+ let metadata: HashMap<String, String> = serde_json::from_str(&caps[1]).with_context(|| format!("JSON Metadata: {:?}", &caps[1])).expect("Failed to deserialize metadata");
+ for (kind, content) in metadata {
+ match kind.as_str() {
+ "description" => {
+ meta_description = Some(content);
+ }
+ "title" => {
+ meta_title = Some(content);
+ }
+ _ => {
+ zlog::warn!(logger => "Unrecognized frontmatter key: {} in {:?}", kind, pretty_path(&file, &root_dir));
+ }
+ }
+ }
+ String::new()
+ });
+ let meta_description = meta_description.as_ref().unwrap_or_else(|| {
+ zlog::warn!(logger => "No meta description found for {:?}", pretty_path(&file, &root_dir));
+ &default_description
+ });
+ let page_title = extract_title_from_page(&contents, pretty_path(&file, &root_dir));
+ let meta_title = meta_title.as_ref().unwrap_or_else(|| {
+ zlog::debug!(logger => "No meta title found for {:?}", pretty_path(&file, &root_dir));
+ &default_title
+ });
+ let meta_title = format!("{} | {}", page_title, meta_title);
+ zlog::trace!(logger => "Updating {:?}", pretty_path(&file, &root_dir));
+ let contents = contents.replace("#description#", meta_description);
+ let contents = TITLE_REGEX
+ .replace(&contents, |_: ®ex::Captures| {
+ format!("<title>{}</title>", meta_title)
+ })
+ .to_string();
+ // let contents = contents.replace("#title#", &meta_title);
+ std::fs::write(file, contents)?;
+ }
+ return Ok(());
+
+ fn pretty_path<'a>(
+ path: &'a std::path::PathBuf,
+ root: &'a std::path::PathBuf,
+ ) -> &'a std::path::Path {
+ &path.strip_prefix(&root).unwrap_or(&path)
+ }
+ const TITLE_REGEX: std::cell::LazyCell<Regex> =
+ std::cell::LazyCell::new(|| Regex::new(r"<title>\s*(.*?)\s*</title>").unwrap());
+ fn extract_title_from_page(contents: &str, pretty_path: &std::path::Path) -> String {
+ let title_tag_contents = &TITLE_REGEX
+ .captures(&contents)
+ .with_context(|| format!("Failed to find title in {:?}", pretty_path))
+ .expect("Page has <title> element")[1];
+ let title = title_tag_contents
+ .trim()
+ .strip_suffix("- Zed")
+ .unwrap_or(title_tag_contents)
+ .trim()
+ .to_string();
+ title
+ }
+}
@@ -21,6 +21,8 @@ const ANSI_MAGENTA: &str = "\x1b[35m";
/// Whether stdout output is enabled.
static mut ENABLED_SINKS_STDOUT: bool = false;
+/// Whether stderr output is enabled.
+static mut ENABLED_SINKS_STDERR: bool = false;
/// Is Some(file) if file output is enabled.
static ENABLED_SINKS_FILE: Mutex<Option<std::fs::File>> = Mutex::new(None);
@@ -45,6 +47,12 @@ pub fn init_output_stdout() {
}
}
+pub fn init_output_stderr() {
+ unsafe {
+ ENABLED_SINKS_STDERR = true;
+ }
+}
+
pub fn init_output_file(
path: &'static PathBuf,
path_rotate: Option<&'static PathBuf>,
@@ -115,6 +123,21 @@ pub fn submit(record: Record) {
},
record.message
);
+ } else if unsafe { ENABLED_SINKS_STDERR } {
+ let mut stdout = std::io::stderr().lock();
+ _ = writeln!(
+ &mut stdout,
+ "{} {ANSI_BOLD}{}{}{ANSI_RESET} {} {}",
+ chrono::Local::now().format("%Y-%m-%dT%H:%M:%S%:z"),
+ LEVEL_ANSI_COLORS[record.level as usize],
+ LEVEL_OUTPUT_STRINGS[record.level as usize],
+ SourceFmt {
+ scope: record.scope,
+ module_path: record.module_path,
+ ansi: true,
+ },
+ record.message
+ );
}
let mut file = ENABLED_SINKS_FILE.lock().unwrap_or_else(|handle| {
ENABLED_SINKS_FILE.clear_poison();