Quick's goal is quick in two senses: fast provisioning and a fast end result. Part of that is the ability to ship the finished site as a static, HTML-only build — provision Drupal locally, scaffold a theme and content, remove every trace of Quick, then deploy a performant static site.
This is done with Tome (the tome_static submodule) driven
by the drush dq:static command. No front-end framework is involved — the
output is plain HTML/CSS/JS rendered by Drupal itself.
- Drush-driven (
drush tome:static) — the same execution model the rest of Quick uses. - Plain HTML output. Tome renders every anonymous-accessible route and
entity canonical path through Drupal's own HTTP kernel, then collects the
referenced stylesheets, scripts, images (incl.
srcset), favicons, and pager links. The theme's builtdist/main.css/dist/main.jsare captured as-is. - Reinforces "no footprint of Quick." The deployed artifact is just static
files — no Drupal, no PHP, no Quick. Drupal + Tome remain locally as the
authoring/build environment;
dq:cleanupstill removes Quick itself. - Deploy targets built in — Tome documents GitHub Pages, Netlify, Render, and more.
- Drupal 11 support — Tome
8.x-1.14(Feb 2026) requires^10 || ^11and is covered by Drupal's security advisory policy. Note it is minimally maintained / feature-complete, so treat it as stable-but-static, not actively evolving.
Static export spans three concerns, each with a natural home:
| Concern | Home | Why |
|---|---|---|
| Project settings (deploy target, base URL) | config.dq.yml static: block |
The single file the user already edits |
| The operation (install Tome, build check, export, emit deploy config) | drush dq:static command |
Recurring/imperative; recipes cannot run an export |
| Capability install (Tome module + its config) | a recipe (future) | Declarative/composable — but premature for one contrib module |
So the current design is config + command, not a recipe. A recipe can't
perform the export, so the command is required regardless, and a recipe that only
does install: [tome_static] is too thin to justify today. Once the recipe
ecosystem is externalized and self-describing (see
extensibility.md), "static export capability" becomes a
natural candidate to extract into a recipe-static.
dq:cleanup deletes config.dq.yml, but static export is recurring — you
re-export whenever content changes, often long after Quick is gone. So settings
cannot live only in config.dq.yml. dq:static therefore persists the
resolved settings into Drupal config (drupalquick.static) on first run.
Resolution order:
drupalquick.staticconfig (survives cleanup) — wins if present.- Otherwise the
static:block inconfig.dq.yml— seeds the first run.
This is also why a recipe is the eventual right home: it would write that persisted config declaratively.
# config.dq.yml
static:
target: "netlify" # netlify | github | none
uri: "https://example.com" # base URL for absolute links (optional)# after the site is scaffolded and the theme is built
ddev drush dq:static
# or override the base URL ad hoc:
ddev drush dq:static --base-url=https://example.com
# then publish the export to the configured target:
ddev drush dq:deploydq:deploy is a separate command (so generation and publishing each carry their
own flags). It reads the target from the persisted drupalquick.static config
(seeded by dq:static), writes the target's deploy config, and pushes. Only
Netlify is automated for now: netlify deploy --prod --dir=html, using a
globally installed netlify CLI if present, otherwise npx netlify-cli. GitHub
Pages deploys via its own workflow (git push), so for that target dq:deploy
just writes .github/workflows/deploy-pages.yml. It is loosely coupled to the
build: if html/ is missing it tells you to run dq:static first rather than
regenerating implicitly.
First deploy (Netlify): with no site linked yet, the bare CLI would prompt
interactively to pick or create a site — and dq:deploy runs it in a
subprocess without a TTY, where that prompt would hang. So when neither the
CLI's link file (.netlify/state.json) nor NETLIFY_SITE_ID resolves a site,
dq:deploy passes --site-name to create one non-interactively: the name
comes from static.site_name in config.dq.yml if set, else
<project>-<random> (Netlify names are a global namespace — a bare common
name is usually taken). The name is used exactly once: the deploy writes the
created site's id to .netlify/state.json, and every later deploy resolves
that id, never the name. Keep state.json (or set NETLIFY_SITE_ID in CI) —
without either, a re-run would create a second site. Custom-domain mapping
(e.g. pointing quickthe.me at the new site) stays a one-time manual step in
the Netlify dashboard.
ddev drush dq:static # inside DDEV the preview vhost is provisioned automatically
ddev restart # once, to activate the new hostname
# then browse https://static.<project>.ddev.siteWhen dq:static runs inside a DDEV web container (detected via DDEV's
IS_DDEV_PROJECT environment variable) the preview vhost is provisioned
automatically; pass --no-ddev-preview to opt out, or --ddev-preview to
require it (outside DDEV, or to make a missing .ddev/ an error instead of
a note). The provisioning creates a second vhost in the same DDEV project that serves the
export directory as plain files (no PHP handler), beside the live site — so
the export can be checked exactly as a host would serve it, and re-exports
are just a refresh away. It writes two files:
.ddev/nginx_full/static.conf— a static-only nginx server block rooted at the export directory..ddev/config.static.yaml— a DDEV config override registeringstatic.<project>inadditional_hostnames, so the user's ownconfig.yamlis never edited.
Both carry a #dq-generated marker: they are rewritten on every
provisioning run until the marker line is removed, at which point the
tool leaves them alone (the same ownership convention DDEV uses for its
generated files). The command runs inside the web container, so it cannot
restart DDEV itself — hence the one-time ddev restart.
Caveat: DDEV config overrides replace list values. If the project already
declares additional_hostnames in config.yaml, fold the static hostname
into that list and delete config.static.yaml.
dq:deploy runs inside the DDEV web container, so the credentials must be present
there — and they are secrets, so they must stay out of version control. The
mechanism:
-
dq-init --ddevdelivers.ddev/.env.web.example(keys, no values) and adds.ddev/.env.webto.gitignore. -
Copy it and fill in your token:
cp .ddev/.env.web.example .ddev/.env.web # edit .ddev/.env.web → NETLIFY_AUTH_TOKEN=... (NETLIFY_SITE_ID optional) ddev restart
DDEV loads .ddev/.env.web into the web container, where netlify deploy picks
the token up. Never put the token in config.local.yaml (its
web_environment is committed) or anywhere tracked.
Don't want secrets in the container at all? Skip dq:deploy and deploy the export
from the host, where netlify login already stored credentials:
netlify deploy --prod --dir=htmldq:static will:
- Resolve settings (persisted config, else
config.dq.yml). composer require drupal/tomeand enabletome_staticif not already present.- Persist
target/uritodrupalquick.static(so it survivesdq:cleanup). - Preflight the active theme — abort if the Vite dev marker is present, warn if
dist/main.cssis missing. - Run
drush tome:static(with--uriif configured).
dq:deploy then:
- Confirms
html/exists (else tells you to rundq:static). - Writes the deploy config for the target (
netlify.tomlor.github/workflows/deploy-pages.yml). - Pushes the export to the target (Netlify automated; GitHub via its workflow).
Output lands in html/ (Tome's default; override with
$settings['tome_static_directory'] in settings.php).
- Dynamic features don't survive static. Forms, search, comments, and anything authenticated are gone. The blog/content-display starter is fine; search would need a static approach (e.g. Pagefind/Lunr) and forms a third-party (Netlify Forms / a function).
- Vite dev mode must be off. If the
.vite-devmarker exists, the theme injectslocalhost:5173dev-server tags, which the export would capture.dq:staticaborts if it detects the marker. Runnpm run buildfirst. - Build the theme first. Without
dist/main.*, the export is unstyled. - Base path / rewrites vary by host. Subdir vs root and clean-URL rewrites differ per platform; the emitted deploy config is a starting point.
- Tome maintenance. Stable on D11 but feature-complete/fixes-only.
- Static Suite — actively maintained but built around exporting JSON for an external SSG (Astro/Gatsby/Eleventy), which reintroduces a JS build chain — contrary to the "no framework / keep it light" goal.
- Static Generator / Static / Static Node Generator — partial or on-demand exporters with smaller communities and weaker D11 maintenance.
- Crawl/mirror tools (wget/httrack) — crude and fragile with Drupal asset URLs.
Tome remains the best fit for full-site, framework-free HTML output.
Today deployStatic() handles one automated target (Netlify). Don't
pre-abstract for one target — but it would balloon if more were added as
inline branches, so here is the intended progression:
-
At target #2: turn
deployStatic()into a thin dispatcher and give each target its own small method, so each one's quirks (auth checks, flags) stay isolated:private function deployStatic(string $target): int { return match ($target) { 'netlify' => $this->deployNetlify(), 'vercel' => $this->deployVercel(), 'github' => 0, // deploys via git push; no-op here default => $this->warnUnsupported($target), }; }
deployStatic()stays ~6 lines regardless of target count. -
If targets trend homogeneous (most static hosts are "run a CLI against the output dir":
netlify,vercel,surge,wrangler pages,aws s3 sync,firebase), promote to a data-drivendeploy-targetsmap — the same pattern asrecipe-registry.json— so a new target is data, not code. Keep a method escape hatch for non-CLI targets: GitHub Pages deploys via git push, not a one-line command. -
Only for external contributions: a
StaticDeployerInterfacewith one class per target and discovery. This is the textbook answer but overkill now, and it fights the runtime context —dq:staticruns in a Drush command with an unreliable container, so heavy DI/plugin discovery is awkward. Reserve it for when packages must contribute their own deployers (see extensibility.md).
- Now:
config.dq.ymlstatic:block +dq:staticcommand (this prototype) — installs Tome idempotently, persists settings, exports, emits a deploy template. - Next: richer deploy targets and a
--buildflag that runs the theme build before exporting. - Later: extract Tome install + config into a
recipe-staticonce recipes are externalized, and consider a static search integration (Pagefind) for the lost dynamic search.