diff --git a/.cloudflare/docs-proxy/src/worker.js b/.cloudflare/docs-proxy/src/worker.js index 08b0265fafbbb0..99fca0c7116614 100644 --- a/.cloudflare/docs-proxy/src/worker.js +++ b/.cloudflare/docs-proxy/src/worker.js @@ -1,6 +1,11 @@ export default { async fetch(request, _env, _ctx) { const url = new URL(request.url); + const acceptHeader = request.headers.get("Accept") || ""; + const wantsMarkdown = acceptHeader + .split(",") + .map((mediaType) => mediaType.split(";")[0].trim().toLowerCase()) + .includes("text/markdown"); if (url.pathname === "/docs/nightly") { url.hostname = "docs-nightly.pages.dev"; @@ -18,6 +23,14 @@ export default { url.hostname = "docs-anw.pages.dev"; } + if (url.pathname === "/docs.md") { + url.pathname = "/docs/getting-started.md"; + } + + if (wantsMarkdown) { + url.pathname = markdownPathFor(url.pathname); + } + let res = await fetch(url, request); if (res.status === 404) { @@ -27,3 +40,31 @@ export default { return res; }, }; + +function markdownPathFor(pathname) { + if (pathname === "/docs" || pathname === "/docs/") { + return "/docs/getting-started.md"; + } + + if (pathname.endsWith("/index.md")) { + return pathname.replace(/\/index\.md$/, "/getting-started.md"); + } + + if (pathname.endsWith(".md")) { + return pathname; + } + + if (pathname.endsWith(".html")) { + return pathname.replace(/\.html$/, ".md"); + } + + if (pathname.split("/").pop().includes(".")) { + return pathname; + } + + if (pathname.endsWith("/")) { + return `${pathname}getting-started.md`; + } + + return `${pathname}.md`; +} diff --git a/crates/docs_preprocessor/Cargo.toml b/crates/docs_preprocessor/Cargo.toml index 87e5110ff52b6e..a422d2811dbb6d 100644 --- a/crates/docs_preprocessor/Cargo.toml +++ b/crates/docs_preprocessor/Cargo.toml @@ -27,4 +27,4 @@ workspace = true [[bin]] name = "docs_preprocessor" -path = "src/main.rs" \ No newline at end of file +path = "src/main.rs" diff --git a/crates/docs_preprocessor/src/ai_discovery.rs b/crates/docs_preprocessor/src/ai_discovery.rs new file mode 100644 index 00000000000000..dbf0c37911406e --- /dev/null +++ b/crates/docs_preprocessor/src/ai_discovery.rs @@ -0,0 +1,596 @@ +use anyhow::{Context, Result}; +use mdbook::BookItem; +use mdbook::book::Book; +use regex::Regex; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use crate::FRONT_MATTER_COMMENT; + +#[derive(Debug)] +pub(crate) struct DocsPage { + section: String, + title: String, + description: Option, + pub(crate) last_updated: Option, + pub(crate) source_path: PathBuf, + content: String, +} + +pub(crate) fn write_ai_discovery_artifacts( + pages: &[DocsPage], + destination: &Path, + site_url: &str, +) -> Result<()> { + copy_markdown_sources(destination, site_url, pages)?; + write_llms_txt(destination, site_url, pages)?; + write_sitemap_xml(destination, site_url, pages)?; + Ok(()) +} + +pub(crate) fn docs_pages(book: &Book, docs_root: &Path) -> Result> { + let mut pages = Vec::new(); + let mut section = "Docs".to_string(); + let git_last_updated = docs_page_last_updated_from_git(docs_root); + let last_updated_fallbacks = docs_page_last_updated_fallbacks(docs_root)?; + let mut missing_last_updated = Vec::new(); + for item in book.iter() { + let BookItem::Chapter(chapter) = item else { + if let BookItem::PartTitle(part_title) = item { + section.clone_from(part_title); + } + continue; + }; + let Some(source_path) = chapter.source_path.as_ref() else { + continue; + }; + if source_path == Path::new("SUMMARY.md") { + continue; + } + let source_path_key = source_path.to_string_lossy().replace('\\', "/"); + let last_updated = git_last_updated + .get(&source_path_key) + .or_else(|| last_updated_fallbacks.get(&source_path_key)) + .cloned(); + if last_updated.is_none() { + missing_last_updated.push(source_path_key); + } + pages.push(DocsPage { + section: section.clone(), + title: chapter.name.clone(), + description: docs_page_description(&chapter.content), + last_updated, + source_path: source_path.clone(), + content: chapter.content.clone(), + }); + } + if !missing_last_updated.is_empty() { + anyhow::bail!( + "missing last-updated metadata for docs pages: {}", + missing_last_updated.join(", ") + ); + } + Ok(pages) +} + +fn copy_markdown_sources(destination: &Path, site_url: &str, pages: &[DocsPage]) -> Result<()> { + for page in pages { + let destination = destination.join(&page.source_path); + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!("failed to create markdown destination {}", parent.display()) + })?; + } + std::fs::write( + &destination, + add_llms_markdown_directive( + &markdown_source_contents(&page.content), + site_url, + page.last_updated.as_deref(), + ), + ) + .with_context(|| { + format!( + "failed to write markdown page {} to {}", + page.source_path.display(), + destination.display() + ) + })?; + } + let getting_started = destination.join("getting-started.md"); + if getting_started.exists() { + std::fs::copy(&getting_started, destination.join("index.md")) + .context("failed to write index.md markdown alias")?; + } + Ok(()) +} + +fn markdown_source_contents(contents: &str) -> String { + front_matter_comment_regex() + .replace(contents, "") + .trim_start() + .to_string() +} + +fn docs_page_last_updated_from_git(docs_root: &Path) -> HashMap { + let output = git_log_last_updated(docs_root).ok(); + let Some(output) = output else { + return HashMap::default(); + }; + if !output.status.success() { + return HashMap::default(); + } + let Ok(output) = String::from_utf8(output.stdout) else { + return HashMap::default(); + }; + + let mut last_updated_by_path = HashMap::new(); + let mut current_date = None; + for line in output.lines() { + if let Some(date) = line.strip_prefix("--") { + current_date = Some(date.to_string()); + continue; + } + if line.is_empty() { + continue; + } + let Some(date) = current_date.as_ref() else { + continue; + }; + let Some(source_path) = line.strip_prefix("src/") else { + continue; + }; + last_updated_by_path + .entry(source_path.to_string()) + .or_insert_with(|| date.clone()); + } + last_updated_by_path +} + +#[allow(clippy::disallowed_methods)] +fn git_log_last_updated(docs_root: &Path) -> std::io::Result { + std::process::Command::new("git") + .current_dir(docs_root) + .args(["log", "--format=--%cs", "--name-only", "--", "src"]) + .output() +} + +fn docs_page_last_updated_fallbacks(docs_root: &Path) -> Result> { + let path = docs_root.join("last-updated.json"); + let Ok(contents) = std::fs::read_to_string(&path) else { + return Ok(HashMap::default()); + }; + serde_json::from_str(&contents).with_context(|| format!("failed to parse {}", path.display())) +} + +fn docs_page_description(contents: &str) -> Option { + docs_page_metadata(contents).and_then(|metadata| { + metadata + .get("description") + .map(|description| { + description + .trim() + .trim_matches('"') + .split_whitespace() + .collect::>() + .join(" ") + }) + .filter(|description| !description.is_empty()) + }) +} + +fn docs_page_metadata(contents: &str) -> Option> { + let captures = front_matter_comment_regex().captures(contents)?; + serde_json::from_str(&captures[1]).ok() +} + +fn front_matter_comment_regex() -> &'static Regex { + static FRONT_MATTER_COMMENT_REGEX: OnceLock = OnceLock::new(); + FRONT_MATTER_COMMENT_REGEX + .get_or_init(|| Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "([^\\n]*)")).unwrap()) +} + +fn write_llms_txt(destination: &Path, site_url: &str, pages: &[DocsPage]) -> Result<()> { + let mut contents = String::new(); + contents.push_str("# Zed Docs\n\n"); + contents.push_str( + "> Official Zed documentation index with links to Markdown versions of each docs page.\n\n", + ); + contents.push_str( + "Use these links for concise Markdown copies of Zed documentation pages. Each linked page mirrors the corresponding `/docs/*.html` page without site navigation or styling.\n\n", + ); + let mut current_section = None; + for page in pages { + if current_section != Some(page.section.as_str()) { + if current_section.is_some() { + contents.push('\n'); + } + contents.push_str("## "); + contents.push_str(&markdown_text(&page.section)); + contents.push_str("\n\n"); + current_section = Some(page.section.as_str()); + } + contents.push_str("- ["); + contents.push_str(&markdown_text(&page.title)); + contents.push_str("]("); + contents.push_str(&absolute_docs_url(site_url, &page.source_path)); + contents.push(')'); + if let Some(description) = &page.description { + contents.push_str(": "); + contents.push_str(&markdown_text(description)); + } + if let Some(last_updated) = &page.last_updated { + contents.push_str(" (Last updated: "); + contents.push_str(last_updated); + contents.push(')'); + } + contents.push('\n'); + } + std::fs::write(destination.join("llms.txt"), contents).context("failed to write llms.txt")?; + Ok(()) +} + +fn markdown_text(text: &str) -> String { + text.replace('\\', "\\\\") + .replace('[', "\\[") + .replace(']', "\\]") +} + +fn write_sitemap_xml(destination: &Path, site_url: &str, pages: &[DocsPage]) -> Result<()> { + let mut contents = String::new(); + contents.push_str("\n"); + contents.push_str("\n"); + for page in pages { + contents.push_str(" "); + contents.push_str(&xml_escape(&absolute_docs_url( + site_url, + &page.source_path.with_extension("html"), + ))); + contents.push_str(""); + if let Some(last_updated) = &page.last_updated { + contents.push_str(""); + contents.push_str(&xml_escape(last_updated)); + contents.push_str(""); + } + contents.push_str("\n"); + } + contents.push_str("\n"); + std::fs::write(destination.join("sitemap.xml"), contents) + .context("failed to write sitemap.xml")?; + Ok(()) +} + +pub(crate) fn write_pages_redirects( + destination: &Path, + redirects: &[(String, String)], + site_url: &str, +) -> Result<()> { + let Some(deploy_root) = destination.parent() else { + return Ok(()); + }; + let mut contents = String::new(); + for (source, destination) in redirects { + write_redirect_line( + &mut contents, + &docs_path(site_url, source), + &redirect_destination(site_url, destination), + ); + if let Some(extensionless_source) = strip_html_suffix(source) { + write_redirect_line( + &mut contents, + &docs_path(site_url, &extensionless_source), + &redirect_destination( + site_url, + &strip_html_suffix(destination).unwrap_or_else(|| destination.to_string()), + ), + ); + } + if let Some(markdown_source) = html_path_to_markdown(source) { + if let Some(markdown_destination) = html_path_to_markdown(destination) { + write_redirect_line( + &mut contents, + &docs_path(site_url, &markdown_source), + &redirect_destination(site_url, &markdown_destination), + ); + } + } + } + std::fs::write(deploy_root.join("_redirects"), contents) + .context("failed to write Cloudflare Pages _redirects")?; + Ok(()) +} + +pub(crate) fn write_markdown_redirect_aliases( + destination: &Path, + redirects: &[(String, String)], + site_url: &str, +) -> Result<()> { + for (source, redirect_destination_path) in redirects { + let Some(source_markdown) = html_path_to_markdown(source) else { + continue; + }; + let Some(destination_markdown) = html_path_to_markdown(redirect_destination_path) else { + continue; + }; + let source_markdown = destination.join(source_markdown.trim_start_matches('/')); + let destination_markdown = + destination.join(destination_markdown.trim_start_matches("/docs/")); + if !destination_markdown.exists() { + continue; + } + if let Some(parent) = source_markdown.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "failed to create markdown alias directory {}", + parent.display() + ) + })?; + } + let contents = format!( + "# Moved\n\n> For the complete documentation index and Markdown links, see [llms.txt]({}).\n\nThis page moved to [the current docs page]({}).\n", + docs_url(site_url, Path::new("llms.txt")), + html_path_to_markdown(redirect_destination_path) + .map(|path| redirect_destination(site_url, &path)) + .unwrap_or_else(|| redirect_destination(site_url, redirect_destination_path)) + ); + std::fs::write(&source_markdown, contents).with_context(|| { + format!( + "failed to write markdown redirect alias from {} to {}", + redirect_destination_path, + source_markdown.display() + ) + })?; + } + Ok(()) +} + +fn write_redirect_line(contents: &mut String, source: &str, destination: &str) { + contents.push_str(source); + contents.push(' '); + contents.push_str(destination); + contents.push_str(" 301\n"); +} + +fn docs_path(site_url: &str, path: &str) -> String { + docs_url(site_url, Path::new(path.trim_start_matches('/'))) +} + +fn redirect_destination(site_url: &str, destination: &str) -> String { + if let Some(path) = destination.strip_prefix("/docs/") { + docs_url(site_url, Path::new(path)) + } else if destination == "/docs" { + docs_url(site_url, Path::new("")) + } else { + destination.to_string() + } +} + +fn strip_html_suffix(path: &str) -> Option { + let (path, fragment) = split_fragment(path); + let path = path.strip_suffix(".html")?; + Some(format!("{path}{fragment}")) +} + +fn html_path_to_markdown(path: &str) -> Option { + let (path, fragment) = split_fragment(path); + if !path.starts_with("/docs/") && path != "/docs" && !path.ends_with(".html") { + return None; + } + let markdown_path = path.strip_suffix(".html").unwrap_or(path); + Some(format!("{markdown_path}.md{fragment}")) +} + +fn split_fragment(path: &str) -> (&str, &str) { + match path.find('#') { + Some(index) => (&path[..index], &path[index..]), + None => (path, ""), + } +} + +pub(crate) fn add_markdown_alternate_link( + contents: &str, + html_file: &Path, + root_dir: &Path, + site_url: &str, +) -> String { + let Ok(relative_path) = html_file.strip_prefix(root_dir) else { + return contents.to_string(); + }; + let markdown_path = relative_path.with_extension("md"); + if !root_dir.join(&markdown_path).exists() { + return contents.to_string(); + } + let markdown_url = docs_url(site_url, &markdown_path); + let link = format!( + " \n", + markdown_url + ); + contents.replacen("", &(link + " "), 1) +} + +fn add_llms_markdown_directive( + contents: &str, + site_url: &str, + last_updated: Option<&str>, +) -> String { + let directive = format!( + "> For the complete documentation index and Markdown links, see [llms.txt]({}).{}\n\n", + docs_url(site_url, Path::new("llms.txt")), + last_updated + .map(|last_updated| format!(" Last updated: {last_updated}.")) + .unwrap_or_default() + ); + if let Some(rest) = contents.strip_prefix("---\n") { + if let Some(frontmatter_end) = rest.find("\n---\n") { + let split_at = "---\n".len() + frontmatter_end + "\n---\n".len(); + let mut output = String::with_capacity(contents.len() + directive.len()); + output.push_str(&contents[..split_at]); + output.push('\n'); + output.push_str(&directive); + output.push_str(&contents[split_at..]); + return output; + } + } + + let mut output = String::with_capacity(contents.len() + directive.len()); + output.push_str(&directive); + output.push_str(contents); + output +} + +pub(crate) fn add_last_updated_meta(contents: &str, last_updated: Option<&str>) -> String { + let Some(last_updated) = last_updated else { + return contents.to_string(); + }; + let meta = format!( + " \n \n", + xml_escape(last_updated), + xml_escape(last_updated), + ); + contents.replacen("", &(meta + " "), 1) +} + +fn docs_url(site_url: &str, path: &Path) -> String { + let mut url = site_url.to_string(); + if !url.ends_with('/') { + url.push('/'); + } + url.push_str(&path.to_string_lossy().replace('\\', "/")); + url +} + +fn absolute_docs_url(site_url: &str, path: &Path) -> String { + let url = docs_url(site_url, path); + if url.starts_with("http://") || url.starts_with("https://") { + url + } else { + format!("https://zed.dev{}", url) + } +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add_llms_markdown_directive_includes_last_updated_after_frontmatter() { + let contents = "---\ntitle: Example\n---\n# Example\n"; + let output = add_llms_markdown_directive(contents, "/docs/", Some("2026-06-19")); + + assert!(output.starts_with("---\ntitle: Example\n---\n\n")); + assert!(output.contains( + "> For the complete documentation index and Markdown links, see [llms.txt](/docs/llms.txt). Last updated: 2026-06-19." + )); + } + + #[test] + fn test_add_last_updated_meta_inserts_machine_readable_dates() { + let output = add_last_updated_meta( + "", + Some("2026-06-19"), + ); + + assert!(output.contains("")); + assert!( + output.contains("") + ); + } + + #[test] + fn test_redirect_destination_uses_channel_site_url_for_docs_paths() { + assert_eq!( + redirect_destination("/docs/preview/", "/docs/ai/overview.html"), + "/docs/preview/ai/overview.html" + ); + assert_eq!( + redirect_destination("/docs/preview/", "/community-links"), + "/community-links" + ); + } + + #[test] + fn test_docs_path_uses_channel_site_url() { + assert_eq!( + docs_path("/docs/preview/", "/assistant.md"), + "/docs/preview/assistant.md" + ); + } + + #[test] + fn test_write_ai_discovery_artifacts_generates_agent_facing_metadata() -> Result<()> { + let destination = std::env::temp_dir().join(format!( + "docs_preprocessor_ai_discovery_test_{}_{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_nanos() + )); + std::fs::create_dir_all(&destination)?; + + let pages = vec![ + DocsPage { + section: "Docs".to_string(), + title: "Getting Started".to_string(), + description: Some("Start using Zed.".to_string()), + last_updated: Some("2026-06-18".to_string()), + source_path: PathBuf::from("getting-started.md"), + content: format!( + "{}\n# Getting Started\n", + FRONT_MATTER_COMMENT.replace("{}", r#"{"description":"Start using Zed."}"#) + ), + }, + DocsPage { + section: "AI".to_string(), + title: "MCP".to_string(), + description: Some("Connect model context servers.".to_string()), + last_updated: Some("2026-06-19".to_string()), + source_path: PathBuf::from("ai/mcp.md"), + content: format!( + "{}\n# MCP\n", + FRONT_MATTER_COMMENT + .replace("{}", r#"{"description":"Connect model context servers."}"#) + ), + }, + ]; + + write_ai_discovery_artifacts(&pages, &destination, "/docs/")?; + + let llms_txt = std::fs::read_to_string(destination.join("llms.txt"))?; + assert!(llms_txt.contains("## Docs")); + assert!(llms_txt.contains( + "- [Getting Started](https://zed.dev/docs/getting-started.md): Start using Zed. (Last updated: 2026-06-18)" + )); + assert!(llms_txt.contains("## AI")); + assert!(llms_txt.contains( + "- [MCP](https://zed.dev/docs/ai/mcp.md): Connect model context servers. (Last updated: 2026-06-19)" + )); + + let sitemap_xml = std::fs::read_to_string(destination.join("sitemap.xml"))?; + assert!(sitemap_xml.contains("https://zed.dev/docs/getting-started.html")); + assert!(sitemap_xml.contains("2026-06-18")); + assert!(sitemap_xml.contains("https://zed.dev/docs/ai/mcp.html")); + assert!(sitemap_xml.contains("2026-06-19")); + + let mcp_markdown = std::fs::read_to_string(destination.join("ai/mcp.md"))?; + assert!(mcp_markdown.starts_with( + "> For the complete documentation index and Markdown links, see [llms.txt](/docs/llms.txt). Last updated: 2026-06-19.\n\n# MCP" + )); + assert!(!mcp_markdown.contains("ZED_META")); + + let index_markdown = std::fs::read_to_string(destination.join("index.md"))?; + assert!(index_markdown.contains("# Getting Started")); + + std::fs::remove_dir_all(&destination)?; + Ok(()) + } +} diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index df3b556d6a47ca..eebaba0fc4b814 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -1,4 +1,10 @@ use anyhow::{Context, Result}; +mod ai_discovery; + +use ai_discovery::{ + add_last_updated_meta, add_markdown_alternate_link, docs_pages, write_ai_discovery_artifacts, + write_markdown_redirect_aliases, write_pages_redirects, +}; use mdbook::BookItem; use mdbook::book::{Book, Chapter}; use mdbook::preprocess::CmdPreprocessor; @@ -188,7 +194,7 @@ fn handle_preprocessing() -> Result<()> { template_big_table_of_actions(&mut book); template_and_validate_keybindings(&mut book, &mut errors); template_and_validate_actions(&mut book, &mut errors); - template_and_validate_json_snippets(&mut book, &mut errors); + template_and_validate_json_snippets(&mut book, &mut errors)?; if !errors.is_empty() { const ANSI_RED: &str = "\x1b[31m"; @@ -252,7 +258,7 @@ fn format_binding(binding: String) -> String { } fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet) { - let regex = Regex::new(r"\{#kb(?::(\w+))?\s+(.*?)\}").unwrap(); + let regex = Regex::new(r"(?s)\{#kb(?::(\w+))?\s+(.*?)\}").unwrap(); for_each_chapter_mut(book, |chapter| { chapter.content = regex @@ -300,7 +306,7 @@ fn template_and_validate_keybindings(book: &mut Book, errors: &mut HashSet) { - let regex = Regex::new(r"\{#action (.*?)\}").unwrap(); + let regex = Regex::new(r"(?s)\{#action\s+(.*?)\}").unwrap(); for_each_chapter_mut(book, |chapter| { chapter.content = regex @@ -379,7 +385,10 @@ fn find_binding_with_overlay( .or_else(|| find_binding(os, action)) } -fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet) { +fn template_and_validate_json_snippets( + book: &mut Book, + errors: &mut HashSet, +) -> Result<()> { let params = SettingsJsonSchemaParams { language_names: &[], font_names: &[], @@ -393,7 +402,7 @@ fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet
 Result<()> {
         .as_table_mut()
         .expect("output is table");
     let zed_html = output.remove("zed-html").expect("zed-html output defined");
+    let redirects = zed_html
+        .get("redirect")
+        .and_then(|redirects| redirects.as_table())
+        .map(|redirects| {
+            redirects
+                .iter()
+                .filter_map(|(source, destination)| {
+                    destination
+                        .as_str()
+                        .map(|destination| (source.clone(), destination.to_string()))
+                })
+                .collect::>()
+        });
     let default_description = zed_html
         .get("default-description")
         .expect("Default description not found")
@@ -733,8 +757,24 @@ fn handle_postprocessing() -> Result<()> {
     }
 
     zlog::info!(logger => "Processing {} `.html` files", files.len());
+    let site_url = ctx
+        .config
+        .get("book.site-url")
+        .and_then(|site_url| site_url.as_str())
+        .map(str::to_string)
+        .unwrap_or_else(|| "/docs/".to_string());
+    let pages = docs_pages(&ctx.book, &ctx.root)?;
+    write_ai_discovery_artifacts(&pages, &root_dir, &site_url)?;
+    let last_updated_by_html_path = pages
+        .iter()
+        .filter_map(|page| {
+            page.last_updated
+                .as_ref()
+                .map(|last_updated| (page.source_path.with_extension("html"), last_updated))
+        })
+        .collect::>();
     let meta_regex = Regex::new(&FRONT_MATTER_COMMENT.replace("{}", "(.*)")).unwrap();
-    for file in files {
+    for file in &files {
         let contents = std::fs::read_to_string(&file)?;
         let mut meta_description = None;
         let mut meta_title = None;
@@ -770,14 +810,27 @@ fn handle_postprocessing() -> Result<()> {
         let contents = contents.replace("#amplitude_key#", &litude_key);
         let contents = contents.replace("#consent_io_instance#", &consent_io_instance);
         let contents = contents.replace("#noindex#", noindex);
+        let contents = add_markdown_alternate_link(&contents, file, &root_dir, &site_url);
+        let contents = match file.strip_prefix(&root_dir) {
+            Ok(relative_path) => add_last_updated_meta(
+                &contents,
+                last_updated_by_html_path
+                    .get(relative_path)
+                    .map(|last_updated| last_updated.as_str()),
+            ),
+            Err(_) => contents,
+        };
         let contents = title_regex()
             .replace(&contents, |_: ®ex::Captures| {
                 format!("{}", meta_title)
             })
             .to_string();
-        // let contents = contents.replace("#title#", &meta_title);
         std::fs::write(file, contents)?;
     }
+    if let Some(redirects) = redirects {
+        write_markdown_redirect_aliases(&root_dir, &redirects, &site_url)?;
+        write_pages_redirects(&root_dir, &redirects, &site_url)?;
+    }
     return Ok(());
 
     fn pretty_path<'a>(
@@ -906,66 +959,4 @@ fn keymap_schema_for_actions(
 }
 
 #[cfg(test)]
-mod tests {
-    use super::*;
-    use serde_json::json;
-
-    #[test]
-    fn test_find_binding_prefers_exact_match_over_parameterized() {
-        let keymap: KeymapFile = serde_json::from_value(json!([
-            {
-                "bindings": {
-                    "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
-                    "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
-                }
-            }
-        ]))
-        .unwrap();
-
-        let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
-        assert_eq!(binding.as_deref(), Some("ctrl-tab"));
-    }
-
-    #[test]
-    fn test_find_binding_falls_back_to_parameterized_match() {
-        let keymap: KeymapFile = serde_json::from_value(json!([
-            {
-                "bindings": {
-                    "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
-                }
-            }
-        ]))
-        .unwrap();
-
-        let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
-        assert_eq!(binding.as_deref(), Some("ctrl-shift-tab"));
-    }
-
-    #[test]
-    fn test_find_binding_prefers_exact_match_regardless_of_order() {
-        let keymap: KeymapFile = serde_json::from_value(json!([
-            {
-                "bindings": {
-                    "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
-                    "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher"
-                }
-            }
-        ]))
-        .unwrap();
-
-        let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
-        assert_eq!(binding.as_deref(), Some("ctrl-tab"));
-    }
-
-    #[test]
-    fn test_find_binding_later_section_overrides_earlier() {
-        let keymap: KeymapFile = serde_json::from_value(json!([
-            { "bindings": { "ctrl-a": "some::Action" } },
-            { "bindings": { "ctrl-b": "some::Action" } }
-        ]))
-        .unwrap();
-
-        let binding = find_binding_in_keymap(&keymap, "some::Action");
-        assert_eq!(binding.as_deref(), Some("ctrl-b"));
-    }
-}
+mod tests;
diff --git a/crates/docs_preprocessor/src/tests.rs b/crates/docs_preprocessor/src/tests.rs
new file mode 100644
index 00000000000000..43438207d8c600
--- /dev/null
+++ b/crates/docs_preprocessor/src/tests.rs
@@ -0,0 +1,61 @@
+use super::*;
+use serde_json::json;
+
+#[test]
+fn test_find_binding_prefers_exact_match_over_parameterized() {
+    let keymap: KeymapFile = serde_json::from_value(json!([
+        {
+            "bindings": {
+                "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher",
+                "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
+            }
+        }
+    ]))
+    .unwrap();
+
+    let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
+    assert_eq!(binding.as_deref(), Some("ctrl-tab"));
+}
+
+#[test]
+fn test_find_binding_falls_back_to_parameterized_match() {
+    let keymap: KeymapFile = serde_json::from_value(json!([
+        {
+            "bindings": {
+                "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }]
+            }
+        }
+    ]))
+    .unwrap();
+
+    let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
+    assert_eq!(binding.as_deref(), Some("ctrl-shift-tab"));
+}
+
+#[test]
+fn test_find_binding_prefers_exact_match_regardless_of_order() {
+    let keymap: KeymapFile = serde_json::from_value(json!([
+        {
+            "bindings": {
+                "ctrl-shift-tab": ["agents_sidebar::ToggleThreadSwitcher", { "select_last": true }],
+                "ctrl-tab": "agents_sidebar::ToggleThreadSwitcher"
+            }
+        }
+    ]))
+    .unwrap();
+
+    let binding = find_binding_in_keymap(&keymap, "agents_sidebar::ToggleThreadSwitcher");
+    assert_eq!(binding.as_deref(), Some("ctrl-tab"));
+}
+
+#[test]
+fn test_find_binding_later_section_overrides_earlier() {
+    let keymap: KeymapFile = serde_json::from_value(json!([
+        { "bindings": { "ctrl-a": "some::Action" } },
+        { "bindings": { "ctrl-b": "some::Action" } }
+    ]))
+    .unwrap();
+
+    let binding = find_binding_in_keymap(&keymap, "some::Action");
+    assert_eq!(binding.as_deref(), Some("ctrl-b"));
+}
diff --git a/docs/book.toml b/docs/book.toml
index 2eed5f7907a668..e1a48d1f7f05f1 100644
--- a/docs/book.toml
+++ b/docs/book.toml
@@ -45,10 +45,10 @@ enable = false
 "/assistant.html" = "/docs/assistant/assistant.html"
 "/assistant/assistant-panel.html" = "/docs/ai/agent-panel.html"
 "/assistant/assistant.html" = "/docs/ai/overview.html"
-"/assistant/commands.html" = "/docs/ai/text-threads.html"
+"/assistant/commands.html" = "/docs/ai/agent-panel.html"
 "/assistant/configuration.html" = "/docs/ai/quick-start.html"
 "/assistant/context-servers.html" = "/docs/ai/mcp.html"
-"/assistant/contexts.html" = "/docs/ai/text-threads.html"
+"/assistant/contexts.html" = "/docs/ai/agent-panel.html"
 "/assistant/inline-assistant.html" = "/docs/ai/inline-assistant.html"
 "/assistant/model-context-protocol.html" = "/docs/ai/mcp.html"
 "/assistant/prompting.html" = "/docs/ai/skills.html"
diff --git a/docs/last-updated.json b/docs/last-updated.json
new file mode 100644
index 00000000000000..9d7a876bc8a1bd
--- /dev/null
+++ b/docs/last-updated.json
@@ -0,0 +1,192 @@
+{
+  "account/billing.md": "2026-06-04",
+  "account/plans-and-pricing.md": "2026-06-04",
+  "account/zed-hosted-models.md": "2026-06-04",
+  "ai/agent-panel.md": "2026-06-11",
+  "ai/agent-profiles.md": "2026-06-04",
+  "ai/agent-settings.md": "2026-06-10",
+  "ai/agents.md": "2026-06-04",
+  "ai/ai-improvement.md": "2026-06-19",
+  "ai/by-company.md": "2026-06-04",
+  "ai/edit-prediction.md": "2026-06-11",
+  "ai/external-agents.md": "2026-06-11",
+  "ai/inline-assistant.md": "2026-06-11",
+  "ai/instructions.md": "2026-06-04",
+  "ai/llm-providers.md": "2026-06-12",
+  "ai/mcp.md": "2026-06-11",
+  "ai/overview.md": "2026-06-19",
+  "ai/parallel-agents.md": "2026-06-19",
+  "ai/privacy-and-security.md": "2026-06-19",
+  "ai/quick-start.md": "2026-06-19",
+  "ai/rules.md": "2026-06-04",
+  "ai/sandboxing.md": "2026-07-01",
+  "ai/skills.md": "2026-06-11",
+  "ai/terminal-threads.md": "2026-06-16",
+  "ai/text-threads.md": "2026-06-04",
+  "ai/tool-permissions.md": "2026-06-11",
+  "ai/tools.md": "2026-06-04",
+  "ai/use-a-gateway.md": "2026-06-04",
+  "ai/use-a-local-model.md": "2026-06-04",
+  "ai/use-an-existing-subscription.md": "2026-06-04",
+  "ai/use-api-access.md": "2026-06-12",
+  "ai/zed-agent.md": "2026-06-04",
+  "all-actions.md": "2026-02-17",
+  "appearance.md": "2026-05-07",
+  "authentication.md": "2026-06-04",
+  "business/admin-controls.md": "2026-06-04",
+  "business/business-support.md": "2026-05-06",
+  "business/organizations.md": "2026-06-03",
+  "business/overview.md": "2026-06-04",
+  "business/privacy.md": "2026-06-09",
+  "collaboration/channels.md": "2026-05-07",
+  "collaboration/contacts-and-private-calls.md": "2026-02-17",
+  "collaboration/overview.md": "2026-03-04",
+  "command-palette.md": "2026-05-07",
+  "completions.md": "2026-03-02",
+  "configuring-languages.md": "2026-05-31",
+  "configuring-zed.md": "2026-06-04",
+  "debugger.md": "2026-05-31",
+  "dev-containers.md": "2026-06-15",
+  "development.md": "2026-02-28",
+  "development/debuggers.md": "2026-02-17",
+  "development/debugging-crashes.md": "2026-02-17",
+  "development/feature-process.md": "2026-03-13",
+  "development/freebsd.md": "2026-02-17",
+  "development/glossary.md": "2026-05-07",
+  "development/linux.md": "2026-05-07",
+  "development/macos.md": "2026-03-17",
+  "development/release-notes.md": "2026-02-17",
+  "development/windows.md": "2026-02-19",
+  "diagnostics.md": "2026-02-11",
+  "editing-code.md": "2026-02-13",
+  "environment.md": "2026-02-17",
+  "extensions.md": "2026-06-02",
+  "extensions/agent-servers.md": "2026-06-02",
+  "extensions/capabilities.md": "2026-02-17",
+  "extensions/debugger-extensions.md": "2026-02-17",
+  "extensions/developing-extensions.md": "2026-05-25",
+  "extensions/icon-themes.md": "2026-02-17",
+  "extensions/installing-extensions.md": "2026-02-17",
+  "extensions/languages.md": "2026-05-31",
+  "extensions/mcp-extensions.md": "2026-02-17",
+  "extensions/slash-commands.md": "2026-03-31",
+  "extensions/snippets.md": "2026-03-13",
+  "extensions/themes.md": "2026-02-17",
+  "finding-navigating.md": "2026-06-04",
+  "getting-started.md": "2026-06-11",
+  "git.md": "2026-06-19",
+  "globs.md": "2026-05-31",
+  "helix.md": "2026-02-17",
+  "icon-themes.md": "2026-05-07",
+  "installation.md": "2026-05-31",
+  "key-bindings.md": "2026-05-31",
+  "languages.md": "2026-05-31",
+  "languages/ansible.md": "2026-03-01",
+  "languages/asciidoc.md": "2026-02-17",
+  "languages/astro.md": "2026-02-17",
+  "languages/bash.md": "2026-05-06",
+  "languages/biome.md": "2026-02-19",
+  "languages/c.md": "2026-05-07",
+  "languages/clojure.md": "2026-02-17",
+  "languages/cpp.md": "2026-05-31",
+  "languages/csharp.md": "2026-06-10",
+  "languages/css.md": "2026-02-17",
+  "languages/dart.md": "2026-02-17",
+  "languages/deno.md": "2026-06-09",
+  "languages/diff.md": "2026-02-17",
+  "languages/docker.md": "2026-02-17",
+  "languages/elixir.md": "2026-04-15",
+  "languages/elm.md": "2026-02-17",
+  "languages/emmet.md": "2026-02-17",
+  "languages/erlang.md": "2026-02-17",
+  "languages/fish.md": "2026-02-17",
+  "languages/gdscript.md": "2026-02-17",
+  "languages/gleam.md": "2026-02-17",
+  "languages/glsl.md": "2026-02-17",
+  "languages/go.md": "2026-04-22",
+  "languages/groovy.md": "2026-02-17",
+  "languages/haskell.md": "2026-02-17",
+  "languages/helm.md": "2026-02-17",
+  "languages/html.md": "2026-02-17",
+  "languages/java.md": "2026-02-17",
+  "languages/javascript.md": "2026-02-17",
+  "languages/json.md": "2026-02-25",
+  "languages/jsonnet.md": "2026-02-17",
+  "languages/julia.md": "2026-02-17",
+  "languages/kotlin.md": "2026-02-17",
+  "languages/lua.md": "2026-05-31",
+  "languages/luau.md": "2026-02-17",
+  "languages/makefile.md": "2026-02-17",
+  "languages/markdown.md": "2026-02-17",
+  "languages/nim.md": "2026-02-17",
+  "languages/ocaml.md": "2026-05-31",
+  "languages/opentofu.md": "2026-05-19",
+  "languages/php.md": "2026-05-31",
+  "languages/powershell.md": "2026-02-17",
+  "languages/prisma.md": "2026-02-17",
+  "languages/proto.md": "2026-02-17",
+  "languages/purescript.md": "2026-02-17",
+  "languages/python.md": "2026-05-31",
+  "languages/r.md": "2026-05-31",
+  "languages/racket.md": "2026-02-17",
+  "languages/rego.md": "2026-02-17",
+  "languages/roc.md": "2026-02-17",
+  "languages/rst.md": "2026-02-17",
+  "languages/ruby.md": "2026-05-31",
+  "languages/rust.md": "2026-05-07",
+  "languages/scala.md": "2026-05-31",
+  "languages/scheme.md": "2026-02-17",
+  "languages/sh.md": "2026-02-17",
+  "languages/sml.md": "2026-05-31",
+  "languages/sql.md": "2026-02-19",
+  "languages/svelte.md": "2026-05-31",
+  "languages/swift.md": "2026-02-17",
+  "languages/tailwindcss.md": "2026-03-25",
+  "languages/terraform.md": "2026-05-19",
+  "languages/toml.md": "2026-04-23",
+  "languages/typescript.md": "2026-04-22",
+  "languages/uiua.md": "2026-02-17",
+  "languages/vue.md": "2026-03-12",
+  "languages/xml.md": "2026-02-17",
+  "languages/yaml.md": "2026-02-25",
+  "languages/yara.md": "2026-02-17",
+  "languages/yarn.md": "2026-02-17",
+  "languages/zig.md": "2026-02-17",
+  "linux.md": "2026-06-10",
+  "macos.md": "2026-05-07",
+  "migrate/intellij.md": "2026-06-04",
+  "migrate/pycharm.md": "2026-06-04",
+  "migrate/rustrover.md": "2026-06-04",
+  "migrate/vs-code.md": "2026-06-04",
+  "migrate/webstorm.md": "2026-06-04",
+  "modelines.md": "2026-03-25",
+  "multibuffers.md": "2026-05-31",
+  "outline-panel.md": "2026-05-07",
+  "performance.md": "2026-05-31",
+  "project-panel.md": "2026-05-31",
+  "quick-start.md": "2025-10-28",
+  "reference/all-settings.md": "2026-06-19",
+  "reference/cli.md": "2026-05-13",
+  "reference/default-key-bindings.md": "2026-02-17",
+  "remote-development.md": "2026-06-19",
+  "repl.md": "2026-05-31",
+  "roles.md": "2026-06-03",
+  "running-testing.md": "2026-02-17",
+  "semantic-tokens.md": "2026-05-07",
+  "snippets.md": "2026-05-31",
+  "soc2.md": "2026-05-06",
+  "tab-switcher.md": "2026-02-17",
+  "tasks.md": "2026-06-19",
+  "telemetry.md": "2026-06-04",
+  "terminal.md": "2026-05-07",
+  "themes.md": "2026-05-07",
+  "toolchains.md": "2026-02-17",
+  "troubleshooting.md": "2026-04-16",
+  "uninstall.md": "2026-03-31",
+  "update.md": "2026-05-07",
+  "vim.md": "2026-05-12",
+  "visual-customization.md": "2026-05-31",
+  "windows-and-projects.md": "2026-06-11",
+  "windows.md": "2026-02-17",
+  "worktree-trust.md": "2026-06-19"
+}
diff --git a/docs/theme/css/chrome.css b/docs/theme/css/chrome.css
index 8f5b40cc19ecfd..f0169fd7c87f73 100644
--- a/docs/theme/css/chrome.css
+++ b/docs/theme/css/chrome.css
@@ -434,6 +434,7 @@ ul#searchresults span.teaser em {
 
 .sidebar {
   position: relative;
+  order: 0;
   width: var(--sidebar-width);
   flex-shrink: 0;
   display: flex;
diff --git a/docs/theme/css/general.css b/docs/theme/css/general.css
index 9c8077bad525da..d32edaa8d61ad7 100644
--- a/docs/theme/css/general.css
+++ b/docs/theme/css/general.css
@@ -201,6 +201,7 @@ hr {
 
 .page {
   outline: 0;
+  order: 1;
   flex: 1;
   display: flex;
   flex-direction: column;
diff --git a/docs/theme/index.hbs b/docs/theme/index.hbs
index 2c7786817aa2f1..d5e0b620051536 100644
--- a/docs/theme/index.hbs
+++ b/docs/theme/index.hbs
@@ -27,6 +27,7 @@
             })();
         
         {{ title }}
+        
         {{#if is_print }}
         
         {{/if}}
@@ -86,6 +87,9 @@
             document.body.classList.remove('no-js');
             document.body.classList.add('js');
         
+        
+ Agent documentation index: llms.txt. Markdown versions are available for docs pages. +
@@ -155,6 +159,95 @@
{{/if}} +
+
+
+ {{{ content }}} + + +
+
+ +
+ + +
+
+