Search by

icetea / icecube

khanhicetea

Ice Component system for PHP, using IceCube

0.2.2 2025-11-12 08:02 UTC

This package is auto-updated.

Last update: 2026-10-02 05:09:37 UTC


README

A PHP Single-File Component (SFC) framework for building reactive, component-based web applications. IceCube allows you to write PHP components with colocated styles and JavaScript in a single file, similar to Vue.js Single File Components.

Goals

IceCube aims to provide:

  1. Single-File Component Architecture: Write PHP components with their styles and JavaScript in one cohesive file (.ice.php)
  2. Component Encapsulation: Scoped styles that don't leak to other components
  3. Reactive Client-Side Behavior: Easy-to-use client-side interactivity with a refs-based system
  4. Flexible Compilation: Support for different style preprocessors (CSS, SCSS) and bundling strategies (embedded, Vite)
  5. Performance Optimization: Automatic compilation, caching, and on-demand loading of components
  6. Developer Experience: Intuitive API for creating interactive components without complex build processes
  7. Progressive Enhancement: Components work server-side first, with optional client-side enhancements

Architecture

Core Components

1. Component System

  • Component: Abstract base class for all components

    • Provides unique ID generation for each component instance
    • Implements SafeStringable interface for seamless HTML rendering
  • SingleFileComponent: Extended base class for SFC pattern

    • Automatically extracts public properties as component props
    • Injects component metadata (data-icecube, data-props) into rendered HTML
    • Manages component lifecycle and client-side hydration

2. Parser Layer

  • IceCubeParser: Extracts content from .ice.php files

    • Separates PHP code from style and script tags
    • Supports multiple <style> tags with optional global attribute
    • Can parse colocated .js files separately
    • Generates content digest for cache invalidation
  • ParsedComponent: Data structure holding parsed component parts

3. Compiler Layer

  • IceCubeCompiler: Main compilation orchestrator

    • Scans directories for .ice.php files
    • Manages autoloading of components
    • Compiles components on-demand or in batch
    • Writes compiled files only when changed (optimization)
  • CompiledComponent: Container for compiled component artifacts

Style Compilers

Implement the StyleCompiler interface:

Script Compilers

Implement the ScriptCompiler interface:

4. Registry System

  • IceCubeRegistry: Global component registry
    • Stores compiled component metadata
    • Provides cache loading/storing functionality
    • Generates collection of all component styles
    • Injects client-side initialization script

5. Cache System

  • CachedComponent: Lightweight cached component data
    • Serializable component metadata for production
    • Supports PHP's var_export for efficient caching

Compilation Flow

.ice.php file
    ↓
IceCubeParser
    ↓
ParsedComponent (PHP + Styles + JS)
    ↓
IceCubeCompiler
    ├→ StyleCompiler → compiled.css
    ├→ ScriptCompiler → compiled.js (with styles)
    └→ PHP class → compiled.php
    ↓
CompiledComponent
    ↓
IceCubeRegistry

Client-Side Runtime

The registry generates JavaScript that:

  1. Dynamically imports component scripts on-demand
  2. Initializes components via MutationObserver (supports dynamic content)
  3. Provides a refs proxy for easy DOM element access
  4. Passes component props from server to client
  5. Tracks component initialization states (icing → iced)

Style Scoping

Styles are automatically scoped by wrapping them with the component's data attribute:

/* Original */
.button {
  color: red;
}

/* Compiled (Nesting) */
[data-icecube="App_Components_Counter"] {
  .button {
    color: red;
  }
}

/* Or with SCSS compiler */
[data-icecube="App_Components_Counter"] .button {
  color: red;
}

Global styles (marked with <style global>) bypass scoping.

Usage Guide

Basic Setup (Without Vite)

1. Create a Component

Create a file app/Components/Counter.ice.php:

<?php

namespace App\Components;

use IceTea\IceCube\SingleFileComponent;
use IceTea\IceDOM\HtmlNode;

class Counter extends SingleFileComponent
{
  public function __construct(
    public int $initialCount = 0,
    public string $label = 'Counter',
  ) {}

  public function render(): HtmlNode
  {
    return _div(['class' => 'counter-container'], [
      _h2($this->label),
      _p([
        _span('Count: '),
        _strong(['class' => 'count-value'], [
          _span($this->initialCount)->data_ref('counter'),
        ]),
      ]),
      _div([
        _button(['class' => 'btn'], '-')->data_ref('decrementBtn'),
        _button(['class' => 'btn'], '+')->data_ref('incrementBtn'),
      ], ['class' => 'button-group']),
    ]);
  }
}

?>

<style>
  & {
    max-width: 400px;
    margin: 2rem auto;
    padding: 2rem;
    background: #f8f9fa;
  }

  .count-value {
    font-size: 2rem;
    color: #007bff;
  }

  .button-group {
    display: flex;
    gap: 0.5rem;
  }
</style>

<script>
export default function ({ root, refs, props }) {
  const { counter, incrementBtn, decrementBtn } = refs;

  let count = props.initialCount || 0;
  const render = () => {
    counter.textContent = count;
  };

  render();

  incrementBtn.addEventListener('click', () => {
    count++;
    render();
  });

  decrementBtn.addEventListener('click', () => {
    count--;
    render();
  });
}
</script>

Alternatively, separate the JavaScript into Counter.js:

// app/Components/Counter.js
export default function ({ root, refs, props }) {
  const { counter, incrementBtn, decrementBtn } = refs;
  // ... rest of the logic
}

2. Initialize the Compiler

In your application bootstrap (e.g., bootstrap/app.php):

use IceTea\IceCube\Compiler\IceCubeCompiler;
use IceTea\IceCube\Compiler\EmbedStyleScriptCompiler;

$compiler = new IceCubeCompiler(
    prefixClass: 'App\\Components',
    sourceDir: __DIR__ . '/../app/Components',
    compiledPhpDir: storage_path('icecube'),
    compiledAssetsDir: public_path('icecube'),
    publicUrl: '/icecube',
    scriptCompiler: new EmbedStyleScriptCompiler()
);

// For development: compile on-demand via autoloader (automatic)
// For production: pre-compile all components
$compiler->scanAndCompile();

3. Render Components in Views

In your Blade template:

<!DOCTYPE html>
<html>
<head>
    <title>My App</title>
    <?= IceCubeRegistry::allStyles() ?>
</head>
<body>
    <?= new Counter(initialCount: 10, label: 'My Counter') ?>

    <?= IceCubeRegistry::iceCubeScript() ?>
</body>
</html>

4. Production Optimization

For production, use caching:

// During build/deployment
use IceTea\IceCube\IceCubeRegistry;

$compiler->scanAndCompile();
IceCubeRegistry::storeCache(storage_path('icecube/cache.php'));
// In production bootstrap
IceCubeRegistry::loadCache(storage_path('icecube/cache.php'));

Advanced Setup (With Vite)

1. Install Required Dependencies

npm install --save-dev vite glob
# Optional: For reactive state management
npm install @preact/signals

2. Configure Vite

Update vite.config.js to use Vite's glob import for dynamic component loading:

// vite.config.js
import { defineConfig } from "vite";
import laravel from "laravel-vite-plugin";

export default defineConfig({
  plugins: [
    laravel({
      input: ["resources/css/app.css", "resources/js/app.js"],
      refresh: true,
    }),
  ],
});

3. Create IceCube Loader Script

Create resources/js/icecube.js:

// Use Vite's import.meta.glob for dynamic imports
const componentScripts = import.meta.glob("./**/*.js", {
  eager: false,
  base: "../../storage/app/public/icecube",
});

const initComponent = async (node) => {
  const name = node.dataset.icecube;
  if (!name) return;
  const mod = await componentScripts[`./${name}.js`]?.();
  if (!mod) return;
  const refs = new Proxy(
    {},
    { get: (_, r) => node.querySelector(`[data-ref="${r}"]`) }
  );
  node.dataset.cube = "icing";
  await mod.default({
    root: node,
    refs,
    props: JSON.parse(node.dataset.props || "{}"),
  });
  node.dataset.cube = "iced";
};

(() => {
  document.querySelectorAll("[data-icecube]").forEach(initComponent);

  const observer = new MutationObserver((mutations) => {
    mutations
      .flatMap((m) => [...m.addedNodes])
      .forEach((node) => {
        if (
          node.nodeType === Node.ELEMENT_NODE &&
          node.dataset.icecube !== undefined
        ) {
          initComponent(node);
          node.querySelectorAll?.("[data-icecube]").forEach(initComponent);
        }
      });
  });

  observer.observe(document.documentElement, {
    childList: true,
    subtree: true,
  });
})();

4. Use icecube.js in your app

In blade template:

@vite(['resources/js/app.js', 'resources/js/icecube.js'])

Or in icedom

<?php

_head([
  _safe(Vite::withEntryPoints(['resources/js/app.js', 'resources/js/icecube.js'])->toHtml()),
])

5. Configure Compiler with Vite Strategy

In your application bootstrap or controller:

use IceTea\IceCube\Compiler\IceCubeCompiler;
use IceTea\IceCube\Compiler\ViteScriptCompiler;
use IceTea\IceCube\Compiler\ScssStyleCompiler;

// Optional: Configure SCSS with custom import paths
$styleCompiler = new ScssStyleCompiler();
$scss = $styleCompiler->getCompiler();
$scss->addImportPath(base_path('app/Components'));

$compiler = new IceCubeCompiler(
    prefixClass: 'App\\Components',
    sourceDir: base_path('app/Components'),
    compiledPhpDir: storage_path('app/private/icecube'),
    compiledAssetsDir: storage_path('app/public/icecube'),
    publicUrl: '/storage/icecube',
    scriptCompiler: new ViteScriptCompiler(),
    styleCompiler: $styleCompiler, // Optional: for SCSS support
);

$compiler->scanAndCompile();

// When using Vite, skip storing styles in cache (they're handled by Vite)
IceCubeRegistry::storeCache(
    storage_path('app/private/icecube/cache.php'),
    includeStyles: false
);

6. Run Vite Dev Server

npm run dev

With ViteScriptCompiler, compiled JavaScript will import CSS separately:

// Compiled output: storage/app/public/icecube/App_Components_Counter.js
import "./App_Components_Counter.css";

export default function ({ root, refs, props }) {
  // Your component logic
}

7. Use NPM Packages in Components

You can import any NPM package in your component JavaScript files:

// app/Components/Counter.js
import { signal, effect } from "@preact/signals";

export default function ({ root, refs, props }) {
  const { counter, incrementBtn, decrementBtn } = refs;

  const count = signal(props.initialCount || 0);

  effect(() => {
    counter.textContent = count.value;
  });

  incrementBtn.addEventListener("click", () => count.value++);
  decrementBtn.addEventListener("click", () => count.value--);
}

Laravel Integration Guide

Quick Setup

1. Register Service Provider

Add IceCubeServiceProvider to config/app.php:

'providers' => [
    // ...
    IceTea\IceCube\Laravel\IceCubeServiceProvider::class,
],

Or for Laravel 11+, add to bootstrap/providers.php:

return [
    // ...
    IceTea\IceCube\Laravel\IceCubeServiceProvider::class,
];

2. Publish Configuration

php artisan vendor:publish --tag=icecube

This creates config/icecube.php and resources/js/icecube.js where you can customize:

  • Component namespace and directories
  • Compiler strategies (Vite/Embed, SCSS/CSS)
  • Cache settings

3. Create Storage Link

php artisan storage:link

4. Use Components in Blade Views

<!DOCTYPE html>
<html>
<head>
    <title>IceCube Demo</title>
    @vite(['resources/js/icecube.js'])
</head>
<body>
    <?= new \App\Components\Counter(initialCount: 10, label: 'My Counter') ?>
    <?= new \App\Components\Counter(initialCount: 100, label: 'Another Counter') ?>
</body>
</html>

Service Provider Behavior

The IceCubeServiceProvider automatically:

  • Development Mode: Compiles components on-demand via autoloader
  • Production Mode: Loads pre-compiled components from cache for maximum performance

Production Build Command

Compile all components and generate cache before deployment:

php artisan icecube:compile

This CompileIceCubeCommand scans all .ice.php files, compiles them, and stores the cache.

Note: The command automatically detects if you're using ViteScriptCompiler and will skip storing styles in cache (since Vite handles CSS bundling separately).

Add to your deployment script:

# Deploy script
composer install --optimize-autoloader --no-dev
php artisan icecube:compile
npm run build  # Vite handles CSS bundling
php artisan config:cache
php artisan route:cache

Configuration

Configuration in config/icecube.php:

return [
    'compilers' => [
        'default' => [
            'prefix_class' => 'App\\Components',
            'source_dir' => base_path('app/Components'),
            'compiled_php_dir' => storage_path('app/private/icecube'),
            'compiled_assets_dir' => storage_path('app/public/icecube'),
            'public_url' => '/storage/icecube',
            'script_compiler' => ViteScriptCompiler::class,
            'style_compiler' => ScssStyleCompiler::class,
            'cache_enabled' => env('ICECUBE_CACHE', true),
            'cache_file' => storage_path('app/private/icecube/cache.php'),
        ],
    ],
];

SCSS Import Paths (Optional)

To use SCSS imports like @import './_base.scss', configure in your service provider:

use IceTea\IceCube\Compiler\ScssStyleCompiler;

$styleCompiler = app(ScssStyleCompiler::class);
$scss = $styleCompiler->getCompiler();
$scss->addImportPath(base_path('app/Components'));

Dynamic Components (HTMX/AJAX)

Components work seamlessly with dynamic content loading:

// Controller
public function loadComponent(Request $request)
{
    return (string) new Counter($request->count, 'Dynamic');
}
<!-- View -->
<div hx-get="/load-component?count=100" hx-swap="outerHTML">
    Load Component
</div>

The icecube.js runtime automatically initializes dynamically added components.

Multiple Compiler Configurations

Configure multiple compilers for different component sets in config/icecube.php:

return [
    'compilers' => [
        'main' => [
            'prefix_class' => 'App\\Components',
            'source_dir' => base_path('app/Components'),
            'compiled_php_dir' => storage_path('app/private/icecube/main'),
            'compiled_assets_dir' => storage_path('app/public/icecube/main'),
            'public_url' => '/storage/icecube/main',
            'script_compiler' => ViteScriptCompiler::class,
            'style_compiler' => ScssStyleCompiler::class,
            'cache_enabled' => true,
            'cache_file' => storage_path('app/private/icecube/main-cache.php'),
        ],

        'admin' => [
            'prefix_class' => 'App\\Admin\\Components',
            'source_dir' => base_path('app/Admin/Components'),
            'compiled_php_dir' => storage_path('app/private/icecube/admin'),
            'compiled_assets_dir' => storage_path('app/public/icecube/admin'),
            'public_url' => '/storage/icecube/admin',
            'script_compiler' => EmbedStyleScriptCompiler::class,
            'style_compiler' => NestingStyleCompiler::class,
            'cache_enabled' => true,
            'cache_file' => storage_path('app/private/icecube/admin-cache.php'),
        ],
    ],
];

Compilers are automatically registered in the Laravel container as icecube.compiler.{name} singletons.

Compile All:

php artisan icecube:compile

Compile Specific:

php artisan icecube:compile --compiler=admin

The IceCubeServiceProvider automatically:

  • Registers all compilers in the container
  • In development: initializes compilers for on-demand compilation
  • In production: loads all caches for maximum performance

Component API

Props System

Public properties are automatically passed to client-side:

class MyComponent extends SingleFileComponent
{
  public function __construct(
    public string $title,      // Passed to client
    public array $items,       // Passed to client
    private string $secret,    // NOT passed to client
  ) {}
}

Refs System

Use data_ref() to mark elements for easy client-side access:

_button('Click me')->data_ref('myButton')
export default function ({ root, refs, props }) {
  refs.myButton.addEventListener("click", () => {
    console.log("Clicked!");
  });
}

Component Parameters

  • root: The component's root DOM element
  • refs: Proxy object for accessing elements with data-ref attributes
  • props: Public properties passed from PHP as JSON

Style Scoping

  • Regular <style> tags: Scoped to component
  • <style global>: Applied globally, not scoped
<style>
  /* Scoped: only affects this component */
  .button { color: blue; }
</style>

<style global>
  /* Global: affects entire page */
  body { margin: 0; }
</style>

SCSS Support

To use SCSS, configure the compiler with ScssStyleCompiler:

use IceTea\IceCube\Compiler\ScssStyleCompiler;

$compiler = new IceCubeCompiler(
    // ... other params
    styleCompiler: new ScssStyleCompiler()
);

Then use SCSS syntax in your components:

<style>
  $primary-color: #007bff;

  & {
    background: $primary-color;

    .nested {
      color: darken($primary-color, 10%);
    }
  }
</style>

File Structure

icecube/
├── src/
│   ├── Component.php                       # Base component class
│   ├── SingleFileComponent.php             # SFC base class
│   ├── IceCubeRegistry.php                # Component registry
│   ├── Parser/
│   │   ├── IceCubeParser.php             # Parses .ice.php files
│   │   └── ParsedComponent.php            # Parsed data structure
│   ├── Compiler/
│   │   ├── IceCubeCompiler.php           # Main compiler
│   │   ├── CompiledComponent.php          # Compiled data structure
│   │   ├── StyleCompiler.php              # Style compiler interface
│   │   ├── NestingStyleCompiler.php       # CSS nesting strategy
│   │   ├── ScssStyleCompiler.php          # SCSS compilation strategy
│   │   ├── ScriptCompiler.php             # Script compiler interface
│   │   ├── EmbedStyleScriptCompiler.php   # Embed CSS in JS
│   │   └── ViteScriptCompiler.php         # Vite-compatible output
│   ├── Cache/
│   │   └── CachedComponent.php            # Cached component data
│   └── Laravel/                            # Laravel integration
│       ├── IceCubeServiceProvider.php     # Service provider
│       ├── CompileIceCubeCommand.php      # Artisan command
│       └── config/
│           └── icecube.php                # Configuration file

Best Practices

  1. Component Organization: Keep components in a dedicated directory (e.g., app/Components)
  2. Naming Convention: Use PascalCase for component class names and files
  3. Separation of Concerns: For complex JavaScript, use separate .js files
  4. Style Scoping: Prefer scoped styles; use global styles sparingly
  5. Props Validation: Validate and type-hint public properties
  6. Production Builds: Always pre-compile and cache components in production
  7. Asset Strategy: Choose between embedded styles or Vite based on your build setup

License

MIT License