Add the Material 3 Expressive foundation
tests / lint (push) Successful in 1m0s
tests / feature (8.4) (push) Successful in 1m0s
tests / feature (8.5) (push) Successful in 1m1s
tests / browser (safari, webkit) (push) Successful in 1m59s
tests / browser (chrome, chromium) (push) Successful in 1m49s
tests / browser (firefox, firefox) (push) Successful in 1m54s
tests / lint (push) Successful in 1m0s
tests / feature (8.4) (push) Successful in 1m0s
tests / feature (8.5) (push) Successful in 1m1s
tests / browser (safari, webkit) (push) Successful in 1m59s
tests / browser (chrome, chromium) (push) Successful in 1m49s
tests / browser (firefox, firefox) (push) Successful in 1m54s
Colour, shape, type, elevation and motion as tokens and Tailwind utilities; `php artisan material:scheme`, which generates an app's colour roles with Google's material-color-utilities (spec 2025); the theme head script with light, dark and system and its Alpine store; Google Sans Flex; every Material Symbol (4,135, outlined and filled) drawn by <x-icon> without blade-icons; all 35 M3 Expressive shapes, ported from androidx, as <x-shape>; the x-figure directive; the Toasts concern; DesignGuard for applications' tests; and a showcase with every token, both themes side by side and an icon search. Colour utilities are `@theme inline`, so a section with its own data-theme repaints; without it they resolve once on :root. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
co-authored by
Claude Opus 5
parent
9b53891a8e
commit
b48e879254
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace NoNameWeb\LivewireMaterial\Concerns;
|
||||
|
||||
/**
|
||||
* A short message after something happened — "Saved", "Link copied" — shown by the snackbar.
|
||||
*
|
||||
* The four method names and arguments are maryUI's `Toast` trait's, so moving to this is a
|
||||
* find-and-replace. The message is a `toast` browser event, dispatched rather than evaluated
|
||||
* as JavaScript, so a test can see it (`assertDispatched('toast')`). It survives
|
||||
* `redirectTo`: the redirect is a wire:navigate, and the event dispatched on the way out is
|
||||
* still on screen when the next page arrives.
|
||||
*
|
||||
* The methods are protected, where maryUI's were public: a public method on a Livewire
|
||||
* component is an action anyone can call from the browser.
|
||||
*/
|
||||
trait Toasts
|
||||
{
|
||||
protected function success(string $title, ?string $description = null, int $timeout = 4000, ?string $redirectTo = null): void
|
||||
{
|
||||
$this->toast('success', $title, $description, $timeout, $redirectTo);
|
||||
}
|
||||
|
||||
protected function warning(string $title, ?string $description = null, int $timeout = 4000, ?string $redirectTo = null): void
|
||||
{
|
||||
$this->toast('warning', $title, $description, $timeout, $redirectTo);
|
||||
}
|
||||
|
||||
protected function error(string $title, ?string $description = null, int $timeout = 4000, ?string $redirectTo = null): void
|
||||
{
|
||||
$this->toast('error', $title, $description, $timeout, $redirectTo);
|
||||
}
|
||||
|
||||
protected function info(string $title, ?string $description = null, int $timeout = 4000, ?string $redirectTo = null): void
|
||||
{
|
||||
$this->toast('info', $title, $description, $timeout, $redirectTo);
|
||||
}
|
||||
|
||||
private function toast(string $type, string $title, ?string $description, int $timeout, ?string $redirectTo): void
|
||||
{
|
||||
$this->dispatch('toast', type: $type, title: $title, description: $description, timeout: $timeout);
|
||||
|
||||
if ($redirectTo !== null) {
|
||||
$this->redirect($redirectTo, navigate: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
<?php
|
||||
|
||||
namespace NoNameWeb\LivewireMaterial\Console;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Filesystem\Filesystem;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use JsonException;
|
||||
use Symfony\Component\Console\Attribute\AsCommand;
|
||||
|
||||
#[AsCommand(name: 'material:scheme')]
|
||||
class SchemeCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'material:scheme
|
||||
{seed : The source colour, as #rrggbb}
|
||||
{--variant=tonal-spot : tonal-spot, vibrant, expressive, neutral, fidelity, content, monochrome, rainbow or fruit-salad}
|
||||
{--contrast=0 : The contrast level, from -1 to 1}
|
||||
{--success=#22a06b : The source of the success colour}
|
||||
{--warning=#e2a400 : The source of the warning colour}
|
||||
{--info=#1d7afc : The source of the info colour}
|
||||
{--output= : The stylesheet to write, resources/css/material-scheme.css by default}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Generate the application\'s Material 3 colour scheme from a seed colour';
|
||||
|
||||
public function handle(Filesystem $files): int
|
||||
{
|
||||
$stylesheet = $this->option('output') ?: resource_path('css/material-scheme.css');
|
||||
$data = preg_replace('/\.css$/', '', $stylesheet).'.json';
|
||||
|
||||
$result = Process::run([
|
||||
config('livewire-material.node', 'node'),
|
||||
__DIR__.'/../../resources/node/scheme.mjs',
|
||||
json_encode([
|
||||
'seed' => $this->argument('seed'),
|
||||
'variant' => $this->option('variant'),
|
||||
'contrast' => (float) $this->option('contrast'),
|
||||
'success' => $this->option('success'),
|
||||
'warning' => $this->option('warning'),
|
||||
'info' => $this->option('info'),
|
||||
]),
|
||||
]);
|
||||
|
||||
if ($result->failed()) {
|
||||
$this->components->error(trim($result->errorOutput()) ?: 'Node could not run the scheme generator. Is `node` installed and on the PATH?');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>} $scheme */
|
||||
$scheme = json_decode($result->output(), true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException) {
|
||||
$this->components->error('The scheme generator answered with something other than JSON.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$files->ensureDirectoryExists(dirname($stylesheet));
|
||||
$files->put($stylesheet, $this->stylesheet($scheme));
|
||||
$files->put($data, json_encode($scheme, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n");
|
||||
|
||||
$this->components->info("Wrote {$stylesheet} and {$data}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{seed: string, variant: string, spec: string, contrast: float, light: array<string, string>, dark: array<string, string>} $scheme
|
||||
*/
|
||||
protected function stylesheet(array $scheme): string
|
||||
{
|
||||
$command = sprintf(
|
||||
'php artisan material:scheme "%s" --variant=%s%s',
|
||||
$scheme['seed'],
|
||||
$scheme['variant'],
|
||||
$scheme['contrast'] != 0 ? ' --contrast='.$scheme['contrast'] : '',
|
||||
);
|
||||
|
||||
$block = fn (array $roles): string => collect($roles)
|
||||
->map(fn (string $hex, string $role): string => " --md-sys-color-{$role}: {$hex};")
|
||||
->implode("\n");
|
||||
|
||||
return <<<CSS
|
||||
/*
|
||||
* Material 3 colour roles, generated by Google's material-color-utilities (spec {$scheme['spec']}).
|
||||
*
|
||||
* {$command}
|
||||
*
|
||||
* Regenerate rather than editing a value: every pair here (a role and its on-role) carries
|
||||
* M3's contrast guarantee only as generated. The head script sets data-theme before the
|
||||
* first paint; the light block also stands without it.
|
||||
*/
|
||||
|
||||
:root,
|
||||
[data-theme='light'] {
|
||||
color-scheme: light;
|
||||
|
||||
{$block($scheme['light'])}
|
||||
}
|
||||
|
||||
[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
|
||||
{$block($scheme['dark'])}
|
||||
}
|
||||
|
||||
CSS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace NoNameWeb\LivewireMaterial\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\View\ComponentAttributeBag;
|
||||
use InvalidArgumentException;
|
||||
use NoNameWeb\LivewireMaterial\Support\SvgFile;
|
||||
|
||||
/**
|
||||
* One Material Symbol as an SVG file, for the showcase's icon search: drawing the matches as
|
||||
* CSS masks keeps 4,000 names searchable without rendering 4,000 inline SVGs.
|
||||
*/
|
||||
class ShowcaseSymbolController
|
||||
{
|
||||
public function __invoke(string $style, string $name): Response
|
||||
{
|
||||
abort_unless(in_array($style, ['outlined', 'filled'], true), 404);
|
||||
|
||||
try {
|
||||
$svg = SvgFile::symbol($name, $style === 'filled', new ComponentAttributeBag);
|
||||
} catch (InvalidArgumentException) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return response($svg->toHtml(), 200, [
|
||||
'Content-Type' => 'image/svg+xml',
|
||||
'Cache-Control' => 'public, max-age=86400',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,10 @@ class LivewireMaterialServiceProvider extends ServiceProvider
|
||||
$this->registerShowcase();
|
||||
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([
|
||||
Console\SchemeCommand::class,
|
||||
]);
|
||||
|
||||
$this->publishes([
|
||||
__DIR__.'/../config/livewire-material.php' => config_path('livewire-material.php'),
|
||||
], 'livewire-material-config');
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace NoNameWeb\LivewireMaterial\Support;
|
||||
|
||||
use Illuminate\Support\HtmlString;
|
||||
use Illuminate\View\ComponentAttributeBag;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Inline SVGs from the package's own folders: Material Symbols and M3 Expressive shapes.
|
||||
*
|
||||
* Not blade-icons: its view-factory hook registers one Blade component per icon on every
|
||||
* request, which for the ~8,000 symbol files here would be ~8,000 registrations each time.
|
||||
* A file is read the first time it is drawn and kept for the life of the worker; the
|
||||
* contents never change while the application runs, so nothing request-specific is held.
|
||||
*/
|
||||
class SvgFile
|
||||
{
|
||||
/** @var array<string, string> */
|
||||
protected static array $contents = [];
|
||||
|
||||
public static function symbol(string $name, bool $filled, ComponentAttributeBag $attributes): HtmlString
|
||||
{
|
||||
if (preg_match('/^[a-z0-9_]+$/', $name) !== 1) {
|
||||
throw new InvalidArgumentException("[{$name}] is not a Material Symbol name. Symbols are named in lowercase with underscores, as on fonts.google.com/icons: `calendar_month`, `arrow_back`.");
|
||||
}
|
||||
|
||||
$path = static::directory('svg/symbols/'.($filled ? 'filled' : 'outlined'))."/{$name}.svg";
|
||||
|
||||
if (! is_file($path)) {
|
||||
throw new InvalidArgumentException("There is no Material Symbol named [{$name}].");
|
||||
}
|
||||
|
||||
return static::render($path, $attributes);
|
||||
}
|
||||
|
||||
public static function shape(string $name, ComponentAttributeBag $attributes): HtmlString
|
||||
{
|
||||
$path = static::directory('svg/shapes')."/{$name}.svg";
|
||||
|
||||
if (preg_match('/^[a-z0-9-]+$/', $name) !== 1 || ! is_file($path)) {
|
||||
throw new InvalidArgumentException("There is no M3 Expressive shape named [{$name}].");
|
||||
}
|
||||
|
||||
return static::render($path, $attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* The names of every Material Symbol the package ships.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function symbolNames(): array
|
||||
{
|
||||
return array_map(
|
||||
fn (string $file): string => basename($file, '.svg'),
|
||||
glob(static::directory('svg/symbols/outlined').'/*.svg') ?: [],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The names of every shape the package ships.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function shapeNames(): array
|
||||
{
|
||||
return array_map(
|
||||
fn (string $file): string => basename($file, '.svg'),
|
||||
glob(static::directory('svg/shapes').'/*.svg') ?: [],
|
||||
);
|
||||
}
|
||||
|
||||
protected static function render(string $path, ComponentAttributeBag $attributes): HtmlString
|
||||
{
|
||||
$svg = static::$contents[$path] ??= trim((string) file_get_contents($path));
|
||||
|
||||
return new HtmlString(preg_replace('/^<svg\b/', '<svg '.$attributes->toHtml(), $svg, 1));
|
||||
}
|
||||
|
||||
protected static function directory(string $path): string
|
||||
{
|
||||
return dirname(__DIR__, 2).'/resources/'.$path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
<?php
|
||||
|
||||
namespace NoNameWeb\LivewireMaterial\Testing;
|
||||
|
||||
use NoNameWeb\LivewireMaterial\Support\SvgFile;
|
||||
use SplFileInfo;
|
||||
use Symfony\Component\Finder\Finder;
|
||||
|
||||
/**
|
||||
* Finds what compiles to nothing and fails silently: maryUI tags, daisyUI classes, colours the
|
||||
* theme does not declare, icon names that are not Material Symbols — plus whatever an
|
||||
* application bans on top (roles its own design rules leave out, patterns it has retired).
|
||||
*
|
||||
* expect(DesignGuard::scan([resource_path('views'), resource_path('js'), app_path()])
|
||||
* ->forbidColours(['tertiary', 'primary-container'])
|
||||
* ->violations())->toBe([]);
|
||||
*
|
||||
* Each violation is "path:line what", with the path relative to the base path. It reads the
|
||||
* source, so a class assembled at runtime (`'text-'.$tone`) is invisible to it — which is one
|
||||
* more reason to write class names out whole, the only form Tailwind compiles anyway.
|
||||
*/
|
||||
class DesignGuard
|
||||
{
|
||||
protected const string UTILITY = '(?:bg|text|border(?:-[trblxyse])?|ring|ring-offset|fill|stroke|from|via|to|outline|divide|decoration|caret|accent|shadow|placeholder)';
|
||||
|
||||
protected const string PALETTE = '(?:red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone)-(?:50|[1-9]00|950)';
|
||||
|
||||
protected const string DAISY_COLOURS = '(?:base-(?:100|200|300|content)|(?:primary|secondary|accent|neutral|info|success|warning|error)-content|accent|neutral)';
|
||||
|
||||
/**
|
||||
* daisyUI's component classes. Tailwind utilities that share a name — `collapse`, `table`,
|
||||
* `select-none`, `tab-4` — and the package's own `link` are not on it.
|
||||
*/
|
||||
protected const string DAISY_CLASSES = '/^(?:btn|badge|card|alert|modal|drawer|dropdown|menu|navbar|footer|hero|stats?|tabs?|tooltip|toast|toggle|checkbox|radio|range|rating|input|select|textarea|file-input|fieldset|label|join|kbd|loading|progress|radial-progress|skeleton|steps?|timeline|swap|indicator|avatar|divider|dock|fab|status|validator|breadcrumbs|carousel|chat|countdown|diff|stack|theme-controller)(?:-[a-z0-9-]+)?$'
|
||||
.'|^(?:table-(?:zebra|xs|sm|md|lg|xl|pin-rows|pin-cols)|collapse-(?:arrow|plus|title|content|open|close)|list-row|link-(?:primary|secondary|accent|neutral|info|success|warning|error))$/';
|
||||
|
||||
protected const string TAILWIND_LOOKALIKES = '/^(?:select-(?:none|text|all|auto)|tab-\d+)$/';
|
||||
|
||||
/** @var list<array{pattern: string, reason: string}> */
|
||||
protected array $forbidden = [];
|
||||
|
||||
/** @var list<string> */
|
||||
protected array $forbiddenColours = [];
|
||||
|
||||
/**
|
||||
* @param list<string> $paths
|
||||
*/
|
||||
final public function __construct(protected array $paths) {}
|
||||
|
||||
/**
|
||||
* @param string|list<string> $paths Files or directories.
|
||||
*/
|
||||
public static function scan(string|array $paths): static
|
||||
{
|
||||
return new static((array) $paths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Roles the application's own rules leave out, e.g. ['tertiary', 'primary-container'].
|
||||
* Their on-roles and containers are forbidden with them.
|
||||
*
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
public function forbidColours(array $roles): static
|
||||
{
|
||||
$this->forbiddenColours = [...$this->forbiddenColours, ...$roles];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Any further pattern, matched line by line.
|
||||
*/
|
||||
public function forbid(string $pattern, string $reason): static
|
||||
{
|
||||
$this->forbidden[] = ['pattern' => $pattern, 'reason' => $reason];
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
public function violations(): array
|
||||
{
|
||||
$violations = [];
|
||||
|
||||
foreach ($this->files() as $file) {
|
||||
$contents = (string) file_get_contents($file->getPathname());
|
||||
$where = $this->relative($file->getPathname());
|
||||
$isBlade = str_ends_with($file->getFilename(), '.blade.php');
|
||||
|
||||
if ($isBlade) {
|
||||
$contents = $this->withoutBladeComments($contents);
|
||||
|
||||
foreach ($this->literalClasses($contents) as [$line, $class]) {
|
||||
if (preg_match(self::DAISY_CLASSES, $class) === 1 && preg_match(self::TAILWIND_LOOKALIKES, $class) !== 1) {
|
||||
$violations[] = "{$where}:{$line} daisyUI class `{$class}`";
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->iconNames($contents) as [$line, $name]) {
|
||||
$violations[] = "{$where}:{$line} unknown Material Symbol `{$name}`";
|
||||
}
|
||||
}
|
||||
|
||||
foreach (explode("\n", $contents) as $index => $text) {
|
||||
$line = $index + 1;
|
||||
|
||||
if ($isBlade && preg_match_all('/<x-mary-[\w.:-]+/', $text, $matches)) {
|
||||
foreach ($matches[0] as $tag) {
|
||||
$violations[] = "{$where}:{$line} maryUI component `{$tag}`";
|
||||
}
|
||||
}
|
||||
|
||||
if (preg_match_all($this->colourPattern(), $text, $matches)) {
|
||||
foreach ($matches[0] as $class) {
|
||||
$violations[] = "{$where}:{$line} colour the theme does not declare `{$class}`";
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->forbidden as $rule) {
|
||||
if (preg_match($rule['pattern'], $text) === 1) {
|
||||
$violations[] = "{$where}:{$line} {$rule['reason']}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $violations;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<SplFileInfo>
|
||||
*/
|
||||
protected function files(): iterable
|
||||
{
|
||||
foreach ($this->paths as $path) {
|
||||
if (is_file($path)) {
|
||||
yield new SplFileInfo($path);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_dir($path)) {
|
||||
yield from Finder::create()->files()->in($path)->name(['*.php', '*.js', '*.ts'])->sortByName();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function colourPattern(): string
|
||||
{
|
||||
$names = [self::PALETTE, self::DAISY_COLOURS];
|
||||
|
||||
foreach ($this->forbiddenColours as $role) {
|
||||
$role = preg_quote($role, '/');
|
||||
$names[] = "(?:on-)?{$role}(?:-container)?";
|
||||
}
|
||||
|
||||
return '/(?<![\w-])'.self::UTILITY.'-(?:'.implode('|', $names).')(?![\w-])/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Blade comments blanked out, their line breaks kept so line numbers still match.
|
||||
*/
|
||||
protected function withoutBladeComments(string $contents): string
|
||||
{
|
||||
return (string) preg_replace_callback(
|
||||
'/\{\{--.*?--\}\}/s',
|
||||
fn (array $match): string => str_repeat("\n", substr_count($match[0], "\n")),
|
||||
$contents,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every class written out literally: in `class="…"`, and in the string keys and values of
|
||||
* `:class`, `@class([...])` and `->class([...])`. Echoes inside a list are skipped.
|
||||
*
|
||||
* @return list<array{0: int, 1: string}>
|
||||
*/
|
||||
protected function literalClasses(string $contents): array
|
||||
{
|
||||
$lists = [];
|
||||
|
||||
preg_match_all('/(?<![\w:.-])class="([^"]*)"/', $contents, $attributes, PREG_OFFSET_CAPTURE);
|
||||
|
||||
foreach ($attributes[1] as [$list, $offset]) {
|
||||
$lists[] = [$list, $offset];
|
||||
}
|
||||
|
||||
preg_match_all('/(?::class="([^"]*)"|@class\(\[(.*?)\]\)|->class\(\[(.*?)\]\))/s', $contents, $bindings, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
|
||||
|
||||
foreach ($bindings as $binding) {
|
||||
[$body, $offset] = array_values(array_filter(array_slice($binding, 1), fn (array $group): bool => $group[1] !== -1))[0] ?? ['', 0];
|
||||
|
||||
preg_match_all("/'([^']*)'/", $body, $strings, PREG_OFFSET_CAPTURE);
|
||||
|
||||
foreach ($strings[1] as [$list, $inner]) {
|
||||
$lists[] = [$list, $offset + $inner];
|
||||
}
|
||||
}
|
||||
|
||||
$found = [];
|
||||
|
||||
foreach ($lists as [$list, $offset]) {
|
||||
$line = substr_count(substr($contents, 0, $offset), "\n") + 1;
|
||||
$list = (string) preg_replace('/\{\{.*?\}\}|\{!!.*?!!\}/', ' ', $list);
|
||||
|
||||
foreach (preg_split('/\s+/', $list, -1, PREG_SPLIT_NO_EMPTY) ?: [] as $token) {
|
||||
if (preg_match('/[$@(){}]/', $token) !== 1) {
|
||||
$found[] = [$line, ltrim((string) preg_replace('/^.*:/', '', $token), '!')];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Literal symbol names that do not exist: `<x-icon name="…">`, and an `icon="…"` or
|
||||
* `icon-right="…"` on any component.
|
||||
*
|
||||
* @return list<array{0: int, 1: string}>
|
||||
*/
|
||||
protected function iconNames(string $contents): array
|
||||
{
|
||||
static $symbols = null;
|
||||
$symbols ??= array_flip(SvgFile::symbolNames());
|
||||
|
||||
$iconTag = 'x-'.config('livewire-material.prefix', '').'icon';
|
||||
|
||||
preg_match_all('/<(x-[\w.:-]+)((?:[^>"]|"[^"]*")*)>/s', $contents, $tags, PREG_OFFSET_CAPTURE | PREG_SET_ORDER);
|
||||
|
||||
$unknown = [];
|
||||
|
||||
foreach ($tags as $tag) {
|
||||
$attributes = $tag[1][0] === $iconTag ? 'name|icon|icon-right' : 'icon|icon-right';
|
||||
|
||||
preg_match_all('/\s(?:'.$attributes.')="([^"]*)"/', $tag[2][0], $values, PREG_OFFSET_CAPTURE);
|
||||
|
||||
foreach ($values[1] as [$name, $offset]) {
|
||||
if ($name === '' || preg_match('/[{$@]/', $name) === 1 || isset($symbols[$name])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$unknown[] = [substr_count(substr($contents, 0, $tag[2][1] + $offset), "\n") + 1, $name];
|
||||
}
|
||||
}
|
||||
|
||||
return $unknown;
|
||||
}
|
||||
|
||||
protected function relative(string $path): string
|
||||
{
|
||||
$base = rtrim(base_path(), '/').'/';
|
||||
|
||||
return str_starts_with($path, $base) ? substr($path, strlen($base)) : $path;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user