Skip to content

Commit

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.

Claude-Session:
https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
Claudeagent and Claudeagentcommitted Parentc5c643bBrowse files
9 files+1306−1230/9 viewed
+1−1
4040 | `project` | The repository's one project with Deployments on | The project's slug. Needed when the repository has several. |
4141 | `root-dir` | The project's setting | Where the project is in the checkout. |
4242 | `build-command` | The project's setting, else what the build finds | What builds it. |
43−| `output-dir` | The project's setting, else the first of `dist`, `build`, `out`, `public`, `_site`, `.output/public` | The folder a static build writes. |
43+| `output-dir` | The project's setting, else the folder the framework writes, else the first of `dist`, `build`, `out`, `public`, `_site`, `.output/public` | The folder a static build writes. |
4444 | `sha` | The run's commit | The commit this is a build of, for its `g1t / deploy` check. |
4545 | `token` | `${{ github.token }}` | The job's own token. It needs `deployments: write`; any other token is refused. |
4646
+2−2
1212 required: false
1313 default: ""
1414 build-command:
15− description: What builds it. The project's own setting, else what the build finds (a Workers config, or the package's build script).
15+ description: What builds it. The project's own setting, else what the build finds (a Workers config, the package's build script, or the framework's own command).
1616 required: false
1717 default: ""
1818 output-dir:
19− description: The folder a static build writes. The project's own setting, else the first of dist, build, out, public, _site or .output/public.
19+ description: The folder a static build writes. The project's own setting, else the folder the framework writes, else the first of dist, build, out, public, _site or .output/public.
2020 required: false
2121 default: ""
2222 sha:
+34−4
1111 uses: g1t/detect@v1
1212 - if: steps.detect.outputs.kind == 'none'
1313 run: echo "::error::Nothing here would be served."
14+ - if: steps.detect.outputs.framework == 'hugo'
15+ run: echo "A Hugo site, served from ${{ steps.detect.outputs.output-dir }}"
1416 - if: steps.detect.outputs.install != ''
1517 run: ${{ steps.detect.outputs.install }}
1618 ```
2729
2830 | Output | |
2931 | --- | --- |
30−| `kind` | `workers` (a `wrangler.jsonc`, `wrangler.json` or `wrangler.toml`), `static` (a site a build writes), `html` (the files as they are) or `none`. |
31−| `install` | How dependencies are installed, from the lockfile: `npm ci`, `pnpm install --frozen-lockfile`, and so on. Empty without a `package.json`. |
32−| `build` | What builds it: your command, `npm run build` for a package with a build script, or `wrangler deploy --dry-run` for Workers. Empty when nothing does. |
33−| `output-dir` | The folder served, relative to the project (`.` for the project itself): a Workers project's assets directory, or the first of `dist`, `build`, `out`, `public`, `_site`, `.output/public` that exists. Empty when a build has yet to write it. |
32+| `kind` | `workers` (a `wrangler.jsonc`, `wrangler.json` or `wrangler.toml`, or a framework's Cloudflare adapter), `static` (a site a build writes), `html` (the files as they are) or `none` (nothing to serve, or a framework set up to need a server). |
33+| `framework` | The framework it is built with, from the table below. Empty when there is none. |
34+| `install` | How dependencies are installed, from the lockfile: `npm ci`, `pnpm install --frozen-lockfile`, and so on, and `bundle install` for Jekyll. Empty when there is nothing to install. |
35+| `build` | What builds it: your command, the package's `build` script (`npm run build`), the framework's own command, or `wrangler deploy --dry-run` for Workers. Empty when nothing does. |
36+| `output-dir` | The folder served, relative to the project (`.` for the project itself): yours, a Workers project's assets directory, the folder the framework writes, or the first of `dist`, `build`, `out`, `public`, `_site`, `.output/public` that exists. Empty when a build has yet to write it, as for a Cloudflare adapter's Worker. |
37+
38+When `kind` is `none` because of how a framework is set up, such as a
39+Next.js app without `output: 'export'`, the step's log says what to
40+change.
41+
42+## Frameworks
43+
44+| `framework` | Found by | Serves |
45+| --- | --- | --- |
46+| `vite`, `vite-react`, `vite-vue`, `vite-svelte`, `vite-solid`, `vite-preact` | `vite`, and the framework's Vite plugin | `dist`, or `build.outDir` |
47+| `astro` | `astro` | `dist`, or `outDir`; a Worker with `@astrojs/cloudflare` |
48+| `nextjs` | `next` | `out` with `output: 'export'`; a Worker with `@opennextjs/cloudflare` and a `wrangler.jsonc` |
49+| `sveltekit` | `@sveltejs/kit` | `build` with `adapter-static`; a Worker with `adapter-cloudflare` |
50+| `nuxt` | `nuxt` | `.output/public`, from `nuxi generate` |
51+| `react-router`, `remix` | `@react-router/dev`, `@remix-run/dev` | `build/client` with `ssr: false` |
52+| `gatsby` | `gatsby` | `public` |
53+| `docusaurus` | `@docusaurus/core` | `build` |
54+| `eleventy` | `@11ty/eleventy` | `_site` |
55+| `angular` | `@angular/core` and `angular.json` | `dist/<project>/browser` |
56+| `create-react-app` | `react-scripts` | `build` |
57+| `hugo` | `hugo.toml`, or Hugo's `config.toml` | `public` |
58+| `jekyll` | `_config.yml` and a `Gemfile` | `_site` |
59+| `mdbook` | `book.toml` | `book` |
60+
61+[What deploys](https://docs.g1t.sh/guides/deployments/#frameworks) has
62+each one's build command. Your `build-command` and `output-dir` win over
63+what is detected.
3464
3565 It runs `g1t-runner detect`, found or downloaded as `g1t/deploy` finds it.
+5−2
11 name: Detect a g1t.page project
2−description: Says what a g1t.page build of this checkout would find, without building anything. Its kind, how it installs, what builds it and where its output is.
2+description: Says what a g1t.page build of this checkout would find, without building anything. Its kind and framework, how it installs, what builds it and where its output is.
33 author: g1t
44
55 inputs:
1818
1919 outputs:
2020 kind:
21− description: workers (a Wrangler config), static (a site a build writes), html (the files as they are) or none.
21+ description: workers (a Wrangler config, or a framework's Cloudflare adapter), static (a site a build writes), html (the files as they are) or none (nothing to serve, or a framework set up to need a server).
2222 value: ${{ steps.detect.outputs.kind }}
2323 install:
2424 description: How its dependencies are installed, such as npm ci. Empty without a package.json.
2929 output-dir:
3030 description: The folder that is served, relative to the project (. for the project itself). Empty when a build has yet to write it.
3131 value: ${{ steps.detect.outputs.output-dir }}
32+ framework:
33+ description: The framework it is built with, such as vite, astro, nextjs, sveltekit or hugo. Empty when there is none.
34+ value: ${{ steps.detect.outputs.framework }}
3235
3336 runs:
3437 using: composite
+5−2
367367
368368 - **Why.** Apps run on Cloudflare Workers, which run only while they answer a
369369 request. That is why an app nobody visits costs nothing.
370−- **Instead.** Deploy the server from a workflow to wherever it runs today,
371− with its credentials in [secrets](/guides/secrets-and-variables/).
370+- **Instead.** Build a framework app as a static export or with its
371+ Cloudflare adapter, as [Frameworks](/guides/deployments/#frameworks)
372+ lists; a build set up to need a server fails and says which. Or deploy
373+ the server from a workflow to wherever it runs today, with its
374+ credentials in [secrets](/guides/secrets-and-variables/).
372375 - **Status.** A runtime for servers is planned.
373376
374377 ### Some Workers bindings are not provisioned
+1−1
268268 | Action | What it does |
269269 | --- | --- |
270270 | [`g1t/deploy@v1`](https://g1t.sh/g1t/deploy) | Builds the checkout and puts it on [g1t.page](/guides/deployments/): production from the project's production branch, a preview from a pull request or another branch. The job needs `deployments: write`. See [deploying from a workflow](/guides/deployments/#deploying-from-a-workflow). |
271−| [`g1t/detect@v1`](https://g1t.sh/g1t/detect) | Says what that build would find (`kind`, `install`, `build`, `output-dir`) without building anything. |
271+| [`g1t/detect@v1`](https://g1t.sh/g1t/detect) | Says what that build would find (`kind`, `install`, `build`, `output-dir`, `framework`) without building anything. |
272272
273273 Both are composite actions around `g1t-runner`, which g1t's machines
274274 have; on a [self-hosted runner](/guides/self-hosted-runners/) they use
+59−6
9595 | The project has | g1t |
9696 | --- | --- |
9797 | `wrangler.jsonc`, `wrangler.json` or `wrangler.toml` | Builds it as a **Workers project**: installs dependencies, runs `wrangler deploy --dry-run` to bundle it (which runs the config's own `build` command), and deploys the bundle with the config's static assets, `compatibility_date`, `compatibility_flags` and `vars`. |
98+| A framework from the table below | Installs dependencies, builds it with the framework's command, and serves the folder it writes as a **static site**, or deploys the Worker its Cloudflare adapter writes as a **Workers project**. |
9899 | A `build` script in `package.json` | Installs dependencies, runs `npm run build`, and serves the output as a **static site**. |
99100 | An `index.html` and nothing to build | Serves it as it is. |
100101
101102 **Settings → Deployments** shows what g1t detected at the project's last
102103 finished build (**Workers project**, **Static site** or **Plain HTML**),
103104 and what running it costs. Before the first build it says **Detected at
104−the first build**.
105+the first build**. The framework is named at the top of the build's log,
106+as `Detected Astro: framework=astro.`
105107
106108 Dependencies are installed by the lockfile that is there: `npm ci`,
107109 `pnpm install --frozen-lockfile`, `yarn install` or `bun install`, and
108110 `npm install` with no lockfile.
109111
110−A static site is served from the first of these that exists after the
111−build: `dist`, `build`, `out`, `public`, `_site`, `.output/public`. Set
112−**Output directory** to choose another.
112+### Frameworks
113113
114+g1t knows a framework by the packages in `package.json`
115+(`dependencies` and `devDependencies`) and by its config file. The build
116+command is the package's own `build` script when it has one, else the
117+command in the table. A **Build command** or **Output directory** of
118+your own, under **Settings → Deployments**, always wins over the table.
119+
120+| Framework | Found by | Builds with | Serves |
121+| --- | --- | --- | --- |
122+| Vite, and React, Vue, Svelte, Solid or Preact on Vite | `vite` | `vite build` | `dist`, or `build.outDir` from `vite.config` |
123+| Astro | `astro` | `astro build` | `dist`, or `outDir` |
124+| Astro with `@astrojs/cloudflare` | `astro`, `@astrojs/cloudflare` | `astro build` | A Workers project: the Worker and config the adapter writes |
125+| Next.js with `output: 'export'` | `next`, and `output: 'export'` in `next.config` | `next build` | `out` |
126+| Next.js with OpenNext | `next`, `@opennextjs/cloudflare` and a `wrangler.jsonc` | `opennextjs-cloudflare build`, then `wrangler deploy --dry-run` | A Workers project |
127+| SvelteKit with `@sveltejs/adapter-static` | `@sveltejs/kit` and the adapter | `vite build` | `build`, or the adapter's `pages` |
128+| SvelteKit with `@sveltejs/adapter-cloudflare` | `@sveltejs/kit` and the adapter | `vite build` | A Workers project: `.svelte-kit/cloudflare/_worker.js` and the files beside it |
129+| Nuxt | `nuxt` | `npm run generate` if there is one, else `nuxi generate` | `.output/public` |
130+| React Router in SPA mode | `@react-router/dev`, and `ssr: false` in `react-router.config` | `react-router build` | `build/client` |
131+| Remix in SPA mode | `@remix-run/dev`, and `ssr: false` in `vite.config` | `remix vite:build` | `build/client` |
132+| Gatsby | `gatsby` | `gatsby build` | `public` |
133+| Docusaurus | `@docusaurus/core` | `docusaurus build` | `build` |
134+| Eleventy | `@11ty/eleventy` | `eleventy` | `_site`, or `dir.output` |
135+| Angular | `@angular/core` and `angular.json` | `ng build` | `dist/<project>/browser`, from `angular.json` |
136+| Create React App | `react-scripts` | `react-scripts build` | `build` |
137+| Hugo | `hugo.toml`, `hugo.yaml` or `hugo.json`; or a Hugo `config.toml` and no `package.json` | `hugo --minify` | `public`, or `publishDir` |
138+| Jekyll | `_config.yml` and a `Gemfile` | `bundle install`, then `bundle exec jekyll build` | `_site`, or `destination` |
139+| mdBook | `book.toml` | `mdbook build` | `book`, or `build.build-dir` |
140+
141+- **Frameworks that need a server fail early, saying what to change.**
142+ g1t.page runs static sites and Workers, not servers that stay up. A
143+ Next.js app without `output: 'export'`, an Astro site with
144+ `output: 'server'` and no Cloudflare adapter, a SvelteKit app with
145+ `adapter-auto` or `adapter-node`, and a React Router or Remix app that
146+ renders on the server each fail before anything is installed, with the
147+ change that makes them deploy: a static export, or the framework's
148+ Cloudflare adapter. An **Output directory** of your own skips the check.
149+- **A framework's Worker.** Astro's and SvelteKit's Cloudflare adapters,
150+ OpenNext, and `@cloudflare/vite-plugin` with a Wrangler config build a
151+ Worker. g1t runs the build, then bundles what it wrote with
152+ `wrangler deploy --dry-run`, reading the config the build wrote
153+ (`.wrangler/deploy/config.json`) when there is one. A `_worker.js`,
154+ `_routes.json` or `.assetsignore` at the top of the static files, and
155+ the names `.assetsignore` lists, are never served.
156+- **Hugo and mdBook** use the `hugo` or `mdbook` on the machine's `PATH`.
157+ Without one, the build fetches Hugo 0.166.0 (the extended edition, with
158+ npm) or builds mdBook 0.5.4 with cargo, which takes a minute or two.
159+ Jekyll's gems are installed into `vendor/bundle` in the project.
160+
161+A project that is none of these is served from the first of these that
162+exists after the build: `dist`, `build`, `out`, `public`, `_site`,
163+`.output/public`. Set **Output directory** to choose another.
164+
114165 ### Static sites
115166
116167 - A site without a `404.html` is treated as a single-page app: an address
294345
295346 `g1t/detect` says what a build of the checkout would find, without
296347 building anything: its outputs are `kind` (`workers`, `static`, `html` or
297−`none`), `install`, `build` and `output-dir`. Use them to cache, or to
298−fail early:
348+`none`), `install`, `build`, `output-dir` and `framework` (such as
349+`astro` or `nextjs`, empty when there is none). A framework set up to
350+need a server is `none`, and the step's log says what to change. Use
351+them to cache, or to fail early:
299352
300353 ```yaml
301354 - id: detect
+773−105
A large diff (1,071 lines) is not shown until you ask for it.
+426−0
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+}