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 let re = Regex::new(r"^:::\{spec\}").unwrap();
108
109 let nt_map = build_nonterminal_map(&book);
111
112 book.for_each_mut(|item: &mut BookItem| {
114 if let BookItem::Chapter(chapter) = item {
115 let has_labels = chapter.content.lines().any(|line| re.is_match(line.trim()));
117
118 chapter.content = process_spec_directives(
120 &chapter.content,
121 chapter.source_path.as_deref(),
122 &nt_map,
123 );
124
125 if has_labels {
127 chapter.content.push('\n');
128 chapter.content.push_str(&get_inline_css());
129 }
130 }
131 });
132
133 populate_rfc_sections(ctx, &mut book)?;
135
136 book.for_each_mut(|item: &mut BookItem| {
138 if let BookItem::Chapter(chapter) = item {
139 let has_rfc_tables = chapter.content.contains("class=\"rfc-table\"");
141
142 let already_has_css = chapter
144 .content
145 .contains("/* Generated by dada-mdbook-preprocessor");
146
147 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
163fn 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
204fn process_spec_directives(
217 content: &str,
218 source_path: Option<&Path>,
219 nt_map: &HashMap<String, String>,
220) -> String {
221 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 heading_tracker.process_line(trimmed);
244
245 if let Some(captures) = directive_start.captures(trimmed) {
246 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 let rfc_badges = dada_spec_common::render_tag_badges(¤t_rfc_tags);
266
267 let expanded_content = dada_spec_common::expand_ebnf_in_directive(&directive_content);
269
270 let linked_content = render_ebnf_blocks(&expanded_content, nt_map, source_path);
272
273 let transformed_content =
275 dada_spec_common::transform_inline_sub_paragraphs(&linked_content, ¤t_id);
276
277 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()); for content_line in &transformed_content {
287 result.push(content_line.clone());
288 }
289 result.push(String::new()); 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 directive_content.push(line.to_string());
299 }
300 }
301
302 result.join("\n")
303}
304
305fn 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 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, ¤t_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
351fn 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 chars.next(); let mut terminal = String::new();
366 while let Some(&c) = chars.peek() {
367 if c == '`' {
368 chars.next(); 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 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 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 match ch {
399 '<' => result.push_str("<"),
400 '>' => result.push_str(">"),
401 '&' => result.push_str("&"),
402 _ => result.push(ch),
403 }
404 chars.next();
405 }
406 }
407
408 result
409}
410
411fn html_escape(s: &str) -> String {
413 s.replace('&', "&")
414 .replace('<', "<")
415 .replace('>', ">")
416}
417
418fn 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 anchor.to_string()
430 } else {
431 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
449fn 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 let mut rfc_chapter_index = None;
737
738 for (index, item) in book.items.iter().enumerate() {
739 if let BookItem::Chapter(chapter) = item {
740 if chapter.name.trim() == "All RFCs" {
742 rfc_chapter_index = Some(index);
743 break;
744 }
745 }
746 }
747
748 if let Some(index) = rfc_chapter_index {
750 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 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 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 rfc_dirs.sort_by(|a, b| a.0.cmp(&b.0));
784
785 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 let html_content = generate_all_rfcs_html(&rfcs_by_status);
799
800 chapter.content = format!("# All RFCs\n\n{html_content}");
802
803 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 let readme_path = dir_path.join("README.md");
842 let readme_content = std::fs::read_to_string(&readme_path)?;
843
844 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 let relative_path = readme_path.strip_prefix(src_dir)?.to_path_buf();
854
855 let mut rfc_parent_names = all_rfcs_chapter.parent_names.clone();
857 rfc_parent_names.push(all_rfcs_chapter.name.clone());
858
859 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 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 sub_files.sort();
891
892 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 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 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 let readme_path = dir_path.join("README.md");
936 let readme_content = std::fs::read_to_string(&readme_path)?;
937
938 let (front_matter, content) = parse_front_matter(&readme_content)?;
940
941 let number = dir_name[..4].to_string();
943
944 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 let summary_section = extract_section(&content, "## Summary");
954
955 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 if lines.is_empty() || !lines[0].trim().starts_with("---") {
977 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 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 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 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; } else if in_section {
1028 break; }
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 if let Some(version) = implemented_version {
1059 badge_text = format!("{badge_text} v{version}");
1060 }
1061
1062 if let Some(issue) = tracking_issue {
1064 if issue.chars().all(|c| c.is_ascii_digit()) {
1065 badge_text = format!("{badge_text} %23{issue}"); } 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 let encoded_text = badge_text.replace(" ", "%20");
1075
1076 let badge_url = format!("https://img.shields.io/badge/Status-{encoded_text}-{color}");
1077
1078 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 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 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 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 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}