1use std::path::Path;
11use std::sync::LazyLock;
12
13use oxc_span::Span;
14
15use crate::asset_url::normalize_asset_url;
16use crate::sfc_template::angular;
17use crate::{
18 AngularTemplateMemberAccessFact, ImportInfo, ImportedName, MemberAccess, ModuleInfo,
19 SemanticFact,
20};
21use fallow_types::discover::FileId;
22
23static HTML_COMMENT_RE: LazyLock<regex::Regex> =
25 LazyLock::new(|| crate::static_regex(r"(?s)<!--.*?-->"));
26
27static SCRIPT_SRC_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
31 crate::static_regex(r#"(?si)<script\b(?:[^>"']|"[^"]*"|'[^']*')*?\bsrc\s*=\s*["']([^"']+)["']"#)
32});
33
34static LINK_HREF_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
38 crate::static_regex(
39 r#"(?si)<link\b(?:[^>"']|"[^"]*"|'[^']*')*?\brel\s*=\s*["'](stylesheet|modulepreload)["'](?:[^>"']|"[^"]*"|'[^']*')*?\bhref\s*=\s*["']([^"']+)["']"#,
40 )
41});
42
43static LINK_HREF_REVERSE_RE: LazyLock<regex::Regex> = LazyLock::new(|| {
45 crate::static_regex(
46 r#"(?si)<link\b(?:[^>"']|"[^"]*"|'[^']*')*?\bhref\s*=\s*["']([^"']+)["'](?:[^>"']|"[^"]*"|'[^']*')*?\brel\s*=\s*["'](stylesheet|modulepreload)["']"#,
47 )
48});
49
50pub(crate) fn is_html_file(path: &Path) -> bool {
52 path.extension()
53 .and_then(|e| e.to_str())
54 .is_some_and(|ext| ext == "html")
55}
56
57pub(crate) fn is_remote_url(src: &str) -> bool {
59 src.starts_with("http://")
60 || src.starts_with("https://")
61 || src.starts_with("//")
62 || src.starts_with("data:")
63}
64
65pub(crate) fn is_template_placeholder(value: &str) -> bool {
80 value.contains("{{") || value.contains("###")
81}
82
83pub(crate) fn collect_asset_refs(source: &str) -> Vec<String> {
90 let stripped = HTML_COMMENT_RE.replace_all(source, "");
91 let mut refs: Vec<String> = Vec::new();
92
93 for cap in SCRIPT_SRC_RE.captures_iter(&stripped) {
94 if let Some(m) = cap.get(1) {
95 let src = m.as_str().trim();
96 if !src.is_empty() && !is_remote_url(src) && !is_template_placeholder(src) {
97 refs.push(src.to_string());
98 }
99 }
100 }
101
102 for cap in LINK_HREF_RE.captures_iter(&stripped) {
103 if let Some(m) = cap.get(2) {
104 let href = m.as_str().trim();
105 if !href.is_empty() && !is_remote_url(href) && !is_template_placeholder(href) {
106 refs.push(href.to_string());
107 }
108 }
109 }
110 for cap in LINK_HREF_REVERSE_RE.captures_iter(&stripped) {
111 if let Some(m) = cap.get(1) {
112 let href = m.as_str().trim();
113 if !href.is_empty() && !is_remote_url(href) && !is_template_placeholder(href) {
114 refs.push(href.to_string());
115 }
116 }
117 }
118
119 refs
120}
121
122static CUSTOM_ELEMENT_TAG_RE: std::sync::LazyLock<regex::Regex> =
127 std::sync::LazyLock::new(|| crate::static_regex(r"</?\s*([a-z][a-z0-9]*-[a-z0-9-]*)"));
128
129pub(crate) fn collect_custom_element_tags(source: &str) -> Vec<String> {
135 let stripped = HTML_COMMENT_RE.replace_all(source, "");
136 let mut tags: Vec<String> = Vec::new();
137 for cap in CUSTOM_ELEMENT_TAG_RE.captures_iter(&stripped) {
138 if let Some(m) = cap.get(1) {
139 let tag = m.as_str();
140 if !tags.iter().any(|t| t == tag) {
141 tags.push(tag.to_string());
142 }
143 }
144 }
145 tags
146}
147
148#[cfg(test)]
150pub(crate) fn parse_html_to_module(file_id: FileId, source: &str, content_hash: u64) -> ModuleInfo {
151 parse_html_to_module_with_complexity(file_id, source, content_hash, false)
152}
153
154struct HtmlModuleParts {
157 imports: Vec<ImportInfo>,
158 member_accesses: Vec<MemberAccess>,
159 semantic_facts: Vec<SemanticFact>,
160 security_sinks: Vec<fallow_types::extract::SinkSite>,
161 angular_used_selectors: Vec<String>,
162 has_dynamic_component_render: bool,
163 complexity: Vec<fallow_types::extract::FunctionComplexity>,
164}
165
166fn collect_html_module_parts(source: &str, need_complexity: bool) -> HtmlModuleParts {
170 let mut imports: Vec<ImportInfo> = collect_asset_refs(source)
171 .into_iter()
172 .map(|raw| ImportInfo {
173 source: normalize_asset_url(&raw),
174 imported_name: ImportedName::SideEffect,
175 local_name: String::new(),
176 is_type_only: false,
177 from_style: false,
178 span: Span::default(),
179 source_span: Span::default(),
180 })
181 .collect();
182
183 imports.sort_unstable_by(|a, b| a.source.cmp(&b.source));
184 imports.dedup_by(|a, b| a.source == b.source);
185
186 let angular::AngularTemplateRefs {
187 identifiers,
188 member_accesses: template_member_accesses,
189 security_sinks,
190 } = angular::collect_angular_template_refs(source);
191 let identifiers: Vec<String> = identifiers.into_iter().collect();
192 let semantic_facts: Vec<SemanticFact> = identifiers
193 .iter()
194 .cloned()
195 .map(|member| {
196 SemanticFact::AngularTemplateMemberAccess(AngularTemplateMemberAccessFact { member })
197 })
198 .collect();
199 let member_accesses = template_member_accesses;
200
201 let angular_used_selectors = angular::collect_angular_used_selectors(source);
206 let has_dynamic_component_render = source.contains("ngComponentOutlet");
207
208 let complexity = if need_complexity {
209 crate::template_complexity::compute_angular_template_complexity(source)
210 .into_iter()
211 .collect()
212 } else {
213 Vec::new()
214 };
215
216 HtmlModuleParts {
217 imports,
218 member_accesses,
219 semantic_facts,
220 security_sinks,
221 angular_used_selectors,
222 has_dynamic_component_render,
223 complexity,
224 }
225}
226
227pub(crate) fn parse_html_to_module_with_complexity(
229 file_id: FileId,
230 source: &str,
231 content_hash: u64,
232 need_complexity: bool,
233) -> ModuleInfo {
234 let parsed_suppressions = crate::suppress::parse_suppressions_from_source(source);
235 let parts = collect_html_module_parts(source, need_complexity);
236 html_module_info(file_id, content_hash, source, parsed_suppressions, parts)
237}
238
239fn html_module_info(
243 file_id: FileId,
244 content_hash: u64,
245 source: &str,
246 parsed_suppressions: crate::suppress::ParsedSuppressions,
247 parts: HtmlModuleParts,
248) -> ModuleInfo {
249 let HtmlModuleParts {
250 imports,
251 member_accesses,
252 semantic_facts,
253 security_sinks,
254 angular_used_selectors,
255 has_dynamic_component_render,
256 complexity,
257 } = parts;
258
259 ModuleInfo {
260 file_id,
261 exports: Vec::new(),
262 imports,
263 re_exports: Vec::new(),
264 dynamic_imports: Vec::new(),
265 dynamic_import_patterns: Vec::new(),
266 require_calls: Vec::new(),
267 package_path_references: Box::default(),
268 member_accesses,
269 semantic_facts: semantic_facts.into(),
270 whole_object_uses: Box::default(),
271 has_cjs_exports: false,
272 has_angular_component_template_url: false,
273 content_hash,
274 suppressions: parsed_suppressions.suppressions,
275 unknown_suppression_kinds: parsed_suppressions.unknown_kinds,
276 unused_import_bindings: Vec::new(),
277 type_referenced_import_bindings: Vec::new(),
278 value_referenced_import_bindings: Vec::new(),
279 line_offsets: fallow_types::extract::compute_line_offsets(source),
280 complexity,
281 flag_uses: Vec::new(),
282 class_heritage: vec![],
283 exported_factory_returns: Box::default(),
284 exported_factory_return_object_shapes: Box::default(),
285 type_member_types: Box::default(),
286 injection_tokens: vec![],
287 local_type_declarations: Vec::new(),
288 public_signature_type_references: Vec::new(),
289 namespace_object_aliases: Vec::new(),
290 iconify_prefixes: Vec::new(),
291 iconify_icon_names: Vec::new(),
292 auto_import_candidates: Vec::new(),
293 directives: Vec::new(),
294 client_only_dynamic_import_spans: Vec::new(),
295 security_sinks,
296 security_sinks_skipped: 0,
297 security_unresolved_callee_sites: Vec::new(),
298 tainted_bindings: Vec::new(),
299 sanitized_sink_args: Vec::new(),
300 security_control_sites: Vec::new(),
301 callee_uses: Vec::new(),
302 misplaced_directives: Vec::new(),
303 inline_server_action_exports: Vec::new(),
304 di_key_sites: Vec::new(),
305 has_dynamic_provide: false,
306 referenced_import_bindings: Vec::new(),
307 component_props: Vec::new(),
308 has_props_attrs_fallthrough: false,
309 has_define_expose: false,
310 has_define_model: false,
311 has_unharvestable_props: false,
312 component_emits: Vec::new(),
313 angular_inputs: Vec::new(),
314 angular_outputs: Vec::new(),
315 angular_component_selectors: Vec::new(),
316 registered_custom_elements: Vec::new(),
317 used_custom_element_tags: collect_custom_element_tags(source),
322 angular_used_selectors,
323 angular_entry_component_refs: Vec::new(),
324 has_dynamic_component_render,
325 has_unharvestable_emits: false,
326 has_dynamic_emit: false,
327 has_emit_whole_object_use: false,
328 load_return_keys: Vec::new(),
329 has_unharvestable_load: false,
330 has_load_data_whole_use: false,
331 has_page_data_store_whole_use: false,
332 has_route_loader_data_whole_use: false,
333 component_functions: Vec::new(),
334 react_props: Vec::new(),
335 hook_uses: Vec::new(),
336 render_edges: Vec::new(),
337 svelte_dispatched_events: Vec::new(),
338 svelte_listened_events: Vec::new(),
339 has_dynamic_dispatch: false,
340 }
341}
342
343#[cfg(test)]
344mod tests {
345 use super::*;
346
347 #[test]
348 fn is_html_file_html() {
349 assert!(is_html_file(Path::new("index.html")));
350 }
351
352 #[test]
353 fn is_html_file_nested() {
354 assert!(is_html_file(Path::new("pages/about.html")));
355 }
356
357 #[test]
358 fn is_html_file_rejects_htm() {
359 assert!(!is_html_file(Path::new("index.htm")));
360 }
361
362 #[test]
363 fn is_html_file_rejects_js() {
364 assert!(!is_html_file(Path::new("app.js")));
365 }
366
367 #[test]
368 fn is_html_file_rejects_ts() {
369 assert!(!is_html_file(Path::new("app.ts")));
370 }
371
372 #[test]
373 fn is_html_file_rejects_vue() {
374 assert!(!is_html_file(Path::new("App.vue")));
375 }
376
377 #[test]
378 fn remote_url_http() {
379 assert!(is_remote_url("http://example.com/script.js"));
380 }
381
382 #[test]
383 fn remote_url_https() {
384 assert!(is_remote_url("https://cdn.example.com/style.css"));
385 }
386
387 #[test]
388 fn remote_url_protocol_relative() {
389 assert!(is_remote_url("//cdn.example.com/lib.js"));
390 }
391
392 #[test]
393 fn remote_url_data() {
394 assert!(is_remote_url("data:text/javascript;base64,abc"));
395 }
396
397 #[test]
398 fn local_relative_not_remote() {
399 assert!(!is_remote_url("./src/entry.js"));
400 }
401
402 #[test]
403 fn local_root_relative_not_remote() {
404 assert!(!is_remote_url("/src/entry.js"));
405 }
406
407 #[test]
408 fn extracts_module_script_src() {
409 let info = parse_html_to_module(
410 FileId(0),
411 r#"<script type="module" src="./src/entry.js"></script>"#,
412 0,
413 );
414 assert_eq!(info.imports.len(), 1);
415 assert_eq!(info.imports[0].source, "./src/entry.js");
416 }
417
418 #[test]
419 fn extracts_plain_script_src() {
420 let info = parse_html_to_module(
421 FileId(0),
422 r#"<script src="./src/polyfills.js"></script>"#,
423 0,
424 );
425 assert_eq!(info.imports.len(), 1);
426 assert_eq!(info.imports[0].source, "./src/polyfills.js");
427 }
428
429 #[test]
430 fn extracts_multiple_scripts() {
431 let info = parse_html_to_module(
432 FileId(0),
433 r#"
434 <script type="module" src="./src/entry.js"></script>
435 <script src="./src/polyfills.js"></script>
436 "#,
437 0,
438 );
439 assert_eq!(info.imports.len(), 2);
440 }
441
442 #[test]
443 fn skips_inline_script() {
444 let info = parse_html_to_module(FileId(0), r#"<script>console.log("hello");</script>"#, 0);
445 assert!(info.imports.is_empty());
446 }
447
448 #[test]
449 fn skips_handlebars_placeholder_in_script_src() {
450 let info = parse_html_to_module(
451 FileId(0),
452 r#"<script src="{{rootURL}}assets/app.js"></script>
453 <script src="{{config.assetsPath}}vendor.js"></script>"#,
454 0,
455 );
456 assert!(
457 info.imports.is_empty(),
458 "Handlebars-placeholder script srcs should not enter the import graph; got {:?}",
459 info.imports
460 );
461 }
462
463 #[test]
464 fn skips_handlebars_placeholder_in_link_href() {
465 let info = parse_html_to_module(
466 FileId(0),
467 r#"<link rel="stylesheet" href="{{rootURL}}assets/app.css">"#,
468 0,
469 );
470 assert!(info.imports.is_empty());
471 }
472
473 #[test]
474 fn skips_ember_cli_blueprint_placeholder() {
475 let info = parse_html_to_module(
476 FileId(0),
477 r####"<script src="###APPNAME###/app.js"></script>"####,
478 0,
479 );
480 assert!(info.imports.is_empty());
481 }
482
483 #[test]
484 fn extracts_normal_specifier_alongside_placeholders() {
485 let info = parse_html_to_module(
486 FileId(0),
487 r#"<script src="{{rootURL}}assets/app.js"></script>
488 <script src="./src/main.ts"></script>"#,
489 0,
490 );
491 assert_eq!(info.imports.len(), 1);
492 assert_eq!(info.imports[0].source, "./src/main.ts");
493 }
494
495 #[test]
496 fn skips_remote_script() {
497 let info = parse_html_to_module(
498 FileId(0),
499 r#"<script src="https://cdn.example.com/lib.js"></script>"#,
500 0,
501 );
502 assert!(info.imports.is_empty());
503 }
504
505 #[test]
506 fn skips_protocol_relative_script() {
507 let info = parse_html_to_module(
508 FileId(0),
509 r#"<script src="//cdn.example.com/lib.js"></script>"#,
510 0,
511 );
512 assert!(info.imports.is_empty());
513 }
514
515 #[test]
516 fn extracts_stylesheet_link() {
517 let info = parse_html_to_module(
518 FileId(0),
519 r#"<link rel="stylesheet" href="./src/global.css" />"#,
520 0,
521 );
522 assert_eq!(info.imports.len(), 1);
523 assert_eq!(info.imports[0].source, "./src/global.css");
524 }
525
526 #[test]
527 fn extracts_modulepreload_link() {
528 let info = parse_html_to_module(
529 FileId(0),
530 r#"<link rel="modulepreload" href="./src/vendor.js" />"#,
531 0,
532 );
533 assert_eq!(info.imports.len(), 1);
534 assert_eq!(info.imports[0].source, "./src/vendor.js");
535 }
536
537 #[test]
538 fn extracts_link_with_reversed_attrs() {
539 let info = parse_html_to_module(
540 FileId(0),
541 r#"<link href="./src/global.css" rel="stylesheet" />"#,
542 0,
543 );
544 assert_eq!(info.imports.len(), 1);
545 assert_eq!(info.imports[0].source, "./src/global.css");
546 }
547
548 #[test]
549 fn bare_script_src_normalized_to_relative() {
550 let info = parse_html_to_module(FileId(0), r#"<script src="app.js"></script>"#, 0);
551 assert_eq!(info.imports.len(), 1);
552 assert_eq!(info.imports[0].source, "./app.js");
553 }
554
555 #[test]
556 fn bare_module_script_src_normalized_to_relative() {
557 let info = parse_html_to_module(
558 FileId(0),
559 r#"<script type="module" src="main.ts"></script>"#,
560 0,
561 );
562 assert_eq!(info.imports.len(), 1);
563 assert_eq!(info.imports[0].source, "./main.ts");
564 }
565
566 #[test]
567 fn bare_stylesheet_link_href_normalized_to_relative() {
568 let info = parse_html_to_module(
569 FileId(0),
570 r#"<link rel="stylesheet" href="styles.css" />"#,
571 0,
572 );
573 assert_eq!(info.imports.len(), 1);
574 assert_eq!(info.imports[0].source, "./styles.css");
575 }
576
577 #[test]
578 fn bare_link_href_reversed_attrs_normalized_to_relative() {
579 let info = parse_html_to_module(
580 FileId(0),
581 r#"<link href="styles.css" rel="stylesheet" />"#,
582 0,
583 );
584 assert_eq!(info.imports.len(), 1);
585 assert_eq!(info.imports[0].source, "./styles.css");
586 }
587
588 #[test]
589 fn bare_modulepreload_link_href_normalized_to_relative() {
590 let info = parse_html_to_module(
591 FileId(0),
592 r#"<link rel="modulepreload" href="vendor.js" />"#,
593 0,
594 );
595 assert_eq!(info.imports.len(), 1);
596 assert_eq!(info.imports[0].source, "./vendor.js");
597 }
598
599 #[test]
600 fn bare_asset_with_subdir_normalized_to_relative() {
601 let info = parse_html_to_module(FileId(0), r#"<script src="assets/app.js"></script>"#, 0);
602 assert_eq!(info.imports.len(), 1);
603 assert_eq!(info.imports[0].source, "./assets/app.js");
604 }
605
606 #[test]
607 fn root_absolute_script_src_unchanged() {
608 let info = parse_html_to_module(FileId(0), r#"<script src="/src/main.ts"></script>"#, 0);
609 assert_eq!(info.imports.len(), 1);
610 assert_eq!(info.imports[0].source, "/src/main.ts");
611 }
612
613 #[test]
614 fn parent_relative_script_src_unchanged() {
615 let info = parse_html_to_module(
616 FileId(0),
617 r#"<script src="../shared/vendor.js"></script>"#,
618 0,
619 );
620 assert_eq!(info.imports.len(), 1);
621 assert_eq!(info.imports[0].source, "../shared/vendor.js");
622 }
623
624 #[test]
625 fn skips_preload_link() {
626 let info = parse_html_to_module(
627 FileId(0),
628 r#"<link rel="preload" href="./src/font.woff2" as="font" />"#,
629 0,
630 );
631 assert!(info.imports.is_empty());
632 }
633
634 #[test]
635 fn skips_icon_link() {
636 let info =
637 parse_html_to_module(FileId(0), r#"<link rel="icon" href="./favicon.ico" />"#, 0);
638 assert!(info.imports.is_empty());
639 }
640
641 #[test]
642 fn skips_remote_stylesheet() {
643 let info = parse_html_to_module(
644 FileId(0),
645 r#"<link rel="stylesheet" href="https://fonts.googleapis.com/css" />"#,
646 0,
647 );
648 assert!(info.imports.is_empty());
649 }
650
651 #[test]
652 fn skips_commented_out_script() {
653 let info = parse_html_to_module(
654 FileId(0),
655 r#"<!-- <script src="./old.js"></script> -->
656 <script src="./new.js"></script>"#,
657 0,
658 );
659 assert_eq!(info.imports.len(), 1);
660 assert_eq!(info.imports[0].source, "./new.js");
661 }
662
663 #[test]
664 fn skips_commented_out_link() {
665 let info = parse_html_to_module(
666 FileId(0),
667 r#"<!-- <link rel="stylesheet" href="./old.css" /> -->
668 <link rel="stylesheet" href="./new.css" />"#,
669 0,
670 );
671 assert_eq!(info.imports.len(), 1);
672 assert_eq!(info.imports[0].source, "./new.css");
673 }
674
675 #[test]
676 fn handles_multiline_script_tag() {
677 let info = parse_html_to_module(
678 FileId(0),
679 "<script\n type=\"module\"\n src=\"./src/entry.js\"\n></script>",
680 0,
681 );
682 assert_eq!(info.imports.len(), 1);
683 assert_eq!(info.imports[0].source, "./src/entry.js");
684 }
685
686 #[test]
687 fn handles_multiline_link_tag() {
688 let info = parse_html_to_module(
689 FileId(0),
690 "<link\n rel=\"stylesheet\"\n href=\"./src/global.css\"\n/>",
691 0,
692 );
693 assert_eq!(info.imports.len(), 1);
694 assert_eq!(info.imports[0].source, "./src/global.css");
695 }
696
697 #[test]
698 fn full_vite_html() {
699 let info = parse_html_to_module(
700 FileId(0),
701 r#"<!doctype html>
702<html>
703 <head>
704 <link rel="stylesheet" href="./src/global.css" />
705 <link rel="icon" href="/favicon.ico" />
706 </head>
707 <body>
708 <div id="app"></div>
709 <script type="module" src="./src/entry.js"></script>
710 </body>
711</html>"#,
712 0,
713 );
714 assert_eq!(info.imports.len(), 2);
715 let sources: Vec<&str> = info.imports.iter().map(|i| i.source.as_str()).collect();
716 assert!(sources.contains(&"./src/global.css"));
717 assert!(sources.contains(&"./src/entry.js"));
718 }
719
720 #[test]
721 fn empty_html() {
722 let info = parse_html_to_module(FileId(0), "", 0);
723 assert!(info.imports.is_empty());
724 }
725
726 #[test]
727 fn html_with_no_assets() {
728 let info = parse_html_to_module(
729 FileId(0),
730 r"<!doctype html><html><body><h1>Hello</h1></body></html>",
731 0,
732 );
733 assert!(info.imports.is_empty());
734 }
735
736 #[test]
737 fn single_quoted_attributes() {
738 let info = parse_html_to_module(FileId(0), r"<script src='./src/entry.js'></script>", 0);
739 assert_eq!(info.imports.len(), 1);
740 assert_eq!(info.imports[0].source, "./src/entry.js");
741 }
742
743 #[test]
744 fn all_imports_are_side_effect() {
745 let info = parse_html_to_module(
746 FileId(0),
747 r#"<script src="./entry.js"></script>
748 <link rel="stylesheet" href="./style.css" />"#,
749 0,
750 );
751 for imp in &info.imports {
752 assert!(matches!(imp.imported_name, ImportedName::SideEffect));
753 assert!(imp.local_name.is_empty());
754 assert!(!imp.is_type_only);
755 }
756 }
757
758 #[test]
759 fn suppression_comments_extracted() {
760 let info = parse_html_to_module(
761 FileId(0),
762 "<!-- fallow-ignore-file -->\n<script src=\"./entry.js\"></script>",
763 0,
764 );
765 assert_eq!(info.imports.len(), 1);
766 }
767
768 #[test]
769 fn angular_template_extracts_member_refs() {
770 let info = parse_html_to_module(
771 FileId(0),
772 "<h1>{{ title() }}</h1>\n\
773 <p [class.highlighted]=\"isHighlighted\">{{ greeting() }}</p>\n\
774 <button (click)=\"onButtonClick()\">Toggle</button>",
775 0,
776 );
777 let fact_names: rustc_hash::FxHashSet<&str> = info
778 .semantic_facts
779 .iter()
780 .filter_map(|fact| {
781 if let SemanticFact::AngularTemplateMemberAccess(access) = fact {
782 Some(access.member.as_str())
783 } else {
784 None
785 }
786 })
787 .collect();
788 assert!(fact_names.contains("title"), "should contain 'title'");
789 assert!(
790 fact_names.contains("isHighlighted"),
791 "should contain 'isHighlighted'"
792 );
793 assert!(fact_names.contains("greeting"), "should contain 'greeting'");
794 assert!(
795 fact_names.contains("onButtonClick"),
796 "should contain 'onButtonClick'"
797 );
798 assert!(
799 info.member_accesses.is_empty(),
800 "Angular template refs should emit typed facts instead of member accesses: {:?}",
801 info.member_accesses
802 );
803 }
804
805 #[test]
806 fn plain_html_no_angular_refs() {
807 let info = parse_html_to_module(
808 FileId(0),
809 "<!doctype html><html><body><h1>Hello</h1></body></html>",
810 0,
811 );
812 assert!(info.member_accesses.is_empty());
813 }
814}