char/avril

(wip) why'd you have to go and make things so complicaaaaated?

git clone https://git.t4t.associates/char/avril

Charlotte Somsupport data urls1c8ffed

main
10.5 KiB296 linesraw
1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use avril_core::Error;
5use avril_runtime::bundle;
6use avril_runtime::caps::PathTarget;
7use avril_runtime::modules::{ModuleGraph, http_url};
8
9use crate::cli::ParseError;
10use crate::fragments::{Arg, Parser, flag_value, reject};
11
12pub struct BundleArgs {
13    pub entry: crate::fragments::Entry,
14    pub entries: Vec<String>,
15    pub output: Output,
16    pub bundle: bundle::Options,
17}
18
19/// `avril bundle` argument parsing failed.
20#[derive(Debug, thiserror::Error)]
21pub enum BundleParseError {
22    #[error(transparent)]
23    Generic(#[from] ParseError),
24    #[error("Unsupported --target {text}: expected es2015 to es2025 or esnext")]
25    BadTarget { text: String },
26    #[error("--out and --outdir cannot be combined")]
27    OutAndOutdir,
28    #[error("--out requires exactly one entry module; use --outdir for multiple entries")]
29    OutRequiresSingle,
30    #[error("--no-splitting requires exactly one entry module")]
31    NoSplittingSingle,
32    #[error(
33        "Expected --define KEY=VALUE, where KEY is a dotted identifier and VALUE an expression"
34    )]
35    BadDefine,
36}
37
38pub(crate) fn target(text: &str) -> Result<Option<u32>, BundleParseError> {
39    if text == "esnext" {
40        return Ok(None);
41    }
42    text.strip_prefix("es")
43        .and_then(|year| year.parse().ok())
44        .filter(|year| (2015..=2025).contains(year))
45        .map(Some)
46        .ok_or_else(|| BundleParseError::BadTarget { text: text.to_owned() })
47}
48
49pub fn parse(argv: Vec<String>, cwd: PathBuf) -> Result<BundleArgs, BundleParseError> {
50    let mut parser = Parser::new("bundle", argv, &cwd)?;
51    let mut bundle = bundle::Options::default();
52    let (mut output, mut outdir, mut raw) = (None, None, Vec::new());
53    while let Some(arg) = parser.next()? {
54        let arg = match arg {
55            Arg::Positional(arg) => {
56                raw.push(arg);
57                continue;
58            }
59            Arg::Flag(arg)
60                if matches!(
61                    arg.as_str(),
62                    "-o" | "--out" | "--outdir" | "--target" | "--define"
63                ) =>
64            {
65                let value = parser.value(ParseError::SpacedValue { flag: arg.clone() })?;
66                format!("{arg}={value}")
67            }
68            Arg::Flag(arg) => arg,
69        };
70        match arg.as_str() {
71            "--minify" => bundle.minify = true,
72            "--sourcemap" => bundle.sourcemap = true,
73            "--no-splitting" => bundle.no_splitting = true,
74            _ => match flag_value(&arg)? {
75                ("-o" | "--out", value) => output = Some(cwd.join(value)),
76                ("--outdir", value) => outdir = Some(cwd.join(value)),
77                ("--target", value) => bundle.target = target(value)?,
78                ("--define", value) => {
79                    let (key, value) = value
80                        .split_once('=')
81                        .filter(|(key, value)| {
82                            !value.is_empty()
83                                && key
84                                    .split('.')
85                                    .all(oxc::syntax::identifier::is_identifier_name)
86                        })
87                        .ok_or(BundleParseError::BadDefine)?;
88                    bundle.defines.push((key.to_owned(), value.to_owned()));
89                }
90                _ => {
91                    return Err(reject(&arg, &["--minify", "--sourcemap", "--no-splitting"]).into());
92                }
93            },
94        }
95    }
96    let output = match (output, outdir) {
97        (Some(_), Some(_)) => return Err(BundleParseError::OutAndOutdir),
98        (Some(path), None) => {
99            if raw.len() > 1 {
100                return Err(BundleParseError::OutRequiresSingle);
101            }
102            Output::File(path)
103        }
104        (None, Some(path)) => Output::Directory(path),
105        (None, None) => return Err(parser.usage().into()),
106    };
107    if bundle.no_splitting && raw.len() > 1 {
108        return Err(BundleParseError::NoSplittingSingle);
109    }
110    let first = raw.first().ok_or_else(|| parser.usage())?;
111    let resolved = parser.resolve(first)?;
112    let mut entries = vec![resolved.module.clone()];
113    for extra in raw.iter().skip(1) {
114        entries.push(parser.resolve(extra)?.module);
115    }
116    let entry = parser
117        .common
118        .finish(resolved.path, resolved.module, resolved.lock)?;
119    Ok(BundleArgs { entry, entries, output, bundle })
120}
121
122pub enum Output {
123    File(PathBuf),
124    Directory(PathBuf),
125}
126
127pub(crate) fn run(
128    graph: ModuleGraph,
129    entries: &[String],
130    output: &Output,
131    options: &bundle::Options,
132) -> Result<(), Error> {
133    let (parent, file_name) = match output {
134        Output::File(out) => {
135            let name = out
136                .file_name()
137                .and_then(|name| name.to_str())
138                .filter(|name| name.ends_with(".js") || name.ends_with(".mjs"))
139                .ok_or_else(|| Error::Module("Output must be a .js or .mjs file".into()))?;
140            url_safe(name)?;
141            (out.parent().unwrap(), Some(name))
142        }
143        Output::Directory(out) => (out.as_path(), None),
144    };
145    let mut entry_files = HashSet::new();
146    let mut input = Vec::new();
147    for entry in entries {
148        let name = if let Some(file_name) = file_name {
149            entry_files.insert(file_name.to_owned());
150            None
151        } else if avril_runtime::modules::data_url(entry).is_some() {
152            return Err(Error::Module(
153                "Data URL entries require an explicit -o output filename".into(),
154            ));
155        } else {
156            let url = http_url(entry);
157            let path = url.as_ref().map_or(entry.as_str(), |url| url.path());
158            let name = Path::new(path)
159                .file_stem()
160                .and_then(|name| name.to_str())
161                .ok_or_else(|| Error::Module(format!("Cannot name entry output: {entry}")))?;
162            let file_name = format!("{name}.js");
163            url_safe(&file_name)?;
164            if !entry_files.insert(file_name.clone()) {
165                return Err(Error::Module(format!(
166                    "Duplicate entry output filename: {file_name}"
167                )));
168            }
169            Some(name.to_owned())
170        };
171        input.push(bundle::Entry { name, import: entry.to_owned() });
172    }
173    // An ambiguous destination, like `..` below a missing directory, fails here.
174    let directory = PathTarget::for_creation(parent)
175        .map_err(|_| Error::Module(format!("Invalid output path: {}", parent.display())))?
176        .into_path();
177    let generated = tokio::runtime::Builder::new_current_thread()
178        .enable_all()
179        .build()
180        .map_err(|error| Error::Module(error.to_string()))?
181        .block_on(bundle::generate(
182            &graph,
183            &input,
184            file_name.unwrap_or("[name].js"),
185            &directory,
186            options,
187        ))?;
188    for warning in &generated.warnings {
189        eprintln!("{warning}");
190    }
191    graph
192        .store()
193        .save_graph(&generated.lock_keys, &generated.modules)
194        .map_err(|error| Error::Module(error.to_string()))?;
195    let mut inputs: HashSet<PathBuf> = generated
196        .modules
197        .iter()
198        .map(Path::new)
199        .filter(|path| path.is_absolute())
200        .map(physical)
201        .collect();
202    inputs.extend(graph.config_inputs().iter().map(|path| physical(path)));
203    inputs.insert(physical(&graph.store().options().lock));
204    publish(&inputs, parent, &directory, &entry_files, generated.files)
205}
206
207/// Output files become relative URLs in `import()` calls and map references.
208fn url_safe(file_name: &str) -> Result<(), Error> {
209    if file_name.is_empty()
210        || file_name.starts_with('.')
211        || file_name.contains(['/', '\\', '?', '#', '%', '[', ']'])
212        || file_name
213            .chars()
214            .any(|c| c.is_control() || c.is_whitespace())
215    {
216        return Err(Error::Module(format!(
217            "Unsupported output filename: {file_name:?}"
218        )));
219    }
220    Ok(())
221}
222
223fn physical(path: &Path) -> PathBuf {
224    path.canonicalize()
225        .or_else(|_| PathTarget::for_entry(path).map(PathTarget::into_path))
226        .unwrap_or_else(|_| path.to_owned())
227}
228
229/// Checks every destination before writing any, stages every file, then
230/// renames chunks and maps into place before the entry that loads them.
231fn publish(
232    inputs: &HashSet<PathBuf>,
233    parent: &Path,
234    directory: &Path,
235    entries: &HashSet<String>,
236    mut files: Vec<(String, Vec<u8>)>,
237) -> Result<(), Error> {
238    let failed = |path: &Path, error: std::io::Error| {
239        Error::Module(format!("Cannot write {}: {error}", path.display()))
240    };
241    for (name, _) in &files {
242        url_safe(name)?;
243        let path = directory.join(name);
244        if std::fs::symlink_metadata(&path).is_ok_and(|metadata| !metadata.is_file()) {
245            return Err(Error::Module(format!(
246                "Output {} is a symlink or not a regular file",
247                path.display()
248            )));
249        }
250        if inputs.contains(&physical(&path)) {
251            return Err(Error::Module(format!(
252                "Output {} would overwrite a bundle input",
253                path.display()
254            )));
255        }
256    }
257    // Creating through the given spelling refuses dangling symlinked parents.
258    std::fs::create_dir_all(parent).map_err(|error| failed(parent, error))?;
259    if PathTarget::resolve(parent)
260        .ok()
261        .as_ref()
262        .map(PathTarget::as_path)
263        != Some(directory)
264    {
265        return Err(Error::Module(format!(
266            "Output directory {} changed while bundling",
267            parent.display()
268        )));
269    }
270    files.sort_by_key(|(name, _)| entries.contains(name));
271    let mut staged = Vec::new();
272    for (name, bytes) in files {
273        let path = directory.join(name);
274        let mut builder = tempfile::Builder::new();
275        builder.prefix(".avril-bundle-");
276        #[cfg(unix)]
277        builder.permissions(std::os::unix::fs::PermissionsExt::from_mode(0o666));
278        let mut file = builder
279            .tempfile_in(directory)
280            .map_err(|error| failed(&path, error))?;
281        std::io::Write::write_all(&mut file, &bytes).map_err(|error| failed(&path, error))?;
282        if let Ok(previous) = std::fs::symlink_metadata(&path)
283            && previous.is_file()
284        {
285            file.as_file()
286                .set_permissions(previous.permissions())
287                .map_err(|error| failed(&path, error))?;
288        }
289        staged.push((file, path));
290    }
291    for (file, path) in staged {
292        file.persist(&path)
293            .map_err(|error| failed(&path, error.error))?;
294    }
295    Ok(())
296}