Skip to content

Commit d847030

Browse files
committed
Merge branch 'main' of github.com:vardumper/extended-htmldocument
2 parents d576812 + 8d3e1c0 commit d847030

383 files changed

Lines changed: 6399 additions & 4480 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,16 @@ echo (string) Anchor::create($dom)
2727
// output is:
2828
// <a class="secondary" href="https://google.com" rel="nofollow" title="Google it"></a>
2929
```
30-
## Twig Templates
31-
There's now a Twig Template for every HTML element included. These allow for better consistency in your design system(s), support all possible HTML attributes and have basic validations for enum attributes. They are compatible with different ways of using Twig (`include`, `embed` and `use`).
30+
## Generated Templates
31+
Templates are generated from the HTML5 schema for every HTML element. These allow for better consistency in your design system(s), support all possible HTML attributes and have basic validations for enum attributes.
32+
Files are grouped into inline, block and void elements. For elements with a specific content model, a composed template is generated as well. (eg `<table><tr><td>Cell</td><tr></table>`)
33+
34+
### Twig
35+
They are compatible with different ways of using Twig (`include`, `embed` and `use`).
3236
```php
3337
$twig->path('vendor/vardumper/extended-htmldocument/templates', 'html'); /** register template path with or without namespace */
3438
```
39+
Example
3540
```twig
3641
{% include '@html/inline/a.twig' with {
3742
href: 'https://example.com',
@@ -42,6 +47,29 @@ $twig->path('vendor/vardumper/extended-htmldocument/templates', 'html'); /** reg
4247
} %}
4348
```
4449

50+
### React & NextJS
51+
Type-safe, auto-generated React components for all HTML5 elements with full ARIA support. Work in both Next.js (Server Components, Client Components) and regular React applications (CRA, Vite, etc.). They use pure functional React patterns without hooks or browser-specific APIs.
52+
Example:
53+
```tsx
54+
import { Button, Div, H1 } from './index';
55+
56+
export default function Page() {
57+
return (
58+
<Div className="container">
59+
<H1>Welcome</H1>
60+
<A
61+
href="/contact"
62+
>
63+
Contact us
64+
</A>
65+
</Div>
66+
);
67+
}
68+
```
69+
70+
### Storybook
71+
72+
4573
## Documentation
4674
See the [Documentation](https://vardumper.github.io/extended-htmldocument/) for more.
4775

bin/ext-html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,8 @@ $app->command('generate:all [generator] [dest] [--overwrite-existing=] [--specif
3939
(new BatchGeneratorCommand())($generator, $dest, $input, $output, (bool) $overwriteExisting, $specification);
4040
})->descriptions('Generates templates. Uses a given generator. Requires a Generator Name (eg. html, twig) and a destination path for the generated templates. You can also provide a list of generators separated by comma.', ['generator' => 'name of the generator(s)', 'dest' => 'destination path']);
4141

42-
$app->command('generate:composed [generator] [dest] [--overwrite-existing=]', function ($generator, $dest, InputInterface $input, OutputInterface $output, $overwriteExisting = false) {
43-
return (new GenerateComposedCommand())($generator, $dest, $input, $output, (bool) $overwriteExisting);
42+
$app->command('generate:composed [generator] [dest] [--overwrite-existing=] [--specification=]', function ($generator, $dest, InputInterface $input, OutputInterface $output, $overwriteExisting = false, $specification = null) {
43+
return (new GenerateComposedCommand())($generator, $dest, $input, $output, (bool) $overwriteExisting, $specification);
4444
})->descriptions('Generates composed component templates showing valid parent-child relationships based on content model metadata', ['generator' => 'name of the generator (nextjs, storybook, twig)', 'dest' => 'destination path']);
4545

4646
$app->command('merge:specs [import] [dest]', function ($import, $dest, InputInterface $input, OutputInterface $output) {

clover.xml

Lines changed: 2266 additions & 354 deletions
Large diffs are not rendered by default.

coverage.svg

Lines changed: 3 additions & 3 deletions
Loading

docs/code-generation.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ php vendor/bin/ext-html make:classes a # only generates the Html\Element\Anchor
2525
php vendor/bin/ext-html make:classes # generates all classes, no enums
2626
php vendor/bin/ext-html make:all # generates both enums and classes
2727
php vendor/bin/ext-html generate:all twig,storybook templates # generates all twig templates and storybook stories, and saved them into templates folder
28+
php vendor/bin/ext-html generate:composed twig,storybook templates # generates composed templates based on content model (eg <table> with <tr>, <td>, etc), and saves them into templates folder
2829
php vendor/bin/ext-html merge:specs custom/schema.yaml destination/path.yaml # merges a custom regex or element-based specification into the html5.yaml file
2930
php vendor/bin/ext-html watch source/path dest/path # component builder. watches for component yaml changes, and generates templates on the fly
3031
```

src/Command/BatchGeneratorCommand.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ class BatchGeneratorCommand extends Command
2727
use ClassResolverTrait;
2828
use GeneratorResolverTrait;
2929

30-
private const HTML_DEFINITION_PATH = __DIR__ . '/../Resources/specifications/html5.yaml';
30+
private const HTML_DEFINITION_PATH = __DIR__ . '/../Resources/specifications/html5-with-aria.yaml';
3131

3232
private ?array $data = null;
3333
private SymfonyStyle $io;

src/Command/GenerateComposedCommand.php

Lines changed: 100 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,15 @@
1212
use Html\TemplateGenerator\StorybookJSGenerator;
1313
use Html\TemplateGenerator\TwigGenerator;
1414
use Html\Trait\ClassResolverTrait;
15+
use Html\Trait\GeneratorResolverTrait;
1516
use ReflectionClass;
1617
use Symfony\Component\Console\Command\Command;
1718
use Symfony\Component\Console\Input\InputArgument;
1819
use Symfony\Component\Console\Input\InputInterface;
1920
use Symfony\Component\Console\Input\InputOption;
2021
use Symfony\Component\Console\Output\OutputInterface;
2122
use Symfony\Component\Console\Style\SymfonyStyle;
23+
use Symfony\Component\Yaml\Yaml;
2224

2325
/**
2426
* Generate composed component templates showing valid parent-child relationships
@@ -30,13 +32,19 @@
3032
class GenerateComposedCommand extends Command
3133
{
3234
use ClassResolverTrait;
35+
use GeneratorResolverTrait;
36+
37+
private const HTML_DEFINITION_PATH = __DIR__ . '/../Resources/specifications/html5-with-aria.yaml';
38+
39+
private ?array $data = null;
3340

3441
public function __invoke(
3542
string $generator,
3643
string $dest,
3744
InputInterface $input,
3845
OutputInterface $output,
39-
bool $overwriteExisting = false
46+
bool $overwriteExisting = false,
47+
?string $specification = null
4048
): int {
4149
$io = new SymfonyStyle($input, $output);
4250

@@ -45,74 +53,88 @@ public function __invoke(
4553
return Command::FAILURE;
4654
}
4755

48-
// Instantiate the appropriate generator
49-
$generatorInstance = match ($generator) {
50-
'nextjs' => new NextJSGenerator(),
51-
'storybook' => new StorybookJSGenerator(),
52-
'twig' => new TwigGenerator(),
53-
default => null,
54-
};
55-
56-
if ($generatorInstance === null) {
57-
$io->error("Unsupported generator '{$generator}'. Supported generators: nextjs, storybook, twig");
58-
return Command::FAILURE;
56+
if (! \str_contains($generator, ',')) {
57+
$generators = [$generator];
58+
} else {
59+
$generators = \explode(',', $generator);
5960
}
6061

61-
// Check if generator supports composed element rendering
62-
if (!method_exists($generatorInstance, 'renderComposedElement')) {
63-
$io->error("Generator '{$generator}' does not support composed element rendering.");
62+
$specificationPath = $input->getOption('specification');
63+
if (! $this->loadHtmlDefinitions($specificationPath)) {
6464
return Command::FAILURE;
6565
}
66-
67-
$baseClasses = [InlineElement::class, BlockElement::class, VoidElement::class];
68-
$elements = [];
69-
foreach ($baseClasses as $baseClass) {
70-
$elements = array_merge($elements, $this->getClassesExtendingClass($baseClass));
71-
}
72-
73-
$dom = HTMLDocumentDelegator::createEmpty();
74-
$generatedCount = 0;
75-
$skippedCount = 0;
76-
77-
// Create content directory
78-
$contentDir = rtrim($dest, \DIRECTORY_SEPARATOR) . \DIRECTORY_SEPARATOR . $generator . \DIRECTORY_SEPARATOR . 'composed';
79-
if (! is_dir($contentDir)) {
80-
mkdir($contentDir, 0755, true);
81-
}
82-
83-
foreach ($elements as $className) {
84-
/** @var class-string<\Html\Interface\HTMLElementInterface> $className */
85-
$elementInstance = $className::create($dom);
86-
87-
// Check if element has SPECIFIC child requirements (not empty $parentOf)
88-
// Skip elements that can contain any content
89-
$ref = new ReflectionClass($className);
90-
$parentOf = $ref->getStaticPropertyValue('parentOf', []);
91-
92-
if (empty($parentOf)) {
93-
$skippedCount++;
94-
continue;
66+
// // Instantiate the appropriate generator
67+
// $generatorInstance = match ($generator) {
68+
// 'nextjs' => new NextJSGenerator(),
69+
// 'storybook' => new StorybookJSGenerator(),
70+
// 'twig' => new TwigGenerator(),
71+
// default => null,
72+
// };
73+
74+
// if ($generatorInstance === null) {
75+
// $io->error("Unsupported generator '{$generator}'. Supported generators: nextjs, storybook, twig");
76+
// return Command::FAILURE;
77+
// }
78+
79+
$templateGenerators = $this->getGenerators($generators);
80+
foreach ($templateGenerators as $generator => $generatorInstance) {
81+
82+
// Check if generator supports composed element rendering
83+
if (!method_exists($generatorInstance, 'renderComposedElement')) {
84+
$io->error("Generator '{$generator}' does not support composed element rendering.");
85+
return Command::FAILURE;
9586
}
9687

97-
$output = $generatorInstance->renderComposedElement($elementInstance);
98-
if ($output === null) {
99-
$skippedCount++;
100-
continue;
88+
$baseClasses = [InlineElement::class, BlockElement::class, VoidElement::class];
89+
$elements = [];
90+
foreach ($baseClasses as $baseClass) {
91+
$elements = array_merge($elements, $this->getClassesExtendingClass($baseClass));
10192
}
10293

103-
$elementShortName = $ref->getShortName();
104-
$fileName = $elementInstance::QUALIFIED_NAME . '.composed.' . $generatorInstance->getExtension();
105-
$outFile = $contentDir . \DIRECTORY_SEPARATOR . $fileName;
94+
$dom = HTMLDocumentDelegator::createEmpty();
95+
$generatedCount = 0;
96+
$skippedCount = 0;
10697

107-
if (file_exists($outFile) && ! $overwriteExisting) {
108-
$io->warning("File '{$outFile}' already exists. Skipping.");
109-
$skippedCount++;
110-
continue;
98+
// Create content directory
99+
$contentDir = rtrim($dest, \DIRECTORY_SEPARATOR) . \DIRECTORY_SEPARATOR . $generator . \DIRECTORY_SEPARATOR . 'composed';
100+
if (! is_dir($contentDir)) {
101+
mkdir($contentDir, 0755, true);
111102
}
112103

113-
file_put_contents($outFile, $output);
114-
$io->success("Generated: {$outFile}");
115-
$generatedCount++;
104+
foreach ($elements as $className) {
105+
/** @var class-string<\Html\Interface\HTMLElementInterface> $className */
106+
$elementInstance = $className::create($dom);
107+
108+
// Check if element has SPECIFIC child requirements (not empty $parentOf)
109+
// Skip elements that can contain any content
110+
$ref = new ReflectionClass($className);
111+
$parentOf = $ref->getStaticPropertyValue('parentOf', []);
112+
113+
if (empty($parentOf)) {
114+
$skippedCount++;
115+
continue;
116+
}
117+
118+
$output = $generatorInstance->renderComposedElement($elementInstance);
119+
if ($output === null) {
120+
$skippedCount++;
121+
continue;
122+
}
123+
124+
$elementShortName = $ref->getShortName();
125+
$fileName = $elementInstance::QUALIFIED_NAME . '.composed.' . $generatorInstance->getExtension();
126+
$outFile = $contentDir . \DIRECTORY_SEPARATOR . $fileName;
127+
128+
if (file_exists($outFile) && ! $overwriteExisting) {
129+
$io->warning("File '{$outFile}' already exists. Skipping.");
130+
$skippedCount++;
131+
continue;
132+
}
133+
134+
file_put_contents($outFile, $output);
135+
$io->success("Generated: {$outFile}");
136+
$generatedCount++;
137+
}
116138
}
117139

118140
$io->success("Generated {$generatedCount} composed templates (specific composition patterns only). Skipped {$skippedCount} elements.");
@@ -142,4 +164,24 @@ public function configure(): void
142164
false
143165
);
144166
}
167+
168+
private function loadHtmlDefinitions(?string $specificationPath): bool
169+
{
170+
if ($specificationPath !== null) {
171+
if (! is_file($specificationPath)) {
172+
$this->io->error('Specification file not found at ' . $specificationPath);
173+
return false;
174+
}
175+
$this->data = Yaml::parseFile($specificationPath);
176+
return true;
177+
}
178+
179+
if (! is_file(self::HTML_DEFINITION_PATH)) {
180+
$this->io->error('HTML definition file not found.');
181+
return false;
182+
}
183+
184+
$this->data = Yaml::parseFile(self::HTML_DEFINITION_PATH);
185+
return true;
186+
}
145187
}

src/Delegator/HTMLDocumentDelegator.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,11 @@ public function createElement(string $qualifiedName, ?string $nodeValue = null):
106106
return new HTMLElementDelegator($htmlElement);
107107
}
108108

109+
public function appendChild($child): void
110+
{
111+
$this->delegated->documentElement->appendChild($child->delegated);
112+
}
113+
109114
public function createTextNode(string $nodeValue): TextDelegator
110115
{
111116
$textNode = $this->delegated->createTextNode($nodeValue);

0 commit comments

Comments
 (0)