Skip to content

croxton/craftcms-hda-starter-kit

Repository files navigation

Hypermedia Driven Application starter kit for Craft CMS

A solid platform for front-end development when using Craft CMS as a backend, following the Hypermedia Driven Application (HDA) architecture and the Locality of Behaviour (LoB) principle. Create highly interactive, SPA-like web apps without the overhead.

Includes a working demo featuring full page transitions and example Alpine.js, Vue 3 and vanilla JS components.

  • Craft CMS
  • Vite - provides a robust ES6 development environment with script and style injection (HMR, file watching)
  • htmx + Booster Pack for HTML-over-the-wire
  • Tailwind CSS for utility-first CSS
  • Alpine.js + Async Alpine for composing behaviour directly in markup, with support for asynchronous on-demand components
  • Vue.js (v3) for complex reactive applications using SFCs
  • Minimalistic JavaScript framework for vanilla JS components:
    • Components can be lazyloaded as they enter the DOM and use loading strategies including visible, idle and media
    • Framework-agnostic - works with vanilla JS, Vue, jQuery, GSAP, Alpine.js or your framework of choice; any third party script can be integrated into the simple component lifecycle
  • SASS auto compiling, prefixing, minifying and sourcemaps
  • CSS Autoprefixer, PostCSS Preset Env for older browsers
  • Legacy bundles for older browsers
  • Image optimisation
  • Critical CSS
  • Static files (fonts, images etc)
  • Eslint
  • Stylelint

Requirements

  • Node.js 18+
  • PHP 8.0.2+
  • MySQL 5.7.8+ with InnoDB, MariaDB 10.2.7+, or PostgreSQL 10+
  • Composer 2.0+

OR

  • Docker
  • DDEV, minimum version 1.19

Installation

Clone this repo

git clone git@github.com:croxton/craftcms-hda-starter-kit.git my-website
cd my-project
rm -rf .git
# (Optional) Start a new repository:
git init .

Option 1: BYO webserver

These instruction assume you will bring your own webserver, e.g. MAMP, Laravel Valet.

1. Create a host

Create a host (e.g. https://my-website.local) pointing to the web directory of the new project, and a new database.

2. Create .env

Craft depends on environment variables set in a root .env file so you’ll need to copy the .env.example over.

cp .env.example.dev .env

Update the PRIMARY_SITE_URL to the host you created, e.g. https://my-website.local, and add your database credentials.

3. Install Node packages

# Use Node 18.x or later
npm install

4. Install Composer packages

composer install

5. Install Craft

php craft setup
php craft install
php craft plugin/install vite

Open thePRIMARY_SITE_URL you specified in a browser to view your site.

Option 2: DDEV

These instructions assume you have installed Docker and DDEV.

1. Copy the DDEV-specific config files

cp vite.config.ddev.js vite.config.js
cp config/vite.ddev.php config/vite.php

2. Configure DDEV (Optional)

You can skip this step if the name of your root directory matches your desired DDEV subdomain.

If you need your local DDEV domain to be different from the name of this project's root directory, run the following command from inside said directory:

ddev config

Follow the prompts.

  • Project name: my-test-site would establish a project URL of https://my-test-site.ddev.site
  • Docroot location: defaults to web, should be kept as-is
  • Project Type: defaults to php, should be kept as-is

3. Install Craft

To install Craft, Vite and the Vite plugin run the following command and follow the prompts.

make install

This builds a Dockerized development environment running the latest version of Craft CMS and installs the front-end packages.

Pay special attention to the Craft installation prompts. After setting the admin’s account credentials, you’ll be prompted for your desired site name and url.

The Site name can be anything, can include spaces and capital letters, and doesn't need to correspond to your project's root folder name or DDEV domain.

The Site url If for some reason the suggested default isn’t acceptable, answer the prompt for a url with the full url (e.g. https://my-website.ddev.site)

_💡 If you’re unclear about the url of your project open another terminal window in the same directory and run ddev describe.

Usage

Run your desired workflow:

Run the development server (with hot module reloading and file watching)

# BYO:
npm run dev

# Or, with DDEV:
make dev

Run the production build

# BYO:
npm run build

# Or, with DDEV:
make build

Run the production build and generate critical CSS

# BYO:
npm run build-critical

# Or, with DDEV:
make build-critical

Fix your javascript with eslint

# BYO:
npm run fix-scripts

# Or, with DDEV:
make fix-scripts

Fix your styles with stylelint

# BYO:
npm run fix-styles

# Or, with DDEV:
make fix-styles 

View list of supported browsers for this project (see package.json to edit):

# BYO:
npx browserslist

# Or, with DDEV:
make browserslist 

DDEV: Other Makefile Commands

  • make up - Confirms your DDEV project is running. Rebuilds the containers and pushes over your SSH credentials if needed.
  • make install - Runs a complete one-time process to set the project up and install Craft
  • make composer <command> - Run Composer commands inside the container, e.g. make composer install
  • make craft <command> - Run Craft commands inside the container, e.g. make craft project-config/touch
  • make npm <command> - Run npm commands inside the container, e.g. make npm install

DDEV: Useful DDEV Commands

ddev start, ddev stop, ddev restart, ddev import-db, ddev describe, and ddev poweroff are among the most useful commands available when using DDEV. They can be run from any directory below your project's root directory.

Front-end development

Our aim is to keep markup and logic (styling / scripting) together in one file, wherever possible, and this starter kit gives you some great tools to start with simply by editing html. Realistically however, this isn’t always possible or desirable as the complexity of an application increases: sometimes we need units of behaviour or style to be separated as individual components that map to elements in the markup. Ideally, these components should be as self-contained and expressive as possible, so they remain readable and composable.

This kit gives you the flexibility to find a pragmatic balance between Locality of Behaviour (LoB) and Separation of Concerns (SoC) that suits your project and preferences.

Styling

You may need to create bespoke styles for UI states that can’t easily be expressed with Tailwind CSS classes. This kit allows you to organise these in a ITCSS-inspired folder hierarchy, and use SASS as much or as little as you wish.

  • Settings – global variables, config switches etc.
  • Functions – globally used functions.
  • Mixins – globally used mixins.
  • Base – styling for bare HTML elements (like BODY, H1, A, etc.).
  • Objects – class-based selectors which define undecorated, design patterns, intended to be reusable between projects (e.g. .o-ratio).
  • Layouts – layout grids and containers (e.g. .l-container).
  • Vendor - third party component stylesheets
  • Components – specific UI components (e.g. .c-button).
  • Utils – utilities and helper classes with ability to override anything which goes before (e.g. .h1).

Scripting

Alpine.js allows you to express UI component behaviour directly in markup, but sometimes you may need to isolate behaviour in an individual component and load it asynchronously on demand rather than in one big script bundle up-front. This kit allows you to use Async Alpine components, Vue SFCs or roll your own vanilla JS components. The later can be used to load heavy third-party libraries like GSAP in a memory-efficient manner, by wrapping them in a mount() / unmount() lifecycle.

framework/start.js

This file controls the components you wish to load, and the selectors they map to.

Global components

Global components are loaded once on initial page load. They manage the state of site-wide elements and behaviours like the main menu, <head> metadata and window resize events. Create global components in components/global and initialise in globalComponents() in framework/start.js.

Local components

Local components are classes attached to elements that are automatically loaded on demand in content swapped into a target by htmx, such as <main>, and destroyed automatically when the element they are attached to is no longer in the DOM. Create local components in components/local and attach to elements with data-component="myComponent". Determine the loading strategy for the component instance with data-load="".

The component can appear once or multiple times in your markup, with each instance respecting the loading strategy specified for the element it is mounted on, and each instance of the class being mounted / unmounted independently. Regardless of the number of instances, the component’s script (split into an individual chunk file by Vite) will only be requested once - when a matching component is first encountered.

For example, if you create a component class at components/local/myComponent.js, you can use it in your html like this:

<div id="a-unique-id" data-component="myComponent" data-load="visible"></div>
<div id="another-unique-id" data-component="myComponent" data-load="media (min-width: 1024px)" data-options='{"option1":"value1", "option2":"value2"}'></div>

Each instance must have a unique ID.

Conductors

Conductors are a special type of local component for managing multiple elements matching a selector, rather than being attached to individual elements via data-component="" attributes. They can be a more efficient way to coordinate the behaviour of arbitrary groups of separated elements, such as lazy loaded images or viewport intersection animations: instead of multiple instances of a component's class there will only ever be one.

To register conductors pass a conductors array to the ConductorFactory() class. Specify the conductor name, CSS selector and loading strategy for each conductor you want to register.

A conductor is loaded and mounted using the specified strategy when its selector is detected in the dom, and unmounted (but not destroyed) when it's selector is no longer found in the dom; as such, conductors are stateful - they retain any properties that you set on the class regardless of mount/unmount lifecycles, unless you destroy the properties in unmount(). Conductors are also not bound to a htmx target, so mounted conductors will be "refreshed" (unmount/mount) on every swap, if the selector remains in the dom after the swap.

    new ConductorFactory('component', [
            { conductor: "myConductor1", selector: "[data-thing-1]", strategy: "eager" }
            { conductor: "myConductor2", selector: "[data-thing-2]", strategy: "visible" }
    ]);

Alpine Async components

Asynchronous Alpine components can be loaded anywhere in your markup.

Create Alpine components in components/alpine. See components/alpine/message.js for an example.

Alpine components must be initialised in asyncAlpineComponents() in framework/start.js:

AsyncAlpine.data("message", () => import("../components/alpine/message.js"));

In your html:

  <div
    ax-load="visible"
    x-data="message('Component loaded with Async Alpine using the `visible` strategy')"
    x-ignore>
  </div>

If the element controlled by Alpine contains markup, preserve the initial markup state for history restores by using the hx-history-preserve attribute. This allows Alpine to reinitialise itself properly when the user navigates to the page with the browser’s back/forward buttons.

<div id="alpine-search" hx-history-preserve x-data='{}'>
    <details class="c-search__item" role="list" :open="isOpen">
        <ul class="border border-200 rounded p-2 shadow-xl max-h-64 overflow-y-scroll" 
            role="listbox" 
            x-on:click.outside="closeSearch">
            <template x-for="item in getItems" :key="item.id">
            </template>
        </ul>
    </details>
</div>

For instructions see Async Alpine.

Vue single-file components

Vue components are loaded on demand in content swapped by htmx, such as <main>. Create components in components/vue, and attach to elements with data-component="MyComponent" and data-type="vue". Determine the loading strategy for the component with data-load="", and pass props via the data-options="" attribute (which accepts any valid JSON string).

No initialisation step is required for Vue components; like local components they are loaded and mounted automatically on demand, as individual Vue application instances.

See components/vue/LocationMap.js for an example.

<div 
  id="map-london"
  data-component="LocationMap"
  data-type="vue"
  data-load="visible"
  data-options='{
    "latitude": "51.509865", 
    "longitude": "-0.118092", 
    "caption": "A map of London"
  }'>
</div>

For more, see Vue SFCs

Loading strategies

Loading strategies allow you to load components asynchronously on demand instead of up-front, freeing up the main thread and speeding up page rendering. Alpine components use the ax-load attribute to specify the strategy, whereas vanilla JS and vue components use the data-load attribute.

Eager

The default strategy if not specified. If the component is present in the page on initial load, or in content swapped into the dom by htmx, it will be loaded and mounted immediately.

Event

Vanilla JS components and Vue components can listen for an event on document.body to be triggered before they are loaded. Pass the event name in parentheses.

<div id="my-thing-1" data-component="myThing" data-load="event (htmx:validation:validate)"></div>

Alpine async components have their own implementation of Event - see: https://async-alpine.dev/docs/strategies/#event.

Idle

Uses requestIdleCallback (where supported) to load when the main thread is less busy. Where requestIdleCallback isn’t supported (Safari) we use an arbitrary 200ms delay to allow the main thread to clear.

Best used for components that aren’t critical to the initial paint/load.

<div id="my-thing-1" data-component="myThing" data-load="idle"></div>

Media

The component will be loaded when the provided media query evaluates as true.

<div id="my-thing-1" data-component="myThing" data-load="media (max-width: 820px)"></div>

Visible

Uses IntersectionObserver to only load when the component is in view, similar to lazy-loading images. Optionally, custom root margins can be provided in parentheses.

<div id="my-thing-1" data-component="myThing" data-load="visible (100px 100px 100px 100px)"></div>

Combined strategies

Strategies can be combined by separating with a pipe |, allowing for advanced and complex code splitting. All strategies must resolve to trigger loading of the component.

<div id="my-thing-1" data-component="myThing" data-load="idle | visible | media (min-width: 1024px)"></div>

Creating your own local components and conductors

Component classes must extend the Booster class and have mount() and unmount() methods.

See HTMX Booster Pack for details.

HTML:

<div id="my-thing-1" data-component="myThing" data-options='{"message":"Hello!"}'></div>

components/local/myThing.js:

import { Booster } from 'htmx-booster-pack';

export default class MyThing extends Booster {
    
    thing;
    thingObserver;
    
    constructor(elm) {
        super(elm);
        
        // default options here are merged with those set on the element
        // with data-options='{"option1":"value1"}'
        this.options = {
            message: "Hi, I'm thing",
        };

        this.mount();
    }

    mount() {
        // setup and mount your component instance
        this.thing = document.querySelector(this.elm);
        
        // do amazing things...
        this.clicked = (e) => {
          console.log('Hello!');
        }
        this.clickHandler = this.clicked.bind(this);
        window.addEventListener('click', this.clickHandler);
        
        this.thingObserver = new IntersectionObserver(...);
        
        this.setState('component', {
          playingVideoId: "123"
        });
    }

    unmount() {
        // remove any event listeners you created on global objects like 'window'
        // and on any dom elements that you created in mount()
        window.removeEventListener('click', this.clickHandler);
        this.clickHandler = null;
        
        // remove any observers you connected
        this.thingObserver.disconnect();
        this.thingObserver = null;
        
        // unset any references to DOM nodes
        this.thing = null;
        
        // destroy state if you used it
        this.destroyState('component');
    }
}

Event bus

For communication between components, the kit comes with PubSubJS, a topic-based publish/subscribe library.

Example use:

import PubSub from 'pubsub-js';

// subscribe to 'video.play'
let topic = 'video.play';
let subscriber = PubSub.subscribe(topic, (msg, id) => {
    if (id !== player.plyId) {
        player.pause();
    }
});

player.on('play', event => {
    this.videoMount.classList.add('is-playing');
    // pause any other videos mounted on the page that are playing
    PubSub.publish(topic, player.plyId);
});
        

Be sure to unsubscribe to topics in unmount():

 // unsubscribe
 PubSub.unsubscribe(subscriber);

Thank you

Inspired by:

About

Hypermedia Driven Application starter kit for Craft CMS

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published