Skip to content
426 linesCodeBlameRaw
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
9use std::collections::BTreeSet;
10use std::path::Path;
11
12use regex::Regex;
13use serde_json::{Map, Value};
14
15/// Hugo, from the `hugo` on `PATH`, else Hugo's own release by npm.
16const 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.
18const 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.
20const 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)]
25pub(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)]
34pub(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
54impl 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.
67struct Package {
68 dependencies: BTreeSet<String>,
69 scripts: Map<String, Value>,
70}
71
72impl 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.
100fn 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.
118fn 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
127fn 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`).
132fn 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.
141fn 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
146const NEXT_CONFIGS: [&str; 4] = ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"];
147const ASTRO_CONFIGS: [&str; 5] = ["astro.config.mjs", "astro.config.js", "astro.config.ts", "astro.config.mts", "astro.config.cjs"];
148const SVELTE_CONFIGS: [&str; 3] = ["svelte.config.js", "svelte.config.mjs", "svelte.config.ts"];
149const VITE_CONFIGS: [&str; 6] = ["vite.config.ts", "vite.config.js", "vite.config.mjs", "vite.config.mts", "vite.config.cjs", "vite.config.cts"];
150const REACT_ROUTER_CONFIGS: [&str; 4] = ["react-router.config.ts", "react-router.config.js", "react-router.config.mjs", "react-router.config.mts"];
151const 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.
156pub(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.
195fn 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>`.
381fn 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)]
412mod 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}