1use std::path::PathBuf;
38
39use regex::Regex;
40
41use super::{ViewConfig, ViewError};
42
43pub fn apply_layout(content: &str, config: &ViewConfig) -> Result<String, ViewError> {
68 let mut result = content.to_string();
69
70 if config.layout_on {
72 if result.contains("{__NOLAYOUT__}") {
73 result = result.replace("{__NOLAYOUT__}", "");
75 } else {
76 let layout_file = resolve_template_path(&config.layout_name, config);
78 if !layout_file.is_file() {
79 return Err(ViewError::TemplateNotFound(format!(
80 "布局模板: {} (解析路径: {})",
81 config.layout_name,
82 layout_file.display()
83 )));
84 }
85 let layout_content = std::fs::read_to_string(&layout_file)?;
86 result = layout_content.replace(&config.layout_item, &result);
89 }
90 } else {
91 result = result.replace("{__NOLAYOUT__}", "");
93 }
94
95 result = parse_layout_tag(&result, config)?;
97
98 Ok(result)
99}
100
101fn parse_layout_tag(content: &str, config: &ViewConfig) -> Result<String, ViewError> {
123 let begin = regex::escape(&config.taglib_begin);
124 let end = regex::escape(&config.taglib_end);
125
126 let pattern = format!(r"{}layout\b\s+([^}}]+){}", begin, end);
131 let re = Regex::new(&pattern).map_err(|e| ViewError::SyntaxError(e.to_string()))?;
132
133 if let Some(caps) = re.captures(content) {
134 let full_match = caps.get(0).expect("正则捕获组 0 必定存在");
135 let tag_name = parse_attr(full_match.as_str(), "name");
136
137 let tag_name = match tag_name {
140 Some(name) if !name.is_empty() => name,
141 _ => {
142 return Ok(content.replace("{__NOLAYOUT__}", ""));
144 }
145 };
146
147 let result = content.replace(full_match.as_str(), "");
149
150 if config.layout_on && config.layout_name == tag_name {
153 return Ok(result);
154 }
155
156 let replace = parse_attr(full_match.as_str(), "replace")
159 .unwrap_or_else(|| config.layout_item.clone());
160
161 let layout_file = resolve_template_path(&tag_name, config);
163 if !layout_file.is_file() {
164 return Err(ViewError::TemplateNotFound(format!(
165 "布局模板: {} (解析路径: {})",
166 tag_name,
167 layout_file.display()
168 )));
169 }
170 let layout_content = std::fs::read_to_string(&layout_file)?;
171
172 Ok(layout_content.replace(&replace, &result))
176 } else {
177 Ok(content.replace("{__NOLAYOUT__}", ""))
179 }
180}
181
182fn parse_attr(tag: &str, attr_name: &str) -> Option<String> {
193 let pattern = format!(r#"{}\s*=\s*["']([^"']*)["']"#, regex::escape(attr_name));
196 let re = Regex::new(&pattern).ok()?;
197 re.captures(tag).map(|c| c[1].to_string())
198}
199
200fn resolve_template_path(name: &str, config: &ViewConfig) -> PathBuf {
209 if std::path::Path::new(name).extension().is_some() {
211 return PathBuf::from(name);
212 }
213
214 let name = name.strip_prefix('/').unwrap_or(name);
216
217 let normalized = name.replace(['/', ':'], &config.view_depr);
219
220 let suffix = config.view_suffix.trim_start_matches('.');
225 let file_name = format!("{}.{}", normalized, suffix);
226 config.view_path.join(file_name)
227}
228
229#[cfg(test)]
234mod tests {
235 use super::*;
236 use std::path::PathBuf;
237 use std::sync::atomic::{AtomicU64, Ordering};
238
239 static TEMP_DIR_COUNTER: AtomicU64 = AtomicU64::new(0);
244
245 fn make_temp_dir() -> PathBuf {
254 let id = TEMP_DIR_COUNTER.fetch_add(1, Ordering::SeqCst);
255 let nanos = std::time::SystemTime::now()
256 .duration_since(std::time::UNIX_EPOCH)
257 .unwrap_or_default()
258 .as_nanos();
259 let dir = std::env::temp_dir().join(format!("sz_rust_layout_test_{}_{}", nanos, id));
260 std::fs::create_dir_all(&dir).unwrap();
261 dir
262 }
263
264 fn write_template(dir: &std::path::Path, name: &str, content: &str) {
266 let path = dir.join(format!("{}.html", name));
267 std::fs::write(&path, content).unwrap();
268 }
269
270 fn cleanup_dir(dir: &std::path::Path) {
272 let _ = std::fs::remove_dir_all(dir);
273 }
274
275 fn make_config(view_path: PathBuf) -> ViewConfig {
277 ViewConfig {
278 view_path,
279 ..Default::default()
280 }
281 }
282
283 fn make_config_layout_on(view_path: PathBuf) -> ViewConfig {
285 ViewConfig {
286 view_path,
287 layout_on: true,
288 ..Default::default()
289 }
290 }
291
292 #[test]
297 fn test_config_layout_on_basic() {
298 let dir = make_temp_dir();
300 write_template(&dir, "layout", "<html><body>{__CONTENT__}</body></html>");
301 let config = make_config_layout_on(dir.clone());
302
303 let result = apply_layout("<h1>Hello</h1>", &config).unwrap();
304 assert_eq!(result, "<html><body><h1>Hello</h1></body></html>");
305
306 cleanup_dir(&dir);
307 }
308
309 #[test]
310 fn test_config_layout_on_with_nolayout() {
311 let dir = make_temp_dir();
313 write_template(&dir, "layout", "<html><body>{__CONTENT__}</body></html>");
314 let config = make_config_layout_on(dir.clone());
315
316 let result = apply_layout("{__NOLAYOUT__}<h1>Hello</h1>", &config).unwrap();
317 assert_eq!(result, "<h1>Hello</h1>");
318
319 cleanup_dir(&dir);
320 }
321
322 #[test]
323 fn test_config_layout_on_custom_name() {
324 let dir = make_temp_dir();
326 write_template(&dir, "custom", "<div class=\"wrapper\">{__CONTENT__}</div>");
327 let config = ViewConfig {
328 view_path: dir.clone(),
329 layout_on: true,
330 layout_name: "custom".to_string(),
331 ..Default::default()
332 };
333
334 let result = apply_layout("<p>Content</p>", &config).unwrap();
335 assert_eq!(result, "<div class=\"wrapper\"><p>Content</p></div>");
336
337 cleanup_dir(&dir);
338 }
339
340 #[test]
341 fn test_config_layout_on_custom_item() {
342 let dir = make_temp_dir();
344 write_template(&dir, "layout", "<html><body>{__BODY__}</body></html>");
345 let config = ViewConfig {
346 view_path: dir.clone(),
347 layout_on: true,
348 layout_item: "{__BODY__}".to_string(),
349 ..Default::default()
350 };
351
352 let result = apply_layout("<h1>Hello</h1>", &config).unwrap();
353 assert_eq!(result, "<html><body><h1>Hello</h1></body></html>");
354
355 cleanup_dir(&dir);
356 }
357
358 #[test]
359 fn test_config_layout_on_file_not_found() {
360 let dir = make_temp_dir();
362 let config = make_config_layout_on(dir.clone());
363
364 let result = apply_layout("<h1>Hello</h1>", &config);
365 assert!(matches!(result, Err(ViewError::TemplateNotFound(_))));
366
367 cleanup_dir(&dir);
368 }
369
370 #[test]
375 fn test_config_layout_off_basic() {
376 let dir = make_temp_dir();
378 let config = make_config(dir.clone());
379
380 let result = apply_layout("<h1>Hello</h1>", &config).unwrap();
381 assert_eq!(result, "<h1>Hello</h1>");
382
383 cleanup_dir(&dir);
384 }
385
386 #[test]
387 fn test_config_layout_off_with_nolayout() {
388 let dir = make_temp_dir();
390 let config = make_config(dir.clone());
391
392 let result = apply_layout("{__NOLAYOUT__}<h1>Hello</h1>", &config).unwrap();
393 assert_eq!(result, "<h1>Hello</h1>");
394
395 cleanup_dir(&dir);
396 }
397
398 #[test]
403 fn test_tag_layout_basic() {
404 let dir = make_temp_dir();
406 write_template(&dir, "custom", "<html><body>{__CONTENT__}</body></html>");
407 let config = make_config(dir.clone());
408
409 let result = apply_layout(r#"{layout name="custom" /}<h1>Hello</h1>"#, &config).unwrap();
410 assert_eq!(result, "<html><body><h1>Hello</h1></body></html>");
411
412 cleanup_dir(&dir);
413 }
414
415 #[test]
416 fn test_tag_layout_without_self_closing() {
417 let dir = make_temp_dir();
419 write_template(&dir, "custom", "<html><body>{__CONTENT__}</body></html>");
420 let config = make_config(dir.clone());
421
422 let result = apply_layout(r#"{layout name="custom"}<h1>Hello</h1>"#, &config).unwrap();
423 assert_eq!(result, "<html><body><h1>Hello</h1></body></html>");
424
425 cleanup_dir(&dir);
426 }
427
428 #[test]
429 fn test_tag_layout_custom_replace() {
430 let dir = make_temp_dir();
435 write_template(&dir, "custom", "<html><body>BODY</body></html>");
436 let config = make_config(dir.clone());
437
438 let result = apply_layout(
439 r#"{layout name="custom" replace="BODY"}<h1>Hello</h1>"#,
440 &config,
441 )
442 .unwrap();
443 assert_eq!(result, "<html><body><h1>Hello</h1></body></html>");
444
445 cleanup_dir(&dir);
446 }
447
448 #[test]
449 fn test_tag_layout_replace_with_brace_php_bug() {
450 let dir = make_temp_dir();
458 write_template(&dir, "custom", "<html><body>{__BODY__}</body></html>");
459 let config = make_config(dir.clone());
460
461 let result = apply_layout(
462 r#"{layout name="custom" replace="{__BODY__}"}<h1>Hello</h1>"#,
463 &config,
464 )
465 .unwrap();
466 assert_eq!(result, "<html><body>{__BODY__}</body></html>");
469
470 cleanup_dir(&dir);
471 }
472
473 #[test]
474 fn test_tag_layout_file_not_found() {
475 let dir = make_temp_dir();
477 let config = make_config(dir.clone());
478
479 let result = apply_layout(r#"{layout name="nonexistent" /}<h1>Hello</h1>"#, &config);
480 assert!(matches!(result, Err(ViewError::TemplateNotFound(_))));
481
482 cleanup_dir(&dir);
483 }
484
485 #[test]
486 fn test_tag_layout_missing_name_attr() {
487 let dir = make_temp_dir();
489 write_template(&dir, "custom", "<html><body>{__CONTENT__}</body></html>");
490 let config = make_config(dir.clone());
491
492 let result =
494 apply_layout(r#"{layout replace="{__CONTENT__}"}<h1>Hello</h1>"#, &config).unwrap();
495 assert_eq!(result, r#"{layout replace="{__CONTENT__}"}<h1>Hello</h1>"#);
497
498 cleanup_dir(&dir);
499 }
500
501 #[test]
502 fn test_tag_layout_empty_name() {
503 let dir = make_temp_dir();
505 let config = make_config(dir.clone());
506
507 let result = apply_layout(r#"{layout name="" /}<h1>Hello</h1>"#, &config).unwrap();
508 assert_eq!(result, r#"{layout name="" /}<h1>Hello</h1>"#);
510
511 cleanup_dir(&dir);
512 }
513
514 #[test]
519 fn test_config_on_tag_same_name_skip() {
520 let dir = make_temp_dir();
523 write_template(&dir, "layout", "<html><body>{__CONTENT__}</body></html>");
524 let config = make_config_layout_on(dir.clone());
525
526 let result = apply_layout(r#"{layout name="layout" /}<h1>Hello</h1>"#, &config).unwrap();
527 assert_eq!(result, "<html><body><h1>Hello</h1></body></html>");
529
530 cleanup_dir(&dir);
531 }
532
533 #[test]
534 fn test_config_on_tag_different_name_double_layout() {
535 let dir = make_temp_dir();
538 write_template(&dir, "layout", "<html><body>{__CONTENT__}</body></html>");
539 write_template(&dir, "other", "<wrapper>{__CONTENT__}</wrapper>");
540 let config = make_config_layout_on(dir.clone());
541
542 let result = apply_layout(r#"{layout name="other" /}<h1>Hello</h1>"#, &config).unwrap();
543 assert_eq!(
546 result,
547 "<wrapper><html><body><h1>Hello</h1></body></html></wrapper>"
548 );
549
550 cleanup_dir(&dir);
551 }
552
553 #[test]
554 fn test_config_off_tag_layout_applied() {
555 let dir = make_temp_dir();
557 write_template(&dir, "custom", "<html><body>{__CONTENT__}</body></html>");
558 let config = make_config(dir.clone());
559
560 let result = apply_layout(r#"{layout name="custom" /}<h1>Hello</h1>"#, &config).unwrap();
561 assert_eq!(result, "<html><body><h1>Hello</h1></body></html>");
562
563 cleanup_dir(&dir);
564 }
565
566 #[test]
571 fn test_nolayout_with_config_on() {
572 let dir = make_temp_dir();
574 write_template(&dir, "layout", "<html><body>{__CONTENT__}</body></html>");
575 let config = make_config_layout_on(dir.clone());
576
577 let result = apply_layout("{__NOLAYOUT__}<h1>Hello</h1>", &config).unwrap();
578 assert_eq!(result, "<h1>Hello</h1>");
579
580 cleanup_dir(&dir);
581 }
582
583 #[test]
584 fn test_nolayout_with_config_off() {
585 let dir = make_temp_dir();
587 let config = make_config(dir.clone());
588
589 let result = apply_layout("{__NOLAYOUT__}<h1>Hello</h1>", &config).unwrap();
590 assert_eq!(result, "<h1>Hello</h1>");
591
592 cleanup_dir(&dir);
593 }
594
595 #[test]
596 fn test_nolayout_in_layout_file() {
597 let dir = make_temp_dir();
599 write_template(
600 &dir,
601 "layout",
602 "{__NOLAYOUT__}<html><body>{__CONTENT__}</body></html>",
603 );
604 let config = make_config_layout_on(dir.clone());
605
606 let result = apply_layout("<h1>Hello</h1>", &config).unwrap();
607 assert_eq!(result, "<html><body><h1>Hello</h1></body></html>");
610
611 cleanup_dir(&dir);
612 }
613
614 #[test]
619 fn test_str_replace_non_recursive() {
620 let dir = make_temp_dir();
623 write_template(&dir, "layout", "<html><body>{__CONTENT__}</body></html>");
624 let config = make_config_layout_on(dir.clone());
625
626 let result = apply_layout("{__CONTENT__}<h1>Hello</h1>", &config).unwrap();
628 assert_eq!(
632 result,
633 "<html><body>{__CONTENT__}<h1>Hello</h1></body></html>"
634 );
635
636 cleanup_dir(&dir);
637 }
638
639 #[test]
640 fn test_multiple_content_placeholders() {
641 let dir = make_temp_dir();
643 write_template(
644 &dir,
645 "layout",
646 "<header>{__CONTENT__}</header><main>{__CONTENT__}</main>",
647 );
648 let config = make_config_layout_on(dir.clone());
649
650 let result = apply_layout("<h1>Hello</h1>", &config).unwrap();
651 assert_eq!(
652 result,
653 "<header><h1>Hello</h1></header><main><h1>Hello</h1></main>"
654 );
655
656 cleanup_dir(&dir);
657 }
658
659 #[test]
664 fn test_resolve_path_simple() {
665 let config = make_config(PathBuf::from("/view"));
666 let path = resolve_template_path("layout", &config);
667 assert_eq!(path, PathBuf::from("/view/layout.html"));
668 }
669
670 #[test]
671 fn test_resolve_path_with_slash_prefix() {
672 let config = make_config(PathBuf::from("/view"));
674 let path = resolve_template_path("/layout", &config);
675 assert_eq!(path, PathBuf::from("/view/layout.html"));
676 }
677
678 #[test]
679 fn test_resolve_path_nested() {
680 let config = make_config(PathBuf::from("/view"));
681 let path = resolve_template_path("admin/layout", &config);
682 assert_eq!(path, PathBuf::from("/view/admin/layout.html"));
683 }
684
685 #[test]
686 fn test_resolve_path_with_colon() {
687 let config = make_config(PathBuf::from("/view"));
689 let path = resolve_template_path("admin:layout", &config);
690 assert_eq!(path, PathBuf::from("/view/admin/layout.html"));
691 }
692
693 #[test]
694 fn test_resolve_path_with_extension() {
695 let config = make_config(PathBuf::from("/view"));
697 let path = resolve_template_path("layout.tpl", &config);
698 assert_eq!(path, PathBuf::from("layout.tpl"));
699 }
700
701 #[test]
702 fn test_resolve_path_custom_view_depr() {
703 let config = ViewConfig {
705 view_path: PathBuf::from("/view"),
706 view_depr: ".".to_string(),
707 ..Default::default()
708 };
709 let path = resolve_template_path("admin/layout", &config);
710 assert_eq!(path, PathBuf::from("/view/admin.layout.html"));
711 }
712
713 #[test]
718 fn test_parse_attr_double_quote() {
719 let val = parse_attr(r#"name="custom""#, "name");
720 assert_eq!(val, Some("custom".to_string()));
721 }
722
723 #[test]
724 fn test_parse_attr_single_quote() {
725 let val = parse_attr(r#"name='custom'"#, "name");
726 assert_eq!(val, Some("custom".to_string()));
727 }
728
729 #[test]
730 fn test_parse_attr_missing() {
731 let val = parse_attr(r#"other="value""#, "name");
732 assert_eq!(val, None);
733 }
734
735 #[test]
736 fn test_parse_attr_with_spaces() {
737 let val = parse_attr(r#"name = "custom""#, "name");
738 assert_eq!(val, Some("custom".to_string()));
739 }
740
741 #[test]
742 fn test_parse_attr_multiple_attrs() {
743 let tag = r#"{layout name="custom" replace="{__BODY__}"}"#;
744 assert_eq!(parse_attr(tag, "name"), Some("custom".to_string()));
745 assert_eq!(parse_attr(tag, "replace"), Some("{__BODY__}".to_string()));
746 }
747
748 #[test]
753 fn test_empty_content() {
754 let dir = make_temp_dir();
755 let config = make_config(dir.clone());
756
757 let result = apply_layout("", &config).unwrap();
758 assert_eq!(result, "");
759
760 cleanup_dir(&dir);
761 }
762
763 #[test]
764 fn test_empty_content_with_layout_on() {
765 let dir = make_temp_dir();
766 write_template(&dir, "layout", "<html><body>{__CONTENT__}</body></html>");
767 let config = make_config_layout_on(dir.clone());
768
769 let result = apply_layout("", &config).unwrap();
770 assert_eq!(result, "<html><body></body></html>");
771
772 cleanup_dir(&dir);
773 }
774
775 #[test]
776 fn test_no_layout_tag_no_nolayout() {
777 let dir = make_temp_dir();
779 let config = make_config(dir.clone());
780
781 let result = apply_layout("<h1>Hello</h1>", &config).unwrap();
782 assert_eq!(result, "<h1>Hello</h1>");
783
784 cleanup_dir(&dir);
785 }
786
787 #[test]
788 fn test_layout_tag_not_first() {
789 let dir = make_temp_dir();
791 write_template(&dir, "custom", "<html><body>{__CONTENT__}</body></html>");
792 let config = make_config(dir.clone());
793
794 let result = apply_layout(r#"Hello {layout name="custom" /}World"#, &config).unwrap();
795 assert_eq!(result, "<html><body>Hello World</body></html>");
797
798 cleanup_dir(&dir);
799 }
800
801 #[test]
806 fn test_custom_taglib_delimiters() {
807 let dir = make_temp_dir();
809 write_template(&dir, "custom", "<html><body>{__CONTENT__}</body></html>");
810 let config = ViewConfig {
811 view_path: dir.clone(),
812 taglib_begin: "<".to_string(),
813 taglib_end: "/>".to_string(),
814 ..Default::default()
815 };
816
817 let result = apply_layout(r#"<layout name="custom" /><h1>Hello</h1>"#, &config).unwrap();
818 assert_eq!(result, "<html><body><h1>Hello</h1></body></html>");
819
820 cleanup_dir(&dir);
821 }
822
823 #[test]
828 fn test_layout_preserves_variable_tags() {
829 let dir = make_temp_dir();
831 write_template(
832 &dir,
833 "layout",
834 "<html><body>{__CONTENT__}</body><title>{$title}</title></html>",
835 );
836 let config = make_config_layout_on(dir.clone());
837
838 let result = apply_layout("<h1>{$name}</h1>", &config).unwrap();
839 assert_eq!(
841 result,
842 "<html><body><h1>{$name}</h1></body><title>{$title}</title></html>"
843 );
844
845 cleanup_dir(&dir);
846 }
847}