From ad2b1a4dc92ed9a47ddf80dc26255df3b6f17027 Mon Sep 17 00:00:00 2001 From: Katie Geer Date: Thu, 2 Jul 2026 13:08:26 -0700 Subject: [PATCH] Add docs last-updated metadata Adds the freshness pipeline on top of docs AI-readiness: per-page last-updated dates from git history with a committed last-updated.json fallback, in sitemap.xml, last-modified / article:modified_time HTML meta, llms.txt date annotations, and the artifact freshness test. --- crates/docs_preprocessor/src/ai_discovery.rs | 146 ++++++++++++-- crates/docs_preprocessor/src/main.rs | 21 +- docs/last-updated.json | 192 +++++++++++++++++++ 3 files changed, 343 insertions(+), 16 deletions(-) create mode 100644 docs/last-updated.json diff --git a/crates/docs_preprocessor/src/ai_discovery.rs b/crates/docs_preprocessor/src/ai_discovery.rs index 97a40482a7a709..dbf0c37911406e 100644 --- a/crates/docs_preprocessor/src/ai_discovery.rs +++ b/crates/docs_preprocessor/src/ai_discovery.rs @@ -13,6 +13,7 @@ pub(crate) struct DocsPage { section: String, title: String, description: Option, + pub(crate) last_updated: Option, pub(crate) source_path: PathBuf, content: String, } @@ -28,9 +29,12 @@ pub(crate) fn write_ai_discovery_artifacts( Ok(()) } -pub(crate) fn docs_pages(book: &Book) -> Result> { +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 { @@ -44,14 +48,29 @@ pub(crate) fn docs_pages(book: &Book) -> Result> { 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) } @@ -65,7 +84,11 @@ fn copy_markdown_sources(destination: &Path, site_url: &str, pages: &[DocsPage]) } std::fs::write( &destination, - add_llms_markdown_directive(&markdown_source_contents(&page.content), site_url), + add_llms_markdown_directive( + &markdown_source_contents(&page.content), + site_url, + page.last_updated.as_deref(), + ), ) .with_context(|| { format!( @@ -90,6 +113,57 @@ fn markdown_source_contents(contents: &str) -> String { .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 @@ -146,6 +220,11 @@ fn write_llms_txt(destination: &Path, site_url: &str, pages: &[DocsPage]) -> Res 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")?; @@ -169,6 +248,11 @@ fn write_sitemap_xml(destination: &Path, site_url: &str, pages: &[DocsPage]) -> &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"); @@ -325,10 +409,17 @@ pub(crate) fn add_markdown_alternate_link( contents.replacen("", &(link + " "), 1) } -fn add_llms_markdown_directive(contents: &str, site_url: &str) -> String { +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", + "> 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") { @@ -348,6 +439,18 @@ fn add_llms_markdown_directive(contents: &str, site_url: &str) -> String { 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('/') { @@ -380,16 +483,29 @@ mod tests { use super::*; #[test] - fn test_add_llms_markdown_directive_inserts_after_frontmatter() { + 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/"); + 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)." + "> 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!( @@ -426,6 +542,7 @@ mod tests { 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", @@ -436,6 +553,7 @@ mod tests { 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", @@ -450,22 +568,22 @@ mod tests { 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." + "- [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." - ) - ); + 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).\n\n# MCP" + "> 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")); diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index cda2ca27e2d652..eebaba0fc4b814 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -2,7 +2,7 @@ use anyhow::{Context, Result}; mod ai_discovery; use ai_discovery::{ - add_markdown_alternate_link, docs_pages, write_ai_discovery_artifacts, + add_last_updated_meta, add_markdown_alternate_link, docs_pages, write_ai_discovery_artifacts, write_markdown_redirect_aliases, write_pages_redirects, }; use mdbook::BookItem; @@ -763,8 +763,16 @@ fn handle_postprocessing() -> Result<()> { .and_then(|site_url| site_url.as_str()) .map(str::to_string) .unwrap_or_else(|| "/docs/".to_string()); - let pages = docs_pages(&ctx.book)?; + 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 { let contents = std::fs::read_to_string(&file)?; @@ -803,6 +811,15 @@ fn handle_postprocessing() -> Result<()> { 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) 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" +}