Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
426 linesCodeBlameRaw

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.

g1t.page builds know the framework a project is built with, from its package's dependencies and config files: Vite (and React, Vue, Svelte, Solid or Preact on it), Astro, Next.js with `output: 'export'`, SvelteKit with the static adapter, Nuxt by `nuxi generate`, React Router and Remix in SPA mode, Gatsby, Docusaurus, Eleventy, Angular, Create React App, Hugo, Jekyll and mdBook are each built with their own command and served from the folder they write, read from their config where it moves it. A Cloudflare adapter's Worker deploys as a Workers project with no Wrangler config of the project's own: Astro's, which now writes its config for wrangler in `.wrangler/deploy/config.json` (as `@cloudflare/vite-plugin` does, and that is read too), SvelteKit's `.svelte-kit/cloudflare/_worker.js`, and OpenNext's with a `wrangler.jsonc`; a site's `_worker.js`, `_routes.json` and what `.assetsignore` lists are never uploaded, and a written config's empty binding lists are no longer warned about. A framework set up to need a server (Next.js without a static export, Astro's server output, SvelteKit's auto or node adapter, a server-rendered React Router or Remix app) fails before anything is installed, saying what to change, in place of "found nothing to serve". The project's Build command and Output directory still win. The build's log names the framework, the finish report carries it as `framework` (which the deployments service ignores for now), and `g1t-runner detect` and the `g1t/detect` action output it. What deploys has a table of every framework, and the detect action's README lists them.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}