Skip to main content

lovely/targets/
web.rs

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 &lt;path&gt;</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('&', "&amp;")
745        .replace('<', "&lt;")
746        .replace('>', "&gt;")
747        .replace('"', "&quot;")
748}