Skip to content

Sage 10 to 11 Migration with Vite + Tailwind 4 & Acorn 5

By Jasper Frumau

So, how to migrate a theme from Sage 10 to Sage 11? This involves several key changes due to the shift from Bud to Vite as the new build tool in Sage 11. But also due to the Tailwind 3 to 4 migration as it turns out. We also include the Acorn 4 to 5 upgrade in this post so there are a few extra steps to take for that part as well.

Vite Configuration

One of the first things I did was create a new vite.config.js file, which replaces the old bud.config.js used in Sage 10. Since Sage 11 now uses Vite, updating your asset pipeline configuration is essential.

Next, I replaced all references to the default sage theme name with the name of our custom theme: nynaeve. So you wind up with this including base adjustment

import { defineConfig } from 'vite'
import tailwindcss from '@tailwindcss/vite';
import laravel from 'laravel-vite-plugin'
import { wordpressPlugin, wordpressThemeJson } from '@roots/vite-plugin';

export default defineConfig({
  base: '/app/themes/nynaeve/public/build/',
  plugins: [
    tailwindcss(),
    laravel({
      input: [
        'resources/css/app.css',
        'resources/js/app.js',
        'resources/css/editor.css',
        'resources/js/editor.js',
      ],
      refresh: true,
    }),

    wordpressPlugin(),

    // Generate the theme.json file in the public/build/assets directory
    // based on the Tailwind config and the theme.json file from base theme folder
    wordpressThemeJson({
      disableTailwindColors: false,
      disableTailwindFonts: false,
      disableTailwindFontSizes: false,
    }),
  ],
  resolve: {
    alias: {
      '@scripts': '/resources/js',
      '@styles': '/resources/css',
      '@fonts': '/resources/fonts',
      '@images': '/resources/images',
    },
  },
})

NB The bud.config.js and jsconfig.json should be removed

Package.json Update

We of course needed to update the JS package.json . We basically copied the boilerplate code over from the Sage 11 theme:

{
  "name": "nynaeve",
  "private": true,
  "engines": {
    "node": ">=20.0.0"
  },
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "translate": "npm run translate:pot && npm run translate:update",
    "translate:pot": "wp i18n make-pot . ./resources/lang/nynaeve.pot --include=\"theme.json,patterns,app,resources\"",
    "translate:update": "for file in ./resources/lang/*.po; do wp i18n update-po ./resources/lang/nynaeve.pot $file; done",
    "translate:compile": "npm run translate:mo && npm run translate:js",
    "translate:js": "wp i18n make-json ./resources/lang --pretty-print",
    "translate:mo": "wp i18n make-mo ./resources/lang ./resources/lang"
  },
  "devDependencies": {
    "@roots/vite-plugin": "^1.0.2",
    "@tailwindcss/vite": "^4.0.9",
    "laravel-vite-plugin": "^1.2.0",
    "tailwindcss": "^4.0.9",
    "vite": "^6.2.0"
  }
}

I only renamed it nynaeve and made the language files nynaeve as well.

Theme Restructuring

The scripts directory becomes js , the styles directory becomes css and fonts.css is moved to root css directory and no longer in css/common . Not sure why this was done, but perhaps it does look clearer this way.

├── css
│   ├── app.css
│   ├── editor.css
│   └── fonts.css
├── fonts
│   ├── menlo-regular-webfont.woff2
│   ├── open-sans-v40-latin-300.woff2
│   ├── open-sans-v40-latin-500.woff2
│   ├── open-sans-v40-latin-600.woff2
│   ├── open-sans-v40-latin-700.woff2
│   ├── open-sans-v40-latin-800.woff2
│   └── open-sans-v40-latin-regular.woff2
├── images
│   ├── icons
│   ├── logo
│   │   └── logo-imagewize-smaller.png
│   └── profiles
│       ├── dall-e-profile image-male.webp
│       ├── dall-e-profile-female.webp
│       ├── dall-e-profile-stylish-male.webp
│       └── dall-e-stylized-illustration-woman-in-a-classic-en-profile-image.webp
├── js
│   ├── app.js
│   ├── editor.js
│   └── filters
│       └── button.filter.js

CSS & JS Updates

We did have to make changes in the main app.css and app.js . In the css we had to add

@import './fonts.css';
@import "tailwindcss" theme(static);
@source "../views/";
@source "../../app/";


@theme {
  --font-open-sans: "Open Sans", sans-serif;
  --font-menlo: "Menlo", monospace;
  
  /* Custom colors */
  --color-textbodygray: #98999a;
  --color-bggray: #ebeced;
  --color-bordergray: #cbcbcb;
  --color-ctablue: #017cb6;
  --color-ctabuttonblue: #026492;
  --color-ctabuttonbluehover: #02567e;
  --color-footerbg: #171b23;
  --color-footertext: #465166;
}
...

to work with custom colors, our custom fonts and to import all the Sage 11 way.

In app.js we had to remove import domReady from '@roots/sage/client/dom-ready'; and use this at the beginning of the file

import.meta.glob([
  '../images/**',
  '../fonts/**',
]);

// Import our local domReady function with named import
import { domReady } from './utils/dom-ready';...

this loads our own domReady for the arrow up button and imports all images, fonts for the build . As for editor.js we only updated it with the new setup so replaced the bud.js stuff with

import domReady from '@wordpress/dom-ready';

domReady(() => {
  //
});

Theme Setup Style Injection

The theme setup file site/web/app/themes/nynaeve/app/setup.php also needs an update to use Vite for Style and JS Injection so we added

**
 * Theme setup.
 */

 namespace App;
 
 use Illuminate\Support\Facades\Vite;

/**
 * Inject styles into the block editor.
 *
 * @return array
 */
add_filter('block_editor_settings_all', function ($settings) {
    $style = Vite::asset('resources/css/editor.css');

    $settings['styles'][] = [
        'css' => Vite::isRunningHot()
            ? "@import url('{$style}')"
            : Vite::content('resources/css/editor.css'),
    ];

    return $settings;
});

/**
 * Inject scripts into the block editor.
 *
 * @return void
 */
add_filter('admin_head', function () {
    if (! get_current_screen()?->is_block_editor()) {
        return;
    }

    $dependencies = json_decode(Vite::content('editor.deps.json'));

    foreach ($dependencies as $dependency) {
        if (! wp_script_is($dependency)) {
            wp_enqueue_script($dependency);
        }
    }

    echo Vite::withEntryPoints([
        'resources/js/editor.js',
    ])->toHtml();
});

/**
 * Add Vite's HMR client to the block editor.
 *
 * @return void
 */
add_action('enqueue_block_assets', function () {
    if (! is_admin() || ! get_current_screen()?->is_block_editor()) {
        return;
    }

    if (! Vite::isRunningHot()) {
        return;
    }

    $script = sprintf(
        <<<'JS'
        window.__vite_client_url = '%s';

        window.self !== window.top && document.head.appendChild(
            Object.assign(document.createElement('script'), { type: 'module', src: '%s' })
        );
        JS,
        untrailingslashit(Vite::asset('')),
        Vite::asset('@vite/client')
    );

    wp_add_inline_script('wp-blocks', $script);
});

/**
 * Use the generated theme.json file.
 *
 * @return string
 */
add_filter('theme_file_path', function ($path, $file) {
    return $file === 'theme.json'
        ? public_path('build/assets/theme.json')
        : $path;
}, 10, 2);
...

The rest with initial theme setup seems to remain as is.

Underline Bug override

With the new Tailwind 4 and WordPress 6.6.x I also bumped into an issue with all menu links to be underlined even though we were using underline . For that I added this to theme.json :

...
"styles": {
    "elements": {
      "link": {
        ":hover": {
          "typography": {
            "textDecoration": "none"
          }
        },
        "typography": {
          "textDecoration": "none"
        }
      }
    }
  },
...

Theme.json Update

Besides the mentioned addition of no underlining to deal with WordPress 6.6 style injection I had to remove all Tailwind colors. They are automatically added via the vite.config.js . I only kept the custom colors for them to be loaded in the editor color palette popup. So in the end it became:

{
  "__preprocessed__": "This file is used to build the theme.json file in the `public/build/assets` directory. The built artifact includes Tailwind's colors, fonts, and font sizes.",
  "$schema": "https://schemas.wp.org/trunk/theme.json",
  "version": 3,
  "styles": {
    "elements": {
      "link": {
        ":hover": {
          "typography": {
            "textDecoration": "none"
          }
        },
        "typography": {
          "textDecoration": "none"
        }
      }
    }
  },
  "settings": {
    "background": {
      "backgroundImage": true
    },
    "color": {
      "custom": false,
      "customDuotone": false,
      "customGradient": false,
      "defaultDuotone": false,
      "defaultGradients": false,
      "defaultPalette": false,
      "duotone": [],
      "palette": [
        {
          "color": "inherit",
          "name": "Inherit",
          "slug": "inherit"
        },
        {
          "color": "currentcolor",
          "name": "Current",
          "slug": "current"
        },
        {
          "color": "transparent",
          "name": "Transparent",
          "slug": "transparent"
        },
        {
          "color": "#000",
          "name": "Black",
          "slug": "black"
        },
        {
          "color": "#fff",
          "name": "White",
          "slug": "white"
        },
        {
          "color": "#98999a",
          "name": "TextBodyGray",
          "slug": "textbodygray"
        },
        {
          "color": "#ebeced",
          "name": "BgGray",
          "slug": "bggray"
        },
        {
          "color": "#cbcbcb",
          "name": "BorderGray",
          "slug": "bordergray"
        },
        {
          "color": "#017cb6",
          "name": "CtaBlue",
          "slug": "ctablue"
        },
        {
          "color": "#026492",
          "name": "CtaButtonBlue",
          "slug": "ctabuttonblue"
        },
        {
          "color": "#02567e",
          "name": "CtaButtonBlueHover",
          "slug": "ctabuttonbluehover"
        },
        {
          "color": "#171b23",
          "name": "FooterBg",
          "slug": "footerbg"
        },
        {
          "color": "#465166",
          "name": "FooterText",
          "slug": "footertext"
        }
      ]
    },
    "custom": {
      "spacing": {},
      "typography": {
        "font-size": {},
        "line-height": {}
      }
    },
    "spacing": {
      "padding": true,
      "margin": true,
      "units": [
        "px",
        "%",
        "em",
        "rem",
        "vw",
        "vh"
      ],
      "blockGap": false
    },
    "layout": {
      "contentSize": "55rem",
      "wideSize": "64rem",
      "fullSize": "100%"
    },
    "typography": {
      "customFontSize": false,
      "fontFamilies": [
        {
          "fontFamily": "ui-sans-serif,system-ui,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\",\"Segoe UI Symbol\",\"Noto Color Emoji\"",
          "name": "Ui-sans-serif",
          "slug": "sans"
        },
        {
          "fontFamily": "ui-serif,Georgia,Cambria,\"Times New Roman\",Times,serif",
          "name": "Ui-serif",
          "slug": "serif"
        },
        {
          "fontFamily": "ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,\"Liberation Mono\",\"Courier New\",monospace",
          "name": "Ui-monospace",
          "slug": "mono"
        },
        {
          "fontFamily": "Open Sans, sans-serif",
          "name": "Open Sans",
          "slug": "open-sans"
        },
        {
          "fontFamily": "Menlo, monospace",
          "name": "Menlo",
          "slug": "menlo"
        }
      ],
      "fontSizes": [
        {
          "name": "xs",
          "size": "0.75rem",
          "slug": "xs"
        },
        {
          "name": "sm",
          "size": "0.875rem",
          "slug": "sm"
        },
        {
          "name": "base",
          "size": "1rem",
          "slug": "base"
        },
        {
          "name": "lg",
          "size": "1.125rem",
          "slug": "lg"
        },
        {
          "name": "xl",
          "size": "1.25rem",
          "slug": "xl"
        },
        {
          "name": "2xl",
          "size": "1.5rem",
          "slug": "2xl"
        },
        {
          "name": "3xl",
          "size": "1.875rem",
          "slug": "3xl"
        },
        {
          "name": "4xl",
          "size": "2.25rem",
          "slug": "4xl"
        },
        {
          "name": "5xl",
          "size": "3rem",
          "slug": "5xl"
        },
        {
          "name": "6xl",
          "size": "3.75rem",
          "slug": "6xl"
        },
        {
          "name": "7xl",
          "size": "4.5rem",
          "slug": "7xl"
        },
        {
          "name": "8xl",
          "size": "6rem",
          "slug": "8xl"
        },
        {
          "name": "9xl",
          "size": "8rem",
          "slug": "9xl"
        }
      ]
    },
    "blocks": {
      "core/heading": {
        "spacing": {
          "padding": true,
          "margin": true
        },
        "typography": {
          "lineHeight": true
        }
      },
      "core/paragraph": {
        "typography": {
          "lineHeight": true
        }
      },
      "core/image": {
        "border": {
          "color": true,
          "radius": true,
          "style": true,
          "width": true
        }
      }
    }
  }
}

This version of theme.json is used to generate the build version together with settings in vite.config.js

Acorn Update

We also decided to update Acorn tot he latest for the latest Laravel goodies. Did that with one command

composer require roots/acorn ^5.0 -W

NB Got stuck with older beta 2 version so had to enforce updating to latest

Then reading the migration guide also needed

...
use Roots\Acorn\Application;
...
Application::configure()
    ->withProviders([
        App\Providers\ThemeServiceProvider::class,
    ])
    ->boot();
...

in site/web/app/themes/nynaeve/functions.php to register the bootloader instead of

if (! function_exists('\Roots\bootloader')) {
    wp_die(
        __('You need to install Acorn to use this theme.', 'sage'),
        '',
        [
            'link_url' => 'https://roots.io/acorn/docs/installation/',
            'link_text' => __('Acorn Docs: Installation', 'sage'),
        ]
    );
}

\Roots\bootloader()->boot();

Installing Dependencies

After updating the theme name and configuration, you’ll need to install the required packages. Although we’re used to running yarn install, the Sage 11 documentation recommends using npm:

  • Run npm install from your theme directory to install all necessary dependencies (e.g., vite, @tailwindcss/vite, etc.).
  • Run npm run build to compile the theme assets.

Important: You must compile the theme assets before accessing your site. If you skip this step, you’ll encounter the following error:

Vite manifest not found at [/path/to/sage/public/build/manifest.json] cannot be found.

NB we removed yarn.lock as we are using npm now.

Trellis Before Deployment

To do the deployment properly with Sage 11 that uses npm and different built location we also had to update trellis/deploy-hooks/build-before.yml with

# Placeholder `deploy_build_before` hook for building theme assets on the
# host machine and then copying the files to the remote server
#
# ⚠️ This example assumes your theme is using Sage 11
#
# Uncomment the lines below if you are using Sage 11
# and replace `nynaeve` with your theme folder
#
---
- name: Install npm dependencies
  command: npm install
  delegate_to: localhost
  args:
    chdir: "{{ project_local_path }}/web/app/themes/nynaeve"

- name: Install Composer dependencies
  command: composer install --no-ansi --no-dev --no-interaction --no-progress --optimize-autoloader --no-scripts --classmap-authoritative
  args:
    chdir: "{{ deploy_helper.new_release_path }}/web/app/themes/nynaeve"

- name: Compile assets for production
  command: npm run build
  delegate_to: localhost
  args:
    chdir: "{{ project_local_path }}/web/app/themes/nynaeve"

- name: Check for manifest
  stat:
    path: "{{ project_local_path }}/web/app/themes/nynaeve/public/build/manifest.json"
  delegate_to: localhost
  register: entrypoints_data

- name: Entrypoints missing
  ansible.builtin.fail:
    msg: "The theme is missing the build manifest file"
  when: not entrypoints_data.stat.exists

- name: Copy production assets
  synchronize:
    src: "{{ project_local_path }}/web/app/themes/nynaeve/public"
    dest: "{{ deploy_helper.new_release_path }}/web/app/themes/nynaeve"
    group: no
    owner: no
    rsync_opts: --chmod=Du=rwx,--chmod=Dg=rx,--chmod=Do=rx,--chmod=Fu=rw,--chmod=Fg=r,--chmod=Fo=r

Leave a Reply