Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's. | 1 | //! Which framework a project is built with, found from its package's |
| 2 | //! dependencies and its config files, and what that means for a g1t.page | |
| 3 | //! build: the command that builds it, the folder its static site is written | |
| 4 | //! to, or the Worker a Cloudflare adapter writes. Nothing here runs anything. | |
| 5 | //! | |
| 6 | //! g1t.page serves static sites and Workers, never a server that stays up, | |
| 7 | //! so a framework set up to need one is reported with what to change. | |
| 8 | ||
| 9 | use std::collections::BTreeSet; | |
| 10 | use std::path::Path; | |
| 11 | ||
| 12 | use regex::Regex; | |
| 13 | use serde_json::{Map, Value}; | |
| 14 | ||
| 15 | /// Hugo, from the `hugo` on `PATH`, else Hugo's own release by npm. | |
| 16 | const HUGO: &str = "if command -v hugo >/dev/null 2>&1; then hugo --minify; else npx --yes hugo-extended@0.166.0 --minify; fi"; | |
| 17 | /// mdBook, from the `mdbook` on `PATH`, else built by cargo. | |
| 18 | const MDBOOK: &str = "if ! command -v mdbook >/dev/null 2>&1; then cargo install mdbook --version 0.5.4 --locked --quiet; fi && mdbook build"; | |
| 19 | /// Jekyll's gems, kept in the project rather than the system's Ruby. | |
| 20 | const BUNDLE_INSTALL: &str = "bundle config set --local path vendor/bundle && bundle install"; | |
| 21 | ||
| 22 | /// The Worker a framework's Cloudflare adapter writes, for a project with no | |
| 23 | /// Wrangler config of its own. | |
| 24 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 25 | pub(crate) struct Adapter { | |
| 26 | /// The Worker's entry, relative to the project: a file, or a directory | |
| 27 | /// holding an `index.js`. | |
| 28 | pub entry: &'static str, | |
| 29 | /// The static files served beside it. | |
| 30 | pub assets: &'static str, | |
| 31 | } | |
| 32 | ||
| 33 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 34 | pub(crate) struct Framework { | |
| 35 | /// Short and stable, as `g1t-runner detect` prints it: `astro`, `nextjs`… | |
| 36 | pub id: &'static str, | |
| 37 | /// For the build's log. | |
| 38 | pub name: &'static str, | |
| 39 | /// What installs what the package manager does not (Jekyll's gems). | |
| 40 | pub install: Option<&'static str>, | |
| 41 | /// What builds it, unless the project gives its own command. | |
| 42 | pub build: Option<String>, | |
| 43 | /// Where it writes its static site, relative to the project. | |
| 44 | pub output: Option<String>, | |
| 45 | /// Built for Workers: with a Wrangler config, `build` runs before | |
| 46 | /// wrangler bundles it. | |
| 47 | pub workers: bool, | |
| 48 | /// Without a Wrangler config, the Worker its adapter writes. | |
| 49 | pub adapter: Option<Adapter>, | |
| 50 | /// Why it cannot be served as it is set up, and what to change. | |
| 51 | pub problem: Option<String>, | |
| 52 | } | |
| 53 | ||
| 54 | impl Framework { | |
| 55 | fn new(id: &'static str, name: &'static str) -> Self { | |
| 56 | Framework { id, name, install: None, build: None, output: None, workers: false, adapter: None, problem: None } | |
| 57 | } | |
| 58 | ||
| 59 | fn static_site(mut self, build: String, output: impl Into<String>) -> Self { | |
| 60 | self.build = Some(build); | |
| 61 | self.output = Some(output.into()); | |
| 62 | self | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | /// `package.json`: what it depends on, and its scripts. | |
| 67 | struct Package { | |
| 68 | dependencies: BTreeSet<String>, | |
| 69 | scripts: Map<String, Value>, | |
| 70 | } | |
| 71 | ||
| 72 | impl Package { | |
| 73 | fn read(dir: &Path) -> Option<Package> { | |
| 74 | let text = std::fs::read_to_string(dir.join("package.json")).ok()?; | |
| 75 | let package: Value = serde_json::from_str(&text).ok()?; | |
| 76 | let dependencies = ["dependencies", "devDependencies"] | |
| 77 | .iter() | |
| 78 | .filter_map(|field| package[field].as_object()) | |
| 79 | .flat_map(|deps| deps.keys().cloned()) | |
| 80 | .collect(); | |
| 81 | let scripts = package["scripts"].as_object().cloned().unwrap_or_default(); | |
| 82 | Some(Package { dependencies, scripts }) | |
| 83 | } | |
| 84 | ||
| 85 | fn has(&self, name: &str) -> bool { | |
| 86 | self.dependencies.contains(name) | |
| 87 | } | |
| 88 | ||
| 89 | fn script(&self, name: &str) -> Option<&str> { | |
| 90 | self.scripts.get(name).and_then(Value::as_str) | |
| 91 | } | |
| 92 | ||
| 93 | /// The package's own `build` script, else the framework's command. | |
| 94 | fn build_or(&self, command: &str) -> String { | |
| 95 | if self.script("build").is_some() { "npm run build".to_owned() } else { command.to_owned() } | |
| 96 | } | |
| 97 | } | |
| 98 | ||
| 99 | /// The first of `names` in `dir`, read, without its comment lines. | |
| 100 | fn config(dir: &Path, names: &[&str]) -> String { | |
| 101 | names | |
| 102 | .iter() | |
| 103 | .find_map(|name| std::fs::read_to_string(dir.join(name)).ok()) | |
| 104 | .map(|text| { | |
| 105 | text.lines() | |
| 106 | .filter(|line| { | |
| 107 | let line = line.trim_start(); | |
| 108 | !(line.starts_with("//") || line.starts_with('*') || line.starts_with("/*") || line.starts_with('#')) | |
| 109 | }) | |
| 110 | .collect::<Vec<_>>() | |
| 111 | .join("\n") | |
| 112 | }) | |
| 113 | .unwrap_or_default() | |
| 114 | } | |
| 115 | ||
| 116 | /// Every string value `key` is given in a JavaScript or JSON config: | |
| 117 | /// `key: 'value'`, `"key": "value"`, and so on. | |
| 118 | fn values_of(text: &str, key: &str) -> Vec<String> { | |
| 119 | let pattern = format!(r#"(?:^|[^\w$.])["']?{}["']?\s*:\s*(?:"([^"]*)"|'([^']*)'|`([^`]*)`)"#, regex::escape(key)); | |
| 120 | let Ok(pattern) = Regex::new(&pattern) else { return Vec::new() }; | |
| 121 | pattern | |
| 122 | .captures_iter(text) | |
| 123 | .filter_map(|found| found.get(1).or(found.get(2)).or(found.get(3)).map(|m| m.as_str().to_owned())) | |
| 124 | .collect() | |
| 125 | } | |
| 126 | ||
| 127 | fn value_of(text: &str, key: &str) -> Option<String> { | |
| 128 | values_of(text, key).into_iter().next().map(|v| v.trim().trim_start_matches("./").trim_matches('/').to_owned()).filter(|v| !v.is_empty()) | |
| 129 | } | |
| 130 | ||
| 131 | /// Whether the config sets `key` to `value`, quoted or not (`ssr: false`). | |
| 132 | fn sets(text: &str, key: &str, value: &str) -> bool { | |
| 133 | if values_of(text, key).iter().any(|v| v == value) { | |
| 134 | return true; | |
| 135 | } | |
| 136 | let pattern = format!(r#"(?:^|[^\w$.])["']?{}["']?\s*:\s*{}\b"#, regex::escape(key), regex::escape(value)); | |
| 137 | Regex::new(&pattern).is_ok_and(|pattern| pattern.is_match(text)) | |
| 138 | } | |
| 139 | ||
| 140 | /// `key = "value"` (TOML) or `key: value` (YAML), case aside. | |
| 141 | fn setting(text: &str, key: &str) -> Option<String> { | |
| 142 | let pattern = format!(r#"(?im)^\s*{}\s*[=:]\s*["']?([^"'\s#]+)"#, regex::escape(key)); | |
| 143 | Regex::new(&pattern).ok()?.captures(text).map(|found| found[1].trim_matches('/').to_owned()) | |
| 144 | } | |
| 145 | ||
| 146 | const NEXT_CONFIGS: [&str; 4] = ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"]; | |
| 147 | const ASTRO_CONFIGS: [&str; 5] = ["astro.config.mjs", "astro.config.js", "astro.config.ts", "astro.config.mts", "astro.config.cjs"]; | |
| 148 | const SVELTE_CONFIGS: [&str; 3] = ["svelte.config.js", "svelte.config.mjs", "svelte.config.ts"]; | |
| 149 | const VITE_CONFIGS: [&str; 6] = ["vite.config.ts", "vite.config.js", "vite.config.mjs", "vite.config.mts", "vite.config.cjs", "vite.config.cts"]; | |
| 150 | const REACT_ROUTER_CONFIGS: [&str; 4] = ["react-router.config.ts", "react-router.config.js", "react-router.config.mjs", "react-router.config.mts"]; | |
| 151 | const ELEVENTY_CONFIGS: [&str; 6] = | |
| 152 | ["eleventy.config.js", "eleventy.config.mjs", "eleventy.config.cjs", ".eleventy.js", ".eleventy.mjs", ".eleventy.cjs"]; | |
| 153 | ||
| 154 | /// The framework the project at `dir` is built with, if g1t knows it. | |
| 155 | /// `wrangler` is whether the project has a Wrangler config of its own. | |
| 156 | pub(crate) fn detect(dir: &Path, wrangler: bool) -> Option<Framework> { | |
| 157 | let package = Package::read(dir); | |
| 158 | if let Some(package) = &package | |
| 159 | && let Some(found) = node(dir, package, wrangler) | |
| 160 | { | |
| 161 | return Some(found); | |
| 162 | } | |
| 163 | let build_or = |command: &str| package.as_ref().map_or_else(|| command.to_owned(), |p| p.build_or(command)); | |
| 164 | for name in ["hugo.toml", "hugo.yaml", "hugo.json", "config.toml", "config.yaml"] { | |
| 165 | let Ok(text) = std::fs::read_to_string(dir.join(name)) else { continue }; | |
| 166 | // `config.toml` is also Zola's and others': Hugo's names `baseURL`, | |
| 167 | // or the folder has Hugo's layout. | |
| 168 | let hugo = name.starts_with("hugo.") | |
| 169 | || (package.is_none() | |
| 170 | && (text.to_ascii_lowercase().contains("baseurl") | |
| 171 | || ["layouts", "archetypes", "themes"].iter().any(|d| dir.join(d).is_dir()))); | |
| 172 | if hugo { | |
| 173 | let output = setting(&text, "publishDir").unwrap_or_else(|| "public".to_owned()); | |
| 174 | return Some(Framework::new("hugo", "Hugo").static_site(build_or(HUGO), output)); | |
| 175 | } | |
| 176 | } | |
| 177 | if dir.join("_config.yml").exists() && dir.join("Gemfile").exists() { | |
| 178 | let text = std::fs::read_to_string(dir.join("_config.yml")).unwrap_or_default(); | |
| 179 | let output = setting(&text, "destination").unwrap_or_else(|| "_site".to_owned()); | |
| 180 | let mut jekyll = Framework::new("jekyll", "Jekyll").static_site(build_or("bundle exec jekyll build"), output); | |
| 181 | jekyll.install = Some(BUNDLE_INSTALL); | |
| 182 | return Some(jekyll); | |
| 183 | } | |
| 184 | if let Ok(text) = std::fs::read_to_string(dir.join("book.toml")) { | |
| 185 | let output = toml::from_str::<toml::Value>(&text) | |
| 186 | .ok() | |
| 187 | .and_then(|book| book.get("build")?.get("build-dir")?.as_str().map(|d| d.trim_matches('/').to_owned())) | |
| 188 | .unwrap_or_else(|| "book".to_owned()); | |
| 189 | return Some(Framework::new("mdbook", "mdBook").static_site(build_or(MDBOOK), output)); | |
| 190 | } | |
| 191 | None | |
| 192 | } | |
| 193 | ||
| 194 | /// A framework found by the package's dependencies. | |
| 195 | fn node(dir: &Path, package: &Package, wrangler: bool) -> Option<Framework> { | |
| 196 | if package.has("next") { | |
| 197 | let mut next = Framework::new("nextjs", "Next.js"); | |
| 198 | if package.has("@opennextjs/cloudflare") { | |
| 199 | next.name = "Next.js (OpenNext's Cloudflare adapter)"; | |
| 200 | next.workers = true; | |
| 201 | next.build = Some("npx opennextjs-cloudflare build".to_owned()); | |
| 202 | if !wrangler { | |
| 203 | next.problem = Some( | |
| 204 | "This Next.js app uses OpenNext's Cloudflare adapter (`@opennextjs/cloudflare`), which deploys as a Workers project: \ | |
| 205 | add a `wrangler.jsonc` beside `package.json` with `main` set to `.open-next/worker.js` and `assets.directory` \ | |
| 206 | to `.open-next/assets`, as OpenNext's guide shows." | |
| 207 | .to_owned(), | |
| 208 | ); | |
| 209 | } | |
| 210 | } else if sets(&config(dir, &NEXT_CONFIGS), "output", "export") { | |
| 211 | next = next.static_site(package.build_or("npx next build"), "out"); | |
| 212 | } else if !wrangler { | |
| 213 | next.problem = Some( | |
| 214 | "This is a Next.js app that needs a server, and g1t.page does not run servers. Set `output: 'export'` in \ | |
| 215 | `next.config` to build it as a static site (served from `out`), or deploy it as a Worker with OpenNext's \ | |
| 216 | Cloudflare adapter, `@opennextjs/cloudflare`, and a `wrangler.jsonc`." | |
| 217 | .to_owned(), | |
| 218 | ); | |
| 219 | } | |
| 220 | return Some(next); | |
| 221 | } | |
| 222 | if package.has("astro") { | |
| 223 | let text = config(dir, &ASTRO_CONFIGS); | |
| 224 | let output = value_of(&text, "outDir").unwrap_or_else(|| "dist".to_owned()); | |
| 225 | let mut astro = Framework::new("astro", "Astro").static_site(package.build_or("npx astro build"), output); | |
| 226 | if package.has("@astrojs/cloudflare") { | |
| 227 | astro.name = "Astro (Cloudflare adapter)"; | |
| 228 | astro.workers = true; | |
| 229 | if !wrangler { | |
| 230 | astro.adapter = Some(Adapter { entry: "dist/_worker.js", assets: "dist" }); | |
| 231 | } | |
| 232 | } else if !wrangler && (sets(&text, "output", "server") || sets(&text, "output", "hybrid")) { | |
| 233 | astro.problem = Some( | |
| 234 | "This Astro site renders on a server (`output: 'server'`), and g1t.page does not run servers. Use \ | |
| 235 | `output: 'static'` (the default) to build a static site, or add the `@astrojs/cloudflare` adapter to run \ | |
| 236 | it as a Worker." | |
| 237 | .to_owned(), | |
| 238 | ); | |
| 239 | } | |
| 240 | return Some(astro); | |
| 241 | } | |
| 242 | if package.has("@sveltejs/kit") { | |
| 243 | // SvelteKit 3 takes its options in `vite.config`, 2 in `svelte.config`. | |
| 244 | let text = format!("{}\n{}", config(dir, &SVELTE_CONFIGS), config(dir, &VITE_CONFIGS)); | |
| 245 | let adapters = ["@sveltejs/adapter-static", "@sveltejs/adapter-cloudflare-workers", "@sveltejs/adapter-cloudflare"]; | |
| 246 | let adapter = adapters | |
| 247 | .iter() | |
| 248 | .find(|adapter| text.contains(*adapter)) | |
| 249 | .or_else(|| adapters.iter().find(|adapter| package.has(adapter))); | |
| 250 | let build = package.build_or("npx vite build"); | |
| 251 | let mut kit = Framework::new("sveltekit", "SvelteKit"); | |
| 252 | match adapter.copied() { | |
| 253 | Some("@sveltejs/adapter-static") => { | |
| 254 | let output = value_of(&text, "pages").unwrap_or_else(|| "build".to_owned()); | |
| 255 | kit = kit.static_site(build, output); | |
| 256 | kit.name = "SvelteKit (static adapter)"; | |
| 257 | } | |
| 258 | Some("@sveltejs/adapter-cloudflare") => { | |
| 259 | kit.name = "SvelteKit (Cloudflare adapter)"; | |
| 260 | kit.build = Some(build); | |
| 261 | kit.workers = true; | |
| 262 | if !wrangler { | |
| 263 | kit.adapter = Some(Adapter { entry: ".svelte-kit/cloudflare/_worker.js", assets: ".svelte-kit/cloudflare" }); | |
| 264 | } | |
| 265 | } | |
| 266 | Some(_) if wrangler => { | |
| 267 | kit.build = Some(build); | |
| 268 | kit.workers = true; | |
| 269 | } | |
| 270 | Some(_) => { | |
| 271 | kit.problem = Some( | |
| 272 | "`@sveltejs/adapter-cloudflare-workers` needs a `wrangler.toml`; it is deprecated, so switch to \ | |
| 273 | `@sveltejs/adapter-cloudflare` instead, which needs none." | |
| 274 | .to_owned(), | |
| 275 | ); | |
| 276 | } | |
| 277 | None if wrangler => {} | |
| 278 | None => { | |
| 279 | kit.problem = Some( | |
| 280 | "This SvelteKit app has no adapter g1t.page can serve. Use `@sveltejs/adapter-static` to build a static \ | |
| 281 | site, or `@sveltejs/adapter-cloudflare` to run it as a Worker; `adapter-auto`, `adapter-node` and the \ | |
| 282 | others need a host of their own." | |
| 283 | .to_owned(), | |
| 284 | ); | |
| 285 | } | |
| 286 | } | |
| 287 | return Some(kit); | |
| 288 | } | |
| 289 | if package.has("nuxt") { | |
| 290 | let mut nuxt = Framework::new("nuxt", "Nuxt"); | |
| 291 | if wrangler { | |
| 292 | nuxt.build = Some(package.build_or("npx nuxi build")); | |
| 293 | nuxt.workers = true; | |
| 294 | } else { | |
| 295 | // Static generation: its `generate` script, a `build` script | |
| 296 | // that generates, or `nuxi generate` itself. | |
| 297 | let build = if package.script("generate").is_some() { | |
| 298 | "npm run generate".to_owned() | |
| 299 | } else if package.script("build").is_some_and(|script| script.contains("generate")) { | |
| 300 | "npm run build".to_owned() | |
| 301 | } else { | |
| 302 | "npx nuxi generate".to_owned() | |
| 303 | }; | |
| 304 | nuxt = nuxt.static_site(build, ".output/public"); | |
| 305 | } | |
| 306 | return Some(nuxt); | |
| 307 | } | |
| 308 | for (dependency, id, name, configs, command) in [ | |
| 309 | ("@react-router/dev", "react-router", "React Router", &REACT_ROUTER_CONFIGS[..], "npx react-router build"), | |
| 310 | ("@remix-run/dev", "remix", "Remix", &VITE_CONFIGS[..], "npx remix vite:build"), | |
| 311 | ] { | |
| 312 | if !package.has(dependency) { | |
| 313 | continue; | |
| 314 | } | |
| 315 | let mut found = Framework::new(id, name); | |
| 316 | if sets(&config(dir, configs), "ssr", "false") { | |
| 317 | found = found.static_site(package.build_or(command), "build/client"); | |
| 318 | found.name = if id == "remix" { "Remix (SPA mode)" } else { "React Router (SPA mode)" }; | |
| 319 | } else if wrangler { | |
| 320 | found.build = Some(package.build_or(command)); | |
| 321 | found.workers = true; | |
| 322 | } else { | |
| 323 | let file = if id == "remix" { "the Remix plugin's options in `vite.config`" } else { "`react-router.config.ts`" }; | |
| 324 | found.problem = Some(format!( | |
| 325 | "This {name} app renders on a server, and g1t.page does not run servers. Set `ssr: false` in {file} \ | |
| 326 | to build it as a single-page app (served from `build/client`), or deploy it as a Worker with \ | |
| 327 | `@cloudflare/vite-plugin` and a `wrangler.jsonc`." | |
| 328 | )); | |
| 329 | } | |
| 330 | return Some(found); | |
| 331 | } | |
| 332 | if package.has("gatsby") { | |
| 333 | return Some(Framework::new("gatsby", "Gatsby").static_site(package.build_or("npx gatsby build"), "public")); | |
| 334 | } | |
| 335 | if package.has("@docusaurus/core") { | |
| 336 | return Some(Framework::new("docusaurus", "Docusaurus").static_site(package.build_or("npx docusaurus build"), "build")); | |
| 337 | } | |
| 338 | if package.has("@11ty/eleventy") { | |
| 339 | let output = value_of(&config(dir, &ELEVENTY_CONFIGS), "output").unwrap_or_else(|| "_site".to_owned()); | |
| 340 | return Some(Framework::new("eleventy", "Eleventy").static_site(package.build_or("npx @11ty/eleventy"), output)); | |
| 341 | } | |
| 342 | if package.has("@angular/core") && dir.join("angular.json").exists() { | |
| 343 | let mut angular = Framework::new("angular", "Angular"); | |
| 344 | angular.build = Some(package.build_or("npx ng build")); | |
| 345 | angular.output = angular_output(dir); | |
| 346 | return Some(angular); | |
| 347 | } | |
| 348 | if package.has("react-scripts") { | |
| 349 | return Some( | |
| 350 | Framework::new("create-react-app", "Create React App").static_site(package.build_or("npx react-scripts build"), "build"), | |
| 351 | ); | |
| 352 | } | |
| 353 | let vite_config = VITE_CONFIGS.iter().any(|name| dir.join(name).exists()); | |
| 354 | if package.has("vite") || vite_config { | |
| 355 | let (id, name) = [ | |
| 356 | ("@vitejs/plugin-react", "vite-react", "React (Vite)"), | |
| 357 | ("@vitejs/plugin-react-swc", "vite-react", "React (Vite)"), | |
| 358 | ("@vitejs/plugin-vue", "vite-vue", "Vue (Vite)"), | |
| 359 | ("@sveltejs/vite-plugin-svelte", "vite-svelte", "Svelte (Vite)"), | |
| 360 | ("vite-plugin-solid", "vite-solid", "Solid (Vite)"), | |
| 361 | ("@preact/preset-vite", "vite-preact", "Preact (Vite)"), | |
| 362 | ] | |
| 363 | .iter() | |
| 364 | .find(|(plugin, ..)| package.has(plugin)) | |
| 365 | .map_or(("vite", "Vite"), |(_, id, name)| (*id, *name)); | |
| 366 | let build = package.build_or("npx vite build"); | |
| 367 | if wrangler && package.has("@cloudflare/vite-plugin") { | |
| 368 | let mut vite = Framework::new(id, name); | |
| 369 | vite.build = Some(build); | |
| 370 | vite.workers = true; | |
| 371 | return Some(vite); | |
| 372 | } | |
| 373 | let output = value_of(&config(dir, &VITE_CONFIGS), "outDir").unwrap_or_else(|| "dist".to_owned()); | |
| 374 | return Some(Framework::new(id, name).static_site(build, output)); | |
| 375 | } | |
| 376 | None | |
| 377 | } | |
| 378 | ||
| 379 | /// Where `ng build` writes the app's browser files: the application | |
| 380 | /// builder's `<outputPath>/browser`, or the older builders' `<outputPath>`. | |
| 381 | fn angular_output(dir: &Path) -> Option<String> { | |
| 382 | let text = std::fs::read_to_string(dir.join("angular.json")).ok()?; | |
| 383 | let workspace: Value = serde_json::from_str(&text).ok()?; | |
| 384 | let projects = workspace["projects"].as_object()?; | |
| 385 | let (name, project) = workspace["defaultProject"] | |
| 386 | .as_str() | |
| 387 | .and_then(|name| projects.get_key_value(name)) | |
| 388 | .or_else(|| projects.iter().find(|(_, project)| project["projectType"] == "application")) | |
| 389 | .or_else(|| projects.iter().next())?; | |
| 390 | let targets = if project["architect"].is_object() { &project["architect"] } else { &project["targets"] }; | |
| 391 | let build = &targets["build"]; | |
| 392 | let builder = build["builder"].as_str().unwrap_or(""); | |
| 393 | let application = builder.is_empty() || builder.ends_with(":application"); | |
| 394 | let trim = |path: &str| path.trim().trim_start_matches("./").trim_matches('/').to_owned(); | |
| 395 | Some(match &build["options"]["outputPath"] { | |
| 396 | Value::String(path) if application => format!("{}/browser", trim(path)), | |
| 397 | Value::String(path) => trim(path), | |
| 398 | Value::Object(output) => { | |
| 399 | let base = output.get("base").and_then(Value::as_str).map_or_else(|| format!("dist/{name}"), trim); | |
| 400 | match output.get("browser").and_then(Value::as_str).map(trim) { | |
| 401 | Some(browser) if browser.is_empty() => base, | |
| 402 | Some(browser) => format!("{base}/{browser}"), | |
| 403 | None => format!("{base}/browser"), | |
| 404 | } | |
| 405 | } | |
| 406 | _ if application => format!("dist/{name}/browser"), | |
| 407 | _ => format!("dist/{name}"), | |
| 408 | }) | |
| 409 | } | |
| 410 | ||
| 411 | #[cfg(test)] | |
| 412 | mod tests { | |
| 413 | use super::*; | |
| 414 | ||
| 415 | #[test] | |
| 416 | fn config_values_are_read_quoted_or_not() { | |
| 417 | let text = "export default defineConfig({\n output: 'export',\n build: { \"outDir\": \"site\" },\n ssr: false,\n})"; | |
| 418 | assert!(sets(text, "output", "export")); | |
| 419 | assert!(!sets(text, "output", "server")); | |
| 420 | assert!(sets(text, "ssr", "false")); | |
| 421 | assert_eq!(value_of(text, "outDir").as_deref(), Some("site")); | |
| 422 | // Another key that ends the same is not it. | |
| 423 | assert!(!sets("{ distOutput: 'export' }", "output", "export")); | |
| 424 | assert_eq!(setting("baseURL = 'https://x'\npublishDir = \"site/\"", "publishDir").as_deref(), Some("site")); | |
| 425 | } | |
| 426 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.