rolldown_plugin_vite_html/
lib.rs

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    // TODO: Support module_side_effects for module info
118    // let mut set_modules = Vec::new();
119    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    // TODO: Extract into a function
125    {
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                    // TODO: Support module_side_effects for module info
171                    // set_modules.push(url);
172                    // add `<script type="module" src="..."/>` as an import
173                    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            // Handle attributes like src/href
248            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                // Collect all attributes into a map for filtering
268                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                // Define which attributes to process based on element type
274                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                // Process srcset attributes (complex, multi-URL handling)
286                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                // Process src/href attributes
293                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            // Handle <tag style="..." />
328            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            // Handle <style>...</style>
344            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    // TODO: Support module_side_effects for module info
470    // for url in set_modules {
471    //   match ctx.resolve(&url, Some(args.id), None).await? {
472    //     Ok(resolved_id) => match ctx.get_module_info(&resolved_id.id) {
473    //       Some(module_info) => module_info.module_side_effects = true,
474    //       None => {
475    //         if !resolved_id.external.is_external() {
476    //           ctx.resolve(specifier, importer, extra_options)
477    //         }
478    //       },
479    //     },
480    //     Err(_) => return Err(anyhow::anyhow!("Failed to resolve {url} from {}", args.id)),
481    //   }
482    // }
483
484    // Force this module to keep from being shared between other entry points.
485    // If the resulting chunk is empty, it will be removed in generateBundle.
486    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      // inject chunk asset links
523      if let Some(chunk) = chunk {
524        // an entry chunk can be inlined if
525        //  - it's an ES module (e.g. not generated by the legacy plugin)
526        //  - it contains no meaningful code other than import statements
527        if args.options.format.is_esm() && utils::is_entirely_import(&chunk.code) {
528          can_inline_entry = true;
529        }
530
531        // when not inlined, inject <script> for entry and modulepreload its dependencies
532        // when inlined, discard entry chunk and inject <script> for everything in post-order
533        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      // TODO: applyHtmlTransforms
649      // result = await applyHtmlTransforms(..)
650
651      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    // all imports from entry have been inlined to html, prevent outputting it
674    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}