WP Vite Setup mit Splide

JSON
// package.json
{
  "name": "vite-wp-config",
  "version": "1.0.0",
  "description": "Vite Config für Child Themes",
  "type": "module",
  "scripts": {
    "dev": "vite build --watch",
    "build": "vite build"
  },
  "devDependencies": {
    "@splidejs/splide": "^4.1.4",
    "sass": "^1.90.0",
    "vite": "^7.1.2"
  }
}
JavaScript
// vite.config.js
import { defineConfig } from 'vite';
import { fileURLToPath } from 'url';
import { dirname, resolve, basename } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

// CSS-Dateinamen ohne Hash/Ordner/Endung
function cssName(info) {
  const n = info.name || '';
  const base = basename(n).replace(/\.(scss|sass|css)$/i, '');
  return `${base}.css`;
}

export default defineConfig({
  build: {
    outDir: resolve(__dirname, 'dist'),
    assetsDir: '',          // keine Unterordner in /dist
    emptyOutDir: true,
    cssCodeSplit: true,
    rollupOptions: {
      input: {
        // EIN JS-Entry (importiert main.scss selbst)
        main: resolve(__dirname, 'assets/scripts/main.js'),
        // ADMIN & LOGIN sind reine CSS-Entries
        admin: resolve(__dirname, 'assets/styles/admin.scss'),
        login: resolve(__dirname, 'assets/styles/login.scss'),
      },
      output: {
        format: 'es',
        entryFileNames: (chunk) => {
          if (chunk.name === 'main') return 'main.js';
          return '[name].js';
        },
        chunkFileNames: 'chunks/[name].js',
        assetFileNames: (info) => {
          if ((info.name || '').match(/\.(css|scss|sass)$/i)) {
            // -> main.css (aus Importen in main.js), admin.css, login.css
            return cssName(info);
          }
          return '[name][extname]';
        },
      },
    },
  },
  resolve: {
    alias: {
      '@styles': resolve(__dirname, 'assets/styles'),
      '@scripts': resolve(__dirname, 'assets/scripts'),
    },
  },
});
JavaScript
// main.js
import '@styles/main.scss';
import '@splidejs/splide/css';
import Splide from '@splidejs/splide';

document.addEventListener('DOMContentLoaded', () => {
  const el = document.querySelector('.js-testimonials-splide');
  if (!el) return;
  new Splide(el, {
    type: 'loop',
    perPage: 1,
    arrows: true,
    pagination: true,
    autoplay: true,
  }).mount();
});

// portfolio
(() => {
  const init = (root) => {
    const grid = root.querySelector('.pf-grid');
    const chips = Array.from(root.querySelectorAll('.pf-chip'));
    const loadMoreBtn = root.querySelector('.pf-loadmore');
    if (!grid || !loadMoreBtn) return;

    const isMobile = () => window.matchMedia('(max-width: 799px)').matches;
    const pageSize = () => (isMobile()
      ? parseInt(root.dataset.pagesizeMobile || '6', 10)
      : parseInt(root.dataset.pagesizeDesktop || '16', 10)
    );

    let selected = new Set(['__all__']);
    const items = Array.from(grid.querySelectorAll('.pf-item'));
    let visibleCount = 0;

    const itemMatchesSelection = (item) => {
      if (selected.has('__all__')) return true;
      const terms = (item.getAttribute('data-terms') || '')
        .split(',')
        .map(s => s.trim())
        .filter(Boolean);
      return terms.some(t => selected.has(t));
    };

    const setChipActiveStates = () => {
      chips.forEach((btn) => {
        const active = selected.has(btn.dataset.filter);
        btn.classList.toggle('is-active', active);
        btn.setAttribute('aria-pressed', active ? 'true' : 'false');
      });
    };

    const applyFilter = (resetPage = true) => {
      const matched = [];
      items.forEach((item) => {
        const match = itemMatchesSelection(item);
        item.classList.toggle('is-hidden', !match);
        if (match) matched.push(item);
      });

      if (resetPage) visibleCount = 0;

      const step = pageSize();
      const target = Math.min(matched.length, visibleCount + step);

      matched.forEach((item, idx) => {
        item.classList.toggle('is-hidden', idx >= target);
      });

      visibleCount = target;

      const canLoadMore = visibleCount < matched.length;
      loadMoreBtn.disabled = !canLoadMore;
      loadMoreBtn.hidden = !canLoadMore;
    };

    chips.forEach((btn) => {
      btn.addEventListener('click', () => {
        const f = btn.dataset.filter;
        if (f === '__all__') {
          selected = new Set(['__all__']);
        } else {
          selected.delete('__all__');
          if (selected.has(f)) {
            selected.delete(f);
          } else {
            selected.add(f);
          }
          if (selected.size === 0) selected.add('__all__');
        }
        setChipActiveStates();
        applyFilter(true);
      });
    });

    loadMoreBtn.addEventListener('click', () => {
      const matched = items.filter(item => itemMatchesSelection(item));
      const currentlyVisible = matched.filter(item => !item.classList.contains('is-hidden')).length;
      const step = pageSize();
      const target = currentlyVisible + step;

      matched.forEach((item, idx) => {
        if (idx < target) item.classList.remove('is-hidden');
      });

      const total = matched.length;
      const canLoadMore = target < total;
      loadMoreBtn.disabled = !canLoadMore;
      loadMoreBtn.hidden = !canLoadMore;
    });

    let rAF = null;
    window.addEventListener('resize', () => {
      if (rAF) cancelAnimationFrame(rAF);
      rAF = requestAnimationFrame(() => applyFilter(true));
    });

    setChipActiveStates();
    applyFilter(true);
  };

  document.addEventListener('DOMContentLoaded', () => {
    document.querySelectorAll('.pf-wrap').forEach(init);
  });
})();
PHP
<?php
// FILE: functions.php

if (! defined('ABSPATH')) {
    exit;
}

/**
 * Bindet rekursiv alle PHP-Dateien aus einem Verzeichnis ein.
 * Ignoriert Dateien/Ordner, die mit "_" oder "." beginnen.
 *
 * @param string $dir
 * @param bool   $sort_files
 */
function gw_require_all_php(string $dir, bool $sort_files = true): void
{
    if (! is_dir($dir)) {
        return;
    }

    $iterator = new RecursiveIteratorIterator(
        new RecursiveCallbackFilterIterator(
            new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS),
            static function (SplFileInfo $current) {
                $name = $current->getFilename();
                // Ordner/Dateien überspringen, die mit "_" oder "." beginnen
                if ($name !== '' && ($name[0] === '_' || $name[0] === '.')) {
                    return false;
                }
                // Verzeichnisse passieren lassen; Dateien nur, wenn .php
                return $current->isDir() || strtolower($current->getExtension()) === 'php';
            }
        ),
        RecursiveIteratorIterator::SELF_FIRST
    );

    $php_files = [];
    foreach ($iterator as $file) {
        if ($file->isFile() && strtolower($file->getExtension()) === 'php') {
            $php_files[] = $file->getPathname();
        }
    }

    if ($sort_files) {
        sort($php_files, SORT_STRING);
    }

    foreach ($php_files as $file) {
        require_once $file;
    }
}

// Alle Includes laden
gw_require_all_php(get_stylesheet_directory() . '/includes');
PHP
<?php
// FILE: includes/01-enqueue.php

if (! defined('ABSPATH')) {
    exit;
}

/**
 * Liefert URI & Version (filemtime) eines Assets im /dist Ordner.
 * Existiert die Datei nicht, ist 'ver' = null.
 *
 * @param string $rel_path Pfad relativ zu /dist, z.B. 'main.css'
 * @return array{uri:string,ver:int|null}
 */
function gw_dist_asset(string $rel_path): array
{
    $rel_path = ltrim($rel_path, '/');
    $abs = trailingslashit(get_stylesheet_directory()) . 'dist/' . $rel_path;
    $uri = trailingslashit(get_stylesheet_directory_uri()) . 'dist/' . $rel_path;

    return [
        'uri' => $uri,
        'ver' => file_exists($abs) ? filemtime($abs) : null,
    ];
}

/**
 * Frontend: Globales CSS + globales main.js als ES-Modul.
 * (JS ist jetzt zentral für Portfolio + Testimonials.)
 */
add_action('wp_enqueue_scripts', function () {
    // main.css global
    $main_css = gw_dist_asset('main.css');
    if ($main_css['ver']) {
        wp_enqueue_style('gw-main', $main_css['uri'], [], $main_css['ver']);
    }

    // admin.css & login.css NICHT hier (eigene Hooks unten)

    // main.js global als ES-Modul
    $main_js = gw_dist_asset('main.js');
    if ($main_js['ver']) {
        wp_enqueue_script('gw-main', $main_js['uri'], [], $main_js['ver'], true);
    }
}, 20);

/**
 * Script-Tag auf type="module" umbiegen für gw-main.
 */
add_filter('script_loader_tag', function ($tag, $handle, $src) {
    if ($handle === 'gw-main') {
        $tag = sprintf(
            '<script type="module" src="%s" id="%s-js"></script>',
            esc_url($src),
            esc_attr($handle)
        );
    }
    return $tag;
}, 10, 3);

/**
 * Login-Styles (falls dist/login.css existiert)
 */
add_action('login_enqueue_scripts', function () {
    $a = gw_dist_asset('login.css');
    if ($a['ver']) {
        wp_enqueue_style('gw-login', $a['uri'], [], $a['ver']);
    }
});

/**
 * Admin-Styles (falls dist/admin.css existiert)
 */
add_action('admin_enqueue_scripts', function () {
    $a = gw_dist_asset('admin.css');
    if ($a['ver']) {
        wp_enqueue_style('gw-admin', $a['uri'], [], $a['ver']);
    }
});