Skip to main content

dada_mdbook_preprocessor/
main.rs

1use anyhow::{bail, Result};
2use clap::{Arg, ArgMatches, Command};
3use mdbook_preprocessor::book::{Book, BookItem, Chapter};
4use mdbook_preprocessor::{Preprocessor, PreprocessorContext};
5use regex::Regex;
6use serde::Deserialize;
7use std::collections::HashMap;
8use std::io;
9use std::path::Path;
10use std::process;
11
12pub fn make_app() -> Command {
13    Command::new("dada-mdbook-preprocessor")
14        .about("An mdbook preprocessor for processing Dada spec directives")
15        .subcommand(
16            Command::new("supports")
17                .arg(Arg::new("renderer").required(true))
18                .about("Check whether a renderer is supported by this preprocessor"),
19        )
20}
21
22fn main() {
23    let matches = make_app().get_matches();
24
25    let preprocessor = DadaPreprocessor::new();
26
27    if let Some(sub_args) = matches.subcommand_matches("supports") {
28        handle_supports(&preprocessor, sub_args);
29    } else if let Err(e) = handle_preprocessing(&preprocessor) {
30        eprintln!("{e}");
31        process::exit(1);
32    }
33}
34
35fn handle_supports(pre: &dyn Preprocessor, sub_args: &ArgMatches) -> ! {
36    let renderer = sub_args
37        .get_one::<String>("renderer")
38        .expect("Required argument");
39    let supported = pre.supports_renderer(renderer).unwrap();
40
41    if supported {
42        process::exit(0);
43    } else {
44        process::exit(1);
45    }
46}
47
48fn handle_preprocessing(pre: &dyn Preprocessor) -> Result<()> {
49    let (ctx, book) = mdbook_preprocessor::parse_input(io::stdin())?;
50
51    let book_version = Version::parse(&ctx.mdbook_version)?;
52    let version_req = VersionReq::parse(mdbook_preprocessor::MDBOOK_VERSION)?;
53
54    if !version_req.matches(&book_version) {
55        eprintln!(
56            "Warning: The {} plugin was built against version {} of mdbook, \
57             but we're being called from version {}",
58            pre.name(),
59            mdbook_preprocessor::MDBOOK_VERSION,
60            ctx.mdbook_version
61        );
62    }
63
64    let processed_book = pre.run(&ctx, book)?;
65    serde_json::to_writer(io::stdout(), &processed_book)?;
66
67    Ok(())
68}
69
70use semver::{Version, VersionReq};
71
72#[derive(Debug, Deserialize)]
73struct RfcFrontMatter {
74    status: String,
75    #[serde(rename = "tracking-issue")]
76    tracking_issue: Option<String>,
77    #[serde(rename = "implemented-version")]
78    implemented_version: Option<String>,
79}
80
81#[derive(Debug)]
82struct RfcInfo {
83    number: String,
84    title: String,
85    path: String,
86    status: String,
87    status_display: String,
88    full_summary_markdown: String,
89}
90
91struct DadaPreprocessor;
92
93impl DadaPreprocessor {
94    pub fn new() -> DadaPreprocessor {
95        DadaPreprocessor
96    }
97}
98
99impl Preprocessor for DadaPreprocessor {
100    fn name(&self) -> &str {
101        "dada-mdbook-preprocessor"
102    }
103
104    fn run(&self, ctx: &PreprocessorContext, book: Book) -> Result<Book> {
105        let mut book = book;
106        // 💡 Match MyST directive syntax: `:::{spec} paragraph.id [rfcN...]`
107        let re = Regex::new(r"^:::\{spec\}").unwrap();
108
109        // Pre-pass: build nonterminal → URL map from headings like `## \`Function\` definition`
110        let nt_map = build_nonterminal_map(&book);
111
112        // First pass: process spec directives
113        book.for_each_mut(|item: &mut BookItem| {
114            if let BookItem::Chapter(chapter) = item {
115                // Check if this chapter has any spec directives
116                let has_labels = chapter.content.lines().any(|line| re.is_match(line.trim()));
117
118                // Process the content
119                chapter.content = process_spec_directives(
120                    &chapter.content,
121                    chapter.source_path.as_deref(),
122                    &nt_map,
123                );
124
125                // If this chapter has labels, inject CSS at the end
126                if has_labels {
127                    chapter.content.push('\n');
128                    chapter.content.push_str(&get_inline_css());
129                }
130            }
131        });
132
133        // Second pass: populate RFC sections
134        populate_rfc_sections(ctx, &mut book)?;
135
136        // Third pass: inject CSS for any chapters with RFC tables
137        book.for_each_mut(|item: &mut BookItem| {
138            if let BookItem::Chapter(chapter) = item {
139                // Check if this chapter has RFC tables (after RFC generation)
140                let has_rfc_tables = chapter.content.contains("class=\"rfc-table\"");
141
142                // Check if CSS is already injected
143                let already_has_css = chapter
144                    .content
145                    .contains("/* Generated by dada-mdbook-preprocessor");
146
147                // If this chapter has RFC tables and doesn't already have CSS, inject it
148                if has_rfc_tables && !already_has_css {
149                    chapter.content.push('\n');
150                    chapter.content.push_str(&get_inline_css());
151                }
152            }
153        });
154
155        Ok(book)
156    }
157
158    fn supports_renderer(&self, renderer: &str) -> Result<bool> {
159        Ok(renderer != "not-supported")
160    }
161}
162
163/// Scans all chapters for headings matching `` ## `Nonterminal` definition ``
164/// and builds a map from nonterminal name to relative URL for cross-chapter linking.
165///
166/// 💡 The heading convention is `` ## `Function` definition `` which mdbook generates
167/// as an anchor like `#function-definition`. For cross-chapter links, we also need
168/// the chapter's path (e.g., `syntax/items.html`).
169fn build_nonterminal_map(book: &Book) -> HashMap<String, String> {
170    let heading_re = Regex::new(r"^#{2,6}\s+`([A-Z][A-Za-z]*)`\s+definition").unwrap();
171    let mut map = HashMap::new();
172
173    fn scan_items(items: &[BookItem], heading_re: &Regex, map: &mut HashMap<String, String>) {
174        for item in items {
175            if let BookItem::Chapter(chapter) = item {
176                let chapter_path = chapter
177                    .path
178                    .as_ref()
179                    .map(|p| p.with_extension("html").to_string_lossy().to_string())
180                    .unwrap_or_default();
181
182                for line in chapter.content.lines() {
183                    if let Some(caps) = heading_re.captures(line.trim()) {
184                        let nt_name = caps[1].to_string();
185                        let anchor = format!("{}-definition", nt_name.to_lowercase());
186                        let url = if chapter_path.is_empty() {
187                            format!("#{anchor}")
188                        } else {
189                            format!("{chapter_path}#{anchor}")
190                        };
191                        map.insert(nt_name, url);
192                    }
193                }
194
195                scan_items(&chapter.sub_items, heading_re, map);
196            }
197        }
198    }
199
200    scan_items(&book.items, &heading_re, &mut map);
201    map
202}
203
204/// Processes MyST `{spec}` directives into HTML with anchors and styling.
205///
206/// 💡 Spec paragraph IDs are resolved from context:
207/// - File path prefix derived from `source_path` (e.g., `syntax/string-literals.md` → `syntax.string-literals`)
208/// - Current heading stack (e.g., `## Delimiters` → `delimiters`)
209/// - Local name from the directive (e.g., `:::{spec} quoted` → `quoted`)
210///
211/// The directive `:::{spec} quoted rfc0001 unimpl` under `## Delimiters` in
212/// `syntax/string-literals.md` resolves to `syntax.string-literals.delimiters.quoted`.
213///
214/// Inline sub-paragraphs `` {spec}`name` `` within a directive block create
215/// individually linkable sub-paragraph anchors.
216fn process_spec_directives(
217    content: &str,
218    source_path: Option<&Path>,
219    nt_map: &HashMap<String, String>,
220) -> String {
221    // 💡 Changed from matching the full ID to just detecting the directive start.
222    // The tokens after `{spec}` are parsed by `dada_spec_common::parse_spec_tokens`
223    // to distinguish local names from tags.
224    let directive_start = Regex::new(r"^:::\{spec\}(.*)$").unwrap();
225    let directive_end = Regex::new(r"^:::$").unwrap();
226
227    let file_prefix = source_path
228        .map(dada_spec_common::file_path_to_prefix)
229        .unwrap_or_default();
230    let mut heading_tracker = dada_spec_common::HeadingTracker::new();
231
232    let mut result = Vec::new();
233    let mut in_directive = false;
234    let mut current_id = String::new();
235    let mut current_rfc_tags: Vec<String> = Vec::new();
236    let mut directive_content: Vec<String> = Vec::new();
237
238    for line in content.lines() {
239        let trimmed = line.trim();
240
241        if !in_directive {
242            // Track headings for auto-prefix resolution
243            heading_tracker.process_line(trimmed);
244
245            if let Some(captures) = directive_start.captures(trimmed) {
246                // Start of a spec directive
247                in_directive = true;
248
249                let rest = captures.get(1).map(|m| m.as_str()).unwrap_or("");
250                let (local_name, tags) = dada_spec_common::parse_spec_tokens(rest);
251
252                current_id = dada_spec_common::resolve_spec_id(
253                    &file_prefix,
254                    &heading_tracker.current_segments(),
255                    local_name.as_deref().unwrap_or(""),
256                );
257                current_rfc_tags = tags;
258
259                directive_content.clear();
260            } else {
261                result.push(line.to_string());
262            }
263        } else if directive_end.is_match(trimmed) {
264            // End of directive - generate HTML
265            let rfc_badges = dada_spec_common::render_tag_badges(&current_rfc_tags);
266
267            // Expand EBNF `...` placeholders from sub-paragraph names
268            let expanded_content = dada_spec_common::expand_ebnf_in_directive(&directive_content);
269
270            // Convert EBNF code fences to HTML with linked nonterminals
271            let linked_content = render_ebnf_blocks(&expanded_content, nt_map, source_path);
272
273            // Transform inline sub-paragraphs in the content
274            let transformed_content =
275                dada_spec_common::transform_inline_sub_paragraphs(&linked_content, &current_id);
276
277            // Generate HTML wrapper
278            result.push(format!(
279                "<div class=\"spec-paragraph\" id=\"{current_id}\">"
280            ));
281            result.push(format!(
282                "<div class=\"spec-label\"><a href=\"#{current_id}\" class=\"spec-label-link\">{current_id}</a>{rfc_badges}</div>"
283            ));
284            result.push("<div class=\"spec-content\">".to_string());
285            result.push(String::new()); // Empty line for markdown processing
286            for content_line in &transformed_content {
287                result.push(content_line.clone());
288            }
289            result.push(String::new()); // Empty line for markdown processing
290            result.push("</div>".to_string());
291            result.push("</div>".to_string());
292
293            in_directive = false;
294            current_id.clear();
295            current_rfc_tags.clear();
296        } else {
297            // Inside directive - collect content
298            directive_content.push(line.to_string());
299        }
300    }
301
302    result.join("\n")
303}
304
305/// Converts markdown ` ```ebnf ``` ` code fences into HTML `<pre>` blocks with linked nonterminals.
306///
307/// Within EBNF blocks:
308/// - PascalCase words that exist in `nt_map` become `<a>` links to their definition
309/// - Backtick-quoted terminals become `<code class="ebnf-t">` spans
310///
311/// 💡 Links are made relative to the current chapter. If the nonterminal is defined
312/// in a different chapter, we compute a relative path; if same chapter, just `#anchor`.
313fn render_ebnf_blocks(
314    content_lines: &[String],
315    nt_map: &HashMap<String, String>,
316    current_source: Option<&Path>,
317) -> Vec<String> {
318    let current_html_path = current_source
319        .map(|p| p.with_extension("html").to_string_lossy().to_string())
320        .unwrap_or_default();
321
322    let mut in_ebnf = false;
323    let mut ebnf_lines: Vec<String> = Vec::new();
324    let mut result = Vec::new();
325
326    for line in content_lines {
327        let trimmed = line.trim();
328
329        if trimmed == "```ebnf" {
330            in_ebnf = true;
331            ebnf_lines.clear();
332        } else if trimmed == "```" && in_ebnf {
333            in_ebnf = false;
334            // Render the collected EBNF as HTML
335            result.push("<pre class=\"ebnf-block\"><code>".to_string());
336            for ebnf_line in &ebnf_lines {
337                let rendered = render_ebnf_line(ebnf_line, nt_map, &current_html_path);
338                result.push(rendered);
339            }
340            result.push("</code></pre>".to_string());
341        } else if in_ebnf {
342            ebnf_lines.push(line.clone());
343        } else {
344            result.push(line.clone());
345        }
346    }
347
348    result
349}
350
351/// Renders a single EBNF line, replacing nonterminal references with links
352/// and backtick-quoted terminals with styled `<code>` spans.
353fn render_ebnf_line(
354    line: &str,
355    nt_map: &HashMap<String, String>,
356    current_html_path: &str,
357) -> String {
358    let mut result = String::new();
359    let mut chars = line.chars().peekable();
360
361    while let Some(&ch) = chars.peek() {
362        if ch == '`' {
363            // Terminal in backticks
364            chars.next(); // consume opening backtick
365            let mut terminal = String::new();
366            while let Some(&c) = chars.peek() {
367                if c == '`' {
368                    chars.next(); // consume closing backtick
369                    break;
370                }
371                terminal.push(c);
372                chars.next();
373            }
374            result.push_str(&format!(
375                "<span class=\"ebnf-t\">{}</span>",
376                html_escape(&terminal)
377            ));
378        } else if ch.is_uppercase() {
379            // Potential nonterminal — collect PascalCase word
380            let mut word = String::new();
381            while let Some(&c) = chars.peek() {
382                if c.is_alphanumeric() {
383                    word.push(c);
384                    chars.next();
385                } else {
386                    break;
387                }
388            }
389            if let Some(url) = nt_map.get(&word) {
390                // 💡 Make the link relative to the current chapter.
391                let href = make_relative_link(url, current_html_path);
392                result.push_str(&format!("<a href=\"{href}\" class=\"ebnf-nt\">{word}</a>"));
393            } else {
394                result.push_str(&word);
395            }
396        } else {
397            // Regular character — HTML-escape it
398            match ch {
399                '<' => result.push_str("&lt;"),
400                '>' => result.push_str("&gt;"),
401                '&' => result.push_str("&amp;"),
402                _ => result.push(ch),
403            }
404            chars.next();
405        }
406    }
407
408    result
409}
410
411/// Escapes HTML special characters in terminal content.
412fn html_escape(s: &str) -> String {
413    s.replace('&', "&amp;")
414        .replace('<', "&lt;")
415        .replace('>', "&gt;")
416}
417
418/// Computes a relative link from `current_path` to `target_url`.
419///
420/// If the target is in the same file, returns just the `#anchor` part.
421/// Otherwise returns a relative path like `../syntax/items.html#anchor`.
422fn make_relative_link(target_url: &str, current_path: &str) -> String {
423    if let Some(hash_pos) = target_url.find('#') {
424        let target_file = &target_url[..hash_pos];
425        let anchor = &target_url[hash_pos..];
426
427        if target_file == current_path || target_file.is_empty() {
428            // Same file — just the anchor
429            anchor.to_string()
430        } else {
431            // 💡 Compute relative path: go up from current dir, then down to target.
432            let current_dir = std::path::Path::new(current_path)
433                .parent()
434                .map(|p| p.to_str().unwrap_or(""))
435                .unwrap_or("");
436            if current_dir.is_empty() {
437                target_url.to_string()
438            } else {
439                let depth = current_dir.matches('/').count() + 1;
440                let up = "../".repeat(depth);
441                format!("{up}{target_file}{anchor}")
442            }
443        }
444    } else {
445        target_url.to_string()
446    }
447}
448
449/// 💡 Returns inline CSS with blank lines stripped. Blank lines inside `<style>` tags
450/// cause mdbook's markdown processor to insert `<p>` tags, which breaks the CSS.
451fn get_inline_css() -> String {
452    let css = r#"<style>
453/* Generated by dada-mdbook-preprocessor - Styling for specification paragraphs */
454.spec-paragraph {
455    margin: 1rem 0;
456    padding: 0.75rem 1rem;
457    border-left: 3px solid #d0d7de;
458    background-color: #f8f9fa;
459    border-radius: 0 4px 4px 0;
460}
461
462.spec-label {
463    margin-bottom: 0.5rem;
464    display: flex;
465    align-items: center;
466    gap: 0.5rem;
467    flex-wrap: wrap;
468}
469
470.spec-label-link {
471    font-size: 0.75rem;
472    color: #666;
473    text-decoration: none;
474    font-family: 'SFMono-Regular', 'Monaco', 'Inconsolata', 'Fira Code', 'Source Code Pro', monospace;
475    background-color: #fff;
476    padding: 0.125rem 0.375rem;
477    border-radius: 0.25rem;
478    border: 1px solid #d0d7de;
479}
480
481.spec-label-link:hover {
482    color: #0366d6;
483    background-color: #f1f8ff;
484    border-color: #c8e1ff;
485    text-decoration: none;
486}
487
488.spec-content {
489    margin: 0;
490}
491
492.spec-content > p:first-child {
493    margin-top: 0;
494}
495
496.spec-content > p:last-child {
497    margin-bottom: 0;
498}
499
500.spec-rfc-badge {
501    font-size: 0.65rem;
502    color: #fff;
503    background-color: #0969da;
504    padding: 0.1rem 0.3rem;
505    border-radius: 0.25rem;
506    font-family: 'SFMono-Regular', 'Monaco', 'Inconsolata', 'Fira Code', 'Source Code Pro', monospace;
507}
508
509.spec-rfc-badge.spec-rfc-deleted {
510    background-color: #cf222e;
511}
512
513.spec-rfc-badge.spec-rfc-unimpl {
514    background-color: #bf8700;
515}
516
517.spec-sub-label {
518    font-size: 0.65rem;
519    color: #999;
520    text-decoration: none;
521    font-family: 'SFMono-Regular', 'Monaco', 'Inconsolata', 'Fira Code', 'Source Code Pro', monospace;
522}
523
524.spec-sub-label:hover {
525    color: #0366d6;
526    text-decoration: none;
527}
528
529/* EBNF grammar blocks — inherits mdbook's default pre/code styling */
530.ebnf-block > code {
531    background: none;
532    border: none;
533    padding: 0;
534}
535
536.ebnf-nt {
537    color: #0969da;
538    text-decoration: none;
539}
540
541.ebnf-nt:hover {
542    text-decoration: underline;
543}
544
545.ebnf-t {
546    color: #0a3069;
547    background-color: rgba(175, 184, 193, 0.2);
548    padding: 0.1rem 0.3rem;
549    border-radius: 3px;
550}
551
552/* Dark theme support */
553.navy .spec-paragraph {
554    border-color: #30363d;
555    background-color: #161b22;
556}
557
558.navy .spec-label-link {
559    color: #c5c5c5;
560    background-color: #21262d;
561    border-color: #30363d;
562}
563
564.navy .spec-label-link:hover {
565    color: #79b8ff;
566    background-color: #1c2128;
567    border-color: #30363d;
568}
569
570.navy .spec-rfc-badge {
571    background-color: #58a6ff;
572    color: #0d1117;
573}
574
575.navy .spec-rfc-badge.spec-rfc-deleted {
576    background-color: #f85149;
577}
578
579.navy .spec-rfc-badge.spec-rfc-unimpl {
580    background-color: #d29922;
581    color: #0d1117;
582}
583
584.navy .spec-sub-label {
585    color: #7d8590;
586}
587
588.navy .spec-sub-label:hover {
589    color: #79b8ff;
590}
591
592.navy .ebnf-nt {
593    color: #58a6ff;
594}
595
596.navy .ebnf-t {
597    color: #79c0ff;
598    background-color: rgba(110, 118, 129, 0.2);
599}
600
601/* RFC Table Styling - GitHub-inspired */
602.rfc-table {
603    width: 100%;
604    border-collapse: collapse;
605    margin-bottom: 1.5rem;
606    border: 1px solid #d0d7de;
607    border-radius: 6px;
608    overflow: hidden;
609}
610
611.rfc-header-row {
612    background-color: #f6f8fa;
613    border-bottom: 1px solid #d0d7de;
614}
615
616.rfc-header-row:hover {
617    background-color: #f1f8ff;
618}
619
620.rfc-number {
621    width: 60px;
622    padding: 12px 16px;
623    text-align: center;
624    font-weight: 600;
625    font-size: 14px;
626    color: #656d76;
627    background-color: inherit;
628}
629
630.rfc-details {
631    display: flex;
632    align-items: center;
633    justify-content: center;
634}
635
636.rfc-details summary {
637    cursor: pointer;
638    list-style: none;
639    display: flex;
640    align-items: center;
641    justify-content: center;
642    position: relative;
643}
644
645.rfc-details summary::-webkit-details-marker {
646    display: none;
647}
648
649.rfc-details summary::before {
650    content: "▶";
651    font-size: 10px;
652    margin-right: 6px;
653    transition: transform 0.2s ease;
654}
655
656.rfc-details[open] summary::before {
657    transform: rotate(90deg);
658}
659
660.rfc-title {
661    padding: 12px 16px;
662    font-weight: 600;
663    font-size: 14px;
664}
665
666.rfc-title a {
667    color: #0969da;
668    text-decoration: none;
669}
670
671.rfc-title a:hover {
672    text-decoration: underline;
673}
674
675.rfc-summary-row {
676    display: none;
677}
678
679.rfc-details[open] ~ .rfc-header-row ~ .rfc-summary-row,
680.rfc-table:has(.rfc-details[open]) .rfc-summary-row {
681    display: table-row;
682}
683
684.rfc-summary-content {
685    padding: 16px;
686    font-size: 13px;
687    color: #656d76;
688    font-weight: normal;
689    border-top: 1px solid #d0d7de;
690    background-color: #f8f9fa;
691}
692
693.rfc-status {
694    width: 120px;
695    padding: 12px 16px;
696    text-align: center;
697    vertical-align: middle;
698}
699
700/* Dark theme support for RFC tables */
701.navy .rfc-table {
702    border-color: #30363d;
703}
704
705.navy .rfc-header-row {
706    background-color: #21262d;
707    border-color: #30363d;
708}
709
710.navy .rfc-header-row:hover {
711    background-color: #1c2128;
712}
713
714.navy .rfc-number {
715    color: #7d8590;
716}
717
718.navy .rfc-title a {
719    color: #58a6ff;
720}
721
722.navy .rfc-summary-content {
723    border-color: #30363d;
724    color: #7d8590;
725    background-color: #161b22;
726}
727</style>"#;
728    css.lines()
729        .filter(|line| !line.trim().is_empty())
730        .collect::<Vec<_>>()
731        .join("\n")
732}
733
734fn populate_rfc_sections(ctx: &PreprocessorContext, book: &mut Book) -> Result<()> {
735    // Find the position of the "All RFCs" chapter
736    let mut rfc_chapter_index = None;
737
738    for (index, item) in book.items.iter().enumerate() {
739        if let BookItem::Chapter(chapter) = item {
740            // Check if this is the All RFCs chapter
741            if chapter.name.trim() == "All RFCs" {
742                rfc_chapter_index = Some(index);
743                break;
744            }
745        }
746    }
747
748    // If we found the All RFCs chapter, populate it
749    if let Some(index) = rfc_chapter_index {
750        // Get a mutable reference to the chapter
751        if let Some(BookItem::Chapter(chapter)) = book.items.get_mut(index) {
752            populate_all_rfcs_section(ctx, chapter)?;
753        }
754    }
755
756    Ok(())
757}
758
759fn populate_all_rfcs_section(ctx: &PreprocessorContext, chapter: &mut Chapter) -> Result<()> {
760    let src_dir = ctx.config.book.src.clone();
761
762    // Find all RFC directories
763    let mut rfc_dirs = Vec::new();
764    if let Ok(entries) = std::fs::read_dir(&src_dir) {
765        for entry in entries {
766            let entry = entry?;
767            let path = entry.path();
768            if path.is_dir() {
769                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
770                    // Match directories like 0001-feature-name
771                    if name.len() > 4
772                        && name[..4].chars().all(|c| c.is_ascii_digit())
773                        && name.chars().nth(4) == Some('-')
774                    {
775                        rfc_dirs.push((name.to_string(), path));
776                    }
777                }
778            }
779        }
780    }
781
782    // Sort by RFC number
783    rfc_dirs.sort_by(|a, b| a.0.cmp(&b.0));
784
785    // Process each RFC directory to extract info
786    let mut rfcs_by_status: HashMap<String, Vec<RfcInfo>> = HashMap::new();
787
788    for (dir_name, dir_path) in rfc_dirs {
789        if let Ok(rfc_info) = extract_rfc_info(&dir_path, &dir_name) {
790            rfcs_by_status
791                .entry(rfc_info.status.clone())
792                .or_default()
793                .push(rfc_info);
794        }
795    }
796
797    // Generate HTML content for the all.md page
798    let html_content = generate_all_rfcs_html(&rfcs_by_status);
799
800    // Replace the chapter content
801    chapter.content = format!("# All RFCs\n\n{html_content}");
802
803    // Process each RFC directory for sub-chapters (keep existing functionality)
804    let src_dir = ctx.config.book.src.clone();
805    let mut rfc_dirs = Vec::new();
806    if let Ok(entries) = std::fs::read_dir(&src_dir) {
807        for entry in entries {
808            let entry = entry?;
809            let path = entry.path();
810            if path.is_dir() {
811                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
812                    if name.len() > 4
813                        && name[..4].chars().all(|c| c.is_ascii_digit())
814                        && name.chars().nth(4) == Some('-')
815                    {
816                        rfc_dirs.push((name.to_string(), path));
817                    }
818                }
819            }
820        }
821    }
822    rfc_dirs.sort_by(|a, b| a.0.cmp(&b.0));
823
824    for ((dir_name, dir_path), index) in rfc_dirs.iter().zip(0..) {
825        if let Ok(rfc_chapter) = create_rfc_chapter(&src_dir, dir_name, dir_path, chapter, index) {
826            chapter.sub_items.push(BookItem::Chapter(rfc_chapter));
827        }
828    }
829
830    Ok(())
831}
832
833fn create_rfc_chapter(
834    src_dir: &Path,
835    dir_name: &str,
836    dir_path: &Path,
837    all_rfcs_chapter: &Chapter,
838    rfc_index: u32,
839) -> Result<Chapter> {
840    // Read README.md to get the title
841    let readme_path = dir_path.join("README.md");
842    let readme_content = std::fs::read_to_string(&readme_path)?;
843
844    // Extract title from first # line
845    let title = readme_content
846        .lines()
847        .find(|line| line.starts_with("# "))
848        .map(|line| line[2..].trim())
849        .unwrap_or(dir_name)
850        .to_string();
851
852    // Create relative path for mdbook
853    let relative_path = readme_path.strip_prefix(src_dir)?.to_path_buf();
854
855    // Create proper parent names for nested structure
856    let mut rfc_parent_names = all_rfcs_chapter.parent_names.clone();
857    rfc_parent_names.push(all_rfcs_chapter.name.clone());
858
859    // Get the section number of the parent and convert it to the section number of the new child
860    let Some(mut section_number) = all_rfcs_chapter.number.clone() else {
861        bail!("All RFCs chapter has no number")
862    };
863    section_number.push(rfc_index);
864
865    let mut rfc_chapter = Chapter::new(
866        &title,
867        readme_content,
868        relative_path,
869        rfc_parent_names.clone(),
870    );
871    rfc_chapter.number = Some(section_number.clone());
872
873    // Find all other .md files in the directory
874    if let Ok(entries) = std::fs::read_dir(dir_path) {
875        let mut sub_files = Vec::new();
876
877        for entry in entries {
878            let entry = entry?;
879            let path = entry.path();
880
881            if path.is_file() && path.extension().and_then(|s| s.to_str()) == Some("md") {
882                let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
883                if file_name != "README.md" {
884                    sub_files.push(path);
885                }
886            }
887        }
888
889        // Sort sub-files for consistent ordering
890        sub_files.sort();
891
892        // Create sub-chapters for each .md file
893        let mut sub_parent_names = rfc_parent_names.clone();
894        sub_parent_names.push(title.clone());
895
896        for (sub_path, sub_index) in sub_files.iter().zip(0..) {
897            if let Ok(sub_content) = std::fs::read_to_string(sub_path) {
898                // Extract title from first # line
899                let sub_title = sub_content
900                    .lines()
901                    .find(|line| line.starts_with("# "))
902                    .map(|line| line[2..].trim())
903                    .unwrap_or_else(|| {
904                        sub_path
905                            .file_stem()
906                            .and_then(|s| s.to_str())
907                            .unwrap_or("Untitled")
908                    })
909                    .to_string();
910
911                let sub_relative_path = sub_path.strip_prefix(src_dir)?.to_path_buf();
912
913                let mut sub_chapter = Chapter::new(
914                    &sub_title,
915                    sub_content,
916                    sub_relative_path,
917                    sub_parent_names.clone(),
918                );
919
920                // assign the section number for this subchapter
921                section_number.push(sub_index);
922                sub_chapter.number = Some(section_number.clone());
923                section_number.pop().unwrap();
924
925                rfc_chapter.sub_items.push(BookItem::Chapter(sub_chapter));
926            }
927        }
928    }
929
930    Ok(rfc_chapter)
931}
932
933fn extract_rfc_info(dir_path: &Path, dir_name: &str) -> Result<RfcInfo> {
934    // Read README.md to get the title and content
935    let readme_path = dir_path.join("README.md");
936    let readme_content = std::fs::read_to_string(&readme_path)?;
937
938    // Parse front matter and content
939    let (front_matter, content) = parse_front_matter(&readme_content)?;
940
941    // Extract RFC number from directory name (e.g., "0001-string-literals" -> "0001")
942    let number = dir_name[..4].to_string();
943
944    // Extract title from first # line
945    let title = content
946        .lines()
947        .find(|line| line.starts_with("# "))
948        .map(|line| line[2..].trim())
949        .unwrap_or(dir_name)
950        .to_string();
951
952    // Extract summary section
953    let summary_section = extract_section(&content, "## Summary");
954
955    // Generate status display
956    let status_display = format_status(
957        &front_matter.status,
958        &front_matter.tracking_issue,
959        &front_matter.implemented_version,
960    );
961
962    Ok(RfcInfo {
963        number,
964        title,
965        path: dir_name.to_string(),
966        status: front_matter.status,
967        status_display,
968        full_summary_markdown: summary_section,
969    })
970}
971
972fn parse_front_matter(content: &str) -> Result<(RfcFrontMatter, String)> {
973    let lines: Vec<&str> = content.lines().collect();
974
975    // Check if content starts with front matter
976    if lines.is_empty() || !lines[0].trim().starts_with("---") {
977        // No front matter, use defaults
978        let default_front_matter = RfcFrontMatter {
979            status: "draft".to_string(),
980            tracking_issue: None,
981            implemented_version: None,
982        };
983        return Ok((default_front_matter, content.to_string()));
984    }
985
986    // Find the end of front matter
987    let mut end_index = None;
988    for (i, line) in lines.iter().enumerate().skip(1) {
989        if line.trim() == "---" {
990            end_index = Some(i);
991            break;
992        }
993    }
994
995    let end_index = end_index.ok_or_else(|| anyhow::anyhow!("Unclosed front matter"))?;
996
997    // Extract and parse front matter
998    let front_matter_text = lines[1..end_index].join("\n");
999    let front_matter: RfcFrontMatter =
1000        serde_yaml::from_str(&front_matter_text).unwrap_or_else(|_| RfcFrontMatter {
1001            status: "draft".to_string(),
1002            tracking_issue: None,
1003            implemented_version: None,
1004        });
1005
1006    // Extract content after front matter
1007    let content_lines = if end_index + 1 < lines.len() {
1008        &lines[end_index + 1..]
1009    } else {
1010        &[]
1011    };
1012    let content = content_lines.join("\n");
1013
1014    Ok((front_matter, content))
1015}
1016
1017fn extract_section(content: &str, heading: &str) -> String {
1018    let lines: Vec<&str> = content.lines().collect();
1019    let mut in_section = false;
1020    let mut section_lines = Vec::new();
1021
1022    for line in lines {
1023        if line.starts_with("## ") {
1024            if line.trim() == heading {
1025                in_section = true;
1026                continue; // Skip the heading itself
1027            } else if in_section {
1028                break; // Hit next section, stop
1029            }
1030        }
1031
1032        if in_section {
1033            section_lines.push(line);
1034        }
1035    }
1036
1037    section_lines.join("\n").trim().to_string()
1038}
1039
1040fn format_status(
1041    status: &str,
1042    tracking_issue: &Option<String>,
1043    implemented_version: &Option<String>,
1044) -> String {
1045    let (color, label) = match status {
1046        "active" => ("blue", "Active"),
1047        "accepted" => ("green", "Accepted"),
1048        "implemented" => ("brightgreen", "Implemented"),
1049        "draft" => ("lightgrey", "Draft"),
1050        "rejected" => ("red", "Rejected"),
1051        "withdrawn" => ("red", "Withdrawn"),
1052        _ => ("lightgrey", "Unknown"),
1053    };
1054
1055    let mut badge_text = label.to_string();
1056
1057    // Add version info if implemented
1058    if let Some(version) = implemented_version {
1059        badge_text = format!("{badge_text} v{version}");
1060    }
1061
1062    // Add tracking issue if present
1063    if let Some(issue) = tracking_issue {
1064        if issue.chars().all(|c| c.is_ascii_digit()) {
1065            badge_text = format!("{badge_text} %23{issue}"); // %23 is URL-encoded #
1066        } else if let Some(issue_num) = issue.strip_prefix('#') {
1067            if issue_num.chars().all(|c| c.is_ascii_digit()) {
1068                badge_text = format!("{badge_text} %23{issue_num}");
1069            }
1070        }
1071    }
1072
1073    // URL encode spaces and other characters
1074    let encoded_text = badge_text.replace(" ", "%20");
1075
1076    let badge_url = format!("https://img.shields.io/badge/Status-{encoded_text}-{color}");
1077
1078    // Generate HTML img tag instead of markdown since we're in an HTML table
1079    if let Some(issue) = tracking_issue {
1080        if issue.chars().all(|c| c.is_ascii_digit())
1081            || (issue.starts_with('#') && issue[1..].chars().all(|c| c.is_ascii_digit()))
1082        {
1083            let issue_num = issue.strip_prefix('#').unwrap_or(issue);
1084            let github_url = format!("https://github.com/dada-lang/dada/issues/{issue_num}");
1085            format!("<a href=\"{github_url}\"><img src=\"{badge_url}\" alt=\"RFC Status\" /></a>")
1086        } else {
1087            format!("<img src=\"{badge_url}\" alt=\"RFC Status\" />")
1088        }
1089    } else {
1090        format!("<img src=\"{badge_url}\" alt=\"RFC Status\" />")
1091    }
1092}
1093
1094fn generate_all_rfcs_html(rfcs_by_status: &HashMap<String, Vec<RfcInfo>>) -> String {
1095    let mut content = String::new();
1096
1097    content.push_str("This page provides an overview of all RFCs (Request for Comments) in the Dada language development process.\n\n");
1098
1099    // Define status order for display
1100    let status_headers = [
1101        (
1102            "active",
1103            "Active RFCs",
1104            "RFCs currently under discussion and development.",
1105        ),
1106        (
1107            "accepted",
1108            "Accepted RFCs",
1109            "RFCs that have been accepted but not yet implemented.",
1110        ),
1111        (
1112            "implemented",
1113            "Implemented RFCs",
1114            "RFCs that have been fully implemented and are part of the language.",
1115        ),
1116        (
1117            "draft",
1118            "Draft RFCs",
1119            "RFCs that are still being written or refined.",
1120        ),
1121        (
1122            "rejected",
1123            "Rejected/Withdrawn RFCs",
1124            "RFCs that were not accepted or were withdrawn.",
1125        ),
1126    ];
1127
1128    for (status, header, description) in status_headers {
1129        if let Some(rfcs) = rfcs_by_status.get(status) {
1130            if !rfcs.is_empty() {
1131                content.push_str(&format!("## {header}\n\n"));
1132                content.push_str(&format!("{description}\n\n"));
1133                content.push_str(&generate_rfc_table_html(rfcs));
1134                content.push('\n');
1135            } else {
1136                // Show empty sections for non-draft/rejected statuses
1137                if status != "rejected" {
1138                    content.push_str(&format!("## {header}\n\n"));
1139                    content.push_str(&format!("{description}\n\n"));
1140                    content.push_str("*(None yet)*\n");
1141                    content.push('\n');
1142                }
1143            }
1144        } else {
1145            // Show empty sections for non-draft/rejected statuses
1146            if status != "rejected" {
1147                content.push_str(&format!("## {header}\n\n"));
1148                content.push_str(&format!("{description}\n\n"));
1149                content.push_str("*(None yet)*\n");
1150                content.push('\n');
1151            }
1152        }
1153    }
1154
1155    content
1156}
1157
1158fn generate_rfc_table_html(rfcs: &[RfcInfo]) -> String {
1159    let mut html = String::new();
1160
1161    for rfc in rfcs {
1162        let rfc_number = rfc.number.parse::<u32>().unwrap_or(0);
1163        // ⚠️ IMPORTANT: The blank lines around {} in rfc-summary-content are REQUIRED!
1164        // They allow mdbook's markdown processor to parse markdown inside HTML blocks.
1165        // Without these newlines, content like `code` and **bold** won't be rendered.
1166        html.push_str(&format!(
1167            r#"<table class="rfc-table">
1168<tr class="rfc-header-row">
1169<td class="rfc-number">
1170<details class="rfc-details">
1171<summary>{}</summary>
1172</details>
1173</td>
1174<td class="rfc-title"><a href="./{}/README.md">{}</a></td>
1175<td class="rfc-status">{}</td>
1176</tr>
1177<tr class="rfc-summary-row">
1178<td colspan="3" class="rfc-summary-content">
1179
1180{}
1181
1182</td>
1183</tr>
1184</table>
1185
1186"#,
1187            rfc_number,
1188            rfc.path,
1189            rfc.title,
1190            rfc.status_display,
1191            rfc.full_summary_markdown.trim()
1192        ));
1193    }
1194
1195    html
1196}