1use crate::ast::{Block, CodeAttrs, Document, Inline, Row, ShortArgs};
2use crate::minify::{self, MinifyOptions, MinifyWarning};
3use crate::shortcode::Registry;
4use std::fmt::Write;
5
6const LONG_BLOCK_LINE_THRESHOLD: usize = 50;
9
10#[derive(Clone, Debug)]
11pub struct Opts {
12 pub strip_emphasis: bool,
13 pub keep_table_rule: bool,
14 pub keep_asset_urls: bool,
15 pub keep_metadata: bool,
16 pub minify_code_blocks: bool,
19 pub minify_languages: Vec<String>,
21 pub preserve_code_fences: bool,
24}
25
26impl Default for Opts {
27 fn default() -> Self {
28 Opts {
35 strip_emphasis: false,
36 keep_table_rule: false,
37 keep_asset_urls: false,
38 keep_metadata: false,
39 minify_code_blocks: true,
40 minify_languages: vec![
41 "json".into(),
42 "jsonl".into(),
43 "rust".into(),
44 "rs".into(),
45 "c".into(),
46 "h".into(),
47 "cpp".into(),
48 "c++".into(),
49 "cc".into(),
50 "cxx".into(),
51 "hpp".into(),
52 "hxx".into(),
53 "java".into(),
54 "go".into(),
55 "javascript".into(),
56 "js".into(),
57 "typescript".into(),
58 "ts".into(),
59 "sql".into(),
60 ],
61 preserve_code_fences: true,
62 }
63 }
64}
65
66pub fn render(doc: &Document, reg: &Registry, opts: &Opts) -> (String, Vec<String>) {
67 let footnotes = collect_footnotes(doc);
68 let mut out = String::new();
69 let frontmatter_minify_code = doc
70 .metadata
71 .as_ref()
72 .and_then(|m| m.get("minify_code"))
73 .and_then(|v| v.as_bool());
74 if opts.keep_metadata {
75 if let Some(meta) = &doc.metadata {
76 let body = toml::to_string(meta).unwrap_or_default();
79 out.push_str("+++\n");
80 out.push_str(&body);
81 if !body.ends_with('\n') {
82 out.push('\n');
83 }
84 out.push_str("+++\n\n");
85 }
86 }
87 let mut ctx = Ctx {
88 reg,
89 opts,
90 counter: 0,
91 in_footnote: false,
92 warnings: Vec::new(),
93 frontmatter_minify_code,
94 resolved_refs: &doc.resolved_refs,
95 };
96 for b in &doc.blocks {
97 render_block(b, &mut ctx, &mut out, 0);
98 }
99 if !footnotes.is_empty() {
100 emit_footnotes_section(&footnotes, reg, opts, &doc.resolved_refs, &mut out);
101 }
102 let warnings = ctx.warnings;
103 let mut collapsed = String::with_capacity(out.len());
104 let mut nl_run = 0;
105 for c in out.chars() {
106 if c == '\n' {
107 nl_run += 1;
108 if nl_run <= 2 {
109 collapsed.push(c);
110 }
111 } else {
112 nl_run = 0;
113 collapsed.push(c);
114 }
115 }
116 (collapsed, warnings)
117}
118
119struct Ctx<'a> {
120 reg: &'a Registry,
121 opts: &'a Opts,
122 counter: u32,
123 in_footnote: bool,
124 warnings: Vec<String>,
125 frontmatter_minify_code: Option<bool>,
126 resolved_refs: &'a std::collections::BTreeMap<crate::span::Span, crate::ast::ResolvedRef>,
127}
128
129fn render_block(b: &Block, ctx: &mut Ctx, out: &mut String, indent: usize) {
130 let pad: String = std::iter::repeat(' ').take(indent).collect();
131 match b {
132 Block::Heading { level, content, .. } => {
133 let hashes: String = std::iter::repeat('#').take(*level as usize).collect();
134 let _ = write!(out, "{} ", hashes);
135 render_inline_seq(content, ctx, out);
136 out.push('\n');
137 }
138 Block::Paragraph { content, .. } => {
139 if content.is_empty() {
140 return;
141 }
142 out.push_str(&pad);
143 render_inline_seq(content, ctx, out);
144 out.push('\n');
145 }
146 Block::List { ordered, items, .. } => {
147 use crate::ast::TaskState;
148 for (i, it) in items.iter().enumerate() {
149 let marker = if *ordered {
150 format!("{}.", i + 1)
151 } else {
152 "-".to_string()
153 };
154 let _ = write!(out, "{}{} ", pad, marker);
155 if let Some(state) = it.task {
156 out.push_str(match state {
157 TaskState::Done => "[x] ",
158 TaskState::Todo => "[ ] ",
159 });
160 }
161 render_inline_seq(&it.content, ctx, out);
162 out.push('\n');
163 for c in &it.children {
164 render_block(c, ctx, out, indent + 1);
165 }
166 }
167 }
168 Block::Blockquote { children, .. } => {
169 for c in children {
170 let mut s = String::new();
171 render_block(c, ctx, &mut s, 0);
172 for line in s.lines() {
173 out.push_str("> ");
174 out.push_str(line);
175 out.push('\n');
176 }
177 }
178 }
179 Block::CodeBlock {
180 lang, body, attrs, ..
181 } => {
182 emit_code_block(lang.as_deref(), body, attrs, ctx, out);
183 }
184 Block::Table { header, rows, .. } => render_table(header, rows, ctx, out),
185 Block::DefinitionList { items, .. } => {
186 out.push_str("@dl\n");
187 for it in items {
188 render_inline_seq(&it.term, ctx, out);
189 out.push('\n');
190 out.push_str(": ");
191 render_inline_seq(&it.definition, ctx, out);
192 out.push('\n');
193 }
194 out.push_str("@end\n");
195 }
196 Block::HorizontalRule { .. } => out.push_str("---\n"),
197 Block::BlockShortcode {
198 name,
199 args,
200 children,
201 ..
202 } => {
203 render_block_shortcode_llm(name, args, children, ctx, out, indent);
204 }
205 }
206}
207
208fn emit_code_block(
209 lang: Option<&str>,
210 body: &str,
211 attrs: &CodeAttrs,
212 ctx: &mut Ctx,
213 out: &mut String,
214) {
215 let minified = try_minify(lang, body, attrs, ctx);
216 let body_to_emit = minified.as_deref().unwrap_or(body);
217 if ctx.opts.preserve_code_fences {
218 out.push_str("```");
219 if let Some(l) = lang {
220 out.push_str(l);
221 }
222 out.push('\n');
223 out.push_str(body_to_emit);
224 out.push('\n');
225 out.push_str("```\n");
226 } else {
227 out.push_str(body_to_emit);
228 out.push('\n');
229 }
230}
231
232fn try_minify(lang: Option<&str>, body: &str, attrs: &CodeAttrs, ctx: &mut Ctx) -> Option<String> {
233 if attrs.nominify {
234 return None;
235 }
236 let lang = lang?;
237 let lang_lc = lang.to_ascii_lowercase();
238
239 let force_minify = attrs.minify || attrs.keep_comments;
240
241 if let Some(reason) = minify::refusal_reason(&lang_lc) {
247 let in_allowlist = ctx
248 .opts
249 .minify_languages
250 .iter()
251 .any(|x| x.eq_ignore_ascii_case(&lang_lc));
252 if force_minify || in_allowlist {
253 ctx.warnings.push(format!(
254 "error[B0704]: language `{}` cannot be minified — {}",
255 lang, reason
256 ));
257 }
258 return None;
259 }
260
261 if !force_minify {
266 if let Some(false) = ctx.frontmatter_minify_code {
267 return None;
268 }
269 if !ctx.opts.minify_code_blocks {
270 return None;
271 }
272 }
273
274 let in_allowlist = ctx
277 .opts
278 .minify_languages
279 .iter()
280 .any(|x| x.eq_ignore_ascii_case(&lang_lc));
281 if !in_allowlist && !force_minify {
282 return None;
283 }
284 if !minify::is_supported(&lang_lc) {
285 return None;
286 }
287
288 let mopts = MinifyOptions {
289 keep_comments: attrs.keep_comments,
290 };
291 match minify::minify(&lang_lc, body, &mopts) {
292 Ok(out) => {
293 for w in &out.warnings {
294 match w {
295 MinifyWarning::LineCommentConverted => {
296 ctx.warnings.push(format!(
297 "warning[B0703]: line comment converted to block form for minification in `{}` block; verify no `*/` content",
298 lang
299 ));
300 }
301 }
302 }
303 let n_lines = body.lines().count();
304 if n_lines > LONG_BLOCK_LINE_THRESHOLD {
305 ctx.warnings.push(format!(
306 "warning[B0702]: minified code block was originally {} lines. LLM consumers cannot reference specific lines after minification. Consider @nominify if line references matter.",
307 n_lines
308 ));
309 }
310 Some(out.body)
311 }
312 Err(e) => {
313 ctx.warnings.push(format!(
314 "warning[B0701]: code block tagged `{}` did not parse; emitted verbatim ({})",
315 lang, e.message
316 ));
317 None
318 }
319 }
320}
321
322fn render_table(header: &Row, rows: &[Row], ctx: &mut Ctx, out: &mut String) {
323 let mut row_strs: Vec<Vec<String>> = Vec::new();
324 let mut h: Vec<String> = Vec::new();
325 for c in &header.cells {
326 let mut s = String::new();
327 render_inline_seq_to(c, ctx, &mut s);
328 h.push(s);
329 }
330 row_strs.push(h);
331 for r in rows {
332 let mut row: Vec<String> = Vec::new();
333 for c in &r.cells {
334 let mut s = String::new();
335 render_inline_seq_to(c, ctx, &mut s);
336 row.push(s);
337 }
338 row_strs.push(row);
339 }
340 let cols = row_strs.iter().map(|r| r.len()).max().unwrap_or(0);
344 let widths: Vec<usize> = (0..cols)
345 .map(|c| {
346 row_strs
347 .iter()
348 .map(|r| r.get(c).map(|s| s.chars().count()).unwrap_or(0))
349 .max()
350 .unwrap_or(0)
351 })
352 .collect();
353 for (i, row) in row_strs.iter().enumerate() {
354 out.push('|');
355 for (c, cell) in row.iter().enumerate() {
356 let w = widths.get(c).copied().unwrap_or(0);
357 let _ = write!(out, " {:width$} |", cell, width = w);
358 }
359 out.push('\n');
360 if i == 0 && ctx.opts.keep_table_rule {
361 out.push('|');
362 for w in &widths {
363 let dashes: String = std::iter::repeat('-').take(*w + 2).collect();
364 out.push_str(&dashes);
365 out.push('|');
366 }
367 out.push('\n');
368 }
369 }
370}
371
372fn render_block_shortcode_llm(
373 name: &str,
374 args: &ShortArgs,
375 children: &[Block],
376 ctx: &mut Ctx,
377 out: &mut String,
378 indent: usize,
379) {
380 if let Some(sc) = ctx.reg.get(name) {
381 if let Some(t) = &sc.template_llm {
382 let mut inner = String::new();
383 for c in children {
384 render_block(c, ctx, &mut inner, indent);
385 }
386 let r = expand_template_llm(t, args, &inner);
387 out.push_str(&r);
388 return;
389 }
390 }
391 match name {
392 "callout" => {
393 let kind = args
394 .keyword
395 .get("kind")
396 .and_then(|v| v.as_str())
397 .unwrap_or("info");
398 let _ = writeln!(out, "[!{}]", kind);
399 for c in children {
400 render_block(c, ctx, out, indent);
401 }
402 let _ = writeln!(out, "[/!]");
403 }
404 "details" => {
405 let summary = args
406 .keyword
407 .get("summary")
408 .and_then(|v| v.as_str())
409 .unwrap_or("");
410 let _ = writeln!(out, "[details: \"{}\"]", summary);
411 for c in children {
412 render_block(c, ctx, out, indent);
413 }
414 let _ = writeln!(out, "[/details]");
415 }
416 "math" => {
417 let mut s = String::new();
418 for c in children {
419 render_block(c, ctx, &mut s, indent);
420 }
421 let _ = writeln!(out, "$${}$$", s.trim());
422 }
423 _ => {
424 let _ = writeln!(out, "@{}", name);
425 for c in children {
426 render_block(c, ctx, out, indent);
427 }
428 let _ = writeln!(out, "@end");
429 }
430 }
431}
432
433fn render_inline_seq(seq: &[Inline], ctx: &mut Ctx, out: &mut String) {
434 for n in seq {
435 render_inline(n, ctx, out);
436 }
437}
438
439fn render_inline_seq_to(seq: &[Inline], ctx: &mut Ctx, out: &mut String) {
440 render_inline_seq(seq, ctx, out)
441}
442
443fn render_inline(node: &Inline, ctx: &mut Ctx, out: &mut String) {
444 match node {
445 Inline::Text { value, .. } => out.push_str(value),
446 Inline::HardBreak { .. } => out.push('\n'),
447 Inline::Bold { content, .. } => emph_wrap(content, ctx, out, '*'),
448 Inline::Italic { content, .. } => emph_wrap(content, ctx, out, '_'),
449 Inline::Underline { content, .. } => emph_wrap(content, ctx, out, '+'),
450 Inline::Strike { content, .. } => emph_wrap(content, ctx, out, '~'),
451 Inline::InlineCode { value, .. } => {
452 out.push('`');
453 out.push_str(value);
454 out.push('`');
455 }
456 Inline::Shortcode {
457 name,
458 args,
459 content,
460 span,
461 } if name == "ref" => {
462 let resolved = ctx.resolved_refs.get(span);
463 let display = if let Some(r) = resolved {
464 r.display.clone()
465 } else if let Some(s) = args.keyword.get("title").and_then(|v| v.as_str()) {
466 s.to_string()
467 } else if let Some(s) = args.positional.first().and_then(|v| v.as_str()) {
468 s.to_string()
469 } else if let Some([Inline::Text { value, .. }]) = content.as_deref() {
470 value.clone()
471 } else {
472 String::new()
473 };
474 out.push_str(&display);
475 }
476 Inline::Shortcode {
477 name,
478 args,
479 content,
480 ..
481 } => {
482 if name == "footnote" {
487 if content.is_none() {
488 return;
489 }
490 if ctx.in_footnote {
491 out.push('[');
492 if let Some(c) = content {
493 render_inline_seq(c, ctx, out);
494 }
495 out.push(']');
496 return;
497 }
498 ctx.counter += 1;
499 let _ = write!(out, "[^{}]", ctx.counter);
500 return;
501 }
502 render_inline_shortcode_llm(name, args, content.as_deref(), ctx, out);
503 }
504 }
505}
506
507fn emph_wrap(content: &[Inline], ctx: &mut Ctx, out: &mut String, m: char) {
508 if ctx.opts.strip_emphasis {
509 render_inline_seq(content, ctx, out);
510 } else {
511 out.push(m);
512 render_inline_seq(content, ctx, out);
513 out.push(m);
514 }
515}
516
517fn render_inline_shortcode_llm(
518 name: &str,
519 args: &ShortArgs,
520 content: Option<&[Inline]>,
521 ctx: &mut Ctx,
522 out: &mut String,
523) {
524 let inner_string = content.map(|c| {
525 let mut s = String::new();
526 render_inline_seq(c, ctx, &mut s);
527 s
528 });
529 if let Some(sc) = ctx.reg.get(name) {
530 if let Some(t) = &sc.template_llm {
531 let r = expand_template_llm(t, args, inner_string.as_deref().unwrap_or(""));
532 out.push_str(&r);
533 return;
534 }
535 }
536 match name {
537 "link" => {
538 let url = args
539 .keyword
540 .get("url")
541 .and_then(|v| v.as_str())
542 .unwrap_or("");
543 let text = inner_string.as_deref().unwrap_or("");
544 let title = args.keyword.get("title").and_then(|v| v.as_str());
545 if let Some(t) = title {
546 let _ = write!(out, "[{}]({} \"{}\")", text, url, t);
547 } else {
548 let _ = write!(out, "[{}]({})", text, url);
549 }
550 }
551 "image" => {
552 let alt = args
553 .keyword
554 .get("alt")
555 .and_then(|v| v.as_str())
556 .unwrap_or("");
557 if ctx.opts.keep_asset_urls {
558 let src = args
559 .keyword
560 .get("src")
561 .and_then(|v| v.as_str())
562 .unwrap_or("");
563 let _ = write!(out, "[image: {} {}]", alt, src);
564 } else {
565 let _ = write!(out, "[image: {}]", alt);
566 }
567 }
568 "kbd" => {
569 let _ = write!(out, "[kbd:{}]", inner_string.as_deref().unwrap_or(""));
570 }
571 "sub" => {
572 let _ = write!(out, "[sub:{}]", inner_string.as_deref().unwrap_or(""));
573 }
574 "sup" => {
575 let _ = write!(out, "[sup:{}]", inner_string.as_deref().unwrap_or(""));
576 }
577 "math" => {
578 let _ = write!(out, "${}$", inner_string.as_deref().unwrap_or(""));
579 }
580 _ => {
581 let _ = write!(out, "@{}", name);
582 if let Some(s) = inner_string {
583 let _ = write!(out, "[{}]", s);
584 }
585 }
586 }
587}
588
589fn collect_footnotes(doc: &Document) -> Vec<Vec<Inline>> {
590 let mut out = Vec::new();
591 for b in &doc.blocks {
592 collect_block(b, &mut out);
593 }
594 out
595}
596
597fn collect_block(b: &Block, out: &mut Vec<Vec<Inline>>) {
598 match b {
599 Block::Heading { content, .. } | Block::Paragraph { content, .. } => {
600 for n in content {
601 collect_inline(n, out);
602 }
603 }
604 Block::List { items, .. } => {
605 for it in items {
606 for n in &it.content {
607 collect_inline(n, out);
608 }
609 for c in &it.children {
610 collect_block(c, out);
611 }
612 }
613 }
614 Block::Blockquote { children, .. } | Block::BlockShortcode { children, .. } => {
615 for c in children {
616 collect_block(c, out);
617 }
618 }
619 Block::Table { header, rows, .. } => {
620 for cell in &header.cells {
621 for n in cell {
622 collect_inline(n, out);
623 }
624 }
625 for row in rows {
626 for cell in &row.cells {
627 for n in cell {
628 collect_inline(n, out);
629 }
630 }
631 }
632 }
633 Block::DefinitionList { items, .. } => {
634 for it in items {
635 for n in &it.term {
636 collect_inline(n, out);
637 }
638 for n in &it.definition {
639 collect_inline(n, out);
640 }
641 }
642 }
643 Block::CodeBlock { .. } | Block::HorizontalRule { .. } => {}
644 }
645}
646
647fn collect_inline(node: &Inline, out: &mut Vec<Vec<Inline>>) {
648 match node {
649 Inline::Bold { content, .. }
650 | Inline::Italic { content, .. }
651 | Inline::Underline { content, .. }
652 | Inline::Strike { content, .. } => {
653 for n in content {
654 collect_inline(n, out);
655 }
656 }
657 Inline::Shortcode { name, content, .. } => {
658 if name == "footnote" {
659 if let Some(c) = content {
660 out.push(c.clone());
661 }
662 return;
663 }
664 if let Some(c) = content {
665 for n in c {
666 collect_inline(n, out);
667 }
668 }
669 }
670 _ => {}
671 }
672}
673
674fn emit_footnotes_section(
675 footnotes: &[Vec<Inline>],
676 reg: &Registry,
677 opts: &Opts,
678 resolved_refs: &std::collections::BTreeMap<crate::span::Span, crate::ast::ResolvedRef>,
679 out: &mut String,
680) {
681 if !out.ends_with('\n') {
682 out.push('\n');
683 }
684 out.push('\n');
685 for (i, body) in footnotes.iter().enumerate() {
686 let n = i + 1;
687 let _ = write!(out, "[^{}]: ", n);
688 let mut ctx = Ctx {
689 reg,
690 opts,
691 counter: 0,
692 in_footnote: true,
693 warnings: Vec::new(),
694 frontmatter_minify_code: None,
695 resolved_refs,
696 };
697 render_inline_seq(body, &mut ctx, out);
698 out.push('\n');
699 }
700}
701
702fn expand_template_llm(tpl: &str, args: &ShortArgs, content: &str) -> String {
703 let mut out = String::new();
704 let bytes = tpl.as_bytes();
705 let mut i = 0;
706 while i < bytes.len() {
707 if bytes[i] == b'{' && bytes.get(i + 1) == Some(&b'{') {
708 if let Some(rel) = tpl[i + 2..].find("}}") {
709 let key = tpl[i + 2..i + 2 + rel].trim();
710 if key == "content" {
711 out.push_str(content);
712 } else if let Some(rest) = key.strip_prefix("args.") {
713 if let Some(v) = args.keyword.get(rest).and_then(|v| v.as_str()) {
714 out.push_str(v);
715 }
716 }
717 i = i + 2 + rel + 2;
718 continue;
719 }
720 }
721 out.push(bytes[i] as char);
722 i += 1;
723 }
724 out
725}
726
727#[cfg(test)]
728mod tests {
729 use super::*;
730 use crate::lexer::lex;
731 use crate::parser::parse;
732 use crate::span::SourceMap;
733
734 fn render_with(input: &str, opts: Opts) -> (String, Vec<String>) {
735 let src = SourceMap::new("d.brf", input);
736 let toks = lex(&src).unwrap();
737 let (doc, diags) = parse(toks, &src);
738 assert!(diags.is_empty(), "{:?}", diags);
739 let reg = Registry::with_builtins();
740 render(&doc, ®, &opts)
741 }
742
743 fn render_default(input: &str) -> String {
744 render_with(input, Opts::default()).0
745 }
746
747 fn opts_with_keep_metadata() -> Opts {
748 Opts {
749 keep_metadata: true,
750 ..Opts::default()
751 }
752 }
753
754 #[test]
755 fn llm_strips_frontmatter_by_default() {
756 let out = render_default("+++\ntitle = \"hi\"\n+++\n# Doc\n");
757 assert!(!out.contains("+++"), "{}", out);
758 assert!(!out.contains("title"), "{}", out);
759 assert!(out.contains("# Doc"));
760 }
761
762 #[test]
763 fn llm_keeps_frontmatter_with_flag() {
764 let (out, _) = render_with(
765 "+++\ntitle = \"hi\"\n+++\n# Doc\n",
766 opts_with_keep_metadata(),
767 );
768 assert!(
769 out.starts_with("+++\n"),
770 "starts with: {:?}",
771 &out[..20.min(out.len())]
772 );
773 assert!(out.contains("title"));
774 assert!(out.contains("# Doc"));
775 let close_pos = out.find("\n+++\n").expect("closing +++ missing");
776 let doc_pos = out.find("# Doc").expect("body missing");
777 assert!(close_pos < doc_pos, "closing +++ must precede body");
778 assert!(
779 out.contains("+++\n\n"),
780 "blank line after closing +++ missing"
781 );
782 }
783
784 #[test]
785 fn llm_keep_metadata_no_op_when_no_metadata() {
786 let (out, _) = render_with("# Doc\n", opts_with_keep_metadata());
787 assert!(!out.contains("+++"), "{}", out);
788 assert!(out.contains("# Doc"));
789 }
790
791 #[test]
792 fn json_block_minified_by_default() {
793 let (out, w) = render_with(
794 "```json\n{\n \"a\": 1,\n \"b\": [1, 2, 3]\n}\n```\n",
795 Opts::default(),
796 );
797 assert!(w.is_empty(), "unexpected warnings: {:?}", w);
798 assert!(out.contains("{\"a\":1,\"b\":[1,2,3]}"), "{}", out);
799 assert!(
800 out.contains("```json"),
801 "fence preserved by default: {}",
802 out
803 );
804 }
805
806 #[test]
807 fn json_block_with_nominify_kept_verbatim() {
808 let src = "```json @nominify\n{\n \"a\": 1\n}\n```\n";
809 let (out, w) = render_with(src, Opts::default());
810 assert!(w.is_empty());
811 assert!(
812 out.contains("\"a\": 1"),
813 "must preserve whitespace: {}",
814 out
815 );
816 }
817
818 #[test]
819 fn invalid_json_falls_back_with_warning() {
820 let src = "```json\n{ not valid }\n```\n";
821 let (out, w) = render_with(src, Opts::default());
822 assert!(out.contains("{ not valid }"), "verbatim body: {}", out);
823 assert_eq!(w.len(), 1, "expected one B0701 warning");
824 assert!(w[0].contains("B0701"));
825 }
826
827 #[test]
828 fn jsonl_block_minified() {
829 let src = "```jsonl\n{\"a\": 1}\n{\"b\": 2}\n```\n";
830 let (out, w) = render_with(src, Opts::default());
831 assert!(w.is_empty(), "{:?}", w);
832 assert!(out.contains("{\"a\":1}\n{\"b\":2}"), "{}", out);
833 }
834
835 #[test]
836 fn rust_block_minified_in_v0_3() {
837 let src = "```rust\nfn x() {\n // hi\n 1\n}\n```\n";
838 let (out, w) = render_with(src, Opts::default());
839 assert!(w.is_empty(), "{:?}", w);
840 assert!(out.contains("fn x(){1}"), "minified rust: {}", out);
841 assert!(!out.contains("// hi"), "comment dropped: {}", out);
842 }
843
844 #[test]
845 fn js_block_preserves_newlines() {
846 let src = "```javascript\nfunction add(a, b) {\n return a + b;\n}\n```\n";
847 let (out, w) = render_with(src, Opts::default());
848 assert!(w.is_empty(), "{:?}", w);
849 assert!(
850 out.contains("function add(a,b){\nreturn a+b;\n}"),
851 "got: {}",
852 out
853 );
854 }
855
856 #[test]
857 fn ts_alias_minifies() {
858 let src = "```ts\nfunction f(x: number): string { return String(x); }\n```\n";
859 let (out, w) = render_with(src, Opts::default());
860 assert!(w.is_empty(), "{:?}", w);
861 assert!(
862 out.contains("function f(x:number):string{return String(x);}"),
863 "got: {}",
864 out
865 );
866 }
867
868 #[test]
869 fn sql_block_minified() {
870 let src = "```sql\nSELECT *\nFROM users\nWHERE id = 1;\n```\n";
871 let (out, w) = render_with(src, Opts::default());
872 assert!(w.is_empty(), "{:?}", w);
873 assert!(
874 out.contains("SELECT*FROM users WHERE id=1;"),
875 "got: {}",
876 out
877 );
878 }
879
880 #[test]
881 fn c_preprocessor_kept_on_own_line() {
882 let src = "```c\n#include <stdio.h>\nint main() { return 0; }\n```\n";
883 let (out, w) = render_with(src, Opts::default());
884 assert!(w.is_empty(), "{:?}", w);
885 assert!(
886 out.contains("#include <stdio.h>\nint main(){return 0;}"),
887 "got: {}",
888 out
889 );
890 }
891
892 #[test]
893 fn cpp_alias_minifies_with_raw_string() {
894 let src = "```cpp\nconst char* s = R\"x(hi)x\";\n```\n";
895 let (out, w) = render_with(src, Opts::default());
896 assert!(w.is_empty(), "{:?}", w);
897 assert!(out.contains("R\"x(hi)x\""), "got: {}", out);
898 }
899
900 #[test]
901 fn java_block_minified() {
902 let src = "```java\npublic class Foo {\n @Override public void f() {}\n}\n```\n";
903 let (out, w) = render_with(src, Opts::default());
904 assert!(w.is_empty(), "{:?}", w);
905 assert!(
906 out.contains("public class Foo{@Override public void f(){}}"),
907 "got: {}",
908 out
909 );
910 }
911
912 #[test]
913 fn go_block_preserves_newlines() {
914 let src = "```go\nfunc add(a, b int) int {\n return a + b\n}\n```\n";
915 let (out, w) = render_with(src, Opts::default());
916 assert!(w.is_empty(), "{:?}", w);
917 assert!(
918 out.contains("func add(a,b int)int{\nreturn a+b\n}"),
919 "got: {}",
920 out
921 );
922 }
923
924 #[test]
925 fn keep_comments_emits_b0703() {
926 let src = "```rust @minify-keep-comments\nfn x() {\n // hi\n 1\n}\n```\n";
927 let (out, w) = render_with(src, Opts::default());
928 assert!(out.contains("/* hi*/"), "comment preserved: {}", out);
929 assert!(
930 w.iter().any(|s| s.contains("B0703")),
931 "B0703 warning emitted: {:?}",
932 w
933 );
934 }
935
936 #[test]
937 fn refused_language_emits_b0704() {
938 let src = "```python @minify\ndef f(x):\n return x\n```\n";
942 let (out, w) = render_with(src, Opts::default());
943 assert!(out.contains("def f(x):\n return x"), "verbatim: {}", out);
944 assert!(
945 w.iter().any(|s| s.contains("B0704")),
946 "B0704 emitted: {:?}",
947 w
948 );
949 }
950
951 #[test]
952 fn refused_language_silent_without_optin() {
953 let opts = Opts {
955 minify_languages: vec!["json".into()],
958 ..Opts::default()
959 };
960 let src = "```python\nx = 1\n```\n";
961 let (_out, w) = render_with(src, opts);
962 assert!(w.is_empty(), "no warning expected: {:?}", w);
963 }
964
965 #[test]
966 fn long_block_emits_b0702() {
967 let mut body = String::from("[\n");
969 for i in 0..60 {
970 body.push_str(&format!(" {}", i));
971 if i < 59 {
972 body.push(',');
973 }
974 body.push('\n');
975 }
976 body.push(']');
977 let src = format!("```json\n{}\n```\n", body);
978 let (_out, w) = render_with(&src, Opts::default());
979 assert!(
980 w.iter().any(|s| s.contains("B0702")),
981 "B0702 emitted for long block: {:?}",
982 w
983 );
984 }
985
986 #[test]
987 fn frontmatter_minify_code_false_disables() {
988 let src = "+++\nminify_code = false\n+++\n```json\n{\"a\": 1}\n```\n";
989 let (out, _) = render_with(src, Opts::default());
990 assert!(out.contains("\"a\": 1"), "verbatim under override: {}", out);
993 }
994
995 #[test]
996 fn frontmatter_override_can_be_force_minified() {
997 let src = "+++\nminify_code = false\n+++\n```json @minify\n{\"a\": 1}\n```\n";
999 let (out, _) = render_with(src, Opts::default());
1000 assert!(out.contains("{\"a\":1}"), "minified anyway: {}", out);
1001 }
1002
1003 #[test]
1004 fn config_disable_minification_globally() {
1005 let src = "```json\n{\"a\": 1}\n```\n";
1006 let opts = Opts {
1007 minify_code_blocks: false,
1008 ..Opts::default()
1009 };
1010 let (out, _) = render_with(src, opts);
1011 assert!(out.contains("\"a\": 1"), "disabled globally: {}", out);
1012 }
1013
1014 #[test]
1015 fn drop_fence_with_preserve_false() {
1016 let src = "```json\n{\"a\": 1}\n```\n";
1017 let opts = Opts {
1018 preserve_code_fences: false,
1019 ..Opts::default()
1020 };
1021 let (out, _) = render_with(src, opts);
1022 assert!(!out.contains("```"), "fence dropped: {}", out);
1023 assert!(out.contains("{\"a\":1}"));
1024 }
1025
1026 #[test]
1027 fn ref_renders_display_text_only_in_llm_mode() {
1028 use crate::project::ProjectIndex;
1029 use crate::resolve::{ResolveProject, resolve_with_project};
1030 use std::collections::BTreeSet;
1031 use std::path::PathBuf;
1032
1033 let src = "See @ref[other.brf#x](the spec).\n";
1035 let mut doc = {
1036 use crate::{lexer, parser, span::SourceMap};
1037 let s = SourceMap::new("t.brf", src);
1038 let tokens = lexer::lex(&s).expect("lex");
1039 let (d, _) = parser::parse(tokens, &s);
1040 d
1041 };
1042 let mut idx = ProjectIndex::default();
1043 idx.anchors
1044 .insert("other.brf".to_string(), BTreeSet::from(["x".into()]));
1045 let p = ResolveProject {
1046 index: &idx,
1047 current: &PathBuf::from("here.brf"),
1048 };
1049 let reg = crate::shortcode::Registry::with_builtins();
1050 let _ = resolve_with_project(&mut doc, ®, Some(&p));
1051
1052 let opts = Opts::default();
1053 let (out, _warnings) = render(&doc, ®, &opts);
1054 assert!(out.contains("the spec"), "got: {}", out);
1055 assert!(
1056 !out.contains("other.brf"),
1057 "url must be dropped in LLM mode: {}",
1058 out
1059 );
1060 assert!(
1061 !out.contains("#x"),
1062 "anchor must be dropped in LLM mode: {}",
1063 out
1064 );
1065 }
1066
1067 #[test]
1068 fn unresolved_ref_in_llm_falls_back_to_display_text() {
1069 let doc = {
1070 use crate::{lexer, parser, span::SourceMap};
1071 let s = SourceMap::new("t.brf", "See @ref[a.brf](just text).\n");
1072 let tokens = lexer::lex(&s).expect("lex");
1073 let (d, _) = parser::parse(tokens, &s);
1074 d
1075 };
1076 let reg = crate::shortcode::Registry::with_builtins();
1077 let opts = Opts::default();
1078 let (out, _) = render(&doc, ®, &opts);
1079 assert!(out.contains("just text"), "got: {}", out);
1080 assert!(!out.contains("a.brf"));
1081 }
1082
1083 #[test]
1084 fn dl_renders_verbatim_brief_form() {
1085 use crate::ast::{Block, DefinitionItem, Document, Inline, ShortArgs};
1086 use crate::span::Span;
1087 let doc = Document {
1088 blocks: vec![Block::DefinitionList {
1089 args: ShortArgs::default(),
1090 items: vec![
1091 DefinitionItem {
1092 term: vec![Inline::Text {
1093 value: "Term1".into(),
1094 span: Span::DUMMY,
1095 }],
1096 definition: vec![Inline::Text {
1097 value: "Def1.".into(),
1098 span: Span::DUMMY,
1099 }],
1100 span: Span::DUMMY,
1101 },
1102 DefinitionItem {
1103 term: vec![Inline::Text {
1104 value: "Term2".into(),
1105 span: Span::DUMMY,
1106 }],
1107 definition: vec![Inline::Text {
1108 value: "Def2.".into(),
1109 span: Span::DUMMY,
1110 }],
1111 span: Span::DUMMY,
1112 },
1113 ],
1114 span: Span::DUMMY,
1115 }],
1116 metadata: None,
1117 resolved_refs: Default::default(),
1118 };
1119 let reg = Registry::with_builtins();
1120 let opts = Opts::default();
1121 let (out, _w) = render(&doc, ®, &opts);
1122 assert!(out.contains("@dl\n"), "got: {}", out);
1123 assert!(out.contains("Term1\n: Def1.\n"), "got: {}", out);
1124 assert!(out.contains("Term2\n: Def2.\n"), "got: {}", out);
1125 assert!(out.contains("@end\n"), "got: {}", out);
1126 }
1127}