1mod html;
2mod utils;
3
4use std::{borrow::Cow, path::Path, pin::Pin, rc::Rc, sync::Arc};
5
6use cow_utils::CowUtils as _;
7use html5gum::Span;
8use oxc::ast_visit::Visit;
9use rolldown_common::side_effects::HookSideEffects;
10use rolldown_plugin::{HookTransformOutput, HookUsage, LogWithoutPlugin, Plugin};
11use rolldown_plugin_utils::{
12 AssetUrlResult, RenderBuiltUrl, ToOutputFilePathEnv, UsizeOrFunction,
13 constants::{CSSBundleName, HTMLProxyMapItem},
14 partial_encode_url_path,
15};
16use rolldown_utils::{dashmap::FxDashMap, pattern_filter::normalize_path};
17use rustc_hash::{FxHashMap, FxHashSet};
18use sugar_path::SugarPath as _;
19
20use crate::utils::{
21 get_css_files_for_chunk,
22 html_tag::{AttrValue, HtmlTagDescriptor},
23 inject_to_head,
24};
25
26pub type ResolveDependencies = dyn Fn(
27 &str,
28 Vec<String>,
29 &str,
30 &str,
31 ) -> Pin<Box<dyn Future<Output = anyhow::Result<Vec<String>>> + Send>>
32 + Send
33 + Sync;
34
35pub enum ResolveDependenciesEither {
36 True,
37 Fn(Arc<ResolveDependencies>),
38}
39
40#[expect(clippy::struct_excessive_bools)]
41#[derive(derive_more::Debug, Default)]
42pub struct ViteHtmlPlugin {
43 pub is_lib: bool,
44 pub is_ssr: bool,
45 pub url_base: String,
46 pub public_dir: String,
47 pub decoded_base: String,
48 pub css_code_split: bool,
49 pub module_preload_polyfill: bool,
50 #[debug(skip)]
51 pub asset_inline_limit: UsizeOrFunction,
52 #[debug(skip)]
53 pub render_built_url: Option<Arc<RenderBuiltUrl>>,
54 #[debug(skip)]
55 pub resolve_dependencies: Option<ResolveDependenciesEither>,
56 html_result_map: FxDashMap<(String, String), (String, bool)>,
57}
58
59impl Plugin for ViteHtmlPlugin {
60 fn name(&self) -> Cow<'static, str> {
61 Cow::Borrowed("builtin:vite-html")
62 }
63
64 fn register_hook_usage(&self) -> rolldown_plugin::HookUsage {
65 HookUsage::BuildStart | HookUsage::Transform | HookUsage::GenerateBundle
66 }
67
68 async fn build_start(
69 &self,
70 _ctx: &rolldown_plugin::PluginContext,
71 _args: &rolldown_plugin::HookBuildStartArgs<'_>,
72 ) -> rolldown_plugin::HookNoopReturn {
73 self.html_result_map.clear();
74 Ok(())
75 }
76
77 #[expect(clippy::too_many_lines)]
78 async fn transform(
79 &self,
80 ctx: rolldown_plugin::SharedTransformPluginContext,
81 args: &rolldown_plugin::HookTransformArgs<'_>,
82 ) -> rolldown_plugin::HookTransformReturn {
83 if !args.id.ends_with(".html") {
84 return Ok(None);
85 }
86
87 let id = normalize_path(args.id);
88 let path = args.id.relative(ctx.cwd());
89 let path_lossy = path.to_string_lossy();
90 let relative_url_path = normalize_path(&path_lossy);
91
92 let public_path = rolldown_utils::concat_string!("/", relative_url_path);
93 let public_base = self.get_base_in_html(&relative_url_path);
94 let public_to_relative = |filename: &Path, _: &Path| {
95 AssetUrlResult::WithoutRuntime(rolldown_utils::concat_string!(
96 &public_base,
97 filename.to_string_lossy()
98 ))
99 };
100 let env = ToOutputFilePathEnv {
101 is_ssr: self.is_ssr,
102 host_id: &relative_url_path,
103 url_base: &self.url_base,
104 decoded_base: &self.decoded_base,
105 render_built_url: self.render_built_url.as_deref(),
106 };
107
108 let mut js = String::new();
109 let mut inline_module_count = 0usize;
110 let mut every_script_is_async = true;
111 let mut some_scripts_are_async = false;
112 let mut some_scripts_are_defer = false;
113
114 let mut style_urls = Vec::new();
115 let mut script_urls = Vec::new();
116
117 let mut src_tasks = Vec::new();
120 let mut srcset_tasks = Vec::new();
121 let mut overwrite_attrs = Vec::new();
122 let mut s = string_wizard::MagicString::new(args.code);
123
124 {
126 let dom = html::parser::parse_html(args.code);
127 let mut stack = vec![dom.document];
128 while let Some(node) = stack.pop() {
129 match &node.data {
130 html::sink::NodeData::Element { name, attrs, span } => {
131 let mut should_remove = false;
132 if &**name == "script" {
133 let mut src = None;
134 let mut is_async = false;
135 let mut is_module = false;
136 let mut is_ignored = false;
137 for attr in attrs.borrow().iter() {
138 match &*attr.name {
139 "src" => {
140 if src.is_none() {
141 src = Some((attr.value.clone(), attr.span));
142 }
143 }
144 "type" if attr.value == "module" => {
145 is_module = true;
146 }
147 "async" => {
148 is_async = true;
149 }
150 "vite-ignore" => {
151 is_ignored = true;
152 s.remove(attr.span.start, attr.span.end);
153 }
154 _ => {}
155 }
156 }
157 if !is_ignored {
158 let is_public_file = src.as_ref().is_some_and(|(s, _)| {
159 rolldown_plugin_utils::check_public_file(s, &self.public_dir).is_some()
160 });
161 if is_public_file && let Some((ref url, span)) = src {
162 overwrite_attrs.push((url[1..].to_owned(), span));
163 }
164 if is_module {
165 inline_module_count += 1;
166 if let Some((url, _)) = src.as_ref()
167 && !is_public_file
168 && !utils::is_excluded_url(url)
169 {
170 js.push_str(&rolldown_utils::concat_string!(
174 "import ",
175 rolldown_plugin_utils::to_string_literal(url),
176 "\n"
177 ));
178 should_remove = true;
179 } else if let Some(node) = node.children.borrow_mut().pop() {
180 let html::sink::NodeData::Text { contents, .. } = &node.data else {
181 panic!("Expected text node but received: {:#?}", node.data);
182 };
183 self.add_to_html_proxy_cache(
184 &ctx,
185 public_path.clone(),
186 inline_module_count - 1,
187 HTMLProxyMapItem { code: contents.into(), map: None },
188 );
189 js.push_str(&rolldown_utils::concat_string!(
190 "import \"",
191 id,
192 "?html-proxy&index=",
193 itoa::Buffer::new().format(inline_module_count - 1),
194 ".js\"\n"
195 ));
196 should_remove = true;
197 }
198 every_script_is_async = every_script_is_async && is_async;
199 some_scripts_are_async = some_scripts_are_async || is_async;
200 some_scripts_are_defer = some_scripts_are_defer || !is_async;
201 } else if let Some((url, _)) = src.as_ref()
202 && !is_public_file
203 {
204 if !utils::is_excluded_url(url) {
205 let message = rolldown_utils::concat_string!(
206 "<script src='",
207 url,
208 "'> in '",
209 public_path,
210 "' can't be bundled without type='module' attribute"
211 );
212 ctx.warn(LogWithoutPlugin { message, ..Default::default() });
213 }
214 } else if let Some(node) = node.children.borrow_mut().pop() {
215 let html::sink::NodeData::Text { contents, span } = &node.data else {
216 panic!("Expected text node but received: {:#?}", node.data);
217 };
218 if utils::constant::INLINE_IMPORT.is_match(contents) {
219 let allocator = oxc::allocator::Allocator::default();
220 let parser_ret = oxc::parser::Parser::new(
221 &allocator,
222 contents,
223 oxc::span::SourceType::default(),
224 )
225 .parse();
226 if parser_ret.panicked
227 && let Some(err) = parser_ret
228 .errors
229 .iter()
230 .find(|e| e.severity == oxc::diagnostics::Severity::Error)
231 {
232 return Err(anyhow::anyhow!(format!(
233 "Failed to parse inline script in '{}': {:?}",
234 public_path, err.message
235 )));
236 }
237 let mut visitor = utils::ScriptInlineImportVisitor {
238 offset: span.start,
239 script_urls: &mut script_urls,
240 };
241 visitor.visit_program(&parser_ret.program);
242 }
243 }
244 }
245 }
246
247 if matches!(
249 &**name,
250 "audio"
251 | "embed"
252 | "img"
253 | "image"
254 | "input"
255 | "link"
256 | "meta"
257 | "object"
258 | "source"
259 | "track"
260 | "use"
261 | "video"
262 ) {
263 let attrs_borrowed = attrs.borrow();
264 if let Some(attr) = attrs_borrowed.iter().find(|a| &*a.name == "vite-ignore") {
265 s.remove(attr.span.start, attr.span.end);
266 } else {
267 let attr_map = attrs_borrowed
269 .iter()
270 .filter_map(|a| (!a.value.is_empty()).then_some((a.name.as_ref(), a)))
271 .collect::<FxHashMap<_, _>>();
272
273 let (src_attrs, srcset_attrs): (&[&str], &[&str]) = match &**name {
275 "audio" | "embed" | "input" | "track" => (&["src"], &[]),
276 "img" | "source" => (&["src"], &["srcset"]),
277 "image" | "use" => (&["href", "xlink:href"], &[]),
278 "link" => (&["href"], &["imagesrcset"]),
279 "meta" => (&["content"], &[]),
280 "object" => (&["data"], &[]),
281 "video" => (&["src", "poster"], &[]),
282 _ => unreachable!("Element type should be matched in outer condition"),
283 };
284
285 for srcset_attr in srcset_attrs {
287 if let Some(attr) = attr_map.get(srcset_attr) {
288 srcset_tasks.push((attr.value.clone(), attr.span));
289 }
290 }
291
292 for src_attr in src_attrs {
294 if let Some(attr) = attr_map.get(src_attr) {
295 let decode_url =
296 rolldown_plugin_utils::uri::decode_uri(&attr.value).into_owned();
297 if rolldown_plugin_utils::check_public_file(&decode_url, &self.public_dir)
298 .is_some()
299 {
300 overwrite_attrs.push((decode_url, attr.span));
301 } else if !utils::is_excluded_url(&decode_url) {
302 if &**name == "link"
303 && rolldown_plugin_utils::css::is_css_request(&decode_url)
304 && !(attr_map.contains_key("media") || attr_map.contains_key("disabled"))
305 {
306 js.push_str("import ");
307 js.push_str(&rolldown_plugin_utils::to_string_literal(&decode_url));
308 js.push_str(";\n");
309 style_urls.push((decode_url, attr.span));
310 }
311 } else {
312 let should_inline = (&**name == "link"
313 && attr_map.get("rel").is_some_and(|attr| {
314 utils::parse_rel_attr(&attr.value).into_iter().any(|v| {
315 ["icon", "apple-touch-icon", "apple-touch-startup-image", "manifest"]
316 .contains(&v.as_str())
317 })
318 }))
319 .then_some(false);
320 src_tasks.push((decode_url, attr.span, should_inline));
321 }
322 }
323 }
324 }
325 }
326
327 if let Some(attr) = attrs.borrow().iter().find(|a| {
329 &*a.name == "style" && (a.value.contains("url(") || a.value.contains("image-set("))
330 }) {
331 self.handle_style_tag_or_attribute(
332 &mut s,
333 &mut js,
334 &id,
335 &ctx,
336 public_path.clone(),
337 &mut inline_module_count,
338 true,
339 (attr.value.as_str(), attr.span),
340 )?;
341 }
342
343 if &**name == "style"
345 && let Some(node) = node.children.borrow_mut().pop()
346 {
347 let html::sink::NodeData::Text { ref contents, span } = node.data else {
348 panic!("Expected text node but received: {:#?}", node.data);
349 };
350 self.handle_style_tag_or_attribute(
351 &mut s,
352 &mut js,
353 &id,
354 &ctx,
355 public_path.clone(),
356 &mut inline_module_count,
357 false,
358 (contents, span),
359 )?;
360 }
361
362 if should_remove {
363 s.remove(span.start, span.end);
364 }
365 }
366 _ => {}
367 }
368 for child in node.children.borrow().iter() {
369 stack.push(Rc::clone(child));
370 }
371 }
372 }
373
374 for (url, span, should_inline) in src_tasks {
375 let processed_encoded_url = self.process_asset_url(&ctx, &url, &id, should_inline).await?;
376 if processed_encoded_url != url {
377 overwrite_attrs.push((processed_encoded_url.into_owned(), span));
378 }
379 }
380
381 for (task, span) in srcset_tasks {
382 let processed_encoded_url = self.process_src_set(&ctx, &task, &id).await?;
383 if processed_encoded_url != task {
384 overwrite_attrs.push((processed_encoded_url, span));
385 }
386 }
387
388 for (url, span) in overwrite_attrs {
389 let asset_url = env.to_output_file_path(&url, "html", true, public_to_relative).await?;
390 utils::overwrite_check_public_file(
391 &mut s,
392 span.start..span.end,
393 partial_encode_url_path(&asset_url.to_asset_url_in_css_or_html()).into_owned(),
394 )?;
395 }
396
397 if some_scripts_are_async && some_scripts_are_defer {
398 let message = rolldown_utils::concat_string!(
399 "\nMixed async and defer script modules in ",
400 id,
401 ", output script will fallback to defer. Every script, including inline ones, need to be marked as async for your output script to be async."
402 );
403 ctx.warn(LogWithoutPlugin { message, ..Default::default() });
404 }
405
406 for (url, range) in script_urls {
407 let url = if rolldown_plugin_utils::check_public_file(&url, &self.public_dir).is_some() {
408 env
409 .to_output_file_path(&url, "html", true, public_to_relative)
410 .await?
411 .to_asset_url_in_css_or_html()
412 } else if !utils::is_excluded_url(&url) {
413 self.url_to_built_url(&ctx, &url, &id, None).await?
414 } else {
415 continue;
416 };
417 utils::overwrite_check_public_file(
418 &mut s,
419 range,
420 partial_encode_url_path(&url).into_owned(),
421 )?;
422 }
423
424 let resolved_style_urls = rolldown_utils::futures::block_on_spawn_all(
425 style_urls.into_iter().map(async |(url, range): (String, Span)| {
426 let resolved = ctx.resolve(&url, Some(&id), None).await;
427 (url, range, resolved)
428 }),
429 )
430 .await;
431
432 for (url, span, resolved) in resolved_style_urls {
433 match resolved?.ok() {
434 Some(_) => {
435 s.remove(span.start, span.end);
436 }
437 None => {
438 ctx.warn(LogWithoutPlugin {
439 message: format!("\n{url} doesn't exist at build time, it will remain unchanged to be resolved at runtime"),
440 ..Default::default()
441 });
442 js = js
443 .cow_replace(
444 &rolldown_utils::concat_string!(
445 "import ",
446 rolldown_plugin_utils::to_string_literal(&url),
447 "\n"
448 ),
449 "",
450 )
451 .into_owned();
452 }
453 }
454 }
455
456 self
457 .html_result_map
458 .insert((args.id.to_string(), public_path), (s.to_string(), every_script_is_async));
459
460 if self.module_preload_polyfill && (some_scripts_are_async || some_scripts_are_defer) {
461 js = rolldown_utils::concat_string!(
462 "import \"",
463 utils::constant::MODULE_PRELOAD_POLYFILL,
464 "\"\n",
465 js
466 );
467 }
468
469 Ok(Some(HookTransformOutput {
487 code: js.into(),
488 side_effects: Some(HookSideEffects::NoTreeshake),
489 ..Default::default()
490 }))
491 }
492
493 #[expect(unused_variables)]
494 async fn generate_bundle(
495 &self,
496 ctx: &rolldown_plugin::PluginContext,
497 args: &mut rolldown_plugin::HookGenerateBundleArgs<'_>,
498 ) -> rolldown_plugin::HookNoopReturn {
499 let mut inline_entry_chunk = FxHashSet::default();
500 let mut analyzed_imported_css_files = FxHashMap::default();
501 for item in &self.html_result_map {
502 let ((id, assets_base), (html, is_async)) = item.pair();
503
504 let mut result = html.to_string();
505
506 let path = id.relative(ctx.cwd());
507 let path_lossy = path.to_string_lossy();
508 let relative_url_path = normalize_path(&path_lossy);
509
510 let mut can_inline_entry = false;
511
512 let chunk = args.bundle.iter().find_map(|o| match o {
513 rolldown_common::Output::Chunk(chunk) => (chunk.is_entry
514 && chunk
515 .facade_module_id
516 .as_ref()
517 .is_some_and(|facade_module_id| facade_module_id.resource_id() == id))
518 .then_some(chunk),
519 rolldown_common::Output::Asset(_) => None,
520 });
521
522 if let Some(chunk) = chunk {
524 if args.options.format.is_esm() && utils::is_entirely_import(&chunk.code) {
528 can_inline_entry = true;
529 }
530
531 let imports = utils::get_imported_chunks(chunk, args.bundle);
534
535 let mut asset_tags = if can_inline_entry {
536 let mut tags = Vec::with_capacity(imports.len());
537 for imported_chunk in imports {
538 let mut tag = HtmlTagDescriptor::new("script");
539 let url = match imported_chunk {
540 utils::ImportedChunk::External(external) => external.to_string(),
541 utils::ImportedChunk::Chunk(chunk) => {
542 self
543 .to_output_file_path(&chunk.filename, assets_base, false, &relative_url_path)
544 .await?
545 }
546 };
547 tag.attrs = Some(FxHashMap::from_iter([
548 ("type", AttrValue::String("module".to_owned())),
549 ("crossorigin", AttrValue::Boolean(true)),
550 ("src", AttrValue::String(url)),
551 ]));
552 tags.push(tag);
553 }
554 tags
555 } else {
556 let mut tags = vec![{
557 let mut tag = HtmlTagDescriptor::new("script");
558 let url = self
559 .to_output_file_path(&chunk.filename, assets_base, false, &relative_url_path)
560 .await?;
561 tag.attrs = Some(FxHashMap::from_iter([
562 ("type", AttrValue::String("module".to_owned())),
563 ("crossorigin", AttrValue::Boolean(true)),
564 ("src", AttrValue::String(url)),
565 ]));
566 if *is_async {
567 tag.attrs.as_mut().unwrap().insert("async", AttrValue::Boolean(true));
568 }
569 tag
570 }];
571 if let Some(resolve_dependencies) = &self.resolve_dependencies {
572 let imports_filenames = imports
573 .iter()
574 .filter_map(|c| match c {
575 utils::ImportedChunk::Chunk(chunk) => Some(chunk.filename.to_string()),
576 utils::ImportedChunk::External(_) => None,
577 })
578 .collect::<Vec<_>>();
579 let resolved_deps = match resolve_dependencies {
580 ResolveDependenciesEither::True => imports_filenames,
581 ResolveDependenciesEither::Fn(r) => {
582 r(&chunk.filename, imports_filenames, &relative_url_path, "html").await?
583 }
584 };
585 for dep in resolved_deps {
586 let mut tag = HtmlTagDescriptor::new("link");
587 let url = self
588 .to_output_file_path(&chunk.filename, assets_base, false, &relative_url_path)
589 .await?;
590 tag.attrs = Some(FxHashMap::from_iter([
591 ("rel", AttrValue::String("modulepreload".to_owned())),
592 ("crossorigin", AttrValue::Boolean(true)),
593 ("href", AttrValue::String(url)),
594 ]));
595 tags.push(tag);
596 }
597 }
598 tags
599 };
600
601 let css_files =
602 get_css_files_for_chunk(ctx, chunk, args.bundle, &mut analyzed_imported_css_files);
603 asset_tags.reserve(css_files.len());
604 for css_file in css_files {
605 let url =
606 self.to_output_file_path(&css_file, assets_base, false, &relative_url_path).await?;
607 let mut tag = HtmlTagDescriptor::new("link");
608 tag.attrs = Some(FxHashMap::from_iter([
609 ("rel", AttrValue::String("stylesheet".to_owned())),
610 ("crossorigin", AttrValue::Boolean(true)),
611 ("href", AttrValue::String(url)),
612 ]));
613 asset_tags.push(tag);
614 }
615
616 result = inject_to_head(&result, &asset_tags, false).into_owned();
617 }
618
619 if !self.css_code_split {
620 let css_bundle_name = ctx.meta().get::<CSSBundleName>();
621 if let Some(css_bundle_name) = css_bundle_name
622 && args.bundle.iter().any(
623 |o| matches!(o, rolldown_common::Output::Asset(asset) if asset.names.contains(&css_bundle_name.0)),
624 )
625 {
626 let url = self.to_output_file_path(&css_bundle_name.0, assets_base, false, &relative_url_path).await?;
627 result = utils::inject_to_head(&result, &[
628 HtmlTagDescriptor {
629 tag: "link",
630 attrs: Some(FxHashMap::from_iter([
631 ("rel", AttrValue::String("stylesheet".to_owned())),
632 ("crossorigin", AttrValue::Boolean(true)),
633 (
634 "href",
635 AttrValue::String(url),
636 ),
637 ])),
638 ..Default::default()
639 }
640 ], false).into_owned();
641 }
642 }
643
644 if let Some(s) = Self::handle_inline_css(ctx, &result) {
645 result = s.to_string();
646 }
647
648 if let Some(s) =
652 self.handle_html_asset_url(ctx, html, chunk, assets_base, &relative_url_path).await?
653 {
654 result = s;
655 }
656
657 if let Some(chunk) = chunk
658 && can_inline_entry
659 {
660 inline_entry_chunk.insert(chunk.filename.clone());
661 }
662
663 ctx
664 .emit_file_async(rolldown_common::EmittedAsset {
665 name: None,
666 original_file_name: Some(id.to_string()),
667 file_name: Some(relative_url_path.into()),
668 source: rolldown_common::StrOrBytes::Str(result),
669 })
670 .await?;
671 }
672
673 args.bundle.retain(|o| match o {
675 rolldown_common::Output::Chunk(chunk) => !inline_entry_chunk.contains(&chunk.filename),
676 rolldown_common::Output::Asset(asset) => true,
677 });
678
679 Ok(())
680 }
681}