Skip to content

Commit 888f5b1

Browse files
innocenzibrendt
andauthored
feat(vite): add <x-vite-tags /> component (#945)
Co-authored-by: brendt <[email protected]>
1 parent 381c58d commit 888f5b1

19 files changed

+525
-111
lines changed

src/Tempest/Core/src/functions.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,15 @@ function path(Stringable|string ...$parts): PathHelper
2121
}
2222

2323
/**
24-
* Creates a path scoped within the root of the project
24+
* Creates an absolute path scoped within the root of the project.
2525
*/
2626
function root_path(string ...$parts): string
2727
{
2828
return path(realpath(get(Kernel::class)->root), ...$parts)->toString();
2929
}
3030

3131
/**
32-
* Creates a path scoped within the src folder of the project
32+
* Creates a relative path scoped within the main directory of the project.
3333
*/
3434
function src_path(string ...$parts): string
3535
{
@@ -39,7 +39,7 @@ function src_path(string ...$parts): string
3939
}
4040

4141
/**
42-
* Creates a namespace scoped within the main namespace of the project
42+
* Creates a namespace scoped within the main namespace of the project.
4343
*/
4444
function src_namespace(?string $append = null): string
4545
{
@@ -76,6 +76,6 @@ function env(string $key, mixed $default = null): mixed
7676
*/
7777
function defer(Closure $closure): void
7878
{
79-
get(DeferredTasks::class)->add($closure);
79+
get(DeferredTasks::class)->add($closure);
8080
}
8181
}

src/Tempest/Framework/Testing/ViteTester.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,14 +128,15 @@ public function call(callable $callback, array $files, bool $manifest = false, ?
128128

129129
$filesystem = new LocalFilesystem();
130130
$filesystem->deleteDirectory($temporaryRootDirectory, recursive: true);
131+
$filesystem->ensureDirectoryExists($temporaryRootDirectory);
131132

132133
$paths = [];
133134

134135
foreach ($files as $path => $content) {
135136
$path = "{$temporaryRootDirectory}/{$path}";
136137
$paths[] = $path;
137138
$filesystem->ensureDirectoryExists(dirname($path));
138-
$filesystem->write($path, json_encode($content, flags: JSON_UNESCAPED_SLASHES));
139+
$filesystem->write($path, is_array($content) ? json_encode($content, flags: JSON_UNESCAPED_SLASHES) : $content);
139140
}
140141

141142
$this->container->get(Kernel::class)->root = $temporaryRootDirectory;

src/Tempest/View/src/Renderers/TempestViewCompiler.php

Lines changed: 63 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@
66

77
use Dom\HTMLDocument;
88
use Dom\NodeList;
9-
use Exception;
9+
use Stringable;
1010
use Tempest\Core\Kernel;
11+
use Tempest\Support\StringHelper;
1112
use Tempest\Discovery\DiscoveryLocation;
1213
use Tempest\Mapper\Exceptions\ViewNotFound;
1314
use Tempest\View\Attributes\AttributeFactory;
@@ -98,59 +99,51 @@ private function retrieveTemplate(string|View $view): string
9899
return file_get_contents($searchPath);
99100
}
100101

101-
private function parseDom(string $template): HTMLDocument|NodeList
102+
private function parseDom(string $template): NodeList
102103
{
103-
$template = str($template)
104104

105-
// Escape PHP tags
106-
->replace(
107-
search: array_keys(self::TOKEN_MAPPING),
108-
replace: array_values(self::TOKEN_MAPPING),
109-
)
110-
111-
// Convert self-closing tags
112-
->replaceRegex(
113-
regex: '/<x-(?<element>.*?)\/>/',
114-
replace: function (array $match) {
115-
$closingTag = str($match['element'])->before(' ')->toString();
105+
$parserFlags = LIBXML_HTML_NOIMPLIED | LIBXML_NOERROR | HTML_NO_DEFAULT_NS;
116106

117-
return sprintf(
118-
'<x-%s></x-%s>',
119-
$match['element'],
120-
$closingTag,
121-
);
122-
},
123-
);
107+
$template = str($template);
124108

125-
$isFullHtmlDocument = $template
126-
->replaceRegex('/('.self::TOKEN_PHP_OPEN.'|'.self::TOKEN_PHP_SHORT_ECHO.')(.|\n)*?'.self::TOKEN_PHP_CLOSE.'/', '')
127-
->trim()
128-
->startsWith(['<html', '<!DOCTYPE', '<!doctype']);
109+
// Find head nodes, these are parsed separately so that we skip HTML's head-parsing rules
110+
$headNodes = [];
129111

130-
$parserFlags = LIBXML_HTML_NOIMPLIED | LIBXML_NOERROR | HTML_NO_DEFAULT_NS;
112+
$headTemplate = $template->match('/<head>((.|\n)*?)<\/head>/')[1] ?? null;
131113

132-
if ($isFullHtmlDocument) {
133-
// If we're rendering a full HTML document, we'll parse it as is
134-
return HTMLDocument::createFromString($template->toString(), $parserFlags);
114+
if ($headTemplate) {
115+
$headNodes = HTMLDocument::createFromString(
116+
source: $this->cleanupTemplate($headTemplate)->toString(),
117+
options: $parserFlags,
118+
)->childNodes;
135119
}
136120

137-
// If we're rendering an HTML snippet, we'll wrap it in a div, and return the resulting nodelist
138-
$dom = HTMLDocument::createFromString("<div id='tempest_render'>{$template}</div>", $parserFlags);
121+
$mainTemplate = $this->cleanupTemplate($template)
122+
// Cleanup head, we'll insert it after having parsed the DOM
123+
->replaceRegex('/<head>((.|\n)*?)<\/head>/', '<head></head>');
139124

140-
return $dom->getElementById('tempest_render')->childNodes;
125+
$dom = HTMLDocument::createFromString(
126+
source: $mainTemplate->toString(),
127+
options: $parserFlags,
128+
);
129+
130+
// If we have head nodes and a head tag, we inject them back
131+
if ($headElement = $dom->getElementsByTagName('head')->item(0)) {
132+
foreach ($headNodes as $headNode) {
133+
$headElement->appendChild($dom->importNode($headNode, deep: true));
134+
}
135+
}
136+
137+
return $dom->childNodes;
141138
}
142139

143140
/**
144141
* @return Element[]
145142
*/
146-
private function mapToElements(HTMLDocument|NodeList $nodes): array
143+
private function mapToElements(NodeList $nodes): array
147144
{
148145
$elements = [];
149146

150-
if ($nodes instanceof HTMLDocument) {
151-
$nodes = $nodes->childNodes;
152-
}
153-
154147
foreach ($nodes as $node) {
155148
$element = $this->elementFactory->make($node);
156149

@@ -206,18 +199,44 @@ private function applyAttributes(array $elements): array
206199
/** @param \Tempest\View\Element[] $elements */
207200
private function compileElements(array $elements): string
208201
{
209-
$compiled = [];
202+
$compiled = arr();
210203

211204
foreach ($elements as $element) {
212205
$compiled[] = $element->compile();
213206
}
214207

215-
$compiled = implode(PHP_EOL, $compiled);
208+
return $compiled
209+
->implode(PHP_EOL)
216210

217-
return str_replace(
218-
search: array_values(self::TOKEN_MAPPING),
219-
replace: array_keys(self::TOKEN_MAPPING),
220-
subject: $compiled,
221-
);
211+
// Unescape PHP tags
212+
->replace(
213+
array_values(self::TOKEN_MAPPING),
214+
array_keys(self::TOKEN_MAPPING),
215+
)
216+
->toString();
217+
}
218+
219+
private function cleanupTemplate(string|Stringable $template): StringHelper
220+
{
221+
return str($template)
222+
// Escape PHP tags
223+
->replace(
224+
search: array_keys(self::TOKEN_MAPPING),
225+
replace: array_values(self::TOKEN_MAPPING),
226+
)
227+
228+
// Convert self-closing tags
229+
->replaceRegex(
230+
regex: '/<x-(?<element>.*?)\/>/',
231+
replace: function (array $match) {
232+
$closingTag = str($match['element'])->before(' ')->toString();
233+
234+
return sprintf(
235+
'<x-%s></x-%s>',
236+
$match['element'],
237+
$closingTag,
238+
);
239+
},
240+
);
222241
}
223242
}

src/Tempest/Vite/src/BuildConfig.php

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,6 @@
44

55
namespace Tempest\Vite;
66

7-
use Tempest\Support\ArrayHelper;
8-
97
final class BuildConfig
108
{
119
/**
@@ -23,9 +21,4 @@ public function __construct(
2321
public array $entrypoints = [],
2422
) {
2523
}
26-
27-
public function getEntryPoints(): ArrayHelper
28-
{
29-
return new ArrayHelper($this->entrypoints);
30-
}
3124
}

src/Tempest/Vite/src/Exceptions/EntrypointNotFoundException.php

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,6 @@
66

77
use Exception;
88

9-
final class EntrypointNotFoundException extends Exception implements ViteException
9+
abstract class EntrypointNotFoundException extends Exception implements ViteException
1010
{
11-
public function __construct(string $entrypoint)
12-
{
13-
parent::__construct("Entrypoint [{$entrypoint}] not found in manifest.");
14-
}
1511
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Vite\Exceptions;
6+
7+
final class FileSystemEntrypointNotFoundException extends EntrypointNotFoundException
8+
{
9+
public function __construct(string $entrypoint)
10+
{
11+
parent::__construct("File `{$entrypoint}` does not exist and cannot be used as an entrypoint.");
12+
}
13+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tempest\Vite\Exceptions;
6+
7+
final class ManifestEntrypointNotFoundException extends EntrypointNotFoundException
8+
{
9+
public function __construct(string $entrypoint)
10+
{
11+
parent::__construct("Entrypoint [{$entrypoint}] not found in manifest.");
12+
}
13+
}
Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1 @@
1-
import './main.css'
2-
31
console.log('🌊')

src/Tempest/Vite/src/Installer/ViteInstaller.php

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,10 @@ public function install(): void
5959

6060
// Publishes the Vite config
6161
$viteConfig = $this->publish(__DIR__ . "/{$templateDirectory}/vite.config.ts", destination: root_path('vite.config.ts'));
62-
$main = $this->publish(__DIR__ . "/{$templateDirectory}/main.ts", destination: src_path('main.ts'));
63-
64-
// Publishes Tailwind's `main.css` file if requested
65-
if ($shouldInstallTailwind) {
66-
$this->publish(__DIR__ . "/{$templateDirectory}/main.css", destination: src_path('main.css'));
67-
}
62+
$mainTs = $this->publish(__DIR__ . "/{$templateDirectory}/main.ts", destination: src_path('main.ts'));
63+
$mainCss = $shouldInstallTailwind
64+
? $this->publish(__DIR__ . "/{$templateDirectory}/main.css", destination: src_path('main.css'))
65+
: null;
6866

6967
// Install package.json scripts
7068
$this->updateJson(root_path('package.json'), function (array $json) {
@@ -103,12 +101,12 @@ public function install(): void
103101
? '<strong>Vite and Tailwind CSS are now installed in your project</strong>!'
104102
: '<strong>Vite is now installed in your project</strong>!',
105103
PHP_EOL,
106-
$main
107-
? "Add <code>\\Tempest\\vite_tags('{$main}')</code> to your template"
108-
: 'Create a file and include it in your template with <code>\\Tempest\\vite_tags()</code>',
109104
$viteConfig
110105
? sprintf('Configure <href="file://%s">vite.config.ts</href> as you see fit', $viteConfig)
111106
: null,
107+
$mainTs
108+
? sprintf("Add <code><x-vite-tags :entrypoints='%s' /></code> to your template", json_encode(array_filter([$mainCss, $mainTs]), JSON_UNESCAPED_SLASHES))
109+
: 'Create a file and include it in your template with <code><x-vite-tags entrypoint="./path/to/file.ts" /></code>',
112110
"Run <code>{$packageManager->getBinaryName()} dev</code> to start the <strong>development server</strong>",
113111
PHP_EOL,
114112
'<style="fg-green">→</style> Read the <href="https://tempestphp.com/docs/vite">documentation</href>',

src/Tempest/Vite/src/TagsResolver/DevelopmentTagsResolver.php

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Tempest\Vite\TagsResolver;
66

7+
use Tempest\Vite\Exceptions\FileSystemEntrypointNotFoundException;
78
use Tempest\Vite\TagCompiler\TagCompiler;
89
use Tempest\Vite\ViteBridgeFile;
910
use function Tempest\root_path;
@@ -23,7 +24,13 @@ public function __construct(
2324
public function resolveTags(array $entrypoints): array
2425
{
2526
return arr($entrypoints)
26-
->map(fn (string $file) => $this->createDevelopmentTag($this->fileToAssetPath($file)))
27+
->map(function (string $entrypoint) {
28+
if (! file_exists(root_path($entrypoint))) {
29+
throw new FileSystemEntrypointNotFoundException($entrypoint);
30+
}
31+
32+
return $this->createDevelopmentTag($this->fileToAssetPath($entrypoint));
33+
})
2734
->prepend($this->createDevelopmentTag(self::CLIENT_SCRIPT_PATH))
2835
->toArray();
2936
}
@@ -49,6 +56,7 @@ private function fileToAssetPath(string $file): string
4956
condition: fn ($file) => $file->startsWith('./'),
5057
callback: fn ($file) => str(realpath(root_path($file->toString()))),
5158
)
59+
->replace('\\', '/') // `realpath` makes slashes backwards, so replacements below wouldn't work
5260
->replaceStart(root_path('public'), '')
5361
->replaceStart(root_path(), '')
5462
->toString();

0 commit comments

Comments
 (0)