| 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 | } |