Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
674 changes: 674 additions & 0 deletions LICENSE

Large diffs are not rendered by default.

11 changes: 9 additions & 2 deletions docs/presets.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,15 @@ Fonts / gstatic `woff2`) and `sha256`. On apply, the script:
copy, so a repeat apply doesn't re-fetch;
2. **generates** the `@font-face` rules from the manifest into a managed
`dq:preset-extra` block in `main.css`, clearing it when switching back to a
token-only preset; and
3. **prunes** any files in `src/fonts/` the active preset no longer needs.
token-only preset;
3. **writes** a matching `<link rel="preload">` per font into a managed
`dq:preset-preload` block in `templates/layout/html.html.twig` — browsers
don't fetch a font just because its `@font-face` rule was parsed, they wait
until they need it for visible text, so this starts the fetch immediately
instead and shrinks the flash-of-unstyled-text window the manifest's
`font-display: swap` default allows. A fontless preset leaves the block
empty; and
4. **prunes** any files in `src/fonts/` the active preset no longer needs.

The font is bundled into `dist/` by Vite, so the built site is self-contained
(no external font request at render time — good for the Tome static export). The
Expand Down
28 changes: 23 additions & 5 deletions docs/static-deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,15 +92,33 @@ 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.

### Previewing the export under DDEV (`--ddev-preview`)
**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.

### Previewing the export under DDEV

```bash
ddev drush dq:static --ddev-preview
ddev restart # once, to activate the new hostname
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.site
```

The flag provisions a second vhost in the same DDEV project that serves the
When `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:
Expand All @@ -112,7 +130,7 @@ are just a refresh away. It writes two files:
`config.yaml` is never edited.

Both carry a `#dq-generated` marker: they are rewritten on every
`--ddev-preview` run until the marker line is removed, at which point the
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`.
Expand Down
16 changes: 14 additions & 2 deletions src/Ddev/StaticPreview.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,22 @@ public static function nginxConf(string $fqdn, string $exportPath): string {
error_log /dev/stdout info;
access_log /var/log/nginx/access.log;

# Pure static files — no PHP handler on this vhost.
# Pure static files — no PHP handler on this vhost. HTML answers
# with no-cache so browsers revalidate on every load (a 304 when
# unchanged) and a fresh `drush dq:static` shows immediately —
# the asset types below stay cached.
#
# \$uri/index.html before \$uri/: the export's internal links are
# slashless (Drupal path form, matching the canonical tags), and
# \$uri/ alone would 301 them to the trailing-slash directory URL.
# That hop breaks cross-document view transitions — a redirect
# discards the old page's snapshot, turning the crossfade into a
# white flash — so serve the directory index at the slashless URL
# directly (200, no redirect).
location / {
absolute_redirect off;
try_files \$uri \$uri/ =404;
add_header Cache-Control "no-cache";
try_files \$uri \$uri/index.html \$uri/ =404;
}

location ~* \\.(?:jpg|jpeg|gif|png|ico|svg|webp|woff2|css|js)\$ {
Expand Down
55 changes: 55 additions & 0 deletions src/Deploy/NetlifySite.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace DrupalQuick\Deploy;

/**
* Pure logic for dq:deploy's Netlify site resolution.
*
* `netlify deploy` with no linked site prompts interactively to pick or
* create one — and dq:deploy runs the CLI in a subprocess without a TTY, so
* on a brand-new project that prompt would hang instead of asking. The
* command therefore resolves the site itself: a linked site (the CLI's
* .netlify/state.json, written on the first successful deploy) or a
* NETLIFY_SITE_ID env var means deploy-as-usual; neither means this is a
* first deploy, and the CLI gets --site-name so it creates the site
* non-interactively. Kept free of Drush/Drupal so it is unit-testable
* (tests/Unit/Deploy); file IO stays in the command.
*/
final class NetlifySite {

/**
* Extracts the linked site id from .netlify/state.json contents.
*
* Returns NULL for missing/invalid JSON or an absent/empty siteId — all
* meaning "not linked".
*/
public static function siteIdFromState(?string $stateJson): ?string {
if ($stateJson === NULL || trim($stateJson) === '') {
return NULL;
}
$data = json_decode($stateJson, TRUE);
$id = is_array($data) ? ($data['siteId'] ?? NULL) : NULL;
return (is_string($id) && $id !== '') ? $id : NULL;
}

/**
* Builds a Netlify-safe site name from a base (usually the project dir).
*
* Netlify names are a global namespace (<name>.netlify.app), so a plain
* project name is likely taken: a random suffix is appended unless the
* caller supplies one (tests inject a fixed suffix). The name is only used
* once — the first deploy links the created site's id via state.json, and
* every later deploy resolves the id, never the name.
*/
public static function generateSiteName(string $base, ?string $suffix = NULL): string {
$slug = strtolower($base);
$slug = preg_replace('/[^a-z0-9-]+/', '-', $slug);
$slug = trim((string) preg_replace('/-{2,}/', '-', $slug), '-');
if ($slug === '') {
$slug = 'site';
}
$suffix ??= bin2hex(random_bytes(3));
return "{$slug}-{$suffix}";
}

}
29 changes: 27 additions & 2 deletions src/Drush/Commands/DeployCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace DrupalQuick\Drush\Commands;

use DrupalQuick\Deploy\NetlifySite;
use Drush\Drush;
use Drush\Style\DrushStyle;
use Symfony\Component\Console\Attribute\AsCommand;
Expand Down Expand Up @@ -62,7 +63,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$this->emitDeployTemplate($target);

// 4. Deploy.
return $this->deployStatic($target, $dir);
return $this->deployStatic($target, $dir, $settings);
}

/**
Expand Down Expand Up @@ -95,7 +96,7 @@ private function emitDeployTemplate(string $target): void {
/**
* Deploys the static export to the configured target (Netlify automated).
*/
private function deployStatic(string $target, string $dir = 'html'): int {
private function deployStatic(string $target, string $dir, array $settings): int {
if ($target !== 'netlify') {
$this->io->warning("dq:deploy currently automates the 'netlify' target only. For '{$target}', deploy via its own workflow (e.g. git push for GitHub Pages — the workflow was written to .github/workflows/).");
return self::SUCCESS;
Expand All @@ -109,6 +110,30 @@ private function deployStatic(string $target, string $dir = 'html'): int {
? [$netlify, 'deploy', '--prod', "--dir={$dir}"]
: ['npx', '--yes', 'netlify-cli', 'deploy', '--prod', "--dir={$dir}"];

// Resolve the Netlify site. With no linked site (the CLI's
// .netlify/state.json, written by the first successful deploy) and no
// NETLIFY_SITE_ID, the CLI would prompt to pick or create one — and this
// subprocess has no TTY, so the prompt hangs instead of asking. Pass
// --site-name on that first deploy so the CLI creates the site
// non-interactively; the name comes from static.site_name in
// config.dq.yml, else <project-dir>-<random> (Netlify names are a global
// namespace, so a bare project name is likely taken). The name is used
// exactly once: the deploy links the created site's id into state.json,
// which every later deploy resolves instead.
$stateFile = getcwd() . '/.netlify/state.json';
$siteId = NetlifySite::siteIdFromState(file_exists($stateFile) ? (string) file_get_contents($stateFile) : NULL)
?: (getenv('NETLIFY_SITE_ID') ?: NULL);
if ($siteId === NULL) {
// Name base: the DDEV project name when inside DDEV — there the cwd is
// always /var/www/html, whose basename ("html") says nothing — else
// the project directory.
$base = getenv('DDEV_PROJECT') ?: basename(getcwd());
$siteName = $settings['site_name'] ?? NetlifySite::generateSiteName($base);
$command[] = "--site-name={$siteName}";
$this->io->writeln(" No linked Netlify site yet — creating '{$siteName}' (set static.site_name in config.dq.yml to choose the name).");
$this->io->writeln(' The new site is linked via .netlify/state.json; later deploys reuse it.');
}

$this->io->writeln('🚀 [drupalquick] Deploying to Netlify...');
$code = $this->runProcess($command);

Expand Down
15 changes: 15 additions & 0 deletions src/Drush/Commands/ScaffoldCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ final class ScaffoldCommand extends Command {
protected function configure(): void {
$this
->addOption('interactive', NULL, InputOption::VALUE_NONE, 'Prompt the user to fill out or override config values interactively.')
->addOption('force', NULL, InputOption::VALUE_NONE, 'Scaffold even when Drupal is already installed. This reinstalls the site and DESTROYS the existing database.')
->addUsage('dq:scaffold')
->addUsage('dq:scaffold --interactive');
}
Expand All @@ -65,6 +66,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int

$registry = $this->registry();

// Refuse to scaffold over a live site: the site:install in runBuild()
// drops and recreates the database, so an accidental re-run (e.g. while
// rehearsing the workflow) would destroy real content. The probe failing
// means no bootable site exists — a fresh project — which is exactly when
// scaffolding should proceed.
if (!$input->getOption('force')) {
$probe = Drush::drush(Drush::aliasManager()->getSelf(), 'php:eval', ["echo \\Drupal::state()->get('install_task') === 'done' ? 'installed' : '';"]);
$probe->run();
if ($probe->isSuccessful() && str_contains((string) $probe->getOutput(), 'installed')) {
$this->io->error('Drupal is already installed here. Re-running dq:scaffold would reinstall it and destroy the existing database. Pass --force to rebuild deliberately.');
return self::FAILURE;
}
}

// --- Interactive prompts ---
if ($input->getOption('interactive')) {
$this->io->writeln("\n💬 [drupalquick] Entering interactive configuration mode...");
Expand Down
Loading
Loading