use std::collections::HashMap;
use std::fmt::Write as _;
use std::path::{Path, PathBuf};
const MANIFEST_OUT: &str = "nextrs_docs_manifest.rs";
const CONTENT_OUT: &str = "nextrs_docs_content.rs";
pub struct DocsConfig<'a> {
pub content_dir: &'a str,
pub base_url: &'a str,
pub route_prefix: &'a str,
pub site_name: &'a str,
pub site_description: &'a str,
}
#[derive(serde::Deserialize)]
struct FrontMatter {
title: String,
description: String,
section: String,
#[serde(default)]
order: u32,
}
#[derive(Debug)]
struct ParsedDoc {
slug: String,
title: String,
description: String,
section: String,
order: u32,
body_md: String,
html: String,
}
pub fn emit_docs(cfg: &DocsConfig) -> std::io::Result<()> {
let manifest_dir = PathBuf::from(
std::env::var_os("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR must be set in build.rs"),
);
let abs_content = manifest_dir.join(cfg.content_dir).canonicalize()?;
println!("cargo:rerun-if-changed={}", abs_content.display());
let docs = load_docs(&abs_content)?;
let outputs = [
(MANIFEST_OUT, generate_manifest_rs(&docs)),
(CONTENT_OUT, generate_content_rs(&docs)),
("llms.txt", llms_txt(cfg, &docs)),
("llms-full.txt", llms_full_txt(&docs)),
];
let out_dir = std::env::var_os("OUT_DIR").expect("OUT_DIR must be set in build.rs");
for (name, content) in &outputs {
std::fs::write(Path::new(&out_dir).join(name), content)?;
}
if let Some(target_dir) = manifest_dir.ancestors().find_map(|p| {
let candidate = p.join("target");
candidate.is_dir().then_some(candidate)
}) {
let inspect_dir = target_dir.join("nextrs");
if std::fs::create_dir_all(&inspect_dir).is_ok() {
for (name, content) in &outputs {
let _ = std::fs::write(inspect_dir.join(name), content);
}
}
}
Ok(())
}
fn load_docs(content_dir: &Path) -> std::io::Result<Vec<ParsedDoc>> {
let mut paths: Vec<PathBuf> = std::fs::read_dir(content_dir)?
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.is_file() && p.extension().is_some_and(|ext| ext == "md"))
.collect();
paths.sort();
let mut docs = Vec::with_capacity(paths.len());
for path in paths {
let slug = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_string();
let source = std::fs::read_to_string(&path)?;
let (front, body) = split_frontmatter(&source)
.ok_or_else(|| invalid(format!("{}: missing +++ TOML frontmatter", path.display())))?;
let fm: FrontMatter = toml::from_str(front)
.map_err(|e| invalid(format!("{}: invalid frontmatter: {}", path.display(), e)))?;
if docs.iter().any(|d: &ParsedDoc| d.slug == slug) {
return Err(invalid(format!("duplicate doc slug: {}", slug)));
}
let html = render_markdown(body);
docs.push(ParsedDoc {
slug,
title: fm.title,
description: fm.description,
section: fm.section,
order: fm.order,
body_md: body.trim().to_string(),
html,
});
}
let mut section_rank: HashMap<String, u32> = HashMap::new();
for d in &docs {
let rank = section_rank.entry(d.section.clone()).or_insert(u32::MAX);
*rank = (*rank).min(d.order);
}
docs.sort_by(|a, b| {
(section_rank[&a.section], &a.section, a.order, &a.slug).cmp(&(
section_rank[&b.section],
&b.section,
b.order,
&b.slug,
))
});
Ok(docs)
}
fn invalid(msg: String) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::InvalidData, msg)
}
fn split_frontmatter(source: &str) -> Option<(&str, &str)> {
let rest = source.strip_prefix("+++")?;
let rest = rest
.strip_prefix("\r\n")
.or_else(|| rest.strip_prefix('\n'))?;
let end = rest.find("\n+++")?;
let front = &rest[..end];
let body = rest[end + "\n+++".len()..].trim_start_matches(['\r', '\n']);
Some((front, body))
}
fn render_markdown(md: &str) -> String {
use pulldown_cmark::{CowStr, Event, Options, Parser, Tag, TagEnd};
let mut opts = Options::empty();
opts.insert(Options::ENABLE_TABLES);
opts.insert(Options::ENABLE_STRIKETHROUGH);
opts.insert(Options::ENABLE_FOOTNOTES);
let events: Vec<Event> = Parser::new_ext(md, opts).collect();
let mut rewritten: Vec<Event> = Vec::with_capacity(events.len());
let mut used_ids: HashMap<String, u32> = HashMap::new();
for (i, event) in events.iter().enumerate() {
match event {
Event::Start(Tag::Heading {
level,
id: None,
classes,
attrs,
}) => {
let mut text = String::new();
for later in &events[i + 1..] {
match later {
Event::End(TagEnd::Heading(_)) => break,
Event::Text(t) | Event::Code(t) => text.push_str(t),
_ => {}
}
}
let mut id = slugify(&text);
let n = used_ids.entry(id.clone()).or_insert(0);
*n += 1;
if *n > 1 {
let _ = write!(id, "-{}", *n - 1);
}
rewritten.push(Event::Start(Tag::Heading {
level: *level,
id: Some(CowStr::from(id)),
classes: classes.clone(),
attrs: attrs.clone(),
}));
}
e => rewritten.push(e.clone()),
}
}
let mut html = String::new();
pulldown_cmark::html::push_html(&mut html, rewritten.into_iter());
html
}
fn slugify(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut prev_dash = true; for c in text.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
prev_dash = false;
} else if !prev_dash {
out.push('-');
prev_dash = true;
}
}
while out.ends_with('-') {
out.pop();
}
out
}
fn generate_manifest_rs(docs: &[ParsedDoc]) -> String {
let mut out = String::new();
out.push_str("// @generated by nextrs::docs. Do not edit by hand.\n\n");
out.push_str("#[allow(dead_code)]\npub struct DocMeta {\n");
out.push_str(" pub slug: &'static str,\n pub title: &'static str,\n");
out.push_str(" pub description: &'static str,\n pub section: &'static str,\n");
out.push_str(" pub order: u32,\n}\n\n");
out.push_str("#[allow(dead_code)]\npub static DOCS: &[DocMeta] = &[\n");
for d in docs {
let _ = writeln!(
out,
" DocMeta {{ slug: {:?}, title: {:?}, description: {:?}, section: {:?}, order: {} }},",
d.slug, d.title, d.description, d.section, d.order
);
}
out.push_str("];\n");
out
}
fn generate_content_rs(docs: &[ParsedDoc]) -> String {
let mut out = String::new();
out.push_str("// @generated by nextrs::docs. Do not edit by hand.\n\n");
out.push_str("#[allow(dead_code)]\npub struct Doc {\n");
out.push_str(" pub slug: &'static str,\n pub title: &'static str,\n");
out.push_str(" pub description: &'static str,\n pub section: &'static str,\n");
out.push_str(" pub html: &'static str,\n}\n\n");
out.push_str("#[allow(dead_code)]\npub static DOCS: &[Doc] = &[\n");
for d in docs {
let _ = writeln!(
out,
" Doc {{ slug: {:?}, title: {:?}, description: {:?}, section: {:?}, html: {:?} }},",
d.slug, d.title, d.description, d.section, d.html
);
}
out.push_str("];\n");
out
}
fn llms_txt(cfg: &DocsConfig, docs: &[ParsedDoc]) -> String {
let mut out = String::new();
let _ = writeln!(out, "# {}\n", cfg.site_name);
let _ = writeln!(out, "> {}\n", cfg.site_description);
let mut current_section = None::<&str>;
for d in docs {
if current_section != Some(d.section.as_str()) {
let _ = writeln!(out, "## {}\n", d.section);
current_section = Some(d.section.as_str());
}
let _ = writeln!(
out,
"- [{}]({}{}/{}): {}",
d.title, cfg.base_url, cfg.route_prefix, d.slug, d.description
);
}
let mut normalized = out.replace("\n## ", "\n\n## ");
normalized.push('\n');
normalized
}
fn llms_full_txt(docs: &[ParsedDoc]) -> String {
let mut out = String::new();
for (i, d) in docs.iter().enumerate() {
if i > 0 {
out.push_str("\n---\n\n");
}
let _ = writeln!(out, "# {}\n", d.title);
let _ = writeln!(out, "> {}\n", d.description);
out.push_str(&d.body_md);
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn write_doc(dir: &Path, name: &str, front: &str, body: &str) {
fs::write(dir.join(name), format!("+++\n{}\n+++\n\n{}", front, body)).unwrap();
}
fn sample_dir() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
write_doc(
tmp.path(),
"getting-started.md",
"title = \"Getting Started\"\ndescription = \"First steps\"\nsection = \"Guides\"\norder = 1",
"# Hello\n\nSome *text*.\n\n## Install\n\n```rust\nfn main() {}\n```",
);
write_doc(
tmp.path(),
"deploy-vercel.md",
"title = \"Deploy to Vercel\"\ndescription = \"Ship it\"\nsection = \"Deploy\"\norder = 1",
"Deploy body.",
);
write_doc(
tmp.path(),
"conventions.md",
"title = \"Conventions\"\ndescription = \"Files\"\nsection = \"Guides\"\norder = 2",
"Conventions body.",
);
tmp
}
#[test]
fn frontmatter_parses_and_body_renders() {
let tmp = sample_dir();
let docs = load_docs(tmp.path()).unwrap();
let gs = docs.iter().find(|d| d.slug == "getting-started").unwrap();
assert_eq!(gs.title, "Getting Started");
assert_eq!(gs.section, "Guides");
assert_eq!(gs.order, 1);
assert!(gs.html.contains("<em>text</em>"));
assert!(gs.body_md.starts_with("# Hello"));
}
#[test]
fn headings_get_slugified_ids() {
let html = render_markdown("## Hello World\n\n## Hello World");
assert!(html.contains("<h2 id=\"hello-world\">"), "{}", html);
assert!(html.contains("<h2 id=\"hello-world-1\">"), "{}", html);
}
#[test]
fn docs_sort_by_section_rank_then_order() {
let tmp = sample_dir();
let docs = load_docs(tmp.path()).unwrap();
let slugs: Vec<&str> = docs.iter().map(|d| d.slug.as_str()).collect();
assert_eq!(slugs, ["deploy-vercel", "getting-started", "conventions"]);
}
#[test]
fn missing_frontmatter_is_a_hard_error() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("bad.md"), "no frontmatter here").unwrap();
let err = load_docs(tmp.path()).unwrap_err();
assert!(err.to_string().contains("frontmatter"), "{}", err);
}
#[test]
fn duplicate_slugs_are_rejected() {
let tmp = sample_dir();
let docs = load_docs(tmp.path()).unwrap();
assert_eq!(docs.len(), 3);
}
#[test]
fn llms_txt_groups_by_section_with_links() {
let tmp = sample_dir();
let docs = load_docs(tmp.path()).unwrap();
let cfg = DocsConfig {
content_dir: "unused",
base_url: "https://example.com",
route_prefix: "/docs",
site_name: "nextrs",
site_description: "A framework.",
};
let txt = llms_txt(&cfg, &docs);
assert!(txt.starts_with("# nextrs\n\n> A framework.\n"), "{}", txt);
assert!(txt.contains("## Guides"), "{}", txt);
assert!(txt.contains("## Deploy"), "{}", txt);
assert!(
txt.contains(
"- [Getting Started](https://example.com/docs/getting-started): First steps"
),
"{}",
txt
);
}
#[test]
fn llms_full_contains_raw_markdown() {
let tmp = sample_dir();
let docs = load_docs(tmp.path()).unwrap();
let txt = llms_full_txt(&docs);
assert!(txt.contains("# Getting Started"), "{}", txt);
assert!(txt.contains("```rust"), "{}", txt);
assert!(txt.contains("\n---\n"), "{}", txt);
}
#[test]
fn generated_rust_is_self_contained_and_escaped() {
let tmp = sample_dir();
let docs = load_docs(tmp.path()).unwrap();
let manifest = generate_manifest_rs(&docs);
assert!(manifest.contains("pub struct DocMeta"), "{}", manifest);
assert!(
manifest.contains("slug: \"getting-started\""),
"{}",
manifest
);
let content = generate_content_rs(&docs);
assert!(content.contains("pub struct Doc"), "{}", content);
assert!(
content.contains("\\\"hello\\\"") || content.contains("id=\\\""),
"{}",
content
);
}
}