Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEATURE] Command to generate XSD Schemas for ViewHelpers #876

Merged
merged 9 commits into from
Jun 13, 2024
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
8 changes: 4 additions & 4 deletions bin/fluid
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,16 @@ declare(strict_types=1);
use TYPO3Fluid\Fluid\Tools\ConsoleRunner;

if (file_exists(__DIR__ . '/../autoload.php')) {
require_once __DIR__ . '/../autoload.php';
$autoloader = require_once __DIR__ . '/../autoload.php';
} elseif (file_exists(__DIR__ . '/../vendor/autoload.php')) {
require_once __DIR__ . '/../vendor/autoload.php';
$autoloader = require_once __DIR__ . '/../vendor/autoload.php';
} elseif (file_exists(__DIR__ . '/../../../autoload.php')) {
require_once __DIR__ . '/../../../autoload.php';
$autoloader = require_once __DIR__ . '/../../../autoload.php';
}

$runner = new ConsoleRunner();
try {
echo $runner->handleCommand($argv);
echo $runner->handleCommand($argv, $autoloader);
} catch (InvalidArgumentException $error) {
echo PHP_EOL . 'ERROR! ' . $error->getMessage() . PHP_EOL . PHP_EOL;
}
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,15 @@
},
"require-dev": {
"ext-json": "*",
"ext-simplexml": "*",
"friendsofphp/php-cs-fixer": "^3.54.0",
"phpstan/phpstan": "^1.10.14",
"phpstan/phpstan-phpunit": "^1.3.11",
"phpunit/phpunit": "^10.2.6"
},
"suggest": {
"ext-json": "PHP JSON is needed when using JSONVariableProvider: A relatively rare use case"
"ext-json": "PHP JSON is needed when using JSONVariableProvider: A relatively rare use case",
"ext-simplexml": "SimpleXML is required for the XSD schema generator"
},
"autoload": {
"psr-4": {
Expand Down
2 changes: 2 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
<directory suffix=".phpt">tests/Unit</directory>
</testsuite>
<testsuite name="Functional">
<directory>tests/Functional</directory>
<directory suffix=".phpt">tests/Functional</directory>
</testsuite>
</testsuites>
<php>
Expand Down
151 changes: 151 additions & 0 deletions src/Schema/SchemaGenerator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
<?php

declare(strict_types=1);

/*
* This file belongs to the package "TYPO3 Fluid".
* See LICENSE.txt that was shipped with this package.
*/

namespace TYPO3Fluid\Fluid\Schema;

/**
* @internal
*/
final class SchemaGenerator
{
/**
* @param ViewHelperMetadata[] $viewHelpers
*/
public function generate(string $xmlNamespace, array $viewHelpers): \SimpleXMLElement
{
$file = $this->createXmlRootElement($xmlNamespace);
foreach ($viewHelpers as $metadata) {
$xsdElement = $file->addChild('xsd:element');
s2b marked this conversation as resolved.
Show resolved Hide resolved

$xsdElement->addAttribute('name', $metadata->tagName);

$documentation = $metadata->documentation;
s2b marked this conversation as resolved.
Show resolved Hide resolved
// Add deprecation information to ViewHelper documentation
if (isset($metadata->docTags['@deprecated'])) {
$documentation .= "\n@deprecated " . $metadata->docTags['@deprecated'];
}
$documentation = trim($documentation);

// Add documentation to xml
if ($documentation !== '') {
$xsdAnnotation = $xsdElement->addChild('xsd:annotation');
$xsdDocumentation = $xsdAnnotation->addChild('xsd:documentation');
$this->appendWithCdata($xsdDocumentation, $documentation);
}

$xsdComplexType = $xsdElement->addChild('xsd:complexType');

// Allow text as well as subelements
$xsdComplexType->addAttribute('mixed', 'true');

// Allow a sequence of arbitrary subelements of any type
$xsdSequence = $xsdComplexType->addChild('xsd:sequence');
$xsdAny = $xsdSequence->addChild('xsd:any');
$xsdAny->addAttribute('minOccurs', '0');

// Add argument definitions to xml
foreach ($metadata->argumentDefinitions as $argumentDefinition) {
$default = $argumentDefinition->getDefaultValue();
$type = $argumentDefinition->getType();

$xsdAttribute = $xsdComplexType->addChild('xsd:attribute');
$xsdAttribute->addAttribute('type', $this->convertPhpTypeToXsdType($type));
$xsdAttribute->addAttribute('name', $argumentDefinition->getName());
if ($argumentDefinition->isRequired()) {
$xsdAttribute->addAttribute('use', 'required');
} else {
$xsdAttribute->addAttribute('default', $this->createFluidRepresentation($default));
}

// Add PHP type to documentation text
// TODO check if there is a better field for this
$documentation = $argumentDefinition->getDescription();
$documentation .= "\n@type $type";
$documentation = trim($documentation);

// Add documentation for argument to xml
$xsdAnnotation = $xsdAttribute->addChild('xsd:annotation');
$xsdDocumentation = $xsdAnnotation->addChild('xsd:documentation');
$this->appendWithCdata($xsdDocumentation, $documentation);
}

if ($metadata->allowsArbitraryArguments) {
$xsdComplexType->addChild('xsd:anyAttribute');
}
}

return $file;
}

private function appendWithCdata(\SimpleXMLElement $parent, string $text): \SimpleXMLElement
{
$parentDomNode = dom_import_simplexml($parent);
$parentDomNode->appendChild($parentDomNode->ownerDocument->createCDATASection($text));
return simplexml_import_dom($parentDomNode);
}

private function createXmlRootElement(string $targetNamespace): \SimpleXMLElement
{
return new \SimpleXMLElement(
'<?xml version="1.0" encoding="UTF-8"?><xsd:schema xmlns:xsd="http://www.w3.org/2001/XMLSchema" targetNamespace="' . $targetNamespace . '"></xsd:schema>',
);
}

private function convertPhpTypeToXsdType(string $type): string
{
switch ($type) {
case 'integer':
return 'xsd:integer';
case 'float':
return 'xsd:float';
case 'double':
return 'xsd:double';
case 'boolean':
case 'bool':
return 'xsd:boolean';
case 'string':
return 'xsd:string';
case 'array':
case 'mixed':
default:
return 'xsd:anySimpleType';
}
}

private function createFluidRepresentation(mixed $input, bool $isRoot = true): string
{
if (is_array($input)) {
$fluidArray = [];
foreach ($input as $key => $value) {
$fluidArray[] = $this->createFluidRepresentation($key, false) . ': ' . $this->createFluidRepresentation($value, false);
}
return '{' . implode(', ', $fluidArray) . '}';
}

if (is_string($input) && !$isRoot) {
return "'" . addcslashes($input, "'") . "'";
}

if (is_bool($input)) {
return ($input) ? 'true' : 'false';
}

if (is_null($input)) {
return 'NULL';
}

s2b marked this conversation as resolved.
Show resolved Hide resolved
// Generally, this wouldn't be correct, since it's not the correct representation,
// but in the context of XSD files we merely need to provide *any* string representation
if (is_object($input)) {
return '';
}

return (string)$input;
}
}
84 changes: 84 additions & 0 deletions src/Schema/ViewHelperFinder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

/*
* This file belongs to the package "TYPO3 Fluid".
* See LICENSE.txt that was shipped with this package.
*/

namespace TYPO3Fluid\Fluid\Schema;

use Composer\Autoload\ClassLoader;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;

/**
* @internal
*/
final class ViewHelperFinder
{
private const FILE_SUFFIX = 'ViewHelper.php';

private ViewHelperMetadataFactory $viewHelperMetadataFactory;

public function __construct(?ViewHelperMetadataFactory $viewHelperMetadataFactory = null)
{
$this->viewHelperMetadataFactory = $viewHelperMetadataFactory ?? new ViewHelperMetadataFactory();
}

/**
* @return ViewHelperMetadata[]
*/
public function findViewHelpersInComposerProject(ClassLoader $autoloader): array
{
$viewHelpers = [];
foreach ($autoloader->getPrefixesPsr4() as $namespace => $paths) {
foreach ($paths as $path) {
$viewHelpers = array_merge($viewHelpers, $this->findViewHelperFilesInPath($namespace, $path));
}
}
return $viewHelpers;
}

/**
* @return ViewHelperMetadata[]
*/
private function findViewHelperFilesInPath(string $namespace, string $path): array
{
$viewHelpers = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS | FilesystemIterator::CURRENT_AS_PATHNAME),
);
foreach ($iterator as $filePath) {
// Naming convention: ViewHelper files need to have "ViewHelper" suffix
if (!str_ends_with((string)$filePath, self::FILE_SUFFIX)) {
continue;
}

// Guesstimate PHP namespace based on file path
$pathInPackage = substr($filePath, strlen($path) + 1, -4);
$className = $namespace . str_replace('/', '\\', $pathInPackage);
$phpNamespace = substr($className, 0, strrpos($className, '\\'));

// Make sure that we generated the correct namespace for the file;
// This prevents duplicate class declarations if files are part of
// multiple/overlapping namespaces
// The alternative would be to use PHP-Parser for the whole finding process,
// but then we would have to check for correct interface implementation of
// ViewHelper classes manually
$phpCode = file_get_contents($filePath);
if (!preg_match('#namespace\s+' . preg_quote($phpNamespace, '#') . '\s*;#', $phpCode)) {
continue;
}

try {
$viewHelpers[] = $this->viewHelperMetadataFactory->createFromViewhelperClass($className);
} catch (\InvalidArgumentException) {
// Just ignore this class
}
}
return $viewHelpers;
}
}
34 changes: 34 additions & 0 deletions src/Schema/ViewHelperMetadata.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

/*
* This file belongs to the package "TYPO3 Fluid".
* See LICENSE.txt that was shipped with this package.
*/

namespace TYPO3Fluid\Fluid\Schema;

use TYPO3Fluid\Fluid\Core\ViewHelper\ArgumentDefinition;

/**
* @internal
*/
final class ViewHelperMetadata
{
/**
* @param array<string, string> $docTags
* @param array<string, ArgumentDefinition> $argumentDefinitions
*/
public function __construct(
public readonly string $className,
public readonly string $namespace,
public readonly string $name,
public readonly string $tagName,
public readonly string $documentation,
public readonly string $xmlNamespace,
public readonly array $docTags,
public readonly array $argumentDefinitions,
public readonly bool $allowsArbitraryArguments,
) {}
}
Loading
Loading