agentleFS
JMAILLYJMAILLY/claude-migration-skills/skills/gulp-to-vite/SKILL.md

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

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
  1. Very specific:

    Before framing this as modernization, run a clean install in the container (rm -rf node_modules && npm install).

    line 58
  2. Distinctive:

    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 …

    line 66
  3. Distinctive:

    Verify each of these; they decide the approach and avoid nasty surprises:

    line 77
  4. Very specific:

    NOT generated from any SCSS source (git ls-files <theme>/css)?

    line 146
  5. Very specific:

    Only pick Arch A if the goal is fleet-wide dist/ uniformity and you accept the churn.

    line 201
  6. Very specific:

    Every plugin runs its work in buildStart and handleHotUpdate (then server.ws.send({type:'full-reload'})) — that dual write is what lets …

    line 245
  7. Very specific:

    Put the two $config lines in the project's shared local settings file (a tracked base.settings.php or equivalent), not in a personal …

    line 280
  8. Distinctive:

    Verify by checking headers, not by eyeballing the page:

    line 301
  9. Very specific:

    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 …

    line 348
  10. Distinctive:

    Clean diff: no regenerated build artifact committed (git checkout HEAD -- the force-committed ones.

    line 370
  11. Very specific:

    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 …

    line 377
  12. Very specific:

    Only then look at the HMR websocket (hmr.host/clientPort/protocol).

    line 388
  13. Very specific:

    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).

    line 390
  14. Distinctive:

    Iso-functional migration and follow-up in one MR: if the concat-manifest retirement lands in the same MR anyway, the manifest fallback …

    line 476

≈ “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.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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.

NeedCommand
Install depsmake npm-install (or make npm c='install')
Dev server (watch + HMR)make npm-dev
Production buildmake npm-build
One-off verify builddocker 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.behaviors IIFEs (no import/export). Gulp just terser-copies them 1:1. → grep the JS source; if there are zero import/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:

  1. What Gulp actually builds — read gulpfile.js + root package.json paths. Scope the port to the deploy task 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_custom when that key doesn't exist in paths, so gulp.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 of deploy. 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 deploy doesn'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.

  2. 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/**/*.js and terser-copies each one.
    • concat manifest — a root JSON (e.g. site.json with a jsFiles list) concatenates vendor libraries plus the theme scripts into a single script.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 to src/js/*.js when the manifest is absent, so deleting it later is a no-op. Replacing the manifest with real *.libraries.yml entries is a separate follow-up, not part of the iso-functional migration → references/vendor-libs-to-libraries.md.
  3. *.libraries.yml — count entries. Per-paragraph CSS/JS mapped 1:1 (dozens of fixed paths) ⇒ rewriting to dist/ 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 **/*.js only, so a declared sibling asset like js/libs/tablesaw/tablesaw.css is never emitted and 404s in production today. Same for .info.yml ckeditor5-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.

  4. Icon font — do NOT assume gulp-iconfont is 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.scss and icons-*.woff/woff2 and drop the task, plus an opt-in npm run icons script for the rare regeneration. Icons change rarely, and the old task minted a new timestamped font on every build, so dozens of icons-<timestamp>.* files are typically already committed.
    • port it — svgicons2svgfont + svg2ttf + ttf2woff + ttf2woff2, plus something to replace gulp-consolidate for the Twig→SCSS template (reuse the project's existing twig dep to keep output shape identical).
    • keep Gulp only for it — undercuts the payoff; last resort.

    Whichever is chosen, note that the generated _fonticon.scss is a live SASS input (@used by theme.scss/_mixins.scss), not an output — never delete it. Also flag the ordering bug these gulpfiles usually have: fonticon runs after compileSass in the series, so a regenerated font only takes effect on the next build.

  5. Two npm projects — the root package.json holds the build tooling, but *.libraries.yml paths like node_modules/<pkg>/… resolve relative to the theme, so the theme has its own package.json + node_modules. Confirm both installs happen; a Makefile npm-install that only covers the root leaves every vendor library 404.
  6. 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, if css/ and js/ are fully .gitignored and empty, there is nothing to protect — confirm before adding emptyOutDir gymnastics.
  7. Favicon — if Gulp uses gulp-real-favicon (remote API) but the favicon markup in html.html.twig is hardcoded and favicons.json is unused at runtime, drop favicon from the build; the committed PNG/ico stay as static assets. Confirm the twig consumer before deleting.
  8. Fonts / tarteaucitron / other statics — if not in any Gulp task and not .gitignored, they're committed statics; leave them.
  9. .gitignore — which outputs are ignored vs force-committed? Force-committed build artifacts (sprites.svg, admin dist/theme.css) will show as modified after any build; keep them out of the migration commit (revert to HEAD).
  10. Docker/Makefile/CI — the node service env (THEME_FOLDER, HOME_URL, exposed port), existing npm-* Makefile targets (often pre-scaffolded), and the CI job that runs gulp deploy (its artifact paths must stay identical).
  11. How the project loads .env — this decides how the dev gate reads APP_ENV, and getting it wrong makes the HMR client silently never attach (or, worse, attach in prod). grep -n dotenv composer.json and read load.environment.php:
    • vlucas/phpdotenv — Dotenv::createImmutable(__DIR__)->safeLoad() populates $_ENV and $_SERVER only; getenv() returns FALSE. Read $_ENV.
    • symfony/dotenv (what drupal/dotenv pulls in) — (new Dotenv())->usePutenv(TRUE)->bootEnv(DRUPAL_ROOT . '/../.env') also putenv()s, so $_ENV, $_SERVER and getenv() all agree. Read getenv() — it is the shortest and the only one that survives a missing variables_order=E.
    • neither — the value exists only because the compose environment: block passes it, which reaches PHP only with clear_env = no in PHP-FPM's www.conf ($_ENV/$_SERVER additionally need E in variables_order). A base.settings.php that 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"));' then curl -ksS https://<project>.dev.localhost/ | grep -c '@vite/client'.
  12. Drupal-side caching — drush config:get system.performance. If css.preprocess/js.preprocess are true or cache.page.max_age is 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/JSRollup entrypoints → dist/Plugins compile/copy → legacy css/ js/ images/
*.libraries.ymlrewrite ~all paths to dist/…untouched
.info.yml ckeditor_stylesheets, Twigrewriteuntouched
Drupal wiringhook_library_info_alter + hot-replacement swap per assetone dev-only @vite/client inject
HMRtrue per-module (for modules)full page reload (== old BrowserSync)
Churn / riskhighlow — 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:

  1. package.json — drop every gulp-* + browser-sync/pump/require-dir, and every imagemin-*. Add vite sass esbuild postcss postcss-pxtorem sharp svgo cheerio tinyglobby (plus @vitejs/plugin-basic-ssl only for TLS Option A, see step 4), each at its latest version. Set "type": "module" and engines.node to the Node LTS major; scripts dev: vite, build: vite build. Delete gulpfile.js. Node = current Active LTS (24 as of late 2026, check endoflife.date) in the node image, engines and the CI image. See "Versions" in the reference: imagemin-* and fast-glob pull critical advisories that have no fix.
  2. vite.config.mjs — dev server (basicSsl() for TLS Option A, or plain HTTP behind Traefik for Option B; the theme's exposed port, usePolling, wss HMR); a live-reload-templates plugin full-reloading on .twig/.theme/.php/.inc/.yml; and a no-op virtual entry + build.write:false so vite build fires the plugins without a real entrypoint (classic JS can't be a Rollup input).
  3. plugins/ (fork from Project D, parametrized, output to LEGACY paths):
    • vite-sass — style.scss → css/style.css; tinyglobby the per-paragraph SCSS (ignore _*) → css/theme/…; also admin theme.scss → styles/dist. Apply postcss-pxtorem inside the plugin (plugins bypass Vite's CSS pipeline, so postcss.config is not auto-applied).
    • vite-copy-js — recursive walk of src/js, esbuild-minify each file → js/, structure preserved.
    • vite-images — sharp + svgo: tinyglobby src/images (ignore sprites/**) → images/, with encoder settings equivalent to the old imagemin task (lossless PNG, never palette: 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, strip fill|stroke, keep viewBox → images/sprites.svg. Every plugin runs its work in buildStart and handleHotUpdate (then server.ws.send({type:'full-reload'})) — that dual write is what lets Drupal serve fresh on-disk files in dev without any library swap.
  4. Dev HMR wiring (Arch B minimal) — a hot-replacement library loading @vite/client (type: external, attributes: {type: module}), attached only in local via THEME_page_attachments_alter() gated on a strict APP_ENV === 'local', read from the source Step 1.11 identified. The strict comparison is what makes it fail closed — absent (FALSE), empty and prod/preprod all 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 loading theme/*.inc often do), merge into it; a second copy fatals with Cannot redeclare function. Pick the dev-server TLS mode with the user (see references, "TLS & HMR: pick one"): Option A — basic-ssl on https://localhost:<port> (simplest, but an untrusted-cert warning to accept, re-triggered on every npm install); Option B — route the dev server through the shared Traefik on https://<project>-vite.dev.localhost so it reuses the trusted mkcert *.dev.localhost cert (no prompt, survives reinstalls). Default to A unless the cert warning bothers them.
  5. Docker / Makefile / CI — node service runs/exposes Vite; drop dead BrowserSync env/labels; install/init and the CI build job run npm run build instead of gulp deploy (artifact paths unchanged). Point stale npm-* targets at the real scripts; remove gulp* targets.
  6. Break the Drupal caching chain — Arch B does not hot-reload without it. This is mandatory, not optional. The plugins rewrite css/theme.css on disk correctly and Vite fires full-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:

    LayerWhy it defeats HMRFix
    system.performance css.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; (and js)
    system.performance cache.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 LocationMatch in the apache vhost (below)

    Put the two $config lines in the project's shared local settings file (a tracked base.settings.php or equivalent), not in a personal settings.php, so the whole team gets them. They are settings-level overrides, so config/sync is 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.conf is COPYed into the image, not bind-mounted, so it needs docker compose build apache + a recreate — and teammates need make rebuild to 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>.css

    Note 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 build in node:<LTS>-alpine must 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, and make npm c='ls imagemin fast-glob gulp' is empty. Any advisory left goes in the MR with its npm ls path and the reason it stays.
  • Images: images/ has the same file list as before (git diff --stat if the outputs were tracked, else compare with a pre-migration build), and no file got bigger.
  • Inspect outputs: css/style.css compressed + font-size:*rem (pxtorem applied); the expected count of per-paragraph CSS; minified single-line JS with structure preserved; a valid sprites.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.yml ckeditor5-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"; done

    This catches both migration regressions and the pre-existing 404s from Step 1.3.

  • PHP: php -l + phpcs --standard=Drupal,DrupalPractice on the dev-wiring .inc.
  • Prod-safety: confirm the dev client resolves to load only for APP_ENV=local — test local/prod/preprod/empty/genuinely absent. 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 leaks in through getenv() 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 CLI php -r check 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-dev running): no host port bound on 0.0.0.0 (docker compose port node <port> empty for Option B, 127.0.0.1: for A); a raw *.php / .env / *.yml under the docroot answers 403 through the dev server; @vite/client answers 200 with Access-Control-Allow-Origin equal to the site origin, never *.
  • Iso-functional images: diff a few transparent/gradient PNGs between src/images and images/ — any colour quantisation is a behaviour change.
  • No stale narrative: git grep the retired names (the manifest, gulp, BrowserSync, -bs. hostnames, "top-level globals") across docs/, READMEs and CLAUDE.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). Run gm: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 for https://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:true means yours holds the port and theirs dies with Port <port> is already in use. Also revert any probe edit you made to a SCSS/JS file to test recompilation, and re-run npm run build so 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:

    1. Is the dev server actually running, and does @vite/client return 200?
    2. Did the change reach the compiled file on disk? (grep the output.) If yes, the plugins are fine and the problem is downstream — go to 3.
    3. The Drupal/Apache caching chain (Step 3.6). This is almost always it.
    4. Only then look at the HMR websocket (hmr.host/clientPort/protocol).

    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).

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 uses sharp, so the toolchain leaves with Gulp — and so does docker/node/Dockerfile when it is left with only FROM + WORKDIR: switch the compose service to image: node:<LTS>-alpine. Do not trust another project's -node container as a reference: it may hold an x86 prebuilt binary that fails under Rosetta.
  • imagemin can fail even IN a correctly-provisioned container. optipng-bin compiles from source successfully, then its post-install --version check fails and npm rolls back the entire install (node_modules ends with 0 entries — not a partial install, nothing). Don't read this as "the toolchain is wrong" and start patching the Dockerfile: if deploy never called imagemin and the images are committed, the answer is to drop the dependency, which the migration does anyway. Confirm with ls node_modules | wc -l after a failed install.
  • A sass@latest bump can reorder the cascade. From 1.92.0, sass emits mixed-decls trailing declarations as a separate later rule. Compare the CSS with the pre-bump snapshot; if it differs, pin ~1.91.0 until sass-migrator has fixed the mixed-decls (reference § Versions).
  • npm 11 (Node 24) holds install scripts for review: approve esbuild, deny node-gyp rebuilds of prebuilt packages, and commit allowScripts.
  • > inside c='…' is a shell redirection in the Makefile recipe (pkg set engines.node=>=24 creates an empty file =24). Edit package.json directly.
  • Host dev-server port collides across GM projects (each -node may publish the same port) → only one dev server at a time; strictPort:true fails loudly. That includes your own verification server (see Step 4).
  • getenv('APP_ENV') and $_ENV['APP_ENV'] are not interchangeable. With vlucas/phpdotenv's createImmutable()->safeLoad() the .env values land in $_ENV/$_SERVER and getenv() stays FALSE, so a getenv()-based gate never attaches the client and you spend an afternoon in the Vite config. Two clean answers, in order of preference: (a) migrate load.environment.php to symfony/dotenv with usePutenv(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 $_ENV only. 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_ROOT is safe to use in load.environment.php even though it runs from Composer's autoload.files: drupal/core declares includes/bootstrap.inc in its autoload.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-favicon are 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: when fonticon is in the deploy chain its output _fonticon.scss is a live SASS input and removing it breaks compilation (Step 1.4).
  • A task left out of deploy is 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. Match deploy, 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 icons replacement 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 emitting js/script.min.js retriggers its own handleHotUpdate — the symptom is an endless 🚀 … log and a laggy page. Two guards, both needed: server.watch.ignored must 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 -c lies 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 a Port 3000 is already in use. Verify with the listener or a real bind: netstat -ltn | grep -c ':3000', or node -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 runs jQuery.noConflict() in core/misc/drupal.init.js, deleting window.$; 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), in references/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). Always cors: { origin: HOME_URL }, server.fs.deny for PHP/YAML/ env files, and no 0.0.0.0 port (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.

Download now (ZIP)

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
What did you use it for? e.g. Reviewing pull requests in a Rails app

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.