gulp-to-vite
Skill0 starsChanged 26 days agoMITView on GitHub
Migrate a Dockerized, Makefile-driven Drupal theme front build from Gulp to Vite. Use when the user mentions replacing Gulp with Vite, a "gulp to vite" migration, modernizing a Drupal theme build, dropping the gulp-* dependency tree, gulp-imagemin ARM/Alpine build pain, an `npm install` that fails on optipng-bin, or a Drupal theme whose SCSS/JS edits do not hot-reload. Covers the feasibility verdict, the plugin-to-disk (Arch B) approach that keeps *.libraries.yml untouched, ready-to-fork Vite plugins, the dev HMR wiring, the Drupal/Apache caching layers that must be disabled for HMR to work at all, and Docker/Makefile/CI changes. IMPORTANT: all build commands MUST go through Makefile/npm targets inside the node container, never run directly on the host.
- Reads credentials
- Deletes or force-pushes
- Installs packages
In this repository17 other files it gives its agents.
- CLAUDE.mdCLAUDE.md
- d11Skill
- drupal-build-foundationsSkill
- drupal-build-intakeSkill
- drupal-buildSkill
- drupal-build-unitSkill
- drupal-rgaaSkill
- drupal-scaffoldSkill
- jquery-4-migrationSkill
- lando-to-dockerSkill
- phpcbfSkill
- phpcsSkill
- php-deprecations-auditSkill
- php-docker-upgradeSkill
- php-standardsSkill
- phpstanSkill
- sass-migratorSkill
The file
Show its rules and text
We split this skill into the rules it gives an agent
14 rules. About 0 are common, 5 are distinctive, and 9 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈5
Distinctive · estimate
This author's own choices. Worth a look.
9
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Very specific: line 58
Before framing this as modernization, run a clean install in the container (rm -rf node_modules && npm install).
- Distinctive: line 66
When that is the case, say so plainly — the migration is not a nice-to-have, it is the fix for a broken local front build, and the …
- Distinctive: line 77
Verify each of these; they decide the approach and avoid nasty surprises:
- Very specific: line 146
NOT generated from any SCSS source (git ls-files <theme>/css)?
- Very specific: line 201
Only pick Arch A if the goal is fleet-wide dist/ uniformity and you accept the churn.
- Very specific: line 245
Every plugin runs its work in buildStart and handleHotUpdate (then server.ws.send({type:'full-reload'})) — that dual write is what lets …
- Very specific: line 280
Put the two $config lines in the project's shared local settings file (a tracked base.settings.php or equivalent), not in a personal …
- Distinctive: line 301
Verify by checking headers, not by eyeballing the page:
- Very specific: line 348
Test the absent case with env -u APP_ENV php -r …: overriding the superglobal alone is not enough, because the container's real env still …
- Distinctive: line 370
Clean diff: no regenerated build artifact committed (git checkout HEAD -- the force-committed ones.
- Very specific: line 377
Hand the dev server back: if you started one to verify, kill it before telling the user to run make npm-dev — strictPort:true means yours …
- Very specific: line 388
Only then look at the HMR websocket (hmr.host/clientPort/protocol).
- Very specific: line 390
If the page contains no @vite/client tag at all, none of the above applies — the APP_ENV gate is reading an empty source (Step 1.11).
- Distinctive: line 476
Iso-functional migration and follow-up in one MR: if the concat-manifest retirement lands in the same MR anyway, the manifest fallback …
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Gulp → Vite migration skill (Dockerized Drupal theme)
Migrate a Drupal theme front build from Gulp to Vite,
iso-functional and low-risk. Reference implementations: Project D (Tailwind) and
Project B (SASS + classic jQuery scripts — the case this skill is tuned for).
Golden rule: everything goes through the container
The project is Dockerized + Makefile-driven. Run the build inside the node
service, never on the host — the host arch (esp. Apple Silicon/macOS) makes the
legacy imagemin-* native binaries fail with ENOEXEC, which is not a code
bug. The migrated build uses sharp (prebuilt musl/arm64 binaries), which does
not have this problem.
| Need | Command |
| Install deps | make npm-install (or make npm c='install') |
| Dev server (watch + HMR) | make npm-dev |
| Production build | make npm-build |
| One-off verify build | docker run --rm -v "$(pwd)":/var/www/html -w /var/www/html node:<LTS>-alpine sh -c 'rm -rf node_modules && npm install && npm run build' |
Step 0 — Feasibility verdict (say this out loud first)
For a classic GM theme, Vite's headline features (bundling, tree-shaking, per-module HMR) buy almost nothing. Check why before promising speed:
- Theme JS is usually classic jQuery/
Drupal.behaviorsIIFEs (noimport/export). Gulp just terser-copies them 1:1. → grep the JS source; if there are zeroimport/export, do not bundle — copy files. - Vendor libs (swiper, fancybox, owl, masonry…) are loaded straight from
node_modules/by*.libraries.yml, never touched by Gulp. → keep that. - CSS is SASS; Vite compiles SASS natively but so did gulp-sass. No win.
- HMR degrades to full page reload for non-module scripts — exactly what Gulp + BrowserSync already did. No regression, no gain.
The real payoff is maintenance, not speed: dropping the ~35-package gulp-*
tree (especially gulp-imagemin, whose Alpine/ARM native build often needs a
Dockerfile patch) and standardizing with the rest of the fleet. Frame the work
this way so expectations are correct.
First check whether the Gulp build still installs at all
Before framing this as modernization, run a clean install in the container
(rm -rf node_modules && npm install). On some projects it fails outright:
gulp-imagemin → optipng-bin compiles fine with the Alpine toolchain, then its
post-install binary check fails, and npm rolls back the whole install, leaving
node_modules with 0 entries. Symptom: the theme's css/ and js/ are empty
and the site renders unstyled, while CI still passes (Debian/x86 images get a
working prebuilt binary).
When that is the case, say so plainly — the migration is not a nice-to-have, it is the fix for a broken local front build, and the dependency blocking every install is one the production build never even calls. Lead with that; it is a far stronger justification than fleet uniformity.
It also means there may be no runnable Gulp baseline to diff against. Don't
stall trying to produce one: derive the expected output set from the deploy task
chain plus *.libraries.yml (Step 1) and verify against that instead.
Step 1 — Investigate before touching anything
Verify each of these; they decide the approach and avoid nasty surprises:
What Gulp actually builds — read
gulpfile.js+ rootpackage.jsonpaths. Scope the port to thedeploytask chain, not to the functions the gulpfile defines. A 400-line gulpfile commonly runs only 3 steps; everything else is dead weight that must NOT be ported. Print the chain (const deploy = gulp.series(…)) and port exactly that.Classify every other function before deciding, because "defined" ≠ "works":
- unwired — never referenced by an exported task (favicon, tarteaucitron).
- wired but crashes — e.g. a task globbing
paths.sources.sass_customwhen that key doesn't exist inpaths, sogulp.src(undefined)throws. - wired but unbuildable — its SCSS
@imports a path that no longer exists (a sibling theme restructured). This is usually why it was left out ofdeploy. Porting it turns a green build red. - not in deploy, output committed — images/sprites. The committed files
are the source of truth; a Vite image plugin would rewrite hundreds of
tracked files for nothing. Drop it, along with every
imagemin-*package.
Rule of thumb: if
deploydoesn't call it and its output is committed or unreferenced, leave it out and say so in the verdict. Reproducing a broken task faithfully is not iso-functional, it is just broken.- JS style —
grep -rlE '^\s*(import|export)\s|require\(' <theme>/src/js. Empty ⇒ classic scripts ⇒ copy, don't bundle. Then check which of the two classic shapes it is, because they need different plugins:- per-file copy — Gulp globs
src/js/**/*.jsand terser-copies each one. - concat manifest — a root JSON (e.g.
site.jsonwith ajsFileslist) concatenates vendor libraries plus the theme scripts into a singlescript.min.js. Order is load-bearing, so the plugin must preserve the manifest sequence exactly and esbuild is only a minifier. Give the plugin a fallback tosrc/js/*.jswhen the manifest is absent, so deleting it later is a no-op. Replacing the manifest with real*.libraries.ymlentries is a separate follow-up, not part of the iso-functional migration →references/vendor-libs-to-libraries.md.
- per-file copy — Gulp globs
*.libraries.yml— count entries. Per-paragraph CSS/JS mapped 1:1 (dozens of fixed paths) ⇒ rewriting todist/is high-churn ⇒ prefer Arch B.Also list every theme-relative path it declares and check each one is actually produced by the old build. Gulp typically globs
**/*.jsonly, so a declared sibling asset likejs/libs/tablesaw/tablesaw.cssis never emitted and 404s in production today. Same for.info.ymlckeditor5-stylesheets, which often points at a path from a previous theme layout. Ask whether to fix these in scope; they are one-line fixes (copy extra extensions / correct the path) and the migration is the natural moment. This list is also your verification target in Step 4.Icon font — do NOT assume
gulp-iconfontis vestigial; check whether it is in the deploy chain. When it is live, it has no Vite equivalent, and it is the single hardest part of the port. Three options, offer them explicitly:- freeze as static (usually best) — keep the committed
_fonticon.scssandicons-*.woff/woff2and drop the task, plus an opt-innpm run iconsscript for the rare regeneration. Icons change rarely, and the old task minted a new timestamped font on every build, so dozens oficons-<timestamp>.*files are typically already committed. - port it —
svgicons2svgfont+svg2ttf+ttf2woff+ttf2woff2, plus something to replacegulp-consolidatefor the Twig→SCSS template (reuse the project's existingtwigdep to keep output shape identical). - keep Gulp only for it — undercuts the payoff; last resort.
Whichever is chosen, note that the generated
_fonticon.scssis a live SASS input (@used bytheme.scss/_mixins.scss), not an output — never delete it. Also flag the ordering bug these gulpfiles usually have:fonticonruns aftercompileSassin the series, so a regenerated font only takes effect on the next build.- freeze as static (usually best) — keep the committed
- Two npm projects — the root
package.jsonholds the build tooling, but*.libraries.ymlpaths likenode_modules/<pkg>/…resolve relative to the theme, so the theme has its ownpackage.json+node_modules. Confirm both installs happen; a Makefilenpm-installthat only covers the root leaves every vendor library 404. - Hand-maintained outputs — is
css/ckeditor.css(or similar) committed but NOT generated from any SCSS source (git ls-files <theme>/css)? If so, Vite must never clobber it (emptyOutDir:false, write only real entries). Conversely, ifcss/andjs/are fully.gitignored and empty, there is nothing to protect — confirm before addingemptyOutDirgymnastics. - Favicon — if Gulp uses
gulp-real-favicon(remote API) but the favicon markup inhtml.html.twigis hardcoded andfavicons.jsonis unused at runtime, drop favicon from the build; the committed PNG/ico stay as static assets. Confirm the twig consumer before deleting. - Fonts / tarteaucitron / other statics — if not in any Gulp task and not
.gitignored, they're committed statics; leave them. .gitignore— which outputs are ignored vs force-committed? Force-committed build artifacts (sprites.svg, admindist/theme.css) will show as modified after any build; keep them out of the migration commit (revert to HEAD).- Docker/Makefile/CI — the
nodeservice env (THEME_FOLDER,HOME_URL, exposed port), existingnpm-*Makefile targets (often pre-scaffolded), and the CI job that runsgulp deploy(its artifact paths must stay identical). - How the project loads
.env— this decides how the dev gate readsAPP_ENV, and getting it wrong makes the HMR client silently never attach (or, worse, attach in prod).grep -n dotenv composer.jsonand readload.environment.php:vlucas/phpdotenv—Dotenv::createImmutable(__DIR__)->safeLoad()populates$_ENVand$_SERVERonly;getenv()returnsFALSE. Read$_ENV.symfony/dotenv(whatdrupal/dotenvpulls in) —(new Dotenv())->usePutenv(TRUE)->bootEnv(DRUPAL_ROOT . '/../.env')alsoputenv()s, so$_ENV,$_SERVERandgetenv()all agree. Readgetenv()— it is the shortest and the only one that survives a missingvariables_order=E.- neither — the value exists only because the compose
environment:block passes it, which reaches PHP only withclear_env = noin PHP-FPM'swww.conf($_ENV/$_SERVERadditionally needEinvariables_order). Abase.settings.phpthat guards on$_ENV['APP_ENV']tells you which source the project already trusts; align with it instead of inventing a fallback chain. Confirm empirically in the web context, not just the CLI:docker compose exec -T --user www-data php php -r 'var_dump(getenv("APP_ENV"), $_ENV["APP_ENV"] ?? NULL, ini_get("variables_order"));'thencurl -ksS https://<project>.dev.localhost/ | grep -c '@vite/client'.
- Drupal-side caching —
drush config:get system.performance. Ifcss.preprocess/js.preprocessaretrueorcache.page.max_ageis non-zero, HMR cannot work no matter how correct the Vite side is. See the caching chain in Step 3.6; budget for it, it is the single most likely reason the user reports "it doesn't hot-reload".
Step 2 — Choose the architecture
Arch A — Project D-faithful (dist/) | Arch B — plugin-to-disk (RECOMMENDED) | |
| CSS/JS | Rollup entrypoints → dist/ | Plugins compile/copy → legacy css/ js/ images/ |
*.libraries.yml | rewrite ~all paths to dist/… | untouched |
.info.yml ckeditor_stylesheets, Twig | rewrite | untouched |
| Drupal wiring | hook_library_info_alter + hot-replacement swap per asset | one dev-only @vite/client inject |
| HMR | true per-module (for modules) | full page reload (== old BrowserSync) |
| Churn / risk | high | low — invisible migration |
For classic-script SASS themes, pick Arch B. It keeps every library path,
info.yml and Twig reference untouched — the migration is invisible to Drupal.
Only pick Arch A if the goal is fleet-wide dist/ uniformity and you accept the
churn.
Step 3 — Implement Arch B
See references/arch-b-implementation.md for the complete, verified,
copy-pasteable code (package.json, vite.config.mjs, the 4 plugins, the Drupal
dev wiring, Docker/Makefile/CI edits).
For the follow-up that retires a concat manifest and serves the vendor
libraries from the theme's own node_modules, see
references/vendor-libs-to-libraries.md — do it on its own branch, with the
jQuery 4 step, never inside the iso-functional migration.
The shape:
package.json— drop everygulp-*+browser-sync/pump/require-dir, and everyimagemin-*. Addvite sass esbuild postcss postcss-pxtorem sharp svgo cheerio tinyglobby(plus@vitejs/plugin-basic-sslonly for TLS Option A, see step 4), each at its latest version. Set"type": "module"andengines.nodeto the Node LTS major; scriptsdev: vite,build: vite build. Deletegulpfile.js. Node = current Active LTS (24 as of late 2026, check endoflife.date) in the node image,enginesand the CI image. See "Versions" in the reference:imagemin-*andfast-globpull critical advisories that have no fix.vite.config.mjs— dev server (basicSsl()for TLS Option A, or plain HTTP behind Traefik for Option B; the theme's exposed port,usePolling,wssHMR); alive-reload-templatesplugin full-reloading on.twig/.theme/.php/.inc/.yml; and a no-op virtual entry +build.write:falsesovite buildfires the plugins without a real entrypoint (classic JS can't be a Rollup input).plugins/(fork from Project D, parametrized, output to LEGACY paths):vite-sass—style.scss→css/style.css;tinyglobbythe per-paragraph SCSS (ignore_*) →css/theme/…; also admintheme.scss→styles/dist. Applypostcss-pxtoreminside the plugin (plugins bypass Vite's CSS pipeline, sopostcss.configis not auto-applied).vite-copy-js— recursive walk ofsrc/js, esbuild-minify each file →js/, structure preserved.vite-images— sharp + svgo:tinyglobbysrc/images(ignoresprites/**) →images/, with encoder settings equivalent to the old imagemin task (lossless PNG, neverpalette: true; the old svgo plugin list copied as-is) and never an output bigger than its source.vite-icon-sprite— cheerio, one<symbol id="<file>">per SVG, stripfill|stroke, keepviewBox→images/sprites.svg. Every plugin runs its work inbuildStartandhandleHotUpdate(thenserver.ws.send({type:'full-reload'})) — that dual write is what lets Drupal serve fresh on-disk files in dev without any library swap.
- Dev HMR wiring (Arch B minimal) — a
hot-replacementlibrary loading@vite/client(type: external,attributes: {type: module}), attached only in local viaTHEME_page_attachments_alter()gated on a strictAPP_ENV === 'local', read from the source Step 1.11 identified. The strict comparison is what makes it fail closed — absent (FALSE), empty andprod/preprodall resolve to no attach — so one authoritative source is enough; do not write a$_ENV ?? getenv()fallback chain. Keeps prod byte-identical. If the theme already implements that hook (grep — GM themes loadingtheme/*.incoften do), merge into it; a second copy fatals withCannot redeclare function. Pick the dev-server TLS mode with the user (see references, "TLS & HMR: pick one"): Option A —basic-sslonhttps://localhost:<port>(simplest, but an untrusted-cert warning to accept, re-triggered on everynpm install); Option B — route the dev server through the shared Traefik onhttps://<project>-vite.dev.localhostso it reuses the trusted mkcert*.dev.localhostcert (no prompt, survives reinstalls). Default to A unless the cert warning bothers them. - Docker / Makefile / CI — node service runs/exposes Vite; drop dead
BrowserSync env/labels;
install/initand the CI build job runnpm run buildinstead ofgulp deploy(artifact paths unchanged). Point stalenpm-*targets at the real scripts; removegulp*targets. Break the Drupal caching chain — Arch B does not hot-reload without it. This is mandatory, not optional. The plugins rewrite
css/theme.csson disk correctly and Vite firesfull-reload, yet the browser still shows the old styles because three independent layers cache in front of the file. All three must go, and each fails silently:Layer Why it defeats HMR Fix system.performancecss.preprocess/js.preprocessDrupal serves sites/default/files/css/css_<hash>.css; the hash derives from the asset list, not contents, so recompiling never invalidates the aggregate$config['system.performance']['css']['preprocess'] = FALSE;(andjs)system.performancecache.page.max_age(often 21600)anonymous responses carry a multi-hour Cache-Control, so the browser reuses whole pages$config['system.performance']['cache']['page']['max_age'] = 0;Drupal's .htaccesssets max-age=31536000+Expires+1 year on theme assets; the asset URL only changes on a cache rebuild, so the browser replays its cached copydev-only LocationMatchin the apache vhost (below)Put the two
$configlines in the project's shared local settings file (a trackedbase.settings.phpor equivalent), not in a personalsettings.php, so the whole team gets them. They are settings-level overrides, soconfig/syncis untouched and production keeps aggregation and page caching on.# docker/apache/vhost.conf — dev only, scoped to theme assets <LocationMatch "^/themes/custom/[^/]+/(css|js|images)/"> Header set Cache-Control "no-store, no-cache, must-revalidate" Header unset Expires Header unset ETag FileETag None </LocationMatch>Requires
headers_module(already enabled in the reference apache image).vhost.confisCOPYed into the image, not bind-mounted, so it needsdocker compose build apache+ a recreate — and teammates needmake rebuildto pick it up. Mention that explicitly; otherwise it works for you and for nobody else.Verify by checking headers, not by eyeballing the page:
curl -ksSI https://<project>.dev.localhost/themes/custom/<theme>/css/theme.css \ | grep -iE 'cache-control|expires' # want: no-store, no-cache; no Expires curl -ksSI https://<project>.dev.localhost/core/misc/drupal.js \ | grep -i cache-control # want: still max-age=31536000 (rule is scoped) curl -ksS https://<project>.dev.localhost/ \ | grep -oE '/themes/custom/<theme>/css/[a-z.]+\.css' # want the raw file, not css_<hash>.cssNote the browser may still hold a year-long cached copy from before the fix: tell the user to hard-reload once (Cmd+Shift+R).
Step 4 — Verify (do not skip)
- Build in the container (not the host): a clean
npm install && npm run buildinnode:<LTS>-alpinemust print every plugin's success line (images only if you kept that plugin). - Outputs vs snapshot: CSS, JS and sprite byte-identical to the pre-change build (reference § Verify); any CSS diff after a sass bump is the 1.92 mixed-decls change, not noise.
- Audit:
make npm c='audit --include=dev'→found 0 vulnerabilities, andmake npm c='ls imagemin fast-glob gulp'is empty. Any advisory left goes in the MR with itsnpm lspath and the reason it stays. - Images:
images/has the same file list as before (git diff --statif the outputs were tracked, else compare with a pre-migration build), and no file got bigger. - Inspect outputs:
css/style.csscompressed +font-size:*rem(pxtorem applied); the expected count of per-paragraph CSS; minified single-line JS with structure preserved; a validsprites.svg; hand-maintained CSS (ckeditor) untouched (git status). Every declared asset resolves — the strongest single check. Loop the theme-relative paths from
*.libraries.yml(+.info.ymlckeditor5-stylesheets) and assert each file now exists on disk:T=web/themes/custom/<theme> grep -oE "^\s+(css/|js/|styles/)[^:]+" $T/<theme>.libraries.yml | tr -d ' ' | sort -u | while read -r p; do [ -f "$T/$p" ] && echo "OK $p" || echo "MISSING $p"; doneThis catches both migration regressions and the pre-existing 404s from Step 1.3.
- PHP:
php -l+phpcs --standard=Drupal,DrupalPracticeon the dev-wiring.inc. - Prod-safety: confirm the dev client resolves to load only for
APP_ENV=local— testlocal/prod/preprod/empty/genuinely absent. Test the absent case withenv -u APP_ENV php -r …: overriding the superglobal alone is not enough, because the container's real env still leaks in throughgetenv()and makes an "unset" test falsely pass. Empty and absent must both resolve to no attach; see the references for the exact snippet. - The gate fires on a real request, not just in the CLI:
curl -ksS https://<project>.dev.localhost/ | grep -c '@vite/client'must return 1 locally. A CLIphp -rcheck passes even when the FPM worker sees no environment at all (clear_env), so it proves nothing on its own. - Dev-server exposure (with
make npm-devrunning): no host port bound on0.0.0.0(docker compose port node <port>empty for Option B,127.0.0.1:for A); a raw*.php/.env/*.ymlunder the docroot answers 403 through the dev server;@vite/clientanswers 200 withAccess-Control-Allow-Originequal to the site origin, never*. - Iso-functional images: diff a few transparent/gradient PNGs between
src/imagesandimages/— any colour quantisation is a behaviour change. - No stale narrative:
git grepthe retired names (the manifest,gulp,BrowserSync,-bs.hostnames, "top-level globals") acrossdocs/, READMEs andCLAUDE.md/AGENTS.md. Each hit is either still true or gets rewritten. - House coding rules on new code: PHP functions/closures you add or touch
get a return type (
: void…); plugin-internal JS functions get a short JSDoc (@param/@returns). Rungm:review(if available) on the diff before opening the MR — it applies these rules. - Clean diff: no regenerated build artifact committed (
git checkout HEAD --the force-committed ones; they're often silently staged). - Manual UAT (needs the stack up):
make npm-dev, accept the self-signed cert forhttps://localhost:<port>once (Option A only), confirm SCSS/JS/Twig edits auto-reload; visual diff; smoke-test the jQuery libs (swiper/fancybox/owl/masonry/mmenu); then deploy to preprod and check the CI build. - Hand the dev server back: if you started one to verify, kill it before
telling the user to run
make npm-dev—strictPort:truemeans yours holds the port and theirs dies withPort <port> is already in use. Also revert any probe edit you made to a SCSS/JS file to test recompilation, and re-runnpm run buildso the on-disk output matches the committed sources. When the user says "it doesn't hot-reload", diagnose in this order — do not start by suspecting the Vite config:
- Is the dev server actually running, and does
@vite/clientreturn 200? - Did the change reach the compiled file on disk? (
grepthe output.) If yes, the plugins are fine and the problem is downstream — go to 3. - The Drupal/Apache caching chain (Step 3.6). This is almost always it.
- Only then look at the HMR websocket (
hmr.host/clientPort/protocol).
If the page contains no
@vite/clienttag at all, none of the above applies — theAPP_ENVgate is reading an empty source (Step 1.11).- Is the dev server actually running, and does
Gotchas (learned the hard way)
- imagemin ENOEXEC (legacy Gulp build) on macOS/host arch is not a bug —
verify in the container, whose Dockerfile compiles
imagemin-*from source for ARM/Alpine. The migrated build usessharp, so the toolchain leaves with Gulp — and so doesdocker/node/Dockerfilewhen it is left with onlyFROM+WORKDIR: switch the compose service toimage: node:<LTS>-alpine. Do not trust another project's-nodecontainer as a reference: it may hold an x86 prebuilt binary that fails under Rosetta. - imagemin can fail even IN a correctly-provisioned container.
optipng-bincompiles from source successfully, then its post-install--versioncheck fails and npm rolls back the entire install (node_modulesends with 0 entries — not a partial install, nothing). Don't read this as "the toolchain is wrong" and start patching the Dockerfile: ifdeploynever called imagemin and the images are committed, the answer is to drop the dependency, which the migration does anyway. Confirm withls node_modules | wc -lafter a failed install. - A
sass@latestbump can reorder the cascade. From 1.92.0, sass emitsmixed-declstrailing declarations as a separate later rule. Compare the CSS with the pre-bump snapshot; if it differs, pin~1.91.0untilsass-migratorhas fixed the mixed-decls (reference § Versions). - npm 11 (Node 24) holds install scripts for review: approve
esbuild, denynode-gyprebuilds of prebuilt packages, and commitallowScripts. >insidec='…'is a shell redirection in the Makefile recipe (pkg set engines.node=>=24creates an empty file=24). Editpackage.jsondirectly.- Host dev-server port collides across GM projects (each
-nodemay publish the same port) → only one dev server at a time;strictPort:truefails loudly. That includes your own verification server (see Step 4). getenv('APP_ENV')and$_ENV['APP_ENV']are not interchangeable. Withvlucas/phpdotenv'screateImmutable()->safeLoad()the.envvalues land in$_ENV/$_SERVERandgetenv()staysFALSE, so agetenv()-based gate never attaches the client and you spend an afternoon in the Vite config. Two clean answers, in order of preference: (a) migrateload.environment.phptosymfony/dotenvwithusePutenv(TRUE)—composer require drupal/dotenv, then(new Dotenv())->usePutenv(TRUE)->bootEnv(DRUPAL_ROOT . '/../.env')— after which all three sources agree and the gate is a one-liner; or (b) leave the loader alone and read$_ENVonly. What you must not do is paper over it with a$_ENV['APP_ENV'] ?? getenv('APP_ENV') ?: ''chain: it hides which source is authoritative, and the fail-closed property came from the strict=== 'local'all along, not from the fallback.DRUPAL_ROOTis safe to use inload.environment.phpeven though it runs from Composer'sautoload.files:drupal/coredeclaresincludes/bootstrap.incin itsautoload.files, and dependencies' files are loaded before the root package's.- Force-committed build outputs get staged by tooling and reappear in the
diff; reset them to HEAD (
git checkout HEAD -- …) before committing. gulp-iconfont/gulp-real-faviconare often present in deps but either unwired or replaceable by keeping the committed static output — check before porting; don't rebuild what nobody generates anymore. But verify rather than assume: whenfonticonis in the deploy chain its output_fonticon.scssis a live SASS input and removing it breaks compilation (Step 1.4).- A task left out of
deployis usually broken, not forgotten. Before "restoring" it under Vite, compile it once: SCSS that@imports a sibling theme's old path will fail, and you will have turned a green build red for no functional gain. Matchdeploy, and report the breakage as a separate ticket. - Opt-in scripts you add but never execute are untested — say so explicitly
rather than implying coverage (e.g. an
npm run iconsreplacement you wrote but had no icon change to run it against). - A plugin that writes the file type it watches will rebuild forever. The
Arch B outputs (
css/ js/ images/) live inside Vite's root, so a JS plugin emittingjs/script.min.jsretriggers its ownhandleHotUpdate— the symptom is an endless🚀 …log and a laggy page. Two guards, both needed:server.watch.ignoredmust exclude the output folders (which also stops the watcher polling hundreds of built images — that is the lag), and each plugin must ignore its own output path and react only inside its source dir. A healthy dev server logs exactly one plugin line per saved file; measure it (idle 0 → one edit 1 → revert 2) rather than eyeballing the log. ps | grep -clies when you check whether the dev server died. The grep matches its own command line, so a leftover Vite reports as "0" and you hand the user aPort 3000 is already in use. Verify with the listener or a real bind:netstat -ltn | grep -c ':3000', ornode -e 'require("net").createServer().listen(3000)'. The container's PID 1 (tail -f /dev/null) reaps nothing, so killed runs leave[esbuild]zombies — harmless, but restart the service for a clean slate.- Removing a theme's bundled jQuery breaks every bare
$. Core runsjQuery.noConflict()incore/misc/drupal.init.js, deletingwindow.$; a jQuery concatenated into the theme bundle used to re-create it after that ran. So a "double jQuery" that looks like an obvious bug is load-bearing. Full treatment, including the three pre-flight greps before wrapping the file in(function ($) { … })(jQuery), inreferences/vendor-libs-to-libraries.md§3. cors: true+root: 'web'+ a published port = the raw docroot readable from the LAN and from any site the developer visits (settings.php,.env). Alwayscors: { origin: HOME_URL },server.fs.denyfor PHP/YAML/ env files, and no0.0.0.0port (Option B needs none).- Iso-functional migration and follow-up in one MR: if the concat-manifest retirement lands in the same MR anyway, the manifest fallback written for the iso-functional step is dead on arrival — remove it before opening the MR (vendor-libs-to-libraries.md, intro).
- Pre-existing SASS deprecations (
slash-div, etc.) surface under Vite too — they were there under Gulp; note them, don't fix in-scope.
Deliverables
One focused branch + Draft MR. If the gm:merge-request skill is available,
delegate branch/commit/MR to it; otherwise do a plain branch → commit → push
(open the MR with glab if authenticated, else hand the user the branch name),
or skip the MR and commit on the current branch — ask the user which. Commit
only migration files; exclude regenerated build artifacts. Estimate
~1–1.5 day iso-functional incl. UAT.
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

