Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/php-compatibility.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: PHP Compatibility

on:
pull_request:
push:
branches:
- main
- "*.x"

jobs:
lint:
name: PHP ${{ matrix.php-version }} lint
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php-version:
- "8.4"
- "8.5"
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php-version }}
coverage: none

- name: Lint PHP sources
run: git ls-files '*.php' | xargs -n 1 php -l
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ This module provides Emulsify Twig extensions, theme-defined Twig namespaces, an

## Compatibility

This module now targets Drupal `11.3+`.
This module now targets Drupal `11.3+` and PHP `8.4+`.

The bundled Drush command follows the Drush 13+ autowiring pattern.
The bundled Drush command follows the Drush 13+ autowiring pattern, and the
codebase now uses PHP 8.4-only syntax where it improves readability.

## Usage

Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"homepage": "https://emulsify.info",
"license": "GPL-2.0-only",
"require": {
"php": ">=8.3",
"php": "^8.4",
"drupal/core": "^11.3"
},
"conflict": {
Expand Down
2 changes: 1 addition & 1 deletion src/Drush/Commands/SubThemeCommands.php
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ private function customizeStarterRecipe(string $name, string $machineName, strin
* The finder.
*/
private function getDirectDescendants(string $dir): Finder {
return (new Finder())
return new Finder()
->in($dir)
->depth('== 0');
}
Expand Down
8 changes: 4 additions & 4 deletions src/SubThemeGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public function generate(string $directory, string $machineName, string $name):
* The original machine name.
*/
private function discoverOriginalMachineName(string $directory): string {
$finder = (new Finder())
$finder = new Finder()
->files()
->depth('== 0')
->in($directory)
Expand Down Expand Up @@ -140,7 +140,7 @@ private function modifyFileContent(string $fileName, array $replacementPairs): v
*/
private function getFileNamesToRename(string $directory, string $originalMachineName): array {
$fileNames = [];
$finder = (new Finder())
$finder = new Finder()
->files()
->in($directory)
->name("*{$originalMachineName}*");
Expand All @@ -165,7 +165,7 @@ private function getFileNamesToRename(string $directory, string $originalMachine
*/
private function getDirectoryNamesToRename(string $directory, string $originalMachineName): array {
$directoryNames = [];
$finder = (new Finder())
$finder = new Finder()
->directories()
->in($directory)
->name("*{$originalMachineName}*");
Expand Down Expand Up @@ -211,7 +211,7 @@ private function getFileContentReplacementPairs(string $machineName, string $nam
*/
private function getFilesToMakeReplacements(string $directory): array {
$fileNames = [];
$finder = (new Finder())
$finder = new Finder()
->files()
->in($directory);

Expand Down
91 changes: 78 additions & 13 deletions src/Twig/ThemeNamespaceRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ final class ThemeNamespaceRegistry {
*/
private array $warnedNamespaces = [];

/**
* Protected default namespaces keyed by namespace.
*
* @var array<string, array{name: string, type: string}>|null
*/
private ?array $protectedNamespaces = NULL;

/**
* Creates the registry.
*/
Expand Down Expand Up @@ -316,18 +323,81 @@ private function resolvePath(Extension $theme, string $path): string {
}

/**
* Returns whether the namespace would shadow a default Drupal namespace.
* Returns protected default namespaces keyed by namespace.
*
* @return array<string, array{name: string, type: string}>
* Protected default namespace owner metadata.
*/
private function isProtectedNamespace(string $namespace, string $definingThemeName): bool {
if ($namespace === $definingThemeName) {
private function getProtectedNamespaces(): array {
return $this->protectedNamespaces ??= $this->buildProtectedNamespaces();
}

/**
* Builds protected default namespaces for installed modules and themes.
*
* Extensions may opt into reuse of their default namespace via
* `components.allow_default_namespace_reuse` or by defining a matching
* default namespace under `components.namespaces`.
*
* @return array<string, array{name: string, type: string}>
* Protected default namespace owner metadata.
*/
private function buildProtectedNamespaces(): array {
$protectedNamespaces = [];

foreach ($this->moduleExtensionList->getList() as $extensionName => $extension) {
if (!$extension instanceof Extension || $this->allowsDefaultNamespaceReuse($extension)) {
continue;
}

$protectedNamespaces[$extensionName] = [
'name' => (string) ($extension->info['name'] ?? $extensionName),
'type' => 'module',
];
}

// Themes win ties to match Drupal's existing namespace precedence.
foreach ($this->themeExtensionList->getList() as $extensionName => $extension) {
if (!$extension instanceof Extension || $this->allowsDefaultNamespaceReuse($extension)) {
continue;
}

$protectedNamespaces[$extensionName] = [
'name' => (string) ($extension->info['name'] ?? $extensionName),
'type' => 'theme',
];
}

return $protectedNamespaces;
}

/**
* Returns whether an extension allows reuse of its default namespace.
*/
private function allowsDefaultNamespaceReuse(Extension $extension): bool {
$components = $extension->info['components'] ?? NULL;
if (!is_array($components)) {
return FALSE;
}

if (isset($this->themeExtensionList->getList()[$namespace])) {
// Mirror drupal/components, where presence of the key opts in.
if (array_key_exists('allow_default_namespace_reuse', $components)) {
return TRUE;
}

return isset($this->moduleExtensionList->getList()[$namespace]);
$definitions = $components['namespaces'] ?? NULL;
return is_array($definitions) && !empty($definitions[$extension->getName()]);
}

/**
* Returns whether the namespace would shadow a protected default namespace.
*/
private function isProtectedNamespace(string $namespace, string $definingThemeName): bool {
if ($namespace === $definingThemeName) {
return FALSE;
}

return isset($this->getProtectedNamespaces()[$namespace]);
}

/**
Expand Down Expand Up @@ -375,14 +445,9 @@ private function logMissingPath(string $themeName, string $namespace, string $pa
* The owner type and human-readable name.
*/
private function getProtectedNamespaceOwner(string $namespace): array {
$theme = $this->themeExtensionList->getList()[$namespace] ?? NULL;
if ($theme instanceof Extension) {
return ['theme', (string) ($theme->info['name'] ?? $namespace)];
}

$module = $this->moduleExtensionList->getList()[$namespace] ?? NULL;
if ($module instanceof Extension) {
return ['module', (string) ($module->info['name'] ?? $namespace)];
$owner = $this->getProtectedNamespaces()[$namespace] ?? NULL;
if (is_array($owner)) {
return [$owner['type'], $owner['name']];
}

return ['extension', $namespace];
Expand Down
78 changes: 78 additions & 0 deletions tests/src/Unit/BemTwigExtensionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

declare(strict_types=1);

namespace Drupal\Tests\emulsify_tools\Unit;

use Drupal\Core\Template\Attribute;
use Drupal\emulsify_tools\BemTwigExtension;
use Drupal\emulsify_tools\TwigAttributeManager;
use Drupal\Tests\UnitTestCase;

/**
* Tests the BEM Twig extension.
*
* @coversDefaultClass \Drupal\emulsify_tools\BemTwigExtension
* @group emulsify_tools
*/
final class BemTwigExtensionTest extends UnitTestCase {

/**
* Tests positional bem() arguments.
*
* @covers ::bem
*/
public function testBemBuildsExpectedClassesFromPositionalArguments(): void {
$extension = new BemTwigExtension(new TwigAttributeManager());
$sourceAttributes = new Attribute([
'class' => ['existing'],
'data-role' => 'heading',
]);

$result = $extension->bem(
['attributes' => $sourceAttributes],
'title',
['small', 'red'],
'card',
['js-click', 'bad value'],
);

$resultArray = $result->toArray();

self::assertSame([
'card__title',
'card__title--small',
'card__title--red',
'js-click',
'bad-value',
'existing',
], $resultArray['class']);
self::assertSame('heading', $resultArray['data-role']);
}

/**
* Tests array-style bem() arguments.
*
* @covers ::bem
*/
public function testBemBuildsExpectedClassesFromConfigurationArray(): void {
$extension = new BemTwigExtension(new TwigAttributeManager());

$result = $extension->bem(
[],
[
'base_class' => 'title',
'modifiers' => 'small',
'blockname' => 'card',
'extra' => ['js-click'],
],
);

self::assertSame([
'card__title',
'card__title--small',
'js-click',
], $result->toArray()['class']);
}

}
Loading
Loading