1use crate::Result;
2use crate::archive::{self, ArchiveEntry};
3use crate::check::{Diagnostic, DiagnosticReport, Severity};
4use crate::config::Config;
5use crate::fsutil;
6use crate::lockfile::LockFile;
7use crate::runtime::{DEFAULT_CHANNEL, RuntimeKind, RuntimeRegistry, cache_dir};
8use crate::targets::{BuildOutput, TargetAdapter};
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12const LOVELY_JS_REPOSITORY: &str = "https://github.com/DVDAGames/lovely.js.git";
13
14pub struct WebAdapter;
15
16impl TargetAdapter for WebAdapter {
17 fn name(&self) -> &'static str {
18 "web"
19 }
20
21 fn doctor(&self, root: &Path, config: &Config, lock: &LockFile) -> Result<DiagnosticReport> {
22 let mut report = DiagnosticReport::default();
23 if !config.targets.web.enabled {
24 report.push(Diagnostic {
25 id: "web.disabled",
26 severity: Severity::Warning,
27 message: "web target is disabled in lovely.toml".to_string(),
28 path: None,
29 });
30 }
31 if lock.runtime_channel != DEFAULT_CHANNEL {
32 report.push(Diagnostic {
33 id: "runtime.channel",
34 severity: Severity::Warning,
35 message: format!(
36 "expected runtime channel {}, found {}",
37 DEFAULT_CHANNEL, lock.runtime_channel
38 ),
39 path: None,
40 });
41 }
42 let configured_runtime = config.targets.web.runtime_path.as_ref();
43 if lock.has_unresolved_checksums() && configured_runtime.is_none() {
44 report.push(Diagnostic {
45 id: "lock.unresolved",
46 severity: Severity::Warning,
47 message: "lovely.lock still contains unresolved runtime checksums; install pinned runtime artifacts before release builds.".to_string(),
48 path: None,
49 });
50 }
51 if let Some(runtime_path) = configured_runtime {
52 let runtime_path = root.join(runtime_path);
53 if !runtime_path.exists() {
54 if is_lovely_js_runtime_path(&runtime_path, &config.targets.web.variant) {
55 report.push(Diagnostic {
56 id: "runtime.restorable",
57 severity: Severity::Warning,
58 message: format!(
59 "configured Lovely.js runtime_path does not exist yet; lovely build web will restore it: {}",
60 runtime_path.display()
61 ),
62 path: Some(runtime_path),
63 });
64 } else {
65 report.push(Diagnostic {
66 id: "runtime.missing",
67 severity: Severity::Error,
68 message: format!(
69 "configured web runtime_path does not exist: {}",
70 runtime_path.display()
71 ),
72 path: Some(runtime_path),
73 });
74 }
75 }
76 } else if RuntimeRegistry::new()
77 .find("web", &lock.runtime_channel)?
78 .is_none()
79 {
80 report.push(Diagnostic {
81 id: "runtime.restorable",
82 severity: Severity::Warning,
83 message: format!(
84 "no cached web runtime for {}; lovely build web will restore the managed Lovely.js {} runtime",
85 lock.runtime_channel, config.targets.web.variant
86 ),
87 path: None,
88 });
89 }
90 for asset in &config.targets.web.html_assets {
91 let asset_path = root.join(asset);
92 if !asset_path.exists() {
93 report.push(Diagnostic {
94 id: "web.html_asset_missing",
95 severity: Severity::Error,
96 message: format!(
97 "configured web html_asset does not exist: {}",
98 asset_path.display()
99 ),
100 path: Some(asset_path),
101 });
102 }
103 }
104 if config.targets.web.variant == "web-threaded" {
105 report.push(Diagnostic {
106 id: "web.cross_origin_isolation",
107 severity: Severity::Warning,
108 message: "web-threaded builds require cross-origin isolation headers; Itch.io generally needs web-compat.".to_string(),
109 path: None,
110 });
111 }
112 Ok(report)
113 }
114
115 fn build(&self, root: &Path, config: &Config, lock: &LockFile) -> Result<BuildOutput> {
116 let source = root.join(&config.paths.source);
117 let output = root.join(&config.paths.output).join("web");
118 fsutil::ensure_dir(&output)?;
119 let love_path = output.join("game.love");
120 archive::create_love_archive(
121 &source,
122 &love_path,
123 &config.paths.includes,
124 &config.paths.excludes,
125 )?;
126
127 let runtime = selected_web_runtime(root, config, lock)?;
128 let configured_index_template = config
129 .targets
130 .web
131 .html_template
132 .as_ref()
133 .map(|template| fsutil::read_to_string(&root.join(template)))
134 .transpose()?;
135 let index_template = if let Some(template) = configured_index_template {
136 template
137 } else if let Some(runtime) = &runtime {
138 runtime_default_html_template(runtime)?.unwrap_or_else(|| default_index(config))
139 } else {
140 default_index(config)
141 };
142 let index = render_html_template(&index_template, config);
143 fsutil::write_string(&output.join("index.html"), &index)?;
144 fsutil::write_string(
145 &output.join("lovely-runtime.txt"),
146 &runtime_manifest(config, lock),
147 )?;
148
149 if let Some(runtime) = &runtime {
150 copy_runtime_into_output(runtime.kind, &runtime.path, &output)?;
151 }
152 let html_assets = html_asset_entries(root, config)?;
153
154 let mut zip_entries = vec![
155 ArchiveEntry::file("index.html", index.into_bytes())?,
156 ArchiveEntry::file(
157 "game.love",
158 std::fs::read(&love_path).map_err(|err| crate::LovelyError::io(&love_path, err))?,
159 )?,
160 ArchiveEntry::file(
161 "lovely-runtime.txt",
162 runtime_manifest(config, lock).into_bytes(),
163 )?,
164 ];
165 if let Some(runtime) = &runtime {
166 append_runtime_zip_entries(runtime.kind, &runtime.path, &mut zip_entries)?;
167 }
168 append_html_asset_zip_entries(&mut zip_entries, &html_assets)?;
169 write_html_asset_entries(&output, &html_assets)?;
170 let upload_zip = root
171 .join(&config.paths.output)
172 .join(format!("{}-web.zip", config.game.id));
173 archive::write_zip(&upload_zip, &zip_entries)?;
174
175 Ok(BuildOutput {
176 target: self.name().to_string(),
177 artifacts: vec![output.join("index.html"), love_path, upload_zip],
178 })
179 }
180}
181
182struct WebRuntime {
183 kind: RuntimeKind,
184 path: PathBuf,
185}
186
187fn configured_web_runtime(root: &Path, config: &Config) -> Result<Option<WebRuntime>> {
188 let Some(path) = &config.targets.web.runtime_path else {
189 return Ok(None);
190 };
191 let path = root.join(path);
192 if !path.exists() {
193 return restore_lovely_js_runtime(&path, &config.targets.web.variant).map(Some);
194 }
195 Ok(Some(WebRuntime {
196 kind: if path.is_dir() {
197 RuntimeKind::Directory
198 } else {
199 RuntimeKind::File
200 },
201 path,
202 }))
203}
204
205fn restore_lovely_js_runtime(path: &Path, variant: &str) -> Result<WebRuntime> {
206 if let Some(runtime) = runtime_from_lovely_js_path_override(variant)? {
207 return Ok(runtime);
208 }
209
210 if !is_lovely_js_runtime_path(path, variant) {
211 return Err(crate::LovelyError::Command(format!(
212 "configured web runtime path does not exist: {}",
213 path.display()
214 )));
215 }
216
217 let repo = lovely_js_repo_from_runtime_path(path).ok_or_else(|| {
218 crate::LovelyError::Command(format!(
219 "could not infer Lovely.js checkout root from {}",
220 path.display()
221 ))
222 })?;
223 checkout_lovely_js(&repo)?;
224 build_lovely_js(&repo)?;
225 runtime_from_lovely_js_repo(&repo, variant)
226}
227
228fn runtime_from_lovely_js_path_override(variant: &str) -> Result<Option<WebRuntime>> {
229 let Some(path) = std::env::var_os("LOVELY_JS_PATH") else {
230 return Ok(None);
231 };
232
233 let repo = PathBuf::from(path);
234 let runtime = repo.join("dist").join(variant);
235 if !runtime.exists() {
236 build_lovely_js(&repo)?;
237 }
238 Ok(Some(runtime_from_lovely_js_repo(&repo, variant)?))
239}
240
241fn managed_lovely_js_runtime(variant: &str) -> Result<WebRuntime> {
242 if let Some(runtime) = runtime_from_lovely_js_path_override(variant)? {
243 return Ok(runtime);
244 }
245
246 let repo = cache_dir().join("tools/lovely.js");
247 checkout_lovely_js(&repo)?;
248 build_lovely_js(&repo)?;
249 runtime_from_lovely_js_repo(&repo, variant)
250}
251
252fn runtime_from_lovely_js_repo(repo: &Path, variant: &str) -> Result<WebRuntime> {
253 let runtime = repo.join("dist").join(variant);
254 if !runtime.exists() {
255 return Err(crate::LovelyError::Command(format!(
256 "Lovely.js runtime bundle does not exist after restore: {}",
257 runtime.display()
258 )));
259 }
260 Ok(WebRuntime {
261 kind: if runtime.is_dir() {
262 RuntimeKind::Directory
263 } else {
264 RuntimeKind::File
265 },
266 path: runtime,
267 })
268}
269
270fn checkout_lovely_js(repo: &Path) -> Result<()> {
271 if repo.join(".git").is_dir() {
272 return Ok(());
273 }
274
275 let source =
276 std::env::var("LOVELY_JS_REPOSITORY").unwrap_or_else(|_| LOVELY_JS_REPOSITORY.to_string());
277 let ref_name = std::env::var("LOVELY_JS_REF").unwrap_or_else(|_| "main".to_string());
278 if let Some(parent) = repo.parent() {
279 fsutil::ensure_dir(parent)?;
280 }
281
282 run_tool(
283 Command::new("git")
284 .arg("clone")
285 .arg("--depth")
286 .arg("1")
287 .arg(&source)
288 .arg(repo),
289 "clone Lovely.js runtime repository",
290 )?;
291 run_tool(
292 Command::new("git")
293 .arg("-C")
294 .arg(repo)
295 .arg("fetch")
296 .arg("--depth")
297 .arg("1")
298 .arg("origin")
299 .arg(&ref_name),
300 "fetch Lovely.js runtime ref",
301 )?;
302 run_tool(
303 Command::new("git")
304 .arg("-C")
305 .arg(repo)
306 .arg("checkout")
307 .arg("--force")
308 .arg("FETCH_HEAD"),
309 "checkout Lovely.js runtime ref",
310 )
311}
312
313fn build_lovely_js(repo: &Path) -> Result<()> {
314 if !repo.join("package.json").is_file() {
315 return Err(crate::LovelyError::Command(format!(
316 "Lovely.js checkout is missing package.json: {}",
317 repo.display()
318 )));
319 }
320
321 let install_command = if repo.join("package-lock.json").is_file() {
322 "ci"
323 } else {
324 "install"
325 };
326 run_tool(
327 Command::new("npm").arg(install_command).current_dir(repo),
328 "install Lovely.js dependencies",
329 )?;
330 run_tool(
331 Command::new("npm")
332 .arg("run")
333 .arg("build")
334 .current_dir(repo),
335 "build Lovely.js runtime bundles",
336 )
337}
338
339fn run_tool(command: &mut Command, action: &str) -> Result<()> {
340 let output = command.output().map_err(|err| {
341 crate::LovelyError::Command(format!(
342 "could not {action}; required tool failed to start: {err}"
343 ))
344 })?;
345 if !output.status.success() {
346 let stderr = String::from_utf8_lossy(&output.stderr);
347 return Err(crate::LovelyError::Command(format!(
348 "could not {action}; command exited with status {}; {}",
349 output.status,
350 stderr.trim()
351 )));
352 }
353 Ok(())
354}
355
356fn is_lovely_js_runtime_path(path: &Path, variant: &str) -> bool {
357 path.file_name().and_then(|name| name.to_str()) == Some(variant)
358 && path
359 .parent()
360 .and_then(Path::file_name)
361 .and_then(|name| name.to_str())
362 == Some("dist")
363 && path
364 .parent()
365 .and_then(Path::parent)
366 .and_then(Path::file_name)
367 .and_then(|name| name.to_str())
368 == Some("lovely.js")
369}
370
371fn lovely_js_repo_from_runtime_path(path: &Path) -> Option<PathBuf> {
372 path.parent()?.parent().map(Path::to_path_buf)
373}
374
375fn selected_web_runtime(
376 root: &Path,
377 config: &Config,
378 lock: &LockFile,
379) -> Result<Option<WebRuntime>> {
380 if let Some(runtime) = configured_web_runtime(root, config)? {
381 return Ok(Some(runtime));
382 }
383 if let Some(runtime) = RuntimeRegistry::new().find("web", &lock.runtime_channel)? {
384 return Ok(Some(WebRuntime {
385 kind: runtime.manifest.kind,
386 path: runtime.path,
387 }));
388 }
389 Ok(Some(managed_lovely_js_runtime(
390 &config.targets.web.variant,
391 )?))
392}
393
394fn copy_runtime_into_output(kind: RuntimeKind, path: &Path, output: &Path) -> Result<()> {
395 match kind {
396 RuntimeKind::Directory => {
397 for file in fsutil::collect_files(path)? {
398 let rel = fsutil::relative_path(path, &file)?;
399 if rel == Path::new("game.love") || rel == Path::new("lovely-runtime.txt") {
400 continue;
401 }
402 if rel == Path::new("index.html") {
403 continue;
404 }
405 fsutil::copy_file(&file, &output.join(rel))?;
406 }
407 }
408 RuntimeKind::File => {
409 let name = path.file_name().ok_or_else(|| {
410 crate::LovelyError::Command("cached runtime has no file name".to_string())
411 })?;
412 fsutil::copy_file(path, &output.join(name))?;
413 }
414 }
415 Ok(())
416}
417
418fn append_runtime_zip_entries(
419 kind: RuntimeKind,
420 path: &Path,
421 entries: &mut Vec<ArchiveEntry>,
422) -> Result<()> {
423 match kind {
424 RuntimeKind::Directory => {
425 for file in fsutil::collect_files(path)? {
426 let rel = fsutil::relative_path(path, &file)?;
427 if rel == Path::new("game.love") || rel == Path::new("lovely-runtime.txt") {
428 continue;
429 }
430 if rel == Path::new("index.html") {
431 continue;
432 }
433 entries.push(ArchiveEntry::file(
434 fsutil::normalize_slashes(&rel),
435 std::fs::read(&file).map_err(|err| crate::LovelyError::io(&file, err))?,
436 )?);
437 }
438 }
439 RuntimeKind::File => {
440 let name = path
441 .file_name()
442 .ok_or_else(|| {
443 crate::LovelyError::Command("cached runtime has no file name".to_string())
444 })?
445 .to_string_lossy()
446 .to_string();
447 entries.push(ArchiveEntry::file(
448 name,
449 std::fs::read(path).map_err(|err| crate::LovelyError::io(path, err))?,
450 )?);
451 }
452 }
453 entries.sort_by(|a, b| a.name.cmp(&b.name));
454 Ok(())
455}
456
457fn html_asset_entries(root: &Path, config: &Config) -> Result<Vec<ArchiveEntry>> {
458 let mut entries = Vec::new();
459 for asset in &config.targets.web.html_assets {
460 let source = root.join(asset);
461 if !source.exists() {
462 return Err(crate::LovelyError::Command(format!(
463 "configured web html_asset does not exist: {}",
464 source.display()
465 )));
466 }
467
468 if source.is_dir() {
469 let prefix = source
470 .file_name()
471 .ok_or_else(|| {
472 crate::LovelyError::Command(format!(
473 "configured web html_asset has no directory name: {}",
474 source.display()
475 ))
476 })?
477 .to_string_lossy();
478 for file in fsutil::collect_files(&source)? {
479 let rel = fsutil::relative_path(&source, &file)?;
480 let name = format!("{}/{}", prefix, fsutil::normalize_slashes(&rel));
481 entries.push(ArchiveEntry::file(
482 name,
483 std::fs::read(&file).map_err(|err| crate::LovelyError::io(&file, err))?,
484 )?);
485 }
486 } else {
487 let name = source
488 .file_name()
489 .ok_or_else(|| {
490 crate::LovelyError::Command(format!(
491 "configured web html_asset has no file name: {}",
492 source.display()
493 ))
494 })?
495 .to_string_lossy()
496 .to_string();
497 entries.push(ArchiveEntry::file(
498 name,
499 std::fs::read(&source).map_err(|err| crate::LovelyError::io(&source, err))?,
500 )?);
501 }
502 }
503 entries.sort_by(|a, b| a.name.cmp(&b.name));
504 Ok(entries)
505}
506
507fn append_html_asset_zip_entries(
508 entries: &mut Vec<ArchiveEntry>,
509 assets: &[ArchiveEntry],
510) -> Result<()> {
511 for asset in assets {
512 if entries.iter().any(|entry| entry.name == asset.name) {
513 return Err(crate::LovelyError::Command(format!(
514 "configured web html_asset conflicts with existing web output: {}",
515 asset.name
516 )));
517 }
518 entries.push(asset.clone());
519 }
520 entries.sort_by(|a, b| a.name.cmp(&b.name));
521 Ok(())
522}
523
524fn write_html_asset_entries(output: &Path, assets: &[ArchiveEntry]) -> Result<()> {
525 for asset in assets {
526 let destination = output.join(&asset.name);
527 if let Some(parent) = destination.parent() {
528 fsutil::ensure_dir(parent)?;
529 }
530 std::fs::write(&destination, &asset.bytes)
531 .map_err(|err| crate::LovelyError::io(&destination, err))?;
532 }
533 Ok(())
534}
535
536fn runtime_default_html_template(runtime: &WebRuntime) -> Result<Option<String>> {
537 if runtime.kind != RuntimeKind::Directory {
538 return Ok(None);
539 }
540
541 let manifest_path = runtime.path.join("lovely-runtime.json");
542 if !manifest_path.is_file() {
543 return Ok(None);
544 }
545
546 let manifest = fsutil::read_to_string(&manifest_path)?;
547 let Some(html_path) = json_string_field(&manifest, "html")? else {
548 return Err(crate::LovelyError::Config(format!(
549 "runtime manifest {} is missing html",
550 manifest_path.display()
551 )));
552 };
553 let html_path = Path::new(&html_path);
554 if html_path.is_absolute()
555 || html_path
556 .components()
557 .any(|part| matches!(part, std::path::Component::ParentDir))
558 {
559 return Err(crate::LovelyError::Config(format!(
560 "runtime manifest {} has unsafe html path: {}",
561 manifest_path.display(),
562 html_path.display()
563 )));
564 }
565
566 Ok(Some(fsutil::read_to_string(&runtime.path.join(html_path))?))
567}
568
569fn default_index(config: &Config) -> String {
570 format!(
571 r#"<!doctype html>
572<html lang="en">
573<head>
574 <meta charset="utf-8">
575 <meta name="viewport" content="width=device-width, initial-scale=1">
576 <title>{title}</title>
577 <style>
578 html, body {{ margin: 0; min-height: 100%; background: #111; color: #eee; font-family: system-ui, sans-serif; }}
579 body {{ display: grid; min-height: 100vh; }}
580 main {{ min-height: 100vh; display: grid; grid-template-rows: auto 1fr auto; gap: 1rem; padding: 1rem; box-sizing: border-box; }}
581 header, footer {{ text-align: center; }}
582 #game-container {{ min-height: 0; display: grid; place-items: center; }}
583 canvas {{ max-width: 100%; max-height: calc(100vh - 9rem); background: #000; image-rendering: pixelated; }}
584 button {{ appearance: none; border: 1px solid #555; background: #222; color: #eee; padding: .5rem .75rem; border-radius: 4px; cursor: pointer; }}
585 code {{ color: #a7f3d0; }}
586 </style>
587</head>
588<body>
589 <main>
590 <header>
591 <h1>{title}</h1>
592 </header>
593 <section id="game-container">
594 <canvas id="canvas" oncontextmenu="event.preventDefault()"></canvas>
595 </section>
596 <footer>
597 <button type="button" id="fullscreen">Fullscreen</button>
598 <p>This package includes <code>game.love</code>. Install a pinned web runtime with <code>lovely runtime fetch web <path></code> to include JavaScript/WASM runtime files.</p>
599 </footer>
600 </main>
601</body>
602</html>
603"#,
604 title = html_escape(&config.game.name)
605 )
606}
607
608fn runtime_manifest(config: &Config, lock: &LockFile) -> String {
609 format!(
610 "target=web\nvariant={}\nruntime_channel={}\nlove_revision={}\nemscripten_revision={}\nmemory_bytes={}\narguments={}\n",
611 config.targets.web.variant,
612 lock.runtime_channel,
613 lock.love.revision,
614 lock.emscripten.revision,
615 config.targets.web.memory_bytes,
616 js_string_array(&web_runtime_arguments(config)),
617 )
618}
619
620fn render_html_template(template: &str, config: &Config) -> String {
621 template
622 .replace("__GAME_TITLE__", &html_escape(&config.game.name))
623 .replace(
624 "__WEB_MEMORY__",
625 &config.targets.web.memory_bytes.to_string(),
626 )
627 .replace(
628 "__WEB_ARGUMENTS__",
629 &js_string_array(&web_runtime_arguments(config)),
630 )
631}
632
633fn web_runtime_arguments(config: &Config) -> Vec<String> {
634 let mut arguments = Vec::with_capacity(config.targets.web.arguments.len() + 1);
635 arguments.push("./game.love".to_string());
636 arguments.extend(config.targets.web.arguments.iter().cloned());
637 arguments
638}
639
640fn js_string_array(values: &[String]) -> String {
641 let values = values
642 .iter()
643 .map(|value| js_string_literal(value))
644 .collect::<Vec<_>>()
645 .join(", ");
646 format!("[{values}]")
647}
648
649fn js_string_literal(input: &str) -> String {
650 let mut output = String::from("\"");
651 for ch in input.chars() {
652 match ch {
653 '\\' => output.push_str("\\\\"),
654 '"' => output.push_str("\\\""),
655 '\n' => output.push_str("\\n"),
656 '\r' => output.push_str("\\r"),
657 '\t' => output.push_str("\\t"),
658 ch if ch.is_control() => output.push_str(&format!("\\u{:04x}", ch as u32)),
659 ch => output.push(ch),
660 }
661 }
662 output.push('"');
663 output
664}
665
666fn json_string_field(text: &str, key: &str) -> Result<Option<String>> {
667 let needle = format!("\"{}\"", key);
668 let Some(key_index) = text.find(&needle) else {
669 return Ok(None);
670 };
671 let after_key = &text[key_index + needle.len()..];
672 let Some(colon_index) = after_key.find(':') else {
673 return Err(crate::LovelyError::Config(format!(
674 "runtime manifest field {key:?} is missing ':'"
675 )));
676 };
677 let value = after_key[colon_index + 1..].trim_start();
678 let Some(value) = value.strip_prefix('"') else {
679 return Err(crate::LovelyError::Config(format!(
680 "runtime manifest field {key:?} is not a string"
681 )));
682 };
683
684 let mut output = String::new();
685 let mut chars = value.chars();
686 while let Some(ch) = chars.next() {
687 match ch {
688 '"' => return Ok(Some(output)),
689 '\\' => {
690 let Some(escaped) = chars.next() else {
691 return Err(crate::LovelyError::Config(format!(
692 "runtime manifest field {key:?} has an incomplete escape"
693 )));
694 };
695 match escaped {
696 '"' => output.push('"'),
697 '\\' => output.push('\\'),
698 '/' => output.push('/'),
699 'b' => output.push('\u{0008}'),
700 'f' => output.push('\u{000c}'),
701 'n' => output.push('\n'),
702 'r' => output.push('\r'),
703 't' => output.push('\t'),
704 'u' => {
705 let mut hex = String::new();
706 for _ in 0..4 {
707 let Some(digit) = chars.next() else {
708 return Err(crate::LovelyError::Config(format!(
709 "runtime manifest field {key:?} has an incomplete unicode escape"
710 )));
711 };
712 hex.push(digit);
713 }
714 let code = u32::from_str_radix(&hex, 16).map_err(|_| {
715 crate::LovelyError::Config(format!(
716 "runtime manifest field {key:?} has an invalid unicode escape"
717 ))
718 })?;
719 let Some(decoded) = char::from_u32(code) else {
720 return Err(crate::LovelyError::Config(format!(
721 "runtime manifest field {key:?} has an invalid unicode scalar"
722 )));
723 };
724 output.push(decoded);
725 }
726 other => {
727 return Err(crate::LovelyError::Config(format!(
728 "runtime manifest field {key:?} has an invalid escape: {other}"
729 )));
730 }
731 }
732 }
733 ch => output.push(ch),
734 }
735 }
736
737 Err(crate::LovelyError::Config(format!(
738 "runtime manifest field {key:?} is unterminated"
739 )))
740}
741
742fn html_escape(input: &str) -> String {
743 input
744 .replace('&', "&")
745 .replace('<', "<")
746 .replace('>', ">")
747 .replace('"', """)
748}